Compare commits

...

37 Commits

Author SHA1 Message Date
MHSanaei
b1fb39c486 v3.4.1 2026-06-26 00:52:00 +02:00
MHSanaei
9381fa284b feat(logs): add auto-update toggle to Access Logs and Logs viewers
A checkbox in both the Xray Access Logs and panel Logs modals polls the
existing refresh every 5s while enabled, respecting the current row count,
level/filter, and Direct/Blocked/Proxy selections. The poller tears down on
close or untoggle. Adds a localized pages.index.autoUpdate key to all 13 locales.
2026-06-26 00:43:32 +02:00
MHSanaei
30796dc2ce chore(deploy): drop the AWS golden-image build stack
Remove the release-driven Packer AMI/qcow2 pipeline and everything that existed only to feed it: the image.yml workflow, deploy/packer, deploy/lightsail, deploy/firstboot, the AWS Marketplace checklist, and the first-boot smoke test/job.

Keep the cloud-agnostic unattended-install path (cloud-init + install.sh non-interactive) and the Hetzner notes, which never depended on the workflow. Hetzner's snapshot path is dropped too since it relied on firstboot to avoid admin/admin on clones; cloud-init regenerates per-instance credentials on its own.

Update deploy/README, the cloud-init and Hetzner docs, the root README plus its six translations, and .gitattributes to match.
2026-06-26 00:35:34 +02:00
MHSanaei
dc6d13b58f chore: bump deps and modernize test loops
- release.yml: download-artifact v7 -> v8
- frontend: i18next 26.3.1 -> 26.3.2, qs 6.15.2 -> 6.15.3
- go.mod: consolidate indirect requires (go mod tidy)
- tests: adopt Go 1.22 range-over-int loops
2026-06-26 00:10:30 +02:00
MHSanaei
e27f2490b2 feat(logs): label the Xray access-log viewer 'Access Logs' across all languages
Distinguishes the access-log modal from the panel 'Logs' viewer it shares a
title with. Adds the accessLogs key to all 13 translation files.
2026-06-25 23:59:59 +02:00
MHSanaei
df0e52cda8 fix(logs): render plain log notices verbatim instead of mangling them as timestamps
A plain message with no timestamp/level (e.g. the Windows 'Syslog is not
supported' notice) was parsed by the app-log branch, which took the first
three words as date/time/level and dropped the rest. Match the strict
'YYYY/MM/DD LEVEL - body' shape only, keep other lines whole, and drop the
leading separator when there is no stamp or level.
2026-06-25 23:59:49 +02:00
MHSanaei
1d69508263 feat(logs): add 1000 rows option and drop 10 from log row count selectors 2026-06-25 23:47:07 +02:00
MHSanaei
8f65aa7e4b fix(hosts): show proper page title instead of falling back to 3X-UI 2026-06-25 23:43:14 +02:00
MHSanaei
293c1e44dc perf(metrics): tiered rollup history (7d at ~1.5MB) and cleaner ranges
Replace the flat 48h@2s ring buffer with a 3-tier rollup ladder (2s/1h, 1m/48h, 10m/7d). A sample feeds every tier and rolls up into progressively coarser averages, so per-metric footprint drops from ~21MB to ~1.5MB (measured, 16 system metrics) while extending the range from 48h to 7 days. aggregate() picks the finest tier covering the requested span; a pre-tier flat gob is migrated by replaying its samples through the rollup.

Tidy the dashboard ranges to a professional ladder: 2m, 1h, 3h, 6h, 12h, 24h, 2d, 7d (drop the irregular 2h/5h, the redundant 30m, and the excessive 30d). The allow-list keeps bucket 30 because the node history panel uses it.

Add an initial FreeOSMemory about 60s after boot to reclaim the startup and metric-restore peak instead of waiting for the periodic release. Cover the rollup, tier selection, round-trip, and footprint with tests.
2026-06-25 23:30:13 +02:00
MHSanaei
69ad8b76e1 perf(memory): report real RSS and cut footprint via GOGC + periodic release
The Usage card showed runtime.MemStats.Sys, a never-shrinking high-water mark of reserved address space that also counts memory already returned to the OS, so it overstated real usage (e.g. ~300 MB on an idle 1-client server). Report process RSS instead so the number matches the OS and drops as memory is freed.

Replace the auto GOMEMLIMIT that targeted ~90 percent of total system RAM (a near no-op while the heap sits far below the limit, and a GC-thrash risk on small/shared VPS per go.dev/doc/gc-guide) with: a lower default GOGC (XUI_GOGC, default 75), a periodic debug.FreeOSMemory job (XUI_MEMORY_RELEASE_INTERVAL, default 10m, 0 disables), and a soft limit applied only from an explicit budget (GOMEMLIMIT, XUI_MEMORY_LIMIT, or a real cgroup cap at 90 percent).
2026-06-25 22:16:38 +02:00
MHSanaei
b32837e523 fix(node): import per-client traffic history on first sync of a node-hosted inbound
On the first sync of a node-hosted inbound, the central inbound adopted the
node's full lifetime counter but every client_traffics row was seeded at 0 (with
the delta baseline set to the node's current counter). So adding or migrating a
node that already had traffic kept the inbound total correct while every
per-client counter restarted from zero, and the master under-reported per-client
usage by the entire pre-attach history.

Seed a new client_traffics row from the node counter only when the inbound was
created during the same sync (a genuine node-add / inbound re-import); a client
reappearing under a pre-existing inbound still seeds 0, preserving the ghost
protection in TestGhostData_NoPhantomTraffic. The seed is additionally gated on
the delete tombstone so a just-deleted client cannot be resurrected if its
inbound is recreated. Baseline still equals the seeded value, so the next sync
delta is 0 and no traffic is double counted.

Adds TestNodeAdd_ImportsClientHistoryWithNewInbound and
TestNodeAdd_TombstonedClientNotResurrected.
2026-06-25 21:19:27 +02:00
MHSanaei
9dec15bd4b feat(uninstall): offer to purge PostgreSQL when removing the panel 2026-06-25 19:40:10 +02:00
MHSanaei
e64e998194 feat(clients): add bulk enable/disable and move selection actions into More menu
Add bulkEnable/bulkDisable named endpoints backed by a shared internal impl, and consolidate the per-selection actions (attach, detach, add to group, ungroup, enable, disable, adjust, sub links) into the clients table's More dropdown so the toolbar only shows the selection count and delete. Translate the new enable/disable confirm dialogs and toasts across all 13 locales.
2026-06-25 19:21:42 +02:00
MHSanaei
a4be5a0deb fix(sub): recover {{TRAFFIC_USED}} for clients with orphaned traffic rows
statsForClient resolved usage only through paths keyed by client_traffics.inbound_id (preloaded ClientStats + the statsByEmail index). That id is written once by AddClientStat and never updated, so an inbound delete+recreate orphans the row from every loaded inbound, both paths miss, and the zero-traffic placeholder makes {{TRAFFIC_USED}} read 0.00B for pre-existing clients while the sub-info header (AggregateTrafficByEmails, email-keyed) stays correct.

Add a last-resort lookup by the globally-unique email, cached into statsByEmail for the request. Closes #5567.
2026-06-25 18:18:47 +02:00
MHSanaei
e4b881e58a feat(panel): surface dev-build version in UI, bot, and CLI
A dev build now shows its `dev+<commit>` identity instead of a misleading stable-looking version in the sidebar badge, dashboard card, update modal, Telegram status report, startup log, and `x-ui -v`. Adds a shared formatPanelVersion helper (single v prefix; dev labels shown verbatim) and fixes the mobile-tag double-v.

Renames the version getters for clarity: config.GetVersion to GetBaseVersion (raw embedded version), config.GetReportedVersion to GetPanelVersion (advertised/displayed), and the xray process GetVersion to GetXrayVersion.
2026-06-25 02:36:41 +02:00
MHSanaei
2adb59bd64 feat(install): add dev-latest install option and sync README translations
install.sh now accepts `dev-latest` (or `dev`) to install the rolling per-commit dev pre-release, bypassing the numeric version-floor check.

README.md documents the version-pinned and dev-latest install commands. All six language READMEs are brought back in sync with the English source: the new install instructions plus the previously-missing "Unattended install & cloud images" section, the XUI_TUNNEL_HEALTH_* env vars, and the custom subscription templates link.
2026-06-25 02:36:30 +02:00
MHSanaei
bcd1358032 fix(nodes): report dev builds as dev+<commit> so updated nodes aren't flagged stale
A node's status reported config.GetVersion() (3.4.0) even on a dev build, so the master compared it against its own dev latestVersion (dev+<sha>) and every node showed 'update available'. Nodes on a dev build now report dev+<short commit>, matching the master's format, so a node on the current dev commit compares as up to date.
2026-06-25 00:46:43 +02:00
MHSanaei
e8878b71a4 feat(nodes): add Dev channel option to node panel updates
The node update confirm dialog now offers a 'Dev channel (latest commit)' choice. The dev flag threads master -> nodes/updatePanel -> UpdatePanels -> remote.UpdatePanel -> the node's updatePanel endpoint, which calls StartUpdateChannel(dev) to install the rolling dev-latest build. With no dev flag the node keeps following its own channel setting.
2026-06-25 00:29:03 +02:00
MHSanaei
11c5b53fac feat(sub): add PROTOCOL, TRANSPORT, SECURITY remark template variables 2026-06-25 00:12:25 +02:00
MHSanaei
896016f7f6 fix(web): remove deleted multi-inbound client from runtime regardless of shared email (#5543)
DelInboundClientByEmail gated the runtime RemoveUser/DeleteUser (and its
push-plan resolution) on !emailShared. But Xray users are keyed by inbound
tag + email, so a client attached to two inbounds left its user live in the
running Xray of every inbound where the email was still shared by a sibling
inbound, until an Xray restart.

Decouple the per-inbound runtime removal from emailShared; keep emailShared
only for preserving the shared email-keyed client_traffics/IP rows.
2026-06-24 22:43:18 +02:00
MHSanaei
e2d25d0ac7 fix(web): show subscription outbounds in dialer proxy dropdown (#5540)
The outbound edit form's Dialer Proxy dropdown only listed local outbounds because subscriptionOutboundTags never reached OutboundsTab. Thread it through XrayPage and feed a dedicated dialerProxyTags list (local non-blackhole outbounds plus subscription tags, excluding the outbound being edited) to SockoptForm. Tag-uniqueness validation still uses the full local tag set, so the blackhole outbound is hidden only from the dropdown, matching HostSockoptForm.
2026-06-24 22:35:39 +02:00
Rick Sanchez
fe025e8af3 feat(xray): add tunnel health monitor (#5480)
* feat(xray): add tunnel health monitor

* fix(tunnelmonitor): reuse netproxy client and init logger in tests

Replace the duplicated newHTTPClient/dialContextWithProxy with netproxy.NewHTTPClient, which centralises the http/https/socks5 handling and avoids the dial-goroutine connection leak on context cancellation. Cap failures at the threshold during cooldown so the counter stays a true consecutive-failure count. Add TestMain to initialise the logger and fix the nil-pointer panic in the success-after-failure path.

* fix(tunnelmonitor): observable recovery, signal headroom, and hardening

Address the remaining review findings on the tunnel health monitor:

- Recovery is now synchronous and observable: the callback calls
  server.RestartXray() directly and returns its error instead of just
  enqueuing SIGUSR1, so a failed restart no longer masks as success and
  arms the cooldown while the tunnel is still down.
- Give the OS signal channel headroom (buffer 8) so producers cannot
  starve a SIGTERM/SIGINT out of the single slot.
- Warn at startup when the monitor is enabled without a proxy, since the
  probe then measures host connectivity rather than the xray tunnel.
- Cap failures at the threshold in the nil-recover branch too, matching
  the cooldown cap.
- Document the XUI_TUNNEL_HEALTH_* vars in .env.example and the README.
- Add tests for status-code classification, Normalize bounds, New proxy
  scheme errors, the recovery-error and nil-recover paths, the cooldown
  cap, and Run context cancellation (coverage 90%).

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-24 22:01:37 +02:00
FunLay123
3ba43bd86d feat(web): vless encryption new modes (#5517)
* feat(web): add vless encryption new modes

* feat(web): add translations for vless encryption modes

* feat(translation): bring "vlessAuthX25519" and "vlessAuthMlkem768" to general form
2026-06-24 21:22:42 +02:00
w3struk
ae9bbdf267 fix(web): serve panel SPA routes from NoRoute (#5536)
* fix(web): serve panel SPA routes from NoRoute

Return the React shell for authenticated panel document routes that are not explicitly registered in Gin, such as /panel/hosts. Keep API, CSRF, static-file, method, and Accept exclusions so API misses remain 404 and auth semantics stay unchanged.

* fix(web): remove unreachable panel path guard

The panel path is always built by appending /panel, so it can never be empty.
Remove the redundant fallback branch without changing SPA routing behavior.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* fix(web): allowlist static-asset extensions in SPA fallback

The blanket path.Ext check rejected any panel route whose last segment contained a dot, which would reintroduce the refresh 404 for a future client route carrying a dotted parameter (version, domain, or email-like value). Restrict the static-asset exclusion to a known, case-insensitive extension allowlist and add predicate regression cases.
2026-06-24 21:19:12 +02:00
MHSanaei
2830f97f50 feat(x-ui.sh): add Dev channel update option to the management menu 2026-06-24 19:12:44 +02:00
MHSanaei
1d1128cf94 fix(update): read setUpdateChannel body as form field, not JSON
The panel's axios layer posts application/x-www-form-urlencoded, so the dev-channel toggle sent dev=true and ShouldBindJSON failed with 'invalid character d'. Parse c.PostForm("dev") to match the codebase's form-encoded POST convention.
2026-06-24 18:24:54 +02:00
MHSanaei
aad2b3eb1e feat(update): add rolling dev update channel for per-commit builds
Adds an opt-in Dev channel so panels running CI per-commit builds can self-update to the latest commit, mirroring the stable online-update flow.

CI publishes/overwrites a single fixed-tag pre-release (dev-latest), force-moved to the newest main commit and marked --latest=false so releases/latest stays the stable tag. Builds stamp the short commit via -ldflags; the panel compares the running commit to the dev release commit to detect an update, and update.sh honors XUI_UPDATE_TAG to install from that tag. Linux/systemd only.
2026-06-24 18:11:22 +02:00
MHSanaei
93ff60e568 fix(tgbot): reload bot on settings save so a new token takes effect without a panel restart
The Telegram bot was only started at panel boot, so saving a token or toggling tgBotEnable persisted to the DB but never reached the running bot until a full restart, making it look like the token did not save (issue #5539). The settings/update controller now reconciles the bot the same way panelOutbound reconciles Xray: when tgBotEnable, the token, chat ID, or API server change, it stops/(re)starts the bot and updates the event-bus subscription.
2026-06-24 17:34:05 +02:00
MHSanaei
23e73cd4a3 fix(clients): use new email after rename and de-duplicate save toast
On client edit the post-update calls (attach/detach/externalLinks) keyed by the original email, so renaming a client made setExternalLinks fail with record-not-found. Key them by the updated email instead.

Each of those sub-step POSTs also auto-toasted its own success, so a save fired the 'Inbound client has been updated' toast twice (or more). Add a silentSuccess HttpUtil option that suppresses the redundant success toast while still surfacing errors and the node-offline warning, and apply it to the attach/detach/externalLinks mutations.
2026-06-24 17:10:17 +02:00
MHSanaei
b0c1156dd6 fix(sub): drive display remarks from the template and split multi-host subpage links
Unify remark generation around the Remark Template. Display contexts (Clients-page QR/Info modals and the HTML sub info page) now render the template name-only client/identity part instead of a hardcoded fallback; the subscription body keeps the full template on a client first link and name-only thereafter. The default template gains the email token so the client email shows by default again (#5532).

BuildPageData now splits each multi-link entry (one link per host of an inbound) into a separate row, so the sub page no longer collapses several host links onto a single mangled line. QR captions on the Clients QR modal and the sub page reuse the link fragment remark.
2026-06-24 16:45:23 +02:00
MHSanaei
5dbd5b1d12 fix(sub): restore client email in panel copy/QR link remark (#5532)
Display-context links (Clients page QR + Information modals and the sub info page) dropped the client email from the link fragment in 3.4.0, showing only the inbound remark. Append the email back so the imported profile keeps its per-client label: inbound-host-email when a host is set, inbound-email otherwise. The usage template stays bypassed in display context, so no traffic or expiry data leaks.
2026-06-24 15:25:41 +02:00
MHSanaei
bd60e770f4 fix(outbound): preserve custom headers for HTTP outbounds (#5519)
The Outbounds form routed HTTP through the SOCKS-shared simpleAuth adapter, which only knew address/port/user/pass, so xray's top-level settings.headers was dropped on both load and save. Opening and re-saving an HTTP outbound destroyed its headers.

Add headers to the HTTP wire/form schemas, round-trip it via dedicated httpFromWire/httpToWire helpers, and expose a HeaderMapEditor in the form. Only settings-level headers round-trip; xray-core ignores per-server headers.
2026-06-24 14:22:25 +02:00
MHSanaei
a5e865c109 fix(backup): name Telegram backups after webDomain/IP instead of x-ui
The bot's ServerService is a separate instance whose mutex-guarded LastStatus is never populated (only RefreshStatus fills it, which the bot never calls), so backupHost's public-IP fallback never fired and bot backups collapsed to x-ui when no webDomain was set.

Resolve the public IP directly via a new mutex-guarded resolvePublicIPs helper (extracted from GetStatus and shared with it) so the bot path gets a real address. Panel downloads keep using the browser request host; the Telegram bot falls back to webDomain then public IP.
2026-06-24 14:12:41 +02:00
Rouzbeh†
82600936d6 fix(flow): restore XTLS Vision when an inbound becomes flow-eligible (#5520)
* fix(flow): restore XTLS Vision when an inbound becomes flow-eligible

clientWithInboundFlow strips Vision from a VLESS client whenever the target
inbound is not flow-eligible at client-write time — e.g. an XHTTP inbound
before its vlessenc (ML-KEM) encryption is set, or a client attached to such
an inbound. Nothing restored the flow once the inbound later became eligible:
an inbound edit stores its settings verbatim and never re-gates the clients.
So enabling encryption on an existing XHTTP inbound left every client without
flow, and the generated configs, share links and subscriptions silently
dropped flow=xtls-rprx-vision — most visibly on node inbounds and on any
inbound where encryption was turned on after the clients existed.

Restore the flow at the two points where an inbound can become eligible:

- UpdateInbound: after the new stream/settings are final, re-add Vision to
  clients that currently carry no flow but whose intended flow (their
  flow_override on a sibling inbound, via EffectiveFlowByEmail) is Vision —
  only when the inbound is now flow-eligible.
- MigrationRestoreVisionFlow: a one-time, idempotent boot migration that
  applies the same repair to existing installs and refreshes flow_override
  via SyncInbound.

The repair is conservative: it never invents a flow for a client that has
none anywhere, never overwrites an explicit flow, and is a no-op on healthy
installs. Adds EffectiveFlowByEmail and a unit test covering keep/skip/no-op
cases.

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

* style(flow): serialize restored settings with MarshalIndent

Match the indented JSON used by the adjacent timestamp block in UpdateInbound
and the externalProxy migration, so a restored inbound's settings column keeps
the same multi-line format as everything else (review nit on #5520).

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

* perf(flow): batch the intended-flow lookup and run it on the active tx

restoreVisionFlowForEligibleInbound resolved each empty-flow client's intended
flow with EffectiveFlowByEmail, which issued two queries per client
(GetRecordByEmail + EffectiveFlow). A client that genuinely uses no Vision keeps
an empty flow forever, so it was re-queried on every UpdateInbound and every
boot — O(clients) queries per save on a Reality/TCP or XHTTP+vlessenc inbound
carrying many non-Vision clients, executed inside the serialized writer
transaction.

Replace it with EffectiveFlowsByEmails: collect every empty-flow email first and
resolve them in a single batched join over client_inbounds + clients (lowest
inbound_id wins, same rule as before), chunked for the SQLite bind-var limit.

Also thread the active tx through restoreVisionFlowForEligibleInbound so the
read runs on the writer's own connection while it holds the lock instead of a
separate pooled connection (UpdateInbound passes its tx; the boot migration
passes nil → GetDB() as before).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:02:42 +02:00
Rouzbeh†
14de0557f9 feat(clients): bulk-set XTLS flow from the Adjust dialog (#5524)
* feat(clients): bulk-set XTLS flow from the Adjust dialog

Add a "Set flow" dropdown to the bulk Adjust dialog so an admin can set or
clear the XTLS flow on all selected clients at once, alongside the existing
days/traffic bumps. Empty by default (no effect on save); "Disable" clears
flow, and the two vision values mirror the per-client credential tab.

Flow rides the existing inbound-JSON -> SyncInbound path (ClientRecord.Flow +
client_inbounds.flow_override), so no new endpoint, DB column, or migration.
Setting a vision flow is gated by inboundCanEnableTlsFlow: ineligible inbounds
are left untouched and reported as skipped; clearing is always allowed. A real
flow change requests an xray restart (local) or a node reconcile (remote).

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

* fix(clients): keep days/traffic write when bulk flow is ineligible

Address review on the bulk-flow-adjust PR:

- Blocking: a client adjusted with both a days/traffic delta and a flow
  directive on a flow-ineligible inbound had the flow-ineligibility recorded
  into the same skip set that gates the ClientTraffic write, so the inbound
  JSON / ClientRecord advanced but ClientTraffic did not — divergent stores,
  and the client misreported as skipped. Track flow ineligibility in its own
  map (bulkInboundAdjustResult.flowIneligible) so it only feeds the final
  Skipped report and never suppresses the expiry/total persistence.
- Drop the broad delete(skippedReasons, email): flow reasons no longer enter
  skippedReasons, so honoring a flow can no longer erase an unrelated skip
  reason (unlimited expiry, a real persistence error on another inbound).
- Drop the inline comment block from ClientBulkAdjustModal.tsx (file had none);
  move the whitelist-sync note next to bulkFlowAllowed, the source of truth.
- Document the optional flow field in the bulkAdjust API-docs example
  (endpoints.ts) and regenerate openapi.json.
- Add a regression test covering days+flow on an ineligible inbound.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:55:08 +02:00
Rouzbeh†
c93beef267 fix(inbounds): accept null rewritePort in tunnel settings (#5516) (#5525)
Clearing the Rewrite port field makes AntD InputNumber write null into the
form store. The tunnel schema declared rewritePort as PortSchema.optional(),
which accepts undefined but not null, so saving (or the JSON tab reflecting
null) failed validation with "settings.rewritePort — Invalid input".

Accept null and collapse it to undefined so the field is simply omitted from
the serialized payload, matching the behavior of deleting the key by hand.
The trailing .optional() keeps the key optional in the inferred type.

Closes #5516
2026-06-24 12:54:05 +02:00
MHSanaei
48c2fb27b8 feat(sub): add Incy client integration and routing tab
Add an Incy quick-import button (incy://add) to the Android and iOS app menus on the subscription page, and a new Incy settings tab with routing enable + rules. Incy routing is delivered by injecting an incy://routing/onadd line into the raw subscription body, avoiding a collision with Happ's Routing header. Includes backend settings, regenerated OpenAPI/zod schemas, and translations for all locales.
2026-06-24 12:51:22 +02:00
147 changed files with 5978 additions and 2756 deletions

View File

@@ -4,3 +4,16 @@ XUI_LOG_FOLDER=x-ui
XUI_BIN_FOLDER=x-ui
XUI_INIT_WEB_BASE_PATH=/
# XUI_PORT=8080
# Optional tunnel health monitor (disabled by default). It periodically probes a
# URL and restarts xray-core after repeated failures. Point XUI_TUNNEL_HEALTH_PROXY
# at a local xray inbound so the probe tests the tunnel; without it the probe only
# checks host connectivity and a restart will not fix host network issues. A restart
# drops every connected client.
# XUI_TUNNEL_HEALTH_MONITOR=true
# XUI_TUNNEL_HEALTH_PROXY=socks5://127.0.0.1:1080
# XUI_TUNNEL_HEALTH_URL=https://www.cloudflare.com/cdn-cgi/trace
# XUI_TUNNEL_HEALTH_INTERVAL=30s
# XUI_TUNNEL_HEALTH_TIMEOUT=10s
# XUI_TUNNEL_HEALTH_FAILURES=3
# XUI_TUNNEL_HEALTH_COOLDOWN=5m

5
.gitattributes vendored
View File

@@ -5,8 +5,5 @@ frontend/src/generated/** text eol=lf
frontend/public/openapi.json 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
# Cloud-init deploy assets are consumed on Linux — force LF regardless of host.
deploy/**/*.yaml text eol=lf

View File

@@ -1,260 +0,0 @@
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@v7
- 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@v7
- 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@v6
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@v6
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"

View File

@@ -97,7 +97,13 @@ jobs:
export CC=$(realpath "$(find "$TOOLCHAIN_DIR/bin" -name '*-gcc.br_real' -type f -executable | head -n1)")
[ -z "$CC" ] && { echo "No gcc.br_real found in $TOOLCHAIN_DIR/bin" >&2; exit 1; }
cd -
go build -ldflags "-w -s -linkmode external -extldflags '-static'" -o xui-release -v main.go
# Stamp the commit into per-commit (dev channel) builds only; tagged
# stable releases stay unstamped so config.IsDevBuild() returns false.
LDFLAGS="-w -s -linkmode external -extldflags '-static'"
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
LDFLAGS="$LDFLAGS -X github.com/mhsanaei/3x-ui/v3/internal/config.buildCommit=${GITHUB_SHA::8} -X github.com/mhsanaei/3x-ui/v3/internal/config.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
fi
go build -ldflags "$LDFLAGS" -o xui-release -v main.go
file xui-release
ldd xui-release || echo "Static binary confirmed"
@@ -245,7 +251,12 @@ jobs:
go version
gcc --version
go build -ldflags "-w -s" -o xui-release.exe -v main.go
# Stamp the commit into per-commit (dev channel) builds only.
LDFLAGS="-w -s"
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
LDFLAGS="$LDFLAGS -X github.com/mhsanaei/3x-ui/v3/internal/config.buildCommit=${GITHUB_SHA:0:8} -X github.com/mhsanaei/3x-ui/v3/internal/config.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
fi
go build -ldflags "$LDFLAGS" -o xui-release.exe -v main.go
- name: Copy and download resources
shell: pwsh
@@ -302,3 +313,61 @@ jobs:
asset_name: x-ui-windows-amd64.zip
overwrite: true
prerelease: true
# =================================
# Rolling dev channel (per-commit)
# =================================
# Publishes/overwrites the build artifacts to a single fixed-tag pre-release
# `dev-latest`, force-moved to the new commit on every push to main. The panel's
# "Dev" update channel installs from this tag. `--latest=false` is load-bearing:
# it keeps releases/latest pointing at the real stable tag, so the stable
# channel is unaffected.
publish-dev:
name: Publish rolling dev release
needs: [build, build-windows]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
# Serialize racing pushes; never cancel an in-flight upload, or the dev
# release could be left with a partial asset set.
concurrency:
group: dev-release
cancel-in-progress: false
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Download all build artifacts
uses: actions/download-artifact@v8
with:
path: dev-artifacts
merge-multiple: true
- name: Publish dev-latest pre-release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMIT: ${{ github.sha }}
run: |
set -e
short="${COMMIT::8}"
notes="Rolling development build — installs via the panel's Dev update channel.
commit=${COMMIT}
built=$(date -u +%Y-%m-%dT%H:%M:%SZ)
Automated per-commit build from main. Not a stable release."
# Force-move the dev-latest tag to this commit so the release tracks it.
git tag -f dev-latest "${COMMIT}"
git push -f origin refs/tags/dev-latest
if gh release view dev-latest >/dev/null 2>&1; then
gh release edit dev-latest --prerelease --latest=false \
--title "Dev build ${short}" --notes "${notes}"
else
gh release create dev-latest --prerelease --latest=false \
--target "${COMMIT}" --title "Dev build ${short}" --notes "${notes}"
fi
gh release upload dev-latest dev-artifacts/*.tar.gz dev-artifacts/*.zip --clobber

View File

@@ -1,7 +1,7 @@
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.
# Container smoke test for the unattended (cloud-init) install path.
# Runs only when the install/deploy assets change.
on:
push:
@@ -30,15 +30,3 @@ jobs:
- uses: actions/checkout@v7
- 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@v7
- name: First-boot credential smoke test
run: bash deploy/test/smoke-firstboot.sh

View File

@@ -33,7 +33,7 @@
- **إحصائيات الترافيك** — لكل اتصال وارد، ولكل عميل، ولكل اتصال صادر، مع عناصر تحكم لإعادة التعيين.
- **دعم العقد المتعددة** — إدارة وتوسيع عبر عدة خوادم من لوحة واحدة.
- **الاتصالات الصادرة والتوجيه** — WARP، NordVPN، قواعد توجيه مخصصة، موازنات تحميل، وتسلسل الوكلاء الصادرة.
- **خادم اشتراك مدمج** بصيغ إخراج متعددة.
- **خادم اشتراك مدمج** بصيغ إخراج متعددة و[قوالب صفحات مخصصة](docs/custom-subscription-templates.md).
- **روبوت تيليجرام** للمراقبة والإدارة عن بُعد.
- **واجهة RESTful API** مع توثيق Swagger داخل اللوحة.
- **تخزين مرن** — SQLite (افتراضي) أو PostgreSQL.
@@ -73,10 +73,32 @@
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
لتثبيت إصدار محدد، أضِف وسمه (مثل `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
```
لتثبيت بنية **dev** المتجددة (أحدث إصدار أولي لكل التزام (commit) من `main`، وليس إصدارًا مستقرًا)، مرّر `dev-latest`:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
```
أثناء التثبيت، يتم إنشاء اسم مستخدم وكلمة مرور ومسار وصول عشوائية. بعد التثبيت، شغّل `x-ui` لفتح قائمة الإدارة، حيث يمكنك بدء/إيقاف الخدمة، وعرض أو إعادة تعيين بيانات تسجيل الدخول، وإدارة شهادات SSL، والمزيد.
للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki).
### التثبيت غير التفاعلي
يعمل المثبِّت أيضًا **بشكل غير تفاعلي** لـ cloud-init.
عيّن `XUI_NONINTERACTIVE=1` (أو مرّره عبر أنبوب دون TTY) وسيتولى التثبيت من البداية إلى النهاية
دون أي مطالبات، مُنشئًا بيانات اعتماد عشوائية وكاتبًا إياها في
`/etc/x-ui/install-result.env`. راجع [`deploy/`](deploy/) لـ:
- [بيانات مستخدم cloud-init](deploy/cloud-init/) — تثبيت غير تفاعلي على أي سحابة (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
- [ملاحظات Hetzner Cloud](deploy/marketplace/hetzner/) — نشر يعتمد على cloud-init على Hetzner
## المنصات المدعومة
**أنظمة التشغيل:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap)، Alpine و Windows.
@@ -134,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
| `XUI_ENABLE_FAIL2BAN` | تفعيل فرض حدود IP المعتمد على Fail2ban | `true` |
| `XUI_LOG_LEVEL` | مستوى السجل (`debug`، `info`، `warning`، `error`) | `info` |
| `XUI_DEBUG` | تفعيل وضع التصحيح | `false` |
| `XUI_TUNNEL_HEALTH_MONITOR` | تفعيل مراقب صحة النفق (يفحص عنوان URL ويعيد تشغيل xray بعد فشل متكرر؛ إعادة التشغيل تقطع جميع العملاء) | `false` |
| `XUI_TUNNEL_HEALTH_PROXY` | الوكيل الذي يُرسَل عبره الفحص؛ وجّهه إلى اتصال xray وارد محلي ليختبر الفحص النفق (مثل `socks5://127.0.0.1:1080`). القيمة الفارغة تعني أن الفحص يتحقق فقط من اتصال المضيف | — |
| `XUI_TUNNEL_HEALTH_URL` | عنوان URL الذي يُفحَص لمعرفة صحة النفق | `https://www.cloudflare.com/cdn-cgi/trace` |
| `XUI_TUNNEL_HEALTH_INTERVAL` | الفترة بين عمليات الفحص | `30s` |
| `XUI_TUNNEL_HEALTH_TIMEOUT` | مهلة كل عملية فحص | `10s` |
| `XUI_TUNNEL_HEALTH_FAILURES` | عدد حالات الفشل المتتالية قبل تشغيل إعادة التشغيل | `3` |
| `XUI_TUNNEL_HEALTH_COOLDOWN` | الحد الأدنى للتأخير بين عمليات إعادة التشغيل المتتالية | `5m` |
## اللغات المدعومة

View File

@@ -33,7 +33,7 @@ Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un sop
- **Estadísticas de tráfico** — por entrada, por cliente y por salida, con controles de reinicio.
- **Soporte multinodo** — gestiona y escala a través de varios servidores desde un único panel.
- **Salida y enrutamiento** — WARP, NordVPN, reglas de enrutamiento personalizadas, balanceadores de carga y encadenamiento de proxy de salida.
- **Servidor de suscripción integrado** con múltiples formatos de salida.
- **Servidor de suscripción integrado** con múltiples formatos de salida y [plantillas de página personalizables](docs/custom-subscription-templates.md).
- **Bot de Telegram** para monitorización y gestión remotas.
- **API RESTful** con documentación Swagger dentro del panel.
- **Almacenamiento flexible** — SQLite (predeterminado) o PostgreSQL.
@@ -73,10 +73,32 @@ Construido como un fork mejorado del proyecto X-UI original, 3X-UI añade un sop
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
Para instalar una versión específica, añade su etiqueta (p. ej. `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
```
Para instalar la versión **dev** continua (la última prelanzamiento por commit desde `main`, no una versión estable), pasa `dev-latest`:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
```
Durante la instalación se generan un nombre de usuario, una contraseña y una ruta de acceso aleatorios. Tras la instalación, ejecuta `x-ui` para abrir el menú de gestión, donde puedes iniciar/detener el servicio, ver o restablecer tus credenciales de acceso, gestionar certificados SSL y mucho más.
Para la documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
### Instalación desatendida
El instalador también se ejecuta de forma **no interactiva** para cloud-init.
Define `XUI_NONINTERACTIVE=1` (o canalízalo sin TTY) y realizará la instalación de principio a fin sin
ninguna pregunta, generando credenciales aleatorias y escribiéndolas en
`/etc/x-ui/install-result.env`. Consulta [`deploy/`](deploy/) para:
- [User-data de cloud-init](deploy/cloud-init/) — instalación desatendida en cualquier nube (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
- [Notas de Hetzner Cloud](deploy/marketplace/hetzner/) — despliegue basado en cloud-init en Hetzner
## Plataformas Compatibles
**Sistemas operativos:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine y Windows.
@@ -134,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
| `XUI_ENABLE_FAIL2BAN` | Habilitar la aplicación de límites de IP basada en Fail2ban | `true` |
| `XUI_LOG_LEVEL` | Nivel de registro (`debug`, `info`, `warning`, `error`) | `info` |
| `XUI_DEBUG` | Habilitar el modo de depuración | `false` |
| `XUI_TUNNEL_HEALTH_MONITOR` | Habilitar el monitor de salud del túnel (sondea una URL y reinicia xray tras fallos repetidos; un reinicio desconecta a todos los clientes) | `false` |
| `XUI_TUNNEL_HEALTH_PROXY` | Proxy a través del cual se envía el sondeo; apúntalo a una entrada local de xray para que el sondeo pruebe el túnel (p. ej. `socks5://127.0.0.1:1080`). Vacío significa que el sondeo solo comprueba la conectividad del host | — |
| `XUI_TUNNEL_HEALTH_URL` | URL sondeada para verificar la salud del túnel | `https://www.cloudflare.com/cdn-cgi/trace` |
| `XUI_TUNNEL_HEALTH_INTERVAL` | Intervalo entre sondeos | `30s` |
| `XUI_TUNNEL_HEALTH_TIMEOUT` | Tiempo de espera por sondeo | `10s` |
| `XUI_TUNNEL_HEALTH_FAILURES` | Fallos consecutivos antes de que se active un reinicio | `3` |
| `XUI_TUNNEL_HEALTH_COOLDOWN` | Retardo mínimo entre reinicios consecutivos | `5m` |
## Idiomas Compatibles

View File

@@ -33,7 +33,7 @@
- **آمار ترافیک** — به‌ازای هر اینباند، هر کلاینت و هر اوتباند، همراه با کنترل بازنشانی (reset).
- **پشتیبانی از چند نود** — مدیریت و مقیاس‌دهی روی چندین سرور از یک پنل واحد.
- **اوتباند و مسیریابی** — WARP، NordVPN، قوانین مسیریابی سفارشی، متعادل‌کننده‌های بار (load balancer) و زنجیره‌کردن پراکسی اوتباند.
- **سرور سابسکریپشن داخلی** با چندین فرمت خروجی.
- **سرور سابسکریپشن داخلی** با چندین فرمت خروجی و [قالب‌های صفحه‌ی سفارشی](docs/custom-subscription-templates.md).
- **ربات تلگرام** برای نظارت و مدیریت از راه دور.
- **RESTful API** همراه با مستندات Swagger درون‌پنل.
- **ذخیره‌سازی منعطف** — SQLite (پیش‌فرض) یا PostgreSQL.
@@ -73,10 +73,32 @@
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
برای نصب یک نسخه‌ی مشخص، تگ آن را در انتها اضافه کنید (مثلاً `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
```
برای نصب نسخه‌ی غلتانِ **dev** (آخرین پیش‌انتشار به‌ازای هر کامیت از شاخه‌ی `main`، نه یک انتشار پایدار)، مقدار `dev-latest` را پاس دهید:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
```
در حین نصب، یک نام کاربری، رمز عبور و مسیر دسترسی تصادفی تولید می‌شود. پس از نصب، دستور `x-ui` را اجرا کنید تا منوی مدیریت باز شود؛ در آنجا می‌توانید سرویس را شروع/متوقف کنید، اطلاعات ورود خود را ببینید یا بازنشانی کنید، گواهی‌های SSL را مدیریت کنید و کارهای دیگری انجام دهید.
برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید.
### نصب بدون نظارت
نصب‌کننده به‌صورت **غیرتعاملی** نیز برای cloud-init اجرا می‌شود.
`XUI_NONINTERACTIVE=1` را تنظیم کنید (یا بدون TTY از طریق pipe اجرا کنید) تا نصب به‌صورت سرتاسری و بدون
هیچ پرسشی انجام شود، اطلاعات ورود تصادفی تولید کرده و آن‌ها را در
`/etc/x-ui/install-result.env` می‌نویسد. برای موارد زیر به [`deploy/`](deploy/) مراجعه کنید:
- [user-data مربوط به Cloud-init](deploy/cloud-init/) — نصب بدون نظارت روی هر ابری (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
- [یادداشت‌های Hetzner Cloud](deploy/marketplace/hetzner/) — استقرار مبتنی بر cloud-init روی Hetzner
## پلتفرم‌های پشتیبانی‌شده
**سیستم‌عامل‌ها:** Ubuntu، Debian، Armbian، Fedora، CentOS، RHEL، AlmaLinux، Rocky Linux، Oracle Linux، Amazon Linux، Virtuozzo، Arch، Manjaro، Parch، openSUSE (Tumbleweed / Leap)، Alpine و Windows.
@@ -134,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
| `XUI_ENABLE_FAIL2BAN` | فعال‌سازی اعمال محدودیت IP مبتنی بر Fail2ban | `true` |
| `XUI_LOG_LEVEL` | سطح گزارش‌گیری (`debug`، `info`، `warning`، `error`) | `info` |
| `XUI_DEBUG` | فعال‌سازی حالت دیباگ | `false` |
| `XUI_TUNNEL_HEALTH_MONITOR` | فعال‌سازی پایشگر سلامت تونل (یک URL را پروب می‌کند و پس از خطاهای مکرر، xray را ری‌استارت می‌کند؛ یک ری‌استارت همه‌ی کلاینت‌ها را قطع می‌کند) | `false` |
| `XUI_TUNNEL_HEALTH_PROXY` | پراکسی‌ای که پروب از طریق آن ارسال می‌شود؛ آن را به یک اینباند محلی xray اشاره دهید تا پروب خودِ تونل را آزمایش کند (مثلاً `socks5://127.0.0.1:1080`). خالی بودن یعنی پروب فقط اتصال به هاست را بررسی می‌کند | — |
| `XUI_TUNNEL_HEALTH_URL` | URL ای که برای سلامت تونل پروب می‌شود | `https://www.cloudflare.com/cdn-cgi/trace` |
| `XUI_TUNNEL_HEALTH_INTERVAL` | فاصله‌ی زمانی بین پروب‌ها | `30s` |
| `XUI_TUNNEL_HEALTH_TIMEOUT` | مهلت زمانی هر پروب | `10s` |
| `XUI_TUNNEL_HEALTH_FAILURES` | تعداد خطاهای متوالی پیش از آن‌که یک ری‌استارت فعال شود | `3` |
| `XUI_TUNNEL_HEALTH_COOLDOWN` | حداقل تأخیر بین ری‌استارت‌های متوالی | `5m` |
## زبان‌های پشتیبانی‌شده

View File

@@ -73,21 +73,31 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
To install a specific version, append its tag (e.g. `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
```
To install the rolling **dev** build (latest per-commit pre-release from `main`, not a stable release), pass `dev-latest`:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
```
During installation a random username, password, and access path are generated. After installation, run `x-ui` to open the management menu, where you can start/stop the service, view or reset your login credentials, manage SSL certificates, and more.
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
### Unattended install & cloud images
### Unattended install
The installer also runs **non-interactively** for cloud-init and golden images.
The installer also runs **non-interactively** for cloud-init.
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/)
- [Hetzner Cloud notes](deploy/marketplace/hetzner/) — cloud-init deployment on Hetzner
## Supported Platforms
@@ -146,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
| `XUI_ENABLE_FAIL2BAN` | Enable Fail2ban-based IP-limit enforcement | `true` |
| `XUI_LOG_LEVEL` | Log verbosity (`debug`, `info`, `warning`, `error`) | `info` |
| `XUI_DEBUG` | Enable debug mode | `false` |
| `XUI_TUNNEL_HEALTH_MONITOR` | Enable the tunnel health monitor (probes a URL and restarts xray after repeated failures; a restart drops all clients) | `false` |
| `XUI_TUNNEL_HEALTH_PROXY` | Proxy the probe is sent through; point it at a local xray inbound so the probe tests the tunnel (e.g. `socks5://127.0.0.1:1080`). Empty means the probe only checks host connectivity | — |
| `XUI_TUNNEL_HEALTH_URL` | URL probed for tunnel health | `https://www.cloudflare.com/cdn-cgi/trace` |
| `XUI_TUNNEL_HEALTH_INTERVAL` | Interval between probes | `30s` |
| `XUI_TUNNEL_HEALTH_TIMEOUT` | Per-probe timeout | `10s` |
| `XUI_TUNNEL_HEALTH_FAILURES` | Consecutive failures before a restart is triggered | `3` |
| `XUI_TUNNEL_HEALTH_COOLDOWN` | Minimum delay between consecutive restarts | `5m` |
## Supported Languages

View File

@@ -33,7 +33,7 @@
- **Статистика трафика** — по каждому входящему, по каждому клиенту и по каждому исходящему, с возможностью сброса.
- **Поддержка нескольких узлов** — управление и масштабирование на несколько серверов из одной панели.
- **Исходящие подключения и маршрутизация** — WARP, NordVPN, пользовательские правила маршрутизации, балансировщики нагрузки и цепочки исходящих прокси.
- **Встроенный сервер подписок** с несколькими форматами вывода.
- **Встроенный сервер подписок** с несколькими форматами вывода и [пользовательскими шаблонами страниц](docs/custom-subscription-templates.md).
- **Telegram-бот** для удалённого мониторинга и управления.
- **RESTful API** с документацией Swagger внутри панели.
- **Гибкое хранилище** — SQLite (по умолчанию) или PostgreSQL.
@@ -73,10 +73,32 @@
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
Чтобы установить конкретную версию, добавьте её тег (например, `v3.4.0`):
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
```
Чтобы установить скользящую **dev**-сборку (новейший предварительный релиз по каждому коммиту из ветки `main`, а не стабильный релиз), передайте `dev-latest`:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
```
Во время установки генерируются случайные имя пользователя, пароль и путь доступа. После установки выполните `x-ui`, чтобы открыть меню управления, где можно запускать/останавливать сервис, просматривать или сбрасывать учётные данные для входа, управлять SSL-сертификатами и многое другое.
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
### Автоматическая установка
Установщик также работает в **неинтерактивном** режиме для cloud-init.
Задайте `XUI_NONINTERACTIVE=1` (или передайте по конвейеру без TTY), и установка пройдёт от начала до конца
без единого запроса: будут сгенерированы случайные учётные данные и записаны в
`/etc/x-ui/install-result.env`. Смотрите [`deploy/`](deploy/) для:
- [Cloud-init user-data](deploy/cloud-init/) — автоматическая установка в любом облаке (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
- [Заметки по Hetzner Cloud](deploy/marketplace/hetzner/) — развёртывание на Hetzner на базе cloud-init
## Поддерживаемые платформы
**Операционные системы:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine и Windows.
@@ -134,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
| `XUI_ENABLE_FAIL2BAN` | Включить применение лимитов IP на основе Fail2ban | `true` |
| `XUI_LOG_LEVEL` | Уровень логирования (`debug`, `info`, `warning`, `error`) | `info` |
| `XUI_DEBUG` | Включить режим отладки | `false` |
| `XUI_TUNNEL_HEALTH_MONITOR` | Включить монитор состояния туннеля (опрашивает URL и перезапускает xray после многократных сбоев; перезапуск отключает всех клиентов) | `false` |
| `XUI_TUNNEL_HEALTH_PROXY` | Прокси, через который отправляется проба; укажите локальный входящий xray, чтобы проба проверяла туннель (например, `socks5://127.0.0.1:1080`). Пустое значение означает, что проба проверяет только связь с хостом | — |
| `XUI_TUNNEL_HEALTH_URL` | URL, опрашиваемый для проверки состояния туннеля | `https://www.cloudflare.com/cdn-cgi/trace` |
| `XUI_TUNNEL_HEALTH_INTERVAL` | Интервал между пробами | `30s` |
| `XUI_TUNNEL_HEALTH_TIMEOUT` | Таймаут на одну пробу | `10s` |
| `XUI_TUNNEL_HEALTH_FAILURES` | Число последовательных сбоев до запуска перезапуска | `3` |
| `XUI_TUNNEL_HEALTH_COOLDOWN` | Минимальная задержка между последовательными перезапусками | `5m` |
## Поддерживаемые языки

View File

@@ -33,7 +33,7 @@ Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa e
- **Trafik istatistikleri** — Gelen bağlantı (Inbound), istemci ve giden bağlantı (Outbound) bazında istatistikler ve sıfırlama kontrolleri.
- **Çoklu düğüm (Multi-node) desteği** — Tek bir panel üzerinden birden fazla sunucuyu yönetin ve ölçeklendirin.
- **Giden bağlantı (Outbound) ve yönlendirme** — WARP, NordVPN, özel yönlendirme kuralları, yük dengeleyiciler (load balancers) ve giden bağlantı proxy zincirleme (proxy chaining).
- **Dahili abonelik sunucusu** (Birden fazla çıktı formatı ile).
- **Dahili abonelik sunucusu** (Birden fazla çıktı formatı ve [özel sayfa şablonları](docs/custom-subscription-templates.md) ile).
- Uzaktan izleme ve yönetim için **Telegram botu**.
- Panel içi Swagger dokümantasyonuna sahip **RESTful API**.
- **Esnek depolama** — SQLite (varsayılan) veya PostgreSQL.
@@ -73,10 +73,32 @@ Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa e
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
Belirli bir sürümü kurmak için, etiketini (ör. `v3.4.0`) ekleyin:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
```
Sürekli güncellenen **dev** sürümünü (kararlı bir sürüm değil; `main` dalından her commit'te oluşturulan en son ön sürüm) kurmak için `dev-latest` değerini geçirin:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
```
Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu oluşturulur. Kurulumdan sonra, hizmeti başlatabileceğiniz/durdurabileceğiniz, giriş bilgilerinizi görüntüleyebileceğiniz veya sıfırlayabileceğiniz, SSL sertifikalarını yönetebileceğiniz ve çok daha fazlasını yapabileceğiniz yönetim menüsünü açmak için terminalde `x-ui` komutunu çalıştırın.
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
### Etkileşimsiz kurulum
Yükleyici, cloud-init için **etkileşimsiz** olarak da çalışır.
`XUI_NONINTERACTIVE=1` ayarlayın (veya TTY olmadan boru hattına aktarın); kurulum baştan
sona hiçbir soru sormadan tamamlanır, rastgele kimlik bilgileri oluşturup bunları
`/etc/x-ui/install-result.env` dosyasına yazar. Şunlar için [`deploy/`](deploy/) klasörüne bakın:
- [Cloud-init user-data](deploy/cloud-init/) — herhangi bir bulutta etkileşimsiz kurulum (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
- [Hetzner Cloud notları](deploy/marketplace/hetzner/) — Hetzner üzerinde cloud-init tabanlı dağıtım
## Desteklenen Platformlar
**İşletim sistemleri:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine ve Windows.
@@ -134,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP limit uygulamasını etkinleştir | `true` |
| `XUI_LOG_LEVEL` | Günlük (Log) ayrıntı seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
| `XUI_DEBUG` | Hata ayıklama (debug) modunu etkinleştir | `false` |
| `XUI_TUNNEL_HEALTH_MONITOR` | Tünel sağlık izleyicisini etkinleştir (bir URL'yi yoklar ve tekrarlanan başarısızlıklardan sonra xray'i yeniden başlatır; yeniden başlatma tüm istemcilerin bağlantısını düşürür) | `false` |
| `XUI_TUNNEL_HEALTH_PROXY` | Yoklamanın gönderildiği proxy; yoklamanın tüneli test etmesi için bunu yerel bir xray gelen bağlantısına yönlendirin (ör. `socks5://127.0.0.1:1080`). Boş bırakılırsa yoklama yalnızca ana makine bağlantısını kontrol eder | — |
| `XUI_TUNNEL_HEALTH_URL` | Tünel sağlığı için yoklanan URL | `https://www.cloudflare.com/cdn-cgi/trace` |
| `XUI_TUNNEL_HEALTH_INTERVAL` | Yoklamalar arasındaki aralık | `30s` |
| `XUI_TUNNEL_HEALTH_TIMEOUT` | Yoklama başına zaman aşımı | `10s` |
| `XUI_TUNNEL_HEALTH_FAILURES` | Yeniden başlatma tetiklenmeden önceki ardışık başarısızlık sayısı | `3` |
| `XUI_TUNNEL_HEALTH_COOLDOWN` | Ardışık yeniden başlatmalar arasındaki minimum gecikme | `5m` |
## Desteklenen Diller

View File

@@ -33,7 +33,7 @@
- **流量统计** — 按入站、按客户端、按出站统计,并支持重置控制。
- **多节点支持** — 从单一面板管理并扩展到多台服务器。
- **出站与路由** — WARP、NordVPN、自定义路由规则、负载均衡器和出站代理链。
- **内置订阅服务器**,支持多种输出格式。
- **内置订阅服务器**,支持多种输出格式和[自定义页面模板](docs/custom-subscription-templates.md)
- **Telegram 机器人**,用于远程监控和管理。
- **RESTful API**,带有面板内置的 Swagger 文档。
- **灵活的存储** — SQLite默认或 PostgreSQL。
@@ -73,10 +73,32 @@
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
若要安装特定版本,请在命令后附加对应的标签(例如 `v3.4.0`
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
```
若要安装滚动更新的 **dev** 版本(来自 `main` 的最新逐次提交预发布版本,而非稳定版本),请传入 `dev-latest`
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
```
安装过程中会生成随机的用户名、密码和访问路径。安装完成后,运行 `x-ui` 打开管理菜单,您可以在其中启动/停止服务、查看或重置登录凭据、管理 SSL 证书等。
完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。
### 无人值守安装
安装程序也可以**非交互式**运行,适用于 cloud-init。
设置 `XUI_NONINTERACTIVE=1`(或在无 TTY 的情况下通过管道传入),它就会全程
零提示地完成端到端安装,生成随机凭据并写入
`/etc/x-ui/install-result.env`。请参阅 [`deploy/`](deploy/)
- [Cloud-init user-data](deploy/cloud-init/) — 在任意云平台上无人值守安装Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle
- [Hetzner Cloud 说明](deploy/marketplace/hetzner/) — 在 Hetzner 上基于 cloud-init 的部署
## 支持的平台
**操作系统:** Ubuntu、Debian、Armbian、Fedora、CentOS、RHEL、AlmaLinux、Rocky Linux、Oracle Linux、Amazon Linux、Virtuozzo、Arch、Manjaro、Parch、openSUSE (Tumbleweed / Leap)、Alpine 和 Windows。
@@ -134,6 +156,13 @@ docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
| `XUI_ENABLE_FAIL2BAN` | 启用基于 Fail2ban 的 IP 限制 | `true` |
| `XUI_LOG_LEVEL` | 日志级别(`debug``info``warning``error` | `info` |
| `XUI_DEBUG` | 启用调试模式 | `false` |
| `XUI_TUNNEL_HEALTH_MONITOR` | 启用隧道健康监控(探测某个 URL在连续多次失败后重启 xray重启会断开所有客户端 | `false` |
| `XUI_TUNNEL_HEALTH_PROXY` | 探测请求所经过的代理;将其指向本地 xray 入站,使探测能够测试隧道(例如 `socks5://127.0.0.1:1080`)。留空表示探测仅检查主机连通性 | — |
| `XUI_TUNNEL_HEALTH_URL` | 用于检测隧道健康状况的探测 URL | `https://www.cloudflare.com/cdn-cgi/trace` |
| `XUI_TUNNEL_HEALTH_INTERVAL` | 两次探测之间的间隔 | `30s` |
| `XUI_TUNNEL_HEALTH_TIMEOUT` | 单次探测的超时时间 | `10s` |
| `XUI_TUNNEL_HEALTH_FAILURES` | 触发重启前的连续失败次数 | `3` |
| `XUI_TUNNEL_HEALTH_COOLDOWN` | 两次连续重启之间的最小间隔 | `5m` |
## 支持的语言

View File

@@ -1,27 +1,20 @@
# Cloud deployment & golden images
# Cloud deployment (unattended install)
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**.
Tooling to ship the 3x-ui panel via unattended install, with **per-instance
credentials generated on first boot** (never `admin/admin`, never a shared
session secret). Works on 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 |
| [`test/`](test/) | Container smoke test | Verifying the install path |
## Two models
## How it works
- **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).
`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).
## Unattended install knobs

View File

@@ -1,12 +1,8 @@
# 3x-ui via cloud-init (generic, no golden image)
# 3x-ui via cloud-init
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.
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**. It works on any cloud-init platform.
## How it works
@@ -53,7 +49,6 @@ Edit the `export XUI_*` lines inside the `write_files` block of
`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.

View File

@@ -1,22 +0,0 @@
[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

View File

@@ -1,166 +0,0 @@
#!/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

View File

@@ -1,94 +0,0 @@
# 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`.

View File

@@ -1,192 +0,0 @@
#!/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 "================================================================"

View File

@@ -1,51 +0,0 @@
#!/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

View File

@@ -1,59 +0,0 @@
#!/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."

View File

@@ -1,92 +0,0 @@
# 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>

View File

@@ -1,9 +1,10 @@
# 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.
Ship 3x-ui via **cloud-init**: each instance installs non-interactively and
generates unique per-instance credentials (no `admin/admin`, no shared secret).
## Option A — cloud-init (recommended, no image build)
## cloud-init (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.
@@ -27,28 +28,6 @@ After boot, fetch the generated credentials:
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

View File

@@ -1,7 +0,0 @@
# Packer build artifacts (never commit images or manifests)
output-qemu/
*.qcow2
*.raw
packer-manifest.json
packer_cache/
crash.log

View File

@@ -1,116 +0,0 @@
# 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.

View File

@@ -1,59 +0,0 @@
#!/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."

View File

@@ -1,39 +0,0 @@
#!/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."

View File

@@ -1,76 +0,0 @@
#!/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."

View File

@@ -1,109 +0,0 @@
// 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"
}

View File

@@ -1,160 +0,0 @@
// 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
}
}

View File

@@ -1,86 +0,0 @@
#!/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 =="

View File

@@ -5,8 +5,8 @@ services:
dockerfile: ./Dockerfile
container_name: 3xui_app
# hostname: yourhostname <- optional
# Optional hard memory cap. When set, the panel auto-derives its Go soft
# limit (GOMEMLIMIT, ~90%) from this so it GCs before the OOM killer fires.
# Optional hard memory cap. When set, the panel derives its Go soft limit
# (GOMEMLIMIT, ~90% of this cap) so it GCs before the OOM killer fires.
# mem_limit: 512m
# The bundled Fail2ban (XUI_ENABLE_FAIL2BAN below) enforces the IP limit
# with iptables, which needs NET_ADMIN. Without these caps a ban is logged
@@ -21,8 +21,12 @@ services:
environment:
XRAY_VMESS_AEAD_FORCED: "false"
XUI_ENABLE_FAIL2BAN: "true"
# Go memory soft limit. If neither is set, the panel auto-detects the
# cgroup/host limit and targets ~90%. Pin it explicitly with one of:
# Memory tuning. The panel keeps RAM low via GOGC + periodic release; it no
# longer sets a soft limit from total host RAM (no benefit, risks GC thrash).
# XUI_GOGC: "75" # lower = less RAM, slightly more CPU; GOGC env overrides
# XUI_MEMORY_RELEASE_INTERVAL: "10" # minutes between FreeOSMemory; 0 disables
# Go memory soft limit, only applied from an explicit budget below (or a
# real cgroup/mem_limit cap). Pin it with one of:
# XUI_MEMORY_LIMIT: "400" # in MiB
# GOMEMLIMIT: "400MiB" # Go syntax, takes precedence
# XUI_PPROF: "true" # expose pprof on 127.0.0.1:6060 for profiling

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "3x-ui-frontend",
"private": true,
"version": "0.4.0",
"version": "0.4.1",
"type": "module",
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
"engines": {
@@ -30,10 +30,10 @@
"axios": "^1.18.1",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"i18next": "^26.3.1",
"i18next": "^26.3.2",
"otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5",
"qs": "^6.15.2",
"qs": "^6.15.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.8",

View File

@@ -232,6 +232,14 @@
"description": "Hide server settings in happ subscription (Only for Happ)",
"type": "boolean"
},
"subIncyEnableRouting": {
"description": "Enable routing injection for the Incy client",
"type": "boolean"
},
"subIncyRoutingRules": {
"description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
"type": "string"
},
"subJsonEnable": {
"description": "Enable JSON subscription endpoint",
"type": "boolean"
@@ -457,6 +465,8 @@
"subEnableRouting",
"subEncrypt",
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
@@ -727,6 +737,14 @@
"description": "Hide server settings in happ subscription (Only for Happ)",
"type": "boolean"
},
"subIncyEnableRouting": {
"description": "Enable routing injection for the Incy client",
"type": "boolean"
},
"subIncyRoutingRules": {
"description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
"type": "string"
},
"subJsonEnable": {
"description": "Enable JSON subscription endpoint",
"type": "boolean"
@@ -959,6 +977,8 @@
"subEnableRouting",
"subEncrypt",
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
@@ -4220,6 +4240,46 @@
}
}
},
"/panel/api/server/setUpdateChannel": {
"post": {
"tags": [
"Server"
],
"summary": "Toggle the panel update channel between stable and the rolling per-commit dev release. Only effective on dev builds.",
"operationId": "post_panel_api_server_setUpdateChannel",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/server/updateGeofile": {
"post": {
"tags": [
@@ -5456,7 +5516,7 @@
"tags": [
"Clients"
],
"summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. Returns the adjusted count and per-email skip reasons.",
"summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
"operationId": "post_panel_api_clients_bulkAdjust",
"requestBody": {
"required": true,
@@ -5471,7 +5531,8 @@
"bob"
],
"addDays": 30,
"addBytes": 53687091200
"addBytes": 53687091200,
"flow": "xtls-rprx-vision"
}
}
}
@@ -5511,6 +5572,122 @@
}
}
},
"/panel/api/clients/bulkEnable": {
"post": {
"tags": [
"Clients"
],
"summary": "Enable many clients in one call. Emails are grouped by inbound and applied with a single read-modify-write per inbound; the running Xray (local or remote node) is updated to add each user. Note that enabling a client whose quota is exhausted or whose expiry has passed only flips the flag — the traffic loop will disable it again on the next tick. Returns the changed count and per-email skip reasons.",
"operationId": "post_panel_api_clients_bulkEnable",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"emails": [
"alice",
"bob"
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"obj": {
"changed": 2,
"skipped": [
{
"email": "carol",
"reason": "client not found"
}
]
}
}
}
}
}
}
}
},
"/panel/api/clients/bulkDisable": {
"post": {
"tags": [
"Clients"
],
"summary": "Disable many clients in one call. Emails are grouped by inbound and applied with a single read-modify-write per inbound; the running Xray (local or remote node) is updated to remove each user. Returns the changed count and per-email skip reasons.",
"operationId": "post_panel_api_clients_bulkDisable",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
},
"example": {
"emails": [
"alice",
"bob"
]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"obj": {
"changed": 2,
"skipped": [
{
"email": "carol",
"reason": "client not found"
}
]
}
}
}
}
}
}
}
},
"/panel/api/clients/bulkDel": {
"post": {
"tags": [
@@ -7379,7 +7556,7 @@
"tags": [
"Nodes"
],
"summary": "Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Returns a per-node result list.",
"summary": "Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Set \"dev\": true to move the nodes to the rolling per-commit dev channel instead of the latest stable release. Returns a per-node result list.",
"operationId": "post_panel_api_nodes_updatePanel",
"requestBody": {
"required": true,
@@ -7393,7 +7570,8 @@
1,
2,
3
]
],
"dev": false
}
}
}

View File

@@ -59,8 +59,8 @@ export function useNodeMutations() {
});
const updatePanelsMut = useMutation({
mutationFn: (ids: number[]) =>
HttpUtil.post<NodeUpdateResult[]>('/panel/api/nodes/updatePanel', { ids }, {
mutationFn: ({ ids, dev }: { ids: number[]; dev: boolean }) =>
HttpUtil.post<NodeUpdateResult[]>('/panel/api/nodes/updatePanel', { ids, dev }, {
headers: { 'Content-Type': 'application/json' },
}),
onSuccess: (msg) => { if (msg?.success) invalidate(); },
@@ -72,7 +72,7 @@ export function useNodeMutations() {
remove: (id: number) => removeMut.mutateAsync(id),
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }),
probe: (id: number) => probeMut.mutateAsync(id),
updatePanels: (ids: number[]): Promise<Msg<NodeUpdateResult[]>> => updatePanelsMut.mutateAsync(ids),
updatePanels: (ids: number[], dev: boolean): Promise<Msg<NodeUpdateResult[]>> => updatePanelsMut.mutateAsync({ ids, dev }),
testConnection: async (payload: Partial<NodeRecord>): Promise<Msg<ProbeResult>> => {
const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
return parseMsg(raw, ProbeResultSchema, 'nodes/test');

View File

@@ -52,6 +52,8 @@ export const EXAMPLES: Record<string, unknown> = {
"subEnableRouting": false,
"subEncrypt": false,
"subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
@@ -152,6 +154,8 @@ export const EXAMPLES: Record<string, unknown> = {
"subEnableRouting": false,
"subEncrypt": false,
"subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",

View File

@@ -206,6 +206,14 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Hide server settings in happ subscription (Only for Happ)",
"type": "boolean"
},
"subIncyEnableRouting": {
"description": "Enable routing injection for the Incy client",
"type": "boolean"
},
"subIncyRoutingRules": {
"description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
"type": "string"
},
"subJsonEnable": {
"description": "Enable JSON subscription endpoint",
"type": "boolean"
@@ -431,6 +439,8 @@ export const SCHEMAS: Record<string, unknown> = {
"subEnableRouting",
"subEncrypt",
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",
@@ -701,6 +711,14 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Hide server settings in happ subscription (Only for Happ)",
"type": "boolean"
},
"subIncyEnableRouting": {
"description": "Enable routing injection for the Incy client",
"type": "boolean"
},
"subIncyRoutingRules": {
"description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
"type": "string"
},
"subJsonEnable": {
"description": "Enable JSON subscription endpoint",
"type": "boolean"
@@ -933,6 +951,8 @@ export const SCHEMAS: Record<string, unknown> = {
"subEnableRouting",
"subEncrypt",
"subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonEnable",
"subJsonFinalMask",
"subJsonMux",

View File

@@ -58,6 +58,8 @@ export interface AllSetting {
subEnableRouting: boolean;
subEncrypt: boolean;
subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonEnable: boolean;
subJsonFinalMask: string;
subJsonMux: string;
@@ -159,6 +161,8 @@ export interface AllSettingView {
subEnableRouting: boolean;
subEncrypt: boolean;
subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonEnable: boolean;
subJsonFinalMask: string;
subJsonMux: string;

View File

@@ -70,6 +70,8 @@ export const AllSettingSchema = z.object({
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
@@ -172,6 +174,8 @@ export const AllSettingViewSchema = z.object({
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),

View File

@@ -14,6 +14,7 @@ import {
BulkAttachResultSchema,
BulkCreateResultSchema,
BulkDeleteResultSchema,
BulkSetEnableResultSchema,
BulkDetachResultSchema,
DelDepletedResultSchema,
type ClientHydrate,
@@ -27,6 +28,7 @@ import {
type BulkAttachResult,
type BulkCreateResult,
type BulkDeleteResult,
type BulkSetEnableResult,
type BulkDetachResult,
} from '@/schemas/client';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
@@ -341,22 +343,31 @@ export function useClients() {
});
const bulkAdjustMut = useMutation({
mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise<Msg<BulkAdjustResult>> => {
mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number; flow: string }): Promise<Msg<BulkAdjustResult>> => {
const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const bulkSetEnableMut = useMutation({
mutationFn: async (payload: { emails: string[]; enable: boolean }): Promise<Msg<BulkSetEnableResult>> => {
const path = payload.enable ? '/panel/api/clients/bulkEnable' : '/panel/api/clients/bulkDisable';
const raw = await HttpUtil.post(path, { emails: payload.emails }, JSON_HEADERS);
return parseMsg(raw, BulkSetEnableResultSchema, payload.enable ? 'clients/bulkEnable' : 'clients/bulkDisable');
},
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
const attachMut = useMutation({
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS),
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
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),
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, { ...JSON_HEADERS, silentSuccess: true }),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
@@ -370,7 +381,7 @@ export function useClients() {
const detachMut = useMutation({
mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS),
HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
});
@@ -435,10 +446,18 @@ export function useClients() {
if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
return bulkCreateMut.mutateAsync(payloads);
}, [bulkCreateMut]);
const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number, flow = '') => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
}, [bulkAdjustMut]);
const bulkEnable = useCallback((emails: string[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
return bulkSetEnableMut.mutateAsync({ emails, enable: true });
}, [bulkSetEnableMut]);
const bulkDisable = useCallback((emails: string[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkSetEnableResult>);
return bulkSetEnableMut.mutateAsync({ emails, enable: false });
}, [bulkSetEnableMut]);
const bulkAddToGroup = useCallback((emails: string[], group: string) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAddToGroupMut.mutateAsync({ emails, group });
@@ -590,6 +609,8 @@ export function useClients() {
remove,
bulkDelete,
bulkAdjust,
bulkEnable,
bulkDisable,
bulkAddToGroup,
bulkRemoveFromGroup,
attach,

View File

@@ -8,6 +8,7 @@ const TITLE_KEYS: Record<string, string> = {
'/clients': 'menu.clients',
'/groups': 'menu.groups',
'/nodes': 'menu.nodes',
'/hosts': 'menu.hosts',
'/settings': 'menu.settings',
'/xray': 'menu.xray',
'/outbound': 'menu.outbounds',

View File

@@ -33,6 +33,7 @@ import {
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { useAllSettings } from '@/api/queries/useAllSettings';
import './AppSidebar.css';
@@ -84,7 +85,7 @@ function DonateButton({ ariaLabel }: { ariaLabel: string }) {
function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
if (!version) return null;
const label = `v${version}`;
const label = formatPanelVersion(version);
return (
<a
href={REPO_URL}

View File

@@ -14,6 +14,18 @@ function parseVersionParts(version: string): [number, number, number] | null {
return [out[0], out[1], out[2]];
}
// Format a panel version for display. Dev builds report a "dev+<commit>"
// identity (see config.GetPanelVersion); show those — and any other
// non-numeric label — verbatim. Semantic versions get a single normalized "v"
// prefix, so a raw "v3.4.0" tag and a bare "3.4.0" both render as "v3.4.0"
// instead of doubling up to "vv3.4.0".
export function formatPanelVersion(version: string | undefined | null): string {
const v = (version || '').trim();
if (!v) return '';
const normalized = v.replace(/^v/i, '');
return /^\d/.test(normalized) ? `v${normalized}` : v;
}
export function isPanelUpdateAvailable(latest: string, current: string): boolean {
if (!latest || !current) return false;
const a = parseVersionParts(latest);

View File

@@ -3,7 +3,7 @@
// per client. This file is the single frontend source of truth for the picker
// UI and the live preview — keep the token list in sync with remark_vars.go.
export type RemarkVarGroup = 'client' | 'traffic' | 'time';
export type RemarkVarGroup = 'client' | 'traffic' | 'time' | 'connection';
export interface RemarkVar {
/** Bare token name, e.g. "TRAFFIC_LEFT" (rendered as {{TRAFFIC_LEFT}}). */
@@ -13,7 +13,7 @@ export interface RemarkVar {
sample: string;
}
export const REMARK_VAR_GROUPS: RemarkVarGroup[] = ['client', 'traffic', 'time'];
export const REMARK_VAR_GROUPS: RemarkVarGroup[] = ['client', 'traffic', 'time', 'connection'];
export const REMARK_VARIABLES: RemarkVar[] = [
// Client identity
@@ -36,11 +36,19 @@ export const REMARK_VARIABLES: RemarkVar[] = [
{ token: 'DOWN', group: 'traffic', sample: '3.20GB' },
// Time / status
{ token: 'STATUS', group: 'time', sample: 'active' },
{ token: 'STATUS_EMOJI', group: 'time', sample: '✅' },
{ token: 'DAYS_LEFT', group: 'time', sample: '12' },
{ token: 'TIME_LEFT', group: 'time', sample: '12d 4h 30m' },
{ token: 'USAGE_PERCENTAGE', group: 'time', sample: '52.3%' },
{ token: 'EXPIRE_DATE', group: 'time', sample: '2026-09-01' },
{ token: 'JALALI_EXPIRE_DATE', group: 'time', sample: '1405/06/10' },
{ token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },
{ token: 'CREATED_UNIX', group: 'time', sample: '1700000000' },
{ token: 'RESET_DAYS', group: 'time', sample: '30' },
// Connection (inbound config descriptors)
{ token: 'PROTOCOL', group: 'connection', sample: 'VLESS' },
{ token: 'TRANSPORT', group: 'connection', sample: 'ws' },
{ token: 'SECURITY', group: 'connection', sample: 'TLS' },
];
const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(

View File

@@ -8,6 +8,7 @@ import type {
DnsRuleForm,
FreedomFinalRuleForm,
FreedomOutboundFormSettings,
HttpOutboundFormSettings,
HysteriaOutboundFormSettings,
LoopbackOutboundFormSettings,
MuxForm,
@@ -178,6 +179,26 @@ function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettin
};
}
function stringRecordFromWire(raw: unknown): Record<string, string> {
const obj = asObject(raw);
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(obj)) {
if (typeof v === 'string') out[k] = v;
}
return out;
}
// HTTP outbound reuses the SOCKS server/user shape but also carries xray's
// top-level `settings.headers` (HTTPClientConfig.Headers), the CONNECT
// headers sent to the upstream proxy. xray ignores per-server `headers`,
// so only the settings-level map round-trips (issue #5519).
function httpFromWire(raw: Raw): HttpOutboundFormSettings {
return {
...simpleAuthFromWire(raw, 8080),
headers: stringRecordFromWire(raw.headers),
};
}
function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
const secretKey = asString(raw.secretKey);
const pubKey = secretKey.length > 0
@@ -395,7 +416,7 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
case 'trojan': typed = { protocol: 'trojan', settings: trojanFromWire(settings) }; break;
case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
case 'socks': typed = { protocol: 'socks', settings: simpleAuthFromWire(settings, 1080) }; break;
case 'http': typed = { protocol: 'http', settings: simpleAuthFromWire(settings, 8080) }; break;
case 'http': typed = { protocol: 'http', settings: httpFromWire(settings) }; break;
case 'wireguard': typed = { protocol: 'wireguard', settings: wireguardFromWire(settings) }; break;
case 'hysteria': typed = { protocol: 'hysteria', settings: hysteriaFromWire(settings) }; break;
case 'freedom': typed = { protocol: 'freedom', settings: freedomFromWire(settings) }; break;
@@ -489,6 +510,14 @@ function simpleAuthToWire(s: SimpleAuthFormSettings) {
};
}
function httpToWire(s: HttpOutboundFormSettings): Raw {
const wire: Raw = simpleAuthToWire(s);
if (s.headers && Object.keys(s.headers).length > 0) {
wire.headers = s.headers;
}
return wire;
}
function wireguardToWire(s: WireguardOutboundFormSettings) {
return {
mtu: s.mtu || undefined,
@@ -629,7 +658,7 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
case 'trojan': settings = trojanToWire(values.settings); break;
case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
case 'socks': settings = simpleAuthToWire(values.settings); break;
case 'http': settings = simpleAuthToWire(values.settings); break;
case 'http': settings = httpToWire(values.settings); break;
case 'wireguard': settings = wireguardToWire(values.settings); break;
case 'hysteria': settings = hysteriaToWire(values.settings); break;
case 'freedom': settings = freedomToWire(values.settings); break;

View File

@@ -13,7 +13,7 @@ export class AllSetting {
pageSize = 25;
expireDiff = 0;
trafficDiff = 0;
remarkTemplate = '{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
tgBotEnable = false;
tgBotToken = '';
@@ -35,6 +35,8 @@ export class AllSetting {
subAnnounce = '';
subEnableRouting = false;
subRoutingRules = '';
subIncyEnableRouting = false;
subIncyRoutingRules = '';
subListen = '';
subPort = 2096;
subPath = '/sub/';

View File

@@ -400,6 +400,15 @@ export const sections: readonly Section[] = [
path: '/panel/api/server/updatePanel',
summary: 'Self-update the panel to the latest version. The server restarts on success.',
},
{
method: 'POST',
path: '/panel/api/server/setUpdateChannel',
summary: 'Toggle the panel update channel between stable and the rolling per-commit dev release. Only effective on dev builds.',
params: [
{ name: 'dev', in: 'body (form)', type: 'boolean', desc: 'true = dev channel, false = stable.' },
],
body: 'dev=true',
},
{
method: 'POST',
path: '/panel/api/server/updateGeofile',
@@ -635,10 +644,24 @@ export const sections: readonly Section[] = [
{
method: 'POST',
path: '/panel/api/clients/bulkAdjust',
summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. Returns the adjusted count and per-email skip reasons.',
body: '{\n "emails": ["alice", "bob"],\n "addDays": 30,\n "addBytes": 53687091200\n}',
summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). Returns the adjusted count and per-email skip reasons.',
body: '{\n "emails": ["alice", "bob"],\n "addDays": 30,\n "addBytes": 53687091200,\n "flow": "xtls-rprx-vision"\n}',
response: '{\n "success": true,\n "obj": {\n "adjusted": 2,\n "skipped": [\n { "email": "carol", "reason": "unlimited expiry" }\n ]\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/bulkEnable',
summary: 'Enable many clients in one call. Emails are grouped by inbound and applied with a single read-modify-write per inbound; the running Xray (local or remote node) is updated to add each user. Note that enabling a client whose quota is exhausted or whose expiry has passed only flips the flag — the traffic loop will disable it again on the next tick. Returns the changed count and per-email skip reasons.',
body: '{\n "emails": ["alice", "bob"]\n}',
response: '{\n "success": true,\n "obj": {\n "changed": 2,\n "skipped": [\n { "email": "carol", "reason": "client not found" }\n ]\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/bulkDisable',
summary: 'Disable many clients in one call. Emails are grouped by inbound and applied with a single read-modify-write per inbound; the running Xray (local or remote node) is updated to remove each user. Returns the changed count and per-email skip reasons.',
body: '{\n "emails": ["alice", "bob"]\n}',
response: '{\n "success": true,\n "obj": {\n "changed": 2,\n "skipped": [\n { "email": "carol", "reason": "client not found" }\n ]\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/bulkDel',
@@ -936,8 +959,8 @@ export const sections: readonly Section[] = [
{
method: 'POST',
path: '/panel/api/nodes/updatePanel',
summary: 'Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Returns a per-node result list.',
body: '{\n "ids": [1, 2, 3]\n}',
summary: 'Trigger the official panel self-updater on each given node (downloads the latest release and restarts). Only enabled, online nodes are updated; offline/disabled ones are reported as skipped. Set "dev": true to move the nodes to the rolling per-commit dev channel instead of the latest stable release. Returns a per-node result list.',
body: '{\n "ids": [1, 2, 3],\n "dev": false\n}',
response: '{\n "success": true,\n "obj": [\n { "id": 1, "name": "de-1", "ok": true },\n { "id": 2, "name": "fr-1", "ok": false, "error": "node is offline" }\n ]\n}',
},
{

View File

@@ -1,16 +1,19 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Form, InputNumber, Modal, message } from 'antd';
import { Alert, Form, InputNumber, Modal, Select, message } from 'antd';
import { ClientBulkAdjustFormSchema } from '@/schemas/client';
import { TLS_FLOW_CONTROL } from '@/schemas/primitives/flow';
const GB = 1024 * 1024 * 1024;
const FLOW_CLEAR = 'none';
interface ClientBulkAdjustModalProps {
open: boolean;
count: number;
onOpenChange: (open: boolean) => void;
onSubmit: (addDays: number, addBytes: number) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
onSubmit: (addDays: number, addBytes: number, flow: string) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
}
export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) {
@@ -18,12 +21,14 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
const [messageApi, messageContextHolder] = message.useMessage();
const [addDays, setAddDays] = useState<number>(0);
const [addGB, setAddGB] = useState<number>(0);
const [flow, setFlow] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
setAddDays(0);
setAddGB(0);
setFlow('');
}
}, [open]);
@@ -31,16 +36,17 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
const validated = ClientBulkAdjustFormSchema.safeParse({
addDays: Math.trunc(Number(addDays) || 0),
addGB: Number(addGB) || 0,
flow,
});
if (!validated.success) {
messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
return;
}
const { addDays: days, addGB: gb } = validated.data;
const { addDays: days, addGB: gb, flow: flowValue } = validated.data;
setSubmitting(true);
try {
const bytes = Math.trunc(gb * GB);
const result = await onSubmit(days, bytes);
const result = await onSubmit(days, bytes, flowValue);
if (!result) return;
const ok = result.adjusted ?? 0;
const skipped = result.skipped?.length ?? 0;
@@ -95,6 +101,18 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
step={1}
/>
</Form.Item>
<Form.Item label={t('pages.clients.bulkFlow')}>
<Select
value={flow}
onChange={setFlow}
style={{ width: '100%' }}
options={[
{ value: '', label: t('pages.clients.bulkFlowNoChange') },
{ value: FLOW_CLEAR, label: t('pages.clients.bulkFlowDisable') },
...Object.values(TLS_FLOW_CONTROL).map((k) => ({ value: k, label: k })),
]}
/>
</Form.Item>
</Form>
</Modal>
</>

View File

@@ -357,7 +357,7 @@ export default function ClientInfoModal({
const parts = parseLinkParts(link);
const fallback = `${t('pages.clients.link')} ${idx + 1}`;
const rowTitle = (parts && linkMetaText(parts)) || fallback;
const qrRemark = [parts?.remark, client.email].filter(Boolean).join('-') || rowTitle;
const qrRemark = parts?.remark || rowTitle;
const canQr = !isPostQuantumLink(link);
return (
<div key={idx} className="link-row">

View File

@@ -106,7 +106,7 @@ export default function ClientQrModal({
children: (
<QrPanel
value={link}
remark={`${client?.email || ''} #${idx + 1}`}
remark={parts?.remark || `${client?.email || ''} #${idx + 1}`}
showQr={!isPostQuantumLink(link)}
/>
),

View File

@@ -27,6 +27,7 @@ import {
} from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import {
CheckCircleOutlined,
ClockCircleOutlined,
DeleteOutlined,
DisconnectOutlined,
@@ -42,6 +43,7 @@ import {
RetweetOutlined,
SearchOutlined,
SortAscendingOutlined,
StopOutlined,
TagsOutlined,
TeamOutlined,
UploadOutlined,
@@ -204,7 +206,7 @@ export default function ClientsPage() {
setQuery,
inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
tgBotEnable, expireDiff, trafficDiff, pageSize,
create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
applyTrafficEvent, applyClientStatsEvent,
refresh,
@@ -641,6 +643,35 @@ export default function ClientsPage() {
});
}
function onBulkSetEnable(enable: boolean) {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
modal.confirm({
title: t(enable ? 'pages.clients.bulkEnableConfirmTitle' : 'pages.clients.bulkDisableConfirmTitle', { count: emails.length }),
content: t(enable ? 'pages.clients.bulkEnableConfirmContent' : 'pages.clients.bulkDisableConfirmContent'),
okText: t('confirm'),
okType: enable ? 'primary' : 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = enable ? await bulkEnable(emails) : await bulkDisable(emails);
setSelectedRowKeys([]);
const changed = msg?.obj?.changed ?? 0;
const skipped = msg?.obj?.skipped ?? [];
const failed = skipped.length;
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
const okKey = enable ? 'pages.clients.toasts.bulkEnabled' : 'pages.clients.toasts.bulkDisabled';
const mixedKey = enable ? 'pages.clients.toasts.bulkEnabledMixed' : 'pages.clients.toasts.bulkDisabledMixed';
if (failed === 0 && msg?.success) {
messageApi.success(t(okKey, { count: changed }));
} else {
messageApi.warning(firstError
? `${t(mixedKey, { ok: changed, failed })} — ${firstError}`
: t(mixedKey, { ok: changed, failed }));
}
},
});
}
function onBulkDelete() {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
@@ -685,16 +716,18 @@ export default function ClientsPage() {
}
const updateMsg = await update(meta.email, payload);
if (!updateMsg?.success) return updateMsg;
const rawEmail = (payload as { email?: unknown }).email;
const emailKey = typeof rawEmail === 'string' && rawEmail.trim() ? rawEmail.trim() : meta.email;
if (Array.isArray(meta.attach) && meta.attach.length > 0) {
const r = await attach(meta.email, meta.attach);
const r = await attach(emailKey, meta.attach);
if (!r?.success) return r;
}
if (Array.isArray(meta.detach) && meta.detach.length > 0) {
const r = await detach(meta.email, meta.detach);
const r = await detach(emailKey, 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);
const r = await setExternalLinks(emailKey, meta.externalLinks);
if (!r?.success) return r;
return updateMsg;
}, [create, update, attach, detach, setExternalLinks]);
@@ -1010,28 +1043,14 @@ export default function ClientsPage() {
{!isMobile && t('pages.clients.addClients')}
</Button>
) : (
<>
<Tag
color="blue"
closable
onClose={() => setSelectedRowKeys([])}
style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
>
{t('pages.clients.selectedCount', { count: selectedRowKeys.length })}
</Tag>
<Button icon={<UsergroupAddOutlined />} onClick={() => setBulkAttachOpen(true)}>
{!isMobile && t('pages.clients.attach')}
</Button>
<Button danger icon={<UsergroupDeleteOutlined />} onClick={() => setBulkDetachOpen(true)}>
{!isMobile && t('pages.clients.detach')}
</Button>
<Button icon={<TagsOutlined />} onClick={() => setBulkGroupOpen(true)}>
{!isMobile && t('pages.clients.addToGroup')}
</Button>
<Button danger icon={<UngroupIcon />} onClick={onBulkUngroup}>
{!isMobile && t('pages.clients.ungroup')}
</Button>
</>
<Tag
color="blue"
closable
onClose={() => setSelectedRowKeys([])}
style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
>
{t('pages.clients.selectedCount', { count: selectedRowKeys.length })}
</Tag>
)}
<Dropdown
trigger={['click']}
@@ -1039,6 +1058,46 @@ export default function ClientsPage() {
menu={{
items: selectedRowKeys.length > 0
? [
{
key: 'attach',
icon: <UsergroupAddOutlined />,
label: t('pages.clients.attach'),
onClick: () => setBulkAttachOpen(true),
},
{
key: 'detach',
icon: <UsergroupDeleteOutlined />,
label: t('pages.clients.detach'),
danger: true,
onClick: () => setBulkDetachOpen(true),
},
{
key: 'addToGroup',
icon: <TagsOutlined />,
label: t('pages.clients.addToGroup'),
onClick: () => setBulkGroupOpen(true),
},
{
key: 'ungroup',
icon: <UngroupIcon />,
label: t('pages.clients.ungroup'),
danger: true,
onClick: onBulkUngroup,
},
{ type: 'divider' as const },
{
key: 'enable',
icon: <CheckCircleOutlined />,
label: t('pages.clients.enable'),
onClick: () => onBulkSetEnable(true),
},
{
key: 'disable',
icon: <StopOutlined />,
label: t('pages.clients.disable'),
danger: true,
onClick: () => onBulkSetEnable(false),
},
{
key: 'adjust',
icon: <ClockCircleOutlined />,
@@ -1418,8 +1477,8 @@ export default function ClientsPage() {
open={bulkAdjustOpen}
count={selectedRowKeys.length}
onOpenChange={setBulkAdjustOpen}
onSubmit={async (addDays, addBytes) => {
const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes);
onSubmit={async (addDays, addBytes, flow) => {
const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes, flow);
if (msg?.success) {
setSelectedRowKeys([]);
return msg.obj ?? { adjusted: 0 };

View File

@@ -285,8 +285,12 @@ export default function InboundFormModal({
) => {
if (block?.id === authId) return true;
const label = (block?.label || '').toLowerCase().replace(/[-_\s]/g, '');
if (authId === 'mlkem768') return label.includes('mlkem768');
if (authId === 'x25519') return label.includes('x25519');
if (authId === 'mlkem768') return label.includes('mlkem768') && !label.includes('xorpub') && !label.includes('random');
if (authId === 'x25519') return label.includes('x25519') && !label.includes('xorpub') && !label.includes('random');
if (authId === 'mlkem768_xorpub') return label.includes('mlkem768') && label.includes('xorpub');
if (authId === 'mlkem768_random') return label.includes('mlkem768') && label.includes('random');
if (authId === 'x25519_xorpub') return label.includes('x25519') && label.includes('xorpub');
if (authId === 'x25519_random') return label.includes('x25519') && label.includes('random');
return false;
};
@@ -319,7 +323,19 @@ export default function InboundFormModal({
const parts = enc.split('.').filter(Boolean);
const authKey = parts[parts.length - 1] || '';
if (!authKey) return t('pages.inbounds.vlessAuthCustom');
return authKey.length > 300
const mode = parts[1] || 'native';
const keyType = authKey.length > 300 ? 'mlkem768' : 'x25519';
if (mode === 'xorpub') {
return keyType === 'mlkem768'
? t('pages.inbounds.vlessAuthMlkem768Xorpub')
: t('pages.inbounds.vlessAuthX25519Xorpub');
}
if (mode === 'random') {
return keyType === 'mlkem768'
? t('pages.inbounds.vlessAuthMlkem768Random')
: t('pages.inbounds.vlessAuthX25519Random');
}
return keyType === 'mlkem768'
? t('pages.inbounds.vlessAuthMlkem768')
: t('pages.inbounds.vlessAuthX25519');
})();

View File

@@ -1,12 +1,21 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Space, Typography } from 'antd';
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
type VlessAuthKind =
| 'x25519'
| 'x25519_xorpub'
| 'x25519_random'
| 'mlkem768'
| 'mlkem768_xorpub'
| 'mlkem768_random';
interface VlessFieldsProps {
saving: boolean;
selectedVlessAuth: string;
network: string;
security: string;
getNewVlessEnc: (kind: 'x25519' | 'mlkem768') => void;
getNewVlessEnc: (kind: VlessAuthKind) => void;
clearVlessEnc: () => void;
}
@@ -19,6 +28,17 @@ export default function VlessFields({
clearVlessEnc,
}: VlessFieldsProps) {
const { t } = useTranslation();
const [authKind, setAuthKind] = useState<VlessAuthKind>('x25519');
const authOptions = [
{ value: 'x25519', label: t('pages.inbounds.vlessAuthX25519') },
{ value: 'x25519_xorpub', label: t('pages.inbounds.vlessAuthX25519Xorpub') },
{ value: 'x25519_random', label: t('pages.inbounds.vlessAuthX25519Random') },
{ value: 'mlkem768', label: t('pages.inbounds.vlessAuthMlkem768') },
{ value: 'mlkem768_xorpub', label: t('pages.inbounds.vlessAuthMlkem768Xorpub') },
{ value: 'mlkem768_random', label: t('pages.inbounds.vlessAuthMlkem768Random') },
];
return (
<>
<Form.Item name={['settings', 'decryption']} label={t('pages.inbounds.decryption')}>
@@ -27,13 +47,16 @@ export default function VlessFields({
<Form.Item name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
<Input />
</Form.Item>
<Form.Item label=" ">
<Form.Item label={t('pages.inbounds.vlessAuthGenerate')}>
<Space size={8} wrap>
<Button type="primary" loading={saving} onClick={() => getNewVlessEnc('x25519')}>
{t('pages.inbounds.vlessAuthX25519')}
</Button>
<Button type="primary" loading={saving} onClick={() => getNewVlessEnc('mlkem768')}>
{t('pages.inbounds.vlessAuthMlkem768')}
<Select
value={authKind}
onChange={(v) => setAuthKind(v)}
options={authOptions}
style={{ width: 240 }}
/>
<Button type="primary" loading={saving} onClick={() => getNewVlessEnc(authKind)}>
{t('pages.inbounds.vlessAuthGenerateButton')}
</Button>
<Button danger onClick={clearVlessEnc}>{t('clear')}</Button>
</Space>

View File

@@ -37,6 +37,7 @@ import {
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import { useTheme } from '@/hooks/useTheme';
import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -65,6 +66,8 @@ export default function IndexPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const [accessLogEnable, setAccessLogEnable] = useState(false);
const [isDevBuild, setIsDevBuild] = useState(false);
const [devChannelEnable, setDevChannelEnable] = useState(false);
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
currentVersion: '',
latestVersion: '',
@@ -87,8 +90,14 @@ export default function IndexPage() {
const [loadingTip, setLoadingTip] = useState(t('loading'));
useEffect(() => {
HttpUtil.post<{ accessLogEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
if (msg?.success && msg.obj) setAccessLogEnable(!!msg.obj.accessLogEnable);
HttpUtil.post<{ accessLogEnable?: boolean; isDevBuild?: boolean; devChannelEnable?: boolean }>(
'/panel/api/setting/defaultSettings',
).then((msg) => {
if (msg?.success && msg.obj) {
setAccessLogEnable(!!msg.obj.accessLogEnable);
setIsDevBuild(!!msg.obj.isDevBuild);
setDevChannelEnable(!!msg.obj.devChannelEnable);
}
});
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
@@ -96,7 +105,7 @@ export default function IndexPage() {
}, []);
const displayVersion = useMemo(
() => panelUpdateInfo.currentVersion || window.X_UI_CUR_VER || '?',
() => window.X_UI_CUR_VER || panelUpdateInfo.currentVersion || '?',
[panelUpdateInfo.currentVersion],
);
@@ -119,13 +128,21 @@ export default function IndexPage() {
}, [refresh]);
function openPanelVersion() {
if (panelUpdateInfo.updateAvailable) {
if (panelUpdateInfo.updateAvailable || isDevBuild) {
setPanelUpdateOpen(true);
} else {
window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer');
}
}
async function handleChannelChange(dev: boolean) {
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
if (!res?.success) return;
setDevChannelEnable(dev);
const msg = await HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo');
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
}
function openTelegram() {
window.open('https://t.me/XrayUI', '_blank', 'noopener,noreferrer');
}
@@ -224,8 +241,8 @@ export default function IndexPage() {
{isMobile && displayVersion && (
<Tag color={panelUpdateInfo.updateAvailable ? 'orange' : 'green'}>
{panelUpdateInfo.updateAvailable
? `v${panelUpdateInfo.latestVersion}`
: `v${displayVersion}`}
? formatPanelVersion(panelUpdateInfo.latestVersion)
: formatPanelVersion(displayVersion)}
</Tag>
)}
</Space>
@@ -254,8 +271,8 @@ export default function IndexPage() {
{!isMobile && (
<span>
{panelUpdateInfo.updateAvailable
? `${t('update')} ${panelUpdateInfo.latestVersion}`
: `v${displayVersion}`}
? `${t('update')} ${formatPanelVersion(panelUpdateInfo.latestVersion)}`
: formatPanelVersion(displayVersion)}
</span>
)}
</Space>,
@@ -446,6 +463,9 @@ export default function IndexPage() {
<PanelUpdateModal
open={panelUpdateOpen}
info={panelUpdateInfo}
isDevBuild={isDevBuild}
devChannelEnable={devChannelEnable}
onChannelChange={handleChannelChange}
onClose={() => setPanelUpdateOpen(false)}
onBusy={setBusy}
/>

View File

@@ -13,12 +13,15 @@ interface LogModalProps {
onClose: () => void;
}
const AUTO_UPDATE_INTERVAL = 5000;
export default function LogModal({ open, onClose }: LogModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [rows, setRows] = useState('20');
const [level, setLevel] = useState('info');
const [syslog, setSyslog] = useState(false);
const [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const openRef = useRef(open);
@@ -39,6 +42,11 @@ export default function LogModal({ open, onClose }: LogModalProps) {
}
}, [rows, level, syslog]);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
useEffect(() => {
openRef.current = open;
if (open) refresh();
@@ -48,6 +56,12 @@ export default function LogModal({ open, onClose }: LogModalProps) {
if (openRef.current) refresh();
}, [rows, level, syslog, refresh]);
useEffect(() => {
if (!open || !autoUpdate) return;
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
return () => clearInterval(id);
}, [open, autoUpdate]);
const parsedLogs = useMemo(() => logs.map(parseLogLine), [logs]);
function download() {
@@ -80,11 +94,11 @@ export default function LogModal({ open, onClose }: LogModalProps) {
style={{ width: 70 }}
onChange={setRows}
options={[
{ value: '10', label: '10' },
{ value: '20', label: '20' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
{ value: '500', label: '500' },
{ value: '1000', label: '1000' },
]}
/>
<Select
@@ -106,6 +120,9 @@ export default function LogModal({ open, onClose }: LogModalProps) {
<Checkbox checked={syslog} onChange={(e) => setSyslog(e.target.checked)}>
SysLog
</Checkbox>
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
{t('pages.index.autoUpdate')}
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button type="primary" onClick={download} icon={<DownloadOutlined />} />
@@ -147,7 +164,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
{log.levelText && <span className={`log-level ${log.levelClass}`}>{log.levelText}</span>}
{(log.body || log.service) && (
<>
<span> - </span>
{(log.stamp || log.levelText) && <span> - </span>}
{log.service && <b>{log.service}</b>}
{log.service && log.body ? ' ' : ''}
<span>{log.body}</span>

View File

@@ -1,14 +1,19 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Modal, Tag } from 'antd';
import { Alert, Button, Modal, Switch, Tag } from 'antd';
import { CloudDownloadOutlined } from '@ant-design/icons';
import axios from 'axios';
import { HttpUtil, PromiseUtil } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import './PanelUpdateModal.css';
export interface PanelUpdateInfo {
channel?: string;
currentVersion: string;
latestVersion: string;
currentCommit?: string;
latestCommit?: string;
updateAvailable: boolean;
}
@@ -20,13 +25,27 @@ interface BusyEvent {
interface PanelUpdateModalProps {
open: boolean;
info: PanelUpdateInfo;
isDevBuild?: boolean;
devChannelEnable?: boolean;
onChannelChange?: (dev: boolean) => void | Promise<void>;
onClose: () => void;
onBusy: (e: BusyEvent) => void;
}
export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelUpdateModalProps) {
export default function PanelUpdateModal({
open,
info,
isDevBuild,
devChannelEnable,
onChannelChange,
onClose,
onBusy,
}: PanelUpdateModalProps) {
const { t } = useTranslation();
const [modal, contextHolder] = Modal.useModal();
const [channelBusy, setChannelBusy] = useState(false);
const isDev = info.channel === 'dev';
async function pollUntilBack(): Promise<boolean> {
await PromiseUtil.sleep(5000);
@@ -43,6 +62,16 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU
return false;
}
async function handleChannel(checked: boolean) {
if (!onChannelChange) return;
setChannelBusy(true);
try {
await onChannelChange(checked);
} finally {
setChannelBusy(false);
}
}
function updatePanel() {
modal.confirm({
title: t('pages.index.panelUpdateDialog'),
@@ -84,15 +113,41 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU
/>
)}
{isDevBuild && (
<div className="version-list">
<div className="version-list-item">
<span>{t('pages.index.devChannel')}</span>
<Switch
checked={!!devChannelEnable}
loading={channelBusy}
onChange={handleChannel}
/>
</div>
</div>
)}
{devChannelEnable && (
<Alert
type="info"
className="mb-12"
title={t('pages.index.devChannelWarning')}
showIcon
/>
)}
<div className="version-list">
<div className="version-list-item">
<span>{t('pages.index.currentPanelVersion')}</span>
<Tag color="green">v{info.currentVersion || '?'}</Tag>
<span>{isDev ? t('pages.index.currentCommit') : t('pages.index.currentPanelVersion')}</span>
{isDev ? (
<Tag color="green">{info.currentCommit || '?'}</Tag>
) : (
<Tag color="green">{formatPanelVersion(window.X_UI_CUR_VER || info.currentVersion) || '?'}</Tag>
)}
</div>
{info.updateAvailable ? (
<div className="version-list-item">
<span>{t('pages.index.latestPanelVersion')}</span>
<Tag color="purple">{info.latestVersion || '-'}</Tag>
<span>{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}</span>
<Tag color="purple">{(isDev ? info.latestCommit : info.latestVersion) || '-'}</Tag>
</div>
) : (
<div className="version-list-item">

View File

@@ -137,10 +137,13 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
const tss: number[] = [];
for (const p of msg.obj) {
const d = new Date(p.t * 1000);
const MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
labs.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
const lab = bucket >= 2880 ? `${MM}-${DD} ${hh}:${mm}` : bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`;
labs.push(lab);
vals.push(Number(p.v) || 0);
tss.push(Number(p.t) || 0);
}
@@ -208,14 +211,13 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
onChange={setBucket}
options={[
{ value: 2, label: '2m' },
{ value: 30, label: '30m' },
{ value: 60, label: '1h' },
{ value: 120, label: '2h' },
{ value: 180, label: '3h' },
{ value: 300, label: '5h' },
{ value: 360, label: '6h' },
{ value: 720, label: '12h' },
{ value: 1440, label: '24h' },
{ value: 2880, label: '48h' },
{ value: 2880, label: '2d' },
{ value: 10080, label: '7d' },
]}
/>
</div>

View File

@@ -44,6 +44,8 @@ function shortTime(value?: string | number): string {
return `${hh}:${mm}:${ss}`;
}
const AUTO_UPDATE_INTERVAL = 5000;
export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
@@ -53,6 +55,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
const [showDirect, setShowDirect] = useState(true);
const [showBlocked, setShowBlocked] = useState(true);
const [showProxy, setShowProxy] = useState(true);
const [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<XrayLogEntry[]>([]);
const openRef = useRef(open);
@@ -75,6 +78,11 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
}
}, [rows, filter, showDirect, showBlocked, showProxy]);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
useEffect(() => {
openRef.current = open;
if (open) refresh();
@@ -84,6 +92,12 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
if (openRef.current) refresh();
}, [rows, showDirect, showBlocked, showProxy, refresh]);
useEffect(() => {
if (!open || !autoUpdate) return;
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
return () => clearInterval(id);
}, [open, autoUpdate]);
function fullDate(value?: string | number): string {
return IntlUtil.formatDate(value, datepicker);
}
@@ -117,7 +131,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
onCancel={onClose}
title={
<>
{t('pages.index.logs')}
{t('pages.index.accessLogs')}
<SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
</>
}
@@ -130,11 +144,11 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
style={{ width: 70 }}
onChange={setRows}
options={[
{ value: '10', label: '10' },
{ value: '20', label: '20' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
{ value: '500', label: '500' },
{ value: '1000', label: '1000' },
]}
/>
</Form.Item>
@@ -158,6 +172,9 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
<Checkbox checked={showProxy} onChange={(e) => setShowProxy(e.target.checked)}>
Proxy
</Checkbox>
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
{t('pages.index.autoUpdate')}
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button type="primary" onClick={download} icon={<DownloadOutlined />} />

View File

@@ -286,11 +286,10 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
onChange={setBucket}
options={[
{ value: 2, label: '2m' },
{ value: 30, label: '30m' },
{ value: 60, label: '1h' },
{ value: 120, label: '2h' },
{ value: 180, label: '3h' },
{ value: 300, label: '5h' },
{ value: 360, label: '6h' },
{ value: 720, label: '12h' },
]}
/>
</div>

View File

@@ -92,7 +92,7 @@ export default function XrayStatusCard({
? [
<Space className="action" key="xraylogs" onClick={onOpenXrayLogs}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.logs')}</span>}
{!isMobile && <span>{t('pages.index.accessLogs')}</span>}
</Space>,
]
: []),

View File

@@ -41,6 +41,11 @@ const SYSLOG_PREFIX = /^([A-Za-z]{3}\s+\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+\S+\s+\S
const GO_LOG_DATE = /^\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/;
// telego's own line prefix: "[Mon Jan _2 15:04:05 MST 2006] LEVEL rest".
const TELEGO = /^\[[^\]]+\]\s+([A-Z]+)\s+(.*)$/;
// App-log format emitted by the in-memory buffer:
// "2006/01/02 15:04:05 LEVEL - message". Only a line matching this exact shape
// carries a structured timestamp/level; anything else (e.g. a plain notice such
// as the Windows "Syslog is not supported" message) is kept whole as the body.
const APP_LOG = /^(\d{4}\/\d{2}\/\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(\S+)\s+-\s+([\s\S]*)$/;
// splitLevelDash pulls a leading "LEVEL - " off a message, returning the level
// and the remainder. Returns null when the message does not start with a level.
@@ -84,16 +89,17 @@ export function parseLogLine(line: string): ParsedLog {
}
}
} else {
// App-log format: "2006/01/02 15:04:05 LEVEL - body"
const [head, ...rest] = raw.split(' - ');
const message = rest.join(' - ');
const parts = head.split(' ');
if (parts.length >= 3) {
[date, time, levelText] = parts;
const app = raw.match(APP_LOG);
if (app) {
// App-log format: "2006/01/02 15:04:05 LEVEL - body"
date = app[1];
time = app[2];
levelText = app[3];
body = app[4];
} else {
levelText = head;
// Plain message with no timestamp/level — show it verbatim.
body = raw;
}
body = message || '';
}
const li = LEVELS.indexOf(levelText);

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Button, Card, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
import { Alert, Button, Card, Checkbox, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
@@ -21,6 +21,33 @@ import { setMessageInstance } from '@/utils/messageBus';
import { HttpUtil } from '@/utils';
import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
// Confirm-dialog body that lets the operator pick the stable or dev channel for
// a node panel update. Reports changes via onChange so the imperative
// modal.confirm onOk can read the latest choice through a ref.
function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void }) {
const { t } = useTranslation();
const [dev, setDev] = useState(false);
return (
<div>
<p>{t('pages.nodes.updateConfirmContent')}</p>
<Checkbox
checked={dev}
onChange={(e) => { setDev(e.target.checked); onChange(e.target.checked); }}
>
{t('pages.nodes.updateDevChannel')}
</Checkbox>
{dev && (
<Alert
type="info"
showIcon
style={{ marginTop: 8 }}
message={t('pages.index.devChannelWarning')}
/>
)}
</div>
);
}
export default function NodesPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
@@ -136,8 +163,10 @@ export default function NodesPage() {
await setEnable(node.id, next);
}, [setEnable]);
const runUpdate = useCallback(async (ids: number[]) => {
const msg = await updatePanels(ids);
const devRef = useRef(false);
const runUpdate = useCallback(async (ids: number[], dev: boolean) => {
const msg = await updatePanels(ids, dev);
if (!msg?.success) {
messageApi.error(msg?.msg || t('somethingWentWrong'));
return;
@@ -156,12 +185,13 @@ export default function NodesPage() {
}, [updatePanels, messageApi, t]);
const onUpdateNode = useCallback((node: NodeRecord) => {
devRef.current = false;
modal.confirm({
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
content: t('pages.nodes.updateConfirmContent'),
content: <UpdateChannelChoice onChange={(v) => { devRef.current = v; }} />,
okText: t('update'),
cancelText: t('cancel'),
onOk: () => runUpdate([node.id]),
onOk: () => runUpdate([node.id], devRef.current),
});
}, [modal, t, runUpdate]);
@@ -173,12 +203,13 @@ export default function NodesPage() {
messageApi.warning(t('pages.nodes.toasts.updateNoneEligible'));
return;
}
devRef.current = false;
modal.confirm({
title: t('pages.nodes.updateConfirmTitle', { count: eligible.length }),
content: t('pages.nodes.updateConfirmContent'),
content: <UpdateChannelChoice onChange={(v) => { devRef.current = v; }} />,
okText: t('update'),
cancelText: t('cancel'),
onOk: () => runUpdate(eligible),
onOk: () => runUpdate(eligible, devRef.current),
});
}, [modal, t, nodes, selectedIds, runUpdate, messageApi]);

View File

@@ -1,5 +1,5 @@
import { Input, InputNumber, Switch, Tabs } from 'antd';
import { BranchesOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
@@ -178,6 +178,21 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
</>
),
},
{
key: '7',
label: catTabLabel(<CompassOutlined />, 'Incy', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subIncyEnableRouting')} description={t('pages.settings.subIncyEnableRoutingDesc')}>
<Switch checked={allSetting.subIncyEnableRouting} onChange={(v) => updateSetting({ subIncyEnableRouting: v })} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subIncyRoutingRules')} description={t('pages.settings.subIncyRoutingRulesDesc')}>
<Input.TextArea value={allSetting.subIncyRoutingRules} placeholder="incy://routing/onadd/..."
onChange={(e) => updateSetting({ subIncyRoutingRules: e.target.value })} />
</SettingListItem>
</>
),
},
]} />
);
}

View File

@@ -139,6 +139,7 @@ export default function SubPage() {
);
const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []);
const happUrl = useMemo(() => `happ://add/${subUrl}`, []);
const incyUrl = useMemo(() => `incy://add/${subUrl}`, []);
const pageClass = useMemo(() => {
const classes = ['subscription-page'];
@@ -200,6 +201,7 @@ export default function SubPage() {
{ key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
{ key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
{ key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) },
{ key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) },
], [copy, open]);
const iosMenuItems = useMemo(() => [
@@ -209,7 +211,8 @@ export default function SubPage() {
{ key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
{ key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
{ key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl]);
{ key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) },
], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl]);
const langMenuItems = useMemo(
() => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({
@@ -418,7 +421,7 @@ export default function SubPage() {
const parts = parseLinkParts(link);
const fallback = `Link ${idx + 1}`;
const rowTitle = parts?.remark || fallback;
const qrLabel = [parts?.remark, linkEmails[idx]].filter(Boolean).join('-') || rowTitle;
const qrLabel = parts?.remark || rowTitle;
const canQr = !isPostQuantumLink(link);
return (
<div key={link} className="sub-link-row">

View File

@@ -221,6 +221,7 @@ export default function XrayPage() {
testingAll={testingAll}
inboundTags={inboundTags}
subscriptionOutbounds={subscriptionOutbounds}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
onResetTraffic={resetOutboundsTraffic}
onTest={onTestOutbound}

View File

@@ -83,6 +83,7 @@ interface OutboundFormModalProps {
open: boolean;
outbound: Record<string, unknown> | null;
existingTags: string[];
dialerProxyTags?: string[];
onClose: () => void;
onConfirm: (outbound: Record<string, unknown>) => void;
}
@@ -92,6 +93,7 @@ export default function OutboundFormModal({
open,
outbound: outboundProp,
existingTags,
dialerProxyTags,
onClose,
onConfirm,
}: OutboundFormModalProps) {
@@ -514,7 +516,7 @@ export default function OutboundFormModal({
{security === 'reality' && realityAllowed && <RealityForm />}
{((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
<SockoptForm form={form} outboundTags={existingTags} />
<SockoptForm form={form} outboundTags={dialerProxyTags ?? existingTags} />
)}
<FinalMaskForm

View File

@@ -75,6 +75,7 @@ interface OutboundsTabProps {
testingAll: boolean;
inboundTags: string[];
subscriptionOutbounds?: unknown[];
subscriptionOutboundTags?: string[];
isMobile: boolean;
onResetTraffic: (tag: string) => void;
onTest: (index: number, mode: string) => void;
@@ -94,6 +95,7 @@ export default function OutboundsTab({
testingAll,
inboundTags: _inboundTags,
subscriptionOutbounds,
subscriptionOutboundTags,
isMobile,
onResetTraffic,
onTest,
@@ -140,6 +142,19 @@ export default function OutboundsTab({
const rows = useMemo(() => outbounds.map((o, i) => ({ ...o, key: i })), [outbounds]);
const dialerProxyTags = useMemo(() => {
const tags = new Set<string>();
(templateSettings?.outbounds || []).forEach((o, i) => {
if (i === editingIndex) return;
if (o?.protocol === 'blackhole') return;
if (o?.tag) tags.add(o.tag);
});
for (const tag of subscriptionOutboundTags || []) {
if (tag) tags.add(tag);
}
return [...tags];
}, [templateSettings?.outbounds, editingIndex, subscriptionOutboundTags]);
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
@@ -521,6 +536,7 @@ export default function OutboundsTab({
open={modalOpen}
outbound={editingOutbound}
existingTags={existingTags}
dialerProxyTags={dialerProxyTags}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>

View File

@@ -1,6 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Form, Input } from 'antd';
import { HeaderMapEditor } from '@/components/form';
export default function HttpFields() {
const { t } = useTranslation();
return (
@@ -11,6 +13,9 @@ export default function HttpFields() {
<Form.Item label={t('password')} name={['settings', 'pass']}>
<Input />
</Form.Item>
<Form.Item label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
<HeaderMapEditor mode="v1" />
</Form.Item>
</>
);
}

View File

@@ -101,6 +101,13 @@ export const BulkDeleteResultSchema = z.object({
.optional(),
});
export const BulkSetEnableResultSchema = z.object({
changed: z.number(),
skipped: z
.array(z.object({ email: z.string(), reason: z.string() }))
.optional(),
});
export const BulkCreateResultSchema = z.object({
created: z.number(),
skipped: z
@@ -188,8 +195,9 @@ export const ClientBulkAdjustFormSchema = z
.object({
addDays: z.number().int(),
addGB: z.number(),
flow: z.string().optional().default(''),
})
.refine((v) => v.addDays !== 0 || v.addGB !== 0, {
.refine((v) => v.addDays !== 0 || v.addGB !== 0 || v.flow !== '', {
message: 'pages.clients.bulkAdjustNothing',
});
@@ -220,6 +228,7 @@ export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;
export type BulkAdjustResult = z.infer<typeof BulkAdjustResultSchema>;
export type BulkDeleteResult = z.infer<typeof BulkDeleteResultSchema>;
export type BulkSetEnableResult = z.infer<typeof BulkSetEnableResultSchema>;
export type BulkCreateResult = z.infer<typeof BulkCreateResultSchema>;
export type BulkAttachResult = z.infer<typeof BulkAttachResultSchema>;
export type BulkDetachResult = z.infer<typeof BulkDetachResultSchema>;

View File

@@ -80,6 +80,7 @@ export const HttpOutboundFormSettingsSchema = z.object({
port: PortSchema.default(8080),
user: z.string().default(''),
pass: z.string().default(''),
headers: z.record(z.string(), z.string()).default({}),
});
export type HttpOutboundFormSettings = z.infer<typeof HttpOutboundFormSettingsSchema>;

View File

@@ -11,7 +11,12 @@ export type TunnelNetwork = z.infer<typeof TunnelNetworkSchema>;
// with arr=false.
export const TunnelInboundSettingsSchema = z.object({
rewriteAddress: z.string().optional(),
rewritePort: PortSchema.optional(),
// AntD InputNumber writes null when cleared; accept it and collapse to
// undefined so the field is omitted from the payload instead of crashing
// validation with "Invalid input" (issue #5516). The trailing .optional()
// keeps the key optional in the inferred type (a bare .transform() would
// make it required).
rewritePort: PortSchema.nullable().transform((v) => v ?? undefined).optional(),
portMap: z.record(z.string(), z.string()).default({}),
allowedNetwork: TunnelNetworkSchema.default('tcp,udp'),
followRedirect: z.boolean().default(false),

View File

@@ -21,5 +21,6 @@ export type HttpOutboundServer = z.infer<typeof HttpOutboundServerSchema>;
export const HttpOutboundSettingsSchema = z.object({
servers: z.array(HttpOutboundServerSchema).min(1),
headers: z.record(z.string(), z.string()).optional(),
});
export type HttpOutboundSettings = z.infer<typeof HttpOutboundSettingsSchema>;

View File

@@ -39,6 +39,8 @@ export const AllSettingSchema = z.object({
subAnnounce: z.string().optional(),
subEnableRouting: z.boolean().optional(),
subRoutingRules: z.string().optional(),
subIncyEnableRouting: z.boolean().optional(),
subIncyRoutingRules: z.string().optional(),
subListen: z.string().optional(),
subPort: port.optional(),
subPath: absolutePath.optional(),

View File

@@ -175,6 +175,29 @@ describe('outbound-form-adapter: round-trip', () => {
});
});
it('http preserves top-level settings.headers across wire → form → wire (#5519)', () => {
const headers = { 'X-T5-Auth': '683556433', Host: '153.3.236.22:443' };
const form = rawOutboundToFormValues({
protocol: 'http',
tag: 'h',
settings: { servers: [{ address: 'a', port: 443, users: [] }], headers },
});
expect(form.protocol).toBe('http');
if (form.protocol === 'http') {
expect(form.settings.headers).toEqual(headers);
}
const back = formValuesToWirePayload(form);
expect(back.settings).toMatchObject({ headers });
});
it('http omits headers when empty', () => {
const back = formValuesToWirePayload(rawOutboundToFormValues({
protocol: 'http',
settings: { servers: [{ address: 'a', port: 8080, users: [] }] },
}));
expect(back.settings).not.toHaveProperty('headers');
});
it('wireguard csv-joins address and reserved on read, splits on write', () => {
const wire = {
protocol: 'wireguard',

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { isPanelUpdateAvailable } from '@/lib/panel-version';
import { formatPanelVersion, isPanelUpdateAvailable } from '@/lib/panel-version';
// Parity with web/service/panel.go isNewerVersion.
describe('isPanelUpdateAvailable', () => {
@@ -31,3 +31,26 @@ describe('isPanelUpdateAvailable', () => {
expect(isPanelUpdateAvailable('nightly-1', 'nightly-1')).toBe(false);
});
});
describe('formatPanelVersion', () => {
it('adds a single v prefix to bare semantic versions', () => {
expect(formatPanelVersion('3.4.0')).toBe('v3.4.0');
expect(formatPanelVersion('2.6.5')).toBe('v2.6.5');
});
it('does not double up the v on already-prefixed tags', () => {
expect(formatPanelVersion('v3.4.0')).toBe('v3.4.0');
expect(formatPanelVersion('V3.4.0')).toBe('v3.4.0');
});
it('shows dev builds verbatim without a v prefix', () => {
expect(formatPanelVersion('dev+1a2b3c4d')).toBe('dev+1a2b3c4d');
expect(formatPanelVersion('dev')).toBe('dev');
});
it('returns empty for blank input and leaves unknown markers untouched', () => {
expect(formatPanelVersion('')).toBe('');
expect(formatPanelVersion(undefined)).toBe('');
expect(formatPanelVersion('?')).toBe('?');
});
});

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { TunnelInboundSettingsSchema } from '@/schemas/protocols/inbound/tunnel';
// Regression for issue #5516: AntD InputNumber writes null when the Rewrite
// port field is cleared, which used to crash validation with "Invalid input".
describe('TunnelInboundSettingsSchema rewritePort', () => {
it('accepts null (cleared field) and omits the port', () => {
const parsed = TunnelInboundSettingsSchema.parse({ rewritePort: null });
expect(parsed.rewritePort).toBeUndefined();
});
it('accepts a missing field', () => {
const parsed = TunnelInboundSettingsSchema.parse({});
expect(parsed.rewritePort).toBeUndefined();
});
it('preserves a valid port', () => {
const parsed = TunnelInboundSettingsSchema.parse({ rewritePort: 8443 });
expect(parsed.rewritePort).toBe(8443);
});
it('still rejects out-of-range ports', () => {
expect(() => TunnelInboundSettingsSchema.parse({ rewritePort: 70000 })).toThrow();
});
});

View File

@@ -19,6 +19,7 @@ export class Msg<T = unknown> {
export interface HttpOptions extends AxiosRequestConfig {
silent?: boolean;
silentSuccess?: boolean;
}
export interface HttpModal {
@@ -27,20 +28,24 @@ export interface HttpModal {
}
export class HttpUtil {
static _handleMsg(msg: unknown): void {
static _handleMsg(msg: unknown, silentSuccess = false): void {
if (!(msg instanceof Msg) || msg.msg === '') {
return;
}
const messageType = msg.success ? 'success' : 'error';
getMessage()[messageType](msg.msg);
if (
msg.success &&
msg.obj &&
typeof msg.obj === 'object' &&
(msg.obj as { nodePending?: unknown }).nodePending === true
) {
getMessage().warning(i18next.t('pages.inbounds.toasts.savedNodeOfflineWillSync'));
if (msg.success) {
if (!silentSuccess) {
getMessage().success(msg.msg);
}
if (
msg.obj &&
typeof msg.obj === 'object' &&
(msg.obj as { nodePending?: unknown }).nodePending === true
) {
getMessage().warning(i18next.t('pages.inbounds.toasts.savedNodeOfflineWillSync'));
}
return;
}
getMessage().error(msg.msg);
}
static _respToMsg(resp: AxiosResponse | undefined): Msg {
@@ -59,11 +64,11 @@ export class HttpUtil {
}
static async get<T = unknown>(url: string, params?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
const { silent, ...axiosOpts } = options;
const { silent, silentSuccess, ...axiosOpts } = options;
try {
const resp = await axios.get(url, { params, ...axiosOpts });
const msg = this._respToMsg(resp) as Msg<T>;
if (!silent) this._handleMsg(msg);
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
console.error('GET request failed:', error);
@@ -75,11 +80,11 @@ export class HttpUtil {
}
static async post<T = unknown>(url: string, data?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
const { silent, ...axiosOpts } = options;
const { silent, silentSuccess, ...axiosOpts } = options;
try {
const resp = await axios.post(url, data, axiosOpts);
const msg = this._respToMsg(resp) as Msg<T>;
if (!silent) this._handleMsg(msg);
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
console.error('POST request failed:', error);

29
go.mod
View File

@@ -30,19 +30,10 @@ require (
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
gorm.io/gorm v1.31.2
pgregory.net/rapid v1.3.0
)
require (
github.com/pion/dtls/v3 v3.1.4 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/stun/v3 v3.1.6 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
)
require (
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
@@ -50,7 +41,7 @@ require (
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudflare/circl v1.6.4 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
@@ -77,11 +68,15 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-sqlite3 v1.14.45 // indirect
github.com/mattn/go-sqlite3 v1.14.47 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.4.2 // indirect
github.com/pion/dtls/v3 v3.1.4 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/stun/v3 v3.1.6 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/pires/go-proxyproto v0.12.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
@@ -98,9 +93,10 @@ require (
github.com/valyala/fastjson v1.6.10 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.7.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.28.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
@@ -108,10 +104,11 @@ require (
golang.org/x/net v0.56.0
golang.org/x/sync v0.21.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.46.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
google.golang.org/protobuf v1.36.11 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect

28
go.sum
View File

@@ -16,8 +16,8 @@ github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NI
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=
github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -129,8 +129,8 @@ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRt
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -146,8 +146,8 @@ github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0C
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
@@ -224,8 +224,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.mongodb.org/mongo-driver/v2 v2.7.0 h1:RO+zqavD2/GCL3cxOMyZhx6R9Irzr8/6gsoqx5tcY/c=
go.mongodb.org/mongo-driver/v2 v2.7.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
@@ -269,8 +269,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
@@ -279,8 +279,8 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@@ -299,8 +299,8 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk=
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=

View File

@@ -1344,19 +1344,27 @@ install_x-ui() {
fi
else
tag_version=$1
tag_version_numeric=${tag_version#v}
min_version="2.3.5"
# The rolling dev channel ships under a fixed, non-semver tag that is
# force-moved to the latest main commit on every push. Accept `dev` as a
# convenient alias and skip the numeric floor check for it.
if [[ "$tag_version" == "dev" || "$tag_version" == "dev-latest" ]]; then
tag_version="dev-latest"
echo -e "${yellow}Installing the rolling dev build (tag: dev-latest). This is a per-commit pre-release, not a stable version.${plain}"
else
tag_version_numeric=${tag_version#v}
min_version="2.3.5"
if [[ "$(printf '%s\n' "$min_version" "$tag_version_numeric" | sort -V | head -n1)" != "$min_version" ]]; then
echo -e "${red}Please use a newer version (at least v2.3.5). Exiting installation.${plain}"
exit 1
if [[ "$(printf '%s\n' "$min_version" "$tag_version_numeric" | sort -V | head -n1)" != "$min_version" ]]; then
echo -e "${red}Please use a newer version (at least v2.3.5). Exiting installation.${plain}"
exit 1
fi
fi
url="https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz"
echo -e "Beginning to install x-ui $1"
echo -e "Beginning to install x-ui ${tag_version}"
curl -fLR --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 300 -o ${xui_folder}-linux-$(arch).tar.gz ${url}
if [[ $? -ne 0 ]]; then
echo -e "${red}Download x-ui $1 failed, please check if the version exists ${plain}"
echo -e "${red}Download x-ui ${tag_version} failed, please check if the version exists ${plain}"
exit 1
fi
fi

View File

@@ -20,6 +20,15 @@ var version string
//go:embed name
var name string
// buildCommit and buildDate are injected at build time via `-ldflags -X` for
// CI per-commit (dev channel) builds; see .github/workflows/release.yml. They
// stay empty for a plain `go build` and for stable tagged releases, which is how
// IsDevBuild tells a rolling dev build apart from a stable/local one.
var (
buildCommit string
buildDate string
)
// LogLevel represents the logging level for the application.
type LogLevel string
@@ -32,8 +41,11 @@ const (
Error LogLevel = "error"
)
// GetVersion returns the version string of the 3x-ui application.
func GetVersion() string {
// GetBaseVersion returns the raw embedded release version of the 3x-ui panel
// (e.g. "3.4.0"). This is the panel's own version, not the Xray version. For the
// version a panel advertises/displays (which adds a "dev+<sha>" label on dev
// builds), use GetPanelVersion.
func GetBaseVersion() string {
return strings.TrimSpace(version)
}
@@ -42,6 +54,39 @@ func GetName() string {
return strings.TrimSpace(name)
}
// GetBuildCommit returns the short git commit this binary was built from, or an
// empty string for a plain/local build or a stable tagged release.
func GetBuildCommit() string {
return strings.TrimSpace(buildCommit)
}
// GetBuildDate returns the UTC build timestamp injected at build time, or empty.
func GetBuildDate() string {
return strings.TrimSpace(buildDate)
}
// IsDevBuild reports whether this binary is a CI per-commit (dev channel) build,
// detected by the injected commit. Stable releases and local builds return false.
func IsDevBuild() bool {
return GetBuildCommit() != ""
}
// GetPanelVersion returns the version a panel advertises to a managing master
// node and displays in the UI: the plain version for stable builds, or
// "dev+<short commit>" for dev builds. The dev form mirrors the master's
// getPanelUpdateInfo latestVersion so a node on the current dev commit compares
// as up to date instead of always showing "update available".
func GetPanelVersion() string {
if !IsDevBuild() {
return GetBaseVersion()
}
commit := GetBuildCommit()
if len(commit) > 8 {
commit = commit[:8]
}
return "dev+" + commit
}
// GetLogLevel returns the current logging level based on environment variables or defaults to Info.
func GetLogLevel() LogLevel {
if IsDebug() {

View File

@@ -5,6 +5,26 @@ import (
"testing"
)
func TestGetPanelVersion(t *testing.T) {
orig := buildCommit
t.Cleanup(func() { buildCommit = orig })
buildCommit = ""
if got := GetPanelVersion(); got != GetBaseVersion() {
t.Fatalf("stable build: GetPanelVersion = %q, want %q", got, GetBaseVersion())
}
buildCommit = "1d1128cf"
if got := GetPanelVersion(); got != "dev+1d1128cf" {
t.Fatalf("dev build: GetPanelVersion = %q, want %q", got, "dev+1d1128cf")
}
buildCommit = "1d1128cf945c4615efa05cf41ba7fa766e2ee428"
if got := GetPanelVersion(); got != "dev+1d1128cf" {
t.Fatalf("dev build (full sha): GetPanelVersion = %q, want %q", got, "dev+1d1128cf")
}
}
func TestGetPortOverride(t *testing.T) {
tests := []struct {
name string

View File

@@ -1 +1 @@
3.4.0
3.4.1

View File

@@ -45,8 +45,8 @@ func TestChar_C1_VlessExternalProxy(t *testing.T) {
}`
s := &SubService{}
got := s.genVlessLink(charVlessInbound(stream), "user")
want := "vless://11111111-2222-4333-8444-555555555555@cdn1.example.com:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1\n" +
"vless://11111111-2222-4333-8444-555555555555@cdn2.example.com:80?encryption=none&security=none&type=tcp#char-R2"
want := "vless://11111111-2222-4333-8444-555555555555@cdn1.example.com:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1-user\n" +
"vless://11111111-2222-4333-8444-555555555555@cdn2.example.com:80?encryption=none&security=none&type=tcp#char-R2-user"
if got != want {
t.Fatalf("C1 mismatch.\n got: %q\nwant: %q", got, want)
}
@@ -106,8 +106,8 @@ func TestChar_C2_VmessExternalProxy(t *testing.T) {
}
s := &SubService{}
got := s.genVmessLink(in, "user")
want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMSIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogInNuaTEuZXhhbXBsZS5jb20iLAogICJ0bHMiOiAidGxzIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9\n" +
"vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMiIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMS11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJzbmkiOiAic25pMS5leGFtcGxlLmNvbSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
"vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMi11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJ0bHMiOiAibm9uZSIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ=="
if got != want {
t.Fatalf("C2 mismatch.\n got: %q\nwant: %q", got, want)
}
@@ -143,7 +143,7 @@ func TestChar_C3_TrojanExternalProxy(t *testing.T) {
}
s := &SubService{}
got := s.genTrojanLink(in, "user")
want := "trojan://p%40ss%2Fw%2Brd%3D@tj.example.com:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ"
want := "trojan://p%40ss%2Fw%2Brd%3D@tj.example.com:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ-user"
if got != want {
t.Fatalf("C3-Trojan mismatch.\n got: %q\nwant: %q", got, want)
}
@@ -168,7 +168,7 @@ func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) {
}
s := &SubService{}
got := s.genShadowsocksLink(in, "user")
want := "ss://2022-blake3-aes-256-gcm:inboundpw:clientpw@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS"
want := "ss://2022-blake3-aes-256-gcm:inboundpw:clientpw@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS-user"
if got != want {
t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
}

View File

@@ -49,13 +49,17 @@ type SUBController struct {
subEnableRouting bool
subRoutingRules string
subHideSettings bool
subPath string
subJsonPath string
subClashPath string
jsonEnabled bool
clashEnabled bool
subEncrypt bool
updateInterval string
subIncyEnableRouting bool
subIncyRoutingRules string
subPath string
subJsonPath string
subClashPath string
jsonEnabled bool
clashEnabled bool
subEncrypt bool
updateInterval string
subService *SubService
subJsonService *SubJsonService
@@ -89,6 +93,8 @@ func NewSUBController(
subEnableRouting bool,
subRoutingRules string,
subHideSettings bool,
subIncyEnableRouting bool,
subIncyRoutingRules string,
) *SUBController {
sub := NewSubService(remarkTemplate)
a := &SUBController{
@@ -99,13 +105,17 @@ func NewSUBController(
subEnableRouting: subEnableRouting,
subRoutingRules: subRoutingRules,
subHideSettings: subHideSettings,
subPath: subPath,
subJsonPath: jsonPath,
subClashPath: clashPath,
jsonEnabled: jsonEnabled,
clashEnabled: clashEnabled,
subEncrypt: encrypt,
updateInterval: update,
subIncyEnableRouting: subIncyEnableRouting,
subIncyRoutingRules: subIncyRoutingRules,
subPath: subPath,
subJsonPath: jsonPath,
subClashPath: clashPath,
jsonEnabled: jsonEnabled,
clashEnabled: clashEnabled,
subEncrypt: encrypt,
updateInterval: update,
subService: sub,
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
@@ -183,6 +193,11 @@ func (a *SUBController) subs(c *gin.Context) {
}
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
result.WriteString(a.subIncyRoutingRules)
result.WriteString("\n")
}
if a.subEncrypt {
c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
} else {

View File

@@ -83,8 +83,8 @@ func TestBuildEndpointLinks_ParamForm(t *testing.T) {
func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
)
want := "vless://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A\n" +
"vless://uid@b.example.com:80?security=none&type=tcp#ib-B"
want := "vless://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A-user\n" +
"vless://uid@b.example.com:80?security=none&type=tcp#ib-B-user"
if got != want {
t.Fatalf("N3 mismatch.\n got: %q\nwant: %q", got, want)
}
@@ -105,8 +105,8 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
}
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEtdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogImEuc25pIiwKICAidGxzIjogInRscyIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ==\n" +
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUItdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
if got != want {
t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want)
}

View File

@@ -0,0 +1,37 @@
package sub
import (
"reflect"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// A single getSubs entry can hold several links (one per host of an inbound)
// joined by newlines. BuildPageData must split them into one entry per link, with
// the email replicated, so the subpage renders one row per host instead of
// collapsing them onto a single mangled line.
func TestBuildPageData_SplitsMultiHostLinks(t *testing.T) {
s := &SubService{}
subs := []string{
"vless://a@h1:443?type=tcp#DE-john@x\nvless://a@h2:443?type=tcp#DE-john@x\nvless://a@h3:443?type=tcp#DE-john@x",
"vless://b@h:443?type=tcp#FR-alice@x",
}
emails := []string{"john@x", "alice@x"}
page := s.BuildPageData("s1", "", xray.ClientTraffic{}, 0, subs, emails, "", "", "", "/", "", "")
if len(page.Result) != 4 {
t.Fatalf("Result len = %d, want 4 (3 host links + 1 single link)", len(page.Result))
}
for i, link := range page.Result {
if strings.Contains(link, "\n") {
t.Fatalf("Result[%d] still multi-line: %q", i, link)
}
}
wantEmails := []string{"john@x", "john@x", "john@x", "alice@x"}
if !reflect.DeepEqual(page.Emails, wantEmails) {
t.Fatalf("Emails = %v, want %v", page.Emails, wantEmails)
}
}

View File

@@ -6,7 +6,6 @@ import (
"strconv"
"strings"
"time"
"unicode"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
@@ -25,6 +24,7 @@ type remarkContext struct {
inbound *model.Inbound
hostRemark string
transport string
security string
}
// configName is the display name for a link: always the inbound's own remark.
@@ -71,6 +71,7 @@ var uiTokenMap = map[string]string{
"USAGE_PERCENTAGE": "USAGE_PERCENTAGE",
"PROTOCOL": "PROTOCOL",
"TRANSPORT": "TRANSPORT",
"SECURITY": "SECURITY",
}
// translateUISingleBrackets converts user-friendly single-brace tokens to the
@@ -226,6 +227,8 @@ func remarkVarValue(token string, ctx remarkContext) string {
return ""
case "TRANSPORT":
return ctx.transport
case "SECURITY":
return strings.ToUpper(ctx.security)
case "TIME_LEFT":
return timeLeftLabel(st.ExpiryTime)
case "JALALI_EXPIRE_DATE":
@@ -438,6 +441,13 @@ func (s *SubService) statsForClient(inbound *model.Inbound, client model.Client)
if stats, ok := s.statsByEmail[client.Email]; ok {
return stats
}
// Both in-memory paths key off client_traffics.inbound_id, which goes stale
// when an inbound is deleted and recreated, orphaning the row from every
// loaded inbound. Fall back to a direct lookup by the globally-unique email
// so usage still resolves for clients predating that recreation (#5567).
if stats, ok := s.statsByEmailFromDB(client.Email); ok {
return stats
}
return xray.ClientTraffic{
Enable: client.Enable,
ExpiryTime: client.ExpiryTime,
@@ -458,52 +468,107 @@ func (s *SubService) lookupClient(inbound *model.Inbound, email string) model.Cl
return model.Client{Email: email}
}
// usageInfoTokens are the per-client status tokens. On every link of a
// subscription except the client's first, these (and the decoration leading
// into them) are dropped, so the traffic/expiry info shows once instead of on
// every server.
var usageInfoTokens = []string{
"TRAFFIC_USED", "TRAFFIC_LEFT", "TRAFFIC_TOTAL",
"TRAFFIC_USED_BYTES", "TRAFFIC_LEFT_BYTES", "TRAFFIC_TOTAL_BYTES",
"UP", "DOWN", "DAYS_LEFT", "EXPIRE_DATE", "EXPIRE_UNIX", "STATUS",
"STATUS_EMOJI", "USAGE_PERCENTAGE", "TIME_LEFT", "JALALI_EXPIRE_DATE",
var usageInfoTokens = map[string]bool{
"TRAFFIC_USED": true, "TRAFFIC_LEFT": true, "TRAFFIC_TOTAL": true,
"TRAFFIC_USED_BYTES": true, "TRAFFIC_LEFT_BYTES": true, "TRAFFIC_TOTAL_BYTES": true,
"UP": true, "DOWN": true, "DAYS_LEFT": true, "EXPIRE_DATE": true, "EXPIRE_UNIX": true,
"STATUS": true, "STATUS_EMOJI": true, "USAGE_PERCENTAGE": true, "TIME_LEFT": true,
"JALALI_EXPIRE_DATE": true,
}
// nameOnlyTemplate returns template with the trailing per-client info part
// removed: everything from the first usage token (and the decoration — emojis,
// spaces, separators — leading into it) onward is dropped, leaving the config
// name. Returns "" when the template is info-only.
func nameOnlyTemplate(template string) string {
idx := -1
for _, tok := range usageInfoTokens {
if i := strings.Index(template, "{{"+tok+"}}"); i >= 0 && (idx < 0 || i < idx) {
idx = i
var connectionTokens = map[string]bool{
"PROTOCOL": true,
"TRANSPORT": true,
"SECURITY": true,
}
var displayRemoveTokens = mergeTokenSets(usageInfoTokens, connectionTokens)
func mergeTokenSets(sets ...map[string]bool) map[string]bool {
out := make(map[string]bool)
for _, set := range sets {
for tok := range set {
out[tok] = true
}
}
if idx < 0 {
return template
return out
}
func filterRemarkTemplate(template string, remove map[string]bool) string {
segments := strings.Split(template, "|")
kept := make([]string, 0, len(segments))
for _, seg := range segments {
if out := filterRemarkSegment(seg, remove); out != "" {
kept = append(kept, out)
}
}
return strings.TrimRightFunc(template[:idx], func(r rune) bool {
return r != '}' && !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
return strings.Join(kept, "|")
}
func filterRemarkSegment(seg string, remove map[string]bool) string {
locs := remarkVarRe.FindAllStringSubmatchIndex(seg, -1)
hasRemove := false
for _, loc := range locs {
if remove[seg[loc[2]:loc[3]]] {
hasRemove = true
break
}
}
if !hasRemove {
return strings.TrimSpace(seg)
}
runs := make([]string, 0, 2)
runStart, leftRemoved := 0, false
for _, loc := range locs {
if !remove[seg[loc[2]:loc[3]]] {
continue
}
runs = appendKeptRun(runs, seg[runStart:loc[0]], leftRemoved, true)
runStart, leftRemoved = loc[1], true
}
runs = appendKeptRun(runs, seg[runStart:], leftRemoved, false)
return strings.Join(runs, " ")
}
func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []string {
locs := remarkVarRe.FindAllStringSubmatchIndex(run, -1)
if len(locs) == 0 {
return runs
}
start, end := 0, len(run)
if leftRemoved {
start = locs[0][0]
}
if rightRemoved {
end = locs[len(locs)-1][1]
}
if frag := strings.TrimSpace(run[start:end]); frag != "" {
runs = append(runs, frag)
}
return runs
}
// effectiveTemplate picks which template to expand for one body link: the full
// template (with the per-client info) for a client's first link, and the
// name-only template for every link thereafter — so the info shows once. Only
// called in the subscription-body context (displays bypass the template).
func (s *SubService) effectiveTemplate(email string) string {
translated := translateUISingleBrackets(s.remarkTemplate)
if s.usageShown == nil {
s.usageShown = map[string]bool{}
}
if s.usageShown[email] {
return nameOnlyTemplate(translated)
return filterRemarkTemplate(translated, usageInfoTokens)
}
s.usageShown[email] = true
return translated
}
func inboundSecurity(inbound *model.Inbound) string {
if inbound == nil {
return ""
}
stream := unmarshalStreamSettings(inbound.StreamSettings)
security, _ := stream["security"].(string)
return security
}
// genTemplatedRemark expands the remark template for one client. hostRemark is
// the host endpoint's remark (empty for a plain inbound); it backs the {{HOST}}
// token only and never substitutes the inbound remark as the config name.
@@ -514,23 +579,27 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
inbound: inbound,
hostRemark: hostRemark,
transport: transport,
security: inboundSecurity(inbound),
}
var tmpl string
if s.subscriptionBody {
tmpl = s.effectiveTemplate(client.Email)
} else {
tmpl = filterRemarkTemplate(translateUISingleBrackets(s.remarkTemplate), displayRemoveTokens)
}
tmpl := s.effectiveTemplate(client.Email)
// Fall back to the config name when the template is empty or expands to
// nothing (e.g. an all-unlimited template whose only segments dropped out).
if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
return out
}
return ctx.configName()
}
// genHostRemark builds one host endpoint's remark for a specific client. The
// config name is always the inbound's own remark; the host's remark is surfaced
// only through the {{HOST}} token. In the subscription body the rest of the
// remark template still applies; displays show just the config name.
// genHostRemark builds one host endpoint's remark for a specific client. With a
// remark template set it is template-driven (body shows the full template on the
// first link and the name-only part thereafter; displays render the name-only
// part). With no template it falls back to inbound, host and email joined by "-".
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
if !s.subscriptionBody {
return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
if s.remarkTemplate != "" {
return s.genTemplatedRemark(inbound, client, hostRemark, transport)
}
return s.genTemplatedRemark(inbound, client, hostRemark, transport)
return fallbackRemark(inbound.Remark, hostRemark, client.Email)
}

View File

@@ -164,15 +164,15 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie
return s, inbound, client
}
// The config name is always the inbound's own remark; the host endpoint's remark
// never substitutes it (it is reachable only through {{HOST}}).
func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
s, inbound, client := hostRemarkService("") // no template → config name only
if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE" {
t.Fatalf("genHostRemark = %q, want %q (inbound remark, host ignored)", got, "DE")
// With no template configured, genHostRemark falls back to the inbound remark,
// host and email joined by "-".
func TestGenHostRemark_NoTemplate_Fallback(t *testing.T) {
s, inbound, client := hostRemarkService("")
if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE-Relay-john@example.com" {
t.Fatalf("genHostRemark = %q, want %q", got, "DE-Relay-john@example.com")
}
if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE")
if got := s.genHostRemark(inbound, client, "", ""); got != "DE-john@example.com" {
t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE-john@example.com")
}
}
@@ -206,12 +206,11 @@ func TestGenRemark_GlobalTemplate(t *testing.T) {
}
}
// With no template, genRemark composes the fallback model and adds no suffix.
func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
func TestGenRemark_NoTemplate_AppendsEmail(t *testing.T) {
s, inbound, _ := hostRemarkService("")
got := s.genRemark(inbound, "john@example.com", "Relay", "")
if got != "DE-Relay" {
t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
if got != "DE-Relay-john@example.com" {
t.Fatalf("genRemark = %q, want %q", got, "DE-Relay-john@example.com")
}
}
@@ -232,43 +231,158 @@ func TestUsageOnFirstLinkOnly(t *testing.T) {
}
}
// Outside the subscription body (panel link/QR displays, sub info page) the
// template is bypassed entirely — links show just the config name, with no
// per-client email or usage info.
func TestRemarkInDisplayContext(t *testing.T) {
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
s, inbound, client := hostRemarkService("{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
s.subscriptionBody = false
// A host link in a display shows only the config name — the inbound's remark,
// with no per-client email or usage info and the host remark ignored.
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE" {
t.Fatalf("display host link = %q, want config name %q", got, "DE")
const want = "DE-john@example.com"
if got := s.genHostRemark(inbound, client, "CDN", ""); got != want {
t.Fatalf("display host link = %q, want %q", got, want)
}
// With no host remark, the config name is likewise the inbound's own remark.
if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
if got := s.genHostRemark(inbound, client, "", ""); got != want {
t.Fatalf("display host link (no host) = %q, want %q", got, want)
}
// genRemark (non-host) likewise drops the template in display context.
if got := s.genRemark(inbound, client.Email, "", ""); got != "DE" {
t.Fatalf("display genRemark = %q, want %q", got, "DE")
if got := s.genRemark(inbound, client.Email, "", ""); got != want {
t.Fatalf("display genRemark = %q, want %q", got, want)
}
s2, inbound2, client2 := hostRemarkService("{{INBOUND}}-{{HOST}}|📊{{TRAFFIC_LEFT}}")
s2.subscriptionBody = false
if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE-CDN" {
t.Fatalf("display host link with HOST token = %q, want %q", got, "DE-CDN")
}
}
// nameOnlyTemplate drops the info part (and its leading decoration), keeping name.
func TestNameOnlyTemplate(t *testing.T) {
func TestFilterRemarkTemplate_BodyRepeat(t *testing.T) {
cases := map[string]string{
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}", // the default → name only
"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}": "{{EMAIL}} {{INBOUND}}", // multi-token name survives the trim
"{{INBOUND}} | {{STATUS}}": "{{INBOUND}}",
"{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}", // no info tokens → unchanged
"{{TRAFFIC_LEFT}}": "", // info only → empty
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}-{{TRANSPORT}}-{{SECURITY}}",
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}",
"{{INBOUND}} {{PROTOCOL}}|📊{{TRAFFIC_LEFT}}": "{{INBOUND}} {{PROTOCOL}}",
"{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}",
"{{TRAFFIC_LEFT}}|{{SECURITY}}": "{{SECURITY}}",
"{{INBOUND}}|📊{{TRAFFIC_LEFT}} {{PROTOCOL}}": "{{INBOUND}}|{{PROTOCOL}}",
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}": "{{INBOUND}}|{{EMAIL}}",
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}": "{{INBOUND}}|{{PROTOCOL}}{{TRANSPORT}}{{SECURITY}}",
"{{EMAIL}} {{TRAFFIC_USED}}5h": "{{EMAIL}}",
"{{PROTOCOL}} {{TRAFFIC_LEFT}}GB": "{{PROTOCOL}}",
"{{EMAIL}}-{{TRAFFIC_LEFT}}D-{{HOST}}": "{{EMAIL}} {{HOST}}",
"{{EMAIL}} 📊{{TRAFFIC_LEFT}} {{PROTOCOL}}": "{{EMAIL}} {{PROTOCOL}}",
}
for tmpl, want := range cases {
if got := nameOnlyTemplate(tmpl); got != want {
t.Errorf("nameOnlyTemplate(%q) = %q, want %q", tmpl, got, want)
if got := filterRemarkTemplate(tmpl, usageInfoTokens); got != want {
t.Errorf("filterRemarkTemplate(%q, usage) = %q, want %q", tmpl, got, want)
}
}
}
func TestFilterRemarkTemplate_Display(t *testing.T) {
cases := map[string]string{
"{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}": "{{INBOUND}}-{{EMAIL}}",
"{{INBOUND}} {{PROTOCOL}}": "{{INBOUND}}",
"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}": "{{EMAIL}} {{INBOUND}}",
"{{INBOUND}} | {{STATUS}}": "{{INBOUND}}",
"{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}",
"{{TRAFFIC_LEFT}}": "",
"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{HOST}}": "{{INBOUND}}|{{HOST}}",
"{{EMAIL}} ⏳{{DAYS_LEFT}}D {{HOST}}": "{{EMAIL}} {{HOST}}",
"{{INBOUND}} {{TRAFFIC_LEFT}} {{EMAIL}}": "{{INBOUND}} {{EMAIL}}",
}
for tmpl, want := range cases {
if got := filterRemarkTemplate(tmpl, displayRemoveTokens); got != want {
t.Errorf("filterRemarkTemplate(%q, display) = %q, want %q", tmpl, got, want)
}
}
}
func TestConnectionTokensOnEveryBodyLink(t *testing.T) {
s := &SubService{
remarkTemplate: "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
subscriptionBody: true,
usageShown: map[string]bool{},
}
inbound := &model.Inbound{
Remark: "DE",
Protocol: "vless",
StreamSettings: `{"network":"ws","security":"tls"}`,
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
}
client := model.Client{Email: "john@x"}
first := s.genTemplatedRemark(inbound, client, "", "ws")
second := s.genTemplatedRemark(inbound, client, "", "ws")
for _, want := range []string{"VLESS", "ws", "TLS"} {
if !strings.Contains(first, want) {
t.Fatalf("first body link %q missing %q", first, want)
}
if !strings.Contains(second, want) {
t.Fatalf("repeat body link %q missing connection token %q", second, want)
}
}
if strings.ContainsAny(second, "📊") || strings.Contains(second, "GB") {
t.Fatalf("repeat body link must drop the usage block: %q", second)
}
}
func TestConnectionTokensMixedIntoUsageSegment(t *testing.T) {
s := &SubService{
remarkTemplate: "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D {{PROTOCOL}} {{TRANSPORT}} {{SECURITY}}",
subscriptionBody: true,
usageShown: map[string]bool{},
}
inbound := &model.Inbound{
Remark: "DE",
Protocol: "vless",
StreamSettings: `{"network":"grpc","security":"reality"}`,
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
}
client := model.Client{Email: "john@x"}
_ = s.genTemplatedRemark(inbound, client, "", "grpc")
second := s.genTemplatedRemark(inbound, client, "", "grpc")
for _, want := range []string{"VLESS", "grpc", "REALITY"} {
if !strings.Contains(second, want) {
t.Fatalf("repeat body link %q missing connection token %q", second, want)
}
}
if strings.Contains(second, "GB") || strings.ContainsRune(second, '⏳') {
t.Fatalf("repeat body link must drop the usage block: %q", second)
}
}
func TestConnectionTokensDisplayContextUnchanged(t *testing.T) {
s := &SubService{
remarkTemplate: "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{PROTOCOL}}",
subscriptionBody: false,
}
inbound := &model.Inbound{
Remark: "DE",
Protocol: "vless",
StreamSettings: `{"network":"ws","security":"tls"}`,
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
}
if got := s.genTemplatedRemark(inbound, model.Client{Email: "john@x"}, "", "ws"); got != "DE" {
t.Fatalf("display remark = %q, want DE (connection after usage stripped outside the body)", got)
}
}
func TestIdentityTokensEverywhere(t *testing.T) {
const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}"
inbound := &model.Inbound{
Remark: "DE",
Protocol: "vless",
StreamSettings: `{"network":"ws","security":"tls"}`,
ClientStats: []xray.ClientTraffic{{Email: "john@x", Enable: true, Total: 100 * gb, Up: 30 * gb}},
}
client := model.Client{Email: "john@x"}
body := &SubService{remarkTemplate: tmpl, subscriptionBody: true, usageShown: map[string]bool{}}
_ = body.genTemplatedRemark(inbound, client, "", "ws") // first link consumes the usage block
if second := body.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(second, "john@x") {
t.Fatalf("repeat body link %q must keep the identity token", second)
}
display := &SubService{remarkTemplate: tmpl, subscriptionBody: false}
if got := display.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(got, "john@x") {
t.Fatalf("display remark %q must keep the identity token", got)
}
}
// statsForClient resolves usage from the per-request statsByEmail map when the
// link's own inbound doesn't carry the client's (globally unique) traffic row —
// the multi-inbound case that made {{TRAFFIC_LEFT}} show the full quota (#5443).
@@ -379,6 +493,7 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
stats: stats,
inbound: inbound,
transport: "ws",
security: "reality",
}
cases := []struct{ tmpl, want string }{
@@ -386,6 +501,7 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
{"{{USAGE_PERCENTAGE}}", "50.0%"},
{"{{PROTOCOL}}", "VLESS"},
{"{{TRANSPORT}}", "ws"},
{"{{SECURITY}}", "REALITY"},
{"{{STATUS_EMOJI}} {{INBOUND}}", "✅ DE"},
}
for _, c := range cases {
@@ -395,6 +511,32 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
}
}
func TestInboundSecurity(t *testing.T) {
cases := []struct{ stream, want string }{
{`{"network":"ws","security":"tls"}`, "tls"},
{`{"network":"tcp","security":"reality"}`, "reality"},
{`{"network":"tcp","security":"none"}`, "none"},
{`{"network":"tcp"}`, ""},
{"", ""},
}
for _, c := range cases {
if got := inboundSecurity(&model.Inbound{StreamSettings: c.stream}); got != c.want {
t.Errorf("inboundSecurity(%q) = %q, want %q", c.stream, got, c.want)
}
}
if got := inboundSecurity(nil); got != "" {
t.Errorf("inboundSecurity(nil) = %q, want empty", got)
}
}
func TestGenTemplatedRemark_SecurityFromStream(t *testing.T) {
s := &SubService{remarkTemplate: "{{INBOUND}} {{SECURITY}}", subscriptionBody: true}
inbound := &model.Inbound{Remark: "DE", StreamSettings: `{"network":"tcp","security":"reality"}`}
if got := s.genTemplatedRemark(inbound, model.Client{Email: "a@x"}, "", "tcp"); got != "DE REALITY" {
t.Fatalf("genTemplatedRemark SECURITY = %q, want %q", got, "DE REALITY")
}
}
func TestTranslateUISingleBrackets(t *testing.T) {
cases := []struct{ in, want string }{
{"{EMAIL}", "{{EMAIL}}"},
@@ -421,6 +563,7 @@ func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
stats: stats,
inbound: inbound,
transport: "ws",
security: "tls",
}
cases := []struct{ tmpl, want string }{
{"{EMAIL}", "alice@test.com"},
@@ -431,6 +574,7 @@ func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
{"{USAGE_PERCENTAGE}", "50.0%"},
{"{PROTOCOL}", "VLESS"},
{"{TRANSPORT}", "ws"},
{"{SECURITY}", "TLS"},
}
for _, c := range cases {
if got := expandRemarkVars(c.tmpl, ctx); got != c.want {

View File

@@ -1634,31 +1634,25 @@ func cloneStringMap(source map[string]string) map[string]string {
}
// genRemark builds the remark for a non-host link (raw default / legacy
// externalProxy / synthetic JSON-Clash entry). In the subscription body a set
// remark template takes over; otherwise (and in every display context) the
// remark is just the config name (inbound remark, then extra).
// externalProxy / synthetic JSON-Clash entry). A set remark template drives it
// in both the body and display contexts (genTemplatedRemark renders the
// name-only part on displays); with no template it falls back to the inbound
// remark, extra and email joined by "-".
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, transport string) string {
if s.remarkTemplate != "" && s.subscriptionBody {
if s.remarkTemplate != "" {
return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
}
// Sub info page + panel link/QR displays: just the config name (no template,
// so no per-client email/usage leaks into the shown remark).
return fallbackRemark(inbound.Remark, extra)
return fallbackRemark(inbound.Remark, extra, email)
}
// fallbackRemark is the minimal remark used only when no template is configured
// (an operator explicitly cleared it): the inbound remark and the host/extra
// remark joined by "-", skipping empties. The configurable remark model was
// removed in favour of the template, whose default already includes the email.
func fallbackRemark(inboundRemark, extra string) string {
switch {
case inboundRemark == "":
return extra
case extra == "":
return inboundRemark
default:
return inboundRemark + "-" + extra
func fallbackRemark(parts ...string) string {
out := make([]string, 0, len(parts))
for _, p := range parts {
if p != "" {
out = append(out, p)
}
}
return strings.Join(out, "-")
}
// findClientStats returns the inbound's traffic record for email, if present.
@@ -1671,6 +1665,32 @@ func (s *SubService) findClientStats(inbound *model.Inbound, email string) (xray
return xray.ClientTraffic{}, false
}
// statsByEmailFromDB resolves a client's traffic row straight from the DB by its
// globally-unique email, caching the hit into statsByEmail for the rest of the
// request. It's the last-resort lookup behind statsForClient: the preloaded
// ClientStats and the statsByEmail index are both keyed by
// client_traffics.inbound_id, which is written once by AddClientStat and never
// updated. When an inbound is deleted and recreated it gets a new id, so the old
// row is orphaned from every loaded inbound and both in-memory paths miss —
// leaving {{TRAFFIC_USED}} stuck at 0 for pre-existing clients even though their
// usage is intact (#5567). Matching by email recovers it, the same way the
// sub-info header's AggregateTrafficByEmails already does.
func (s *SubService) statsByEmailFromDB(email string) (xray.ClientTraffic, bool) {
db := database.GetDB()
if db == nil {
return xray.ClientTraffic{}, false
}
var row xray.ClientTraffic
if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).First(&row).Error; err != nil {
return xray.ClientTraffic{}, false
}
if s.statsByEmail == nil {
s.statsByEmail = map[string]xray.ClientTraffic{}
}
s.statsByEmail[email] = row
return row, true
}
func searchKey(data any, key string) (any, bool) {
switch val := data.(type) {
case map[string]any:
@@ -2343,6 +2363,19 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
datepicker = "gregorian"
}
pageLinks := make([]string, 0, len(subs))
pageEmails := make([]string, 0, len(subs))
for i, sub := range subs {
email := ""
if i < len(emails) {
email = emails[i]
}
for _, link := range splitLinkLines(sub) {
pageLinks = append(pageLinks, link)
pageEmails = append(pageEmails, email)
}
}
return PageData{
Host: hostHeader,
BasePath: basePath,
@@ -2364,8 +2397,8 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
SubClashUrl: subClashURL,
SubTitle: subTitle,
SubSupportUrl: subSupportUrl,
Result: subs,
Emails: emails,
Result: pageLinks,
Emails: pageEmails,
}
}

View File

@@ -0,0 +1,54 @@
package sub
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// statsForClient recovers a client's usage by email when the client_traffics row
// is orphaned — its inbound_id points at an inbound that was deleted and
// recreated, so the preloaded ClientStats and the statsByEmail index both miss.
// Before the email fallback, {{TRAFFIC_USED}} stayed at 0 for such pre-existing
// clients while the sub-info header was correct (#5567).
func TestStatsForClient_OrphanedInboundIdFallback(t *testing.T) {
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
const email = "old-client@example.com"
const total = int64(100) * gb
db := database.GetDB()
if err := db.Create(&xray.ClientTraffic{
InboundId: 999,
Email: email,
Up: 15 * gb,
Down: 5 * gb,
Total: total,
Enable: true,
}).Error; err != nil {
t.Fatalf("seed orphaned traffic: %v", err)
}
s := &SubService{statsByEmail: map[string]xray.ClientTraffic{}}
inbound := &model.Inbound{Id: 1, Remark: "DE"}
client := model.Client{Email: email, TotalGB: total, Enable: true}
st := s.statsForClient(inbound, client)
if used := st.Up + st.Down; used != 20*gb {
t.Fatalf("statsForClient used = %d, want %d (email fallback)", used, 20*gb)
}
if _, ok := s.statsByEmail[email]; !ok {
t.Fatalf("email fallback must cache the row into statsByEmail")
}
if got := remarkVarValue("TRAFFIC_USED", remarkContext{stats: st}); got != "20.00GB" {
t.Fatalf("TRAFFIC_USED = %q, want 20.00GB", got)
}
}

View File

@@ -175,6 +175,16 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubHideSettings = false
}
SubIncyEnableRouting, err := s.settingService.GetSubIncyEnableRouting()
if err != nil {
SubIncyEnableRouting = false
}
SubIncyRoutingRules, err := s.settingService.GetSubIncyRoutingRules()
if err != nil {
SubIncyRoutingRules = ""
}
// set per-request localizer from headers/cookies
engine.Use(locale.LocalizerMiddleware())
@@ -232,7 +242,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController(
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates,
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings)
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings,
SubIncyEnableRouting, SubIncyRoutingRules)
return engine, nil
}

View File

@@ -0,0 +1,271 @@
package tunnelmonitor
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
)
const (
defaultHealthURL = "https://www.cloudflare.com/cdn-cgi/trace"
defaultInterval = 30 * time.Second
defaultTimeout = 10 * time.Second
defaultFailureThreshold = 3
defaultCooldown = 5 * time.Minute
)
// Config controls the optional tunnel health monitor.
type Config struct {
Enabled bool
URL string
ProxyURL string
Interval time.Duration
Timeout time.Duration
FailureThreshold int
Cooldown time.Duration
}
// RecoveryFunc performs recovery after the monitor reaches the configured
// failure threshold. The panel wires this to an Xray core restart.
type RecoveryFunc func(context.Context) error
// Monitor periodically probes a URL and triggers recovery after repeated
// failures. It is intentionally independent from panel settings/UI so it can be
// enabled safely through service environment variables first.
type Monitor struct {
cfg Config
client *http.Client
recover RecoveryFunc
failures int
lastRecovery time.Time
now func() time.Time
}
// DefaultConfig returns disabled-by-default monitor settings.
func DefaultConfig() Config {
return Config{
Enabled: false,
URL: defaultHealthURL,
Interval: defaultInterval,
Timeout: defaultTimeout,
FailureThreshold: defaultFailureThreshold,
Cooldown: defaultCooldown,
}
}
// ConfigFromEnv builds Config from XUI_TUNNEL_HEALTH_* environment variables.
//
// Supported variables:
// - XUI_TUNNEL_HEALTH_MONITOR=true
// - XUI_TUNNEL_HEALTH_URL=https://www.cloudflare.com/cdn-cgi/trace
// - XUI_TUNNEL_HEALTH_PROXY=socks5://127.0.0.1:1080
// - XUI_TUNNEL_HEALTH_INTERVAL=30s
// - XUI_TUNNEL_HEALTH_TIMEOUT=10s
// - XUI_TUNNEL_HEALTH_FAILURES=3
// - XUI_TUNNEL_HEALTH_COOLDOWN=5m
func ConfigFromEnv() Config {
cfg := DefaultConfig()
cfg.Enabled = parseBool(os.Getenv("XUI_TUNNEL_HEALTH_MONITOR"))
cfg.URL = firstNonEmpty(os.Getenv("XUI_TUNNEL_HEALTH_URL"), cfg.URL)
cfg.ProxyURL = strings.TrimSpace(os.Getenv("XUI_TUNNEL_HEALTH_PROXY"))
cfg.Interval = parseDurationEnv("XUI_TUNNEL_HEALTH_INTERVAL", cfg.Interval)
cfg.Timeout = parseDurationEnv("XUI_TUNNEL_HEALTH_TIMEOUT", cfg.Timeout)
cfg.Cooldown = parseDurationEnv("XUI_TUNNEL_HEALTH_COOLDOWN", cfg.Cooldown)
cfg.FailureThreshold = parseIntEnv("XUI_TUNNEL_HEALTH_FAILURES", cfg.FailureThreshold)
return cfg.Normalize()
}
// Normalize applies safe bounds and defaults.
func (c Config) Normalize() Config {
if strings.TrimSpace(c.URL) == "" {
c.URL = defaultHealthURL
}
c.URL = strings.TrimSpace(c.URL)
c.ProxyURL = strings.TrimSpace(c.ProxyURL)
if c.Interval < time.Second {
c.Interval = defaultInterval
}
if c.Timeout < time.Second {
c.Timeout = defaultTimeout
}
if c.FailureThreshold < 1 {
c.FailureThreshold = defaultFailureThreshold
}
if c.Cooldown < time.Second {
c.Cooldown = defaultCooldown
}
return c
}
// New creates a monitor with an HTTP client based on cfg.
func New(cfg Config, recover RecoveryFunc) (*Monitor, error) {
cfg = cfg.Normalize()
client, err := netproxy.NewHTTPClient(cfg.ProxyURL, cfg.Timeout)
if err != nil {
return nil, err
}
return newWithClient(cfg, client, recover), nil
}
func newWithClient(cfg Config, client *http.Client, recover RecoveryFunc) *Monitor {
cfg = cfg.Normalize()
if client == nil {
client = &http.Client{Timeout: cfg.Timeout}
}
return &Monitor{
cfg: cfg,
client: client,
recover: recover,
now: time.Now,
}
}
// Run starts the monitor loop until ctx is cancelled.
func (m *Monitor) Run(ctx context.Context) {
if m == nil || !m.cfg.Enabled {
return
}
logger.Info("Tunnel health monitor enabled: ", m.cfg.URL)
ticker := time.NewTicker(m.cfg.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("Tunnel health monitor stopped")
return
case <-ticker.C:
recovered, err := m.Step(ctx)
if err != nil {
logger.Warning("Tunnel health monitor check failed: ", err)
}
if recovered {
logger.Warning("Tunnel health monitor triggered Xray restart")
}
}
}
}
// Step performs one probe and maybe triggers recovery.
func (m *Monitor) Step(ctx context.Context) (bool, error) {
if m == nil {
return false, errors.New("nil monitor")
}
if err := m.probe(ctx); err != nil {
m.failures++
if m.failures < m.cfg.FailureThreshold {
return false, fmt.Errorf("probe failed %d/%d: %w", m.failures, m.cfg.FailureThreshold, err)
}
now := m.now()
if !m.lastRecovery.IsZero() && now.Sub(m.lastRecovery) < m.cfg.Cooldown {
m.failures = m.cfg.FailureThreshold
return false, fmt.Errorf("probe failed %d/%d; recovery cooldown active: %w", m.failures, m.cfg.FailureThreshold, err)
}
if m.recover == nil {
m.failures = m.cfg.FailureThreshold
return false, errors.New("recovery function is not configured")
}
if recErr := m.recover(ctx); recErr != nil {
return false, fmt.Errorf("recovery failed after probe error %v: %w", err, recErr)
}
m.lastRecovery = now
m.failures = 0
return true, err
}
if m.failures > 0 {
logger.Info("Tunnel health monitor recovered after successful probe")
}
m.failures = 0
return false, nil
}
func (m *Monitor) probe(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.URL, nil)
if err != nil {
return err
}
resp, err := m.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)
}
return nil
}
func parseBool(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "y", "on", "enable", "enabled":
return true
default:
return false
}
}
func parseDurationEnv(name string, fallback time.Duration) time.Duration {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
d, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return d
}
func parseIntEnv(name string, fallback int) int {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
n, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return n
}
func firstNonEmpty(value string, fallback string) string {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
return value
}

View File

@@ -0,0 +1,454 @@
package tunnelmonitor
import (
"context"
"errors"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/op/go-logging"
)
func TestMain(m *testing.M) {
logger.InitLogger(logging.ERROR)
m.Run()
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestMonitorRestartsAfterFailureThreshold(t *testing.T) {
cfg := Config{
Enabled: true,
URL: "http://example.test",
Interval: time.Minute,
Timeout: time.Second,
FailureThreshold: 2,
Cooldown: time.Minute,
}
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("tunnel down")
}),
}
restarts := 0
monitor := newWithClient(cfg, client, func(ctx context.Context) error {
restarts++
return nil
})
monitor.now = func() time.Time {
return time.Unix(100, 0)
}
if recovered, _ := monitor.Step(context.Background()); recovered {
t.Fatal("first failure must not trigger recovery")
}
if recovered, _ := monitor.Step(context.Background()); !recovered {
t.Fatal("second consecutive failure should trigger recovery")
}
if restarts != 1 {
t.Fatalf("expected 1 recovery, got %d", restarts)
}
}
func TestMonitorRespectsRecoveryCooldown(t *testing.T) {
cfg := Config{
Enabled: true,
URL: "http://example.test",
Interval: time.Minute,
Timeout: time.Second,
FailureThreshold: 1,
Cooldown: time.Minute,
}
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("tunnel down")
}),
}
now := time.Unix(100, 0)
restarts := 0
monitor := newWithClient(cfg, client, func(ctx context.Context) error {
restarts++
return nil
})
monitor.now = func() time.Time {
return now
}
recovered, _ := monitor.Step(context.Background())
if !recovered {
t.Fatal("first failure should trigger recovery when threshold is 1")
}
recovered, _ = monitor.Step(context.Background())
if recovered {
t.Fatal("cooldown should suppress immediate second recovery")
}
if restarts != 1 {
t.Fatalf("expected 1 recovery during cooldown, got %d", restarts)
}
now = now.Add(time.Minute + time.Second)
recovered, _ = monitor.Step(context.Background())
if !recovered {
t.Fatal("recovery should be allowed after cooldown")
}
if restarts != 2 {
t.Fatalf("expected 2 recoveries after cooldown, got %d", restarts)
}
}
func TestMonitorSuccessResetsFailures(t *testing.T) {
cfg := Config{
Enabled: true,
URL: "http://example.test",
Interval: time.Minute,
Timeout: time.Second,
FailureThreshold: 2,
Cooldown: time.Minute,
}
fail := true
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if fail {
return nil, errors.New("tunnel down")
}
return &http.Response{
StatusCode: http.StatusOK,
Body: http.NoBody,
}, nil
}),
}
restarts := 0
monitor := newWithClient(cfg, client, func(ctx context.Context) error {
restarts++
return nil
})
_, _ = monitor.Step(context.Background())
fail = false
if recovered, err := monitor.Step(context.Background()); recovered || err != nil {
t.Fatalf("successful probe should not recover or fail, recovered=%v err=%v", recovered, err)
}
fail = true
if recovered, _ := monitor.Step(context.Background()); recovered {
t.Fatal("failure after success should be counted as first failure again")
}
if restarts != 0 {
t.Fatalf("expected no recovery, got %d", restarts)
}
}
func TestConfigFromEnvParsesValues(t *testing.T) {
t.Setenv("XUI_TUNNEL_HEALTH_MONITOR", "true")
t.Setenv("XUI_TUNNEL_HEALTH_URL", "https://example.com/health")
t.Setenv("XUI_TUNNEL_HEALTH_PROXY", "socks5://127.0.0.1:1080")
t.Setenv("XUI_TUNNEL_HEALTH_INTERVAL", "15s")
t.Setenv("XUI_TUNNEL_HEALTH_TIMEOUT", "3s")
t.Setenv("XUI_TUNNEL_HEALTH_FAILURES", "4")
t.Setenv("XUI_TUNNEL_HEALTH_COOLDOWN", "2m")
cfg := ConfigFromEnv()
if !cfg.Enabled {
t.Fatal("expected monitor to be enabled")
}
if cfg.URL != "https://example.com/health" {
t.Fatalf("unexpected URL: %s", cfg.URL)
}
if !strings.HasPrefix(cfg.ProxyURL, "socks5://") {
t.Fatalf("unexpected proxy URL: %s", cfg.ProxyURL)
}
if cfg.Interval != 15*time.Second {
t.Fatalf("unexpected interval: %s", cfg.Interval)
}
if cfg.Timeout != 3*time.Second {
t.Fatalf("unexpected timeout: %s", cfg.Timeout)
}
if cfg.FailureThreshold != 4 {
t.Fatalf("unexpected threshold: %d", cfg.FailureThreshold)
}
if cfg.Cooldown != 2*time.Minute {
t.Fatalf("unexpected cooldown: %s", cfg.Cooldown)
}
}
func failingClient() *http.Client {
return &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("tunnel down")
}),
}
}
func statusClient(code int) *http.Client {
return &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: code, Body: http.NoBody}, nil
}),
}
}
func TestProbeStatusCodeClassification(t *testing.T) {
cases := []struct {
status int
healthy bool
}{
{199, false},
{200, true},
{204, true},
{301, true},
{399, true},
{400, false},
{404, false},
{500, false},
}
for _, tc := range cases {
cfg := Config{
Enabled: true,
URL: "http://example.test",
Interval: time.Minute,
Timeout: time.Second,
FailureThreshold: 100,
Cooldown: time.Minute,
}
monitor := newWithClient(cfg, statusClient(tc.status), func(ctx context.Context) error {
return nil
})
recovered, err := monitor.Step(context.Background())
if recovered {
t.Fatalf("status %d: unexpected recovery", tc.status)
}
if tc.healthy && err != nil {
t.Fatalf("status %d: expected healthy probe, got error %v", tc.status, err)
}
if !tc.healthy && err == nil {
t.Fatalf("status %d: expected failure, got nil error", tc.status)
}
}
}
func TestNormalizeClampsBounds(t *testing.T) {
got := Config{
URL: " ",
Interval: 0,
Timeout: 500 * time.Millisecond,
FailureThreshold: 0,
Cooldown: 0,
}.Normalize()
if got.URL != defaultHealthURL {
t.Fatalf("URL not defaulted: %q", got.URL)
}
if got.Interval != defaultInterval {
t.Fatalf("Interval not clamped: %s", got.Interval)
}
if got.Timeout != defaultTimeout {
t.Fatalf("Timeout not clamped: %s", got.Timeout)
}
if got.FailureThreshold != defaultFailureThreshold {
t.Fatalf("FailureThreshold not clamped: %d", got.FailureThreshold)
}
if got.Cooldown != defaultCooldown {
t.Fatalf("Cooldown not clamped: %s", got.Cooldown)
}
valid := Config{
URL: "https://example.com/health",
Interval: 15 * time.Second,
Timeout: 3 * time.Second,
FailureThreshold: 5,
Cooldown: 2 * time.Minute,
}.Normalize()
if valid.URL != "https://example.com/health" ||
valid.Interval != 15*time.Second ||
valid.Timeout != 3*time.Second ||
valid.FailureThreshold != 5 ||
valid.Cooldown != 2*time.Minute {
t.Fatalf("valid config was mutated: %+v", valid)
}
}
func TestNewRejectsUnsupportedProxyScheme(t *testing.T) {
m, err := New(Config{ProxyURL: "ftp://127.0.0.1:21"}, func(ctx context.Context) error {
return nil
})
if err == nil || m != nil {
t.Fatalf("expected error and nil monitor for bad scheme, got m=%v err=%v", m, err)
}
m, err = New(Config{}, func(ctx context.Context) error {
return nil
})
if err != nil || m == nil {
t.Fatalf("expected a valid monitor for empty proxy, got m=%v err=%v", m, err)
}
}
func TestMonitorRecoveryErrorDoesNotArmCooldown(t *testing.T) {
cfg := Config{
Enabled: true,
URL: "http://example.test",
Interval: time.Minute,
Timeout: time.Second,
FailureThreshold: 1,
Cooldown: time.Minute,
}
attempts := 0
monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
attempts++
return errors.New("restart failed")
})
monitor.now = func() time.Time {
return time.Unix(100, 0)
}
recovered, err := monitor.Step(context.Background())
if recovered || err == nil {
t.Fatalf("failed recovery must report recovered=false with an error, got recovered=%v err=%v", recovered, err)
}
if !monitor.lastRecovery.IsZero() {
t.Fatal("a failed recovery must not arm the cooldown")
}
if _, err := monitor.Step(context.Background()); err == nil {
t.Fatal("expected error on the second failing step")
}
if attempts != 2 {
t.Fatalf("recovery should be retried (no cooldown) after a failure, attempts=%d", attempts)
}
}
func TestMonitorNilRecoverStaysBounded(t *testing.T) {
cfg := Config{
Enabled: true,
URL: "http://example.test",
Interval: time.Minute,
Timeout: time.Second,
FailureThreshold: 2,
Cooldown: time.Minute,
}
monitor := newWithClient(cfg, failingClient(), nil)
for range 5 {
recovered, _ := monitor.Step(context.Background())
if recovered {
t.Fatal("a nil recovery func must never report recovery")
}
if monitor.failures > cfg.FailureThreshold {
t.Fatalf("failures must stay capped at threshold %d, got %d", cfg.FailureThreshold, monitor.failures)
}
}
}
func TestMonitorFailuresCappedDuringCooldown(t *testing.T) {
cfg := Config{
Enabled: true,
URL: "http://example.test",
Interval: time.Minute,
Timeout: time.Second,
FailureThreshold: 2,
Cooldown: time.Minute,
}
restarts := 0
monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
restarts++
return nil
})
monitor.now = func() time.Time {
return time.Unix(100, 0)
}
monitor.Step(context.Background())
if recovered, _ := monitor.Step(context.Background()); !recovered {
t.Fatal("expected recovery once the threshold is reached")
}
for range 6 {
monitor.Step(context.Background())
if monitor.failures > cfg.FailureThreshold {
t.Fatalf("failures must never exceed threshold %d during cooldown, got %d", cfg.FailureThreshold, monitor.failures)
}
}
if restarts != 1 {
t.Fatalf("cooldown should suppress further recoveries, restarts=%d", restarts)
}
}
func TestMonitorRunStopsOnContextCancel(t *testing.T) {
cfg := Config{
Enabled: true,
URL: "http://example.test",
Timeout: time.Second,
FailureThreshold: 1,
Cooldown: time.Hour,
}
recovered := make(chan struct{})
var once sync.Once
monitor := newWithClient(cfg, failingClient(), func(ctx context.Context) error {
once.Do(func() { close(recovered) })
return nil
})
monitor.cfg.Interval = 5 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
monitor.Run(ctx)
close(done)
}()
select {
case <-recovered:
case <-time.After(2 * time.Second):
cancel()
t.Fatal("Run did not trigger recovery within the deadline")
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not return after context cancellation")
}
}

View File

@@ -1,27 +1,59 @@
package sys
import (
"fmt"
"os"
"runtime/debug"
"strconv"
"strings"
"github.com/shirou/gopsutil/v4/mem"
)
// memLimitHeadroomPercent is the share of detected memory used for the soft
// limit, leaving room for non-heap (stacks, mmap, the xray child) before the OS
// OOM-kills the process.
const memLimitHeadroomPercent = 90
const (
memLimitHeadroomPercent = 90
defaultGCPercent = 75
defaultReleaseMinutes = 10
)
// ApplyMemoryLimit sets a Go soft memory limit (the runtime's GOMEMLIMIT) when
// one is not already configured, so a long-running panel in a memory-capped
// container or VPS triggers GC as it approaches the cap instead of growing RSS
// until the OS OOM-kills it. Precedence: an explicit GOMEMLIMIT env is left to
// the runtime; otherwise XUI_MEMORY_LIMIT (in MiB) wins; otherwise the limit is
// derived from the cgroup memory limit, falling back to total system RAM.
// Returns the limit applied in bytes (0 when none) and a short source label.
func ApplyMemoryLimit() (int64, string) {
// ApplyMemoryTuning configures the Go runtime for a lower, steadier footprint and
// returns one log line per decision. It does NOT derive a soft limit from total
// system RAM: on a shared or uncontrolled host that gives no benefit (GOGC, not
// the limit, paces GC while the heap is far below it) and risks GC thrashing, so
// memory is kept low via GOGC plus the periodic release job instead.
func ApplyMemoryTuning() []string {
lines := []string{applyGCPercent()}
if limit, source := applyMemoryLimit(); limit > 0 {
lines = append(lines, fmt.Sprintf("Go memory soft limit set to %d MiB (%s)", limit>>20, source))
} else {
lines = append(lines, "Go memory soft limit not enforced: "+source)
}
return lines
}
// applyGCPercent lowers GOGC so the heap high-water mark, and thus RSS, stays
// smaller. An explicit GOGC env (including GOGC=off) is left to the runtime.
func applyGCPercent() string {
if _, ok := os.LookupEnv("GOGC"); ok {
return "GC percent: GOGC env (handled by the Go runtime)"
}
pct := defaultGCPercent
if v := strings.TrimSpace(os.Getenv("XUI_GOGC")); v != "" {
if n, err := strconv.Atoi(v); err == nil {
pct = n
}
}
if pct <= 0 {
return "GC percent left at Go default"
}
debug.SetGCPercent(pct)
return fmt.Sprintf("GC percent set to %d", pct)
}
// applyMemoryLimit sets the soft limit only from an explicit budget: GOMEMLIMIT
// env (left to the runtime), XUI_MEMORY_LIMIT in MiB, or a real cgroup limit at
// 90% to leave headroom for non-heap and the xray child. No budget -> Go default.
func applyMemoryLimit() (int64, string) {
if strings.TrimSpace(os.Getenv("GOMEMLIMIT")) != "" {
return 0, "GOMEMLIMIT env (handled by the Go runtime)"
}
@@ -34,28 +66,32 @@ func ApplyMemoryLimit() (int64, string) {
}
}
total, source := detectAvailableMemory()
if total <= 0 {
return 0, "undetectable; left at Go default"
if v, ok := cgroupMemoryLimit(); ok {
limit := v / 100 * memLimitHeadroomPercent
debug.SetMemoryLimit(limit)
return limit, "cgroup limit"
}
limit := total / 100 * memLimitHeadroomPercent
debug.SetMemoryLimit(limit)
return limit, source
return 0, "no explicit budget; soft limit left at Go default"
}
func detectAvailableMemory() (int64, string) {
if v, ok := cgroupMemoryLimit(); ok {
return v, "cgroup limit"
// MemoryReleaseIntervalMinutes reports how often freed heap memory is returned to
// the OS via debug.FreeOSMemory. XUI_MEMORY_RELEASE_INTERVAL overrides the
// default; an explicit 0 disables the periodic release.
func MemoryReleaseIntervalMinutes() int {
v := strings.TrimSpace(os.Getenv("XUI_MEMORY_RELEASE_INTERVAL"))
if v == "" {
return defaultReleaseMinutes
}
if vm, err := mem.VirtualMemory(); err == nil && vm.Total > 0 {
return int64(vm.Total), "system RAM"
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
return 0, ""
return defaultReleaseMinutes
}
// cgroupMemoryLimit reads the container memory limit from cgroup v2 then v1.
// A "max" value or the v1 unlimited sentinel (~8 EiB) means no limit at this
// level, so it reports not-found and the caller falls back to system RAM. The
// level, so it reports not-found and the caller falls back to the Go default. The
// files are absent off Linux, which also yields not-found.
func cgroupMemoryLimit() (int64, bool) {
const unlimited = int64(1) << 62

View File

@@ -0,0 +1,34 @@
package sys
import (
"os"
"sync"
"github.com/shirou/gopsutil/v4/process"
)
var (
selfProc *process.Process
selfProcOnce sync.Once
)
// SelfRSS returns the resident set size of the current process in bytes — the
// real physical memory the OS attributes to the panel. Unlike
// runtime.MemStats.Sys (a never-shrinking high-water mark of reserved address
// space that also counts memory already returned to the OS), RSS reflects current
// usage and drops as memory is released. Returns 0 when unavailable.
func SelfRSS() uint64 {
selfProcOnce.Do(func() {
if p, err := process.NewProcess(int32(os.Getpid())); err == nil {
selfProc = p
}
})
if selfProc == nil {
return 0
}
if mi, err := selfProc.MemoryInfo(); err == nil && mi != nil {
return mi.RSS
}
return 0
}

View File

@@ -64,6 +64,8 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/delDepleted", a.delDepleted)
g.POST("/bulkAdjust", a.bulkAdjust)
g.POST("/bulkEnable", a.bulkEnable)
g.POST("/bulkDisable", a.bulkDisable)
g.POST("/bulkDel", a.bulkDelete)
g.POST("/bulkCreate", a.bulkCreate)
g.POST("/bulkAttach", a.bulkAttach)
@@ -248,6 +250,7 @@ type bulkAdjustRequest struct {
Emails []string `json:"emails"`
AddDays int `json:"addDays"`
AddBytes int64 `json:"addBytes"`
Flow string `json:"flow"`
}
func (a *ClientController) bulkAdjust(c *gin.Context) {
@@ -256,7 +259,7 @@ func (a *ClientController) bulkAdjust(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes)
result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes, req.Flow)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
@@ -337,6 +340,36 @@ func (a *ClientController) bulkDelete(c *gin.Context) {
notifyClientsChanged()
}
type bulkEnableRequest struct {
Emails []string `json:"emails"`
}
func (a *ClientController) bulkEnable(c *gin.Context) {
a.bulkSetEnable(c, true)
}
func (a *ClientController) bulkDisable(c *gin.Context) {
a.bulkSetEnable(c, false)
}
func (a *ClientController) bulkSetEnable(c *gin.Context, enable bool) {
var req bulkEnableRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkSetEnable(&a.inboundService, req.Emails, enable)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) bulkCreate(c *gin.Context) {
var payloads []service.ClientCreatePayload
if err := c.ShouldBindJSON(&payloads); err != nil {

View File

@@ -2,9 +2,9 @@ package controller
import (
"bytes"
"embed"
"encoding/json"
htmlpkg "html"
"io/fs"
"net/http"
"strings"
"time"
@@ -16,10 +16,10 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
)
var distFS embed.FS
var distFS fs.FS
func SetDistFS(fs embed.FS) {
distFS = fs
func SetDistFS(fsys fs.FS) {
distFS = fsys
}
var distPageBuildTime = time.Now()
@@ -30,7 +30,7 @@ var distPageBuildTime = time.Now()
// produced at frontend build time by scripts/build-openapi.mjs and
// embedded into the binary via the dist FS.
func ServeOpenAPISpec(c *gin.Context) {
body, err := distFS.ReadFile("dist/openapi.json")
body, err := fs.ReadFile(distFS, "dist/openapi.json")
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
return
@@ -72,7 +72,7 @@ func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
}
func serveDistPage(c *gin.Context, name string) {
body, err := distFS.ReadFile("dist/" + name)
body, err := fs.ReadFile(distFS, "dist/"+name)
if err != nil {
c.String(http.StatusInternalServerError, "missing embedded page: %s", name)
return
@@ -112,7 +112,7 @@ func serveDistPage(c *gin.Context, name string) {
}
script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
if name != "login.html" {
escapedVer := jsEscape.Replace(config.GetVersion())
escapedVer := jsEscape.Replace(config.GetPanelVersion())
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
script += `;window.X_UI_DB_TYPE="` + config.GetDBKind() + `"`
}

View File

@@ -318,6 +318,7 @@ func (a *NodeController) probe(c *gin.Context) {
func (a *NodeController) updatePanel(c *gin.Context) {
var req struct {
Ids []int `json:"ids"`
Dev bool `json:"dev"`
}
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
@@ -327,7 +328,7 @@ func (a *NodeController) updatePanel(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("no nodes selected"))
return
}
results, err := a.nodeService.UpdatePanels(req.Ids)
results, err := a.nodeService.UpdatePanels(req.Ids, req.Dev)
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.updateStarted"), results, err)
}

View File

@@ -69,6 +69,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.POST("/restartXrayService", a.restartXrayService)
g.POST("/installXray/:version", a.installXray)
g.POST("/updatePanel", a.updatePanel)
g.POST("/setUpdateChannel", a.setUpdateChannel)
g.POST("/updateGeofile", a.updateGeofile)
g.POST("/updateGeofile/:fileName", a.updateGeofile)
g.POST("/logs/:count", a.getLogs)
@@ -205,12 +206,36 @@ func (a *ServerController) installXray(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err)
}
// updatePanel starts a panel self-update to the latest release.
// updatePanel starts a panel self-update. With no "dev" form value it follows
// this panel's own channel setting; an explicit "dev" (sent by the master node
// updater) overrides it for this run.
func (a *ServerController) updatePanel(c *gin.Context) {
err := a.panelService.StartUpdate()
devParam := c.PostForm("dev")
var err error
if devParam == "" {
err = a.panelService.StartUpdate()
} else {
dev, perr := strconv.ParseBool(devParam)
if perr != nil {
jsonMsg(c, "invalid data", perr)
return
}
err = a.panelService.StartUpdateChannel(dev)
}
jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err)
}
// setUpdateChannel toggles whether self-update tracks the rolling dev release.
func (a *ServerController) setUpdateChannel(c *gin.Context) {
dev, err := strconv.ParseBool(c.PostForm("dev"))
if err != nil {
jsonMsg(c, "invalid data", err)
return
}
err = a.settingService.SetDevChannelEnable(dev)
jsonMsg(c, I18nWeb(c, "pages.index.updateChannelChanged"), err)
}
// updateGeofile updates the specified geo file for Xray.
func (a *ServerController) updateGeofile(c *gin.Context) {
fileName := c.Param("fileName")

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