Compare commits

..

45 Commits

Author SHA1 Message Date
Sanaei 37c5e0bfd2 feat(node): node hardening — mTLS, hashed+zstd reconcile transport, per-node net metrics (#5382)
* fix(api-docs): document clientIpsByGuid route

Restores a green `go test ./...` baseline: TestAPIRoutesDocumented
flagged POST /panel/api/clients/clientIpsByGuid (added in 9385b6c6)
as undocumented in endpoints.ts.

* test(node): characterize current node TLS + API auth behavior

Phase 0 regression net for the mTLS work. These pass on unchanged
production code and lock the pre-mTLS contracts so later phases can be
proven additive:

- tlsConfigForNode: skip -> InsecureSkipVerify (no VerifyConnection);
  pin -> VerifyConnection installed.
- checkAPIAuth: bearer match -> Next + api_authed; unauthenticated ->
  401 (XHR) / 404; valid session -> Next.
- panel HTTPS listener with no ClientAuth accepts a client that presents
  no client certificate (the browsers-keep-working invariant).

* feat(crypto): node-auth CA + client-cert minting (TDD)

Stdlib-only ECDSA P-256 helpers for the node mTLS work:
- GenerateNodeCA: self-signed CA (IsCA, CertSign, path len 0)
- IssueClientCert: client-auth leaf (ExtKeyUsageClientAuth) signed by CA
- LoadCAFromPEM: parse a CA cert+key for issuing / trust-pool building

Tests assert the contract (leaf verifies against the issuing CA with
ExtKeyUsageClientAuth), seen failing on the assertion before impl.

* feat(node): lazy node mTLS CA + client cert in settings (TDD)

SettingService gains opt-in mTLS material, all stored as Setting rows
with empty defaults and kept out of entity.AllSetting (so private keys
never reach the settings UI/export):
- EnsureNodeMtlsCA: mint+persist the node-auth CA once, reuse thereafter
- EnsureMasterClientCert: issue the master client cert from the CA, idempotent
- NodeMtlsClientCAPool: ClientCAs trust pool for the listener; nil when
  unconfigured so the no-mTLS path is unchanged

Tests assert idempotency and that the client cert verifies against the CA
for client auth; seen failing on the assertion before impl.

* feat(node): mtls client TLS config + master-cert provider (TDD)

tlsConfigForNode gains an 'mtls' branch that presents the master client
certificate and verifies the node server against system roots (no
InsecureSkipVerify, no custom RootCAs). The cert is supplied via an
injected MasterClientCertProvider so runtime need not import service;
it fails closed when unconfigured. skip/pin contracts unchanged.

* feat(node): allow tokenless mtls nodes in remote do() (TDD)

mtls nodes authenticate with a client certificate, so the bearer token
becomes optional for them: do() no longer rejects an empty ApiToken when
TlsVerifyMode is mtls, and the Authorization header is omitted when no
token is set. Every other mode still requires a token (regression kept).

* feat(node): authenticate verified client certs in checkAPIAuth (TDD)

A completed mTLS handshake (non-empty r.TLS.VerifiedChains) now
authenticates an API request, equivalent to a valid bearer token, and
sets api_authed so the CSRF middleware lets cert-authed mutations
through. Bearer/session/reject paths unchanged. The accept-path assert
was mutation-checked (guard flipped -> test red -> reverted).

* feat(node): opt-in mTLS on the panel listener (TDD; mutation-checked)

web.go now applies VerifyClientCertIfGiven + ClientCAs to the HTTPS
listener when a node trust CA is configured, and wires the master client
cert provider for outbound mtls calls. With no CA the listener is
byte-identical to before (browsers unaffected).

applyNodeMtls is covered end-to-end: no-cert client handshakes (browsers
keep working), a CA-signed client cert verifies, a foreign-CA cert is
rejected at the handshake. Mutation-checked:
- RequireAndVerifyClientCert -> no-cert client rejected (red) -> reverted
- drop ClientCAs -> master cert no longer trusted (red) -> reverted

* feat(node): accept mtls verify-mode + CA reveal endpoint (TDD)

- model.Node.TlsVerifyMode validator now accepts 'mtls'
- normalize() preserves mtls and requires the node scheme to be https
  (fail closed), instead of clamping mtls back to verify
- NodeService.NodeMtlsCaCert + POST /panel/api/nodes/mtls/ca return this
  panel's node-auth CA cert (public) to paste into a node, minting the CA
  + master client cert on first call
- endpoints.ts documents the new route (doc-sync test)

No model column added (enum is a string), so no migration/codegen.

* feat(node): node mTLS UI + trust-CA setter (TDD)

Backend:
- NodeService.SetNodeMtlsTrustCA + POST /panel/api/nodes/mtls/trustCA
  store the CA this panel trusts for incoming node-API client certs
  (validates PEM, empty clears); applied on next restart
- endpoints.ts + regenerated openapi.json document both mtls routes

Frontend:
- node form: 'mtls' TLS-verify option + setup hint (zod enum updated)
- Nodes page 'Node mTLS' card: copy this panel's CA, and paste/save the
  trusted parent CA
- en-US i18n keys (other locales fall back to en-US)

Gates green: go build (native+windows), vet, go test ./...; frontend
typecheck, lint, vitest (541).

* style(node): gofmt web_mtls_test doc comment

* feat(node): hashed+zstd reconcile transport (TDD, negotiated, mixed-version safe)

Adds an integrity + compression envelope to node config pushes:
- internal/util/wirecodec: shared zstd codec (bomb-capped decode) +
  SHA-256 hashing + the header/capability constants
- Remote.do(): always attaches X-Config-Sha256 of the uncompressed body;
  zstd-compresses only when the node advertised support (learned from its
  X-3x-Node-Caps response header) and the body is >=1KiB
- ConfigEnvelopeMiddleware on /panel/api: advertises the cap, decompresses
  and verifies the hash (handler not invoked on mismatch) before binding

Mixed-version safe: old nodes never advertise the cap -> plain bodies;
the hash header is verify-if-present so any panel/node mix interoperates
(existing reconcile tests stay green). klauspost/compress promoted to a
direct dep. Hash-mismatch reject was mutation-checked (compare defeated
-> test red -> reverted).

* feat(node): per-node network throughput metrics (TDD)

The node status response already carries gopsutil netIO.up/down (summed
non-virtual interfaces), so no node-side change is needed:
- probe() parses netIO.up/down into HeartbeatPatch.NetUp/NetDown
- Node gains net_up/net_down columns (AutoMigrate); UpdateHeartbeat
  persists them and appends netUp/netDown to the per-node metric history
- NodeMetricKeys whitelists netUp/netDown so the history endpoint serves them
- NodeHistoryPanel renders Net Up/Down sparklines (KB/s, no 0-100 clamp)
- regenerated frontend types + openapi.json for the new Node fields

* feat(node): move node mTLS controls into a toolbar button + modal

The Node mTLS panel was an always-visible card cluttering the nodes
page. Replace it with a 'Node mTLS' button beside 'Add node' that opens
a modal with the same copy-CA + trusted-parent-CA controls; the modal
closes on a successful save. No backend/i18n changes.

* i18n(node): translate mTLS + net-metrics keys for all locales

Adds the node mTLS strings (tlsMtls, mtlsFormHint, mtls.* dialog + the
saveMtls toast) and the netUp/netDown chart labels to all 12 non-English
catalogs (ar, es, fa, id, ja, pt, ru, tr, uk, vi, zh-CN, zh-TW), matching
each catalog's existing terminology. Technical tokens (mTLS/TLS/CA/API/
KB/s) kept verbatim.

* fix(node): address Copilot review on node-hardening PR

- setting_mtls: fail closed on a half-present CA/master-cert pair instead of
  silently regenerating (which would rotate the CA and break fleet trust).
- config_envelope: reject non-zstd Content-Encoding on the envelope path
  rather than hashing/forwarding a still-encoded body to the handler.
- node mTLS: support tokenless mTLS end-to-end — apiToken is now
  required_unless tlsVerifyMode=mtls (model) with matching conditional
  validation in NodeFormSchema, so the runtime allowance is actually reachable.
- NodesPage: add a catch block to onSaveTrustCa so save failures surface.
2026-06-16 12:19:33 +02:00
MHSanaei f3eba04ed8 ci: use .nvmrc for setup-node version in codeql/release workflows 2026-06-15 23:50:05 +02:00
MHSanaei 9385b6c609 feat(nodes): per-node client IP attribution for IP-limit
Record each panel's own Xray IP observations under its panelGuid and merge each node's guid-keyed report on the master, so the panel can tell which node a client IP is connecting through (the flat inbound_client_ips union is pushed back to every node and cannot attribute). Adds the NodeClientIp model + migration, the clientIpsByGuid endpoint and node-sync merge, node-name labels in the client IP log, and cleanup on node deletion.
2026-06-15 23:50:05 +02:00
MHSanaei d882d6aa74 feat(inbounds): add Real client IP presets to capture visitor IP behind CDN/relay
Surface the existing sockopt knobs (acceptProxyProtocol, trustedXForwardedFor) as a guided 'Real client IP' preset selector in the inbound form, so the real visitor IP is recovered behind Cloudflare CDN or an L4 tunnel/relay instead of recording the intermediary address. Presets are mutually exclusive, warn on incompatible transports, and add tooltips, docs, and translations for all locales.
2026-06-15 23:50:04 +02:00
MHSanaei bbab83db17 refactor(frontend): stack client credential fields and use label hints on inbound form
Stack UUID/password/subId/auth/flow/security fields vertically in the client modal instead of two-column rows, and replace the inbound form's 'extra' help lines with hover tooltip hints on field labels.
2026-06-15 21:38:11 +02:00
MHSanaei dc781b28c4 chore(deps): bump telego to v1.10.0 2026-06-15 21:15:38 +02:00
MHSanaei 5b8504c756 chore(deps): bump frontend deps and override js-yaml to patch DoS advisory
Force swagger-ui-react's bundled js-yaml to ^4.2.0 (GHSA-h67p-54hq-rp68)
without downgrading swagger-ui-react. Also picks up minor bumps to antd,
axios, react-router-dom and dev deps.
2026-06-15 21:15:38 +02:00
MHSanaei c1fdcd98d2 fix(nodes): route 'load inbounds' through the connection outbound
Loading a node's inbound list bypassed the configured connection
outbound and dialed the remote panel directly, so a node only reachable
through that outbound timed out with 'context deadline exceeded' even
though Test Connection succeeded.

Extract the temporary loopback SOCKS5 bridge setup from ProbeWithOutbound
into a shared withOutboundBridge helper and route GetRemoteInboundOptions
through it when an outbound tag is set.
2026-06-15 21:13:27 +02:00
Sentiago eec030f86f feat(notifications): event bus architecture with Telegram and SMTP subscribers (#5326)
* feat(notifications): event bus architecture with Telegram and SMTP subscribers

- Event bus core with buffered channel, fan-out, panic recovery
- Telegram subscriber with HTML formatting and rate limiting
- Email subscriber with SMTP/TLS/STARTTLS support and stage diagnostics
- 5 event types: outbound.down/up, xray.crash, cpu.high, login.attempt
- CPU threshold checks per subscriber (tgCpu for TG, smtpCpu for Email)
- SystemMetricData struct for raw metric values in events
- i18n keys for en-US, ru-RU, and English defaults for other locales

* fix

* fix(notifications): repair crash/CPU alerts, harden secrets, add node alerts

Bug fixes:
- Xray crash notifications were permanently suppressed after the first crash:
  XrayStateTracker latched state="down" with no reset and no recovery event,
  so only the first crash per process lifetime ever notified. Removed the
  tracker; the existing 1/min rate limiter already dedupes crash-loop spam.
- Email CPU alerts could never fire unless Telegram was also enabled, because
  the CPU job was registered only inside the tgbot block. Register it whenever
  either Telegram or SMTP wants cpu.high (new cpuAlarmWanted gate) and relax
  the cadence to @every 1m (cpu.Percent already samples over a full minute).
- SMTP password (and, pre-existing, all other secrets) were shipped to the
  browser in plaintext: GetAllSettingView was dead code and /setting/all
  returned the raw model. Wire getAllSetting -> GetAllSettingView, redact
  smtpPassword with a hasSmtpPassword presence flag, and preserve it on blank
  save. Closes the leak for tgBotToken/ldapPassword/2FA token too.

Polish:
- email Send: use nil SMTP auth when no credentials (Go refuses PlainAuth over
  the unencrypted "none" transport).
- Remove unused EventClientDepleted; fix inaccurate bus.go doc comments; drop
  stale tgBotLoginNotify from the frontend schema; gofmt alignment.

Feature - node online/offline alerts:
- Emit node.down/node.up from the heartbeat job on a real status transition
  (with a startup-spam guard), reusing NodeHealthData. Formatted by both the
  Telegram and email subscribers and selectable in the settings UI.

Regenerated frontend types (hasSmtpPassword). New i18n keys added to en-US;
other locales fall back to English (bundle default) until translated.

* fix(settings): use antd Space orientation instead of deprecated direction

Ant Design 6 deprecated Space's `direction` prop in favor of `orientation`,
which logged a console warning from the Telegram/Email notification tabs. Brings
these two tabs in line with the rest of the codebase, which already uses
`orientation`.

* i18n(notifications): translate the notification feature into all locales

The notifications PR shipped ~99 new strings (SMTP settings, event labels,
Telegram/email message templates) as English placeholders in every non-English
locale. Translate them — plus the node-alert keys added during this review —
into all 12 locales: Arabic, Spanish, Persian, Indonesian, Japanese,
Portuguese-BR, Russian, Turkish, Ukrainian, Vietnamese, and Simplified/
Traditional Chinese.

Go-template placeholders ({{ .Tag }}, {{ .Name }}, etc.) are preserved exactly;
tgbot message values carry no leading status emoji (the bot/email code adds
those, so an emoji in the value would duplicate it); product/protocol names
(SMTP, STARTTLS, TLS, CPU, Xray, Telegram) are kept as-is.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 21:03:41 +02:00
MHSanaei 7fe082a7f1 fix(nodes): stop multi-attached client traffic inflating across node inbounds
Xray counts client traffic globally per email, so a client attached to
several of a node's inbounds has its single shared counter copied onto
every inbound by the node's enriched inbound list. When those copies
diverge (legacy per-inbound rows surviving a v3.2.x->v3.3.x upgrade, or
any drift) the per-inbound delta loop read the lower sibling as a
node-counter reset and re-added its full value, inflating the client far
past real usage (#5274).

Fold each email to its per-field node-wide max before the delta loop so
every occurrence is equal: the per-email baseline dedup then holds and
the reset clamp never misfires.
2026-06-15 19:31:57 +02:00
MHSanaei f7ffe89813 fix(outbound): preserve non-ASCII characters in imported subscription tags (#5354)
SlugRemark stripped every non-ASCII character, so tags generated from
remarks like Cyrillic names collapsed to just their digits, making
imported outbounds hard to identify. Keep Unicode letters and digits in
the slug regex while still collapsing punctuation into dashes.
2026-06-15 19:16:57 +02:00
MHSanaei c1fbfd0510 fix(outbound): parse xmux from imported share links (#5353)
The inbound link generator bundles xmux and downloadSettings as nested
objects inside the `extra=` JSON blob, but the outbound link parser only
pulled scalar fields and headers from it, silently dropping xmux on
import. Extract the nested objects too so they round-trip into the
outbound XMUX sub-form.
2026-06-15 19:12:47 +02:00
MHSanaei cbb21b7575 fix(nodes): propagate single-client deletion to remote nodes (#5352)
Deleting a client attached to a remote-node inbound could silently fail
to reach the node, so the node's next traffic snapshot resurrected the
client once the 90s delete tombstone expired.

Two paths in the single-client delete (Delete -> DelInboundClientByEmail):

- A disabled client was skipped entirely: the node-propagation and
  mark-dirty block sat behind the client's enable flag (needApiDel), so a
  disabled client on a node never detached and never marked the node
  dirty. The bulk and multi-client delete paths already handle the node
  case independently of enable state; mirror that structure here.

- Remote.DeleteUser returned nil when resolveRemoteID failed, hiding the
  failure from the caller so the node was never marked dirty. Surface the
  error like AddClient/UpdateUser do, so the caller marks the node dirty
  and the next reconcile converges.

Add a regression test asserting a disabled node client's deletion marks
the node dirty.
2026-06-15 17:56:12 +02:00
MHSanaei cf5f37e409 fix(iplimit): ban UDP as well as TCP in fail2ban action (#5350)
The generated 3x-ipl fail2ban action only matched -p tcp, so UDP-based
inbounds (Hysteria2, TUIC, WireGuard) from a banned IP kept working,
bypassing IP-limit enforcement. Drop the protocol qualifier from the
chain jump and ban both tcp and udp, keeping the SSH/panel port exemption.
2026-06-15 17:34:23 +02:00
MHSanaei 0d87bb8b4b fix(inbounds): flag conflicts with the reserved Xray API port (#5304)
The internal API inbound (tag "api", default port 62789 on 127.0.0.1) lives in
the Xray config template, not the inbounds table, so checkPortConflict never
caught a local user inbound reusing it — Xray then bound the port twice and
served requests unpredictably. Now reject a local TCP inbound whose listen
overlaps loopback on the reserved API port, read from the template (fallback
62789). Nodes are unaffected since they run their own Xray.
2026-06-15 17:21:06 +02:00
MHSanaei f00512d12e fix(frontend): TProxy schema, VLESS+XHTTP flow links, clearable Jalali date picker (#5339, #5322, #5313)
- #5339: accept transportless tunnel/TProxy streamSettings that carry no
  `security` key by adding a transportless branch to SecuritySettingsSchema,
  mirroring NetworkSettingsSchema. Fixes "streamSettings.security Invalid input".
- #5322: emit XTLS Vision `flow` in panel VLESS share links for XHTTP+vlessenc
  via the shared canEnableTlsFlow predicate, so panel links match the form and
  the subscription output.
- #5313: give the Jalali expiry date picker a working clear (X) button
  (remount on clear, since the library reads `value` only on mount) and a blank
  placeholder instead of the library's hardcoded Persian text.
2026-06-15 17:20:54 +02:00
nima1024m cdaf5f80db fix(inbound): strip XHTTP client-only fields from xray config, keep for subscriptions (#5349)
Inbound XMUX and other client-side xHTTP knobs were written into
bin/config.json even though xray-core's server listener ignores them.
Strip them in GenXrayInboundConfig while leaving the DB row intact so
buildXhttpExtra still pushes defaults to clients via share links.
2026-06-15 16:35:43 +02:00
n0ctal ac8cb505d1 fix(subscriptions): avoid shared mutable state during generation (#5270)
* fix(subscriptions): avoid shared mutable state during generation

* fix(subscriptions): serve external-link-only subs in JSON/Clash; load remark settings per request

The ForRequest refactor added an early `len(inbounds) == 0` return to
GetJson/GetClash that fired before external links were fetched, so a
subscription whose only entries are external links (or whose inbounds are
all disabled) rendered empty in the JSON and Clash formats. Drop the
premature check — the existing inbounds+externalLinks empty guard already
covers the truly-empty case.

Also load datepicker/emailInRemark in PrepareForRequest rather than only in
getSubs, so JSON and Clash remarks honor these settings instead of seeing
the zero values (emailInRemark previously depended on the shared-state leak
this PR fixes).

Add a regression test covering an external-link-only sub across both formats.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 16:23:47 +02:00
n0ctal 71616b7cf2 feat(web): cap request body size on state-changing routes (#5271)
* feat(web): cap request body size on state-changing routes

* fix(web): exempt importDB from request body size cap

The 10 MiB body cap was applied globally, which would break database
restore (/panel/api/server/importDB) on any panel whose SQLite backup
exceeds the limit. Make MaxBodyBytes accept exempt path suffixes and
pass importDB through uncapped; the cap still covers all other
state-changing routes. Add a test for the skip-suffix behavior.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 16:04:16 +02:00
Rouzbeh† 628406117e fix(nodes): sync "start after first connect" expiry so un-activated nodes do not reset it (#5319)
* fix(nodes): stop un-activated nodes from resetting "start after first connect" expiry

In a multi-node setup a client is attached to inbounds on several nodes, but
its `client_traffics` row is shared per-email (the column is `gorm:"unique"`).
With "start expiry after first connect", the expiry is stored as a negative
duration and each node converts it to an absolute deadline (now+duration) the
first time the client connects *there*.

The master's per-node traffic merge wrote `expiry_time = ?` unconditionally for
every node sync. So a node where the client never connected keeps reporting the
un-activated negative duration and clobbers the absolute deadline that the node
where the client *did* connect had already activated — last writer wins. The
shared row flip-flops and usually lands back on the negative value, so the main
panel shows the timer "not started" while the active node counts down, and the
subscription (which reads this row and recomputes negative as now+duration on
every fetch) reports a perpetually-resetting, wrong expiry and usage.

Guard the merge so an un-activated (<= 0) value reported by a node can never
reset an already-activated absolute deadline. A positive node value is still
adopted, so a node that legitimately moves the deadline forward (traffic reset /
auto-renew) still propagates. The rule lives in both the SQL CASE used by the
merge and a small `mergeActivationExpiry` helper (kept in lockstep) that the
structural-change check reuses so the guard does not trigger spurious config
re-pushes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(nodes): cast expiry merge params to BIGINT for Postgres

The "start after first connect" merge guard introduced the comparison
`? <= 0` in the client_traffics expiry_time CASE. There Postgres infers
the parameter type as int4 from the literal 0, so binding a real expiry
value — a negative start-after-connect duration or a positive absolute
deadline (~1.7e12 ms) — overflows int4 and the whole setRemoteTrafficLocked
transaction fails, breaking node traffic and expiry sync on Postgres.
SQLite (dynamic typing) was unaffected.

Wrap both params in CAST(? AS BIGINT) (portable across SQLite and
Postgres) so the parameter is typed bigint, matching the explicit casts
the sibling GreatestExpr/ClientTrafficEnableMergeExpr helpers already use.

Verified against Postgres 16: TestNodeFirstConnectExpiry_NotClobbered
failed before this change and passes after; SQLite suite unchanged.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 15:46:19 +02:00
Sanaei 7605902324 Test-quality audit: fix 2 prod bugs, strengthen weak tests, add mutation/fuzz/CI tooling (#5345)
* test(audit): add gremlins/rapid/coverage tooling + AUDIT.md scaffold

* test(audit): hygiene sweep (race-clean except logger global; Finding #2) + smell inventory

* test(audit): cover untested error/edge branches (TLS proxy+pin, migration tag cleanup=Finding #1)

* test(audit): strengthen internal/sub link tests (dedup key, TLS/Reality mapping, clash well-formedness)

* test(audit): property (rapid) + fuzz tests for joinHostPort/userinfo/pin/ParseLink

* test(audit): tighten frontend subSortIndex rejection assertions + wire coverage

* ci(audit): add shuffle gate + non-blocking race job (Finding #2) + fuzz-smoke; document mutation policy

* chore(audit): gitignore frontend coverage output

* test(audit): exhaustive whole-repo pass — strengthen 5 weak/fake tests (netproxy, CSP, modal per-protocol loops, schema coercions)

* docs(contributing): add Testing section (conventions, race/shuffle, fuzz, mutation policy); drop AUDIT.md ledger

* fix(logger,migration): guard logBuffer with mutex; execute legacy tag cleanup (tx.Exec); make CI race gate blocking

* ci(mutation): add nightly scoped gremlins workflow (informational artifacts)

* test(audit): strengthen runtime tests — baseURL scheme/port bounds, isNonEmptySlice, trafficReset

* test(audit): strengthen clash tests — reality field mapping + tcp-header validation

* test(audit): runtime — egress-proxy + content-type tests; drop redundant bp=='' branch

* test(audit): strengthen link parser/helper tests (defaultPort, splitComma, base64, canonicalQuery, tls/reality/transport mapping)

* test(audit): strengthen sub/xray/common/netsafe/mtproto/config/middleware tests (kill surviving mutants)

* test(audit): raise timeout on protocol-iteration modal tests (heavy re-renders, slow on CI)

* fix(logger): GetLogs returns at most c entries (off-by-one fix; addresses PR review)

* perf(logger): snapshot logBuffer under lock so GetLogs doesn't block logging; clarify fuzz-seed docs (addresses PR review)
2026-06-15 15:17:03 +02:00
tonymoses10 b5872af279 Frontend operation button size optimization (#5343)
* Update ClientsPage.tsx

* Update GroupsPage.tsx

* Update RowActions.tsx

* Update NodeList.tsx

* Update BalancersTab.tsx
2026-06-15 14:26:51 +02:00
Abdalrahman 53f6ed394f Add Enable/Disable Toggle for Xray Routing Rules (#5296)
* feat: add enable/disable toggle for xray routing rules

* fix(routing): never let the internal api rule be disabled

The Enable/Disable toggle could strip the stats api rule: its table
switch was locked, but the rule-form modal's Enable dropdown was not,
and stripDisabledRules had no api-rule guard (EnsureStatsRouting's
delete only runs when the api rule isn't already first). A disabled
api rule then dropped out of the generated config and broke traffic
accounting.

- stripDisabledRules now always keeps the api rule, even if marked
  disabled, and strips the panel-only enabled key from every rule
- extract isApiRule helper (backend + frontend) and reuse it across
  the table switch, card switch, and form modal
- disable the form-modal Enable dropdown for the api rule
- add stripDisabledRules tests covering the api-rule survival path

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 00:43:49 +02:00
Volov Vyacheslav 66a9a788fc fix(reality): load dest as target alias so existing inbounds aren't wiped (#5295)
xray-core accepts both `target` and `dest` for the REALITY destination
(infra/conf/transport_internet.go: REALITYConfig has json:"target" and
json:"dest"). The frontend schema only knows `target`, so an inbound whose
realitySettings use `dest` — older panel builds, external tools, or the
panel's own /panel/api/inbounds API — loads with an empty (required) Target
field even though xray is running fine. Re-saving then serializes the blank
`target` and drops the working `dest`, breaking REALITY on the next restart.

Normalize `dest` -> `target` on parse (z.preprocess) when `target` is
absent/empty, matching xray-core's alias behavior. Add unit tests covering
the schema directly and through the security discriminated union.

Co-authored-by: Volov <volovdata@google.com>
2026-06-15 00:25:10 +02:00
Rouzbeh† dab0add191 feat(finalmask): support Salamander packetSize (Gecko) and Realm tlsConfig for Hysteria2 (#5278)
* feat(finalmask): support salamander packetSize (Gecko) and realm tlsConfig

Hysteria v2.9.1/v2.9.2 added two finalmask features that the pinned
Xray-core (26.6.1, 94ffd50) already supports but the panel UI did not
expose: Salamander's packetSize range (Gecko, XTLS/Xray-core#6198) and
the Realm UDP hole-punching mask's optional tlsConfig (XTLS/Xray-core#6137).

Add typed schemas and form fields for both, keeping UdpMaskSchema.settings
permissive per the existing finalmask design note. packetSize reuses the
existing dash-range preprocess (like udpHop.ports) so it round-trips under
the fm= share-link param with no new URI key; realm tlsConfig emits xray's
flat TLSConfig shape (serverName/alpn/fingerprint/allowInsecure).

Verified against the bundled Xray 26.6.1: configs with packetSize and
realm tlsConfig validate (Configuration OK.), plain salamander stays
backward-compatible, and a malformed packetSize is correctly rejected by
the salamander mask builder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(finalmask): add snapshots for salamander-gecko and realm-tls fixtures

vitest run does not auto-create missing snapshots in CI mode, so the two
new fixtures need committed snapshot entries. Verified under node:22 that
finalmask.test.ts passes (6/6) with these snapshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(finalmask): polished Gecko UX with core-grounded validation

Fold PR #5281's Gecko work into the Realm tlsConfig base:

- Replace the plain packetSize input with a Salamander/Gecko mode
  selector and validated Min/Max number inputs.
- parseGeckoPacketSize enforces xray-core's real bound
  (1 <= min <= max <= 2048, the gecko buffer size) so the panel
  rejects configs core would reject at runtime.
- Accurate Gecko description; add parser unit tests.
- Drop the unused Salamander/Realm settings schemas; settings stay
  permissive and are validated at the form level.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 00:21:31 +02:00
Nikan Zeyaei 7c737820d1 fix(links): bracket ipv6 hosts in share links and qr codes (#5310)
* fix(sub): bracket ipv6 hosts in share links

* fix(frontend): bracket ipv6 hosts in share links
2026-06-14 23:38:58 +02:00
MHSanaei 335470607f fix(ui): match node connection-outbound picker to panel-outbound selector
Group the tags into Outbounds/Balancers, hide blackhole outbounds, and show
the 'Direct connection' placeholder on empty via getValueProps so the field
never looks unset and an empty default can't read as a second 'direct'.
2026-06-14 23:25:37 +02:00
Nikan Zeyaei 05ad7f417c feat(node): per node outbound routing (#5275)
* feat: add per-node outbound routing for panel-to-node connections

* feat(ui): add outbound tag selector to node form with i18n

* fix(xray): avoid potential overflow warning in node egress rule allocation

* chore: run "npm run gen"

* fix

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-14 23:10:52 +02:00
n0ctal 2188830612 perf(db): index group_name and client_traffics hot columns (#5268)
* perf(db): index group_name and client_traffics hot columns

* fix

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-14 22:54:59 +02:00
n0ctal d14f341b21 refactor(web): centralize background job cadences (#5269) 2026-06-14 22:50:24 +02:00
Nikan Zeyaei f4bbaf40f0 feat(ui): show per-inbound live speed (#5261)
* feat(utils): add speedFormat utility and tests

* feat(inbounds): add InboundSpeedEntry type

* feat(inbounds): add speed column to inbound list

* feat(inbounds): show speed in inbound stats modal

* feat(inbounds): compute inbound speed from traffic deltas

* feat(inbounds): wire inbound speed through page

* feat(i18n): add speed translation for all locales

* refactor(inbounds): dedupe live-speed UI and harden formatting

Extract a shared InboundSpeedTag component and isActiveSpeed guard used by the speed column and stats modal, unify InboundSpeedEntry into a single type, and route speedFormat through sizeFormat.

Also guard sizeFormat against non-finite input (no more "NaN PB/s") and clear stale per-inbound speeds when a traffic poll returns no deltas.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-14 22:39:40 +02:00
MHSanaei 1c75034957 ci(smoke): retry transient GitHub download failures
The first-boot smoke test and install.sh fetched the released binary with
a single curl attempt, so a transient GitHub/CDN 504 failed the whole job.

- smoke-firstboot.sh: add --retry/--retry-all-errors with connect/max
  timeouts to the version API and tarball downloads, split the download
  into a guarded step, and assert the tarball is non-empty.
- install.sh: add --retry plus connect/max timeouts to the release-binary
  downloads and version lookups. Omit --retry-all-errors here for curl
  < 7.71 (Ubuntu 20.04 / Debian 10 / CentOS 7) compatibility; plain
  --retry already covers 504 and other transient errors.
2026-06-14 21:17:59 +02:00
Pavel 7f34c306d7 feat(docker): support XUI_PORT runtime override (#5240)
* feat(docker): support XUI_PORT runtime override

Allow deployments to select the panel listener port without mutating the persisted webPort setting. Invalid values fall back to the database-backed port and are covered by parser boundary tests.

* docs: describe XUI_PORT deployment usage

Add commented local and Compose examples, explain runtime precedence, and call out matching Docker bridge port mappings.
2026-06-14 21:15:08 +02:00
MHSanaei a133282fc3 ci(smoke): set least-privilege GITHUB_TOKEN permissions
Add a top-level `permissions: contents: read` block so the smoke-test
workflow no longer inherits the repository default token permissions.
Resolves CodeQL actions/missing-workflow-permissions.
2026-06-14 21:09:00 +02:00
MHSanaei dcb923b4a1 feat(sub): per-client external links and remote subscriptions
Add a Links tab to the client form for attaching third-party share
links and remote subscription URLs per client. They are merged into
the client's raw/JSON/Clash subscription output: links are emitted
verbatim and parsed for JSON/Clash; subscription URLs are fetched
(cached, with a short timeout) and their configs merged in.

i18n keys added across all 13 locales.
2026-06-14 20:57:14 +02:00
Sanaei 7c2598fae9 feat: release-driven golden-image & unattended-install deployment pipeline (#5323)
* feat(install): add non-interactive install path for cloud/golden-image use

Trigger non-interactive mode when XUI_NONINTERACTIVE=1 or stdin is not a
TTY (curl | bash, cloud-init). Every prompt is then replaced by an env var
or a sane default; interactive prompts stay byte-for-byte identical.

Honored env vars: XUI_USERNAME, XUI_PASSWORD, XUI_PANEL_PORT,
XUI_WEB_BASE_PATH (unset => random, as before), XUI_SSL_MODE=none|ip|domain
(default none), XUI_DOMAIN, XUI_ACME_EMAIL, XUI_DB_TYPE/XUI_DB_DSN, plus
additive XUI_ACME_HTTP_PORT, XUI_SSL_IPV6, XUI_SERVER_IP.

On success, write /etc/x-ui/install-result.env (mode 600) with the panel
creds + access URL + api token, in both interactive and non-interactive
modes, so cloud-init/MOTD can surface them. Postgres in non-interactive
mode requires XUI_DB_DSN or installs locally; never silently downgrades.

* feat(deploy): add first-boot per-instance credential generation

Golden images ship with no x-ui.db. x-ui-firstboot.sh runs once (guarded by
/etc/x-ui/.firstboot-done), before x-ui.service, and replaces the seeded
admin/admin with fresh random username/password on a random high port,
regenerates the session secret/panel GUID via 'x-ui setting -reset', mints an
API token, and writes the creds to /etc/x-ui/credentials.txt (600) + /etc/motd.

Idempotent: skips regeneration if a non-default admin already exists. The
oneshot unit is ordered After=network-online/cloud-init and Before=x-ui.service
so the panel never serves default credentials.

* chore(deploy): force LF for cloud-image deploy assets (.service/.hcl/.yaml)

* feat(deploy): add Packer config + provisioning scripts for golden image

One build, two sources: amazon-ebs (AWS AMI, Canonical Ubuntu 24.04 base via
source_ami_filter) and qemu (qcow2 + raw, NoCloud-seeded for build-time SSH).
Provisioner order is fixed: provision.sh -> harden.sh -> cleanup.sh.

- provision.sh: downloads the released x-ui tarball (no Go build), installs the
  panel + firstboot unit, enables but does NOT start services, creates NO DB.
- harden.sh: key-only SSH, no root password login, locks default account
  passwords, enables unattended-upgrades (scanner-compliant).
- cleanup.sh: wipes any DB/creds, SSH host keys, authorized_keys, machine-id,
  cloud-init state, logs and history; fails the build if any secret survives.

packer fmt -check clean; packer validate passes for both sources.

* feat(deploy): add generic cloud-init user-data for unattended install

cloud-init.yaml installs the latest 3x-ui non-interactively (XUI_NONINTERACTIVE=1)
on any cloud-init platform, generating unique per-instance credentials and
surfacing them via /etc/x-ui/install-result.env, serial console and MOTD.
README documents per-provider usage (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
and all XUI_* knobs.

* ci: add image.yml to build cloud images on release

On release: published (or workflow_dispatch with a tag), waits for the
x-ui-linux-amd64.tar.gz asset (handles the release-matrix upload race), then:
- qemu-image (always): builds the qcow2 with Packer and attaches a compressed
  .qcow2.xz + sha256 to the GitHub release. Uses KVM when /dev/kvm exists,
  else TCG.
- ami-image (gated): builds the AWS AMI only when AWS creds exist (OIDC role
  preferred, else access keys), so forks skip cleanly. Prints the AMI ID to the
  job summary. No secrets or AMI IDs are committed.

* test(deploy): add container smoke tests for install + firstboot

smoke-noninteractive.sh: runs install.sh piped (no TTY) with
XUI_NONINTERACTIVE=1 in an Ubuntu container; asserts install-result.env (600)
holds random non-default creds, hasDefaultCredential is false, and the panel
serves HTTP.

smoke-firstboot.sh: installs the released binary with no DB, runs
x-ui-firstboot.sh; asserts per-instance creds + credentials.txt (600) + MOTD,
no admin/admin, and that a second run is a no-op (sentinel honored).

smoke.yml runs both as gated jobs on PRs/pushes touching install.sh or deploy/**.
Both pass locally against the v3.3.1 release binary.

* docs(deploy): add Packer/marketplace docs and link from README

- deploy/README.md: index of the cloud-deploy tooling and the two models
- deploy/packer/README.md: how to build locally, variables, first-boot behavior
- deploy/marketplace/aws/README.md: seller registration -> AMI scan ->
  limited-visibility preview -> go-public checklist
- deploy/marketplace/hetzner/README.md: cloud-init-first guidance + snapshot
  caveat (delete x-ui.db first) + hetznercloud/apps reference
- README.md: link the unattended-install / cloud-image docs from Quick Start

* feat(deploy): build golden images for arm64 as well as amd64

The install path was already multi-arch (install.sh auto-detects arch); this
extends the golden image + CI to arm64:

- packer: xui_arch (amd64|arm64, validated) now derives the base AMI filter and
  the Ubuntu cloud image; the qemu source switches to qemu-system-aarch64 + virt
  machine + AAVMF UEFI firmware for arm64. amd64 path unchanged.
- image.yml: arch matrix. AMIs for amd64 (t3.small) + arm64 (t4g.small/Graviton)
  from one runner; qcow2 for amd64 on a standard runner and arm64 on a native
  ubuntu-24.04-arm runner. Waits for both release tarballs.
- smoke.yml: run install + firstboot smoke tests on amd64 and arm64 runners;
  smoke-firstboot.sh now resolves the arch tarball via dpkg.
- docs updated for both arches.

packer fmt/validate pass for amd64 and arm64; actionlint + shellcheck clean.
Verified locally: non-interactive install AND firstboot run on the real arm64
release binary under emulation (ELF aarch64, no admin/admin).

* chore(deploy): default AWS region to eu-central-1 (Frankfurt)

Replace the us-east-1 fallback in image.yml (4 sites) and the Packer 'region'
default + doc examples. Still overridable via the AWS_REGION repo variable / the
-var 'region=...' flag.

* feat(deploy): add Amazon Lightsail support (launch script + snapshot builder)

Lightsail can't launch from an EC2 AMI and its blueprint list isn't
self-publishable, so add the two self-service paths instead:

- launch-script.sh: paste into Lightsail 'Add launch script' (or --user-data) to
  install 3x-ui non-interactively with unique per-instance credentials.
- snapshot-userdata.sh + build-snapshot.sh: AWS CLI pipeline that provisions a
  build instance (panel installed, NO DB, firstboot enabled), runs the shared
  cleanup.sh, then snapshots it. Instances launched from the snapshot mint their
  own credentials on first boot. Optional --panel-port pins a known port for the
  Lightsail firewall.
- README documents both paths, the firewall caveat, and the blueprint reality.

EC2 AMI / Marketplace path kept untouched alongside. All scripts shellcheck-clean.

* fix(deploy): address Copilot PR review findings

- install.sh + firstboot: write install-result.env / credentials.txt values with
  printf %q so the files stay safe to source even if creds are pinned with shell
  metacharacters (no-op for the alphanumeric random defaults).
- firstboot: fail closed if 'x-ui setting -show' can't be parsed to true/false —
  exit without writing the sentinel so the next boot retries, instead of silently
  skipping regeneration and risking admin/admin.
- firstboot + cloud-init + lightsail launch-script: keep secrets out of the
  world-readable /etc/motd (show URL + username only; full creds via the mode-600
  file / serial console).
- lightsail build-snapshot: handle download-default-key-pair returning either a
  PEM or base64, and assert a valid PEM before using it for SSH.
- image.yml: pin hashicorp/setup-packer@v3 (was @main).
- deploy/README: document XUI_ACME_HTTP_PORT / XUI_SSL_IPV6 / XUI_SERVER_IP.

Both container smoke tests still pass; shellcheck + actionlint clean.
2026-06-14 18:08:35 +02:00
MHSanaei 1c0fdb4527 fix(outbounds): test subscriptions in Test All, skip direct/dns
Test All only iterated the editable template outbounds, so subscription
outbounds (the read-only "from subscriptions" table) were never probed in
bulk. They are now queued too, keyed by tag in subscriptionTestStates so
their rows light up live; the template and subscription HTTP lanes run
serially to respect the backend's single-batch lock (TCP runs alongside).

Also stop testing freedom ("direct") and dns outbounds: they aren't
proxies, so an HTTP probe through them only measures the host's own
reachability, not a tunnel. They are now untestable in every mode -- the
per-row button is disabled and Test All skips them -- with a matching
backend guard so a direct API caller can't HTTP-test them either.
2026-06-13 11:48:02 +02:00
MHSanaei 2d6dea4bf6 fix(settings): rename remark model 'Other' to 'External Proxy' (#5265)
The 'o' remark block is sourced from an external proxy's remark, but the
label 'Other' gave no hint where to set it. Rename the display label to
'External Proxy' to match the inbound form section; the stored 'o' key is
unchanged so existing remarkModel values stay compatible.
2026-06-13 11:14:22 +02:00
MHSanaei 4c8d3cb625 fix(nodes): honor TLS verify mode skip/pin for remote node operations (#5264)
The node probe honored the per-node TlsVerifyMode (skip/pin) but
runtime.Remote used a shared client with no TLSClientConfig, so traffic
sync and every other remote op fell back to system-CA verification and
failed against self-signed nodes even after the operator set skip/pin.

Move the TLS client builder into the runtime layer (HTTPClientForNode /
DecodeCertPin) as the single source of truth, have Remote build and cache
its per-node client through it, and delegate the service probe to the same
builder so the two paths can no longer diverge.
2026-06-13 11:11:02 +02:00
MHSanaei 9a8247fa78 fix(tgbot): clear legacy panelProxy/tgBotProxy settings on upgrade
v3.3.1 removed the Panel Proxy URL field from the UI but left the stored
panelProxy/tgBotProxy values in the DB. The Telegram bot still reads
tgBotProxy directly, so a stale value masked the panelOutbound egress
fallback. Add a one-off seeder to drop both rows.

Closes #5266
2026-06-13 10:56:02 +02:00
MHSanaei 355262e632 fix(clients): keep the client list live with a background poll (#5262)
The paged client list is sorted/paginated server-side but fetched with staleTime: Infinity, so the WS client_stats patch only refreshed traffic on already-visible rows — newly connected clients never appeared and the sort order went stale until a manual refresh.

Add a 5s refetchInterval so the current page tracks reality, and drive the table overlay off isPlaceholderData so the background poll does not flash it.
2026-06-13 10:42:24 +02:00
MHSanaei 8f556fe2db fix(clients): centre the online dot inside the Online tag (#5238)
The .online-dot uses vertical-align: middle, which in inline layout
aligns to baseline + half x-height — visibly off-centre inside the Ant
Tag's line box. Add a .dot-tag utility (inline-flex, align-items:
center) and apply it to the Online tag so the dot and label share one
centred axis. Other dot usages (Nodes page Space, card heads, stat
rows) already sit in flex containers and are unaffected.
2026-06-13 01:19:19 +02:00
MHSanaei b770287995 fix(sub): stop appending the node name to subscription remarks (#5231)
The #5035 change tagged node-hosted entries with the node name to
disambiguate multi-node subscriptions, but the node name is
panel-internal and leaked into the profile names end users see in
their client apps. Drop the suffix entirely — remarks are the
admin-set inbound remark again.
2026-06-12 22:35:41 +02:00
MHSanaei 3c68b039f6 fix(sub): deliver vision flow for VLESS+XHTTP+REALITY in share links and Clash (#5232)
The vlessenc fix (#5185) enabled flow on XHTTP only in the security=none
branch of genVlessLink, and the Clash builder still gated flow on
network==tcp. With XHTTP+REALITY+vlessenc the panel accepts and stores
the flow (inboundCanEnableTlsFlow passes), but subscriptions dropped it,
so clients received configs without xtls-rprx-vision.

Add vlessFlowAllowed mirroring inboundCanEnableTlsFlow — tcp with
tls/reality, or xhttp with vlessenc regardless of security layer — and
use it in both the vless:// link generator and the Clash proxy builder.
2026-06-12 22:25:04 +02:00
MHSanaei c200e248f7 fix(script): report per-file geo update status and skip restart when nothing changed
Updating geo files printed raw curl progress meters showing 0 bytes when
files were already current (curl -z conditional download), claimed success
unconditionally, and restarted xray even when nothing was downloaded —
confusing enough to be reported as a bug (#5230).

Now each file reports updated / already up to date / download failed,
failures no longer print a success message, and the restart (which drops
live connections) only happens when a file actually changed. Same for the
non-interactive 'x-ui update-all-geofiles' command.
2026-06-12 22:18:42 +02:00
244 changed files with 16540 additions and 1294 deletions
+1
View File
@@ -3,3 +3,4 @@ XUI_DB_FOLDER=x-ui
XUI_LOG_FOLDER=x-ui
XUI_BIN_FOLDER=x-ui
XUI_INIT_WEB_BASE_PATH=/
# XUI_PORT=8080
+7 -1
View File
@@ -3,4 +3,10 @@ DockerInit.sh text eol=lf
DockerEntrypoint.sh text eol=lf
frontend/src/generated/** text eol=lf
frontend/public/openapi.json text eol=lf
frontend/src/test/__snapshots__/** text eol=lf
frontend/src/test/__snapshots__/** text eol=lf
# Cloud-image deploy assets are consumed on Linux — force LF regardless of host.
*.service text eol=lf
deploy/**/*.service text eol=lf
deploy/**/*.hcl text eol=lf
deploy/**/*.yaml text eol=lf
+34 -1
View File
@@ -35,7 +35,7 @@ jobs:
- name: Test
run: |
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test $(cat /tmp/go-packages.txt)
go test -shuffle=on -count=1 $(cat /tmp/go-packages.txt)
codegen:
runs-on: ubuntu-latest
@@ -69,6 +69,39 @@ jobs:
- name: Run govulncheck
run: govulncheck ./...
# Race + shuffle hygiene gate: data races and order-dependent tests fail the build.
race:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub internal/web/dist for go:embed
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
- name: Race + shuffle
run: |
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test -race -shuffle=on -count=1 $(cat /tmp/go-packages.txt)
# Brief native-fuzz smoke on the security-/parser-critical decoders. Each runs the
# generated corpus plus 30s of exploration; a crash here is a real input-handling bug.
fuzz-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub internal/web/dist for go:embed
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
- name: Fuzz critical parsers (smoke)
run: |
go test -run '^$' -fuzz 'FuzzParseLink$' -fuzztime=30s ./internal/util/link/
go test -run '^$' -fuzz 'FuzzDecodeCertPin$' -fuzztime=30s ./internal/web/runtime/
frontend:
runs-on: ubuntu-latest
steps:
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
if: matrix.language == 'go'
uses: actions/setup-node@v6
with:
node-version: '22'
node-version-file: .nvmrc
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
+260
View File
@@ -0,0 +1,260 @@
name: Build Cloud Images
# Build golden cloud images from a published release, for amd64 and arm64:
# * qemu -> qcow2 attached to the GitHub release (always)
# * amazon-ebs -> AWS AMI (only when AWS credentials are configured)
#
# Images contain NO database and NO baked credentials; first boot generates
# unique per-instance credentials (see deploy/firstboot + deploy/packer).
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to build images for (e.g. v3.3.1)"
required: true
type: string
permissions:
contents: write
concurrency:
group: image-${{ github.event.release.tag_name || inputs.tag }}
cancel-in-progress: false
jobs:
# Resolve the tag and wait until BOTH arch tarballs are actually published
# (the release matrix uploads assets one by one, so 'published' can fire
# before the tarballs exist).
setup:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.resolve.outputs.tag }}
steps:
- name: Resolve tag
id: resolve
run: |
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
else
TAG="${{ inputs.tag }}"
fi
[ -n "$TAG" ] || { echo "::error::no tag resolved"; exit 1; }
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Wait for released binary assets (amd64 + arm64)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.resolve.outputs.tag }}
run: |
want="x-ui-linux-amd64.tar.gz x-ui-linux-arm64.tar.gz"
for i in $(seq 1 30); do
names=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets -q '.assets[].name')
missing=""
for w in $want; do
echo "$names" | grep -qx "$w" || missing="$missing $w"
done
if [ -z "$missing" ]; then
echo "All assets present on $TAG"
exit 0
fi
echo "Waiting for$missing on $TAG ($i/30)..."
sleep 20
done
echo "::error::missing release assets on $TAG after 10 minutes:$missing"
exit 1
# Gate the AWS AMI build so forks without secrets skip it cleanly
# (secrets cannot be referenced directly in job-level `if`).
check-aws:
runs-on: ubuntu-latest
outputs:
enabled: ${{ steps.c.outputs.enabled }}
use_oidc: ${{ steps.c.outputs.use_oidc }}
steps:
- id: c
env:
ROLE: ${{ secrets.AWS_ROLE_ARN }}
KEY: ${{ secrets.AWS_ACCESS_KEY_ID }}
run: |
if [ -n "$ROLE" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "use_oidc=true" >> "$GITHUB_OUTPUT"
elif [ -n "$KEY" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "use_oidc=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "use_oidc=false" >> "$GITHUB_OUTPUT"
echo "::notice::No AWS credentials configured; skipping the AMI build."
fi
qemu-image:
needs: setup
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
qemu_pkgs: qemu-system-x86 qemu-utils
- arch: arm64
runner: ubuntu-24.04-arm
qemu_pkgs: qemu-system-arm qemu-efi-aarch64 qemu-utils
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install QEMU
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ${{ matrix.qemu_pkgs }}
- name: Setup Packer
uses: hashicorp/setup-packer@v3
with:
version: latest
- name: Verify released binary asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.setup.outputs.tag }}
run: |
mkdir -p _asset
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "x-ui-linux-${{ matrix.arch }}.tar.gz" --dir _asset
ls -la _asset
- name: Select accelerator
id: accel
run: |
if [ -e /dev/kvm ]; then echo "value=kvm" >> "$GITHUB_OUTPUT"; else echo "value=tcg" >> "$GITHUB_OUTPUT"; fi
- name: Packer init
run: packer init deploy/packer/
- name: Build qcow2 image
env:
TAG: ${{ needs.setup.outputs.tag }}
ACCEL: ${{ steps.accel.outputs.value }}
run: |
packer build -only='qemu.x-ui' \
-var "xui_version=${TAG}" \
-var "xui_arch=${{ matrix.arch }}" \
-var "qemu_accelerator=${ACCEL}" \
deploy/packer/
- name: Compress qcow2
id: pack
env:
TAG: ${{ needs.setup.outputs.tag }}
run: |
cd deploy/packer/output-qemu
src="3x-ui-ubuntu-24.04-${{ matrix.arch }}.qcow2"
out="3x-ui-ubuntu-24.04-${TAG}-${{ matrix.arch }}.qcow2.xz"
xz -T0 -6 -c "$src" > "$out"
sha256sum "$out" > "${out}.sha256"
echo "file=deploy/packer/output-qemu/${out}" >> "$GITHUB_OUTPUT"
echo "sha=deploy/packer/output-qemu/${out}.sha256" >> "$GITHUB_OUTPUT"
ls -la
- name: Attach qcow2 to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.setup.outputs.tag }}
run: |
gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber \
"${{ steps.pack.outputs.file }}" "${{ steps.pack.outputs.sha }}"
- name: Summary
env:
TAG: ${{ needs.setup.outputs.tag }}
ACCEL: ${{ steps.accel.outputs.value }}
run: |
{
echo "## QEMU image (${{ matrix.arch }})"
echo "- Tag: \`${TAG}\`"
echo "- Accelerator: \`${ACCEL}\`"
echo "- Attached: \`$(basename "${{ steps.pack.outputs.file }}")\`"
} >> "$GITHUB_STEP_SUMMARY"
ami-image:
needs: [setup, check-aws]
if: needs.check-aws.outputs.enabled == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
instance_type: t3.small
- arch: arm64
instance_type: t4g.small
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Packer
uses: hashicorp/setup-packer@v3
with:
version: latest
- name: Configure AWS credentials (OIDC)
if: needs.check-aws.outputs.use_oidc == 'true'
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION || 'eu-central-1' }}
- name: Configure AWS credentials (access keys)
if: needs.check-aws.outputs.use_oidc != 'true'
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION || 'eu-central-1' }}
- name: Verify released binary asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.setup.outputs.tag }}
run: |
mkdir -p _asset
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
--pattern "x-ui-linux-${{ matrix.arch }}.tar.gz" --dir _asset
ls -la _asset
- name: Packer init
run: packer init deploy/packer/
- name: Build AMI
env:
TAG: ${{ needs.setup.outputs.tag }}
REGION: ${{ vars.AWS_REGION || 'eu-central-1' }}
run: |
packer build -only='amazon-ebs.x-ui' \
-var "xui_version=${TAG}" \
-var "xui_arch=${{ matrix.arch }}" \
-var "instance_type=${{ matrix.instance_type }}" \
-var "region=${REGION}" \
deploy/packer/
- name: Publish AMI id to summary
env:
REGION: ${{ vars.AWS_REGION || 'eu-central-1' }}
run: |
AMI_ID=$(jq -r '.builds[] | select(.builder_type=="amazon-ebs") | .artifact_id' packer-manifest.json | tail -1 | cut -d: -f2)
{
echo "## AWS AMI (${{ matrix.arch }})"
echo "- Region: \`${REGION}\`"
echo "- Instance type: \`${{ matrix.instance_type }}\`"
echo "- AMI ID: \`${AMI_ID}\`"
} >> "$GITHUB_STEP_SUMMARY"
+62
View File
@@ -0,0 +1,62 @@
name: Mutation testing
# Mutation testing (gremlins) is the objective check for "fake" tests: it mutates the
# source and a surviving (LIVED) mutant means no test caught the change. It is SLOW, so it
# runs nightly / on demand and scoped per package — never per-commit. It is informational:
# no thresholds are set, so it reports survivors as artifacts without failing the build.
on:
schedule:
- cron: "0 3 * * *" # 03:00 UTC daily
workflow_dispatch:
permissions:
contents: read
jobs:
gremlins:
runs-on: ubuntu-latest
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- name: sub
path: ./internal/sub/
exclude: ""
- name: runtime
path: ./internal/web/runtime/
exclude: ""
- name: link
path: ./internal/util/link/
exclude: ""
- name: database
path: ./internal/database/
exclude: 'dump_sqlite\.go'
- name: service
path: ./internal/web/service/
exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub internal/web/dist for go:embed
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
- name: Install gremlins
run: go install github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0
- name: Run gremlins on ${{ matrix.name }}
run: |
OUT="mutation-${{ matrix.name }}.json"
if [ -n "${{ matrix.exclude }}" ]; then
gremlins unleash -E '${{ matrix.exclude }}' -o "$OUT" ${{ matrix.path }}
else
gremlins unleash -o "$OUT" ${{ matrix.path }}
fi
- name: Upload mutation report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutation-${{ matrix.name }}
path: mutation-${{ matrix.name }}.json
if-no-files-found: ignore
+2 -2
View File
@@ -59,7 +59,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
node-version-file: .nvmrc
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
@@ -210,7 +210,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
node-version-file: .nvmrc
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
+44
View File
@@ -0,0 +1,44 @@
name: Deploy Smoke Tests
# Container smoke tests for the unattended install path and first-boot
# credential generation. Runs only when the install/deploy assets change.
on:
push:
paths:
- "install.sh"
- "deploy/**"
- ".github/workflows/smoke.yml"
pull_request:
paths:
- "install.sh"
- "deploy/**"
- ".github/workflows/smoke.yml"
permissions:
contents: read
jobs:
noninteractive-install:
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Non-interactive install smoke test
run: bash deploy/test/smoke-noninteractive.sh
first-boot:
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: First-boot credential smoke test
run: bash deploy/test/smoke-firstboot.sh
+51
View File
@@ -73,6 +73,7 @@ XUI_DB_FOLDER=x-ui
XUI_LOG_FOLDER=x-ui
XUI_BIN_FOLDER=x-ui
XUI_INIT_WEB_BASE_PATH=/
# XUI_PORT=8080
```
Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` and `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows, `wintun.dll` is also required for testing TUN inbounds.
@@ -235,6 +236,49 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
| `internal/config/` | Environment-variable helpers, paths, defaults |
| `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
## Testing
Tests live next to the code (`foo.go` ↔ `foo_test.go`); frontend specs and golden fixtures live in `frontend/src/test/`.
### Go conventions
- **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers.
- **Assert the contract, not internals.** Pin the exact value / typed error / emitted string — not `err != nil` or `len > 0`. A test that still passes when the behavior is broken is worse than no test.
- **Real dependencies over mocks.** Get a throwaway DB with `database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` + `t.Cleanup(func() { _ = database.CloseDB() })` (Windows-safe), and use `httptest` servers for HTTP. The `internal/sub` suite's `initSubDB(t)` is the template.
### Running
| Goal | Command |
|------|---------|
| Standard run | `go test ./...` |
| Hygiene — data races + order-dependence | `go test -race -shuffle=on -count=1 ./...` (`-race` needs the C compiler from Prerequisites) |
| Coverage gaps | `go test -coverprofile=cov.out ./<pkg>/... && go tool cover -func=cov.out` |
| Fuzz a parser briefly | `go test -run '^$' -fuzz 'FuzzName$' -fuzztime=30s ./<pkg>/...` |
Frontend: `cd frontend && npm run test` (vitest), or `npm run test -- --coverage`.
### Property and fuzz tests
Input-heavy or pure logic (link builders, parsers, decoders) is also covered by **property tests** (`pgregory.net/rapid`) and **native fuzz targets** (`go test -fuzz`). A fuzz target's **seed corpus** (its inline `f.Add` cases plus any `testdata/fuzz` entries) runs as ordinary subtests under a plain `go test` — no `-fuzz` flag needed — so CI's normal test job exercises the seeds; the time-boxed *fuzzing* exploration (`-fuzz=...`) runs separately as the `fuzz-smoke` job.
### Mutation testing (optional, manual)
[gremlins](https://github.com/go-gremlins/gremlins) checks whether tests actually fail when the code is mutated — a surviving (`LIVED`) mutant means a weak test. It is **slow**, so run it **scoped per package**, never repo-wide or per-commit:
```bash
go install github.com/go-gremlins/gremlins/cmd/gremlins@latest
gremlins unleash ./internal/sub/
gremlins unleash -E 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go' ./internal/web/service/
```
Treat each survivor as one of: a weak test (strengthen it), dead code (remove it), or an equivalent mutant (unkillable — leave it). Don't write a test purely to kill a mutant if it doesn't reflect real behavior.
CI runs this for you nightly (and on demand) via `.github/workflows/mutation.yml` — scoped per package, results uploaded as artifacts. It is **informational**, not a gate (no thresholds), so check the reports when hardening a suite rather than waiting for a red build.
### CI
`.github/workflows/ci.yml` runs per PR: `go-test` (with `-shuffle -count=1`), a `race` job (`-race -shuffle -count=1`), a `fuzz-smoke` job on the critical parsers, and the frontend `typecheck`/`lint`/`test`/`build`. Snapshots are regression guards — regenerate them (`npx vitest run -u`) only for intentional output changes, never to make a red test green.
## Sending a pull request
1. Branch off `main` (e.g. `feat/short-description`).
@@ -256,9 +300,16 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
| `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
| `XUI_BIN_FOLDER` | `bin` | Where the xray binary, geo files, and xray `config.json` live |
| `XUI_INIT_WEB_BASE_PATH` | `/` | The initial URI path for the web panel |
| `XUI_PORT` | persisted `webPort` | Runtime-only web panel listener port override (`1` through `65535`) |
| `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
| `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
A valid `XUI_PORT` takes precedence over the database-backed `webPort` for the
current process without changing the stored setting. Unset, empty, whitespace-only,
malformed, or out-of-range values fall back to `webPort`; invalid configured values
also produce a warning. With Docker bridge networking, the published container port
must match the override, for example `XUI_PORT: "8080"` with `ports: ["8080:8080"]`.
## Issues
- Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
+6 -5
View File
@@ -44,23 +44,24 @@ before = iptables-allports.conf
[Definition]
actionstart = <iptables> -N f2b-<name>
<iptables> -A f2b-<name> -j <returntype>
<iptables> -I <chain> -p <protocol> -j f2b-<name>
<iptables> -I <chain> -j f2b-<name>
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
actionstop = <iptables> -D <chain> -j f2b-<name>
<actionflush>
<iptables> -X f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
<iptables> -I f2b-<name> 1 -s <ip> -p udp -m multiport ! --dports <exemptports> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
actionunban = <iptables> -D f2b-<name> -s <ip> -p tcp -m multiport ! --dports <exemptports> -j <blocktype>
<iptables> -D f2b-<name> -s <ip> -p udp -m multiport ! --dports <exemptports> -j <blocktype>
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
[Init]
name = default
protocol = tcp
chain = INPUT
exemptports = $EXEMPT_PORTS
EOF
+12
View File
@@ -77,6 +77,18 @@ During installation a random username, password, and access path are generated.
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
### Unattended install & cloud images
The installer also runs **non-interactively** for cloud-init and golden images.
Set `XUI_NONINTERACTIVE=1` (or pipe with no TTY) and it installs end-to-end with
zero prompts, generating random credentials and writing them to
`/etc/x-ui/install-result.env`. See [`deploy/`](deploy/) for:
- [Cloud-init user-data](deploy/cloud-init/) — unattended install on any cloud (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
- [Packer golden image](deploy/packer/) — build an AWS EC2 AMI + qcow2 (amd64/arm64) with per-instance credentials generated on first boot
- [Amazon Lightsail](deploy/lightsail/) — launch script + reusable snapshot builder
- [AWS Marketplace checklist](deploy/marketplace/aws/)
## Supported Platforms
**Operating systems:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine, and Windows.
+38
View File
@@ -0,0 +1,38 @@
# Cloud deployment & golden images
Tooling to ship the 3x-ui panel as a cloud image or via unattended install,
with **per-instance credentials generated on first boot** (never `admin/admin`,
never a shared session secret). Everything here supports **amd64 and arm64**.
| Path | What it is | Use when |
| --- | --- | --- |
| [`cloud-init/`](cloud-init/) | Generic cloud-init user-data (unattended `install.sh`) | Any cloud, no image build |
| [`packer/`](packer/) | Packer build → AWS AMI + qcow2/raw | Reusable / Marketplace images |
| [`lightsail/`](lightsail/) | Launch script + snapshot builder | Amazon Lightsail |
| [`firstboot/`](firstboot/) | First-boot unit + script that mints per-instance creds | Used by the Packer/Lightsail images |
| [`marketplace/aws/`](marketplace/aws/) | AWS Marketplace submission checklist | Publishing an EC2 AMI |
| [`marketplace/hetzner/`](marketplace/hetzner/) | Hetzner Cloud notes | Hetzner deployments |
| [`test/`](test/) | Container smoke tests | Verifying the install/firstboot paths |
## Two models
- **Non-interactive install (cloud-init):** `install.sh` runs unattended when
`XUI_NONINTERACTIVE=1` or stdin is not a TTY. Each instance installs and
configures itself with random credentials. See [`cloud-init/README.md`](cloud-init/README.md).
- **Golden image (Packer):** the image contains the panel but **no DB and no
secrets**; `firstboot` generates unique credentials on first boot. See
[`packer/README.md`](packer/README.md).
## Unattended install knobs
`install.sh` reads these env vars in non-interactive mode (all optional; unset ⇒
secure random / default):
`XUI_USERNAME`, `XUI_PASSWORD`, `XUI_PANEL_PORT`, `XUI_WEB_BASE_PATH`,
`XUI_SSL_MODE` (`none`|`ip`|`domain`, default `none`), `XUI_DOMAIN`,
`XUI_ACME_EMAIL`, `XUI_ACME_HTTP_PORT` (ACME HTTP-01 listener port, default `80`),
`XUI_SSL_IPV6` (optional IPv6 address to add to an `ip`-mode cert),
`XUI_SERVER_IP` (fallback IP for the displayed access URL when auto-detection fails),
`XUI_DB_TYPE` (`sqlite`|`postgres`), `XUI_DB_DSN`.
The resulting credentials are written to `/etc/x-ui/install-result.env` (mode 600).
+71
View File
@@ -0,0 +1,71 @@
# 3x-ui via cloud-init (generic, no golden image)
This is the **secondary** deployment path: a single [`cloud-init.yaml`](cloud-init.yaml)
user-data file that installs 3x-ui non-interactively on a fresh Ubuntu/Debian
VM and generates **unique random credentials per instance**. Use it when you do
not want to build a golden image — it works on any cloud-init platform.
> For AWS Marketplace / reusable images, use the Packer build in
> [`../packer/`](../packer/) instead.
## How it works
1. The VM boots a stock Ubuntu/Debian cloud image.
2. cloud-init writes and runs `/opt/xui-bootstrap.sh`, which exports
`XUI_NONINTERACTIVE=1` and pipes the project's `install.sh` into `bash`.
3. `install.sh` runs end-to-end with **zero prompts**, picking secure random
values for any credential you didn't pin.
4. The generated credentials are written to `/etc/x-ui/install-result.env`
(mode 600), echoed to the **serial console**, and appended to `/etc/motd`.
Retrieve them after boot with either:
```bash
sudo cat /etc/x-ui/install-result.env # over SSH
```
…or read the provider's **serial console** output (handy before you have SSH).
## Customising
Edit the `export XUI_*` lines inside the `write_files` block of
[`cloud-init.yaml`](cloud-init.yaml). All knobs are optional; unset ⇒ random/secure default.
| Env var | Default | Meaning |
| --- | --- | --- |
| `XUI_SSL_MODE` | `none` | `none` (plain HTTP), `ip` (Let's Encrypt IP cert), `domain` |
| `XUI_USERNAME` | random | Admin username |
| `XUI_PASSWORD` | random | Admin password |
| `XUI_PANEL_PORT` | random high port | Panel listen port |
| `XUI_WEB_BASE_PATH` | random | Panel base path (obscures the URL) |
| `XUI_DOMAIN` | — | Required when `XUI_SSL_MODE=domain` |
| `XUI_ACME_EMAIL` | — | Let's Encrypt account email (domain mode) |
| `XUI_DB_TYPE` / `XUI_DB_DSN` | `sqlite` | Set `postgres` + DSN to use PostgreSQL |
> **TLS note:** `none` serves the panel over plain HTTP on a random high port —
> fine behind a reverse proxy or an SSH tunnel, but put TLS in front of it before
> exposing the panel publicly. `domain` mode needs a public DNS A record pointing
> at the box and port 80 reachable at install time.
## Per-provider usage
- **Hetzner Cloud** — *Create Server → Cloud config*: paste the file. Or CLI:
`hcloud server create --image ubuntu-24.04 --user-data-from-file cloud-init.yaml ...`
- **AWS EC2** — *Advanced details → User data*: paste the file. Or
`aws ec2 run-instances --user-data file://cloud-init.yaml ...`
(For a reusable Marketplace image use the Packer AMI build instead.)
- **DigitalOcean** — *Create Droplet → Advanced options → Add Initialization
scripts (user data)*: paste the file. Or `doctl compute droplet create --user-data-file cloud-init.yaml ...`
- **Vultr** — *Deploy → Additional Features → Cloud-Init User-Data*: paste the file.
- **Google Cloud (GCE)** — `gcloud compute instances create xui \
--image-family ubuntu-2404-lts-amd64 --image-project ubuntu-os-cloud \
--metadata-from-file user-data=cloud-init.yaml`
- **Azure** — `az vm create --image Ubuntu2404 --custom-data cloud-init.yaml ...`
- **Oracle Cloud (OCI)** — *Create Instance → Show advanced options →
Management → Cloud-init script*: paste (or base64-upload) the file.
## Validate before you deploy
```bash
cloud-init schema --config-file deploy/cloud-init/cloud-init.yaml
```
+78
View File
@@ -0,0 +1,78 @@
#cloud-config
# ---------------------------------------------------------------------------
# Generic 3x-ui unattended install via cloud-init user-data.
#
# Works on any cloud-init platform: Hetzner, AWS, DigitalOcean, Vultr, GCP,
# Azure, Oracle. Paste the whole file as the instance "user data".
#
# It installs the latest 3x-ui release NON-INTERACTIVELY, generating unique
# random credentials per instance. Full credentials are surfaced ONLY on the
# serial console (owner-only); /etc/motd (world-readable) shows just the access
# URL + username. Nothing is baked in advance — every instance is unique.
#
# Requires the non-interactive install.sh (3x-ui with XUI_NONINTERACTIVE support).
# Edit the exported XUI_* knobs in /opt/xui-bootstrap.sh below to customise.
# ---------------------------------------------------------------------------
package_update: true
package_upgrade: false
write_files:
- path: /opt/xui-bootstrap.sh
permissions: '0700'
owner: root:root
content: |
#!/usr/bin/env bash
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
# --- Non-interactive install knobs --------------------------------------
export XUI_NONINTERACTIVE=1
# SSL mode: none (plain HTTP, default) | ip | domain
export XUI_SSL_MODE="${XUI_SSL_MODE:-none}"
# Pin credentials instead of random (leave unset for secure random values):
# export XUI_USERNAME="admin2"
# export XUI_PASSWORD="change-me-please"
# export XUI_PANEL_PORT="2053"
# export XUI_WEB_BASE_PATH="panel"
# Let's Encrypt domain certificate instead of plain HTTP:
# export XUI_SSL_MODE="domain"
# export XUI_DOMAIN="panel.example.com"
# export XUI_ACME_EMAIL="you@example.com"
# PostgreSQL instead of SQLite:
# export XUI_DB_TYPE="postgres"
# export XUI_DB_DSN="postgres://user:pass@host:5432/db?sslmode=disable"
# ------------------------------------------------------------------------
curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh | bash
# Surface the generated credentials. Full creds (incl. password + API token)
# go ONLY to the serial console (/dev/console, owner-only). /etc/motd is
# world-readable, so it gets just the access URL + username and a pointer
# to the root-only env file.
if [ -r /etc/x-ui/install-result.env ]; then
{
echo
echo "=== 3x-ui panel credentials (generated on first boot) ==="
cat /etc/x-ui/install-result.env
echo "========================================================"
echo "Change the password after first login."
} > /dev/console 2>/dev/null || true
# shellcheck disable=SC1091
. /etc/x-ui/install-result.env
{
echo
echo "=== 3x-ui panel (generated on first boot) ==="
echo "URL: ${XUI_ACCESS_URL:-unknown}"
echo "Username: ${XUI_USERNAME:-unknown}"
echo "Password + API token: sudo cat /etc/x-ui/install-result.env"
echo "============================================="
echo "Change the password after first login."
} >> /etc/motd 2>/dev/null || true
fi
runcmd:
- [bash, /opt/xui-bootstrap.sh]
final_message: "3x-ui installed — full credentials in /etc/x-ui/install-result.env (sudo); /etc/motd shows the URL + username only."
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=3x-ui first-boot per-instance credential generation
Documentation=https://github.com/MHSanaei/3x-ui
# Run after the network and cloud-init are up, but BEFORE the panel starts, so
# the panel never serves the default admin/admin account.
After=network-online.target cloud-init.service
Wants=network-online.target
Before=x-ui.service
# Skip entirely once the sentinel exists (cheap guard; the script re-checks too).
ConditionPathExists=!/etc/x-ui/.firstboot-done
[Service]
Type=oneshot
RemainAfterExit=yes
# Inherit the same DB configuration the panel uses (sqlite default / postgres).
EnvironmentFile=-/etc/default/x-ui
EnvironmentFile=-/etc/conf.d/x-ui
EnvironmentFile=-/etc/sysconfig/x-ui
ExecStart=/usr/local/x-ui/x-ui-firstboot.sh
[Install]
WantedBy=multi-user.target
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
#
# x-ui-firstboot.sh — generate per-instance 3x-ui panel credentials on first boot.
#
# A golden image (AMI / qcow2) MUST ship without an initialized x-ui.db: the
# panel seeds a hardcoded admin/admin user and generates its session secret +
# panel GUID on first start, so a baked DB would make every clone share the same
# credentials and secret. This script runs ONCE, before x-ui.service starts, and
# replaces the default admin with fresh random credentials on a random high port.
#
# Idempotent: a sentinel file guards against re-running. If a non-default admin
# already exists (operator pre-configured the box), regeneration is skipped.
#
# Wired up by deploy/packer/scripts/provision.sh; ordered Before=x-ui.service.
set -u
SENTINEL="/etc/x-ui/.firstboot-done"
CRED_FILE="/etc/x-ui/credentials.txt"
MOTD_FILE="/etc/motd"
XUI_DIR="${XUI_MAIN_FOLDER:-/usr/local/x-ui}"
XUI_BIN="${XUI_DIR}/x-ui"
log() { echo "[x-ui-firstboot] $*"; }
# Already provisioned — nothing to do (idempotent on re-run / re-image).
if [ -f "$SENTINEL" ]; then
log "sentinel $SENTINEL present; skipping."
exit 0
fi
if [ ! -x "$XUI_BIN" ]; then
log "ERROR: x-ui binary not found at $XUI_BIN"
exit 1
fi
# Inherit DB configuration (sqlite default; postgres via XUI_DB_TYPE/XUI_DB_DSN)
# from the same env files the systemd unit loads, so the binary talks to the
# same database the panel will use.
for ef in /etc/default/x-ui /etc/conf.d/x-ui /etc/sysconfig/x-ui; do
if [ -r "$ef" ]; then
set -a
# shellcheck disable=SC1090
. "$ef"
set +a
fi
done
install -d -m 755 /etc/x-ui 2> /dev/null || true
# Defense-in-depth: make sure the panel is not running while we mutate the DB.
if command -v systemctl > /dev/null 2>&1; then
systemctl stop x-ui > /dev/null 2>&1 || true
fi
gen_random_string() {
local length="$1"
openssl rand -base64 $((length * 2)) | tr -dc 'a-zA-Z0-9' | head -c "$length"
}
# Best-effort public IPv4 for the displayed access URL (cosmetic only — the
# panel binds 0.0.0.0). Falls back to the primary local IP, then a placeholder.
detect_ip() {
local ip=""
local url
for url in https://api4.ipify.org https://ipv4.icanhazip.com https://4.ident.me; do
ip=$(curl -fsS4 --max-time 3 "$url" 2> /dev/null | tr -d '[:space:]')
if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "$ip"
return 0
fi
done
ip=$(hostname -I 2> /dev/null | awk '{print $1}')
if [ -n "$ip" ]; then
echo "$ip"
return 0
fi
echo "<server-ip>"
}
# Detect whether the seeded admin/admin default is still in place.
default_creds=$("$XUI_BIN" setting -show true 2> /dev/null | grep -Eo 'hasDefaultCredential: .+' | awk '{print $2}')
# The parse MUST yield exactly "true" or "false". If the command failed or its
# output format changed, refuse to proceed: do NOT write the sentinel, so the
# next boot retries instead of silently leaving admin/admin in place.
if [ "$default_creds" != "true" ] && [ "$default_creds" != "false" ]; then
log "ERROR: could not determine credential state (hasDefaultCredential='${default_creds}'); not writing sentinel, will retry next boot."
exit 1
fi
if [ "$default_creds" = "false" ]; then
log "non-default admin already configured; skipping credential regeneration."
{
echo "3x-ui first-boot: a non-default admin account already exists on this"
echo "instance, so credentials were left unchanged."
} > "$MOTD_FILE" 2> /dev/null || true
: > "$SENTINEL" 2> /dev/null || true
chmod 600 "$SENTINEL" 2> /dev/null || true
exit 0
fi
log "generating per-instance credentials..."
NEW_USER="${XUI_USERNAME:-$(gen_random_string 10)}"
NEW_PASS="${XUI_PASSWORD:-$(gen_random_string 16)}"
NEW_PATH="${XUI_WEB_BASE_PATH:-$(gen_random_string 18)}"
NEW_PORT="${XUI_PANEL_PORT:-$(shuf -i 1024-62000 -n 1)}"
# Clean settings slate: drops any baked port/webBasePath and forces the panel
# to regenerate its session secret + panel GUID on next start (per-instance).
"$XUI_BIN" setting -reset > /dev/null 2>&1 || true
# Apply fresh random identity. UpdateFirstUser renames the seeded admin row and
# rehashes the password, so admin/admin no longer exists after this call.
if ! "$XUI_BIN" setting -username "$NEW_USER" -password "$NEW_PASS" -port "$NEW_PORT" -webBasePath "$NEW_PATH" > /dev/null 2>&1; then
log "ERROR: failed to apply new panel settings."
exit 1
fi
API_TOKEN=$("$XUI_BIN" setting -getApiToken true 2> /dev/null | grep -Eo 'apiToken: .+' | awk '{print $2}')
SERVER_IP=$(detect_ip)
ACCESS_URL="http://${SERVER_IP}:${NEW_PORT}/${NEW_PATH}"
# Persist credentials for the operator (root-only). Values are shell-escaped
# with %q so the file stays safe to `source` even if a value contains shell
# metacharacters (the smoke test and operators source this file).
umask 077
{
echo "# 3x-ui per-instance credentials (generated on first boot)"
printf 'XUI_USERNAME=%q\n' "$NEW_USER"
printf 'XUI_PASSWORD=%q\n' "$NEW_PASS"
printf 'XUI_PANEL_PORT=%q\n' "$NEW_PORT"
printf 'XUI_WEB_BASE_PATH=%q\n' "$NEW_PATH"
printf 'XUI_ACCESS_URL=%q\n' "$ACCESS_URL"
printf 'XUI_API_TOKEN=%q\n' "$API_TOKEN"
} > "$CRED_FILE"
chmod 600 "$CRED_FILE" 2> /dev/null || true
# Friendly login banner shown on SSH / console before the panel is reachable.
# /etc/motd is world-readable, so it MUST NOT contain the password or API token;
# those secrets live only in ${CRED_FILE} (mode 600). Show non-secret info only.
cat > "$MOTD_FILE" 2> /dev/null << EOF
========================================================================
3x-ui panel — per-instance credentials (generated on first boot)
========================================================================
Access URL : ${ACCESS_URL}
Username : ${NEW_USER}
The password and API token are NOT shown here (this banner is
world-readable). Read them as root with:
sudo cat ${CRED_FILE}
Change the password after login. If no public IP is shown above,
replace <server-ip> with the address you reach this server on.
========================================================================
EOF
# Mark complete so we never regenerate on subsequent boots.
: > "$SENTINEL" 2> /dev/null || true
chmod 600 "$SENTINEL" 2> /dev/null || true
log "done. Panel will start on port ${NEW_PORT} with a unique admin account."
exit 0
+94
View File
@@ -0,0 +1,94 @@
# 3x-ui on Amazon Lightsail
Two self-service ways to run 3x-ui on Lightsail, both producing **unique
per-instance credentials** (never `admin/admin`, never a shared secret).
> **Reality check.** The Lightsail *blueprint* list (WordPress, LAMP, GitLab…)
> is curated by AWS — you **cannot** self-publish your panel there, and Lightsail
> **cannot** launch from an arbitrary EC2 AMI. What you *can* do yourself is the
> two paths below. (For a public AWS listing you'd use the EC2 **AMI** +
> Marketplace path in [`../marketplace/aws/`](../marketplace/aws/), which is a
> different product from Lightsail.)
---
## Path A — launch script (simplest, self-service)
Install on a fresh instance at creation time. No image to build.
1. **Create instance** → platform **Linux/Unix** → blueprint **OS Only → Ubuntu 24.04**.
2. **Add launch script** → paste [`launch-script.sh`](launch-script.sh).
3. Create the instance.
4. After it boots, read the credentials:
```bash
ssh ubuntu@<public-ip> 'sudo cat /etc/x-ui/install-result.env'
```
5. **Open the panel port** (see the firewall note below) and log in.
CLI equivalent:
```bash
aws lightsail create-instances \
--instance-names my-3xui \
--availability-zone eu-central-1a \
--blueprint-id ubuntu_24_04 \
--bundle-id small_3_0 \
--user-data file://deploy/lightsail/launch-script.sh \
--region eu-central-1
```
By default the panel uses a **random** high port (in `install-result.env`). To
pin a known port so you can pre-open it, set `export XUI_PANEL_PORT=54321` inside
`launch-script.sh`.
---
## Path B — reusable snapshot (your own "ready image")
Build a Lightsail **snapshot** once; launch as many instances from it as you
like, each generating its own credentials on first boot (the golden-image model).
```bash
deploy/lightsail/build-snapshot.sh --region eu-central-1 --panel-port 54321
```
What it does: launches a temporary Ubuntu instance with
[`snapshot-userdata.sh`](snapshot-userdata.sh) (installs the panel, **no DB**,
enables the first-boot unit), strips all state via the shared
[`cleanup.sh`](../packer/scripts/cleanup.sh), then snapshots and deletes the
build instance. Requires `awscli`, `jq`, `ssh` and Lightsail permissions.
Launch instances from the snapshot:
```bash
aws lightsail create-instances-from-snapshot \
--instance-snapshot-name 3x-ui-ubuntu-24.04-<stamp> \
--instance-names my-3xui-1 --bundle-id small_3_0 \
--availability-zone eu-central-1a --region eu-central-1
```
Each launched instance runs `x-ui-firstboot` and writes its unique credentials to
`/etc/x-ui/credentials.txt` + `/etc/motd`. With `--panel-port` the port is the
same across instances (only the credentials differ), so you can pre-open it.
> Lightsail snapshots are **private to your AWS account** (and region). To use one
> elsewhere you can export it to EC2 (`aws lightsail export-snapshot`) and share
> the resulting AMI.
---
## Lightsail firewall note (important)
Lightsail's per-instance firewall only opens **22 / 80 / 443** by default. The
panel runs on a different port, so you must open it:
- Console: instance → **Networking → IPv4 Firewall → Add rule** (TCP, the panel port).
- CLI:
```bash
aws lightsail open-instance-public-ports --region eu-central-1 \
--instance-name my-3xui \
--port-info fromPort=54321,toPort=54321,protocol=TCP
```
The panel port is in `/etc/x-ui/install-result.env` (Path A) or
`/etc/x-ui/credentials.txt` (Path B), or fixed via `--panel-port` / `XUI_PANEL_PORT`.
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env bash
#
# build-snapshot.sh — build a reusable Amazon Lightsail snapshot of 3x-ui.
#
# Flow (mirrors the Packer golden-image model, via the Lightsail API):
# 1. create an Ubuntu Lightsail instance with snapshot-userdata.sh
# (installs the panel, NO database, enables the first-boot unit)
# 2. wait for provisioning, then (optionally) pin a known panel port and run
# the shared cleanup.sh (wipes any DB/creds/keys/host-keys/cloud-init state)
# 3. stop the instance and create an instance snapshot
# 4. delete the build instance (unless --keep-instance)
#
# Every instance you later launch from the snapshot generates its OWN unique
# credentials on first boot (see deploy/firstboot/). The snapshot is private to
# your AWS account.
#
# Requirements: awscli v2, jq, ssh. AWS credentials with Lightsail permissions.
# Usage:
# deploy/lightsail/build-snapshot.sh --region eu-central-1 [options]
# Options:
# --region <r> AWS region (default: $AWS_REGION or eu-central-1)
# --blueprint-id <id> Lightsail blueprint (default: ubuntu_24_04)
# --bundle-id <id> Lightsail bundle/size (default: small_3_0)
# --availability-zone <z> AZ (default: <region>a)
# --panel-port <p> Pin the panel port in the snapshot so you can pre-open
# it in the Lightsail firewall (default: random per instance)
# --snapshot-name <n> Snapshot name (default: 3x-ui-ubuntu-24.04-<timestamp>)
# --keep-instance Do not delete the build instance afterwards
set -euo pipefail
REGION="${AWS_REGION:-eu-central-1}"
BLUEPRINT="ubuntu_24_04"
BUNDLE="small_3_0"
AZ=""
PANEL_PORT=""
SNAPSHOT_NAME=""
KEEP_INSTANCE=0
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
STAMP="$(date +%Y%m%d-%H%M%S)"
INSTANCE_NAME="3xui-build-${STAMP}"
KEY_FILE=""
log() { echo "[build-snapshot] $*"; }
die() {
echo "[build-snapshot] ERROR: $*" >&2
exit 1
}
while [ $# -gt 0 ]; do
case "$1" in
--region) REGION="$2"; shift 2 ;;
--blueprint-id) BLUEPRINT="$2"; shift 2 ;;
--bundle-id) BUNDLE="$2"; shift 2 ;;
--availability-zone) AZ="$2"; shift 2 ;;
--panel-port) PANEL_PORT="$2"; shift 2 ;;
--snapshot-name) SNAPSHOT_NAME="$2"; shift 2 ;;
--keep-instance) KEEP_INSTANCE=1; shift ;;
-h | --help) sed -n '2,40p' "$0"; exit 0 ;;
*) die "unknown option: $1" ;;
esac
done
[ -n "$AZ" ] || AZ="${REGION}a"
[ -n "$SNAPSHOT_NAME" ] || SNAPSHOT_NAME="3x-ui-ubuntu-24.04-${STAMP}"
for cmd in aws jq ssh; do
command -v "$cmd" > /dev/null 2>&1 || die "'$cmd' is required"
done
SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR)
cleanup() {
[ -n "$KEY_FILE" ] && rm -f "$KEY_FILE"
if [ "$KEEP_INSTANCE" -eq 0 ]; then
aws lightsail delete-instance --instance-name "$INSTANCE_NAME" --region "$REGION" > /dev/null 2>&1 || true
fi
}
trap cleanup EXIT
wait_state() {
local want="$1" tries="${2:-60}" st
for _ in $(seq 1 "$tries"); do
st=$(aws lightsail get-instance-state --instance-name "$INSTANCE_NAME" --region "$REGION" \
--query 'state.name' --output text 2> /dev/null || echo "")
[ "$st" = "$want" ] && return 0
sleep 5
done
return 1
}
log "creating build instance ${INSTANCE_NAME} (${BLUEPRINT}/${BUNDLE}) in ${REGION}..."
aws lightsail create-instances \
--instance-names "$INSTANCE_NAME" \
--availability-zone "$AZ" \
--blueprint-id "$BLUEPRINT" \
--bundle-id "$BUNDLE" \
--user-data "file://${SCRIPT_DIR}/snapshot-userdata.sh" \
--region "$REGION" > /dev/null
log "waiting for instance to run..."
wait_state running 60 || die "instance did not reach 'running'"
IP=$(aws lightsail get-instance --instance-name "$INSTANCE_NAME" --region "$REGION" \
--query 'instance.publicIpAddress' --output text)
if [ -z "$IP" ] || [ "$IP" = "None" ]; then die "no public IP"; fi
log "instance IP: ${IP}"
KEY_FILE="$(mktemp)"
# download-default-key-pair returns the key in 'privateKeyBase64'. Despite the
# name, the CLI historically emits the plaintext PEM (-----BEGIN...); the API
# docs describe it as base64. Handle both: write PEM as-is, else base64-decode.
KEY_RAW="$(aws lightsail download-default-key-pair --region "$REGION" \
--query 'privateKeyBase64' --output text)"
[ -n "$KEY_RAW" ] && [ "$KEY_RAW" != "None" ] || die "failed to download default key pair"
case "$KEY_RAW" in
*-----BEGIN*) printf '%s\n' "$KEY_RAW" > "$KEY_FILE" ;;
*) printf '%s' "$KEY_RAW" | base64 -d > "$KEY_FILE" 2> /dev/null \
|| die "private key is neither PEM nor valid base64" ;;
esac
grep -q -- "-----BEGIN" "$KEY_FILE" || die "downloaded key is not a valid PEM private key"
chmod 600 "$KEY_FILE"
log "waiting for provisioning to finish (this installs the panel)..."
ok=0
for _ in $(seq 1 72); do # ~12 min
if ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \
'test -f /var/lib/3xui-provision-done' 2> /dev/null; then
ok=1
break
fi
sleep 10
done
[ "$ok" -eq 1 ] || die "provisioning did not complete in time"
log "provisioning complete."
if [ -n "$PANEL_PORT" ]; then
log "pinning panel port ${PANEL_PORT} (username/password stay random)..."
ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \
"echo 'XUI_PANEL_PORT=${PANEL_PORT}' | sudo tee -a /etc/default/x-ui >/dev/null"
fi
log "stripping instance state (shared cleanup.sh)..."
ssh "${SSH_OPTS[@]}" -i "$KEY_FILE" "ubuntu@${IP}" \
'curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/deploy/packer/scripts/cleanup.sh | sudo bash'
log "stopping instance..."
aws lightsail stop-instance --instance-name "$INSTANCE_NAME" --region "$REGION" > /dev/null
wait_state stopped 60 || die "instance did not stop"
log "creating snapshot ${SNAPSHOT_NAME}..."
aws lightsail create-instance-snapshot \
--instance-name "$INSTANCE_NAME" \
--instance-snapshot-name "$SNAPSHOT_NAME" \
--region "$REGION" > /dev/null
log "waiting for snapshot to become available..."
snap_ok=0
for _ in $(seq 1 120); do # ~20 min
state=$(aws lightsail get-instance-snapshot --instance-snapshot-name "$SNAPSHOT_NAME" \
--region "$REGION" --query 'instanceSnapshot.state' --output text 2> /dev/null || echo "")
[ "$state" = "available" ] && {
snap_ok=1
break
}
sleep 10
done
[ "$snap_ok" -eq 1 ] || die "snapshot did not become available"
log "DONE."
echo
echo "================================================================"
echo " Lightsail snapshot ready: ${SNAPSHOT_NAME} (region ${REGION})"
echo "================================================================"
echo " Launch an instance from it:"
echo " aws lightsail create-instances-from-snapshot \\"
echo " --instance-snapshot-name ${SNAPSHOT_NAME} \\"
echo " --instance-names my-3xui-1 --bundle-id ${BUNDLE} \\"
echo " --availability-zone ${AZ} --region ${REGION}"
if [ -n "$PANEL_PORT" ]; then
echo
echo " Then open the panel port (pinned to ${PANEL_PORT}):"
echo " aws lightsail open-instance-public-ports --region ${REGION} \\"
echo " --instance-name my-3xui-1 \\"
echo " --port-info fromPort=${PANEL_PORT},toPort=${PANEL_PORT},protocol=TCP"
else
echo
echo " Each instance picks a RANDOM panel port. After it boots, read it from"
echo " sudo cat /etc/x-ui/credentials.txt"
echo " and open that TCP port in the instance's Lightsail IPv4 firewall."
fi
echo "================================================================"
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
#
# Amazon Lightsail launch script for 3x-ui (self-service, per-instance creds).
#
# Use it one of two ways when creating an Ubuntu 24.04 Lightsail instance:
# * Console: "Add launch script" -> paste this file.
# * CLI: aws lightsail create-instances --user-data file://launch-script.sh ...
#
# It installs the latest 3x-ui release non-interactively and generates unique
# random credentials for THIS instance. The full credentials land in
# /etc/x-ui/install-result.env (mode 600); /etc/motd shows only the URL + username.
#
# IMPORTANT (Lightsail firewall): Lightsail only opens 22/80/443 by default. The
# panel listens on a random high port, so after boot read the port from
# /etc/x-ui/install-result.env and open it under the instance's Networking tab
# (IPv4 Firewall), or pin a known port below and pre-open it.
set -e
export DEBIAN_FRONTEND=noninteractive
# --- Non-interactive install knobs ------------------------------------------
export XUI_NONINTERACTIVE=1
export XUI_SSL_MODE="${XUI_SSL_MODE:-none}"
# Pin a known panel port so you can pre-open it in the Lightsail firewall
# (otherwise a random high port is chosen). Username/password stay random:
# export XUI_PANEL_PORT="54321"
# Other optional pins (unset => secure random):
# export XUI_USERNAME="admin2"
# export XUI_PASSWORD="change-me"
# export XUI_WEB_BASE_PATH="panel"
# Domain TLS instead of plain HTTP:
# export XUI_SSL_MODE="domain" XUI_DOMAIN="panel.example.com" XUI_ACME_EMAIL="you@example.com"
# ----------------------------------------------------------------------------
curl -fsSL https://raw.githubusercontent.com/MHSanaei/3x-ui/main/install.sh | bash
# /etc/motd is world-readable, so it gets ONLY non-secret info (URL + username);
# the full credentials stay in the root-only /etc/x-ui/install-result.env
# (mode 600) — read them with `sudo cat` over SSH.
if [ -r /etc/x-ui/install-result.env ]; then
# shellcheck disable=SC1091
. /etc/x-ui/install-result.env
{
echo
echo "=== 3x-ui panel (generated on first boot) ==="
echo "URL: ${XUI_ACCESS_URL:-unknown}"
echo "Username: ${XUI_USERNAME:-unknown}"
echo "Password + API token: sudo cat /etc/x-ui/install-result.env"
echo "Open the panel port in the Lightsail IPv4 firewall, then log in."
echo "============================================="
} >> /etc/motd 2>/dev/null || true
fi
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
#
# Lightsail snapshot provisioning user-data (used by build-snapshot.sh).
#
# Installs the 3x-ui panel into a build instance but creates NO database and
# NO credentials, and enables the first-boot unit. The instance is then snapshot
# so that every instance launched from the snapshot generates its own unique
# credentials on first boot (see deploy/firstboot/).
#
# This is the Lightsail equivalent of deploy/packer/scripts/provision.sh. It is
# NOT for end users — use deploy/lightsail/launch-script.sh for a direct install.
set -e
export DEBIAN_FRONTEND=noninteractive
REPO=MHSanaei/3x-ui
XUI_DIR=/usr/local/x-ui
RAW="https://raw.githubusercontent.com/${REPO}/main"
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates curl tar tzdata socat openssl cron jq
ARCH=$(dpkg --print-architecture) # amd64 | arm64
VER=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r .tag_name)
if [ -z "$VER" ] || [ "$VER" = "null" ]; then
echo "failed to resolve 3x-ui version" >&2
exit 1
fi
tmp=$(mktemp -d)
curl -fL4 --retry 3 -o "${tmp}/x.tar.gz" \
"https://github.com/${REPO}/releases/download/${VER}/x-ui-linux-${ARCH}.tar.gz"
systemctl stop x-ui > /dev/null 2>&1 || true
rm -rf "$XUI_DIR"
tar -xzf "${tmp}/x.tar.gz" -C /usr/local/
chmod +x "${XUI_DIR}/x-ui" "${XUI_DIR}/x-ui.sh"
chmod +x "${XUI_DIR}"/bin/* 2> /dev/null || true
cp -f "${XUI_DIR}/x-ui.sh" /usr/bin/x-ui
chmod +x /usr/bin/x-ui
mkdir -p /var/log/x-ui
# Panel + first-boot systemd units.
install -m 644 "${XUI_DIR}/x-ui.service.debian" /etc/systemd/system/x-ui.service
curl -fL4 -o "${XUI_DIR}/x-ui-firstboot.sh" "${RAW}/deploy/firstboot/x-ui-firstboot.sh"
curl -fL4 -o /etc/systemd/system/x-ui-firstboot.service "${RAW}/deploy/firstboot/x-ui-firstboot.service"
chmod 755 "${XUI_DIR}/x-ui-firstboot.sh"
chmod 644 /etc/systemd/system/x-ui-firstboot.service
systemctl daemon-reload
systemctl enable x-ui-firstboot.service
systemctl enable x-ui.service
# No DB, no creds in the image — first boot generates them per-instance.
rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* /etc/x-ui/.firstboot-done 2> /dev/null || true
# Marker that build-snapshot.sh polls for over SSH.
touch /var/lib/3xui-provision-done
echo "[snapshot-userdata] provisioned 3x-ui ${VER} (${ARCH}); no DB created."
+92
View File
@@ -0,0 +1,92 @@
# Publishing 3x-ui to the AWS Marketplace (AMI)
This is the checklist for turning the Packer-built AMI into an AWS Marketplace
listing. It assumes you have already built an AMI with
[`../../packer/`](../../packer/) (locally or via `.github/workflows/image.yml`).
> Do **not** commit AMI IDs, AWS account numbers, or credentials. The AMI ID is
> printed to the workflow job summary at build time.
## 1. Seller registration (one-time)
1. Sign in to the [AWS Marketplace Management Portal](https://aws.amazon.com/marketplace/management/)
with the AWS account that will own the listing.
2. Complete **seller registration** (legal entity, bank, tax interview). Required
before any product can be submitted.
## 2. Build a compliant AMI
Build in the seller account (or share the AMI into it):
```bash
cd deploy/packer
packer init .
# amd64
packer build -only='amazon-ebs.x-ui' \
-var 'xui_version=vX.Y.Z' -var 'xui_arch=amd64' -var 'instance_type=t3.small' -var 'region=eu-central-1' .
# arm64 (Graviton)
packer build -only='amazon-ebs.x-ui' \
-var 'xui_version=vX.Y.Z' -var 'xui_arch=arm64' -var 'instance_type=t4g.small' -var 'region=eu-central-1' .
```
You can list both AMIs (amd64 + arm64) as architectures of a single Marketplace
product, or as separate products.
The image already satisfies the Marketplace AMI policies enforced by `harden.sh`
+ `cleanup.sh`:
- ✅ `PasswordAuthentication no`, `PermitRootLogin prohibit-password`
- ✅ no default OS account passwords (all locked)
- ✅ no baked `authorized_keys`, no SSH host keys (regenerated on boot)
- ✅ base OS = current Ubuntu 24.04 LTS, patched at build time
- ✅ no application default credentials — the panel admin is generated on first
boot on a random high port (no `admin/admin`, no shipped `x-ui.db`)
## 3. Run the self-service AMI scan
1. In the Management Portal: **Server products → AMIs → Upload/scan an AMI**.
2. Share the AMI with the AWS Marketplace scanning account when prompted
(the portal gives you the exact account id and the `modify-image-attribute`
command, or share it from the EC2 console).
3. Start the scan. It checks SSH config, default credentials, open ports, and
for malware. Fix any finding and re-scan.
Common scan findings and where they're handled:
| Finding | Fix (already in the build) |
| --- | --- |
| Password authentication enabled | `harden.sh` sshd drop-in |
| Root login with password | `harden.sh` `PermitRootLogin prohibit-password` |
| Default user password set | `harden.sh` `passwd -l` on all accounts |
| Authorized keys present | `cleanup.sh` removes them |
| Out-of-date packages | base image is the latest LTS; `provision.sh` runs `apt-get update` |
## 4. Create the product (limited / private first)
1. **Server products → Create new product → AMI** (or AMI + CloudFormation).
2. Add title, description, categories, pricing (free or paid), regions, the AMI
id, recommended instance types, and the **usage instructions** (tell buyers
to read `/etc/x-ui/credentials.txt` / MOTD after first boot for the generated
admin login, then change the password).
3. Submit as a **Limited** (private) listing first. AWS publishes it with
restricted visibility so only your account / allow-listed accounts see it.
## 5. Preview & launch test
1. From the limited listing, **subscribe and launch** a test instance.
2. SSH in, `sudo cat /etc/x-ui/credentials.txt`, open the panel URL, log in,
confirm the panel works and the credentials are unique to that instance.
3. Launch a second instance and confirm its credentials differ (no shared
secrets).
## 6. Go public
1. Once the scan passes and the preview looks correct, request **public
visibility** (move from Limited to Public) in the listing.
2. AWS does a final review before the listing goes live.
## References
- AWS Marketplace seller guide: <https://docs.aws.amazon.com/marketplace/latest/userguide/>
- AMI-based product requirements: <https://docs.aws.amazon.com/marketplace/latest/userguide/product-and-ami-policies.html>
- Self-service AMI scanning: <https://docs.aws.amazon.com/marketplace/latest/userguide/product-submission.html>
+58
View File
@@ -0,0 +1,58 @@
# 3x-ui on Hetzner Cloud
Hetzner Cloud does **not** have a third-party image marketplace the way AWS does.
There are two practical ways to ship 3x-ui on Hetzner.
## Option A — cloud-init (recommended, no image build)
Use the generic user-data from [`../../cloud-init/`](../../cloud-init/). It installs
3x-ui non-interactively and generates unique per-instance credentials.
Web console: **Create Server → Cloud config** → paste
[`deploy/cloud-init/cloud-init.yaml`](../../cloud-init/cloud-init.yaml).
CLI:
```bash
hcloud server create \
--name xui-1 \
--type cx22 \
--image ubuntu-24.04 \
--user-data-from-file deploy/cloud-init/cloud-init.yaml
```
After boot, fetch the generated credentials:
```bash
ssh root@<server-ip> 'cat /etc/x-ui/install-result.env'
```
## Option B — snapshot from the qcow2 / a configured server
Hetzner lets you create a **snapshot** of a running server and launch new
servers from it. Two ways to get there:
1. **From the Packer qcow2:** Hetzner does not allow direct qcow2 upload via the
normal API, but you can boot a server, write the image to its disk in rescue
mode, then take a snapshot — or simply use Option A, which needs no image.
2. **From a configured server:** spin up a server, install via cloud-init
(Option A), verify, then **delete `/etc/x-ui/x-ui.db` and the first-boot
sentinel** before snapshotting so clones regenerate their own credentials:
```bash
systemctl stop x-ui
rm -f /etc/x-ui/x-ui.db /etc/x-ui/.firstboot-done /etc/x-ui/credentials.txt
# re-enable first-boot regeneration if you installed via Packer:
systemctl enable x-ui-firstboot 2>/dev/null || true
```
> ⚠️ If you snapshot a server **with** its `x-ui.db`, every clone shares the
> same admin credentials and session secret. Always remove the DB first.
## "App"-style listing
Hetzner's curated apps live in the community repo
[`github.com/hetznercloud/apps`](https://github.com/hetznercloud/apps): each app
is essentially a documented cloud-init config plus metadata. To propose 3x-ui as
a Hetzner app, follow that repo's contribution pattern and base the app's
cloud-config on [`deploy/cloud-init/cloud-init.yaml`](../../cloud-init/cloud-init.yaml).
+7
View File
@@ -0,0 +1,7 @@
# Packer build artifacts (never commit images or manifests)
output-qemu/
*.qcow2
*.raw
packer-manifest.json
packer_cache/
crash.log
+116
View File
@@ -0,0 +1,116 @@
# 3x-ui golden image (Packer)
Builds a cloud image with the 3x-ui panel pre-installed but **not configured**:
the image ships with **no database and no credentials**, and generates a unique
admin account on first boot. This is the **primary** path for AWS Marketplace
and any reusable image.
Two sources, one build:
| Source | Output | For |
| --- | --- | --- |
| `amazon-ebs` | AWS AMI | AWS / Marketplace |
| `qemu` | `qcow2` (+ `raw`) | Hetzner, DigitalOcean, Vultr, GCP, Azure, Oracle, bare metal |
Both sources build for **`amd64` and `arm64`** (select with `-var xui_arch=...`).
## Why no baked DB
3x-ui seeds a hardcoded `admin/admin` user and generates its session secret +
panel GUID the first time it starts. If an image shipped an initialized
`x-ui.db`, **every clone would share the same credentials and secret**. So the
build deliberately:
- installs the panel binary + systemd unit but **never starts it** and **never
creates a DB** (`scripts/provision.sh`);
- wipes any stray DB/credentials/host-keys at the end (`scripts/cleanup.sh`);
- enables `x-ui-firstboot.service`, which on first boot resets settings, sets a
random username/password on a random high port, regenerates the secret/GUID,
and writes the credentials to `/etc/x-ui/credentials.txt` + `/etc/motd`
(`deploy/firstboot/`).
## Prerequisites
- [Packer](https://developer.hashicorp.com/packer) ≥ 1.9
- For `qemu` amd64: `qemu-system-x86`, `qemu-utils` (and `/dev/kvm` for acceptable speed)
- For `qemu` arm64: `qemu-system-arm`, `qemu-efi-aarch64`, `qemu-utils` — best built on an
arm64 host (native KVM); cross-building from x86 works but uses slow TCG emulation
- For `amazon-ebs`: AWS credentials with EC2 build permissions (arm64 builds on a Graviton
instance such as `t4g.small`)
```bash
cd deploy/packer
packer init .
packer fmt -check . # formatting
packer validate . # both sources
```
## Build
Build a specific release (recommended) or `latest`:
```bash
# amd64 qcow2 (no cloud account needed)
packer build -only='qemu.x-ui' -var 'xui_version=v3.3.1' -var 'xui_arch=amd64' .
# arm64 qcow2 (run on an arm64 host for native KVM)
packer build -only='qemu.x-ui' -var 'xui_version=v3.3.1' -var 'xui_arch=arm64' .
# amd64 AWS AMI
packer build -only='amazon-ebs.x-ui' \
-var 'xui_version=v3.3.1' -var 'xui_arch=amd64' -var 'instance_type=t3.small' -var 'region=eu-central-1' .
# arm64 AWS AMI (Graviton)
packer build -only='amazon-ebs.x-ui' \
-var 'xui_version=v3.3.1' -var 'xui_arch=arm64' -var 'instance_type=t4g.small' -var 'region=eu-central-1' .
```
Outputs (per arch):
- `output-qemu/3x-ui-ubuntu-24.04-<arch>.qcow2` and `.raw`
- the AMI id (also recorded in `packer-manifest.json`)
If `/dev/kvm` is unavailable, add `-var 'qemu_accelerator=tcg'` (much slower).
## Key variables
See [`variables.pkr.hcl`](variables.pkr.hcl) for the full list.
| Variable | Default | Notes |
| --- | --- | --- |
| `xui_version` | `latest` | Release tag to install, e.g. `v3.3.1` |
| `xui_arch` | `amd64` | `amd64` or `arm64` (derives the base AMI / cloud image) |
| `region` | `eu-central-1` | AWS region (amazon-ebs) |
| `instance_type` | `t3.small` | EC2 build instance — must match the arch (`t4g.small` for arm64) |
| `qemu_accelerator` | `kvm` | `kvm` or `tcg` |
| `qemu_cpu` | `host` | arm64 `-cpu` model (`host` with KVM, `max` for TCG) |
| `ubuntu_version` | `24.04` | Base Ubuntu LTS (naming/tags) |
The CI workflow builds both arches automatically: amd64 qcow2 on a standard runner,
arm64 qcow2 on a native `ubuntu-24.04-arm` runner, and both AMIs from a single runner
(the build instance runs in AWS).
## First boot
On the first boot of any instance launched from the image:
1. `x-ui-firstboot.service` runs **before** `x-ui.service`.
2. It generates a unique admin username/password, a random panel port, a random
base path, and an API token.
3. Credentials are written to `/etc/x-ui/credentials.txt` (root-only) and shown
in `/etc/motd`. Retrieve them with `sudo cat /etc/x-ui/credentials.txt`.
4. The panel then starts on the random port. `admin/admin` never exists.
## CI
`.github/workflows/image.yml` runs this build on `release: published` (and via
`workflow_dispatch`), attaching the compressed `qcow2` to the release and
building the AMI when AWS credentials are configured.
## A note on host firewalls
`scripts/harden.sh` intentionally does **not** enable a restrictive host
firewall. 3x-ui opens Xray inbound ports on admin-chosen ports at runtime, which
a host firewall would block. Use your cloud provider's security groups/firewall
instead, and open the panel port + your inbound ports there. If you still want a
host firewall, add `ufw` rules in `harden.sh` allowing SSH, the panel port and
your inbound ports.
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
#
# cleanup.sh — strip all instance-specific state and secrets from the image.
#
# Runs LAST. The output image must contain no panel database, no credentials,
# no SSH host keys, and no baked authorized_keys. Fails the build if any of
# those survive.
set -euo pipefail
echo "[cleanup] removing panel database, credentials and first-boot sentinel..."
rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* 2> /dev/null || true
rm -f /etc/x-ui/install-result.env /etc/x-ui/credentials.txt 2> /dev/null || true
rm -f /etc/x-ui/.firstboot-done 2> /dev/null || true
echo "[cleanup] removing SSH host keys (regenerated on first boot)..."
rm -f /etc/ssh/ssh_host_* 2> /dev/null || true
echo "[cleanup] removing any baked authorized_keys..."
rm -f /root/.ssh/authorized_keys 2> /dev/null || true
find /home -maxdepth 3 -name authorized_keys -type f -delete 2> /dev/null || true
echo "[cleanup] resetting machine-id..."
truncate -s 0 /etc/machine-id 2> /dev/null || true
rm -f /var/lib/dbus/machine-id 2> /dev/null || true
ln -sf /etc/machine-id /var/lib/dbus/machine-id 2> /dev/null || true
echo "[cleanup] resetting cloud-init so it re-runs on the real first boot..."
cloud-init clean --logs --seed > /dev/null 2>&1 || rm -rf /var/lib/cloud/* 2> /dev/null || true
echo "[cleanup] truncating logs, history and package caches..."
find /var/log -type f -exec truncate -s 0 {} + 2> /dev/null || true
rm -rf /var/lib/x-ui /var/log/x-ui/* 2> /dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* 2> /dev/null || true
rm -f /root/.bash_history 2> /dev/null || true
find /home -maxdepth 3 -name .bash_history -type f -delete 2> /dev/null || true
rm -rf /tmp/firstboot 2> /dev/null || true
echo "[cleanup] verifying the image is clean..."
fail=0
for f in /etc/x-ui/x-ui.db /etc/x-ui/credentials.txt /etc/x-ui/install-result.env /etc/x-ui/.firstboot-done; do
if [ -e "$f" ]; then
echo "[cleanup] FATAL: $f is present in the image" >&2
fail=1
fi
done
if ls /etc/ssh/ssh_host_* > /dev/null 2>&1; then
echo "[cleanup] FATAL: SSH host keys present in the image" >&2
fail=1
fi
if [ -e /root/.ssh/authorized_keys ]; then
echo "[cleanup] FATAL: /root/.ssh/authorized_keys present in the image" >&2
fail=1
fi
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo "[cleanup] OK — no DB, no credentials, no host keys, no authorized_keys."
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
#
# harden.sh — baseline OS hardening for AWS Marketplace AMI scanner compliance.
#
# Focus: the controls the scanner actually checks — key-only SSH, no root
# password login, and no default OS account passwords. A restrictive host
# firewall is intentionally NOT enforced by default because 3x-ui opens Xray
# inbound ports on admin-chosen ports at runtime (see README for the rationale
# and how to add ufw rules if you want them).
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
echo "[harden] applying SSH hardening..."
install -d -m 755 /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/99-3xui-hardening.conf << 'EOF'
# 3x-ui golden image hardening (AWS Marketplace scanner compliance)
PasswordAuthentication no
PermitRootLogin prohibit-password
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
EOF
chmod 644 /etc/ssh/sshd_config.d/99-3xui-hardening.conf
echo "[harden] locking passwords on default OS accounts..."
# No account may ship with a usable password. Keys are provisioned per-instance
# by the cloud platform (EC2 metadata / cloud-init) on first boot.
# passwd -l locks the PASSWORD only; key-based login keeps working.
for u in root ubuntu admin; do
if id "$u" > /dev/null 2>&1; then
passwd -l "$u" > /dev/null 2>&1 || true
fi
done
echo "[harden] enabling automatic security updates..."
apt-get update
apt-get install -y --no-install-recommends unattended-upgrades
systemctl enable unattended-upgrades > /dev/null 2>&1 || true
echo "[harden] done."
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
#
# provision.sh — install the 3x-ui panel into a golden image (Packer).
#
# Self-contained: mirrors install.sh's download/extract logic but DELIBERATELY
# does NOT run config_after_install and does NOT create a database. The image
# must ship without /etc/x-ui/x-ui.db so that deploy/firstboot generates unique
# per-instance credentials on first boot. Both x-ui.service and
# x-ui-firstboot.service are enabled but NOT started here.
#
# Inputs (from Packer environment_vars):
# XUI_VERSION release tag (e.g. v3.3.1) or 'latest'
# XUI_ARCH amd64 (default) or arm64
set -euo pipefail
XUI_VERSION="${XUI_VERSION:-latest}"
XUI_ARCH="${XUI_ARCH:-amd64}"
XUI_DIR="/usr/local/x-ui"
REPO="MHSanaei/3x-ui"
export DEBIAN_FRONTEND=noninteractive
echo "[provision] installing base packages..."
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates curl tar tzdata socat openssl cron jq
echo "[provision] resolving 3x-ui version..."
if [ "$XUI_VERSION" = "latest" ]; then
XUI_VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r '.tag_name')
fi
if [ -z "$XUI_VERSION" ] || [ "$XUI_VERSION" = "null" ]; then
echo "[provision] ERROR: could not resolve 3x-ui release tag" >&2
exit 1
fi
echo "[provision] installing 3x-ui ${XUI_VERSION} (${XUI_ARCH})"
tarball="x-ui-linux-${XUI_ARCH}.tar.gz"
url="https://github.com/${REPO}/releases/download/${XUI_VERSION}/${tarball}"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
# Download the RELEASED binary tarball (no Go build inside the image).
curl -fL4 --retry 3 -o "${tmp}/${tarball}" "$url"
# Extract into /usr/local/ (the tarball contains an x-ui/ directory).
systemctl stop x-ui > /dev/null 2>&1 || true
rm -rf "$XUI_DIR"
tar -xzf "${tmp}/${tarball}" -C /usr/local/
chmod +x "${XUI_DIR}/x-ui" "${XUI_DIR}/x-ui.sh"
chmod +x "${XUI_DIR}"/bin/* 2> /dev/null || true
# Install the x-ui management CLI.
if [ -f "${XUI_DIR}/x-ui.sh" ]; then
cp -f "${XUI_DIR}/x-ui.sh" /usr/bin/x-ui
else
curl -fL4 -o /usr/bin/x-ui "https://raw.githubusercontent.com/${REPO}/main/x-ui.sh"
fi
chmod +x /usr/bin/x-ui
mkdir -p /var/log/x-ui
# Panel systemd unit (Ubuntu base => debian variant).
install -m 644 "${XUI_DIR}/x-ui.service.debian" /etc/systemd/system/x-ui.service
# First-boot per-instance credential unit + script (uploaded to /tmp/firstboot).
install -m 755 /tmp/firstboot/x-ui-firstboot.sh "${XUI_DIR}/x-ui-firstboot.sh"
install -m 644 /tmp/firstboot/x-ui-firstboot.service /etc/systemd/system/x-ui-firstboot.service
systemctl daemon-reload
# Enable (start on next boot) but do NOT start now — there is no DB yet.
systemctl enable x-ui-firstboot.service
systemctl enable x-ui.service
# Belt-and-braces: ensure no DB / sentinel was created during provisioning.
rm -f /etc/x-ui/x-ui.db /etc/x-ui/x-ui.db-* /etc/x-ui/.firstboot-done 2> /dev/null || true
echo "[provision] done — panel installed, services enabled, NO database initialized."
+109
View File
@@ -0,0 +1,109 @@
// Input variables for the 3x-ui golden-image build.
// See README.md for usage. Override with -var / -var-file or env (PKR_VAR_*).
variable "xui_version" {
type = string
description = "3x-ui release tag to install, e.g. v3.3.1. 'latest' resolves the newest GitHub release at build time."
default = "latest"
}
variable "xui_arch" {
type = string
description = "CPU architecture to build for: amd64 or arm64."
default = "amd64"
validation {
condition = contains(["amd64", "arm64"], var.xui_arch)
error_message = "The xui_arch value must be 'amd64' or 'arm64'."
}
}
variable "ubuntu_version" {
type = string
description = "Ubuntu LTS version label, used only for image naming/tags."
default = "24.04"
}
// --- amazon-ebs (AMI) ---------------------------------------------------------
variable "region" {
type = string
description = "AWS region the AMI is built in."
default = "eu-central-1"
}
variable "instance_type" {
type = string
description = "EC2 instance type used to build the AMI. Must match xui_arch (e.g. t3.small for amd64, t4g.small for arm64/Graviton)."
default = "t3.small"
}
variable "ami_name_prefix" {
type = string
description = "Prefix for the produced AMI name."
default = "3x-ui"
}
variable "source_ami_filter_name" {
type = string
description = "Override for the Canonical Ubuntu base AMI name filter. Empty ⇒ derived from xui_arch (latest patched 24.04 LTS for that arch)."
default = ""
}
variable "ssh_username" {
type = string
description = "Default SSH user on the base Ubuntu cloud image."
default = "ubuntu"
}
// --- qemu (qcow2 / raw) -------------------------------------------------------
variable "qemu_iso_url" {
type = string
description = "Override for the Ubuntu cloud image used as the qemu base disk. Empty ⇒ derived from xui_arch (amd64/arm64 cloud image)."
default = ""
}
variable "qemu_iso_checksum" {
type = string
description = "Checksum for the qemu base disk. 'file:<SHA256SUMS url>' auto-fetches; 'none' skips verification."
default = "file:https://cloud-images.ubuntu.com/releases/24.04/release/SHA256SUMS"
}
variable "qemu_accelerator" {
type = string
description = "QEMU accelerator: 'kvm' when /dev/kvm is available, else 'tcg' (slow software emulation)."
default = "kvm"
}
variable "qemu_headless" {
type = bool
description = "Run QEMU without a display (required on CI runners)."
default = true
}
variable "qemu_build_password" {
type = string
description = "Temporary password injected via cloud-init for Packer's build-time SSH. Locked/removed before the image is finalized."
default = "packer-build-temp-pw"
sensitive = true
}
# --- qemu arm64-only knobs (ignored for amd64) -------------------------------
variable "qemu_cpu" {
type = string
description = "QEMU -cpu model for arm64 builds: 'host' with KVM on an arm64 host, 'max' for TCG emulation."
default = "host"
}
variable "qemu_efi_code" {
type = string
description = "Path to the arm64 UEFI code firmware (AAVMF). Only used when xui_arch=arm64."
default = "/usr/share/AAVMF/AAVMF_CODE.fd"
}
variable "qemu_efi_vars" {
type = string
description = "Path to the arm64 UEFI vars firmware template (AAVMF). Only used when xui_arch=arm64."
default = "/usr/share/AAVMF/AAVMF_VARS.fd"
}
+160
View File
@@ -0,0 +1,160 @@
// 3x-ui golden image — one build, two sources:
// * amazon-ebs : produces an AWS AMI (Marketplace-scannable)
// * qemu : produces a qcow2 (+ raw) for Hetzner/DO/Vultr/GCP/Azure/Oracle
//
// The image ships WITHOUT an initialized x-ui.db and WITHOUT any baked
// credentials. deploy/firstboot/x-ui-firstboot.{sh,service} generates unique
// per-instance credentials on first boot, before x-ui.service starts.
//
// Provisioner order is fixed: provision.sh -> harden.sh -> cleanup.sh.
packer {
required_plugins {
amazon = {
version = ">= 1.3.0"
source = "github.com/hashicorp/amazon"
}
qemu = {
version = ">= 1.1.0"
source = "github.com/hashicorp/qemu"
}
}
}
locals {
build_stamp = formatdate("YYYYMMDD-hhmmss", timestamp())
image_name = "${var.ami_name_prefix}-ubuntu-${var.ubuntu_version}-${var.xui_arch}"
is_arm = var.xui_arch == "arm64"
# Base images are derived from xui_arch unless explicitly overridden.
source_ami_name = var.source_ami_filter_name != "" ? var.source_ami_filter_name : "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-${var.xui_arch}-server-*"
qemu_iso_url = var.qemu_iso_url != "" ? var.qemu_iso_url : "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-${var.xui_arch}.img"
}
source "amazon-ebs" "x-ui" {
region = var.region
instance_type = var.instance_type
ssh_username = var.ssh_username
ami_name = "${local.image_name}-${var.xui_version}-${local.build_stamp}"
ami_description = "3x-ui panel on Ubuntu ${var.ubuntu_version}. Per-instance credentials are generated on first boot."
source_ami_filter {
filters = {
name = local.source_ami_name
root-device-type = "ebs"
virtualization-type = "hvm"
}
owners = ["099720109477"] // Canonical
most_recent = true
}
launch_block_device_mappings {
device_name = "/dev/sda1"
volume_size = 8
volume_type = "gp3"
delete_on_termination = true
}
tags = {
Name = local.image_name
Project = "3x-ui"
XuiVersion = var.xui_version
BuildTool = "packer"
BaseOS = "ubuntu-${var.ubuntu_version}"
}
}
source "qemu" "x-ui" {
iso_url = local.qemu_iso_url
iso_checksum = var.qemu_iso_checksum
disk_image = true
disk_size = "10G"
format = "qcow2"
accelerator = var.qemu_accelerator
headless = var.qemu_headless
cpus = 2
memory = 2048
net_device = "virtio-net"
disk_interface = "virtio"
// Arch-specific QEMU machine. amd64 uses Packer defaults (BIOS boot, x86_64);
// arm64 needs the aarch64 binary, the 'virt' machine and UEFI (AAVMF) firmware.
qemu_binary = local.is_arm ? "qemu-system-aarch64" : null
machine_type = local.is_arm ? "virt" : null
efi_boot = local.is_arm
efi_firmware_code = local.is_arm ? var.qemu_efi_code : null
efi_firmware_vars = local.is_arm ? var.qemu_efi_vars : null
qemuargs = local.is_arm ? [["-cpu", var.qemu_cpu]] : []
output_directory = "output-qemu"
vm_name = "${local.image_name}.qcow2"
// Build-time access: a NoCloud seed sets a temporary password for the default
// user so Packer can SSH in. The seed is a separate CD-ROM (not part of the
// output disk); the password is locked by harden.sh and state wiped by cleanup.sh.
cd_label = "cidata"
cd_content = {
"meta-data" = ""
"user-data" = <<-EOT
#cloud-config
password: ${var.qemu_build_password}
chpasswd: { expire: false }
ssh_pwauth: true
EOT
}
ssh_username = var.ssh_username
ssh_password = var.qemu_build_password
ssh_timeout = "20m"
boot_wait = "45s"
shutdown_command = "sudo shutdown -P now"
}
build {
name = "3x-ui"
sources = ["source.amazon-ebs.x-ui", "source.qemu.x-ui"]
// Upload the first-boot unit + script so provision.sh can install them.
provisioner "shell" {
inline = ["mkdir -p /tmp/firstboot"]
}
provisioner "file" {
source = "${path.root}/../firstboot/x-ui-firstboot.sh"
destination = "/tmp/firstboot/x-ui-firstboot.sh"
}
provisioner "file" {
source = "${path.root}/../firstboot/x-ui-firstboot.service"
destination = "/tmp/firstboot/x-ui-firstboot.service"
}
provisioner "shell" {
environment_vars = [
"XUI_VERSION=${var.xui_version}",
"XUI_ARCH=${var.xui_arch}",
"DEBIAN_FRONTEND=noninteractive",
]
execute_command = "chmod +x {{ .Path }}; sudo -E bash {{ .Path }}"
scripts = [
"${path.root}/scripts/provision.sh",
"${path.root}/scripts/harden.sh",
"${path.root}/scripts/cleanup.sh",
]
// give cloud-init time to release apt locks on the very first boot
pause_before = "10s"
}
// Convert the qcow2 to raw for clouds that need it (qemu source only).
post-processor "shell-local" {
only = ["qemu.x-ui"]
inline = ["qemu-img convert -p -O raw output-qemu/${local.image_name}.qcow2 output-qemu/${local.image_name}.raw"]
}
// Record the AMI id / artifacts for CI to surface.
post-processor "manifest" {
output = "packer-manifest.json"
strip_path = true
}
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# smoke-firstboot.sh — verify the first-boot per-instance credential script.
#
# Installs the released x-ui binary into a container WITHOUT a database, runs
# x-ui-firstboot.sh, and asserts:
# * fresh random credentials are generated (no admin/admin)
# * /etc/x-ui/credentials.txt (600) and /etc/motd are written
# * the sentinel is created and a second run is a no-op (creds unchanged)
#
# Requires Docker and network access. Usage: bash deploy/test/smoke-firstboot.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
IMAGE="${SMOKE_IMAGE:-ubuntu:24.04}"
if ! command -v docker > /dev/null 2>&1; then
echo "ERROR: docker is required for this smoke test." >&2
exit 1
fi
echo "== first-boot credential smoke test (image: $IMAGE) =="
docker run --rm \
-v "${REPO_ROOT}/deploy/firstboot/x-ui-firstboot.sh:/root/x-ui-firstboot.sh:ro" \
-e DEBIAN_FRONTEND=noninteractive \
"$IMAGE" bash -euo pipefail -c '
apt-get update -qq
apt-get install -y -qq curl tar openssl ca-certificates jq > /dev/null
echo "--- installing released x-ui binary (no DB, no systemd) ---"
REPO=MHSanaei/3x-ui
ARCH=$(dpkg --print-architecture) # amd64 | arm64
echo "container arch: $ARCH"
VER=$(curl --fail --location --silent --show-error \
--retry 5 --retry-all-errors --retry-delay 3 \
--connect-timeout 15 --max-time 60 \
"https://api.github.com/repos/${REPO}/releases/latest" | jq -r .tag_name)
[ -n "$VER" ] && [ "$VER" != "null" ] || { echo "FAIL: cannot resolve version"; exit 1; }
tmp=$(mktemp -d)
# 504s and other transient GitHub/CDN hiccups are retried; a real HTTP
# failure (e.g. missing arch asset) still aborts after the retries.
if ! curl -4 --fail --location --silent --show-error \
--retry 5 --retry-all-errors --retry-delay 3 \
--connect-timeout 15 --max-time 300 \
-o "${tmp}/x.tar.gz" \
"https://github.com/${REPO}/releases/download/${VER}/x-ui-linux-${ARCH}.tar.gz"; then
echo "FAIL: cannot download x-ui-linux-${ARCH}.tar.gz (${VER})" >&2; exit 1
fi
test -s "${tmp}/x.tar.gz" || { echo "FAIL: downloaded tarball is empty"; exit 1; }
tar -xzf "${tmp}/x.tar.gz" -C /usr/local/
chmod +x /usr/local/x-ui/x-ui
install -m 755 /root/x-ui-firstboot.sh /usr/local/x-ui/x-ui-firstboot.sh
# Guarantee a clean slate (the image must never ship a DB).
rm -f /etc/x-ui/x-ui.db /etc/x-ui/.firstboot-done
echo "--- run 1: generate per-instance credentials ---"
/usr/local/x-ui/x-ui-firstboot.sh
test -f /etc/x-ui/.firstboot-done || { echo "FAIL: sentinel not created"; exit 1; }
test -f /etc/x-ui/credentials.txt || { echo "FAIL: credentials.txt missing"; exit 1; }
perms=$(stat -c %a /etc/x-ui/credentials.txt)
[ "$perms" = "600" ] || { echo "FAIL: credentials.txt perms=$perms (want 600)"; exit 1; }
grep -q "3x-ui" /etc/motd || { echo "FAIL: motd not written"; exit 1; }
# shellcheck disable=SC1090
. /etc/x-ui/credentials.txt
[ -n "${XUI_USERNAME:-}" ] && [ "$XUI_USERNAME" != "admin" ] \
|| { echo "FAIL: username missing or still admin"; exit 1; }
first_user="$XUI_USERNAME"
/usr/local/x-ui/x-ui setting -show | grep -q "hasDefaultCredential: false" \
|| { echo "FAIL: hasDefaultCredential is not false"; exit 1; }
echo "--- run 2: must be a no-op (sentinel honored) ---"
/usr/local/x-ui/x-ui-firstboot.sh
# shellcheck disable=SC1090
. /etc/x-ui/credentials.txt
[ "$XUI_USERNAME" = "$first_user" ] \
|| { echo "FAIL: credentials changed on re-run"; exit 1; }
echo "SMOKE_PASS: firstboot user=$first_user (stable across re-run)"
'
echo "== first-boot smoke test PASSED =="
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
#
# smoke-noninteractive.sh — verify the non-interactive install path.
#
# Runs install.sh inside an Ubuntu container with NO TTY (piped) and
# XUI_NONINTERACTIVE=1, then asserts:
# * /etc/x-ui/install-result.env exists (mode 600) with random, non-default creds
# * the panel reports hasDefaultCredential: false (no admin/admin remains)
# * the panel HTTP server actually serves on the generated port/base path
#
# Requires Docker and network access (install.sh downloads the released binary).
# Usage: bash deploy/test/smoke-noninteractive.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
IMAGE="${SMOKE_IMAGE:-ubuntu:24.04}"
if ! command -v docker > /dev/null 2>&1; then
echo "ERROR: docker is required for this smoke test." >&2
exit 1
fi
echo "== non-interactive install smoke test (image: $IMAGE) =="
docker run --rm \
-v "${REPO_ROOT}/install.sh:/root/install.sh:ro" \
-e XUI_NONINTERACTIVE=1 \
-e XUI_SSL_MODE=none \
-e DEBIAN_FRONTEND=noninteractive \
"$IMAGE" bash -euo pipefail -c '
apt-get update -qq
apt-get install -y -qq curl tar openssl ca-certificates > /dev/null
echo "--- running install.sh piped (no TTY) ---"
# Piping guarantees stdin is not a TTY, exercising the auto non-interactive path.
cat /root/install.sh | bash
echo "--- assertions ---"
RESULT=/etc/x-ui/install-result.env
test -f "$RESULT" || { echo "FAIL: $RESULT missing"; exit 1; }
perms=$(stat -c %a "$RESULT")
[ "$perms" = "600" ] || { echo "FAIL: $RESULT perms=$perms (want 600)"; exit 1; }
# shellcheck disable=SC1090
. "$RESULT"
[ -n "${XUI_USERNAME:-}" ] && [ "$XUI_USERNAME" != "admin" ] \
|| { echo "FAIL: username missing or still admin"; exit 1; }
[ -n "${XUI_PASSWORD:-}" ] && [ "$XUI_PASSWORD" != "admin" ] \
|| { echo "FAIL: password missing or still admin"; exit 1; }
[ -n "${XUI_PANEL_PORT:-}" ] || { echo "FAIL: port missing"; exit 1; }
# No default admin in the DB.
/usr/local/x-ui/x-ui setting -show | grep -q "hasDefaultCredential: false" \
|| { echo "FAIL: hasDefaultCredential is not false"; exit 1; }
echo "--- verifying the panel serves HTTP ---"
cd /usr/local/x-ui
./x-ui > /tmp/xui.log 2>&1 &
xpid=$!
for _ in $(seq 1 15); do
code=$(curl -s -o /dev/null -w "%{http_code}" \
"http://127.0.0.1:${XUI_PANEL_PORT}/${XUI_WEB_BASE_PATH}/" 2>/dev/null || true)
case "$code" in 200|301|302|307|308) break ;; esac
sleep 1
done
kill "$xpid" 2>/dev/null || true
echo "panel HTTP status: ${code:-none}"
case "${code:-}" in
200|301|302|307|308) : ;;
*) echo "FAIL: panel did not serve (status ${code:-none})"; tail -n 30 /tmp/xui.log; exit 1 ;;
esac
echo "SMOKE_PASS: user=$XUI_USERNAME port=$XUI_PANEL_PORT path=$XUI_WEB_BASE_PATH"
'
echo "== non-interactive smoke test PASSED =="
+3 -1
View File
@@ -19,6 +19,7 @@ services:
XRAY_VMESS_AEAD_FORCED: "false"
XUI_ENABLE_FAIL2BAN: "true"
# XUI_INIT_WEB_BASE_PATH: "/"
# XUI_PORT: "8080"
# To use PostgreSQL instead of the default SQLite, run:
# docker compose --profile postgres up -d
# and uncomment the two lines below.
@@ -26,6 +27,7 @@ services:
# XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
tty: true
ports:
# When XUI_PORT is set, publish the same container port (for example "8080:8080").
- "2053:2053"
restart: unless-stopped
@@ -39,4 +41,4 @@ services:
POSTGRES_DB: xui
volumes:
- $PWD/pgdata/:/var/lib/postgresql/data
restart: unless-stopped
restart: unless-stopped
+102
View File
@@ -0,0 +1,102 @@
# Capturing the Real Client IP
When an Xray inbound sits behind an intermediary — a CDN like Cloudflare, an L4 tunnel/relay,
or another panel — the IP that Xray sees is the **intermediary's** address, not the visitor's.
That intermediary IP is what shows up in the panel's online/IP view and what the per-client
**IP limit** counts against, which makes both useless behind a proxy.
Xray-core can recover the real visitor IP. 3x-ui exposes the two mechanisms in the inbound form
and feeds the recovered IP into the same pipeline that drives IP-limit enforcement, the online
list, and multi-node sync — so once it is set, everything downstream just works.
## Where to set it
Open an inbound → **Transport / Stream Settings** → enable **Sockopt** → use the
**Real client IP** preset selector:
| Preset | What it does | Use for |
|---|---|---|
| **Off / direct** | Clears both fields. | Inbound reachable directly by clients. |
| **Cloudflare CDN** | Sets `sockopt.trustedXForwardedFor = ["CF-Connecting-IP"]`. | WebSocket / HTTPUpgrade / XHTTP behind Cloudflare's CDN (orange cloud). |
| **L4 relay / Spectrum (PROXY)** | Sets `acceptProxyProtocol = true`. | An L4 tunnel/relay in front, or Cloudflare **Spectrum**. |
The raw `Proxy Protocol` switch and `Trusted X-Forwarded-For` list stay visible below the preset
selector for manual / advanced tuning — the presets just fill them in for you.
## Scenario 1 — Cloudflare CDN
Cloudflare's CDN (the orange cloud) forwards the visitor's IP in the `CF-Connecting-IP` request
header. Xray reads it when the transport is **WebSocket**, **HTTPUpgrade**, or **XHTTP** and
the header name is listed in `sockopt.trustedXForwardedFor`.
```json
"streamSettings": {
"network": "ws",
"sockopt": { "trustedXForwardedFor": ["CF-Connecting-IP"] }
}
```
Pick the **Cloudflare CDN** preset. You can add `X-Real-IP`, `True-Client-IP`, or `X-Client-IP`
to the list if a different upstream uses those.
> This is **not** the same as Cloudflare Spectrum. The free/CDN tier forwards HTTP headers — use
> this scenario. Spectrum (a TCP/L4 product) can send the PROXY protocol — use Scenario 2.
## Scenario 2 — L4 tunnel / relay or Cloudflare Spectrum (PROXY protocol)
For a TCP-level front (HAProxy, gost, nginx `stream`, an Xray dokodemo-door relay, or Cloudflare
Spectrum), the real IP is carried in the **PROXY protocol** header. Enable
`acceptProxyProtocol` and make sure the **upstream emits PROXY protocol** — otherwise the
connection will fail.
```json
"streamSettings": {
"network": "tcp",
"sockopt": { "acceptProxyProtocol": true }
}
```
Pick the **L4 relay / Spectrum (PROXY)** preset. Works on TCP/RAW, WebSocket, HTTPUpgrade, gRPC
and XHTTP; **not** on mKCP. The front must be configured to send the header, e.g.:
- **HAProxy**: `server backend 127.0.0.1:443 send-proxy` (or `send-proxy-v2`).
- **nginx** (`stream {}` block): `proxy_protocol on;` on the `server`, and on the upstream side
`proxy_protocol on;` in the `server` that connects to Xray.
## Transport support matrix
| Mechanism | TCP/RAW | mKCP | WebSocket | gRPC | HTTPUpgrade | XHTTP |
|---|:--:|:--:|:--:|:--:|:--:|:--:|
| `trustedXForwardedFor` (header) | – | – | ✅ | – | ✅ | ✅ |
| `acceptProxyProtocol` (PROXY) | ✅ | – | ✅ | ✅ | ✅ | ✅ |
The form shows a warning when you select a preset that the current transport cannot honor.
> **Use one, not both.** `acceptProxyProtocol` and `trustedXForwardedFor` are independent — the
> first reads the real IP from the L4 PROXY header, the second from an HTTP request header. On
> WebSocket / HTTPUpgrade / XHTTP, xray applies the HTTP header *last*, so a stale
> `trustedXForwardedFor` would override (and defeat) a PROXY-protocol setup. The presets are
> mutually exclusive and clear the other field for you; only mix them by hand if you know your
> upstream chain needs it.
## Multi-node
No extra configuration is needed. The inbound's `streamSettings` (including these sockopt
fields) is pushed to child nodes verbatim, so the node's Xray records the real IP, and the
parent panel pulls each node's per-client IPs roughly every 10 seconds. The real visitor IP
shows up on the parent automatically.
## Security note
Both `acceptProxyProtocol` and `trustedXForwardedFor` are **server-side only** — they are
stripped from subscription output, so they never reach clients. Only enable
`trustedXForwardedFor` when the inbound is genuinely behind a trusted proxy that sets the
header; otherwise a client could spoof the header and forge its own source IP.
## Verifying
1. Set the preset and save the inbound.
2. Inspect the generated Xray config and confirm `streamSettings.sockopt` carries the expected
field (`trustedXForwardedFor` or `acceptProxyProtocol`).
3. Connect through the intermediary, then open the client's IPs / online view in the panel — it
should show the real visitor IP rather than the CDN/relay address.
+1
View File
@@ -2,3 +2,4 @@ node_modules/
.vite/
*.log
*.tsbuildinfo
coverage/
+315 -134
View File
@@ -13,8 +13,8 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"antd": "^6.4.3",
"axios": "^1.17.0",
"antd": "^6.4.4",
"axios": "^1.18.0",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.1",
@@ -24,7 +24,7 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.16.0",
"react-router-dom": "^7.17.0",
"recharts": "^3.8.1",
"swagger-ui-react": "^5.32.6",
"zod": "^4.4.3"
@@ -33,18 +33,19 @@
"@eslint/js": "^10.0.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.16",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.1",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"typescript-eslint": "^8.61.1",
"vite": "8.0.16",
"vitest": "^4.1.8"
"vitest": "^4.1.9"
},
"engines": {
"node": ">=22.0.0",
@@ -456,6 +457,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -617,9 +628,9 @@
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz",
"integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==",
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz",
"integrity": "sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==",
"dev": true,
"funding": [
{
@@ -1236,9 +1247,9 @@
}
},
"node_modules/@rc-component/form": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.3.tgz",
"integrity": "sha512-jNkat3uxZ246ELudKwnjQhnDI8+rSxgLxjztvQU3Mrb0G+LwDyOrPu9RNfekOjqU5GQ5QJepi225x+9LhCizJw==",
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.4.tgz",
"integrity": "sha512-I2FHNMWoiGQNjC+hQFhAj/rQeScAIBc+AkZqvu4Zyaxe4I3WOVpQte2E5lyZhruswyT8aULYHu1clPaPwE9L2A==",
"license": "MIT",
"dependencies": {
"@rc-component/async-validator": "^6.0.0",
@@ -3096,17 +3107,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
"integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
"integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/type-utils": "8.61.0",
"@typescript-eslint/utils": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/type-utils": "8.61.1",
"@typescript-eslint/utils": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -3119,7 +3130,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.61.0",
"@typescript-eslint/parser": "^8.61.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
@@ -3135,16 +3146,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz",
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3"
},
"engines": {
@@ -3160,14 +3171,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
"integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.61.0",
"@typescript-eslint/types": "^8.61.0",
"@typescript-eslint/tsconfig-utils": "^8.61.1",
"@typescript-eslint/types": "^8.61.1",
"debug": "^4.4.3"
},
"engines": {
@@ -3182,14 +3193,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
"integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0"
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3200,9 +3211,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
"integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3217,15 +3228,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz",
"integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
"integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/utils": "8.61.0",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/utils": "8.61.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -3242,9 +3253,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
"integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3256,16 +3267,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
"integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.61.0",
"@typescript-eslint/tsconfig-utils": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"@typescript-eslint/project-service": "8.61.1",
"@typescript-eslint/tsconfig-utils": "8.61.1",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -3297,16 +3308,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz",
"integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz",
"integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0"
"@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3321,13 +3332,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
"integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/types": "8.61.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -3364,17 +3375,48 @@
}
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.9",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.9",
"vitest": "4.1.9"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -3383,13 +3425,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.8",
"@vitest/spy": "4.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -3410,9 +3452,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3423,13 +3465,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.8",
"@vitest/utils": "4.1.9",
"pathe": "^2.0.3"
},
"funding": {
@@ -3437,14 +3479,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.8",
"@vitest/utils": "4.1.8",
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -3453,9 +3495,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3463,13 +3505,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.8",
"@vitest/pretty-format": "4.1.9",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -3647,6 +3689,25 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
"integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3678,9 +3739,9 @@
}
},
"node_modules/axios": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
@@ -4941,6 +5002,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
@@ -5067,6 +5138,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -5320,6 +5398,45 @@
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/js-file-download": {
"version": "0.4.12",
"resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz",
@@ -5333,9 +5450,19 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -5832,6 +5959,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7033,6 +7201,19 @@
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/swagger-client": {
"version": "3.37.4",
"resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.37.4.tgz",
@@ -7175,22 +7356,22 @@
}
},
"node_modules/tldts": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
"integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz",
"integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.2"
"tldts-core": "^7.4.3"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
"integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz",
"integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==",
"dev": true,
"license": "MIT"
},
@@ -7366,16 +7547,16 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz",
"integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==",
"version": "8.61.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz",
"integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.61.0",
"@typescript-eslint/parser": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/utils": "8.61.0"
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/utils": "8.61.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7390,9 +7571,9 @@
}
},
"node_modules/undici": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7566,19 +7747,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.8",
"@vitest/pretty-format": "4.1.8",
"@vitest/runner": "4.1.8",
"@vitest/snapshot": "4.1.8",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -7606,12 +7787,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.8",
"@vitest/browser-preview": "4.1.8",
"@vitest/browser-webdriverio": "4.1.8",
"@vitest/coverage-istanbul": "4.1.8",
"@vitest/coverage-v8": "4.1.8",
"@vitest/ui": "4.1.8",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+12 -8
View File
@@ -26,8 +26,8 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"antd": "^6.4.3",
"axios": "^1.17.0",
"antd": "^6.4.4",
"axios": "^1.18.0",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.1",
@@ -37,7 +37,7 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.16.0",
"react-router-dom": "^7.17.0",
"recharts": "^3.8.1",
"swagger-ui-react": "^5.32.6",
"zod": "^4.4.3"
@@ -46,24 +46,28 @@
"@eslint/js": "^10.0.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.16",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/swagger-ui-react": "^5.18.0",
"@vitejs/plugin-react": "^6.0.2",
"eslint": "^10.4.1",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.5.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"typescript-eslint": "^8.61.1",
"vite": "8.0.16",
"vitest": "^4.1.8"
"vitest": "^4.1.9"
},
"overrides": {
"react-copy-to-clipboard": "^5.1.1",
"react-inspector": "^9.0.0",
"react-debounce-input": {
"react": "^19.0.0"
},
"swagger-ui-react": {
"js-yaml": "^4.2.0"
}
},
"allowScripts": {
@@ -73,4 +77,4 @@
"core-js-pure": false,
"tree-sitter-json": false
}
}
}
+391 -12
View File
@@ -138,6 +138,46 @@
"minimum": 1,
"type": "integer"
},
"smtpCpu": {
"description": "CPU threshold for email notifications",
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpEnable": {
"description": "Email (SMTP) notification settings\nEnable email notifications",
"type": "boolean"
},
"smtpEnabledEvents": {
"description": "Comma-separated event types to send via email",
"type": "string"
},
"smtpEncryptionType": {
"description": "SMTP encryption: none, starttls, tls",
"type": "string"
},
"smtpHost": {
"description": "SMTP server host",
"type": "string"
},
"smtpPassword": {
"description": "SMTP password",
"type": "string"
},
"smtpPort": {
"description": "SMTP server port",
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"smtpTo": {
"description": "Comma-separated recipient emails",
"type": "string"
},
"smtpUsername": {
"description": "SMTP username",
"type": "string"
},
"subAnnounce": {
"description": "Subscription announce",
"type": "string"
@@ -277,10 +317,6 @@
"description": "Telegram bot settings\nEnable Telegram bot notifications",
"type": "boolean"
},
"tgBotLoginNotify": {
"description": "Send login notifications",
"type": "boolean"
},
"tgBotProxy": {
"description": "Proxy URL for Telegram bot",
"type": "string"
@@ -295,6 +331,10 @@
"minimum": 0,
"type": "integer"
},
"tgEnabledEvents": {
"description": "Comma-separated event types to send via Telegram",
"type": "string"
},
"tgLang": {
"description": "Telegram bot language",
"type": "string"
@@ -387,6 +427,15 @@
"remarkModel",
"restartXrayOnClientDisable",
"sessionMaxAge",
"smtpCpu",
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpHost",
"smtpPassword",
"smtpPort",
"smtpTo",
"smtpUsername",
"subAnnounce",
"subCertFile",
"subClashEnable",
@@ -421,10 +470,10 @@
"tgBotBackup",
"tgBotChatId",
"tgBotEnable",
"tgBotLoginNotify",
"tgBotProxy",
"tgBotToken",
"tgCpu",
"tgEnabledEvents",
"tgLang",
"tgRunTime",
"timeLocation",
@@ -471,6 +520,9 @@
"hasNordSecret": {
"type": "boolean"
},
"hasSmtpPassword": {
"type": "boolean"
},
"hasTgBotToken": {
"type": "boolean"
},
@@ -572,6 +624,46 @@
"minimum": 1,
"type": "integer"
},
"smtpCpu": {
"description": "CPU threshold for email notifications",
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpEnable": {
"description": "Email (SMTP) notification settings\nEnable email notifications",
"type": "boolean"
},
"smtpEnabledEvents": {
"description": "Comma-separated event types to send via email",
"type": "string"
},
"smtpEncryptionType": {
"description": "SMTP encryption: none, starttls, tls",
"type": "string"
},
"smtpHost": {
"description": "SMTP server host",
"type": "string"
},
"smtpPassword": {
"description": "SMTP password",
"type": "string"
},
"smtpPort": {
"description": "SMTP server port",
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"smtpTo": {
"description": "Comma-separated recipient emails",
"type": "string"
},
"smtpUsername": {
"description": "SMTP username",
"type": "string"
},
"subAnnounce": {
"description": "Subscription announce",
"type": "string"
@@ -711,10 +803,6 @@
"description": "Telegram bot settings\nEnable Telegram bot notifications",
"type": "boolean"
},
"tgBotLoginNotify": {
"description": "Send login notifications",
"type": "boolean"
},
"tgBotProxy": {
"description": "Proxy URL for Telegram bot",
"type": "string"
@@ -729,6 +817,10 @@
"minimum": 0,
"type": "integer"
},
"tgEnabledEvents": {
"description": "Comma-separated event types to send via Telegram",
"type": "string"
},
"tgLang": {
"description": "Telegram bot language",
"type": "string"
@@ -799,6 +891,7 @@
"hasApiToken",
"hasLdapPassword",
"hasNordSecret",
"hasSmtpPassword",
"hasTgBotToken",
"hasTwoFactorToken",
"hasWarpSecret",
@@ -827,6 +920,15 @@
"remarkModel",
"restartXrayOnClientDisable",
"sessionMaxAge",
"smtpCpu",
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpHost",
"smtpPassword",
"smtpPort",
"smtpTo",
"smtpUsername",
"subAnnounce",
"subCertFile",
"subClashEnable",
@@ -861,10 +963,10 @@
"tgBotBackup",
"tgBotChatId",
"tgBotEnable",
"tgBotLoginNotify",
"tgBotProxy",
"tgBotToken",
"tgCpu",
"tgEnabledEvents",
"tgLang",
"tgRunTime",
"timeLocation",
@@ -1617,10 +1719,21 @@
"example": "de-fra-1",
"type": "string"
},
"netDown": {
"example": 2097152,
"type": "integer"
},
"netUp": {
"example": 1048576,
"type": "integer"
},
"onlineCount": {
"example": 3,
"type": "integer"
},
"outboundTag": {
"type": "string"
},
"panelVersion": {
"example": "v3.x.x",
"type": "string"
@@ -1658,7 +1771,8 @@
"enum": [
"verify",
"skip",
"pin"
"pin",
"mtls"
],
"type": "string"
},
@@ -1707,7 +1821,10 @@
"latencyMs",
"memPct",
"name",
"netDown",
"netUp",
"onlineCount",
"outboundTag",
"panelVersion",
"pinnedCertSha256",
"port",
@@ -4390,7 +4507,7 @@
"tags": [
"Clients"
],
"summary": "Fetch one client by email, including the inbound IDs it is attached to.",
"summary": "Fetch one client by email, including the inbound IDs and external config IDs it is attached to.",
"operationId": "get_panel_api_clients_get_email",
"parameters": [
{
@@ -4719,6 +4836,74 @@
}
}
},
"/panel/api/clients/{email}/externalLinks": {
"post": {
"tags": [
"Clients"
],
"summary": "Replace a client's external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.",
"operationId": "post_panel_api_clients_email_externalLinks",
"parameters": [
{
"name": "email",
"in": "path",
"required": true,
"description": "Client email (unique identifier).",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"externalLinks": [
{
"kind": "link",
"value": "vless://uuid@host:443?...#srv",
"remark": "DE"
},
{
"kind": "subscription",
"value": "https://provider.example/sub/abc",
"remark": "Provider"
}
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true
}
}
}
}
}
}
},
"/panel/api/clients/resetAllTraffics": {
"post": {
"tags": [
@@ -5762,6 +5947,49 @@
}
}
},
"/panel/api/clients/clientIpsByGuid": {
"post": {
"tags": [
"Clients"
],
"summary": "Per-client source IPs grouped by the panelGuid of the node that observed them. Lets the central panel attribute and enforce per-client IP limits using the real visitor IPs each node sees, instead of the address of the intermediate panel it syncs through.",
"operationId": "post_panel_api_clients_clientIpsByGuid",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"obj": {
"a1b2-...": {
"user1": [
{
"ip": "1.2.3.4",
"timestamp": 1700000000
}
]
}
}
}
}
}
}
}
}
},
"/panel/api/clients/activeInbounds": {
"post": {
"tags": [
@@ -6049,7 +6277,10 @@
"latencyMs": 42,
"memPct": 45.1,
"name": "de-fra-1",
"netDown": 2097152,
"netUp": 1048576,
"onlineCount": 3,
"outboundTag": "",
"panelVersion": "v3.x.x",
"parentGuid": "",
"pinnedCertSha256": "",
@@ -6073,6 +6304,85 @@
}
}
},
"/panel/api/nodes/mtls/ca": {
"post": {
"tags": [
"Nodes"
],
"summary": "This panel's node-auth CA certificate (public, PEM) to paste into a node's mTLS trust setting. Lazily mints the CA and the master client cert on first call. Pair with setting tlsVerifyMode=mtls on the node.",
"operationId": "post_panel_api_nodes_mtls_ca",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"obj": {
"caCert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
}
}
}
}
}
}
}
},
"/panel/api/nodes/mtls/trustCA": {
"post": {
"tags": [
"Nodes"
],
"summary": "Set the CA certificate this panel trusts for incoming node-API client certificates (this panel acting as a node). Paste the managing panel's CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value must be a PEM certificate. Applied on the next panel restart.",
"operationId": "post_panel_api_nodes_mtls_trustCA",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"caCert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/nodes/get/{id}": {
"get": {
"tags": [
@@ -6936,6 +7246,75 @@
}
}
},
"/panel/api/setting/testSmtp": {
"post": {
"tags": [
"Settings"
],
"summary": "Test SMTP connection with stage-by-stage reporting (connect, auth, send). Returns structured result with stage and message.",
"operationId": "post_panel_api_setting_testSmtp",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"stage": "send",
"msg": "Test email sent successfully"
}
}
}
}
}
}
},
"/panel/api/setting/testTgBot": {
"post": {
"tags": [
"Settings"
],
"summary": "Test Telegram bot connection by sending a test message to the configured chat.",
"operationId": "post_panel_api_setting_testTgBot",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"msg": "Test message sent to Telegram"
}
}
}
}
}
}
},
"/panel/api/setting/getDefaultJsonConfig": {
"get": {
"tags": [
+41 -3
View File
@@ -7,7 +7,8 @@ import { fetchXrayConfig } from '@/hooks/useXraySetting';
// inbound's Telegram traffic to. Shares the cached xray config query so opening
// the inbound form costs no extra request when the Xray page was already
// visited; `select` derives just the tag list without disturbing other readers.
export function useOutboundTags() {
export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
const excludeBlackhole = opts?.excludeBlackhole ?? false;
return useQuery({
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
@@ -15,8 +16,10 @@ export function useOutboundTags() {
select: (data): string[] => {
const tags = new Set<string>();
for (const o of data?.xraySetting?.outbounds ?? []) {
const tag = (o as { tag?: string } | null)?.tag;
if (tag) tags.add(tag);
const ob = o as { tag?: string; protocol?: string } | null;
if (!ob?.tag) continue;
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
tags.add(ob.tag);
}
for (const t of data?.subscriptionOutboundTags ?? []) {
if (t) tags.add(t);
@@ -31,3 +34,38 @@ export function useOutboundTags() {
},
});
}
export interface OutboundTagGroups {
outbounds: string[];
balancers: string[];
}
// Same data as useOutboundTags, but keeps outbound and balancer tags apart so a
// picker can render them in labeled groups (like the panel-outbound selector)
// instead of one flat list.
export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
const excludeBlackhole = opts?.excludeBlackhole ?? false;
return useQuery({
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
staleTime: Infinity,
select: (data): OutboundTagGroups => {
const outbounds = new Set<string>();
for (const o of data?.xraySetting?.outbounds ?? []) {
const ob = o as { tag?: string; protocol?: string } | null;
if (!ob?.tag) continue;
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
outbounds.add(ob.tag);
}
for (const t of data?.subscriptionOutboundTags ?? []) {
if (t) outbounds.add(t);
}
const balancers: string[] = [];
const bal = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
for (const b of bal ?? []) {
if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag);
}
return { outbounds: [...outbounds], balancers };
},
});
}
@@ -1,5 +1,6 @@
.jdp-wrap {
width: 100%;
position: relative;
}
.jdp-wrap > * {
@@ -33,3 +34,38 @@
pointer-events: none;
opacity: 0.6;
}
/* persian-calendar-suite has no allowClear; overlay our own clear button so
the Jalali picker matches the Gregorian AntD DatePicker's X affordance. */
.jdp-wrap .jdp-clear {
position: absolute;
top: 50%;
right: 11px;
transform: translateY(-50%);
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: auto;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
line-height: 1;
color: rgba(0, 0, 0, 0.25);
transition: color 0.2s;
}
.jdp-wrap .jdp-clear:hover {
color: rgba(0, 0, 0, 0.45);
}
.jdp-dark .jdp-clear {
color: rgba(255, 255, 255, 0.30);
}
.jdp-dark .jdp-clear:hover,
.jdp-ultra .jdp-clear:hover {
color: rgba(255, 255, 255, 0.45);
}
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { CloseCircleFilled } from '@ant-design/icons';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
@@ -54,6 +55,10 @@ export default function DateTimePicker({
}: DateTimePickerProps) {
const { datepicker } = useDatepicker();
const { isDark, isUltra } = useTheme();
const jalaliRef = useRef<HTMLDivElement>(null);
// Bumped on clear: persian-calendar-suite reads `value` only on mount, so
// remounting via key is the only way to reflect an externally cleared value.
const [clearNonce, setClearNonce] = useState(0);
const persianTheme = useMemo(() => {
if (isUltra) return ULTRA_DARK_THEME;
@@ -61,10 +66,21 @@ export default function DateTimePicker({
return LIGHT_THEME;
}, [isDark, isUltra]);
// The library hardcodes a Persian placeholder and exposes no working prop to
// override it, so clear it (or apply the caller's) on the input directly so
// the empty field shows no leftover Persian text. No dep array: re-apply
// after every render (incl. clear-remounts).
useEffect(() => {
if (datepicker !== 'jalalian') return;
const input = jalaliRef.current?.querySelector('input');
if (input) input.placeholder = placeholder;
});
if (datepicker === 'jalalian') {
return (
<div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
<div ref={jalaliRef} className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
<PersianDateTimePicker
key={clearNonce}
value={value ? value.valueOf() : null}
onChange={(next: number | string | null) => {
if (next == null || next === '') {
@@ -80,6 +96,21 @@ export default function DateTimePicker({
rtlCalendar
theme={persianTheme}
/>
{value && !disabled && (
<button
type="button"
className="jdp-clear"
aria-label="clear"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
onChange(null);
setClearNonce((n) => n + 1);
}}
>
<CloseCircleFilled />
</button>
)}
</div>
);
}
@@ -1,27 +1,23 @@
import { useTranslation } from 'react-i18next';
import { Button } from 'antd';
interface Option {
value: number;
}
interface SelectAllClearButtonsProps {
options: Option[];
value: number[];
onChange: (value: number[]) => void;
interface SelectAllClearButtonsProps<T extends string | number = number> {
options: Array<{ value: T }>;
value: T[];
onChange: (value: T[]) => void;
/** Override the default "Select all" label (defaults to the inbound copy). */
selectAllLabel?: string;
/** Override the default "Clear all" label (defaults to the inbound copy). */
clearLabel?: string;
}
export default function SelectAllClearButtons({
export default function SelectAllClearButtons<T extends string | number = number>({
options,
value,
onChange,
selectAllLabel,
clearLabel,
}: SelectAllClearButtonsProps) {
}: SelectAllClearButtonsProps<T>) {
const { t } = useTranslation();
const optionValues = options.map((o) => o.value);
@@ -0,0 +1,147 @@
import { Checkbox, Collapse, InputNumber, Space } from 'antd';
import { DownOutlined, RightOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
interface EventGroup {
key: string;
labelKey: string;
events: { value: string; labelKey: string }[];
}
const EVENT_GROUPS: EventGroup[] = [
{
key: 'outbound',
labelKey: 'pages.settings.eventGroupOutbound',
events: [
{ value: 'outbound.down', labelKey: 'pages.settings.eventOutboundDown' },
{ value: 'outbound.up', labelKey: 'pages.settings.eventOutboundUp' },
],
},
{
key: 'xray',
labelKey: 'pages.settings.eventGroupXray',
events: [
{ value: 'xray.crash', labelKey: 'pages.settings.eventXrayCrash' },
],
},
{
key: 'node',
labelKey: 'pages.settings.eventGroupNode',
events: [
{ value: 'node.down', labelKey: 'pages.settings.eventNodeDown' },
{ value: 'node.up', labelKey: 'pages.settings.eventNodeUp' },
],
},
{
key: 'system',
labelKey: 'pages.settings.eventGroupSystem',
events: [
{ value: 'cpu.high', labelKey: 'pages.settings.eventCPUHigh' },
],
},
{
key: 'security',
labelKey: 'pages.settings.eventGroupSecurity',
events: [
{ value: 'login.attempt', labelKey: 'pages.settings.eventLoginAttempt' },
],
},
];
interface EventBusCheckboxesProps {
value: string;
onChange: (v: string) => void;
/** Maps event value → { key: setting field name, value: current value } for inline inputs */
extra?: Record<string, { key: string; value: number }>;
/** Callback when extra input changes: (settingKey, newValue) => void */
onExtraChange?: (key: string, v: number | null) => void;
}
export function EventBusCheckboxes({ value, onChange, extra, onExtraChange }: EventBusCheckboxesProps) {
const { t } = useTranslation();
const selected = value ? value.split(',').map((s) => s.trim()).filter(Boolean) : [];
function toggle(eventType: string) {
const next = selected.includes(eventType)
? selected.filter((e) => e !== eventType)
: [...selected, eventType];
onChange(next.join(','));
}
function toggleGroup(group: EventGroup) {
const groupValues = group.events.map((e) => e.value);
const allSelected = groupValues.every((v) => selected.includes(v));
let next: string[];
if (allSelected) {
next = selected.filter((v) => !groupValues.includes(v));
} else {
next = [...new Set([...selected, ...groupValues])];
}
onChange(next.join(','));
}
const items = EVENT_GROUPS.map((group) => {
const count = group.events.filter((e) => selected.includes(e.value)).length;
const total = group.events.length;
const allSelected = count === total;
return {
key: group.key,
label: (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 500 }}>{t(group.labelKey)}</span>
<span style={{ color: '#999', fontSize: 12 }}>
{count}/{total}
</span>
<Checkbox
checked={allSelected}
indeterminate={count > 0 && count < total}
onClick={(e) => e.stopPropagation()}
onChange={() => toggleGroup(group)}
/>
</div>
),
children: (
<Checkbox.Group value={selected} style={{ width: '100%' }}>
<Space wrap size={[16, 4]}>
{group.events.map((et) => {
const checked = selected.includes(et.value);
const extraConf = extra?.[et.value];
return (
<span key={et.value} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Checkbox value={et.value} onChange={() => toggle(et.value)}>
{t(et.labelKey)}
</Checkbox>
{extraConf && onExtraChange && (
<InputNumber
size="small"
min={0}
max={100}
value={extraConf.value}
disabled={!checked}
onChange={(v) => onExtraChange(extraConf.key, v)}
style={{ width: 60 }}
/>
)}
</span>
);
})}
</Space>
</Checkbox.Group>
),
};
});
const defaultActiveKeys = EVENT_GROUPS
.filter((g) => g.events.some((e) => selected.includes(e.value)))
.map((g) => g.key);
return (
<Collapse
items={items}
defaultActiveKey={defaultActiveKeys.length > 0 ? defaultActiveKeys : ['outbound']}
expandIcon={({ isActive }) => isActive ? <DownOutlined /> : <RightOutlined />}
size="small"
/>
);
}
+1
View File
@@ -1,3 +1,4 @@
export { default as InputAddon } from './InputAddon';
export { default as InfinityIcon } from './InfinityIcon';
export { default as SettingListItem } from './SettingListItem';
export { EventBusCheckboxes } from './EventBusCheckboxes';
+24 -2
View File
@@ -30,6 +30,15 @@ export const EXAMPLES: Record<string, unknown> = {
"remarkModel": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"smtpCpu": 0,
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpHost": "",
"smtpPassword": "",
"smtpPort": 1,
"smtpTo": "",
"smtpUsername": "",
"subAnnounce": "",
"subCertFile": "",
"subClashEnable": false,
@@ -64,10 +73,10 @@ export const EXAMPLES: Record<string, unknown> = {
"tgBotBackup": false,
"tgBotChatId": "",
"tgBotEnable": false,
"tgBotLoginNotify": false,
"tgBotProxy": "",
"tgBotToken": "",
"tgCpu": 0,
"tgEnabledEvents": "",
"tgLang": "",
"tgRunTime": "",
"timeLocation": "",
@@ -91,6 +100,7 @@ export const EXAMPLES: Record<string, unknown> = {
"hasApiToken": false,
"hasLdapPassword": false,
"hasNordSecret": false,
"hasSmtpPassword": false,
"hasTgBotToken": false,
"hasTwoFactorToken": false,
"hasWarpSecret": false,
@@ -119,6 +129,15 @@ export const EXAMPLES: Record<string, unknown> = {
"remarkModel": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"smtpCpu": 0,
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpHost": "",
"smtpPassword": "",
"smtpPort": 1,
"smtpTo": "",
"smtpUsername": "",
"subAnnounce": "",
"subCertFile": "",
"subClashEnable": false,
@@ -153,10 +172,10 @@ export const EXAMPLES: Record<string, unknown> = {
"tgBotBackup": false,
"tgBotChatId": "",
"tgBotEnable": false,
"tgBotLoginNotify": false,
"tgBotProxy": "",
"tgBotToken": "",
"tgCpu": 0,
"tgEnabledEvents": "",
"tgLang": "",
"tgRunTime": "",
"timeLocation": "",
@@ -353,7 +372,10 @@ export const EXAMPLES: Record<string, unknown> = {
"latencyMs": 42,
"memPct": 45.1,
"name": "de-fra-1",
"netDown": 2097152,
"netUp": 1048576,
"onlineCount": 3,
"outboundTag": "",
"panelVersion": "v3.x.x",
"parentGuid": "",
"pinnedCertSha256": "",
+128 -11
View File
@@ -112,6 +112,46 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 1,
"type": "integer"
},
"smtpCpu": {
"description": "CPU threshold for email notifications",
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpEnable": {
"description": "Email (SMTP) notification settings\nEnable email notifications",
"type": "boolean"
},
"smtpEnabledEvents": {
"description": "Comma-separated event types to send via email",
"type": "string"
},
"smtpEncryptionType": {
"description": "SMTP encryption: none, starttls, tls",
"type": "string"
},
"smtpHost": {
"description": "SMTP server host",
"type": "string"
},
"smtpPassword": {
"description": "SMTP password",
"type": "string"
},
"smtpPort": {
"description": "SMTP server port",
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"smtpTo": {
"description": "Comma-separated recipient emails",
"type": "string"
},
"smtpUsername": {
"description": "SMTP username",
"type": "string"
},
"subAnnounce": {
"description": "Subscription announce",
"type": "string"
@@ -251,10 +291,6 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Telegram bot settings\nEnable Telegram bot notifications",
"type": "boolean"
},
"tgBotLoginNotify": {
"description": "Send login notifications",
"type": "boolean"
},
"tgBotProxy": {
"description": "Proxy URL for Telegram bot",
"type": "string"
@@ -269,6 +305,10 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"tgEnabledEvents": {
"description": "Comma-separated event types to send via Telegram",
"type": "string"
},
"tgLang": {
"description": "Telegram bot language",
"type": "string"
@@ -361,6 +401,15 @@ export const SCHEMAS: Record<string, unknown> = {
"remarkModel",
"restartXrayOnClientDisable",
"sessionMaxAge",
"smtpCpu",
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpHost",
"smtpPassword",
"smtpPort",
"smtpTo",
"smtpUsername",
"subAnnounce",
"subCertFile",
"subClashEnable",
@@ -395,10 +444,10 @@ export const SCHEMAS: Record<string, unknown> = {
"tgBotBackup",
"tgBotChatId",
"tgBotEnable",
"tgBotLoginNotify",
"tgBotProxy",
"tgBotToken",
"tgCpu",
"tgEnabledEvents",
"tgLang",
"tgRunTime",
"timeLocation",
@@ -445,6 +494,9 @@ export const SCHEMAS: Record<string, unknown> = {
"hasNordSecret": {
"type": "boolean"
},
"hasSmtpPassword": {
"type": "boolean"
},
"hasTgBotToken": {
"type": "boolean"
},
@@ -546,6 +598,46 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 1,
"type": "integer"
},
"smtpCpu": {
"description": "CPU threshold for email notifications",
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpEnable": {
"description": "Email (SMTP) notification settings\nEnable email notifications",
"type": "boolean"
},
"smtpEnabledEvents": {
"description": "Comma-separated event types to send via email",
"type": "string"
},
"smtpEncryptionType": {
"description": "SMTP encryption: none, starttls, tls",
"type": "string"
},
"smtpHost": {
"description": "SMTP server host",
"type": "string"
},
"smtpPassword": {
"description": "SMTP password",
"type": "string"
},
"smtpPort": {
"description": "SMTP server port",
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"smtpTo": {
"description": "Comma-separated recipient emails",
"type": "string"
},
"smtpUsername": {
"description": "SMTP username",
"type": "string"
},
"subAnnounce": {
"description": "Subscription announce",
"type": "string"
@@ -685,10 +777,6 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Telegram bot settings\nEnable Telegram bot notifications",
"type": "boolean"
},
"tgBotLoginNotify": {
"description": "Send login notifications",
"type": "boolean"
},
"tgBotProxy": {
"description": "Proxy URL for Telegram bot",
"type": "string"
@@ -703,6 +791,10 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"tgEnabledEvents": {
"description": "Comma-separated event types to send via Telegram",
"type": "string"
},
"tgLang": {
"description": "Telegram bot language",
"type": "string"
@@ -773,6 +865,7 @@ export const SCHEMAS: Record<string, unknown> = {
"hasApiToken",
"hasLdapPassword",
"hasNordSecret",
"hasSmtpPassword",
"hasTgBotToken",
"hasTwoFactorToken",
"hasWarpSecret",
@@ -801,6 +894,15 @@ export const SCHEMAS: Record<string, unknown> = {
"remarkModel",
"restartXrayOnClientDisable",
"sessionMaxAge",
"smtpCpu",
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpHost",
"smtpPassword",
"smtpPort",
"smtpTo",
"smtpUsername",
"subAnnounce",
"subCertFile",
"subClashEnable",
@@ -835,10 +937,10 @@ export const SCHEMAS: Record<string, unknown> = {
"tgBotBackup",
"tgBotChatId",
"tgBotEnable",
"tgBotLoginNotify",
"tgBotProxy",
"tgBotToken",
"tgCpu",
"tgEnabledEvents",
"tgLang",
"tgRunTime",
"timeLocation",
@@ -1591,10 +1693,21 @@ export const SCHEMAS: Record<string, unknown> = {
"example": "de-fra-1",
"type": "string"
},
"netDown": {
"example": 2097152,
"type": "integer"
},
"netUp": {
"example": 1048576,
"type": "integer"
},
"onlineCount": {
"example": 3,
"type": "integer"
},
"outboundTag": {
"type": "string"
},
"panelVersion": {
"example": "v3.x.x",
"type": "string"
@@ -1632,7 +1745,8 @@ export const SCHEMAS: Record<string, unknown> = {
"enum": [
"verify",
"skip",
"pin"
"pin",
"mtls"
],
"type": "string"
},
@@ -1681,7 +1795,10 @@ export const SCHEMAS: Record<string, unknown> = {
"latencyMs",
"memPct",
"name",
"netDown",
"netUp",
"onlineCount",
"outboundTag",
"panelVersion",
"pinnedCertSha256",
"port",
+25 -2
View File
@@ -3,6 +3,7 @@ export type OnlineAPISupport = number;
export type ProcessState = string;
export type Protocol = string;
export type SubLinkProvider = unknown;
export type staticEgressResolver = string;
export type transportBits = number;
export interface AllSetting {
@@ -35,6 +36,15 @@ export interface AllSetting {
remarkModel: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
smtpCpu: number;
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpHost: string;
smtpPassword: string;
smtpPort: number;
smtpTo: string;
smtpUsername: string;
subAnnounce: string;
subCertFile: string;
subClashEnable: boolean;
@@ -69,10 +79,10 @@ export interface AllSetting {
tgBotBackup: boolean;
tgBotChatId: string;
tgBotEnable: boolean;
tgBotLoginNotify: boolean;
tgBotProxy: string;
tgBotToken: string;
tgCpu: number;
tgEnabledEvents: string;
tgLang: string;
tgRunTime: string;
timeLocation: string;
@@ -97,6 +107,7 @@ export interface AllSettingView {
hasApiToken: boolean;
hasLdapPassword: boolean;
hasNordSecret: boolean;
hasSmtpPassword: boolean;
hasTgBotToken: boolean;
hasTwoFactorToken: boolean;
hasWarpSecret: boolean;
@@ -125,6 +136,15 @@ export interface AllSettingView {
remarkModel: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
smtpCpu: number;
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpHost: string;
smtpPassword: string;
smtpPort: number;
smtpTo: string;
smtpUsername: string;
subAnnounce: string;
subCertFile: string;
subClashEnable: boolean;
@@ -159,10 +179,10 @@ export interface AllSettingView {
tgBotBackup: boolean;
tgBotChatId: string;
tgBotEnable: boolean;
tgBotLoginNotify: boolean;
tgBotProxy: string;
tgBotToken: string;
tgCpu: number;
tgEnabledEvents: string;
tgLang: string;
tgRunTime: string;
timeLocation: string;
@@ -357,7 +377,10 @@ export interface Node {
latencyMs: number;
memPct: number;
name: string;
netDown: number;
netUp: number;
onlineCount: number;
outboundTag: string;
panelVersion: string;
parentGuid?: string;
pinnedCertSha256: string;
+28 -3
View File
@@ -12,6 +12,9 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
export const SubLinkProviderSchema = z.unknown();
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
export const staticEgressResolverSchema = z.string();
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
export const transportBitsSchema = z.number().int();
export type transportBits = z.infer<typeof transportBitsSchema>;
@@ -45,6 +48,15 @@ export const AllSettingSchema = z.object({
remarkModel: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600),
smtpCpu: z.number().int().min(0).max(100),
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpHost: z.string(),
smtpPassword: z.string(),
smtpPort: z.number().int().min(1).max(65535),
smtpTo: z.string(),
smtpUsername: z.string(),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashEnable: z.boolean(),
@@ -79,10 +91,10 @@ export const AllSettingSchema = z.object({
tgBotBackup: z.boolean(),
tgBotChatId: z.string(),
tgBotEnable: z.boolean(),
tgBotLoginNotify: z.boolean(),
tgBotProxy: z.string(),
tgBotToken: z.string(),
tgCpu: z.number().int().min(0).max(100),
tgEnabledEvents: z.string(),
tgLang: z.string(),
tgRunTime: z.string(),
timeLocation: z.string(),
@@ -108,6 +120,7 @@ export const AllSettingViewSchema = z.object({
hasApiToken: z.boolean(),
hasLdapPassword: z.boolean(),
hasNordSecret: z.boolean(),
hasSmtpPassword: z.boolean(),
hasTgBotToken: z.boolean(),
hasTwoFactorToken: z.boolean(),
hasWarpSecret: z.boolean(),
@@ -136,6 +149,15 @@ export const AllSettingViewSchema = z.object({
remarkModel: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600),
smtpCpu: z.number().int().min(0).max(100),
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpHost: z.string(),
smtpPassword: z.string(),
smtpPort: z.number().int().min(1).max(65535),
smtpTo: z.string(),
smtpUsername: z.string(),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashEnable: z.boolean(),
@@ -170,10 +192,10 @@ export const AllSettingViewSchema = z.object({
tgBotBackup: z.boolean(),
tgBotChatId: z.string(),
tgBotEnable: z.boolean(),
tgBotLoginNotify: z.boolean(),
tgBotProxy: z.string(),
tgBotToken: z.string(),
tgCpu: z.number().int().min(0).max(100),
tgEnabledEvents: z.string(),
tgLang: z.string(),
tgRunTime: z.string(),
timeLocation: z.string(),
@@ -383,7 +405,10 @@ export const NodeSchema = z.object({
latencyMs: z.number().int(),
memPct: z.number(),
name: z.string(),
netDown: z.number().int(),
netUp: z.number().int(),
onlineCount: z.number().int(),
outboundTag: z.string(),
panelVersion: z.string(),
parentGuid: z.string().optional(),
pinnedCertSha256: z.string(),
@@ -391,7 +416,7 @@ export const NodeSchema = z.object({
remark: z.string(),
scheme: z.enum(['http', 'https']),
status: z.string(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
transitive: z.boolean().optional(),
updatedAt: z.number().int(),
uptimeSecs: z.number().int(),
+24 -1
View File
@@ -22,6 +22,7 @@ import {
type ClientsSummary,
type ClientPageResponse,
type InboundOption,
type ExternalLink,
type BulkAdjustResult,
type BulkAttachResult,
type BulkCreateResult,
@@ -30,7 +31,10 @@ import {
} from '@/schemas/client';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
// One row sent to POST /clients/:email/externalLinks.
export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption, ExternalLink };
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
@@ -183,6 +187,9 @@ export function useClients() {
queryKey: keys.clients.list(query),
queryFn: () => fetchClientPage(query),
staleTime: Infinity,
// List is sorted/paged server-side, so the WS patch can't add new or
// re-sort rows; poll the current page to keep it live (pauses when hidden).
refetchInterval: 5000,
placeholderData: keepPreviousData,
});
@@ -216,6 +223,9 @@ export function useClients() {
const fetched = listQuery.data !== undefined || listQuery.isError;
const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
const loading = listQuery.isFetching;
// Showing kept-previous data for a new key (filter/sort/page) — drives the
// table overlay so the 5s background poll doesn't flash it.
const transitioning = listQuery.isPlaceholderData;
const inbounds = inboundOptionsQuery.data ?? [];
const onlines = useMemo(() => onlinesQuery.data ?? [], [onlinesQuery.data]);
@@ -344,6 +354,12 @@ export function useClients() {
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const setExternalLinksMut = useMutation({
mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkAttachMut = useMutation({
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
@@ -358,6 +374,7 @@ export function useClients() {
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkDetachMut = useMutation({
mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
@@ -418,6 +435,10 @@ export function useClients() {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return attachMut.mutateAsync({ email, inboundIds });
}, [attachMut]);
const setExternalLinks = useCallback((email: string, externalLinks: ExternalLinkInput[]) => {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
return setExternalLinksMut.mutateAsync({ email, externalLinks });
}, [setExternalLinksMut]);
const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
@@ -528,6 +549,7 @@ export function useClients() {
inbounds,
onlines,
loading,
transitioning,
fetched,
fetchError,
subSettings,
@@ -546,6 +568,7 @@ export function useClients() {
bulkAddToGroup,
bulkRemoveFromGroup,
attach,
setExternalLinks,
bulkAttach,
detach,
bulkDetach,
+67 -18
View File
@@ -142,10 +142,12 @@ export function useXraySetting(): UseXraySettingResult {
const xraySettingRef = useRef('');
const outboundTestUrlRef = useRef(outboundTestUrl);
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
const subscriptionOutboundsRef = useRef<unknown[]>([]);
xraySettingRef.current = xraySetting;
outboundTestUrlRef.current = outboundTestUrl;
templateSettingsRef.current = templateSettings;
subscriptionOutboundsRef.current = subscriptionOutbounds;
// Seed local editor state from the config query. Runs on first fetch and
// every time the query refetches (e.g. after a successful save).
@@ -316,41 +318,61 @@ export function useXraySetting(): UseXraySettingResult {
);
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
const list = templateSettingsRef.current?.outbounds || [];
if (list.length === 0 || testingAll) return;
// Template outbounds key their results by index (outboundTestStates);
// subscription outbounds aren't in the template, so they key by tag
// (subscriptionTestStates). Both go through the same probe endpoint.
const templateList = templateSettingsRef.current?.outbounds || [];
const subList = (subscriptionOutboundsRef.current || []) as Array<{ tag?: string; protocol?: string }>;
if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
setTestingAll(true);
try {
const tcpQueue: { index: number; outbound: unknown }[] = [];
const httpQueue: { index: number; outbound: unknown }[] = [];
list.forEach((ob, i) => {
const tag = ob?.tag;
type TcpEntry =
| { kind: 'tpl'; index: number; outbound: unknown }
| { kind: 'sub'; tag: string; outbound: unknown };
const tcpQueue: TcpEntry[] = [];
// HTTP batches stay homogeneous (all template or all subscription) so a
// tag shared between a template and a subscription outbound can't collide
// inside one batch, and each batch's results route to one state map.
const httpTplQueue: { index: number; outbound: unknown }[] = [];
const httpSubQueue: { tag: string; outbound: unknown }[] = [];
const enqueue = (ob: { tag?: string; protocol?: string }, kind: 'tpl' | 'sub', index: number, tag: string) => {
const proto = ob?.protocol;
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return;
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return;
if (mode === 'http' || isUdpOutbound(ob)) {
httpQueue.push({ index: i, outbound: ob });
if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
// freedom ("direct") and dns aren't proxies — skip them in every mode.
if (proto === 'freedom' || proto === 'dns') return;
if (kind === 'sub' && !tag) return;
const toHttp = mode === 'http' || isUdpOutbound(ob);
if (kind === 'tpl') {
if (toHttp) httpTplQueue.push({ index, outbound: ob });
else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
} else if (toHttp) {
httpSubQueue.push({ tag, outbound: ob });
} else {
tcpQueue.push({ index: i, outbound: ob });
tcpQueue.push({ kind: 'sub', tag, outbound: ob });
}
});
};
templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
// TCP probes are dial-only and cheap server-side; per-item requests
// keep results landing one by one.
// keep results landing one by one, each routed to its own state map.
const runTcpLane = async () => {
const queue = [...tcpQueue];
const worker = async () => {
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
await testOutbound(item.index, item.outbound, mode);
if (item.kind === 'sub') await testSubscriptionOutbound(item.tag, item.outbound, mode);
else await testOutbound(item.index, item.outbound, mode);
}
};
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
};
// HTTP probes go out as chunked batches — one temp xray spawn per
// chunk instead of one per outbound, with results landing per chunk.
const runHttpLane = async () => {
for (let at = 0; at < httpQueue.length; at += HTTP_BATCH_CHUNK) {
const chunk = httpQueue.slice(at, at + HTTP_BATCH_CHUNK);
const runTplHttpLane = async () => {
for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
setOutboundTestStates((prev) => {
const next = { ...prev };
for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
@@ -366,11 +388,38 @@ export function useXraySetting(): UseXraySettingResult {
});
}
};
const runSubHttpLane = async () => {
for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
setSubscriptionTestStates((prev) => {
const next = { ...prev };
for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: 'http' };
return next;
});
const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
setSubscriptionTestStates((prev) => {
const next = { ...prev };
chunk.forEach((item, i) => {
next[item.tag] = { testing: false, result: results[i] };
});
return next;
});
}
};
// HTTP batches must not overlap: the backend serialises them with a
// non-blocking lock and rejects a second concurrent batch ("Another
// outbound test is already running"). Run the template and subscription
// HTTP lanes one after the other; TCP probes don't take that lock, so
// they still run alongside.
const runHttpLane = async () => {
await runTplHttpLane();
await runSubHttpLane();
};
await Promise.all([runTcpLane(), runHttpLane()]);
} finally {
setTestingAll(false);
}
}, [testingAll, testOutbound, postOutboundTestBatch]);
}, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
useEffect(() => {
const timer = window.setInterval(() => {
+2
View File
@@ -16,6 +16,7 @@ import {
HeartOutlined,
ImportOutlined,
LogoutOutlined,
MailOutlined,
MenuOutlined,
MessageOutlined,
MoonFilled,
@@ -153,6 +154,7 @@ export default function AppSidebar() {
{ key: '/settings#general', icon: <SettingOutlined />, label: t('pages.settings.panelSettings') },
{ key: '/settings#security', icon: <SafetyOutlined />, label: t('pages.settings.securitySettings') },
{ key: '/settings#telegram', icon: <MessageOutlined />, label: t('pages.settings.TGBotSettings') },
{ key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
{ key: '/settings#subscription', icon: <CloudServerOutlined />, label: t('pages.settings.subSettings') },
];
if (showSubFormats) {
+33
View File
@@ -0,0 +1,33 @@
// Shape of one entry in a client's IP log, as returned by
// POST /panel/api/clients/ips/:email. `node` is the name of the node the IP is
// connecting through, or '' when it is on this local panel (or unattributed).
export type ClientIpInfo = {
ip: string;
time: string;
node: string;
};
// normalizeClientIps accepts the API payload and returns typed entries. It also
// tolerates the legacy shape (a plain array of "ip (time)" strings) so the UI
// keeps working against older panels.
export function normalizeClientIps(obj: unknown): ClientIpInfo[] {
if (!Array.isArray(obj)) return [];
const out: ClientIpInfo[] = [];
for (const x of obj) {
if (typeof x === 'string') {
if (x.length > 0) out.push({ ip: x, time: '', node: '' });
continue;
}
if (x && typeof x === 'object') {
const o = x as Record<string, unknown>;
const ip = typeof o.ip === 'string' ? o.ip : '';
if (!ip) continue;
out.push({
ip,
time: typeof o.time === 'string' ? o.time : '',
node: typeof o.node === 'string' ? o.node : '',
});
}
}
return out;
}
@@ -4,7 +4,9 @@ import type { FormInstance } from 'antd/es/form';
import type { NamePath } from 'antd/es/form/interface';
import { RandomUtil } from '@/utils';
import { OutboundProtocols } from '@/schemas/primitives';
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ value, label: value }));
export interface FinalMaskFormProps {
name: NamePath;
@@ -18,6 +20,46 @@ export interface FinalMaskFormProps {
}
const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
const DEFAULT_GECKO_PACKET_SIZE = { min: 512, max: 1200 };
// Xray-core caps the Gecko output packet size at its internal buffer (2048)
// and needs 1 <= min <= max; mirror those bounds so the panel rejects what
// core would reject at runtime (salamander/conn.go).
const GECKO_MIN_PACKET_SIZE = 1;
const GECKO_MAX_PACKET_SIZE = 2048;
export function parseGeckoPacketSize(value: unknown): { min: number; max: number } | null {
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
const match = /^(\d+)-(\d+)$/.exec(str);
if (!match) return null;
const min = Number(match[1]);
const max = Number(match[2]);
if (
!Number.isSafeInteger(min) || !Number.isSafeInteger(max)
|| min < GECKO_MIN_PACKET_SIZE || max < min || max > GECKO_MAX_PACKET_SIZE
) {
return null;
}
return { min, max };
}
function formatGeckoPacketSize(min: number, max: number): string {
return `${min}-${max}`;
}
function splitGeckoPacketSize(value: unknown): { min: number | null; max: number | null } {
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
const [minRaw = '', maxRaw = ''] = str.split('-', 2);
const min = /^\d+$/.test(minRaw) ? Number(minRaw) : null;
const max = /^\d+$/.test(maxRaw) ? Number(maxRaw) : null;
return { min, max };
}
function validateGeckoPacketSize(_rule: unknown, value: unknown): Promise<void> {
if (parseGeckoPacketSize(value)) return Promise.resolve();
return Promise.reject(new Error(
`Use a range like 512-1200 (${GECKO_MIN_PACKET_SIZE}-${GECKO_MAX_PACKET_SIZE}, max ≥ min)`,
));
}
function asPath(name: NamePath): (string | number)[] {
return Array.isArray(name) ? [...name] : [name];
@@ -470,22 +512,7 @@ function UdpMaskItem({
{({ getFieldValue }) => {
const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
if (type === 'salamander') {
return (
<Form.Item label="Password">
<Space.Compact block>
<Form.Item name={[fieldName, 'settings', 'password']} noStyle>
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => form.setFieldValue(
[...absolutePath, 'settings', 'password'],
RandomUtil.randomLowerAndNum(16),
)}
/>
</Space.Compact>
</Form.Item>
);
return <SalamanderUdpMaskSettings fieldName={fieldName} form={form} absolutePath={absolutePath} />;
}
if (type === 'mkcp-legacy') {
return (
@@ -537,6 +564,35 @@ function UdpMaskItem({
<Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} placeholder="host:port" />
</Form.Item>
<Divider plain style={{ margin: '8px 0' }}>TLS (optional)</Divider>
<Form.Item label="Server Name" name={[fieldName, 'settings', 'tlsConfig', 'serverName']}>
<Input placeholder="SNI for the realm server (leave empty to skip TLS)" />
</Form.Item>
<Form.Item label="ALPN" name={[fieldName, 'settings', 'tlsConfig', 'alpn']}>
<Select
mode="multiple"
style={{ width: '100%' }}
options={[
{ value: 'h3', label: 'h3' },
{ value: 'h2', label: 'h2' },
{ value: 'http/1.1', label: 'http/1.1' },
]}
/>
</Form.Item>
<Form.Item label="Fingerprint" name={[fieldName, 'settings', 'tlsConfig', 'fingerprint']}>
<Select
allowClear
style={{ width: '100%' }}
options={UTLS_FINGERPRINT_OPTIONS}
/>
</Form.Item>
<Form.Item
label="Allow Insecure"
name={[fieldName, 'settings', 'tlsConfig', 'allowInsecure']}
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
);
}
@@ -565,6 +621,111 @@ function UdpMaskItem({
);
}
function SalamanderUdpMaskSettings({
fieldName, form, absolutePath,
}: {
fieldName: number;
form: FormInstance;
absolutePath: (string | number)[];
}) {
const packetSizePath = [...absolutePath, 'settings', 'packetSize'];
const packetSize = Form.useWatch(packetSizePath, { form, preserve: true });
const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander';
return (
<>
<Form.Item
label="Mode"
extra={mode === 'gecko'
? 'Salamander plus Gecko: splits each packet into random-padded fragments sized within the range below, defeating packet-length fingerprinting. Stored as Salamander with packetSize.'
: 'Scrambles each packet into random-looking bytes.'}
>
<Select
value={mode}
onChange={(next) => {
if (next === 'gecko') {
const current = form.getFieldValue(packetSizePath);
form.setFieldValue(
packetSizePath,
parseGeckoPacketSize(current)
? current
: formatGeckoPacketSize(DEFAULT_GECKO_PACKET_SIZE.min, DEFAULT_GECKO_PACKET_SIZE.max),
);
} else {
form.setFieldValue(packetSizePath, undefined);
}
}}
options={[
{ value: 'salamander', label: 'Salamander' },
{ value: 'gecko', label: 'Gecko experimental' },
]}
/>
</Form.Item>
<Form.Item label="Password">
<Space.Compact block>
<Form.Item name={[fieldName, 'settings', 'password']} noStyle>
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => form.setFieldValue(
[...absolutePath, 'settings', 'password'],
RandomUtil.randomLowerAndNum(16),
)}
/>
</Space.Compact>
</Form.Item>
{mode === 'gecko' && (
<Form.Item
label="Packet size"
name={[fieldName, 'settings', 'packetSize']}
rules={[{ validator: validateGeckoPacketSize }]}
extra="Serialized as a string range, for example 512-1200."
>
<GeckoPacketSizeInput />
</Form.Item>
)}
</>
);
}
function GeckoPacketSizeInput({
value,
onChange,
}: {
value?: string;
onChange?: (value: string) => void;
}) {
const { min, max } = splitGeckoPacketSize(value);
return (
<Space.Compact block>
<InputNumber
addonBefore="Min"
min={GECKO_MIN_PACKET_SIZE}
max={GECKO_MAX_PACKET_SIZE}
precision={0}
value={min}
placeholder={String(DEFAULT_GECKO_PACKET_SIZE.min)}
onChange={(next) => onChange?.(`${next ?? ''}-${max ?? ''}`)}
style={{ width: '50%' }}
/>
<InputNumber
addonBefore="Max"
min={GECKO_MIN_PACKET_SIZE}
max={GECKO_MAX_PACKET_SIZE}
precision={0}
value={max}
placeholder={String(DEFAULT_GECKO_PACKET_SIZE.max)}
onChange={(next) => onChange?.(`${min ?? ''}-${next ?? ''}`)}
style={{ width: '50%' }}
/>
</Space.Compact>
);
}
function UdpHeaderCustom({
udpFieldName, form, absoluteSettingsPath,
}: {
+27 -8
View File
@@ -12,6 +12,7 @@ import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalma
import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
import { getHeaderValue } from './headers';
import { canEnableTlsFlow } from './protocol-capabilities';
// Share-link generators. Each per-protocol fn takes a typed inbound plus
// client overrides and returns a URL (or '' when the protocol doesn't
@@ -23,6 +24,14 @@ import { getHeaderValue } from './headers';
type ForceTls = 'same' | 'tls' | 'none';
const SHARE_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
// Format a host for interpolation into a URL authority. IPv6 literals are
// wrapped in square brackets per RFC 3986; IPv4 and hostnames are left as-is.
// Any brackets already present are first stripped so the helper is idempotent.
function formatUrlHost(address: string): string {
const bare = address.replace(/^\[|\]$/g, '');
return bare.includes(':') ? `[${bare}]` : bare;
}
// xHTTP headers ship as Record<string, string> on the wire (Zod schema)
// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
@@ -178,7 +187,7 @@ export function genVmessLink(input: GenVmessLinkInput): string {
const stream = inbound.streamSettings;
if (!stream) return '';
const tls = forceTls === 'same' ? stream.security : forceTls;
const tls = forceTls === 'same' ? (stream.security ?? 'none') : forceTls;
const obj: Record<string, unknown> = {
v: '2',
ps: remark,
@@ -374,7 +383,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
if (tls.settings.pinnedPeerCertSha256.length > 0) {
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
}
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
}
applyExternalProxyTLSParams(externalProxy, params, security);
} else if (security === 'reality') {
@@ -394,13 +402,24 @@ export function genVlessLink(input: GenVlessLinkInput): string {
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
if (reality.settings.spiderX.length > 0) params.set('spx', reality.settings.spiderX);
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
if (stream.network === 'tcp' && flow.length > 0) params.set('flow', flow);
}
} else {
params.set('security', 'none');
}
const url = new URL(`vless://${clientId}@${address}:${port}`);
// XTLS Vision flow: TCP over tls/reality (classic) or XHTTP+vlessenc (the
// VLESS-level encryption stands in for transport TLS). Mirrors the backend's
// vlessFlowAllowed and the form's flow-field gating so panel link, share
// link and subscription agree.
if (flow.length > 0 && canEnableTlsFlow({
protocol: inbound.protocol,
settings: inbound.settings,
streamSettings: stream,
})) {
params.set('flow', flow);
}
const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
@@ -524,7 +543,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
params.set('security', 'none');
}
const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${address}:${port}`);
const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${formatUrlHost(address)}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
@@ -583,7 +602,7 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
if (isSSMultiUser) passwords.push(clientPassword);
const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
const url = new URL(`ss://${userinfo}@${address}:${port}`);
const url = new URL(`ss://${userinfo}@${formatUrlHost(address)}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
@@ -681,7 +700,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
params.set('mport', hopPorts);
}
const url = new URL(`${scheme}://${clientAuth}@${address}:${port}`);
const url = new URL(`${scheme}://${clientAuth}@${formatUrlHost(address)}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
@@ -724,7 +743,7 @@ export function genWireguardLink(input: GenWireguardLinkInput): string {
const peer = settings.peers[peerIndex];
if (!peer) return '';
const url = new URL(`wireguard://${address}:${port}`);
const url = new URL(`wireguard://${formatUrlHost(address)}:${port}`);
url.username = peer.privateKey ?? '';
const pubKey = settings.secretKey.length > 0
+11 -2
View File
@@ -9,8 +9,9 @@ import { Base64 } from '@/utils';
// fields the common vmess:// / vless:// links carry as query params.
// XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
// scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
// present in either the JSON or URL params. xmux, reality shortIds,
// padding obfs key/header/placement, hysteria udphop are still left
// present in either the JSON or URL params. xmux and downloadSettings
// round-trip through the `extra` JSON blob. reality shortIds, padding
// obfs key/header/placement, hysteria udphop are still left
// to the user to fill in after import — the legacy Outbound.fromLink
// was ~250 lines of dense edge-case handling we don't need to
// replicate verbatim for the common phone-to-panel workflow.
@@ -33,6 +34,10 @@ const XHTTP_NUMBER_KEYS = [
const XHTTP_BOOL_KEYS = [
'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
] as const;
// Nested objects the inbound link bundles into the `extra` JSON blob
// (and vmess JSON carries inline). The outbound form adapter expands
// xmux into the XMUX sub-form (enableXmux) on load.
const XHTTP_OBJECT_KEYS = ['xmux', 'downloadSettings'] as const;
function asBool(s: string | null): boolean | undefined {
if (s === null) return undefined;
@@ -88,6 +93,10 @@ function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): vo
for (const k of XHTTP_BOOL_KEYS) {
if (typeof json[k] === 'boolean') xhttp[k] = json[k];
}
for (const k of XHTTP_OBJECT_KEYS) {
const v = json[k];
if (v && typeof v === 'object' && !Array.isArray(v)) xhttp[k] = v;
}
}
function buildStream(network: string, security: string): Raw {
+11 -2
View File
@@ -17,12 +17,10 @@ export class AllSetting {
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
tgBotEnable = false;
tgBotToken = '';
tgBotProxy = '';
tgBotAPIServer = '';
tgBotChatId = '';
tgRunTime = '@daily';
tgBotBackup = false;
tgBotLoginNotify = true;
tgCpu = 80;
tgLang = 'en-US';
twoFactorEnable = false;
@@ -84,12 +82,23 @@ export class AllSetting {
ldapDefaultTotalGB = 0;
ldapDefaultExpiryDays = 0;
ldapDefaultLimitIP = 0;
tgEnabledEvents = '';
smtpEnable = false;
smtpHost = '';
smtpPort = 587;
smtpUsername = '';
smtpPassword = '';
smtpTo = '';
smtpEncryptionType = 'starttls';
smtpEnabledEvents = '';
smtpCpu = 80;
hasTgBotToken = false;
hasTwoFactorToken = false;
hasLdapPassword = false;
hasApiToken = false;
hasWarpSecret = false;
hasNordSecret = false;
hasSmtpPassword = false;
constructor(data?: unknown) {
if (data != null) {
+43 -2
View File
@@ -503,12 +503,12 @@ export const sections: readonly Section[] = [
{
method: 'GET',
path: '/panel/api/clients/get/:email',
summary: 'Fetch one client by email, including the inbound IDs it is attached to.',
summary: 'Fetch one client by email, including the inbound IDs and external config IDs it is attached to.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
],
response:
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5]\n }\n}',
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n }\n}',
},
{
method: 'POST',
@@ -563,6 +563,17 @@ export const sections: readonly Section[] = [
body: '{\n "inboundIds": [5]\n}',
response: '{\n "success": true\n}',
},
{
method: 'POST',
path: '/panel/api/clients/:email/externalLinks',
summary: 'Replace a client\'s external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
{ name: 'externalLinks', in: 'body (json)', type: 'object[]', desc: 'Rows of { kind: "link" | "subscription", value, remark }. kind=link must be a share link; kind=subscription must be an http(s) URL.' },
],
body: '{\n "externalLinks": [\n { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE" },\n { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider" }\n ]\n}',
response: '{\n "success": true\n}',
},
{
method: 'POST',
path: '/panel/api/clients/resetAllTraffics',
@@ -720,6 +731,12 @@ export const sections: readonly Section[] = [
summary: 'Online client emails grouped by the panelGuid of the node that physically hosts each client. The local panel uses its own GUID; each node (at any depth in a chain) uses its GUID. Lets the inbounds page attribute online status to the real node instead of the intermediate one it syncs through.',
response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["user1"],\n "c3d4-...": ["user1", "user2"]\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/clientIpsByGuid',
summary: 'Per-client source IPs grouped by the panelGuid of the node that observed them. Lets the central panel attribute and enforce per-client IP limits using the real visitor IPs each node sees, instead of the address of the intermediate panel it syncs through.',
response: '{\n "success": true,\n "obj": {\n "a1b2-...": {\n "user1": [\n { "ip": "1.2.3.4", "timestamp": 1700000000 }\n ]\n }\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/activeInbounds',
@@ -779,6 +796,18 @@ export const sections: readonly Section[] = [
responseSchema: 'Node',
responseSchemaArray: true,
},
{
method: 'POST',
path: '/panel/api/nodes/mtls/ca',
summary: "This panel's node-auth CA certificate (public, PEM) to paste into a node's mTLS trust setting. Lazily mints the CA and the master client cert on first call. Pair with setting tlsVerifyMode=mtls on the node.",
response: '{\n "success": true,\n "obj": {\n "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n }\n}',
},
{
method: 'POST',
path: '/panel/api/nodes/mtls/trustCA',
summary: "Set the CA certificate this panel trusts for incoming node-API client certificates (this panel acting as a node). Paste the managing panel's CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value must be a PEM certificate. Applied on the next panel restart.",
body: '{\n "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n}',
},
{
method: 'GET',
path: '/panel/api/nodes/get/:id',
@@ -931,6 +960,18 @@ export const sections: readonly Section[] = [
path: '/panel/api/setting/restartPanel',
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
},
{
method: 'POST',
path: '/panel/api/setting/testSmtp',
summary: 'Test SMTP connection with stage-by-stage reporting (connect, auth, send). Returns structured result with stage and message.',
response: '{\n "success": true,\n "stage": "send",\n "msg": "Test email sent successfully"\n}',
},
{
method: 'POST',
path: '/panel/api/setting/testTgBot',
summary: 'Test Telegram bot connection by sending a test message to the configured chat.',
response: '{\n "success": true,\n "msg": "Test message sent to Telegram"\n}',
},
{
method: 'GET',
path: '/panel/api/setting/getDefaultJsonConfig',
+163 -71
View File
@@ -16,16 +16,18 @@ import {
Tabs,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import { EyeOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { HttpUtil, RandomUtil } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -38,6 +40,13 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
// One editable row in the Links tab. `key` is a stable client-side id for React.
interface ExternalLinkRow {
key: number;
kind: 'link' | 'subscription';
value: string;
}
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
@@ -51,10 +60,13 @@ interface SaveMetaEdit {
email: string;
attach: number[];
detach: number[];
externalLinks: ExternalLinkInput[];
}
interface SaveMetaCreate {
isEdit: false;
email: string;
externalLinks: ExternalLinkInput[];
}
interface SaveCreatePayload {
@@ -67,6 +79,7 @@ interface ClientFormModalProps {
mode: Mode;
client: ClientRecord | null;
inbounds: InboundOption[];
attachedExternalLinks?: ExternalLink[];
attachedIds?: number[];
tgBotEnable?: boolean;
groups?: string[];
@@ -98,6 +111,7 @@ interface FormState {
comment: string;
enable: boolean;
inboundIds: number[];
externalLinks: ExternalLinkRow[];
}
function emptyForm(): FormState {
@@ -121,9 +135,19 @@ function emptyForm(): FormState {
comment: '',
enable: true,
inboundIds: [],
externalLinks: [],
};
}
let externalLinkRowSeq = 0;
function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] {
return (links || []).map((l) => ({
key: (externalLinkRowSeq += 1),
kind: l.kind === 'subscription' ? 'subscription' : 'link',
value: l.value || '',
}));
}
function bytesToGB(bytes: number): number {
if (!bytes || bytes <= 0) return 0;
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
@@ -139,6 +163,7 @@ export default function ClientFormModal({
mode,
client,
inbounds,
attachedExternalLinks = [],
attachedIds = [],
tgBotEnable = false,
groups = [],
@@ -153,7 +178,7 @@ export default function ClientFormModal({
const [form, setForm] = useState<FormState>(emptyForm);
const [submitting, setSubmitting] = useState(false);
const [resetting, setResetting] = useState(false);
const [clientIps, setClientIps] = useState<string[]>([]);
const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
const [ipsLoading, setIpsLoading] = useState(false);
const [ipsClearing, setIpsClearing] = useState(false);
const [ipsModalOpen, setIpsModalOpen] = useState(false);
@@ -162,6 +187,27 @@ export default function ClientFormModal({
setForm((prev) => ({ ...prev, [key]: value }));
}
function addExternalLinkRow(kind: 'link' | 'subscription') {
setForm((prev) => ({
...prev,
externalLinks: [...prev.externalLinks, { key: (externalLinkRowSeq += 1), kind, value: '' }],
}));
}
function updateExternalLinkRow(key: number, value: string) {
setForm((prev) => ({
...prev,
externalLinks: prev.externalLinks.map((r) => (r.key === key ? { ...r, value } : r)),
}));
}
function removeExternalLinkRow(key: number) {
setForm((prev) => ({
...prev,
externalLinks: prev.externalLinks.filter((r) => r.key !== key),
}));
}
useEffect(() => {
if (!open) return;
setIpsModalOpen(false);
@@ -186,6 +232,7 @@ export default function ClientFormModal({
comment: client.comment || '',
enable: !!client.enable,
inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
externalLinks: toExternalLinkRows(attachedExternalLinks),
};
if (et < 0) {
next.delayedStart = true;
@@ -300,14 +347,16 @@ export default function ClientFormModal({
[inbounds],
);
const linkRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'link'), [form.externalLinks]);
const subscriptionRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'subscription'), [form.externalLinks]);
async function loadIps() {
if (!isEdit || !client?.email) return;
setIpsLoading(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
if (!msg?.success) { setClientIps([]); return; }
const arr = Array.isArray(msg.obj) ? msg.obj : [];
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
setClientIps(normalizeClientIps(msg.obj));
} finally {
setIpsLoading(false);
}
@@ -400,6 +449,10 @@ export default function ClientFormModal({
clientPayload.reverse = { tag: reverseTag };
}
const externalLinks: ExternalLinkInput[] = form.externalLinks
.map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
.filter((r) => r.value !== '');
setSubmitting(true);
try {
let msg;
@@ -413,11 +466,12 @@ export default function ClientFormModal({
email: client.email,
attach: toAttach,
detach: toDetach,
externalLinks,
});
} else {
msg = await save(
{ client: clientPayload, inboundIds: form.inboundIds },
{ isEdit: false },
{ isEdit: false, email: clientPayload.email as string, externalLinks },
);
}
if (msg?.success) close();
@@ -624,71 +678,106 @@ export default function ClientFormModal({
label: t('pages.clients.tabCredentials'),
children: (
<>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.uuid')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
</Space.Compact>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.password')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
</Space.Compact>
</Form.Item>
</Col>
</Row>
<Form.Item label={t('pages.clients.uuid')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
</Space.Compact>
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
</Space.Compact>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.hysteriaAuth')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
</Space.Compact>
</Form.Item>
</Col>
</Row>
<Form.Item label={t('pages.clients.password')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
</Space.Compact>
</Form.Item>
<Row gutter={16}>
{showFlow && (
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.flow')}>
<Select
value={form.flow}
onChange={(v) => update('flow', v)}
options={[
{ value: '', label: t('none') },
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
]}
/>
</Form.Item>
</Col>
)}
{showSecurity && (
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.vmessSecurity')}>
<Select
value={form.security}
onChange={(v) => update('security', v)}
options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
/>
</Form.Item>
</Col>
)}
</Row>
<Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.hysteriaAuth')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
</Space.Compact>
</Form.Item>
{showFlow && (
<Form.Item label={t('pages.clients.flow')}>
<Select
value={form.flow}
onChange={(v) => update('flow', v)}
options={[
{ value: '', label: t('none') },
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
]}
/>
</Form.Item>
)}
{showSecurity && (
<Form.Item label={t('pages.clients.vmessSecurity')}>
<Select
value={form.security}
onChange={(v) => update('security', v)}
options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
/>
</Form.Item>
)}
</>
),
},
{
key: 'links',
label: t('pages.clients.tabLinks'),
children: (
<>
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
{t('pages.clients.linksHint')}
</Typography.Paragraph>
<Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('link')}>
{t('pages.clients.addExternalLink')}
</Button>
<div style={{ marginTop: 12, marginBottom: 24 }}>
{linkRows.length === 0 ? (
<Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
) : linkRows.map((row) => (
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input
value={row.value}
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
/>
<Tooltip title={t('delete')}>
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
</Tooltip>
</div>
))}
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('subscription')}>
{t('pages.clients.addExternalSubscription')}
</Button>
<div style={{ marginTop: 12 }}>
{subscriptionRows.length === 0 ? (
<Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
) : subscriptionRows.map((row) => (
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input
value={row.value}
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
placeholder="https://provider.example/sub/…"
/>
<Tooltip title={t('delete')}>
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
</Tooltip>
</div>
))}
</div>
</>
),
},
@@ -717,7 +806,7 @@ export default function ClientFormModal({
>
{clientIps.length > 0 ? (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{clientIps.map((ip, idx) => (
{clientIps.map((entry, idx) => (
<Tag
key={idx}
color="blue"
@@ -730,7 +819,10 @@ export default function ClientFormModal({
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
}}
>
{ip}
{entry.ip}{entry.time ? ` (${entry.time})` : ''}
{entry.node ? (
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
) : null}
</Tag>
))}
</div>
@@ -5,6 +5,7 @@ import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
@@ -80,7 +81,7 @@ export default function ClientInfoModal({
const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
const [messageApi, messageContextHolder] = message.useMessage();
const [links, setLinks] = useState<string[]>([]);
const [clientIps, setClientIps] = useState<string[]>([]);
const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
const [ipsLoading, setIpsLoading] = useState(false);
const [ipsClearing, setIpsClearing] = useState(false);
const [ipsModalOpen, setIpsModalOpen] = useState(false);
@@ -144,8 +145,7 @@ export default function ClientInfoModal({
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
if (!msg?.success) { setClientIps([]); return; }
const arr = Array.isArray(msg.obj) ? msg.obj : [];
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
setClientIps(normalizeClientIps(msg.obj));
} finally {
setIpsLoading(false);
}
@@ -503,7 +503,7 @@ export default function ClientInfoModal({
>
{clientIps.length > 0 ? (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{clientIps.map((ip, idx) => (
{clientIps.map((entry, idx) => (
<Tag
key={idx}
color="blue"
@@ -516,7 +516,10 @@ export default function ClientInfoModal({
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
}}
>
{ip}
{entry.ip}{entry.time ? ` (${entry.time})` : ''}
{entry.node ? (
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
) : null}
</Tag>
))}
</div>
+29 -14
View File
@@ -53,7 +53,7 @@ import { useWebSocket } from '@/hooks/useWebSocket';
import { useClients } from '@/hooks/useClients';
import { useNodesQuery } from '@/api/queries/useNodesQuery';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
import AppSidebar from '@/layouts/AppSidebar';
import { IntlUtil, SizeFormatter } from '@/utils';
@@ -197,9 +197,9 @@ export default function ClientsPage() {
summary: serverSummary,
allGroups,
setQuery,
inbounds, onlines, loading, fetched, fetchError, subSettings,
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
tgBotEnable, expireDiff, trafficDiff, pageSize,
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
resetTraffic, resetAllTraffics, delDepleted, setEnable,
applyTrafficEvent, applyClientStatsEvent,
refresh,
@@ -220,6 +220,7 @@ export default function ClientsPage() {
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
const [infoOpen, setInfoOpen] = useState(false);
const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
const [qrOpen, setQrOpen] = useState(false);
@@ -429,6 +430,7 @@ export default function ClientsPage() {
setFormMode('add');
setEditingClient(null);
setEditingAttachedIds([]);
setEditingExternalLinks([]);
setFormOpen(true);
}
@@ -441,6 +443,7 @@ export default function ClientsPage() {
setEditingClient(merged);
const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
setEditingAttachedIds([...ids]);
setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
setFormOpen(true);
}
@@ -567,10 +570,18 @@ export default function ClientsPage() {
const onSave = useCallback(async (
payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] },
meta:
| { isEdit: false; email: string; externalLinks: ExternalLinkInput[] }
| { isEdit: true; email: string; attach: number[]; detach: number[]; externalLinks: ExternalLinkInput[] },
) => {
if (!meta.isEdit) {
return create(payload);
const createMsg = await create(payload);
if (!createMsg?.success) return createMsg;
if (meta.email && meta.externalLinks.length > 0) {
const r = await setExternalLinks(meta.email, meta.externalLinks);
if (!r?.success) return r;
}
return createMsg;
}
const updateMsg = await update(meta.email, payload);
if (!updateMsg?.success) return updateMsg;
@@ -582,8 +593,11 @@ export default function ClientsPage() {
const r = await detach(meta.email, meta.detach);
if (!r?.success) return r;
}
// Always replace the client's external links (an empty set clears them).
const r = await setExternalLinks(meta.email, meta.externalLinks);
if (!r?.success) return r;
return updateMsg;
}, [create, update, attach, detach]);
}, [create, update, attach, detach, setExternalLinks]);
const pageClass = useMemo(() => {
const classes = ['clients-page'];
@@ -605,19 +619,19 @@ export default function ClientsPage() {
render: (_v, record) => (
<Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
</Tooltip>
<Tooltip title={t('pages.clients.clientInfo')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
</Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button size="small" type="text" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
@@ -649,7 +663,7 @@ export default function ClientsPage() {
</Tooltip>
);
if (record.enable && isOnline(record.email)) return (
<Tag color="green"><span className="online-dot" />{t('pages.clients.online')}</Tag>
<Tag color="green" className="dot-tag"><span className="online-dot" />{t('pages.clients.online')}</Tag>
);
if (!record.enable) return <Tag>{t('disabled')}</Tag>;
if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
@@ -1100,7 +1114,7 @@ export default function ClientsPage() {
<Table<ClientRecord>
columns={columns}
dataSource={sortedClients}
loading={loading}
loading={transitioning}
rowKey="email"
rowSelection={rowSelection}
pagination={tablePagination}
@@ -1117,7 +1131,7 @@ export default function ClientsPage() {
}}
/>
) : (
<Spin spinning={loading}>
<Spin spinning={transitioning}>
<div className="client-cards">
{filteredClients.length > 0 && (
<div className="card-bulk-bar">
@@ -1243,6 +1257,7 @@ export default function ClientsPage() {
mode={formMode}
client={editingClient}
attachedIds={editingAttachedIds}
attachedExternalLinks={editingExternalLinks}
inbounds={inbounds}
tgBotEnable={tgBotEnable}
groups={allGroups}
+2 -2
View File
@@ -407,10 +407,10 @@ export default function GroupsPage() {
render: (_v, row) => (
<Space size={4}>
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
</Dropdown>
<Tooltip title={t('pages.groups.rename')}>
<Button size="small" type="text" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
</Tooltip>
</Space>
),
@@ -82,6 +82,7 @@ export default function InboundsPage() {
clientCount,
onlineClients,
lastOnlineMap,
inboundSpeed,
totals,
expireDiff,
trafficDiff,
@@ -620,6 +621,7 @@ export default function InboundsPage() {
clientCount={clientCount}
onlineClients={onlineClients}
lastOnlineMap={lastOnlineMap}
inboundSpeed={inboundSpeed}
expireDiff={expireDiff}
trafficDiff={trafficDiff}
pageSize={pageSize}
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { QuestionCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import {
Alert,
@@ -83,6 +84,16 @@ import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
// Render a field label with a hover tooltip icon instead of an `extra` help line below.
const labelWithHint = (label: string, hint: string) => (
<span>
{label}
<Tooltip title={hint}>
<QuestionCircleOutlined style={{ marginInlineStart: 4, color: 'rgba(128,128,128,0.65)' }} />
</Tooltip>
</span>
);
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
@@ -538,16 +549,14 @@ export default function InboundFormModal({
<Form.Item
name="listen"
label={t('pages.inbounds.address')}
extra={t('pages.inbounds.form.listenHelp')}
label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))}
>
<Input placeholder={t('pages.inbounds.monitorDesc')} />
</Form.Item>
<Form.Item
name="shareAddrStrategy"
label={t('pages.inbounds.form.shareAddrStrategy')}
extra={t('pages.inbounds.form.shareAddrStrategyHelp')}
label={labelWithHint(t('pages.inbounds.form.shareAddrStrategy'), t('pages.inbounds.form.shareAddrStrategyHelp'))}
>
<Select
options={SHARE_ADDR_STRATEGIES
@@ -562,8 +571,7 @@ export default function InboundFormModal({
{shareAddrStrategy === 'custom' && (
<Form.Item
name="shareAddr"
label={t('pages.inbounds.form.shareAddr')}
extra={t('pages.inbounds.form.shareAddrHelp')}
label={labelWithHint(t('pages.inbounds.form.shareAddr'), t('pages.inbounds.form.shareAddrHelp'))}
rules={[{
validator: (_, value) => (
isValidShareAddrInput(String(value ?? ''))
@@ -578,8 +586,7 @@ export default function InboundFormModal({
<Form.Item
name="subSortIndex"
label={t('pages.inbounds.form.subSortIndex')}
extra={t('pages.inbounds.form.subSortIndexHelp')}
label={labelWithHint(t('pages.inbounds.form.subSortIndex'), t('pages.inbounds.form.subSortIndexHelp'))}
>
<InputNumber min={1} />
</Form.Item>
@@ -811,7 +818,7 @@ export default function InboundFormModal({
<ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
)}
<SockoptForm toggleSockopt={toggleSockopt} />
<SockoptForm toggleSockopt={toggleSockopt} network={network as string} />
{/* Transport masks don't apply to tunnel (a transparent forwarder), so
its stream tab is just sockopt + TProxy. */}
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { Alert, Button, Form, Input, InputNumber, Segmented, Select, Space, Switch } from 'antd';
import {
Address_Port_Strategy,
@@ -8,12 +8,68 @@ import {
} from '@/schemas/primitives';
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
// Transport key that carries its own acceptProxyProtocol field (mirrored
// alongside the sockopt-level one so the PROXY preset never silently no-ops).
const TRANSPORT_PROXY_FIELD: Record<string, string> = {
tcp: 'tcpSettings',
ws: 'wsSettings',
httpupgrade: 'httpupgradeSettings',
};
// Transports on which xray-core honors sockopt.trustedXForwardedFor.
const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp'];
type RealClientIpPreset = 'off' | 'cloudflare' | 'proxy';
export default function SockoptForm({
toggleSockopt,
network,
}: {
toggleSockopt: (on: boolean) => void;
network: string;
}) {
const { t } = useTranslation();
// Presets write the same sockopt fields the user could set by hand below,
// picking the mechanism xray-core actually honors for the chosen transport:
// CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp) or the
// PROXY-protocol header via acceptProxyProtocol (every transport but mKCP).
const applyRealClientIpPreset = (
preset: RealClientIpPreset,
getFieldValue: (name: (string | number)[]) => unknown,
setFieldValue: (name: (string | number)[], value: unknown) => void,
) => {
const sockopt = getFieldValue(['streamSettings', 'sockopt']);
const sockoptOn =
!!sockopt && typeof sockopt === 'object' && Object.keys(sockopt as object).length > 0;
if (preset !== 'off' && !sockoptOn) {
toggleSockopt(true);
}
const transportField = TRANSPORT_PROXY_FIELD[network];
if (preset === 'off') {
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
return;
}
if (preset === 'cloudflare') {
const current = getFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor']);
const list = Array.isArray(current) ? [...(current as string[])] : [];
if (!list.includes('CF-Connecting-IP')) list.push('CF-Connecting-IP');
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], list);
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
return;
}
// proxy — clear trustedXForwardedFor so a lingering header can't override the
// PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp).
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], true);
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], true);
};
return (
<Form.Item
noStyle
@@ -33,6 +89,89 @@ export default function SockoptForm({
</Form.Item>
{on && (
<>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => {
type ProxyWatch = {
streamSettings?: {
sockopt?: { trustedXForwardedFor?: unknown; acceptProxyProtocol?: unknown };
tcpSettings?: { acceptProxyProtocol?: unknown };
wsSettings?: { acceptProxyProtocol?: unknown };
httpupgradeSettings?: { acceptProxyProtocol?: unknown };
};
};
const pick = (v: ProxyWatch) => {
const s = v.streamSettings;
return JSON.stringify([
s?.sockopt?.trustedXForwardedFor,
s?.sockopt?.acceptProxyProtocol,
s?.tcpSettings?.acceptProxyProtocol,
s?.wsSettings?.acceptProxyProtocol,
s?.httpupgradeSettings?.acceptProxyProtocol,
]);
};
return pick(prev as ProxyWatch) !== pick(curr as ProxyWatch);
}}
>
{({ getFieldValue, setFieldValue }) => {
const sockopt = (getFieldValue(['streamSettings', 'sockopt']) ?? {}) as Record<
string,
unknown
>;
const transportField = TRANSPORT_PROXY_FIELD[network];
const transportPP = transportField
? getFieldValue(['streamSettings', transportField, 'acceptProxyProtocol']) === true
: false;
const proxyOn = sockopt.acceptProxyProtocol === true || transportPP;
const trusted = Array.isArray(sockopt.trustedXForwardedFor)
? (sockopt.trustedXForwardedFor as string[])
: [];
const value: RealClientIpPreset = proxyOn
? 'proxy'
: trusted.length > 0
? 'cloudflare'
: 'off';
const trustedMismatch =
trusted.length > 0 && !TRUSTED_HEADER_NETWORKS.includes(network);
const proxyMismatch = proxyOn && network === 'kcp';
return (
<>
<Form.Item
label={t('pages.inbounds.form.realClientIp')}
tooltip={t('pages.inbounds.form.realClientIpHint')}
>
<Segmented
value={value}
onChange={(v) =>
applyRealClientIpPreset(v as RealClientIpPreset, getFieldValue, setFieldValue)
}
options={[
{ value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
{ value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') },
{ value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
]}
/>
</Form.Item>
{trustedMismatch && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
/>
)}
{proxyMismatch && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
/>
)}
</>
);
}}
</Form.Item>
<Form.Item name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
<InputNumber min={0} />
</Form.Item>
@@ -67,6 +206,7 @@ export default function SockoptForm({
<Form.Item
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
label={t('pages.inbounds.form.proxyProtocol')}
tooltip={t('pages.inbounds.form.proxyProtocolHint')}
valuePropName="checked"
>
<Switch />
@@ -139,6 +279,7 @@ export default function SockoptForm({
<Form.Item
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
label={t('pages.inbounds.form.trustedXForwardedFor')}
tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
>
<Select
mode="tags"
@@ -36,6 +36,7 @@ export default function InboundList({
dbInbounds,
clientCount,
lastOnlineMap: _lastOnlineMap,
inboundSpeed,
expireDiff,
trafficDiff,
pageSize,
@@ -124,6 +125,7 @@ export default function InboundList({
hasActiveNode,
nodesById,
clientCount,
inboundSpeed,
subEnable,
expireDiff,
trafficDiff,
@@ -271,6 +273,7 @@ export default function InboundList({
hasActiveNode={hasActiveNode}
nodesById={nodesById}
clientCount={clientCount}
inboundSpeed={inboundSpeed}
trafficDiff={trafficDiff}
expireDiff={expireDiff}
onClose={() => setStatsRecord(null)}
@@ -0,0 +1,39 @@
import { Tag, Tooltip } from 'antd';
import { SizeFormatter } from '@/utils';
import type { InboundSpeedEntry } from './types';
// True when an inbound has live throughput worth showing.
export function isActiveSpeed(speed?: InboundSpeedEntry): speed is InboundSpeedEntry {
return !!speed && (speed.up > 0 || speed.down > 0);
}
interface InboundSpeedTagProps {
speed: InboundSpeedEntry;
withTooltip?: boolean;
}
// Blue "↑ up / ↓ down" rate tag, optionally with a stacked breakdown tooltip.
export function InboundSpeedTag({ speed, withTooltip = false }: InboundSpeedTagProps) {
const tag = (
<Tag color="blue">
↑ {SizeFormatter.speedFormat(speed.up)}
{' / '}
↓ {SizeFormatter.speedFormat(speed.down)}
</Tag>
);
if (!withTooltip) return tag;
return (
<Tooltip
title={(
<div>
<div>↑ {SizeFormatter.speedFormat(speed.up)}</div>
<div>↓ {SizeFormatter.speedFormat(speed.down)}</div>
</div>
)}
>
{tag}
</Tooltip>
);
}
@@ -13,7 +13,8 @@ import {
tunnelNetworkLabel,
mixedNetworkLabel,
} from './helpers';
import type { ClientCountEntry, DBInboundRecord } from './types';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry } from './types';
interface InboundStatsModalProps {
open: boolean;
@@ -21,6 +22,7 @@ interface InboundStatsModalProps {
hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>;
clientCount: Record<number, ClientCountEntry>;
inboundSpeed: Record<number, InboundSpeedEntry>;
trafficDiff: number;
expireDiff: number;
onClose: () => void;
@@ -32,6 +34,7 @@ export default function InboundStatsModal({
hasActiveNode,
nodesById,
clientCount,
inboundSpeed,
trafficDiff,
expireDiff,
onClose,
@@ -109,6 +112,16 @@ export default function InboundStatsModal({
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
</Tag>
</div>
{(() => {
const speed = inboundSpeed[record.id];
if (!isActiveSpeed(speed)) return null;
return (
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.speed')}</span>
<InboundSpeedTag speed={speed} />
</div>
);
})()}
{clientCount[record.id] && (
<div className="stat-row">
<span className="stat-label">{t('clients')}</span>
@@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
const { t } = useTranslation();
return (
<div className="action-buttons">
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
<Dropdown
trigger={['click']}
menu={{
@@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
onClick: ({ key }) => onClick(key as RowAction),
}}
>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<MoreOutlined />} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
</Dropdown>
</div>
);
@@ -44,6 +44,11 @@ export interface ClientCountEntry {
online: string[];
}
export interface InboundSpeedEntry {
up: number;
down: number;
}
export type RowAction =
| 'edit'
| 'showInfo'
@@ -63,6 +68,7 @@ export interface InboundListProps {
clientCount: Record<number, ClientCountEntry>;
onlineClients: string[];
lastOnlineMap: Record<string, number>;
inboundSpeed: Record<number, InboundSpeedEntry>;
expireDiff: number;
trafficDiff: number;
pageSize: number;
@@ -9,6 +9,7 @@ import { useDatepicker } from '@/hooks/useDatepicker';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { RowActionsCell } from './RowActions';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
import {
readStreamHints,
networkLabel,
@@ -17,7 +18,7 @@ import {
tunnelNetworkLabel,
mixedNetworkLabel,
} from './helpers';
import type { ClientCountEntry, DBInboundRecord, RowAction } from './types';
import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry, RowAction } from './types';
interface UseInboundColumnsParams {
hasAnyRemark: boolean;
@@ -25,6 +26,7 @@ interface UseInboundColumnsParams {
hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>;
clientCount: Record<number, ClientCountEntry>;
inboundSpeed: Record<number, InboundSpeedEntry>;
subEnable: boolean;
expireDiff: number;
trafficDiff: number;
@@ -38,6 +40,7 @@ export function useInboundColumns({
hasActiveNode,
nodesById,
clientCount,
inboundSpeed,
subEnable,
expireDiff,
trafficDiff,
@@ -262,6 +265,19 @@ export function useInboundColumns({
</Popover>
),
},
{
title: t('pages.inbounds.speed'),
key: 'speed',
align: 'center',
width: 90,
render: (_, record) => {
const speed = inboundSpeed[record.id];
if (!isActiveSpeed(speed)) {
return <Tag color='default'>—</Tag>;
}
return <InboundSpeedTag speed={speed} withTooltip />;
},
},
{
title: t('pages.inbounds.expireDate'),
key: 'expiryTime',
@@ -283,5 +299,5 @@ export function useInboundColumns({
);
return cols;
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
}, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, inboundSpeed, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
}
+43 -1
View File
@@ -12,6 +12,8 @@ import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from
import { OnlinesSchema, OnlineByNodeSchema, ActiveInboundsByNodeSchema } from '@/schemas/client';
import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
import type { InboundSpeedEntry } from './list/types';
export interface SubSettings {
enable: boolean;
subTitle: string;
@@ -26,6 +28,17 @@ export interface SubSettings {
type DBInboundInstance = InstanceType<typeof DBInbound>;
// Server-side traffic polling interval in seconds. XrayTrafficJob broadcasts
// deltas accumulated over this window, so dividing by it yields bytes/sec.
const TRAFFIC_POLL_INTERVAL_S = 5;
interface TrafficDelta {
Tag: string;
Up: number;
Down: number;
IsInbound?: boolean;
}
interface ClientRollup {
clients: number;
active: string[];
@@ -179,6 +192,8 @@ export function useInbounds() {
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
const [statsVersion, setStatsVersion] = useState(0);
const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>({});
const [onlineClients, setOnlineClients] = useState<string[]>([]);
const onlineClientsRef = useRef<string[]>([]);
onlineClientsRef.current = onlineClients;
@@ -383,7 +398,13 @@ export function useInbounds() {
const applyTrafficEvent = useCallback(
(payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { onlineClients?: string[]; onlineByGuid?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
const p = payload as {
traffics?: TrafficDelta[];
onlineClients?: string[];
onlineByGuid?: Record<string, string[]>;
activeInbounds?: Record<string, string[]>;
lastOnlineMap?: Record<string, number>;
};
if (Array.isArray(p.onlineClients)) {
onlineClientsRef.current = p.onlineClients;
setOnlineClients(p.onlineClients);
@@ -397,6 +418,26 @@ export function useInbounds() {
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
}
// Full-replace each poll so idle inbounds (and an empty array after an
// Xray stat reset) clear their speed instead of showing a stale value.
if (Array.isArray(p.traffics)) {
const byTag = new Map<string, TrafficDelta>();
for (const tr of p.traffics) {
if (!tr || typeof tr.Tag !== 'string') continue;
if (tr.IsInbound === false) continue;
byTag.set(tr.Tag, tr);
}
const nextSpeed: Record<number, InboundSpeedEntry> = {};
for (const ib of dbInboundsRef.current) {
const delta = byTag.get(ib.tag);
if (!delta) continue;
nextSpeed[ib.id] = {
up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
};
}
setInboundSpeed(nextSpeed);
}
rebuildClientCount();
},
[rebuildClientCount],
@@ -481,6 +522,7 @@ export function useInbounds() {
clientCount,
onlineClients,
lastOnlineMap,
inboundSpeed,
statsVersion,
totals,
expireDiff,
@@ -18,6 +18,7 @@ import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
import type { Msg } from '@/utils';
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
import { antdRule } from '@/utils/zodForm';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import './NodeFormModal.css';
type Mode = 'add' | 'edit';
@@ -49,6 +50,7 @@ function defaultValues(): NodeFormValues {
pinnedCertSha256: '',
inboundSyncMode: 'all',
inboundTags: [],
outboundTag: '',
};
}
@@ -75,6 +77,23 @@ export default function NodeFormModal({
const scheme = Form.useWatch('scheme', form) ?? 'https';
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector);
// when balancers exist they get a labeled group so it's clear the selection
// routes through a balancer. Empty falls back to the placeholder ("Direct
// connection") rather than a synthetic option, so it can't read as a second
// "direct" next to a real freedom outbound.
const outboundOptions = useMemo<
({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[]
>(() => {
const outOpts = (outboundGroups?.outbounds ?? []).map((tag) => ({ label: tag, value: tag }));
if (!outboundGroups?.balancers.length) return outOpts;
return [
{ label: t('pages.xray.Outbounds'), options: outOpts },
{ label: t('pages.xray.Balancers'), options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })) },
];
}, [outboundGroups, t]);
useEffect(() => {
if (!open) return;
@@ -117,6 +136,7 @@ export default function NodeFormModal({
pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
inboundSyncMode: values.inboundSyncMode,
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
outboundTag: values.outboundTag || '',
};
}
@@ -319,6 +339,7 @@ export default function NodeFormModal({
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
{ value: 'pin', label: t('pages.nodes.tlsPin') },
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
]}
/>
</Form.Item>
@@ -332,6 +353,15 @@ export default function NodeFormModal({
/>
)}
{tlsVerifyMode === 'mtls' && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.mtlsFormHint')}
/>
)}
{tlsVerifyMode === 'pin' && (
<Form.Item
label={t('pages.nodes.pinnedCert')}
@@ -356,6 +386,20 @@ export default function NodeFormModal({
<Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
</Form.Item>
<Form.Item
label={t('pages.nodes.outboundTag')}
name="outboundTag"
extra={t('pages.nodes.outboundTagHint')}
getValueProps={(v) => ({ value: (v as string) || undefined })}
>
<Select
allowClear
showSearch
placeholder={t('pages.nodes.outboundTagPlaceholder')}
options={outboundOptions}
/>
</Form.Item>
<Form.Item
label={t('pages.nodes.inboundSyncMode')}
name="inboundSyncMode"
+51 -3
View File
@@ -31,6 +31,10 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
const [cpuLabels, setCpuLabels] = useState<string[]>([]);
const [memPoints, setMemPoints] = useState<number[]>([]);
const [memLabels, setMemLabels] = useState<string[]>([]);
const [netUpPoints, setNetUpPoints] = useState<number[]>([]);
const [netUpLabels, setNetUpLabels] = useState<string[]>([]);
const [netDownPoints, setNetDownPoints] = useState<number[]>([]);
const [netDownLabels, setNetDownLabels] = useState<string[]>([]);
const lastNodeId = useRef<number>(node.id);
@@ -46,7 +50,9 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
return `${hh}:${mm}:${ss}`;
};
const fetchSeries = async (metric: 'cpu' | 'mem') => {
// cpu/mem are percentages (clamp 0-100); net throughput is bytes/sec shown
// as KB/s (no upper clamp, the sparkline auto-scales).
const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => {
try {
const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
const msg = await HttpUtil.get(url) as ApiMsg<SeriesPoint[]>;
@@ -55,7 +61,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
const labs: string[] = [];
for (const p of msg.obj) {
labs.push(bucketLabel(p.t));
vals.push(Math.max(0, Math.min(100, Number(p.v) || 0)));
const n = Number(p.v) || 0;
vals.push(kind === 'pct' ? Math.max(0, Math.min(100, n)) : Math.max(0, n / 1024));
}
return { vals, labs };
}
@@ -66,12 +73,21 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
};
const refresh = async () => {
const [cpu, mem] = await Promise.all([fetchSeries('cpu'), fetchSeries('mem')]);
const [cpu, mem, netUp, netDown] = await Promise.all([
fetchSeries('cpu', 'pct'),
fetchSeries('mem', 'pct'),
fetchSeries('netUp', 'rate'),
fetchSeries('netDown', 'rate'),
]);
if (cancelled) return;
setCpuPoints(cpu.vals);
setCpuLabels(cpu.labs);
setMemPoints(mem.vals);
setMemLabels(mem.labs);
setNetUpPoints(netUp.vals);
setNetUpLabels(netUp.labs);
setNetDownPoints(netDown.vals);
setNetDownLabels(netDown.labs);
};
refresh();
@@ -118,6 +134,38 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
showTooltip
/>
</div>
<div className="series">
<div className="series-title">{t('pages.nodes.netUp')}</div>
<Sparkline
data={netUpPoints}
labels={netUpLabels}
height={120}
stroke="#1677ff"
showGrid
showAxes
tickCountX={4}
maxPoints={netUpPoints.length || 1}
fillOpacity={0.18}
markerRadius={2.6}
showTooltip
/>
</div>
<div className="series">
<div className="series-title">{t('pages.nodes.netDown')}</div>
<Sparkline
data={netDownPoints}
labels={netDownLabels}
height={120}
stroke="#fa8c16"
showGrid
showAxes
tickCountX={4}
maxPoints={netDownPoints.length || 1}
fillOpacity={0.18}
markerRadius={2.6}
showTooltip
/>
</div>
</div>
);
}
+10 -4
View File
@@ -27,6 +27,7 @@ import {
MoreOutlined,
PlusOutlined,
RightOutlined,
SafetyCertificateOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
@@ -43,6 +44,7 @@ interface NodeListProps {
selectedIds: number[];
onSelectionChange: (ids: number[]) => void;
onAdd: () => void;
onMtls: () => void;
onEdit: (node: NodeRecord) => void;
onDelete: (node: NodeRecord) => void;
onProbe: (node: NodeRecord) => void;
@@ -163,6 +165,7 @@ export default function NodeList({
selectedIds,
onSelectionChange,
onAdd,
onMtls,
onEdit,
onDelete,
onProbe,
@@ -241,18 +244,18 @@ export default function NodeList({
) : (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
</Tooltip>
{isUpdateEligible(record) && (
<Tooltip title={t('pages.nodes.updatePanel')}>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
</Tooltip>
)}
<Tooltip title={t('edit')}>
<Button type="text" size="small" style={{ fontSize: 18 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button type="text" size="small" danger style={{ fontSize: 18 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
<Button type="text" size="small" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
@@ -417,6 +420,9 @@ export default function NodeList({
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
{t('pages.nodes.addNode')}
</Button>
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
{t('pages.nodes.mtls.title')}
</Button>
{selectedIds.length > 0 && (
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
{t('pages.nodes.updateSelected', { count: selectedIds.length })}
+73 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Button, Card, Col, ConfigProvider, Layout, Modal, Result, Row, Spin, Statistic, message } from 'antd';
import { Button, Card, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
@@ -45,6 +45,45 @@ export default function NodesPage() {
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [mtlsOpen, setMtlsOpen] = useState(false);
const [trustCa, setTrustCa] = useState('');
const [copyingCa, setCopyingCa] = useState(false);
const [savingTrustCa, setSavingTrustCa] = useState(false);
const onCopyNodeCa = useCallback(async () => {
setCopyingCa(true);
try {
const msg = await HttpUtil.post<{ caCert: string }>('/panel/api/nodes/mtls/ca');
const ca = msg?.obj?.caCert;
if (msg?.success && ca) {
await navigator.clipboard.writeText(ca);
messageApi.success(t('pages.nodes.mtls.caCopied'));
} else {
messageApi.error(msg?.msg || t('pages.nodes.mtls.caFailed'));
}
} catch {
messageApi.error(t('pages.nodes.mtls.caFailed'));
} finally {
setCopyingCa(false);
}
}, [messageApi, t]);
const onSaveTrustCa = useCallback(async () => {
setSavingTrustCa(true);
try {
const msg = await HttpUtil.post('/panel/api/nodes/mtls/trustCA', { caCert: trustCa });
if (msg?.success) {
messageApi.success(t('pages.nodes.mtls.saved'));
setMtlsOpen(false);
} else {
messageApi.error(msg?.msg || t('somethingWentWrong'));
}
} catch {
messageApi.error(t('somethingWentWrong'));
} finally {
setSavingTrustCa(false);
}
}, [trustCa, messageApi, t]);
const onAdd = useCallback(() => {
setFormMode('add');
@@ -215,6 +254,7 @@ export default function NodesPage() {
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
onAdd={onAdd}
onMtls={() => setMtlsOpen(true)}
onEdit={onEdit}
onDelete={onDelete}
onProbe={onProbe}
@@ -239,6 +279,38 @@ export default function NodesPage() {
save={onSave}
onOpenChange={setFormOpen}
/>
<Modal
open={mtlsOpen}
title={t('pages.nodes.mtls.title')}
footer={null}
onCancel={() => setMtlsOpen(false)}
destroyOnHidden
>
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
{t('pages.nodes.mtls.intro')}
</Typography.Paragraph>
<Button onClick={onCopyNodeCa} loading={copyingCa} style={{ marginBottom: 4 }}>
{t('pages.nodes.mtls.copyCa')}
</Button>
<Typography.Paragraph type="secondary">
{t('pages.nodes.mtls.copyCaHint')}
</Typography.Paragraph>
<Typography.Text strong>{t('pages.nodes.mtls.trustLabel')}</Typography.Text>
<Input.TextArea
rows={5}
value={trustCa}
onChange={(e) => setTrustCa(e.target.value)}
placeholder={t('pages.nodes.mtls.trustPlaceholder')}
style={{ marginTop: 4, fontFamily: 'monospace' }}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
{t('pages.nodes.mtls.trustHint')}
</Typography.Paragraph>
<Button type="primary" onClick={onSaveTrustCa} loading={savingTrustCa} block>
{t('pages.nodes.mtls.save')}
</Button>
</Modal>
</Layout>
</ConfigProvider>
);
+137
View File
@@ -0,0 +1,137 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
import { SettingListItem, EventBusCheckboxes } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
interface EmailTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
interface SmtpTestResult {
success: boolean;
stage?: string;
msg: string;
}
export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<SmtpTestResult | null>(null);
const stageLabel: Record<string, string> = {
connect: t('pages.settings.smtpStageConnect'),
auth: t('pages.settings.smtpStageAuth'),
send: t('pages.settings.smtpStageSend'),
};
async function handleTestSmtp() {
setTestLoading(true);
setTestResult(null);
try {
const res = await HttpUtil.post('/panel/api/setting/testSmtp') as SmtpTestResult;
setTestResult(res);
} catch (e: unknown) {
setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') });
} finally {
setTestLoading(false);
}
}
return (
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.smtpSettings'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.smtpEnable')} description={t('pages.settings.smtpEnableDesc')}>
<Switch checked={allSetting.smtpEnable} onChange={(v) => updateSetting({ smtpEnable: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpHost')} description={t('pages.settings.smtpHostDesc')}>
<Input value={allSetting.smtpHost} placeholder="smtp.gmail.com"
onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
onChange={(v) => updateSetting({ smtpPort: Number(v) || 587 })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
<Input value={allSetting.smtpUsername} placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpUsername: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpPassword')}
description={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
<Input.Password value={allSetting.smtpPassword}
placeholder={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordPlaceholder') : ''}
onChange={(e) => updateSetting({ smtpPassword: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>
<Input value={allSetting.smtpTo} placeholder="admin@example.com, ops@example.com"
onChange={(e) => updateSetting({ smtpTo: e.target.value })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.smtpEncryption')} description={t('pages.settings.smtpEncryptionDesc')}>
<Select
value={allSetting.smtpEncryptionType}
onChange={(v) => updateSetting({ smtpEncryptionType: v })}
options={[
{ value: 'none', label: t('pages.settings.smtpEncryptionNone') },
{ value: 'starttls', label: t('pages.settings.smtpEncryptionStartTLS') },
{ value: 'tls', label: t('pages.settings.smtpEncryptionTLS') },
]}
style={{ width: '100%' }}
/>
</SettingListItem>
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button type="primary" icon={<SendOutlined />} loading={testLoading} onClick={handleTestSmtp}>
{t('pages.settings.testSmtp')}
</Button>
{testResult && (
<Alert
type={testResult.success ? 'success' : 'error'}
message={
testResult.success
? t('pages.settings.' + testResult.msg)
: <span><b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b> {t('pages.settings.' + testResult.msg)}</span>
}
showIcon
closable
onClose={() => setTestResult(null)}
/>
)}
</Space>
</>
),
},
{
key: '2',
label: catTabLabel(<MailOutlined />, t('pages.settings.emailNotifications'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.smtpEventBusNotify')} description={t('pages.settings.smtpEventBusNotifyDesc')}>
<EventBusCheckboxes
value={allSetting.smtpEnabledEvents}
onChange={(v) => updateSetting({ smtpEnabledEvents: v })}
extra={{ 'cpu.high': { key: 'smtpCpu', value: allSetting.smtpCpu } }}
onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })}
/>
</SettingListItem>
</>
),
},
]} />
);
}
+3 -1
View File
@@ -26,6 +26,7 @@ import AppSidebar from '@/layouts/AppSidebar';
import GeneralTab from './GeneralTab';
import SecurityTab from './SecurityTab';
import TelegramTab from './TelegramTab';
import EmailTab from './EmailTab';
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
import './SettingsPage.css';
@@ -34,7 +35,7 @@ interface ApiMsg {
success?: boolean;
}
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
const tabSlugs = ['general', 'security', 'telegram', 'email', 'subscription', 'subscription-formats'];
function isIp(h: string): boolean {
if (typeof h !== 'string') return false;
@@ -197,6 +198,7 @@ export default function SettingsPage() {
switch (activeSlug) {
case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'telegram': return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'email': return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription': return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-formats': return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
@@ -8,7 +8,7 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath';
const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'Other' };
const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'External Proxy' };
const REMARK_SAMPLES: Record<string, string> = { i: 'Germany', e: 'john', o: 'Relay' };
const REMARK_SEPARATORS = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
+45 -12
View File
@@ -1,10 +1,11 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { BellOutlined, SettingOutlined } from '@ant-design/icons';
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { LanguageManager } from '@/utils';
import { HttpUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { SettingListItem, EventBusCheckboxes } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
@@ -107,7 +108,7 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
];
return (
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Select<Mode>
style={{ width: '100%' }}
value={state.mode}
@@ -144,6 +145,21 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; msg: string } | null>(null);
async function handleTestTgBot() {
setTestLoading(true);
setTestResult(null);
try {
const res = await HttpUtil.post('/panel/api/setting/testTgBot') as { success?: boolean; msg?: string };
setTestResult({ success: !!res.success, msg: res.msg || '' });
} catch (e: unknown) {
setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') });
} finally {
setTestLoading(false);
}
}
const langOptions = useMemo(
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
@@ -172,11 +188,11 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
<SettingListItem
paddings="small"
title={t('pages.settings.telegramToken')}
description={allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc')}
description={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
>
<Input.Password
value={allSetting.tgBotToken}
placeholder={allSetting.hasTgBotToken ? 'Configured - enter a new token to replace' : ''}
placeholder={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenPlaceholder') : ''}
onChange={(e) => updateSetting({ tgBotToken: e.target.value })}
/>
</SettingListItem>
@@ -198,6 +214,21 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
<Input value={allSetting.tgBotAPIServer} placeholder="https://api.example.com"
onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })} />
</SettingListItem>
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button type="primary" icon={<SendOutlined />} loading={testLoading} onClick={handleTestTgBot}>
{t('pages.settings.testTgBot')}
</Button>
{testResult && (
<Alert
type={testResult.success ? 'success' : 'error'}
message={testResult.msg}
showIcon
closable
onClose={() => setTestResult(null)}
/>
)}
</Space>
</>
),
},
@@ -212,12 +243,14 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}>
<Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyLogin')} description={t('pages.settings.tgNotifyLoginDesc')}>
<Switch checked={allSetting.tgBotLoginNotify} onChange={(v) => updateSetting({ tgBotLoginNotify: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyCpu')} description={t('pages.settings.tgNotifyCpuDesc')}>
<InputNumber value={allSetting.tgCpu} min={0} max={100} style={{ width: '100%' }}
onChange={(v) => updateSetting({ tgCpu: Number(v) || 0 })} />
<SettingListItem paddings="small" title={t('pages.settings.tgEventBusNotify')} description={t('pages.settings.tgEventBusNotifyDesc')}>
<EventBusCheckboxes
value={allSetting.tgEnabledEvents}
onChange={(v) => updateSetting({ tgEnabledEvents: v })}
extra={{ 'cpu.high': { key: 'tgCpu', value: allSetting.tgCpu } }}
onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })}
/>
</SettingListItem>
</>
),
@@ -265,7 +265,7 @@ export default function BalancersTab({
align: 'center',
render: (_v, record) =>
(record.selector || []).map((sel) => (
<Tag key={sel} className="info-large-tag">
<Tag key={sel} className="info-large-tag" style={{ margin: 0, marginRight: 4 }}>
{sel}
</Tag>
)),
@@ -110,7 +110,7 @@ export default function OutboundCardList({
shape="circle"
size="small"
loading={isTesting(outboundTestStates, index)}
disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)}
disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
icon={<ThunderboltOutlined />}
onClick={() => onTest(index, testMode)}
/>
@@ -110,7 +110,7 @@ export default function SubscriptionOutbounds({
shape="circle"
size={isMobile ? 'small' : undefined}
loading={isTesting(subscriptionTestStates, key)}
disabled={!record.tag || isUntestable(record, testMode) || isTesting(subscriptionTestStates, key)}
disabled={!record.tag || isUntestable(record) || isTesting(subscriptionTestStates, key)}
icon={<ThunderboltOutlined />}
onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
/>
@@ -31,10 +31,13 @@ export function outboundAddresses(o: OutboundRow): string[] {
}
}
export function isUntestable(o: OutboundRow, mode: string): boolean {
export function isUntestable(o: OutboundRow): boolean {
if (!o) return true;
if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') return true;
if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
// freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and
// an HTTP probe would only measure the host's own direct reachability, so
// they're untestable in every mode.
if (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS) return true;
return false;
}
@@ -172,7 +172,7 @@ export function useOutboundColumns({
type="primary"
shape="circle"
loading={isTesting(outboundTestStates, index)}
disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)}
disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
icon={<ThunderboltOutlined />}
onClick={() => onTest(index, testMode)}
/>
@@ -235,3 +235,7 @@
text-align: center;
}
.rule-disabled {
opacity: 0.5;
filter: grayscale(1);
}
@@ -58,6 +58,7 @@ export default function RoutingTab({
() =>
rules.map((rule, idx) => {
const r: RuleRow = { key: idx };
r.enabled = rule.enabled !== false;
r.domain = arrJoin(rule.domain);
r.ip = arrJoin(rule.ip);
r.port = rule.port;
@@ -185,6 +186,13 @@ export default function RoutingTab({
[list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
});
}
function toggleRule(idx: number, enabled: boolean) {
mutate((tt) => {
const list = tt.routing?.rules;
if (!list || !list[idx]) return;
list[idx].enabled = enabled;
});
}
function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
if (ev.button != null && ev.button !== 0) return;
@@ -247,6 +255,7 @@ export default function RoutingTab({
moveUp,
moveDown,
confirmDelete,
toggleRule,
});
const tableScrollX = desktopColumns.reduce((sum, c) => {
@@ -289,6 +298,7 @@ export default function RoutingTab({
moveUp={moveUp}
moveDown={moveDown}
confirmDelete={confirmDelete}
toggleRule={toggleRule}
/>
) : (
<Table
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag, Tooltip } from 'antd';
import { Button, Dropdown, Tag, Tooltip, Switch } from 'antd';
import {
MoreOutlined,
EditOutlined,
@@ -13,7 +13,7 @@ import {
} from '@ant-design/icons';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, ruleCriteriaChips } from './helpers';
import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, isApiRule, ruleCriteriaChips } from './helpers';
import type { RuleRow } from './types';
interface RuleCardListProps {
@@ -25,6 +25,7 @@ interface RuleCardListProps {
moveUp: (idx: number) => void;
moveDown: (idx: number) => void;
confirmDelete: (idx: number) => void;
toggleRule: (idx: number, enabled: boolean) => void;
}
export default function RuleCardList({
@@ -36,6 +37,7 @@ export default function RuleCardList({
moveUp,
moveDown,
confirmDelete,
toggleRule,
}: RuleCardListProps) {
const { t } = useTranslation();
const { data: inboundOptions } = useInboundOptions();
@@ -50,7 +52,9 @@ export default function RuleCardList({
key={rule.key}
className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
dropTargetIndex === index && draggedIndex != null && index < draggedIndex ? 'drop-before' : ''
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''}`}
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''} ${
rule.enabled === false ? 'rule-disabled' : ''
}`}
data-row-key={index}
>
<div className="rule-card-head">
@@ -72,6 +76,13 @@ export default function RuleCardList({
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
<Switch
size="small"
checked={rule.enabled !== false}
onChange={(checked) => toggleRule(index, checked)}
disabled={isApiRule(rule)}
style={{ marginLeft: 8 }}
/>
</div>
<div className="rule-flow">
@@ -5,9 +5,10 @@ import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design
import { InputAddon } from '@/components/ui';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
import { buildRemarkByTag, formatInboundTag } from './helpers';
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
export interface RoutingRule {
enabled?: boolean;
type?: string;
domain?: string | string[];
ip?: string | string[];
@@ -38,6 +39,7 @@ interface RuleFormModalProps {
type FormState = RuleFormValues;
const initialForm = (): FormState => ({
enabled: true,
domain: '',
ip: '',
port: '',
@@ -81,6 +83,7 @@ export default function RuleFormModal({
if (!open) return;
if (rule) {
setForm({
enabled: rule.enabled !== false,
domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
port: rule.port || '',
@@ -109,6 +112,7 @@ export default function RuleFormModal({
const v = validated.data;
const built: Record<string, unknown> = {
type: 'field',
enabled: v.enabled,
domain: csv(v.domain),
ip: csv(v.ip),
port: v.port,
@@ -151,6 +155,18 @@ export default function RuleFormModal({
onCancel={onClose}
>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label={t('enable')}>
<Select
value={form.enabled}
onChange={(v) => update('enabled', v)}
disabled={isApiRule(rule ?? {})}
options={[
{ value: true, label: t('enable') },
{ value: false, label: t('disable') },
]}
/>
</Form.Item>
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
@@ -69,6 +69,13 @@ export function inboundTagChipPreview(
return chipPreviewParts(formatInboundTagList(tags, remarkByTag));
}
/** The internal api rule (stats traffic) — its enabled state must stay locked on. */
export function isApiRule(rule: { outboundTag?: string; inboundTag?: string | string[] }): boolean {
if (rule.outboundTag !== 'api') return false;
const tags = Array.isArray(rule.inboundTag) ? rule.inboundTag : csv(rule.inboundTag);
return tags.includes('api');
}
export function ruleCriteriaChips(rule: RuleRow) {
const chips: { label: string; value?: string }[] = [];
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
+1
View File
@@ -1,5 +1,6 @@
export interface RuleRow {
key: number;
enabled?: boolean;
domain?: string;
ip?: string;
port?: string;
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag } from 'antd';
import { Button, Dropdown, Switch, Tag } from 'antd';
import {
MoreOutlined,
EditOutlined,
@@ -15,7 +15,7 @@ import type { ColumnsType } from 'antd/es/table';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import CriterionRow from './CriterionRow';
import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle } from './helpers';
import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle, isApiRule } from './helpers';
import type { RuleRow } from './types';
interface RoutingColumnsParams {
@@ -28,6 +28,7 @@ interface RoutingColumnsParams {
moveUp: (idx: number) => void;
moveDown: (idx: number) => void;
confirmDelete: (idx: number) => void;
toggleRule: (idx: number, enabled: boolean) => void;
}
export function useRoutingColumns({
@@ -40,6 +41,7 @@ export function useRoutingColumns({
moveUp,
moveDown,
confirmDelete,
toggleRule,
}: RoutingColumnsParams): ColumnsType<RuleRow> {
const { t } = useTranslation();
const { data: inboundOptions } = useInboundOptions();
@@ -49,44 +51,66 @@ export function useRoutingColumns({
{
title: '#',
align: 'center',
width: 100,
key: 'action',
width: 60,
key: 'index',
render: (_v, _r, index) => (
<div className="action-cell">
<div className="action-cell" style={{ justifyContent: 'center' }}>
<HolderOutlined
className="drag-handle"
title={t('pages.xray.routing.dragToReorder')}
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
/>
<span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && (
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
: []),
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{
key: 'down',
label: <ArrowDownOutlined />,
disabled: index === rowsLength - 1,
onClick: () => moveDown(index),
},
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
),
},
{
title: t('pages.clients.actions'),
align: 'center',
width: 80,
key: 'action',
render: (_v, _r, index) => (
<div className={!isMobile ? 'action-buttons' : ''} style={{ justifyContent: 'center', margin: 0 }}>
{!isMobile && (
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
: []),
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{
key: 'down',
label: <ArrowDownOutlined />,
disabled: index === rowsLength - 1,
onClick: () => moveDown(index),
},
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
),
},
{
title: t('enable'),
align: 'center',
width: 80,
key: 'enabled',
render: (_v, _r, index) => (
<Switch
size="small"
checked={_r.enabled !== false}
onChange={(checked) => toggleRule(index, checked)}
disabled={isApiRule(_r)}
/>
),
},
{
title: t('pages.xray.rules.source'),
align: 'left',
@@ -184,6 +208,6 @@ export function useRoutingColumns({
),
},
],
[t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete],
[t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete, toggleRule],
);
}
+12
View File
@@ -71,9 +71,20 @@ export const ClientPageResponseSchema = z.object({
groups: nullableStringArray.optional(),
});
// A per-client external link surfaced in the client's subscription:
// kind=link is a single share link, kind=subscription is a remote sub URL.
export const ExternalLinkSchema = z.object({
kind: z.enum(['link', 'subscription']).default('link'),
value: z.string(),
remark: z.string().optional().default(''),
}).loose();
export const ExternalLinkListSchema = z.array(ExternalLinkSchema).nullable().transform((v) => v ?? []);
export const ClientHydrateSchema = z.object({
client: ClientRecordSchema,
inboundIds: nullableNumberArray,
externalLinks: ExternalLinkListSchema.optional(),
});
export const BulkAdjustResultSchema = z.object({
@@ -203,6 +214,7 @@ export const ClientBulkAddFormSchema = z.object({
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
export type InboundOption = z.infer<typeof InboundOptionSchema>;
export type ExternalLink = z.infer<typeof ExternalLinkSchema>;
export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
+15 -3
View File
@@ -29,11 +29,12 @@ export const NodeRecordSchema = z.object({
xrayState: z.string().optional(),
xrayError: z.string().optional(),
allowPrivateAddress: z.boolean().optional(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']).optional(),
pinnedCertSha256: z.string().optional(),
inboundSyncMode: z.enum(['all', 'selected']).optional(),
// Backend serializes a nil []string as null for nodes saved before #5178.
inboundTags: z.array(z.string()).nullish(),
outboundTag: z.string().optional(),
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
// whether it's a read-only transitive sub-node surfaced from a downstream node.
guid: z.string().optional(),
@@ -61,15 +62,26 @@ export const NodeFormSchema = z.object({
address: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
port: z.number().int().min(1).max(65535),
basePath: z.string(),
apiToken: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
// mTLS nodes authenticate via the client certificate, so the token is optional
// there; every other verify mode still requires one (matches remote.do()).
apiToken: z.string().trim(),
enable: z.boolean(),
allowPrivateAddress: z.boolean(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
pinnedCertSha256: z.string().optional().default(''),
inboundSyncMode: z.enum(['all', 'selected']).optional().default('all'),
// Unmounted when sync mode is "all" (absent from antd onFinish values) and
// serialized as null by the backend for a nil slice — tolerate both.
inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
outboundTag: z.string().optional(),
}).superRefine((val, ctx) => {
if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0) {
ctx.addIssue({
code: 'custom',
path: ['apiToken'],
message: 'pages.nodes.toasts.fillRequired',
});
}
});
export type NodeRecord = z.infer<typeof NodeRecordSchema>;
@@ -15,9 +15,18 @@ export type Security = z.infer<typeof SecuritySchema>;
// 'none' neither key appears. The Xray panel's StreamSettings class emits
// `undefined` for the inactive branch which strips the key during JSON
// serialization, so this DU faithfully describes what's on disk.
export const SecuritySettingsSchema = z.discriminatedUnion('security', [
z.object({ security: z.literal('none') }),
z.object({ security: z.literal('tls'), tlsSettings: TlsStreamSettingsSchema }),
z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
//
// Tunnel (dokodemo-door / TProxy) is transportless and may carry only
// `sockopt` — its streamSettings has no `security` key at all. The
// transportless branch accepts that shape, mirroring NetworkSettingsSchema's
// `network: never().optional()` handling. A present-but-invalid security
// still fails both branches so a typo can't slip through.
export const SecuritySettingsSchema = z.union([
z.discriminatedUnion('security', [
z.object({ security: z.literal('none') }),
z.object({ security: z.literal('tls'), tlsSettings: TlsStreamSettingsSchema }),
z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
]),
z.object({ security: z.never().optional() }),
]);
export type SecuritySettings = z.infer<typeof SecuritySettingsSchema>;
@@ -14,28 +14,51 @@ export const RealityClientSettingsSchema = z.object({
});
export type RealityClientSettings = z.infer<typeof RealityClientSettingsSchema>;
// xray-core accepts both `target` and `dest` as the REALITY destination —
// they are aliases (infra/conf/transport_internet.go: REALITYConfig has
// `json:"target"` and `json:"dest"`). The panel writes `target`, but configs
// produced by older panel builds, external tools, or the panel's own
// `/panel/api/inbounds` API commonly use `dest`. Map `dest` -> `target` on
// parse when `target` is absent/empty: otherwise such an inbound loads with
// an empty (required) Target field even though it runs fine, and re-saving
// it serializes the blank `target` and drops the working `dest` — silently
// breaking REALITY on the next xray restart.
const aliasRealityDest = (value: unknown): unknown => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
const hasTarget = typeof obj.target === 'string' && obj.target !== '';
if (!hasTarget && typeof obj.dest === 'string' && obj.dest !== '') {
return { ...obj, target: obj.dest };
}
}
return value;
};
// Reality stream payload. `serverNames` and `shortIds` are stored as
// comma-joined strings in the panel class but ship as string[] on the wire
// — fixtures round-trip through the array form. `target` is the dest host
// Reality piggybacks on; the panel auto-generates random target+SNI when
// blank.
export const RealityStreamSettingsSchema = z.object({
show: z.boolean().default(false),
xver: z.number().int().min(0).default(0),
target: z.string().default(''),
serverNames: z.array(z.string()).default([]),
privateKey: z.string().default(''),
minClientVer: z.string().default(''),
maxClientVer: z.string().default(''),
maxTimediff: z.number().int().min(0).default(0),
shortIds: z.array(z.string()).default([]),
mldsa65Seed: z.string().default(''),
settings: RealityClientSettingsSchema.default({
publicKey: '',
fingerprint: 'chrome',
serverName: '',
spiderX: '/',
mldsa65Verify: '',
export const RealityStreamSettingsSchema = z.preprocess(
aliasRealityDest,
z.object({
show: z.boolean().default(false),
xver: z.number().int().min(0).default(0),
target: z.string().default(''),
serverNames: z.array(z.string()).default([]),
privateKey: z.string().default(''),
minClientVer: z.string().default(''),
maxClientVer: z.string().default(''),
maxTimediff: z.number().int().min(0).default(0),
shortIds: z.array(z.string()).default([]),
mldsa65Seed: z.string().default(''),
settings: RealityClientSettingsSchema.default({
publicKey: '',
fingerprint: 'chrome',
serverName: '',
spiderX: '/',
mldsa65Verify: '',
}),
}),
});
);
export type RealityStreamSettings = z.infer<typeof RealityStreamSettingsSchema>;
+1
View File
@@ -17,6 +17,7 @@ export type RuleWebhook = z.infer<typeof RuleWebhookSchema>;
export const RuleObjectSchema = z.object({
type: z.literal('field').default('field'),
enabled: z.boolean().optional(),
domain: z.array(z.string()).optional(),
ip: z.array(z.string()).optional(),
port: PortValueSchema.optional(),
+1 -1
View File
@@ -26,7 +26,6 @@ export const AllSettingSchema = z.object({
tgBotChatId: z.string().optional(),
tgRunTime: z.string().optional(),
tgBotBackup: z.boolean().optional(),
tgBotLoginNotify: z.boolean().optional(),
tgCpu: z.number().int().min(0).max(100).optional(),
tgLang: z.string().optional(),
twoFactorEnable: z.boolean().optional(),
@@ -91,6 +90,7 @@ export const AllSettingSchema = z.object({
hasApiToken: z.boolean().optional(),
hasWarpSecret: z.boolean().optional(),
hasNordSecret: z.boolean().optional(),
hasSmtpPassword: z.boolean().optional(),
}).loose();
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
+1
View File
@@ -84,6 +84,7 @@ export const OutboundTestResultSchema = z.object({
export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
export const RuleFormSchema = z.object({
enabled: z.boolean(),
domain: z.string(),
ip: z.string(),
port: z.string(),
+8
View File
@@ -33,6 +33,14 @@
animation: online-blink 1.1s ease-in-out infinite;
}
/* For Tags that carry a status dot: inline layout aligns the dot to
baseline + half x-height (vertical-align: middle), which sits visibly
off-centre next to the label; flex centring is exact. */
.dot-tag {
display: inline-flex;
align-items: center;
}
@keyframes online-blink {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.55); }
50% { opacity: 0.35; box-shadow: 0 0 0 4px rgba(82, 196, 26, 0); }
@@ -61,6 +61,46 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params byte-stably
}
`;
exports[`FinalMaskStreamSettingsSchema fixtures > parses realm-tls byte-stably 1`] = `
{
"tcp": [],
"udp": [
{
"settings": {
"stunServers": [
"stun.l.google.com:19302",
],
"tlsConfig": {
"allowInsecure": false,
"alpn": [
"h3",
],
"fingerprint": "chrome",
"serverName": "example.com",
},
"url": "realm://public@example.com/my-realm",
},
"type": "realm",
},
],
}
`;
exports[`FinalMaskStreamSettingsSchema fixtures > parses salamander-gecko byte-stably 1`] = `
{
"tcp": [],
"udp": [
{
"settings": {
"packetSize": "100-200",
"password": "swordfish",
},
"type": "salamander",
},
],
}
`;
exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`] = `
{
"tcp": [
@@ -80,6 +80,7 @@ exports[`inbound transport forms > RawForm field structure is stable 1`] = `
exports[`inbound transport forms > SockoptForm field structure is stable (enabled + happy eyeballs) 1`] = `
[
"Sockopt",
"Real client IP",
"Route Mark",
"TCP Keep Alive Interval",
"TCP Keep Alive Idle",

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