mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-28 05:57:19 +00:00
Compare commits
23 Commits
v3.6.0
...
8a8da88548
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a8da88548 | |||
| 1f846c3cb2 | |||
| 1c255fc00c | |||
| 75032fd498 | |||
| ece1655939 | |||
| cb902314db | |||
| 7eacce6a46 | |||
| 199ddaf485 | |||
| d142307366 | |||
| 3883882726 | |||
| 216d18b3c4 | |||
| 2a8c3bc0db | |||
| e71b75e99e | |||
| 5bc81dfd1d | |||
| f4b7b08e08 | |||
| 1ff90c5b66 | |||
| 138e1bd840 | |||
| 31c1eed5dc | |||
| 264f61eb90 | |||
| ac584cfc90 | |||
| 91c5d7b19f | |||
| b2fe233108 | |||
| 5373786faa |
Vendored
+12
-3
@@ -29,10 +29,19 @@
|
||||
"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}"
|
||||
"XUI_DB_DSN": "postgres://xui:xuipass@127.0.0.1:5432/xui?sslmode=disable"
|
||||
},
|
||||
"windows": {
|
||||
"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"
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+9
-1
@@ -8,9 +8,17 @@
|
||||
"args": [
|
||||
"build",
|
||||
"-o",
|
||||
"bin/3x-ui.exe",
|
||||
"bin/3x-ui",
|
||||
"./main.go"
|
||||
],
|
||||
"windows": {
|
||||
"args": [
|
||||
"build",
|
||||
"-o",
|
||||
"bin/3x-ui.exe",
|
||||
"./main.go"
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
|
||||
@@ -12,8 +12,10 @@ file locations when it can answer in one hop.
|
||||
Runs Xray-core as a managed child process (`internal/xray/process.go`) and
|
||||
imports `github.com/xtls/xray-core` for config types + gRPC stats/handler/router
|
||||
API. MTProto inbounds run a second managed child — the `mtg-multi` binary
|
||||
(`github.com/mhsanaei/mtg-multi`, a multi-secret fork built from source;
|
||||
`internal/mtproto/`) — outside Xray, one process per inbound serving each
|
||||
(a multi-secret mtg fork — NOT a Go dependency; its prebuilt release binary is
|
||||
fetched at image/release build time by `DockerInit.sh` + `release.yml`,
|
||||
panel-side code in `internal/mtproto/`) — outside Xray, one process per inbound
|
||||
serving each
|
||||
client's FakeTLS secret via the fork's `[secrets]` section (plus per-client
|
||||
ad-tags via `[secret-ad-tags]` and per-client data quota / expiry via
|
||||
`[secret-limits]`, mapped from the client's `totalGB`/`expiryTime`). Client,
|
||||
@@ -32,9 +34,9 @@ file locations when it can answer in one hop.
|
||||
- `main.go` — entry point + `x-ui` CLI (run, migrate, migrate-db, setting, cert).
|
||||
- `internal/config/` — env parsing (XUI_DEBUG, XUI_LOG_LEVEL, XUI_LOG_FOLDER,
|
||||
XUI_BIN_FOLDER, XUI_SKIP_HSTS, XUI_PORT, XUI_DB_*).
|
||||
- `internal/database/` + `internal/database/model/` — GORM schema (Inbound,
|
||||
Client, Setting, User), inbound Protocol enum, AutoMigrate + hand-written
|
||||
migrations in `db.go`.
|
||||
- `internal/database/` + `internal/database/model/` — GORM schema (~24 models;
|
||||
Inbound, Client, Setting, User are the core), inbound Protocol enum,
|
||||
AutoMigrate + hand-written migrations in `db.go`.
|
||||
- `internal/xray/` — Xray child-process lifecycle, config generation, gRPC API.
|
||||
- `internal/mtproto/` — MTProto inbounds via the bundled `mtg-multi` binary.
|
||||
- `internal/sub/` — subscription server (raw / JSON / Clash).
|
||||
@@ -46,7 +48,8 @@ file locations when it can answer in one hop.
|
||||
- `controller/` — panel + REST API handlers; OpenAPI at /panel/api/openapi.json.
|
||||
- `service/` — business logic (InboundService, SettingService, XrayService,
|
||||
node sync); subpackages tgbot/, email/, outbound/, panel/, integration/.
|
||||
- `job/` — cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP).
|
||||
- `job/` — 17 cron jobs (traffic, fail2ban IP-limit, node heartbeat/sync, LDAP,
|
||||
CPU/memory watchdogs, …); full table in `docs/architecture.md` §5.4.
|
||||
- `middleware/`, `entity/`, `global/`, `session/` (CSRF), `network/`,
|
||||
`runtime/` (master/sub-node over mTLS), `websocket/`.
|
||||
- `locale/` + `translation/` — i18n, 13 embedded locale JSON files.
|
||||
@@ -54,27 +57,51 @@ file locations when it can answer in one hop.
|
||||
- `tools/openapigen/` — Go generator that emits frontend types + Zod/JSON schemas
|
||||
into `frontend/src/generated/` from Go structs. The OpenAPI doc itself
|
||||
(`frontend/public/openapi.json`) is assembled from those + `endpoints.ts` by
|
||||
`frontend/scripts/build-openapi.mjs`.
|
||||
`frontend/scripts/build-openapi.mjs`. (`tools/seedperf/` is a separate seeding
|
||||
/load helper.)
|
||||
- `docs/` — separate Next.js/Fumadocs site (pnpm, own CI in `docs-ci.yml`,
|
||||
outside `make verify`). Holds a THIRD independent implementation of
|
||||
link/subscription generation in `docs/lib/xray/` — check it whenever
|
||||
share-link or install-command output changes.
|
||||
|
||||
## Hard rules (non-negotiable)
|
||||
- NO `//` line comments in committed Go/TS. Names carry meaning; rename instead
|
||||
of annotating. Exempt: `//go:build`, `//go:generate`, and other directives.
|
||||
- Fix size must match bug size. Find the root cause, then make the SMALLEST
|
||||
change that removes it — a one-line guard beats a new subsystem. A small bug
|
||||
does not earn new columns, jobs, abstractions, config knobs or helper layers.
|
||||
If a fix genuinely needs new architecture, say so and get agreement first;
|
||||
never ship it unasked next to the fix.
|
||||
- Comments in committed Go/TS: 2 lines MAX per comment block. Make the name
|
||||
carry the meaning first and rename rather than annotate; spend the 2 lines on
|
||||
the *why* a name cannot hold — an invariant, an issue number, a non-obvious
|
||||
constraint. Exempt: `//go:build`, `//go:generate`, and other directives.
|
||||
HTML `<!-- -->` is fine. (A linter cannot enforce this — you must.)
|
||||
- New `g.POST`/`g.GET` in `internal/web/controller/` REQUIRES a matching entry
|
||||
in `frontend/src/pages/api-docs/endpoints.ts`, then `make gen` (or
|
||||
`cd frontend && npm run gen`). It is a hand-maintained registry — nothing checks
|
||||
it against the Go routes, so an omitted route silently vanishes from the docs.
|
||||
`cd frontend && npm run gen`). Hand-maintained but pinned both ways by
|
||||
`TestRouteRegistryContract` (`internal/web/routes_contract_test.go`): a missing
|
||||
OR stale entry fails `make test-go`. Scope: `/panel/api/*` + a few session
|
||||
routes; sub-server routes are exempt.
|
||||
- Response examples come from Go struct `example:` tags via `tools/openapigen` —
|
||||
never hand-write them. A new struct must be added to openapigen's `StructAllow`
|
||||
allowlist (`tools/openapigen/main.go`) or it is silently omitted from
|
||||
schemas/examples (and `build-openapi.mjs` then fails on the missing schema).
|
||||
- A new English i18n key must be added to EVERY locale JSON in
|
||||
`internal/web/translation/` (13 files). Missing keys fall back to en-US (or
|
||||
render the raw key if absent there too); nothing fails the build, so they are
|
||||
easy to miss.
|
||||
- A new or renamed endpoint has a FOURTH step nothing checks: copy
|
||||
`frontend/public/openapi.json` → `docs/public/openapi.json`, then
|
||||
`cd docs && pnpm gen:api` to refresh the MDX under
|
||||
`docs/content/docs/en/reference/api/`. `docs-ci.yml` fires only on `docs/**`.
|
||||
- A new English i18n key goes in EVERY locale JSON in `internal/web/translation/`
|
||||
(13 files) AND must be referenced from `frontend/src` or Go in the SAME commit —
|
||||
`frontend/src/test/i18n-dead-keys.test.ts` fails both ways. It is a frontend
|
||||
test, so run `npm test`, not just `make test-go`. At runtime the frontend falls
|
||||
back to en-US; Go (`internal/web/locale/`) returns "" for an unknown key.
|
||||
- DB / model changes require a migration in `internal/database/db.go`.
|
||||
- Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `docs`,
|
||||
`style`): `<area>: short imperative summary`, then a body explaining the why.
|
||||
- Every state-changing inbound/client op dispatches through `runtime.Runtime`
|
||||
(`internal/web/runtime/`) — never straight to `internal/xray/api.go`, never from
|
||||
a controller or cron job. A direct call passes every local test and silently
|
||||
breaks every multi-node deployment. Other layering rules: `docs/architecture.md` §8.
|
||||
- Conventional commits: `type(area): short imperative summary`, then a body
|
||||
explaining the why. Types in use: `fix`, `feat`, `chore`, `refactor`, `perf`,
|
||||
`docs`, `style`.
|
||||
|
||||
## Go conventions
|
||||
- Stdlib `testing` only (no testify). Table-driven, `t.Run` subtests,
|
||||
@@ -83,13 +110,26 @@ file locations when it can answer in one hop.
|
||||
`database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` +
|
||||
`t.Cleanup(func() { _ = database.CloseDB() })`; `httptest` for HTTP.
|
||||
`internal/sub`'s `initSubDB(t)` is the template.
|
||||
- A test must fail without its fix. Write it, revert the fix, watch it go red,
|
||||
restore. A test that passes either way is worse than no test: it certifies
|
||||
nothing and then gets cited as proof the fix works.
|
||||
- Test what can actually break. No test for a getter, a constant, a rename, a
|
||||
pure map lookup, or inputs the function can never receive. One real test that
|
||||
drives the bug through the actual code path beats five that restate the code.
|
||||
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
|
||||
- Postgres, xray-gRPC-e2e and scale tests `t.Skip` unless `XUI_TEST_PG_DSN`,
|
||||
`XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY` or `XUI_SCALE_TEST` is set — a
|
||||
green `go test ./...` does not mean those paths ran.
|
||||
|
||||
## Frontend conventions (summary; full version in frontend/CLAUDE.md)
|
||||
- Ant Design 6 only — no Tailwind/shadcn. Targeted tweaks, not rewrites.
|
||||
- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
|
||||
`src/schemas/` are the source of truth; infer types with `z.infer`, never
|
||||
hand-write. Do not edit `src/generated/`.
|
||||
- Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
|
||||
stripping; Node 22 dies with `ERR_UNKNOWN_FILE_EXTENSION`. `npm test` includes
|
||||
a headless-Chromium Storybook project, so run
|
||||
`npx playwright install --with-deps chromium` once or `make verify` fails.
|
||||
- Editing `frontend/src` does NOT change what users see until the Vite build is
|
||||
regenerated into `internal/web/dist/`. In `XUI_DEBUG=true`, HTML is served from
|
||||
the frozen embedded FS but JS/CSS off disk — after `npm run build` you MUST
|
||||
@@ -99,15 +139,23 @@ file locations when it can answer in one hop.
|
||||
output changes, never to make a red test green.
|
||||
|
||||
## Build, test, verify
|
||||
Run `make help` for all targets. The full local gate that mirrors CI:
|
||||
A fresh clone has no `internal/web/dist/`, so a bare `go build ./...` dies with
|
||||
`pattern all:dist: no matching files found` while ~35 other packages pass — it
|
||||
reads as a broken repo, not a missing step. Run `make dist-stub` once; every
|
||||
`make` Go target already depends on it, which is why `make test-go` beats
|
||||
`go test ./...`. Run `make help` for all targets. The local gate:
|
||||
|
||||
make verify
|
||||
make verify # gen-check + lint + typecheck + test + build + build-storybook
|
||||
|
||||
That is the *fast* gate, not all of CI. `ci.yml` also runs `make race`,
|
||||
`make vulncheck`, a live-Postgres job (where a SKIP counts as a failure) and a
|
||||
30s fuzz smoke on `FuzzParseLink`/`FuzzDecodeCertPin` — run those locally when
|
||||
you touch DB/dialect or parser code.
|
||||
|
||||
Common targets: `make gen` (regenerate Zod/OpenAPI), `make lint` (Go + frontend),
|
||||
`make test` (Go `-shuffle=on` + frontend), `make race`, `make build`. See `Makefile`.
|
||||
|
||||
## Definition of done (before opening a PR)
|
||||
1. `make gen` and confirm `git diff` on `frontend/src/generated` +
|
||||
`frontend/public/openapi.json` is clean.
|
||||
2. `make verify` passes.
|
||||
3. Diff is focused; refactors are separate from feature work.
|
||||
1. `make verify` passes — its `gen-check` already runs `make gen` and fails on a
|
||||
dirty `frontend/src/generated` / `frontend/public/openapi.json`.
|
||||
2. Diff is focused; refactors are separate from feature work.
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import '../global.css';
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||
import { Inter, Vazirmatn } from 'next/font/google';
|
||||
import { i18n, localeDirection } from '@/lib/i18n';
|
||||
import { i18n } from '@/lib/i18n';
|
||||
import { provider } from '@/lib/i18n-ui';
|
||||
import SearchDialog from '@/components/search-dialog';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'], display: 'swap' });
|
||||
// Persian UI font; covers Arabic + Latin glyphs so mixed content renders well.
|
||||
const vazirmatn = Vazirmatn({ subsets: ['arabic'], display: 'swap' });
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.map((lang) => ({ lang }));
|
||||
}
|
||||
|
||||
export default async function LangLayout({ params, children }: LayoutProps<'/[lang]'>) {
|
||||
const { lang } = await params;
|
||||
const dir = localeDirection(lang);
|
||||
const fontClassName = lang === 'fa' ? vazirmatn.className : inter.className;
|
||||
|
||||
return (
|
||||
<html lang={lang} dir={dir} className={fontClassName} suppressHydrationWarning>
|
||||
<body className="flex min-h-screen flex-col" suppressHydrationWarning>
|
||||
<RootProvider i18n={provider(lang)} search={{ SearchDialog }}>
|
||||
{children}
|
||||
</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
<RootProvider i18n={provider(lang)} search={{ SearchDialog }} theme={{ enabled: false }}>
|
||||
{children}
|
||||
</RootProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+30
-5
@@ -1,10 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Inter, Vazirmatn } from 'next/font/google';
|
||||
import './global.css';
|
||||
import { appName, appTagline, siteUrl } from '@/lib/shared';
|
||||
import { i18n, localeDirection } from '@/lib/i18n';
|
||||
|
||||
// Global SEO defaults. The real <html>/<body> live in `app/[lang]/layout.tsx`
|
||||
// so we can set `lang`/`dir` per locale (RTL for fa); this root layout is a
|
||||
// pass-through that only carries site-wide metadata.
|
||||
const inter = Inter({ subsets: ['latin'], display: 'swap' });
|
||||
// Persian UI font; covers Arabic + Latin glyphs so mixed content renders well.
|
||||
const vazirmatn = Vazirmatn({ subsets: ['arabic'], display: 'swap' });
|
||||
|
||||
// Global SEO defaults and document shell. Locale-aware html attributes are
|
||||
// computed from route params so RTL locales get a correct base direction.
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
@@ -26,6 +32,25 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
params: Promise<{ lang?: string }>;
|
||||
}) {
|
||||
const { lang: rawLang } = await params;
|
||||
const lang = i18n.languages.includes(rawLang as (typeof i18n.languages)[number])
|
||||
? (rawLang as (typeof i18n.languages)[number])
|
||||
: i18n.defaultLanguage;
|
||||
const dir = localeDirection(lang);
|
||||
const fontClassName = lang === 'fa' ? vazirmatn.className : inter.className;
|
||||
|
||||
return (
|
||||
<html lang={lang} dir={dir} className={fontClassName} suppressHydrationWarning>
|
||||
<body className="flex min-h-screen flex-col" suppressHydrationWarning>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -542,7 +542,7 @@ golangci-lint run # full lint (gofumpt + goimports formatting)
|
||||
go run main.go # run the panel locally (serves embedded dist if built)
|
||||
```
|
||||
|
||||
**Frontend (`cd frontend`, Node ≥ 22):**
|
||||
**Frontend (`cd frontend`, Node 24 — see `.nvmrc`):**
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Vite dev server on :5173; proxies API to Go backend on :2053 (run `go run main.go` too)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { create } from '@orama/orama';
|
||||
import { create } from 'zbsearch';
|
||||
import { useDocsSearch } from 'fumadocs-core/search/client';
|
||||
import { oramaStaticClient } from 'fumadocs-core/search/client/orama-static';
|
||||
import {
|
||||
@@ -25,8 +25,8 @@ interface SharedProps {
|
||||
// default static dialog feeds those codes to Orama as a tokenizer language, but
|
||||
// Orama only accepts full names ("english") and throws on "en" — which silently
|
||||
// breaks search entirely. All docs content is English (other locales fall back
|
||||
// to it), so re-create the dialog — the documented escape hatch for custom Orama
|
||||
// setups — with an initOrama that always builds an English index.
|
||||
// to it), so re-create the dialog — the documented escape hatch for custom search
|
||||
// setups — with an initDB that always builds an English index.
|
||||
export default function SearchDialogClient(props: SharedProps) {
|
||||
const { locale } = useI18n();
|
||||
const client = useMemo(
|
||||
@@ -34,7 +34,7 @@ export default function SearchDialogClient(props: SharedProps) {
|
||||
oramaStaticClient({
|
||||
from: '/api/search',
|
||||
locale,
|
||||
initOrama: () => create({ schema: { _: 'string' }, language: 'english' }),
|
||||
initDB: () => create({ schema: { _: 'string' }, language: 'english' }),
|
||||
}),
|
||||
[locale],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
type ThemeMode = 'light-dark' | 'light-dark-system';
|
||||
type ThemePref = 'light' | 'dark' | 'system';
|
||||
|
||||
const STORAGE_KEY = 'docs-theme';
|
||||
|
||||
// `useSyncExternalStore` supplies the same value for SSR and hydration, then
|
||||
// switches to the browser value after React has attached to the markup.
|
||||
const subscribeToHydration = () => () => {};
|
||||
const getHydrationClientSnapshot = () => true;
|
||||
const getHydrationServerSnapshot = () => false;
|
||||
|
||||
function getStoredTheme(): ThemePref {
|
||||
if (typeof window === 'undefined') return 'system';
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
return raw === 'light' || raw === 'dark' || raw === 'system' ? raw : 'system';
|
||||
}
|
||||
|
||||
function getResolvedTheme(theme: ThemePref): 'light' | 'dark' {
|
||||
if (theme !== 'system') return theme;
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemePref): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const resolved = getResolvedTheme(theme);
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', resolved === 'dark');
|
||||
root.style.colorScheme = resolved;
|
||||
}
|
||||
|
||||
export function DocsThemeSwitch({
|
||||
className,
|
||||
mode = 'light-dark-system',
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
mode?: ThemeMode;
|
||||
} & Omit<ComponentProps<'div'>, 'children'>) {
|
||||
// Keep the server and first client render identical. Reading localStorage or
|
||||
// matchMedia here would make a persisted/system preference change the client
|
||||
// markup before React has finished hydrating it.
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemePref>('system');
|
||||
const hydrated = useSyncExternalStore(
|
||||
subscribeToHydration,
|
||||
getHydrationClientSnapshot,
|
||||
getHydrationServerSnapshot,
|
||||
);
|
||||
const theme = hydrated ? getStoredTheme() : selectedTheme;
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated) applyTheme(theme);
|
||||
}, [hydrated, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
if (theme !== 'system') return;
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const update = () => applyTheme('system');
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, [hydrated, theme]);
|
||||
|
||||
const resolved = hydrated ? getResolvedTheme(theme) : 'light';
|
||||
|
||||
const setTheme = (nextTheme: ThemePref) => {
|
||||
window.localStorage.setItem(STORAGE_KEY, nextTheme);
|
||||
applyTheme(nextTheme);
|
||||
setSelectedTheme(nextTheme);
|
||||
};
|
||||
|
||||
const nextTheme = () => {
|
||||
if (mode === 'light-dark') return resolved === 'dark' ? 'light' : 'dark';
|
||||
if (theme === 'light') return 'dark';
|
||||
if (theme === 'dark') return 'system';
|
||||
return resolved === 'dark' ? 'light' : 'dark';
|
||||
};
|
||||
|
||||
const label =
|
||||
mode === 'light-dark-system'
|
||||
? `Switch theme (current: ${theme})`
|
||||
: `Switch to ${resolved === 'dark' ? 'light' : 'dark'} mode`;
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex', className)} {...props}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => setTheme(nextTheme())}
|
||||
className="inline-flex size-8 items-center justify-center rounded-lg text-fd-muted-foreground transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground"
|
||||
>
|
||||
{resolved === 'dark' ? <Moon className="size-4" /> : <Sun className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { TelegramIcon } from '@/components/icons';
|
||||
import { DocsThemeSwitch } from '@/components/theme-switch';
|
||||
import { appName, productRepoUrl, telegramChannel, telegramChannelUrl, donateUrl, siteUrl } from './shared';
|
||||
import { getSiteMessages } from './site-i18n';
|
||||
|
||||
@@ -12,6 +13,9 @@ export function baseOptions(lang: string): BaseLayoutProps {
|
||||
const m = getSiteMessages(lang);
|
||||
|
||||
return {
|
||||
slots: {
|
||||
themeSwitch: DocsThemeSwitch,
|
||||
},
|
||||
nav: {
|
||||
title: (
|
||||
<span className="inline-flex items-center gap-2 font-semibold">
|
||||
|
||||
+16
-14
@@ -19,34 +19,36 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@orama/orama": "^3.1.18",
|
||||
"fumadocs-core": "^16.11.5",
|
||||
"fumadocs-core": "^16.14.1",
|
||||
"fumadocs-docgen": "^3.1.0",
|
||||
"fumadocs-mdx": "^15.2.0",
|
||||
"fumadocs-openapi": "^11.2.2",
|
||||
"fumadocs-ui": "^16.11.5",
|
||||
"lucide-react": "^1.25.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"next": "16.2.11",
|
||||
"fumadocs-mdx": "^15.2.2",
|
||||
"fumadocs-openapi": "^11.2.3",
|
||||
"fumadocs-ui": "^16.14.1",
|
||||
"lucide-react": "^1.29.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"next": "16.3.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zbsearch": "3.3.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/mdx": "^2.0.14",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "16.2.11",
|
||||
"postcss": "^8.5.21",
|
||||
"eslint-config-next": "16.3.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"postcss": "^8.5.26",
|
||||
"prettier": "^3.9.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"packageManager": "pnpm@11.15.1+sha512.81350b07e53c9538a02f1f2303b4290fa2d7be04e56e2a970c4cc4b417dc761de196edabd49d55c7dc9580db81007c44143e4e3d7e462b3000d23c255122d065"
|
||||
"packageManager": "pnpm@11.20.0"
|
||||
}
|
||||
|
||||
Generated
+691
-548
File diff suppressed because it is too large
Load Diff
@@ -10,3 +10,7 @@ overrides:
|
||||
minimumReleaseAgeExclude:
|
||||
- '@mermaid-js/parser@1.2.0'
|
||||
- mermaid@11.16.0
|
||||
- fumadocs-core@16.14.1
|
||||
- fumadocs-ui@16.14.1
|
||||
- lucide-react@1.29.0
|
||||
- postcss@8.5.26
|
||||
|
||||
Generated
+912
-879
File diff suppressed because it is too large
Load Diff
+22
-21
@@ -30,52 +30,52 @@
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@hookform/resolvers": "^5.5.7",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.7.1",
|
||||
"@noble/hashes": "^2.3.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-query-devtools": "^5.101.4",
|
||||
"antd": "^6.5.2",
|
||||
"antd": "^6.6.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.6",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"persian-calendar-suite": "^1.5.6",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.83.0",
|
||||
"react-hook-form": "^7.85.0",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-router": "^8.3.0",
|
||||
"swagger-ui-react": "^5.32.11",
|
||||
"swagger-ui-react": "^5.32.13",
|
||||
"uplot": "^1.6.32",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@storybook/addon-a11y": "^10.5.5",
|
||||
"@storybook/addon-docs": "^10.5.5",
|
||||
"@storybook/addon-vitest": "^10.5.5",
|
||||
"@storybook/react-vite": "^10.5.5",
|
||||
"@storybook/addon-a11y": "^10.5.7",
|
||||
"@storybook/addon-docs": "^10.5.7",
|
||||
"@storybook/addon-vitest": "^10.5.7",
|
||||
"@storybook/react-vite": "^10.5.7",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.8.0",
|
||||
"globals": "^17.11.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^30.0.1",
|
||||
"lint-staged": "^17.2.0",
|
||||
"lint-staged": "^17.3.0",
|
||||
"msw": "^2.15.0",
|
||||
"playwright": "^1.62.0",
|
||||
"storybook": "^10.5.5",
|
||||
"playwright": "^1.62.1",
|
||||
"storybook": "^10.5.7",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "8.1.5",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vite": "8.2.1",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
@@ -89,7 +89,8 @@
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"swagger-ui-react": {
|
||||
"js-yaml": "^4.2.0"
|
||||
"js-yaml": "^4.2.0",
|
||||
"brace-expansion": "^5.0.9"
|
||||
},
|
||||
"@typeschema/valibot": {
|
||||
"valibot": "^1.1.0"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
||||
import { AllSettingResponseSchema, AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { useServerDraft } from '@/hooks/useServerDraft';
|
||||
|
||||
@@ -17,7 +17,7 @@ type SettingSaveResult = {
|
||||
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
||||
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
|
||||
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
|
||||
const validated = parseMsg(msg, AllSettingResponseSchema, 'setting/all');
|
||||
return validated.obj;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.ant-sidebar > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
|
||||
.ant-sidebar:not(.sidebar-pinned) > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
|
||||
box-shadow: 0 0 32px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
@@ -53,10 +53,18 @@
|
||||
.brand-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-actions .sidebar-pin,
|
||||
.brand-actions .sidebar-docs,
|
||||
.brand-actions .sidebar-donate,
|
||||
.brand-actions .sidebar-theme-cycle {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.sidebar-donate {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -230,6 +238,34 @@
|
||||
padding: 8px 8px 12px;
|
||||
}
|
||||
|
||||
.sidebar-pin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--ant-color-text-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-pin:hover,
|
||||
.sidebar-pin:focus-visible {
|
||||
background-color: color-mix(in srgb, var(--ant-color-primary) 10%, transparent);
|
||||
color: var(--ant-color-primary);
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-pin .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sider-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -245,6 +281,11 @@
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.ant-layout-sider-collapsed .sider-version {
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.sider-version .anticon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
MessageOutlined,
|
||||
MoonFilled,
|
||||
MoonOutlined,
|
||||
PushpinFilled,
|
||||
PushpinOutlined,
|
||||
ReadOutlined,
|
||||
SafetyOutlined,
|
||||
SettingOutlined,
|
||||
@@ -44,7 +46,8 @@ const DOCS_URL = 'https://docs.sanaei.dev/';
|
||||
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
||||
const LOGOUT_KEY = '__logout__';
|
||||
const RAIL_WIDTH = 72;
|
||||
const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties;
|
||||
const SIDER_WIDTH = 220;
|
||||
const SIDEBAR_PINNED_KEY = 'sidebar-pinned';
|
||||
|
||||
let hoveredAcrossRemounts = false;
|
||||
|
||||
@@ -135,6 +138,20 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
|
||||
);
|
||||
}
|
||||
|
||||
function readSidebarPinned() {
|
||||
try {
|
||||
return localStorage.getItem(SIDEBAR_PINNED_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSidebarPinned(pinned: boolean) {
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_PINNED_KEY, String(pinned));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export default function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||
@@ -144,8 +161,13 @@ export default function AppSidebar() {
|
||||
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
||||
|
||||
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
||||
const [pinned, setPinned] = useState(readSidebarPinned);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const railCollapsed = !hovered;
|
||||
const railCollapsed = !hovered && !pinned;
|
||||
const railStyle = useMemo(
|
||||
() => ({ '--sider-rail': `${pinned ? SIDER_WIDTH : RAIL_WIDTH}px` }) as CSSProperties,
|
||||
[pinned],
|
||||
);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateHovered = useCallback((value: boolean) => {
|
||||
@@ -153,6 +175,12 @@ export default function AppSidebar() {
|
||||
setHovered(value);
|
||||
}, []);
|
||||
|
||||
const togglePinned = useCallback(() => {
|
||||
const next = !pinned;
|
||||
saveSidebarPinned(next);
|
||||
setPinned(next);
|
||||
}, [pinned]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const el = rootRef.current;
|
||||
@@ -261,14 +289,14 @@ export default function AppSidebar() {
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="ant-sidebar"
|
||||
className={`ant-sidebar${pinned ? ' sidebar-pinned' : ''}`}
|
||||
style={railStyle}
|
||||
onMouseEnter={() => updateHovered(true)}
|
||||
onMouseLeave={() => updateHovered(false)}
|
||||
>
|
||||
<Layout.Sider
|
||||
theme={currentTheme}
|
||||
width={220}
|
||||
width={SIDER_WIDTH}
|
||||
collapsedWidth={RAIL_WIDTH}
|
||||
collapsed={railCollapsed}
|
||||
>
|
||||
@@ -278,6 +306,16 @@ export default function AppSidebar() {
|
||||
</div>
|
||||
{!railCollapsed && (
|
||||
<div className="brand-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-pin"
|
||||
aria-label={t('menu.pinSidebar')}
|
||||
aria-pressed={pinned}
|
||||
title={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')}
|
||||
onClick={togglePinned}
|
||||
>
|
||||
{pinned ? <PushpinFilled /> : <PushpinOutlined />}
|
||||
</button>
|
||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||
<ThemeCycleButton
|
||||
|
||||
@@ -104,6 +104,15 @@ export const AllSettingSchema = z.object({
|
||||
|
||||
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
|
||||
|
||||
// Existing installations can contain regex values saved before the backend
|
||||
// enforced its 2,048-character limit. Accept those values when reading so the
|
||||
// settings page can display and let users correct them, while keeping the
|
||||
// stricter schema above for outgoing updates.
|
||||
export const AllSettingResponseSchema = AllSettingSchema.extend({
|
||||
subJsonUserAgentRegex: z.string().optional(),
|
||||
subClashUserAgentRegex: z.string().optional(),
|
||||
});
|
||||
|
||||
export const FactoryDefaultsSchema = z.record(z.string(), z.string());
|
||||
|
||||
export type FactoryDefaults = z.infer<typeof FactoryDefaultsSchema>;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
vi.mock('@/api/queries/useAllSettings', () => ({
|
||||
useAllSettings: () => ({ allSetting: {} }),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function renderSidebar() {
|
||||
return renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<AppSidebar />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
test('keeps the sidebar expanded after pinning it from the header and restores the choice', () => {
|
||||
const first = renderSidebar();
|
||||
const sidebar = first.container.querySelector('.ant-layout-sider');
|
||||
const sidebarRoot = first.container.querySelector('.ant-sidebar');
|
||||
|
||||
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(true);
|
||||
|
||||
fireEvent.mouseEnter(sidebarRoot!);
|
||||
|
||||
const pinButton = screen.getByRole('button', { name: 'Pin sidebar' });
|
||||
expect(pinButton.closest('.brand-actions')).not.toBeNull();
|
||||
|
||||
fireEvent.click(pinButton);
|
||||
fireEvent.mouseLeave(sidebarRoot!);
|
||||
|
||||
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
|
||||
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 220px');
|
||||
expect(localStorage.getItem('sidebar-pinned')).toBe('true');
|
||||
|
||||
first.unmount();
|
||||
|
||||
const second = renderSidebar();
|
||||
const restoredSidebar = second.container.querySelector('.ant-layout-sider');
|
||||
const restoredSidebarRoot = second.container.querySelector('.ant-sidebar');
|
||||
|
||||
expect(restoredSidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
|
||||
expect(restoredSidebarRoot?.getAttribute('style')).toContain('--sider-rail: 220px');
|
||||
expect(screen.getByRole('button', { name: 'Pin sidebar' })).not.toBeNull();
|
||||
});
|
||||
|
||||
test('returns to the compact rail after unpinning', () => {
|
||||
const view = renderSidebar();
|
||||
const sidebar = view.container.querySelector('.ant-layout-sider');
|
||||
const sidebarRoot = view.container.querySelector('.ant-sidebar');
|
||||
|
||||
fireEvent.mouseEnter(sidebarRoot!);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' }));
|
||||
fireEvent.mouseLeave(sidebarRoot!);
|
||||
|
||||
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(true);
|
||||
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px');
|
||||
expect(localStorage.getItem('sidebar-pinned')).toBe('false');
|
||||
});
|
||||
@@ -99,7 +99,11 @@ describe('useClients query gating', () => {
|
||||
});
|
||||
|
||||
it('reports settingsReady even when the settings request fails, so the page can still render', async () => {
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', emptyPage));
|
||||
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => new Msg(
|
||||
true,
|
||||
'',
|
||||
url.includes('/inbounds/options') ? [] : emptyPage,
|
||||
));
|
||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(false, 'boom', null));
|
||||
const { result } = renderHook(() => useClients(), { wrapper: wrapperFor() });
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ const envelope = (data: unknown): HttpResponse => ({ ok: true, status: 200, stat
|
||||
describe('HttpUtil', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
it('unwraps a success envelope and shows a success toast', async () => {
|
||||
@@ -80,6 +81,7 @@ describe('HttpUtil', () => {
|
||||
|
||||
expect(msg.success).toBe(false);
|
||||
expect(msg.msg).toBe('bad input');
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps a thrown native error to a failure Msg via its message', async () => {
|
||||
@@ -88,6 +90,7 @@ describe('HttpUtil', () => {
|
||||
const msg = await HttpUtil.get('/x', undefined, { silent: true });
|
||||
|
||||
expect(msg.msg).toBe('Network down');
|
||||
expect(console.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns "No response data" for an empty body', async () => {
|
||||
|
||||
@@ -48,6 +48,16 @@ if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
// jsdom does not implement pseudo-element styles or Range geometry. Ant
|
||||
// Design and CodeMirror use these APIs for layout, so supply harmless test
|
||||
// fallbacks instead of emitting noisy "Not implemented" errors.
|
||||
const nativeGetComputedStyle = window.getComputedStyle.bind(window);
|
||||
window.getComputedStyle = ((element: Element) => nativeGetComputedStyle(element)) as typeof window.getComputedStyle;
|
||||
|
||||
if (!Range.prototype.getClientRects) {
|
||||
Range.prototype.getClientRects = () => [] as unknown as DOMRectList;
|
||||
}
|
||||
|
||||
if (!i18next.isInitialized) {
|
||||
void i18next.use(initReactI18next).init({
|
||||
lng: 'en-US',
|
||||
@@ -75,9 +85,12 @@ afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue({ success: true, obj: {} } as any);
|
||||
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => new Msg(
|
||||
true,
|
||||
'',
|
||||
url.includes('/panel/api/inbounds/options') ? [] : {},
|
||||
));
|
||||
|
||||
@@ -13,9 +13,10 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('useAllSettings', () => {
|
||||
it('keeps backend-accepted settings editable when the frontend schema is stricter', async () => {
|
||||
it('accepts legacy overlength regex settings without logging a response validation warning', async () => {
|
||||
const subJsonUserAgentRegex = 'x'.repeat(2_049);
|
||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', { subJsonUserAgentRegex }));
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
@@ -25,6 +26,7 @@ describe('useAllSettings', () => {
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
expect(result.current.allSetting.subJsonUserAgentRegex).toBe(subJsonUserAgentRegex);
|
||||
expect(warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an edited setting when a refetch returns older server data', async () => {
|
||||
|
||||
@@ -13,10 +13,15 @@ afterEach(() => {
|
||||
describe('parseMsg', () => {
|
||||
it('rejects a successful response whose payload violates its schema', () => {
|
||||
const msg = new Msg(true, '', { id: 'not-a-number' });
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
expect(() => parseMsg(msg, z.object({ id: z.number() }), 'test/value', { strict: true })).toThrow(
|
||||
'test/value response failed validation',
|
||||
);
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
'[zod] test/value response failed validation',
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['id'] })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a missing successful payload for callers that handle empty values', () => {
|
||||
@@ -25,17 +30,27 @@ describe('parseMsg', () => {
|
||||
|
||||
it('rejects malformed paged-client payloads', () => {
|
||||
const payload = { items: [], total: 'one', filtered: 1, page: 1, pageSize: 20 };
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
expect(() => parseMsg(new Msg(true, '', payload), ClientPageResponseSchema, 'clients/list/paged', { strict: true })).toThrow(
|
||||
'clients/list/paged response failed validation',
|
||||
);
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
'[zod] clients/list/paged response failed validation',
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['total'] })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchXrayConfig', () => {
|
||||
it('keeps a malformed xray payload available for repair', async () => {
|
||||
vi.spyOn(HttpUtil, 'post').mockResolvedValue(new Msg(true, '', JSON.stringify({ xraySetting: 'not-an-object' })));
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
await expect(fetchXrayConfig()).resolves.toEqual({ xraySetting: 'not-an-object' });
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
'[zod] xray/ config payload failed validation',
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'invalid_type', path: ['xraySetting'] })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,11 +75,13 @@ export class HttpUtil {
|
||||
if (!silent) this._handleMsg(msg, silentSuccess);
|
||||
return msg;
|
||||
} catch (error) {
|
||||
console.error('GET request failed:', error);
|
||||
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
|
||||
const data = err.response?.data;
|
||||
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
|
||||
if (!silent) this._handleMsg(errorMsg);
|
||||
if (!silent) {
|
||||
console.error('GET request failed:', error);
|
||||
this._handleMsg(errorMsg);
|
||||
}
|
||||
return errorMsg;
|
||||
}
|
||||
}
|
||||
@@ -92,11 +94,13 @@ export class HttpUtil {
|
||||
if (!silent) this._handleMsg(msg, silentSuccess);
|
||||
return msg;
|
||||
} catch (error) {
|
||||
console.error('POST request failed:', error);
|
||||
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
|
||||
const data = err.response?.data;
|
||||
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
|
||||
if (!silent) this._handleMsg(errorMsg);
|
||||
if (!silent) {
|
||||
console.error('POST request failed:', error);
|
||||
this._handleMsg(errorMsg);
|
||||
}
|
||||
return errorMsg;
|
||||
}
|
||||
}
|
||||
|
||||
+61
-9
@@ -4,7 +4,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
const outDir = path.resolve(__dirname, '../internal/web/dist');
|
||||
const outDir = path.resolve(import.meta.dirname, '../internal/web/dist');
|
||||
const BACKEND_TARGET = 'http://localhost:2053';
|
||||
|
||||
function resolveDBPath() {
|
||||
@@ -12,12 +12,12 @@ function resolveDBPath() {
|
||||
if (envFolder) {
|
||||
const abs = path.isAbsolute(envFolder)
|
||||
? envFolder
|
||||
: path.resolve(__dirname, '..', envFolder);
|
||||
: path.resolve(import.meta.dirname, '..', envFolder);
|
||||
return path.join(abs, 'x-ui.db');
|
||||
}
|
||||
const repoSubDB = path.resolve(__dirname, '..', 'x-ui', 'x-ui.db');
|
||||
const repoSubDB = path.resolve(import.meta.dirname, '..', 'x-ui', 'x-ui.db');
|
||||
if (fs.existsSync(repoSubDB)) return repoSubDB;
|
||||
const repoDB = path.resolve(__dirname, '..', 'x-ui.db');
|
||||
const repoDB = path.resolve(import.meta.dirname, '..', 'x-ui.db');
|
||||
if (fs.existsSync(repoDB)) return repoDB;
|
||||
return '/etc/x-ui/x-ui.db';
|
||||
}
|
||||
@@ -54,7 +54,7 @@ function refreshBasePath() {
|
||||
|
||||
function readPanelVersion() {
|
||||
try {
|
||||
const versionFile = path.resolve(__dirname, '..', 'config', 'version');
|
||||
const versionFile = path.resolve(import.meta.dirname, '..', 'config', 'version');
|
||||
return fs.readFileSync(versionFile, 'utf8').trim();
|
||||
} catch (_e) {
|
||||
return '';
|
||||
@@ -151,11 +151,62 @@ function makeBackendProxy(target) {
|
||||
};
|
||||
}
|
||||
|
||||
// Deps only reachable through swagger-ui-react (verified via `npm ls`). The
|
||||
// catch-all `vendor` chunk would otherwise eager-load them on first paint,
|
||||
// although the api-docs page is the only lazy route importing them.
|
||||
const SWAGGER_ONLY_DEPS = [
|
||||
'@babel/runtime-corejs3',
|
||||
'@scarf/scarf',
|
||||
'@swagger-api/apidom-',
|
||||
'@swaggerexpert/',
|
||||
'base64-js',
|
||||
'buffer',
|
||||
'classnames',
|
||||
'css.escape',
|
||||
'deep-extend',
|
||||
'dompurify',
|
||||
'fast-json-patch',
|
||||
'highlight.js',
|
||||
'highlightjs-vue',
|
||||
'ieee754',
|
||||
'immutable',
|
||||
'js-file-download',
|
||||
'js-yaml',
|
||||
'lodash',
|
||||
'lowlight',
|
||||
'neotraverse',
|
||||
'node-abort-controller',
|
||||
'openapi-path-templating',
|
||||
'openapi-server-url-templating',
|
||||
'prismjs',
|
||||
'prop-types',
|
||||
'ramda',
|
||||
'ramda-adjunct',
|
||||
'randexp',
|
||||
'react-copy-to-clipboard',
|
||||
'react-debounce-input',
|
||||
'react-immutable-proptypes',
|
||||
'react-immutable-pure-component',
|
||||
'react-inspector',
|
||||
'react-redux',
|
||||
'react-syntax-highlighter',
|
||||
'redux',
|
||||
'redux-immutable',
|
||||
'remarkable',
|
||||
'reselect',
|
||||
'serialize-error',
|
||||
'sha.js',
|
||||
'url-parse',
|
||||
'xml',
|
||||
'xml-but-prettier',
|
||||
'zenscroll',
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), injectBasePathPlugin(), rocketLoaderOptOutPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@': path.resolve(import.meta.dirname, 'src'),
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
@@ -182,9 +233,9 @@ export default defineConfig({
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: path.resolve(__dirname, 'index.html'),
|
||||
login: path.resolve(__dirname, 'login.html'),
|
||||
subpage: path.resolve(__dirname, 'subpage.html'),
|
||||
index: path.resolve(import.meta.dirname, 'index.html'),
|
||||
login: path.resolve(import.meta.dirname, 'login.html'),
|
||||
subpage: path.resolve(import.meta.dirname, 'subpage.html'),
|
||||
},
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
@@ -222,6 +273,7 @@ export default defineConfig({
|
||||
id.includes('/node_modules/swagger-ui-react/')
|
||||
|| id.includes('/node_modules/swagger-ui/')
|
||||
|| id.includes('/node_modules/swagger-client/')
|
||||
|| SWAGGER_ONLY_DEPS.some((dep) => id.includes(`/node_modules/${dep}/`))
|
||||
) return 'vendor-swagger';
|
||||
if (id.includes('/node_modules/uplot/')) return 'vendor-uplot';
|
||||
if (id.includes('dayjs')) return 'vendor-dayjs';
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
||||
const dirname = import.meta.dirname;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -17,6 +16,8 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
globals: false,
|
||||
// Keep jsdom-heavy form tests within the memory budget of local and CI runners.
|
||||
maxWorkers: 2,
|
||||
projects: [
|
||||
{
|
||||
extends: true,
|
||||
|
||||
@@ -18,7 +18,7 @@ require (
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.6
|
||||
github.com/shirou/gopsutil/v4 v4.26.7
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/valyala/fasthttp v1.73.0
|
||||
github.com/xlzd/gotp v0.1.0
|
||||
@@ -27,9 +27,9 @@ require (
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/text v0.40.0
|
||||
google.golang.org/grpc v1.82.1
|
||||
google.golang.org/grpc v1.83.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/postgres v1.6.2
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
pgregory.net/rapid v1.3.0
|
||||
@@ -41,7 +41,7 @@ require (
|
||||
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
||||
github.com/cloudflare/circl v1.6.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
@@ -67,7 +67,7 @@ require (
|
||||
github.com/klauspost/compress v1.19.1
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
@@ -76,7 +76,7 @@ require (
|
||||
github.com/pion/dtls/v3 v3.1.5 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/stun/v3 v3.1.6 // indirect
|
||||
github.com/pion/transport/v4 v4.0.2 // indirect
|
||||
github.com/pion/transport/v4 v4.1.0 // indirect
|
||||
github.com/pires/go-proxyproto v0.15.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
@@ -88,7 +88,7 @@ require (
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.2 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/vishvananda/netlink v1.3.1 // indirect
|
||||
@@ -108,7 +108,7 @@ require (
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
|
||||
@@ -12,8 +12,8 @@ github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
|
||||
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=
|
||||
@@ -125,8 +125,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 h1:YkjVPl/YH5XlJ+/NiwzJtPYXXKRcyjmEUhsDci6YK3c=
|
||||
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||
@@ -154,8 +154,8 @@ github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
|
||||
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
|
||||
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
|
||||
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ=
|
||||
github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
|
||||
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -178,8 +178,8 @@ github.com/sagernet/sing v0.8.11 h1:AKZRvjFPHtAXwGCjOJrzAQPiZxr8mobhuSUqkHf+VQw=
|
||||
github.com/sagernet/sing v0.8.11/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
|
||||
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
|
||||
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -200,8 +200,8 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
|
||||
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
|
||||
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
|
||||
@@ -228,16 +228,16 @@ go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4Dh
|
||||
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
@@ -279,10 +279,10 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
|
||||
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 h1:mJiOtnGp0k/BcSgdu03G2NwnscCfCH+h2QKUBZr18KI=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
||||
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -295,8 +295,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
|
||||
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
|
||||
@@ -140,6 +140,9 @@ func initModels() error {
|
||||
if err := migrateTgIDIndex(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateSyncOrphanColumns(); err != nil {
|
||||
return err
|
||||
}
|
||||
if IsPostgres() {
|
||||
if err := resyncPostgresSequences(db, models); err != nil {
|
||||
log.Printf("Error resyncing postgres sequences: %v", err)
|
||||
@@ -297,6 +300,15 @@ func rebuildInboundsWithoutInlineUniquePort() error {
|
||||
})
|
||||
}
|
||||
|
||||
// AutoMigrate adds the column; this only backfills the NULLs an older SQLite
|
||||
// ALTER TABLE leaves behind, so the reaper's predicate never compares to NULL.
|
||||
func migrateSyncOrphanColumns() error {
|
||||
if !db.Migrator().HasColumn(&model.ClientRecord{}, "sync_orphaned_at") {
|
||||
return nil
|
||||
}
|
||||
return db.Exec("UPDATE clients SET sync_orphaned_at = 0 WHERE sync_orphaned_at IS NULL").Error
|
||||
}
|
||||
|
||||
func migrateHostVerifyPeerCertByNameColumn() error {
|
||||
if !db.Migrator().HasColumn(&model.Host{}, "verify_peer_cert_by_name") {
|
||||
return nil
|
||||
|
||||
@@ -911,6 +911,9 @@ type ClientRecord struct {
|
||||
Reset int `json:"reset" gorm:"default:0"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
// Owned solely by the node-snapshot sweep, which soft-orphans instead of
|
||||
// deleting; orphans from any other cause stay at zero and are never reaped.
|
||||
SyncOrphanedAt int64 `json:"-" gorm:"column:sync_orphaned_at;default:0"`
|
||||
}
|
||||
|
||||
func (ClientRecord) TableName() string { return "clients" }
|
||||
|
||||
@@ -163,7 +163,5 @@ func (a *IndexController) csrfToken(c *gin.Context) {
|
||||
// getTwoFactorEnable retrieves the current status of two-factor authentication.
|
||||
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
|
||||
status, err := a.settingService.GetTwoFactorEnable()
|
||||
if err == nil {
|
||||
jsonObj(c, status, nil)
|
||||
}
|
||||
jsonObj(c, status, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package job
|
||||
|
||||
import "testing"
|
||||
|
||||
// A bind that succeeds with an unusable search returns (empty, nil); the only
|
||||
// guard was `err != nil`, so that answer detached the entire inbound.
|
||||
func TestAutoDeleteAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
previous int64
|
||||
fetched int
|
||||
want bool
|
||||
}{
|
||||
{"empty fetch on a fresh job is refused", 0, 0, false},
|
||||
{"empty fetch after a healthy sync is refused", 500, 0, false},
|
||||
{"full fetch on a fresh job is allowed", 0, 500, true},
|
||||
{"steady fetch is allowed", 500, 500, true},
|
||||
{"growth is allowed", 500, 900, true},
|
||||
{"shrink above the retention floor is allowed", 500, 250, true},
|
||||
{"shrink below the retention floor is refused", 500, 249, false},
|
||||
{"collapse to a single user is refused", 500, 1, false},
|
||||
{"single user with no history is allowed", 0, 1, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
j := NewLdapSyncJob()
|
||||
j.lastFlagCount.Store(c.previous)
|
||||
if got := j.autoDeleteSafeForFetch(c.fetched); got != c.want {
|
||||
t.Fatalf("autoDeleteSafeForFetch(previous=%d, fetched=%d) = %v, want %v",
|
||||
c.previous, c.fetched, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package job
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
@@ -12,11 +13,16 @@ import (
|
||||
|
||||
var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
|
||||
|
||||
// Share of the previous successful fetch a new one must still return to be
|
||||
// trusted: a sudden collapse means a broken directory far more often than churn.
|
||||
const ldapAutoDeleteMinRetainPercent = 50
|
||||
|
||||
type LdapSyncJob struct {
|
||||
settingService service.SettingService
|
||||
inboundService service.InboundService
|
||||
clientService service.ClientService
|
||||
xrayService service.XrayService
|
||||
lastFlagCount atomic.Int64
|
||||
}
|
||||
|
||||
// --- Helper functions for mustGet ---
|
||||
@@ -77,7 +83,7 @@ func (j *LdapSyncJob) Run() {
|
||||
UserFilter: mustGetString(j.settingService.GetLdapUserFilter),
|
||||
UserAttr: mustGetString(j.settingService.GetLdapUserAttr),
|
||||
FlagField: mustGetStringOr(j.settingService.GetLdapFlagField, mustGetString(j.settingService.GetLdapVlessField)),
|
||||
TruthyVals: splitCsv(mustGetString(j.settingService.GetLdapTruthyValues)),
|
||||
TruthyVals: truthyValuesOrDefault(mustGetString(j.settingService.GetLdapTruthyValues)),
|
||||
Invert: mustGetBool(j.settingService.GetLdapInvertFlag),
|
||||
}
|
||||
|
||||
@@ -157,7 +163,7 @@ func (j *LdapSyncJob) Run() {
|
||||
|
||||
// --- Auto delete clients not in LDAP ---
|
||||
autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete)
|
||||
if autoDelete {
|
||||
if autoDelete && j.autoDeleteSafeForFetch(len(flags)) {
|
||||
ldapEmailSet := map[string]struct{}{}
|
||||
for e := range flags {
|
||||
ldapEmailSet[e] = struct{}{}
|
||||
@@ -166,11 +172,35 @@ func (j *LdapSyncJob) Run() {
|
||||
j.deleteClientsNotInLDAP(tag, ldapEmailSet)
|
||||
}
|
||||
}
|
||||
j.lastFlagCount.Store(int64(len(flags)))
|
||||
}
|
||||
|
||||
// FetchVlessFlags returns (empty, nil) when the bind succeeds but the search
|
||||
// yields nothing — a renamed OU, a lost read grant — which is not "all gone".
|
||||
func (j *LdapSyncJob) autoDeleteSafeForFetch(fetched int) bool {
|
||||
if fetched == 0 {
|
||||
logger.Warning("LDAP auto-delete skipped: directory returned no usable users")
|
||||
return false
|
||||
}
|
||||
previous := j.lastFlagCount.Load()
|
||||
if previous > 0 && int64(fetched)*100 < previous*ldapAutoDeleteMinRetainPercent {
|
||||
logger.Warningf("LDAP auto-delete skipped: fetched %d users, previous successful sync saw %d (below %d%% retention)",
|
||||
fetched, previous, ldapAutoDeleteMinRetainPercent)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func truthyValuesOrDefault(s string) []string {
|
||||
if vals := splitCsv(s); len(vals) > 0 {
|
||||
return vals
|
||||
}
|
||||
return DefaultTruthyValues
|
||||
}
|
||||
|
||||
func splitCsv(s string) []string {
|
||||
if s == "" {
|
||||
return DefaultTruthyValues
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// ReapSyncOrphansJob hard-deletes the clients the node-snapshot sweep only
|
||||
// soft-orphaned, once enough clean merges have agreed they are really gone.
|
||||
type ReapSyncOrphansJob struct {
|
||||
clientService service.ClientService
|
||||
}
|
||||
|
||||
func NewReapSyncOrphansJob() *ReapSyncOrphansJob {
|
||||
return new(ReapSyncOrphansJob)
|
||||
}
|
||||
|
||||
func (j *ReapSyncOrphansJob) Run() {
|
||||
reaped, err := j.clientService.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
logger.Warning("reap sync orphans failed:", err)
|
||||
return
|
||||
}
|
||||
if reaped > 0 {
|
||||
logger.Infof("reap sync orphans: removed %d client(s)", reaped)
|
||||
}
|
||||
}
|
||||
@@ -835,13 +835,16 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
|
||||
successEmails := make([]string, 0, len(recordsByEmail))
|
||||
successIds := make([]int, 0, len(recordsByEmail))
|
||||
failedEmails := make([]string, 0, len(recordsByEmail))
|
||||
for email, rec := range recordsByEmail {
|
||||
if _, skipped := skippedReasons[email]; skipped {
|
||||
failedEmails = append(failedEmails, email)
|
||||
continue
|
||||
}
|
||||
successEmails = append(successEmails, email)
|
||||
successIds = append(successIds, rec.Id)
|
||||
}
|
||||
withdrawClientTombstones(failedEmails...)
|
||||
|
||||
if len(successIds) > 0 {
|
||||
// Serialize the row cleanup against the traffic poll to avoid the
|
||||
@@ -875,6 +878,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
withdrawClientTombstones(successEmails...)
|
||||
return result, needRestart, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -21,7 +22,7 @@ import (
|
||||
|
||||
func hasForbiddenClientChar(s string) bool {
|
||||
for _, r := range s {
|
||||
if r == '/' || r == '\\' || r == ' ' || r < 0x20 || r == 0x7f {
|
||||
if r == '/' || r == '\\' || r < 0x20 || r == 0x7f || unicode.IsSpace(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -523,6 +524,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
|
||||
inboundIds, err := s.GetInboundIdsForRecord(id)
|
||||
if err != nil {
|
||||
withdrawClientTombstones(existing.Email)
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -560,7 +562,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
}
|
||||
// A failed inbound still holds the client in its settings JSON: keep the
|
||||
// record so the next delete retries exactly the leftovers, and report it.
|
||||
// The tombstone lifts with it, or the next node merge finishes the deletion.
|
||||
if len(delErrs) > 0 {
|
||||
withdrawClientTombstones(existing.Email)
|
||||
return needRestart, errors.Join(delErrs...)
|
||||
}
|
||||
|
||||
@@ -593,6 +597,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
}
|
||||
return tx.Delete(&model.ClientRecord{}, id).Error
|
||||
}); err != nil {
|
||||
withdrawClientTombstones(existing.Email)
|
||||
return needRestart, err
|
||||
}
|
||||
return needRestart, nil
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// Delete tombstones up front and keeps the record when an inbound fails. A
|
||||
// surviving tombstone lets the next node merge finish the refused deletion.
|
||||
func TestFailedDeleteWithdrawsTombstone(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
db := database.GetDB()
|
||||
|
||||
const email = "retry@x"
|
||||
broken := mkInbound(t, 30401, model.VLESS, `{"clients": [ THIS IS NOT JSON`)
|
||||
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "33333333-3333-3333-3333-333333333333"}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("create client record: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: broken.Id}).Error; err != nil {
|
||||
t.Fatalf("attach client: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { withdrawClientTombstones(email) })
|
||||
|
||||
if _, err := svc.Delete(inboundSvc, rec.Id, false); err == nil {
|
||||
t.Fatal("setup: delete was expected to fail on the unparseable inbound settings")
|
||||
}
|
||||
|
||||
var surviving int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
|
||||
t.Fatalf("count clients: %v", err)
|
||||
}
|
||||
if surviving != 1 {
|
||||
t.Fatalf("failed delete must keep the record for a retry, got %d rows", surviving)
|
||||
}
|
||||
if isClientEmailTombstoned(email) {
|
||||
t.Fatal("delete kept the record but left a live tombstone: the next node sync would finish the deletion it refused")
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,9 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
|
||||
}
|
||||
|
||||
incoming := clients[i].ToRecord()
|
||||
// ToRecord copies the raw email; store the trimmed key this function
|
||||
// looks up by, or a padded email is inserted and never found again.
|
||||
incoming.Email = email
|
||||
row, ok := existing[email]
|
||||
if !ok {
|
||||
if _, dup := pending[email]; !dup {
|
||||
|
||||
@@ -105,6 +105,19 @@ func tombstoneClientEmail(email string) {
|
||||
}
|
||||
}
|
||||
|
||||
func withdrawClientTombstones(emails ...string) {
|
||||
if len(emails) == 0 {
|
||||
return
|
||||
}
|
||||
recentlyDeletedMu.Lock()
|
||||
defer recentlyDeletedMu.Unlock()
|
||||
for _, email := range emails {
|
||||
if email != "" {
|
||||
delete(recentlyDeleted, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tombstoneClientEmails(emails []string) {
|
||||
if len(emails) == 0 {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// How long a client stays recoverable after a node merge concluded it is gone.
|
||||
// Any merge that sees it attached again inside this window clears the mark.
|
||||
const syncOrphanReapGrace = 15 * time.Minute
|
||||
|
||||
// A traffic row with no clients row behind it is drift the orphan sweep will
|
||||
// never mark, so it stays on the old delete-immediately path.
|
||||
func clientRecordExists(tx *gorm.DB, email string) bool {
|
||||
var n int64
|
||||
if err := tx.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&n).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func markSyncOrphan(tx *gorm.DB, email string, nowMs int64) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.ClientRecord{}).
|
||||
Where("email = ? AND sync_orphaned_at = 0", email).
|
||||
Update("sync_orphaned_at", nowMs).Error
|
||||
}
|
||||
|
||||
// A client that is attached again is not orphaned, whatever an earlier merge
|
||||
// concluded — this is what makes a bad merge recoverable instead of fatal.
|
||||
func clearSyncOrphanMarks(tx *gorm.DB) error {
|
||||
return tx.Model(&model.ClientRecord{}).
|
||||
Where("sync_orphaned_at > 0 AND EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
|
||||
Update("sync_orphaned_at", 0).Error
|
||||
}
|
||||
|
||||
// ReapSyncOrphans deletes the clients the node-snapshot sweep marked and no
|
||||
// later merge reclaimed. It is the only path that hard-deletes for that sweep.
|
||||
func (s *ClientService) ReapSyncOrphans() (int, error) {
|
||||
db := database.GetDB()
|
||||
cutoff := time.Now().Add(-syncOrphanReapGrace).UnixMilli()
|
||||
|
||||
var emails []string
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("sync_orphaned_at > 0 AND sync_orphaned_at <= ?", cutoff).
|
||||
Where("NOT EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)").
|
||||
Pluck("email", &emails).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
reaped := 0
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
if err := runSerializedTx(func(tx *gorm.DB) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error
|
||||
}); err != nil {
|
||||
return reaped, err
|
||||
}
|
||||
reaped += len(batch)
|
||||
logger.Infof("reaped %d client(s) confirmed removed on their node", len(batch))
|
||||
}
|
||||
return reaped, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func readOrphanMark(t *testing.T, db *gorm.DB, email string) int64 {
|
||||
t.Helper()
|
||||
var row model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
|
||||
t.Fatalf("read client %q: %v", email, err)
|
||||
}
|
||||
return row.SyncOrphanedAt
|
||||
}
|
||||
|
||||
func backdateOrphanMark(t *testing.T, db *gorm.DB, email string) {
|
||||
t.Helper()
|
||||
past := time.Now().Add(-2 * syncOrphanReapGrace).UnixMilli()
|
||||
if err := db.Model(&model.ClientRecord{}).
|
||||
Where("email = ?", email).
|
||||
Update("sync_orphaned_at", past).Error; err != nil {
|
||||
t.Fatalf("backdate orphan mark: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The merge must soft-orphan, not delete: everything stays recoverable until
|
||||
// the grace period has elapsed and the reaper confirms nothing reclaimed it.
|
||||
func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "gone@x"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
||||
|
||||
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
||||
t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
|
||||
t.Fatalf("orphaning merge: %v", err)
|
||||
}
|
||||
if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
|
||||
t.Fatalf("merge hard-deleted the client: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
if readOrphanMark(t, db, email) <= 0 {
|
||||
t.Fatal("merge did not stamp sync_orphaned_at")
|
||||
}
|
||||
|
||||
reaped, err := clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap inside grace: %v", err)
|
||||
}
|
||||
if reaped != 0 {
|
||||
t.Fatalf("reaped %d client(s) inside the grace period, want 0", reaped)
|
||||
}
|
||||
if rec, _ := countClientRows(t, db, email); rec != 1 {
|
||||
t.Fatal("client removed before the grace period elapsed")
|
||||
}
|
||||
|
||||
backdateOrphanMark(t, db, email)
|
||||
reaped, err = clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap after grace: %v", err)
|
||||
}
|
||||
if reaped != 1 {
|
||||
t.Fatalf("reaped %d client(s) after the grace period, want 1", reaped)
|
||||
}
|
||||
rec, traf := countClientRows(t, db, email)
|
||||
if rec != 0 || traf != 0 {
|
||||
t.Fatalf("after reap: clients=%d client_traffics=%d, want 0/0", rec, traf)
|
||||
}
|
||||
}
|
||||
|
||||
// A client the node reports again was never gone: clearing the mark is what
|
||||
// turns a bad merge into a recoverable blip instead of a delayed deletion.
|
||||
func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "flaky@x"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
|
||||
t.Fatalf("orphaning merge: %v", err)
|
||||
}
|
||||
if readOrphanMark(t, db, email) <= 0 {
|
||||
t.Fatal("setup: expected the merge to mark the client")
|
||||
}
|
||||
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true})
|
||||
|
||||
if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 {
|
||||
t.Fatalf("re-attached client kept its orphan mark: sync_orphaned_at=%d", orphanedAt)
|
||||
}
|
||||
|
||||
backdateOrphanMark(t, db, email)
|
||||
if reaped, err := clientSvc.ReapSyncOrphans(); err != nil || reaped != 0 {
|
||||
t.Fatalf("reaped %d client(s) (err=%v) that the node still reports, want 0", reaped, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The reaper is scoped to the node sweep. Orphans from any other cause carry no
|
||||
// mark and keep their existing manual-cleanup semantics.
|
||||
func TestReapSyncOrphansIgnoresUnmarkedOrphans(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
clientSvc := &ClientService{}
|
||||
|
||||
const email = "manual@x"
|
||||
rec := &model.ClientRecord{Email: email, Enable: true, UUID: "44444444-4444-4444-4444-444444444444"}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
|
||||
reaped, err := clientSvc.ReapSyncOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("reap: %v", err)
|
||||
}
|
||||
if reaped != 0 {
|
||||
t.Fatalf("reaped %d unmarked orphan(s), want 0", reaped)
|
||||
}
|
||||
var surviving int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil {
|
||||
t.Fatalf("count clients: %v", err)
|
||||
}
|
||||
if surviving != 1 {
|
||||
t.Fatalf("unmarked orphan was reaped: %d rows survive, want 1", surviving)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,43 @@ func TestUpdateInboundClientCaseOnlyRenameDoesNotDuplicateRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The IP-limit job keys its tracking rows on the casing Xray reports, so an
|
||||
// inbound whose settings JSON drifted in case leaves a row under each spelling.
|
||||
func TestUpdateInboundClientCaseOnlyRenameSurvivesExistingClientIpsRow(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{{Email: "Sanaei", ID: "aaaaaaaa-0000-0000-0000-000000000009", SubID: "sub-ips", Enable: true}}
|
||||
ib := mkInbound(t, 22011, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
for _, email := range []string{"Sanaei", "sanaei"} {
|
||||
row := &model.InboundClientIps{ClientEmail: email, Ips: `[{"ip":"1.2.3.4","timestamp":1700000000}]`}
|
||||
if err := database.GetDB().Create(row).Error; err != nil {
|
||||
t.Fatalf("seed client ips for %q: %v", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
lowered := source
|
||||
lowered[0].Email = "sanaei"
|
||||
if _, err := svc.UpdateInboundClient(inboundSvc, &model.Inbound{
|
||||
Id: ib.Id,
|
||||
Settings: clientsSettings(t, lowered),
|
||||
}, "sanaei"); err != nil {
|
||||
t.Fatalf("UpdateInboundClient with a colliding client ips row: %v", err)
|
||||
}
|
||||
|
||||
var rows []model.InboundClientIps
|
||||
if err := database.GetDB().Find(&rows).Error; err != nil {
|
||||
t.Fatalf("read client ips: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ClientEmail != "sanaei" {
|
||||
t.Fatalf("client ips rows after rename = %+v, want a single row for %q", rows, "sanaei")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
@@ -1078,7 +1078,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
payload := inbound
|
||||
pushable := true
|
||||
if inbound.Protocol == model.MTProto {
|
||||
if built, bErr := s.buildRuntimeInboundForAPI(tx, inbound); bErr == nil {
|
||||
if built, bErr := s.buildInboundForLocalRuntime(tx, inbound); bErr == nil {
|
||||
payload = built
|
||||
} else {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", bErr)
|
||||
@@ -1326,7 +1326,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
return needRestart, nil
|
||||
}
|
||||
|
||||
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
|
||||
runtimeInbound, err := s.buildInboundForLocalRuntime(db, inbound)
|
||||
if err != nil {
|
||||
logger.Debug("SetInboundEnable: build runtime config failed:", err)
|
||||
return true, nil
|
||||
@@ -1511,7 +1511,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
payload := oldInbound
|
||||
pushable := true
|
||||
if inbound.Enable {
|
||||
if built, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound); err2 == nil {
|
||||
if built, err2 := s.buildInboundForLocalRuntime(tx, oldInbound); err2 == nil {
|
||||
payload = built
|
||||
} else {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", err2)
|
||||
@@ -1537,7 +1537,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
var runtimeInbound *model.Inbound
|
||||
if inbound.Enable {
|
||||
var err2 error
|
||||
runtimeInbound, err2 = s.buildRuntimeInboundForAPI(tx, oldInbound)
|
||||
runtimeInbound, err2 = s.buildInboundForLocalRuntime(tx, oldInbound)
|
||||
if err2 != nil {
|
||||
logger.Debug("Unable to prepare runtime inbound config:", err2)
|
||||
needRestart = true
|
||||
@@ -1608,81 +1608,95 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
return inbound, needRestart, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
// A node mirrors this payload into its own DB, so every client must survive:
|
||||
// filtering one out makes the node delete it, and the master then mirrors that.
|
||||
func (s *InboundService) buildInboundForNodePush(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
if inbound == nil {
|
||||
return nil, fmt.Errorf("inbound is nil")
|
||||
}
|
||||
|
||||
runtimeInbound := *inbound
|
||||
built := *inbound
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mutated := false
|
||||
if clients, ok := settings["clients"].([]any); ok {
|
||||
var clientStats []xray.ClientTraffic
|
||||
err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("inbound_id = ?", inbound.Id).
|
||||
Select("email", "enable").
|
||||
Find(&clientStats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !inboundCanHostFallbacks(inbound) {
|
||||
return &built, nil
|
||||
}
|
||||
fallbacks, err := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fallbacks) == 0 {
|
||||
return &built, nil
|
||||
}
|
||||
generic := make([]any, 0, len(fallbacks))
|
||||
for _, f := range fallbacks {
|
||||
generic = append(generic, f)
|
||||
}
|
||||
settings["fallbacks"] = generic
|
||||
|
||||
enableMap := make(map[string]bool, len(clientStats))
|
||||
for _, clientTraffic := range clientStats {
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
modifiedSettings, mErr := json.MarshalIndent(settings, "", " ")
|
||||
if mErr != nil {
|
||||
return nil, mErr
|
||||
}
|
||||
built.Settings = string(modifiedSettings)
|
||||
return &built, nil
|
||||
}
|
||||
|
||||
finalClients := make([]any, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
c, ok := client.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
email, _ := c["email"].(string)
|
||||
if enable, exists := enableMap[email]; exists && !enable {
|
||||
continue
|
||||
}
|
||||
|
||||
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
|
||||
continue
|
||||
}
|
||||
|
||||
finalClients = append(finalClients, c)
|
||||
}
|
||||
|
||||
settings["clients"] = finalClients
|
||||
mutated = true
|
||||
// Strips disabled clients on top of the node payload. Safe only because the
|
||||
// target here is an in-memory Xray/mtg config, not another panel's database.
|
||||
func (s *InboundService) buildInboundForLocalRuntime(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
|
||||
built, err := s.buildInboundForNodePush(tx, inbound)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if inboundCanHostFallbacks(inbound) {
|
||||
fallbacks, fbErr := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
|
||||
if fbErr != nil {
|
||||
return nil, fbErr
|
||||
}
|
||||
if len(fallbacks) > 0 {
|
||||
generic := make([]any, 0, len(fallbacks))
|
||||
for _, f := range fallbacks {
|
||||
generic = append(generic, f)
|
||||
}
|
||||
settings["fallbacks"] = generic
|
||||
mutated = true
|
||||
}
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(built.Settings), &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
return built, nil
|
||||
}
|
||||
|
||||
if !mutated {
|
||||
return &runtimeInbound, nil
|
||||
var clientStats []xray.ClientTraffic
|
||||
if err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("inbound_id = ?", built.Id).
|
||||
Select("email", "enable").
|
||||
Find(&clientStats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enableMap := make(map[string]bool, len(clientStats))
|
||||
for _, clientTraffic := range clientStats {
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
|
||||
finalClients := make([]any, 0, len(clients))
|
||||
for _, client := range clients {
|
||||
c, ok := client.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
email, _ := c["email"].(string)
|
||||
if enable, exists := enableMap[email]; exists && !enable {
|
||||
continue
|
||||
}
|
||||
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
|
||||
continue
|
||||
}
|
||||
finalClients = append(finalClients, c)
|
||||
}
|
||||
settings["clients"] = finalClients
|
||||
|
||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeInbound := *built
|
||||
runtimeInbound.Settings = string(modifiedSettings)
|
||||
|
||||
return &runtimeInbound, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,13 @@ func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClient
|
||||
}
|
||||
|
||||
func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail string) error {
|
||||
// The caller only renames onto a free identity, so a row already sitting on
|
||||
// newEmail is stale tracking data — drop it instead of failing the edit.
|
||||
if oldEmail != newEmail {
|
||||
if err := tx.Where("client_email = ?", newEmail).Delete(model.InboundClientIps{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Model(model.InboundClientIps{}).Where("client_email = ?", oldEmail).Update("client_email", newEmail).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@ func TestBuildRuntimeInboundForAPI_InjectsFallbacks(t *testing.T) {
|
||||
t.Fatalf("seed fallback: %v", err)
|
||||
}
|
||||
|
||||
runtimeIb, err := svc.buildRuntimeInboundForAPI(db, master)
|
||||
runtimeIb, err := svc.buildInboundForLocalRuntime(db, master)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
|
||||
t.Fatalf("buildInboundForLocalRuntime: %v", err)
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
@@ -68,9 +68,9 @@ func TestBuildRuntimeInboundForAPI_NoFallbacksOnWsInbound(t *testing.T) {
|
||||
t.Fatalf("seed fallback: %v", err)
|
||||
}
|
||||
|
||||
runtimeIb, err := svc.buildRuntimeInboundForAPI(db, ib)
|
||||
runtimeIb, err := svc.buildInboundForLocalRuntime(db, ib)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
|
||||
t.Fatalf("buildInboundForLocalRuntime: %v", err)
|
||||
}
|
||||
if strings.Contains(runtimeIb.Settings, "fallbacks") {
|
||||
t.Fatalf("ws inbound must not receive fallbacks: %s", runtimeIb.Settings)
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// running: one instance per enabled local mtproto inbound, serving only the
|
||||
// secrets of clients that are both enabled in the inbound settings and not
|
||||
// depletion-disabled in client_traffics. That is the same effective client set
|
||||
// buildRuntimeInboundForAPI pushes on interactive edits, so the reconcile job
|
||||
// buildInboundForLocalRuntime pushes on interactive edits, so the reconcile job
|
||||
// and the push paths agree on one fingerprint — a disagreement would surface
|
||||
// as a needless mtg restart, and a job that read only the raw settings would
|
||||
// keep serving depleted clients until an unrelated restart. Inbounds whose
|
||||
@@ -95,7 +95,7 @@ func (s *InboundService) applyLocalMtproto(inboundId int) {
|
||||
}
|
||||
payload := inbound
|
||||
if inbound.Enable {
|
||||
if built, bErr := s.buildRuntimeInboundForAPI(database.GetDB(), inbound); bErr == nil {
|
||||
if built, bErr := s.buildInboundForLocalRuntime(database.GetDB(), inbound); bErr == nil {
|
||||
payload = built
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,9 @@ func TestDesiredMtprotoInstancesFiltersDepleted(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("matchesInteractivePushFiltering", func(t *testing.T) {
|
||||
built, err := svc.buildRuntimeInboundForAPI(database.GetDB(), served)
|
||||
built, err := svc.buildInboundForLocalRuntime(database.GetDB(), served)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRuntimeInboundForAPI: %v", err)
|
||||
t.Fatalf("buildInboundForLocalRuntime: %v", err)
|
||||
}
|
||||
pushInst, ok := mtproto.InstanceFromInbound(built)
|
||||
if !ok {
|
||||
|
||||
@@ -125,11 +125,8 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reconcile with the same runtime-built payload interactive pushes
|
||||
// send (disabled clients filtered, settings.fallbacks injected) so
|
||||
// fingerprints line up and fallback edits actually reach the node.
|
||||
runtimeIb := ib
|
||||
if built, bErr := s.buildRuntimeInboundForAPI(db, ib); bErr == nil {
|
||||
if built, bErr := s.buildInboundForNodePush(db, ib); bErr == nil {
|
||||
runtimeIb = built
|
||||
}
|
||||
if _, err := rt.ReconcileInbound(ctx, runtimeIb, existsOnNode); err != nil {
|
||||
@@ -352,7 +349,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
// origin (an inbound the node forwards from its own sub-node) is kept as-is,
|
||||
// so a chained Node1->Node2->Node3 still attributes Node3's inbounds to Node3.
|
||||
var nodeRow model.Node
|
||||
db.Select("guid").Where("id = ?", nodeID).First(&nodeRow)
|
||||
db.Select("guid", "config_dirty", "inbound_sync_mode", "inbound_tags").Where("id = ?", nodeID).First(&nodeRow)
|
||||
// Re-read inside the serialized writer: a client added while this snapshot
|
||||
// was in flight marks the node dirty after the caller sampled the flag.
|
||||
dirty = dirty || nodeRow.ConfigDirty
|
||||
nodeRow.Id = nodeID
|
||||
unmanagedTag := unmanagedTagPredicate(&nodeRow)
|
||||
selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid})
|
||||
guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid
|
||||
originGuidFor := func(snapIb *model.Inbound) string {
|
||||
@@ -671,6 +673,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if _, kept := snapTags[c.Tag]; kept {
|
||||
continue
|
||||
}
|
||||
if unmanagedTag(c.Tag) {
|
||||
continue
|
||||
}
|
||||
var goneEmails []string
|
||||
if err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("inbound_id = ?", c.Id).
|
||||
@@ -886,7 +891,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if uErr != nil {
|
||||
return false, uErr
|
||||
}
|
||||
if !stillUsed {
|
||||
// Usage, quota and expiry live on this row, so a client the orphan
|
||||
// sweep will mark keeps it until the reaper confirms the removal.
|
||||
if !stillUsed && !clientRecordExists(tx, existing.Email) {
|
||||
if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
|
||||
Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return false, err
|
||||
@@ -901,6 +908,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
emails map[string]struct{}
|
||||
}
|
||||
var perInboundOld []oldSet
|
||||
syncFailedInbounds := map[int]struct{}{}
|
||||
for _, snapIb := range snap.Inbounds {
|
||||
if snapIb == nil {
|
||||
continue
|
||||
@@ -973,10 +981,16 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
}
|
||||
if err := s.clientService.SyncInbound(tx, c.Id, filtered); err != nil {
|
||||
logger.Warningf("setRemoteTraffic: sync clients for tag %q failed: %v", snapIb.Tag, err)
|
||||
syncFailedInbounds[c.Id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, old := range perInboundOld {
|
||||
// The sweep's premise is that links were just rebuilt from the snapshot,
|
||||
// which is exactly what a failed SyncInbound violates.
|
||||
if _, failed := syncFailedInbounds[old.inboundID]; failed {
|
||||
continue
|
||||
}
|
||||
var stillAttached []string
|
||||
if err := tx.Table("clients").
|
||||
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
||||
@@ -1002,19 +1016,20 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if attachmentCount > 0 {
|
||||
continue
|
||||
}
|
||||
if err := tx.Where("email = ?", email).Delete(&model.ClientRecord{}).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: delete ClientRecord %q failed: %v", email, err)
|
||||
}
|
||||
if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: delete ClientTraffic %q failed: %v", email, err)
|
||||
}
|
||||
if err := tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: delete NodeClientTraffic %q failed: %v", email, err)
|
||||
// "Ended the merge unattached" is true for a real remote deletion and
|
||||
// equally true for a bad merge, so record a strike instead of deleting.
|
||||
if err := markSyncOrphan(tx, email, now); err != nil {
|
||||
logger.Warningf("setRemoteTraffic: mark orphan %q failed: %v", email, err)
|
||||
continue
|
||||
}
|
||||
structuralChange = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := clearSyncOrphanMarks(tx); err != nil {
|
||||
logger.Warning("setRemoteTraffic: clear orphan marks failed:", err)
|
||||
}
|
||||
|
||||
if err := liftActivatedClientRecordExpiries(tx); err != nil {
|
||||
logger.Warning("setRemoteTraffic: lift activated expiries failed:", err)
|
||||
}
|
||||
|
||||
@@ -652,9 +652,9 @@ func (s *NodeService) EnsureInboundTagAllowedTx(tx *gorm.DB, nodeID int, tag str
|
||||
Updates(map[string]any{"inbound_tags": string(buf)}).Error
|
||||
}
|
||||
|
||||
func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
|
||||
if n == nil || snap == nil || n.InboundSyncMode != "selected" {
|
||||
return
|
||||
func nodeSelectedTagSet(n *model.Node) map[string]struct{} {
|
||||
if n == nil || n.InboundSyncMode != "selected" {
|
||||
return nil
|
||||
}
|
||||
prefix := nodeTagPrefix(&n.Id)
|
||||
allowed := make(map[string]struct{}, len(n.InboundTags)*2)
|
||||
@@ -668,6 +668,27 @@ func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
// A deselected tag is still served by the node — FilterNodeSnapshot just stops
|
||||
// reporting it — so its absence must never be read as "the node deleted it".
|
||||
func unmanagedTagPredicate(n *model.Node) func(string) bool {
|
||||
managed := nodeSelectedTagSet(n)
|
||||
if managed == nil {
|
||||
return func(string) bool { return false }
|
||||
}
|
||||
return func(tag string) bool {
|
||||
_, ok := managed[tag]
|
||||
return !ok
|
||||
}
|
||||
}
|
||||
|
||||
func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
|
||||
if n == nil || snap == nil || n.InboundSyncMode != "selected" {
|
||||
return
|
||||
}
|
||||
allowed := nodeSelectedTagSet(n)
|
||||
filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
|
||||
for _, inbound := range snap.Inbounds {
|
||||
if inbound == nil {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func payloadClientEmails(t *testing.T, settings string) []string {
|
||||
t.Helper()
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||
t.Fatalf("settings not valid json: %v", err)
|
||||
}
|
||||
raw, _ := parsed["clients"].([]any)
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, c := range raw {
|
||||
cm, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if email, _ := cm["email"].(string); email != "" {
|
||||
out = append(out, email)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Stripping disabled clients from a node push made the node delete the row and
|
||||
// the master mirror that back: every depleted client destroyed, not disabled.
|
||||
func TestNodePushKeepsDisabledClientsLocalPushStripsThem(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &InboundService{}
|
||||
db := database.GetDB()
|
||||
|
||||
clients := []model.Client{
|
||||
{Email: "alive@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true},
|
||||
{Email: "quota@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 30301, model.VLESS, clientsSettings(t, clients))
|
||||
|
||||
for _, c := range clients {
|
||||
row := &xray.ClientTraffic{InboundId: ib.Id, Email: c.Email, Enable: true}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatalf("seed traffic %s: %v", c.Email, err)
|
||||
}
|
||||
}
|
||||
if err := db.Model(xray.ClientTraffic{}).
|
||||
Where("email = ?", "quota@x").
|
||||
Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("deplete quota@x: %v", err)
|
||||
}
|
||||
|
||||
t.Run("node payload keeps the depletion-disabled client", func(t *testing.T) {
|
||||
built, err := svc.buildInboundForNodePush(db, ib)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInboundForNodePush: %v", err)
|
||||
}
|
||||
got := payloadClientEmails(t, built.Settings)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("node push dropped a client: got %v, want both alive@x and quota@x", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("local xray payload still strips it", func(t *testing.T) {
|
||||
built, err := svc.buildInboundForLocalRuntime(db, ib)
|
||||
if err != nil {
|
||||
t.Fatalf("buildInboundForLocalRuntime: %v", err)
|
||||
}
|
||||
got := payloadClientEmails(t, built.Settings)
|
||||
if len(got) != 1 || got[0] != "alive@x" {
|
||||
t.Fatalf("local runtime payload = %v, want only alive@x", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func countClientRows(t *testing.T, db *gorm.DB, email string) (records, traffics int64) {
|
||||
t.Helper()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&records).Error; err != nil {
|
||||
t.Fatalf("count clients %q: %v", email, err)
|
||||
}
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&traffics).Error; err != nil {
|
||||
t.Fatalf("count client_traffics %q: %v", email, err)
|
||||
}
|
||||
return records, traffics
|
||||
}
|
||||
|
||||
func seedNodeRow(t *testing.T, db *gorm.DB, n *model.Node) {
|
||||
t.Helper()
|
||||
if err := db.Create(n).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotWithClients(t *testing.T, tag, settings string, stats ...xray.ClientTraffic) *runtime.TrafficSnapshot {
|
||||
t.Helper()
|
||||
return &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{Tag: tag, Settings: settings, ClientStats: stats}},
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotWithoutClients(t *testing.T, tag string) *runtime.TrafficSnapshot {
|
||||
t.Helper()
|
||||
return snapshotWithClients(t, tag, `{"clients":[]}`)
|
||||
}
|
||||
|
||||
func snapshotWithTwoInbounds(t *testing.T, tagA, settingsA, emailA, tagB, settingsB, emailB string) *runtime.TrafficSnapshot {
|
||||
t.Helper()
|
||||
return &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{
|
||||
{Tag: tagA, Settings: settingsA, ClientStats: []xray.ClientTraffic{{Email: emailA, Enable: true}}},
|
||||
{Tag: tagB, Settings: settingsB, ClientStats: []xray.ClientTraffic{{Email: emailB, Enable: true}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The job samples config_dirty before the snapshot round-trip; a client added
|
||||
// in that window is deleted again unless the merge re-reads the flag itself.
|
||||
func TestSetRemoteTrafficRereadsConfigDirty(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
|
||||
|
||||
const email = "carol"
|
||||
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
|
||||
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
|
||||
xray.ClientTraffic{Email: email, Up: 1, Down: 1, Enable: true})
|
||||
|
||||
if rec, _ := countClientRows(t, db, email); rec != 1 {
|
||||
t.Fatalf("setup: client not attached, got %d rows", rec)
|
||||
}
|
||||
|
||||
if err := db.Model(model.Node{}).Where("id = ?", 1).Update("config_dirty", true).Error; err != nil {
|
||||
t.Fatalf("mark node dirty: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
rec, traf := countClientRows(t, db, email)
|
||||
if rec != 1 || traf != 1 {
|
||||
t.Fatalf("stale dirty=false wiped a client added mid-flight: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
}
|
||||
|
||||
// FilterNodeSnapshot stops reporting a deselected tag; reading that absence as
|
||||
// "the node deleted it" wiped the master's copy of an inbound still running.
|
||||
func TestDeselectedTagIsNotSwept(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
seedNodeRow(t, db, &model.Node{
|
||||
Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true,
|
||||
InboundSyncMode: "selected", InboundTags: []string{"keep"},
|
||||
})
|
||||
|
||||
createNodeInboundWithClient(t, db, 1, "keep", 41001, "kept@x")
|
||||
createNodeInboundWithClient(t, db, 1, "drop", 41002, "dropped@x")
|
||||
|
||||
keepSettings := `{"clients":[{"email":"kept@x","enable":true}]}`
|
||||
dropSettings := `{"clients":[{"email":"dropped@x","enable":true}]}`
|
||||
snap := snapshotWithTwoInbounds(t, "keep", keepSettings, "kept@x", "drop", dropSettings, "dropped@x")
|
||||
if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
|
||||
t.Fatalf("seed sync: %v", err)
|
||||
}
|
||||
if rec, traf := countClientRows(t, db, "dropped@x"); rec != 1 || traf != 1 {
|
||||
t.Fatalf("setup: dropped@x not seeded, clients=%d client_traffics=%d", rec, traf)
|
||||
}
|
||||
|
||||
keepOnly := snapshotWithClients(t, "keep", keepSettings, xray.ClientTraffic{Email: "kept@x", Enable: true})
|
||||
if _, err := svc.setRemoteTrafficLocked(1, keepOnly, false); err != nil {
|
||||
t.Fatalf("post-deselect sync: %v", err)
|
||||
}
|
||||
|
||||
rec, traf := countClientRows(t, db, "dropped@x")
|
||||
if rec != 1 || traf != 1 {
|
||||
t.Fatalf("deselecting a tag deleted its clients: clients=%d client_traffics=%d, want 1/1", rec, traf)
|
||||
}
|
||||
var inbounds int64
|
||||
if err := db.Model(model.Inbound{}).Where("tag = ?", "drop").Count(&inbounds).Error; err != nil {
|
||||
t.Fatalf("count inbounds: %v", err)
|
||||
}
|
||||
if inbounds != 1 {
|
||||
t.Fatalf("deselecting a tag deleted the inbound the node still serves: got %d rows, want 1", inbounds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncInboundStoresTrimmedEmail(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
ib := &model.Inbound{UserId: 1, Tag: "trim-in", Enable: true, Port: 41501, Protocol: model.VLESS}
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
padded := "bob "
|
||||
clients := []model.Client{{Email: padded, Enable: true}}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("first SyncInbound: %v", err)
|
||||
}
|
||||
|
||||
var stored []string
|
||||
if err := db.Model(&model.ClientRecord{}).Pluck("email", &stored).Error; err != nil {
|
||||
t.Fatalf("read clients: %v", err)
|
||||
}
|
||||
if len(stored) != 1 || stored[0] != "bob" {
|
||||
t.Fatalf("stored email = %q, want the trimmed %q — the lookup key must match what is written", stored, "bob")
|
||||
}
|
||||
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("second SyncInbound must not hit a unique-constraint on the untrimmed row: %v", err)
|
||||
}
|
||||
var links int64
|
||||
if err := db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&links).Error; err != nil {
|
||||
t.Fatalf("count links: %v", err)
|
||||
}
|
||||
if links != 1 {
|
||||
t.Fatalf("links after re-sync = %d, want 1", links)
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,9 @@
|
||||
"donate": "تبرع",
|
||||
"hosts": "المضيفات",
|
||||
"docs": "التوثيق",
|
||||
"openMenu": "فتح القائمة"
|
||||
"openMenu": "فتح القائمة",
|
||||
"pinSidebar": "تثبيت الشريط الجانبي",
|
||||
"unpinSidebar": "إلغاء تثبيت الشريط الجانبي"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"apiDocs": "API Docs",
|
||||
"donate": "Donate",
|
||||
"docs": "Documentation",
|
||||
"openMenu": "Open menu"
|
||||
"openMenu": "Open menu",
|
||||
"pinSidebar": "Pin sidebar",
|
||||
"unpinSidebar": "Unpin sidebar"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"donate": "Donar",
|
||||
"hosts": "Hosts",
|
||||
"docs": "Documentación",
|
||||
"openMenu": "Abrir menú"
|
||||
"openMenu": "Abrir menú",
|
||||
"pinSidebar": "Fijar barra lateral",
|
||||
"unpinSidebar": "Desfijar barra lateral"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"donate": "حمایت مالی",
|
||||
"hosts": "میزبانها",
|
||||
"docs": "مستندات",
|
||||
"openMenu": "باز کردن منو"
|
||||
"openMenu": "باز کردن منو",
|
||||
"pinSidebar": "ثابت کردن نوار کناری",
|
||||
"unpinSidebar": "برداشتن تثبیت نوار کناری"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"donate": "Donasi",
|
||||
"hosts": "Host",
|
||||
"docs": "Dokumentasi",
|
||||
"openMenu": "Buka menu"
|
||||
"openMenu": "Buka menu",
|
||||
"pinSidebar": "Sematkan bilah sisi",
|
||||
"unpinSidebar": "Lepas sematan bilah sisi"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"donate": "寄付",
|
||||
"hosts": "ホスト",
|
||||
"docs": "ドキュメント",
|
||||
"openMenu": "メニューを開く"
|
||||
"openMenu": "メニューを開く",
|
||||
"pinSidebar": "サイドバーを固定",
|
||||
"unpinSidebar": "サイドバーの固定を解除"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"donate": "Doar",
|
||||
"hosts": "Hosts",
|
||||
"docs": "Documentação",
|
||||
"openMenu": "Abrir menu"
|
||||
"openMenu": "Abrir menu",
|
||||
"pinSidebar": "Fixar barra lateral",
|
||||
"unpinSidebar": "Desafixar barra lateral"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"donate": "Поддержать",
|
||||
"hosts": "Хосты",
|
||||
"docs": "Документация",
|
||||
"openMenu": "Открыть меню"
|
||||
"openMenu": "Открыть меню",
|
||||
"pinSidebar": "Закрепить боковую панель",
|
||||
"unpinSidebar": "Открепить боковую панель"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"donate": "Bağış Yap",
|
||||
"hosts": "Host'lar",
|
||||
"docs": "Belgeler",
|
||||
"openMenu": "Menüyü aç"
|
||||
"openMenu": "Menüyü aç",
|
||||
"pinSidebar": "Kenar çubuğunu sabitle",
|
||||
"unpinSidebar": "Kenar çubuğu sabitlemesini kaldır"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"donate": "Підтримати",
|
||||
"hosts": "Хости",
|
||||
"docs": "Документація",
|
||||
"openMenu": "Відкрити меню"
|
||||
"openMenu": "Відкрити меню",
|
||||
"pinSidebar": "Закріпити бічну панель",
|
||||
"unpinSidebar": "Відкріпити бічну панель"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"donate": "Quyên góp",
|
||||
"hosts": "Hosts",
|
||||
"docs": "Tài liệu",
|
||||
"openMenu": "Mở menu"
|
||||
"openMenu": "Mở menu",
|
||||
"pinSidebar": "Ghim thanh bên",
|
||||
"unpinSidebar": "Bỏ ghim thanh bên"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"donate": "捐赠",
|
||||
"hosts": "主机",
|
||||
"docs": "文档",
|
||||
"openMenu": "打开菜单"
|
||||
"openMenu": "打开菜单",
|
||||
"pinSidebar": "固定侧边栏",
|
||||
"unpinSidebar": "取消固定侧边栏"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -109,7 +109,9 @@
|
||||
"donate": "捐贈",
|
||||
"hosts": "Hosts",
|
||||
"docs": "文件",
|
||||
"openMenu": "開啟選單"
|
||||
"openMenu": "開啟選單",
|
||||
"pinSidebar": "固定側邊欄",
|
||||
"unpinSidebar": "取消固定側邊欄"
|
||||
},
|
||||
"pages": {
|
||||
"login": {
|
||||
|
||||
@@ -291,6 +291,7 @@ const (
|
||||
cadenceNodeHeartbeat = "@every 5s"
|
||||
cadenceNodeTraffic = "@every 5s"
|
||||
cadenceOutboundSub = "@every 5m"
|
||||
cadenceReapOrphans = "@every 5m"
|
||||
cadenceXrayLogPrune = "@every 10m"
|
||||
cadenceCheckHash = "@every 2m"
|
||||
// cpu.Percent samples over a full minute (blocking), so a finer cadence just
|
||||
@@ -336,6 +337,8 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
||||
// Outbound subscription auto-refresh (respects per-sub updateInterval)
|
||||
_, _ = s.cron.AddJob(cadenceOutboundSub, job.NewOutboundSubscriptionJob())
|
||||
|
||||
_, _ = s.cron.AddJob(cadenceReapOrphans, job.NewReapSyncOrphansJob())
|
||||
|
||||
// check client ips from log file every day
|
||||
_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
|
||||
_, _ = s.cron.AddJob(cadenceXrayLogPrune, job.NewPruneXrayLogsJob())
|
||||
|
||||
@@ -85,7 +85,7 @@ func getLogPath(key string) (string, error) {
|
||||
return logPath, nil
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// GetAccessLogPath reads the Xray config and returns the access log file path.
|
||||
@@ -93,7 +93,6 @@ func GetAccessLogPath() (string, error) {
|
||||
return getLogPath("access")
|
||||
}
|
||||
|
||||
// GetErrorLogPath reads the Xray config and returns the error log file path.
|
||||
// GetErrorLogPath reads the Xray config and returns the error log file path.
|
||||
func GetErrorLogPath() (string, error) {
|
||||
return getLogPath("error")
|
||||
|
||||
@@ -71,24 +71,20 @@ func runWebServer() {
|
||||
log.Fatalf("Error initializing database: %v", err)
|
||||
}
|
||||
|
||||
var server *web.Server
|
||||
server = web.NewServer()
|
||||
server := web.NewServer()
|
||||
global.SetWebServer(server)
|
||||
err = server.Start()
|
||||
if err != nil {
|
||||
log.Fatalf("Error starting web server: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var subServer *sub.Server
|
||||
sub.SetDistFS(web.EmbeddedDist())
|
||||
service.RegisterSubLinkProvider(sub.NewLinkProvider())
|
||||
subServer = sub.NewServer()
|
||||
subServer := sub.NewServer()
|
||||
global.SetSubServer(subServer)
|
||||
err = subServer.Start()
|
||||
if err != nil {
|
||||
log.Fatalf("Error starting sub server: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 8)
|
||||
@@ -142,7 +138,6 @@ func runWebServer() {
|
||||
err = server.StartPanelOnly()
|
||||
if err != nil {
|
||||
log.Fatalf("Error restarting web server: %v", err)
|
||||
return
|
||||
}
|
||||
log.Println("Web server restarted successfully.")
|
||||
|
||||
@@ -152,7 +147,6 @@ func runWebServer() {
|
||||
err = subServer.Start()
|
||||
if err != nil {
|
||||
log.Fatalf("Error restarting sub server: %v", err)
|
||||
return
|
||||
}
|
||||
log.Println("Sub server restarted successfully.")
|
||||
case sys.SIGUSR1:
|
||||
@@ -360,7 +354,7 @@ func updateSetting(port int, username string, password string, webBasePath strin
|
||||
if err != nil {
|
||||
fmt.Println("Failed to set listen IP:", err)
|
||||
} else {
|
||||
fmt.Printf("listen %v set successfully", listenIP)
|
||||
fmt.Printf("listen %v set successfully\n", listenIP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,7 +573,7 @@ func main() {
|
||||
fmt.Println()
|
||||
fmt.Println("Commands:")
|
||||
fmt.Println(" run run web panel")
|
||||
fmt.Println(" migrate migrate form other/old x-ui")
|
||||
fmt.Println(" migrate migrate from other/old x-ui")
|
||||
fmt.Println(" migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)")
|
||||
fmt.Println(" setting set settings")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user