Compare commits

...

5 Commits

Author SHA1 Message Date
Rouzbeh† 0daedd3db9 feat: add support for subscription-based outbounds with auto-update (#5037)
* feat: add support for subscription-based outbounds with auto-update

- New OutboundSubscription model (full support on both SQLite and PostgreSQL)
- Go subscription link parser (vmess/vless/trojan/ss/hysteria2/wireguard) matching frontend behavior
- Stable tag assignment across refreshes (designed for balancer + routing use)
- Runtime merge of subscription outbounds into Xray config (additive only)
- Full CRUD + manual refresh + preview API
- Background auto-update job (per-subscription interval)
- Frontend management UI in Outbounds tab (Subscriptions drawer) + tag integration in balancers/routing rules
- Proper dual-database support including CLI migration path

Review & hardening notes:
- Fixed merge logic bug that could drop manual outbounds
- Added SSRF/private-IP protection on subscription URLs using SanitizePublicHTTPURL
- Improved update interval UX (hours + minutes)
- Auto-fetch on first subscription creation
- Added detailed comments on tag stability strategy and balancer implications when servers are added/removed/rotated
- Updated migrationModels() for CLI migrate-db support

* fix: resolve frontend lint/type errors and Go build break

Frontend (eslint + tsc clean):
- Destructure subscriptionOutboundTags prop in RoutingTab and
  BalancersTab. It was declared in the interface and used in useMemo
  but never destructured, so it resolved as an unresolved global
  (react-hooks warning + tsc "Cannot find name"). The prop is passed
  by XrayPage, so the feature was silently inert.
- OutboundsTab: remove unused useEffect import, add an OutboundSub
  type to replace any[] state and the any/any table render signature,
  type the subscriptionOutbounds cast, and replace unused catch (e)
  bindings with parameter-less catch. Also type HttpUtil.post as
  OutboundSub so r.obj?.id type-checks.

Backend (go build clean):
- outbound_subscription_job: websocket.MessageTypeXray is undefined;
  use the existing MessageTypeOutbounds since the job refreshes
  outbound subscriptions.

* fix(xray): make outbound subscription creation work end-to-end

- Correct API paths from /panel/xray/outbound-subs to
  /panel/api/xray/outbound-subs. The controller is mounted under
  /panel/api, so the old paths hit the SPA page route (GET-only)
  and 404'd on POST.
- Send the create-subscription body as a plain object instead of
  URLSearchParams. The axios request interceptor serializes bodies
  with qs.stringify, which can't read URLSearchParams' internal
  storage and produced an empty body, so the backend rejected it
  with "subscription URL is required".
- Use message.useMessage() + context holder instead of the static
  antd message API (resolves the "Static function can not consume
  context" warning), matching XrayPage's pattern.
- Migrate the subscriptions Drawer to antd v6 props: width -> size,
  destroyOnClose -> destroyOnHidden, and Space direction -> orientation.

* feat(xray): show traffic/test for subscription outbounds; harden + test the feature

Display (the reported issue):
- Replace the flat read-only pills with a proper read-only table (desktop)
  and cards (mobile) in a new SubscriptionOutbounds component, showing
  Address, Protocol, Traffic (matched by tag — already collected by Xray),
  and a Test button with Latency. No edit/delete/move (read-only).
- Test subscription outbounds via the existing /testOutbound endpoint, with
  results keyed by tag (subscriptionTestStates + testSubscriptionOutbound in
  useXraySetting, wired through XrayPage). Generalize isTesting/testResult to
  a string|number key so the same helpers serve index- and tag-keyed states.

i18n:
- Replace all hardcoded English subscription strings with t() calls and add
  pages.xray.outboundSub.* keys to en-US.json (other locales fall back).

Backend hardening + tests:
- xray.go: drop the tautological `subSvc != nil` check.
- outbound_subscription: re-validate every redirect hop against private/
  internal addresses (CheckRedirect) and cap the redirect chain, closing an
  SSRF gap where only the initial host was checked.
- Extract assignStableTags as a pure function and add unit tests for tag
  stability and SSRF rejection (the feature previously had no tests).

Misc:
- gofmt util/link/outbound.go (it was not gofmt-clean).

* fix(xray): make outbound-subs feature pass CI (test compile, route docs, openapi)

- outbound_test.go: remove unused `inner`/`lines` variables that broke the
  `util/link` test build (declared and not used).
- Document the 7 outbound-subscription routes in endpoints.ts (list, create,
  update, delete, del alias, refresh, parse) so TestAPIRoutesDocumented passes.
- Regenerate frontend/public/openapi.json (npm run gen) to include the new
  endpoints, satisfying the codegen freshness check.

* feat(xray): per-subscription allow-private, gap-filled tags, UI tweaks, delete refresh

Backend:
- Add a per-subscription AllowPrivate flag (default off). Create/Update/refresh
  and the redirect check sanitize the URL with it, so localhost/LAN sources work
  only when explicitly opted in; the SSRF guard still blocks private targets by
  default. Controller reads the allowPrivate form field on create/update/parse.
- Default outbound tag prefix now uses the smallest free "subN-" number instead
  of the auto-increment id, so deleting a subscription frees its number for reuse
  (a fresh start gives sub1) while staying stable per subscription. Extracted a
  pure defaultPrefixNumber() with unit tests.
- deleteOutboundSub now signals SetToNeedRestart so xray drops the outbounds.

Frontend:
- "Allow private address" toggle in the add form (sends allowPrivate).
- Delete now refreshes the xray view immediately (no manual page reload).
- Subscriptions manager opens as a centered Modal instead of a right-side Drawer.
- Move Outbounds to a top-level sidebar item under Nodes (out of Xray Configs).
- Collapse WARP/NordVPN into a "more" dropdown.
- Document the allowPrivate param in endpoints.ts.

* i18n(xray): translate outbound-subscription UI into all locales

- Translate the pages.xray.outboundSub.* strings (and allowPrivate label/hint)
  into all 12 non-English locales, matching each file's existing terminology.
- Remove the unused outboundSub.add ("Add subscription") key from every locale.

* feat(xray): subscription manager — edit, reorder/priority, status, preview, refresh-all

Backend:
- Per-subscription Priority + Prepend: subscriptions are ordered by Priority and
  placed before (Prepend) or after the manual template outbounds in the merge, so
  a subscription server can become the default. New Move(up/down) endpoint
  re-normalizes priorities; merge split into prepend/template/append.
- List now returns a derived OutboundCount and orders by priority, and strips the
  heavy LastFetchedOutbounds/LinkIdentities blobs from the list payload.
- Create/Update accept the prepend flag; new subs append at the end of priority.

Frontend (Outbound Subscriptions modal):
- Edit existing subscriptions (reuses the form + Update endpoint).
- Inline enable/disable Switch, Status column (OK / error tooltip), Outbounds
  count column, per-row refresh spinner, "Refresh all" button.
- Reorder (move up/down) controls + a "Before manual outbounds" toggle.
- Preview button: fetch+parse a URL via /parse without saving.
- Document the move route + prepend param in endpoints.ts; regenerate openapi.json.

* i18n(xray): translate new subscription-manager strings into all locales

Add the prepend/prependHint, preview/previewEmpty, refreshAll, statusOk and
toastUpdated keys to all 12 non-English locales, matching each file's terminology.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-06-08 18:09:53 +02:00
Rouzbeh† 21e01cc1e6 fix(postgres): make node traffic sync robust after public API inbound updates (#5038)
* fix(postgres): make node traffic sync robust after public API inbound updates

The background NodeTrafficSyncJob (every 5s) started failing after a
successful POST /panel/api/inbounds/update/{id} (including flows that
inject streamSettings.externalProxy) with:

  node traffic sync: merge for <node> failed:
  ERROR: CASE types boolean and integer cannot be matched (SQLSTATE 42804)

Root cause:
- The merge lives in setRemoteTrafficLocked (called from SetRemoteTraffic).
- The client_traffics delta path used a dialect-sensitive expression:
    enable = enable AND ?
    last_online = GREATEST(last_online, ?)
- On PostgreSQL, GREATEST / AND / COALESCE are implemented with internal
  CASE expressions. When "enable" columns (client_traffics, inbounds, ...)
  were INTEGER (common after SQLite → PG data migrations, older
  AutoMigrate, or mixed write paths) and the right-hand side was a
  boolean parameter (from snapshot ClientStats or form-bound API payload),
  PG rejected the expression at plan time.
- The public API update path (unlike the internal remote wire path)
  always runs updateClientTraffics + UpdateClientStat + SyncInbound.
  This touches client_traffics.enable rows for any inbound that has
  clients.
- SQLite tolerated 0/1 numeric bools; PG is strict.

Fix:
- Use an explicit CASE with ::boolean casts in the critical enable
  expression so the result type is always boolean.
- Make GreatestExpr emit safe casts on Postgres.
- Add a one-time normalization step in MigrationRequirements (runs on
  startup + xray restarts) that forces the relevant enable/enabled
  columns to boolean on Postgres using an idempotent DO block + USING
  cast. This cleans up pre-existing skew without a full re-migration.

This branch is based on upstream/main (original mhsanaei/3x-ui main).

The node traffic sync now survives arbitrary public-API inbound
updates on PostgreSQL.

* fix: make client traffic enable merge expression safe on SQLite too

The previous commit introduced an explicit CASE for the "only node
can disable" logic in the node traffic sync merge to fix the PG
"CASE types boolean and integer cannot be matched" error after
public API inbound updates.

That expression used PostgreSQL-only `::boolean` casts:

    CASE WHEN ?::boolean THEN enable::boolean ELSE false END

This is invalid syntax on SQLite (and would break the merge when
the client_traffics delta UPDATE runs — which is commonly triggered
right after an API /inbounds/update because that path calls
updateClientTraffics + SyncInbound and touches client_traffics rows).

Extracted the expression to a new dialect-aware helper
`ClientTrafficEnableMergeExpr()` (following the same pattern as
GreatestExpr, JSONClientsFromInbound, etc.).

- On Postgres: keeps the strict boolean-typed CASE with casts.
- On SQLite: uses a numeric-compatible form
  `CASE WHEN ? THEN enable ELSE 0 END` that produces the expected
  0/1 result matching the column affinity.

The logical behavior ("node may only force-disable, never re-enable")
is preserved on both databases.

This is a follow-up commit on the same branch so that one PR
contains both the original Postgres fix and the SQLite compatibility
fix.

Builds directly on top of 91643f68.

* fix

---------

Co-authored-by: Rqzbeh <rqzbeh@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-08 14:54:53 +02:00
jq 46684dd164 fix(sub): emit VLESS encryption in Clash configs (#5053)
Co-authored-by: jq <fs187438@gmail.com>
2026-06-08 14:39:54 +02:00
Sanaei 1ca5924a44 feat(mtproto): add MTProto (FakeTLS) protocol via managed mtg sidecar (#5076)
* feat(mtproto): add MTProto (FakeTLS) protocol via managed mtg sidecar

Xray-core has no mtproto proxy, so mtproto inbounds run as standalone
mtg (9seconds/mtg) sidecar processes managed by the panel — one per
inbound — and are excluded from the generated Xray config entirely.

- model: MTProto protocol constant, validator, and FakeTLS secret
  helpers (GenerateFakeTLSSecret/HealMtprotoSecret)
- mtproto package: per-inbound mtg process manager with reconcile,
  graceful stop, and best-effort Prometheus traffic scraping
- runtime: delegate mtproto inbounds to the mtg manager instead of the
  Xray gRPC API; skip mtproto when building the Xray config
- web: boot reconcile + StopAll wiring, periodic reconcile/traffic job,
  port-conflict transport, secret healing on inbound add/update
- sub: tg:// proxy share-link generation
- frontend: protocol option, Zod schema, Protocol tab (FakeTLS domain +
  regenerable secret), info-modal link, and i18n
- provisioning: fetch mtg v2.2.8 in install.sh, DockerInit.sh, and the
  Linux + Windows release workflows

* fix

* fix

* fix: address Copilot review comments on mtproto PR

- web/web.go: create NewMtprotoJob once and reuse for cron + initial run
- mtproto/manager.go: StopAll cleans up per-inbound config files on shutdown
- mtproto/manager.go: CollectTraffic releases mutex before HTTP scrapes to
  avoid blocking Ensure/Reconcile/Remove during network I/O
- database/model/model.go: panic on crypto/rand failure in mtprotoRandomMiddle
  instead of silently producing a weak all-zero secret
- install.sh: fix chmod to handle renamed bin/mtg-linux-arm on armv5/v6/v7
2026-06-08 14:28:19 +02:00
Sanaei af3c808444 fix: default hysteria tls to no utls fingerprint 2026-06-08 13:15:51 +02:00
75 changed files with 5271 additions and 93 deletions
+19
View File
@@ -150,6 +150,16 @@ jobs:
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
mv xray xray-linux-${{ matrix.platform }}
# mtg (MTProto sidecar) - only for arches mtg publishes
MTG_VER="2.2.8"
case "${{ matrix.platform }}" in
amd64|arm64|armv7|armv6|386)
wget -q "https://github.com/9seconds/mtg/releases/download/v${MTG_VER}/mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz"
tar -xzf "mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz"
mv "mtg-${MTG_VER}-linux-${{ matrix.platform }}/mtg" "mtg-linux-${{ matrix.platform }}" 2>/dev/null || mv mtg "mtg-linux-${{ matrix.platform }}"
rm -rf "mtg-${MTG_VER}-linux-${{ matrix.platform }}" "mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz"
;;
esac
cd ../..
- name: Package
@@ -258,6 +268,15 @@ jobs:
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
Rename-Item xray.exe xray-windows-amd64.exe
# Download mtg (MTProto sidecar) for Windows
$MTG_VER = "2.2.8"
Invoke-WebRequest -Uri "https://github.com/9seconds/mtg/releases/download/v$MTG_VER/mtg-$MTG_VER-windows-amd64.zip" -OutFile "mtg-windows-amd64.zip"
Expand-Archive -Path "mtg-windows-amd64.zip" -DestinationPath "mtg-tmp"
$mtgExe = Get-ChildItem -Path "mtg-tmp" -Recurse -Filter "mtg.exe" | Select-Object -First 1
Move-Item $mtgExe.FullName "mtg-windows-amd64.exe"
Remove-Item "mtg-windows-amd64.zip", "mtg-tmp" -Recurse -Force
cd ..
Copy-Item -Path ..\windows_files\* -Destination . -Recurse
cd ..
+1 -1
View File
@@ -162,7 +162,7 @@ Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/setting/*`, `/panel/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/api/setting/*`, `/panel/api/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
+12
View File
@@ -3,34 +3,46 @@ case $1 in
amd64)
ARCH="64"
FNAME="amd64"
MTG_ARCH="amd64"
;;
i386)
ARCH="32"
FNAME="i386"
MTG_ARCH="386"
;;
armv8 | arm64 | aarch64)
ARCH="arm64-v8a"
FNAME="arm64"
MTG_ARCH="arm64"
;;
armv7 | arm | arm32)
ARCH="arm32-v7a"
FNAME="arm32"
MTG_ARCH="armv7"
;;
armv6)
ARCH="arm32-v6"
FNAME="armv6"
MTG_ARCH="armv6"
;;
*)
ARCH="64"
FNAME="amd64"
MTG_ARCH="amd64"
;;
esac
MTG_VER="2.2.8"
mkdir -p build/bin
cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.1/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"
curl -sfLRO "https://github.com/9seconds/mtg/releases/download/v${MTG_VER}/mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz"
tar -xzf "mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz"
mv "mtg-${MTG_VER}-linux-${MTG_ARCH}/mtg" "mtg-linux-${FNAME}" 2>/dev/null || mv mtg "mtg-linux-${FNAME}"
rm -rf "mtg-${MTG_VER}-linux-${MTG_ARCH}" "mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz"
chmod +x "mtg-linux-${FNAME}"
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
+1
View File
@@ -73,6 +73,7 @@ func initModels() error {
&model.ClientGroup{},
&model.InboundFallback{},
&model.NodeClientTraffic{},
&model.OutboundSubscription{},
}
for _, mdl := range models {
if err := db.AutoMigrate(mdl); err != nil {
+25 -1
View File
@@ -22,7 +22,31 @@ func JSONFieldText(expr, key string) string {
func GreatestExpr(a, b string) string {
if IsPostgres() {
return fmt.Sprintf("GREATEST(%s, %s)", a, b)
return fmt.Sprintf("GREATEST(%s::bigint, %s::bigint)", a, b)
}
return fmt.Sprintf("MAX(%s, %s)", a, b)
}
// ClientTrafficEnableMergeExpr returns the SQL expression used in the
// node traffic merge to update client_traffics.enable.
//
// The intent is: only allow the remote node to *disable* a client
// (never re-enable one that the central panel has disabled).
//
// We use a dialect-specific expression because:
// - On PostgreSQL we want strict boolean typing and casts to avoid
// "CASE types boolean and integer cannot be matched" errors
// (and similar internal expansions of AND/GREATEST).
// - On SQLite, enable is stored with INTEGER affinity (0/1), there is
// no :: cast syntax, and we must produce a numeric-compatible result.
//
// The expression must be valid SQL for tx.Exec with a boolean parameter
// as the first ?.
func ClientTrafficEnableMergeExpr() string {
if IsPostgres() {
return "CASE WHEN ?::boolean THEN enable::boolean ELSE false END"
}
// SQLite: no :: casts. Use numeric CASE. 1/0 work as true/false
// thanks to SQLite's affinity and how GORM/drivers bind bools.
return "CASE WHEN ? THEN enable ELSE 0 END"
}
+15 -3
View File
@@ -20,9 +20,20 @@ import (
"gorm.io/gorm/logger"
)
// migrationModels is the FK-aware order in which tables are created and copied.
// Parents come before their children so foreign-key constraints stay satisfied
// even when checks are not explicitly disabled.
// migrationModels is the FK-aware order in which tables are created and copied
// during `x-ui migrate-db --dsn` (SQLite → PostgreSQL data migration) and in
// related tests.
//
// Important: When adding a new top-level model (like OutboundSubscription),
// you must add it here **in addition to** the list in database/db.go:initModels().
// This list is used for:
// - Creating the destination schema during cross-DB migration
// - Truncating tables
// - Copying data row-by-row
// - Resyncing Postgres sequences after bulk insert
//
// DumpSQLite / RestoreSQLite are schema-introspective (they read sqlite_master)
// so they do not need manual updates.
func migrationModels() []any {
return []any{
&model.User{},
@@ -39,6 +50,7 @@ func migrationModels() []any {
&model.ClientInbound{},
&model.InboundFallback{},
&model.NodeClientTraffic{},
&model.OutboundSubscription{},
}
}
+90 -1
View File
@@ -3,6 +3,8 @@ package model
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
@@ -29,6 +31,7 @@ const (
Mixed Protocol = "mixed"
WireGuard Protocol = "wireguard"
Hysteria Protocol = "hysteria"
MTProto Protocol = "mtproto"
)
// User represents a user account in the 3x-ui panel.
@@ -56,7 +59,7 @@ type Inbound struct {
// Xray configuration fields
Listen string `json:"listen" form:"listen"`
Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun" example:"vless"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"`
Settings string `json:"settings" form:"settings"`
StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
@@ -366,6 +369,70 @@ func HealShadowsocksClientMethods(settings string) (string, bool) {
return string(out), true
}
// GenerateFakeTLSSecret builds an MTProto FakeTLS secret for the given domain:
// the "ee" FakeTLS marker, 16 random bytes, then the domain encoded as hex.
// This single value is what mtg's config and the client tg:// link both use.
func GenerateFakeTLSSecret(domain string) string {
return "ee" + mtprotoRandomMiddle() + hex.EncodeToString([]byte(domain))
}
func mtprotoRandomMiddle() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
panic(fmt.Errorf("mtproto: crypto/rand read failed: %w", err))
}
return hex.EncodeToString(buf)
}
// mtprotoSecretMiddle returns the 16-byte random middle of an existing secret
// when it is well-formed, otherwise a freshly generated one. Reusing the middle
// keeps the secret stable when only the FakeTLS domain changes.
func mtprotoSecretMiddle(secret string) string {
s := secret
if strings.HasPrefix(s, "ee") || strings.HasPrefix(s, "dd") {
s = s[2:]
}
if len(s) >= 32 {
mid := s[:32]
if _, err := hex.DecodeString(mid); err == nil {
return mid
}
}
return mtprotoRandomMiddle()
}
// HealMtprotoSecret normalises an mtproto inbound's settings JSON before the
// value leaves for the mtg sidecar or a share link: it rebuilds `secret` so it
// is always a valid FakeTLS secret whose trailing domain matches
// `fakeTlsDomain`, generating the random middle when one is missing and
// rewriting the domain suffix when the domain changed. Returns the rewritten
// settings and true when anything changed.
func HealMtprotoSecret(settings string) (string, bool) {
if settings == "" {
return settings, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return settings, false
}
domain, _ := parsed["fakeTlsDomain"].(string)
domain = strings.TrimSpace(domain)
if domain == "" {
return settings, false
}
secret, _ := parsed["secret"].(string)
expected := "ee" + mtprotoSecretMiddle(secret) + hex.EncodeToString([]byte(domain))
if secret == expected {
return settings, false
}
parsed["secret"] = expected
out, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(out), true
}
// Setting stores key-value configuration settings for the 3x-ui panel.
type Setting struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
@@ -638,6 +705,28 @@ type ClientMergeConflict struct {
Kept any
}
type OutboundSubscription struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Remark string `json:"remark" form:"remark"`
Url string `json:"url" form:"url"`
Enabled bool `json:"enabled" form:"enabled" gorm:"default:true"`
AllowPrivate bool `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
TagPrefix string `json:"tagPrefix" form:"tagPrefix"`
UpdateInterval int `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
Priority int `json:"priority" form:"priority" gorm:"default:0"` // order among subscriptions in the merged outbounds (lower = earlier)
Prepend bool `json:"prepend" form:"prepend" gorm:"default:false"` // place this subscription's outbounds before the manual template outbounds
LastUpdated int64 `json:"lastUpdated" form:"lastUpdated"`
LastError string `json:"lastError" form:"lastError"`
LastFetchedOutbounds string `json:"lastFetchedOutbounds" form:"lastFetchedOutbounds" gorm:"type:text"`
LinkIdentities string `json:"-" gorm:"type:text;column:link_identities"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
// OutboundCount is a derived count of the last fetched outbounds (not
// persisted); List populates it so the UI can show how many outbounds a
// subscription produced without shipping the full payload.
OutboundCount int `json:"outboundCount" gorm:"-"`
}
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
var conflicts []ClientMergeConflict
keep := func(field string, oldV, newV, kept any) {
+71
View File
@@ -0,0 +1,71 @@
package model
import (
"encoding/hex"
"encoding/json"
"strings"
"testing"
)
func TestGenerateFakeTLSSecret(t *testing.T) {
domain := "www.cloudflare.com"
s := GenerateFakeTLSSecret(domain)
if !strings.HasPrefix(s, "ee") {
t.Fatalf("secret must start with ee, got %q", s)
}
wantSuffix := hex.EncodeToString([]byte(domain))
if !strings.HasSuffix(s, wantSuffix) {
t.Fatalf("secret must end with hex(domain) %q, got %q", wantSuffix, s)
}
if len(s) != 2+32+len(wantSuffix) {
t.Fatalf("unexpected secret length %d", len(s))
}
if _, err := hex.DecodeString(s[2:34]); err != nil {
t.Fatalf("middle is not valid hex: %v", err)
}
}
func TestHealMtprotoSecret(t *testing.T) {
domain := "example.com"
suffix := hex.EncodeToString([]byte(domain))
in := `{"fakeTlsDomain":"example.com","secret":""}`
out, changed := HealMtprotoSecret(in)
if !changed {
t.Fatal("expected heal to populate an empty secret")
}
var parsed map[string]any
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("healed settings not valid json: %v", err)
}
got, _ := parsed["secret"].(string)
if !strings.HasPrefix(got, "ee") || !strings.HasSuffix(got, suffix) {
t.Fatalf("healed secret malformed: %q", got)
}
if _, changed2 := HealMtprotoSecret(out); changed2 {
t.Fatal("expected no change for an already-valid secret")
}
mid := got[2:34]
newDomain := "telegram.org"
in3 := `{"fakeTlsDomain":"telegram.org","secret":"` + got + `"}`
out3, changed3 := HealMtprotoSecret(in3)
if !changed3 {
t.Fatal("expected heal to rewrite the domain suffix")
}
if err := json.Unmarshal([]byte(out3), &parsed); err != nil {
t.Fatalf("healed settings not valid json: %v", err)
}
got3, _ := parsed["secret"].(string)
if got3[2:34] != mid {
t.Fatalf("random middle should be preserved on domain change: %q vs %q", got3[2:34], mid)
}
if !strings.HasSuffix(got3, hex.EncodeToString([]byte(newDomain))) {
t.Fatalf("suffix not updated for new domain: %q", got3)
}
if _, changed4 := HealMtprotoSecret(`{"secret":"ee"}`); changed4 {
t.Fatal("expected no change when fakeTlsDomain is missing")
}
}
+293 -1
View File
@@ -1341,7 +1341,8 @@
"http",
"mixed",
"tunnel",
"tun"
"tun",
"mtproto"
],
"example": "vless",
"type": "string"
@@ -7551,6 +7552,297 @@
}
}
},
"/panel/api/xray/outbound-subs": {
"get": {
"tags": [
"Xray Settings"
],
"summary": "List all outbound subscriptions (remote URLs that supply additional outbounds), newest first.",
"operationId": "get_panel_api_xray_outbound_subs",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
},
"post": {
"tags": [
"Xray Settings"
],
"summary": "Create an outbound subscription. The URL is fetched, parsed into outbounds with stable tags, and merged additively into the running Xray config.",
"operationId": "post_panel_api_xray_outbound_subs",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/xray/outbound-subs/{id}": {
"post": {
"tags": [
"Xray Settings"
],
"summary": "Update an existing outbound subscription by id. Accepts the same form fields as create.",
"operationId": "post_panel_api_xray_outbound_subs_id",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Subscription id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
},
"delete": {
"tags": [
"Xray Settings"
],
"summary": "Delete an outbound subscription by id.",
"operationId": "delete_panel_api_xray_outbound_subs_id",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Subscription id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/xray/outbound-subs/{id}/del": {
"post": {
"tags": [
"Xray Settings"
],
"summary": "Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).",
"operationId": "post_panel_api_xray_outbound_subs_id_del",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Subscription id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/xray/outbound-subs/{id}/refresh": {
"post": {
"tags": [
"Xray Settings"
],
"summary": "Force an immediate re-fetch of the subscription and return the parsed outbounds. Signals Xray to reload.",
"operationId": "post_panel_api_xray_outbound_subs_id_refresh",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Subscription id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/xray/outbound-subs/{id}/move": {
"post": {
"tags": [
"Xray Settings"
],
"summary": "Reorder a subscription one step up or down in priority (controls its position in the merged outbounds).",
"operationId": "post_panel_api_xray_outbound_subs_id_move",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "Subscription id.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/xray/outbound-subs/parse": {
"post": {
"tags": [
"Xray Settings"
],
"summary": "Preview a subscription URL: fetch and parse it into outbounds without persisting anything.",
"operationId": "post_panel_api_xray_outbound_subs_parse",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/{subPath}{subid}": {
"get": {
"tags": [
+2 -1
View File
@@ -1315,7 +1315,8 @@ export const SCHEMAS: Record<string, unknown> = {
"http",
"mixed",
"tunnel",
"tun"
"tun",
"mtproto"
],
"example": "vless",
"type": "string"
+1 -1
View File
@@ -316,7 +316,7 @@ export const InboundSchema = z.object({
nodeId: z.number().int().nullable().optional(),
originNodeGuid: z.string().optional(),
port: z.number().int().min(0).max(65535),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']),
remark: z.string(),
settings: z.unknown(),
sniffing: z.unknown(),
+64 -32
View File
@@ -51,9 +51,12 @@ export interface UseXraySettingResult {
setOutboundTestUrl: (v: string) => void;
inboundTags: string[];
clientReverseTags: string[];
subscriptionOutbounds: unknown[];
subscriptionOutboundTags: string[];
restartResult: string;
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
subscriptionTestStates: Record<string, OutboundTestState>;
testingAll: boolean;
fetchAll: () => Promise<void>;
fetchOutboundsTraffic: () => Promise<void>;
@@ -63,6 +66,11 @@ export interface UseXraySettingResult {
outbound: unknown,
mode?: string,
) => Promise<OutboundTestResult | null>;
testSubscriptionOutbound: (
tag: string,
outbound: unknown,
mode?: string,
) => Promise<OutboundTestResult | null>;
testAllOutbounds: (mode?: string) => Promise<void>;
saveAll: () => Promise<void>;
resetToDefault: () => Promise<void>;
@@ -118,8 +126,13 @@ export function useXraySetting(): UseXraySettingResult {
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [inboundTags, setInboundTags] = useState<string[]>([]);
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState<string[]>([]);
const [restartResult, setRestartResult] = useState('');
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
// Subscription outbounds aren't in templateSettings.outbounds, so their test
// results are keyed by tag rather than by index.
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
const [testingAll, setTestingAll] = useState(false);
const oldXraySettingRef = useRef('');
@@ -146,6 +159,8 @@ export function useXraySetting(): UseXraySettingResult {
syncingRef.current = false;
setInboundTags(obj.inboundTags || []);
setClientReverseTags(obj.clientReverseTags || []);
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
setOutboundTestUrlState(nextUrl);
oldOutboundTestUrlRef.current = nextUrl;
@@ -255,6 +270,26 @@ export function useXraySetting(): UseXraySettingResult {
const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
// Shared POST + parse for a single outbound test. Returns an OutboundTestResult
// (success or a failure-shaped result); callers store it under their own key.
const postOutboundTest = useCallback(
async (outbound: unknown, effMode: string): Promise<OutboundTestResult> => {
try {
const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode: effMode,
});
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
if (msg?.success && msg.obj) return msg.obj;
return { success: false, error: msg?.msg || 'Unknown error', mode: effMode };
} catch (e) {
return { success: false, error: String(e), mode: effMode };
}
},
[],
);
const testOutbound = useCallback(
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
if (!outbound) return null;
@@ -263,39 +298,28 @@ export function useXraySetting(): UseXraySettingResult {
...prev,
[index]: { testing: true, result: null, mode: effMode },
}));
try {
const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode: effMode,
});
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
if (msg?.success && msg.obj) {
setOutboundTestStates((prev) => ({
...prev,
[index]: { testing: false, result: msg.obj },
}));
return msg.obj;
}
setOutboundTestStates((prev) => ({
...prev,
[index]: {
testing: false,
result: { success: false, error: msg?.msg || 'Unknown error', mode: effMode },
},
}));
} catch (e) {
setOutboundTestStates((prev) => ({
...prev,
[index]: {
testing: false,
result: { success: false, error: String(e), mode: effMode },
},
}));
}
return null;
const result = await postOutboundTest(outbound, effMode);
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
return result.success ? result : null;
},
[],
[postOutboundTest],
);
// Test a subscription outbound (not present in templateSettings.outbounds);
// results are keyed by tag in subscriptionTestStates.
const testSubscriptionOutbound = useCallback(
async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
if (!outbound || !tag) return null;
const effMode = isUdpOutbound(outbound) ? 'http' : mode;
setSubscriptionTestStates((prev) => ({
...prev,
[tag]: { testing: true, result: null, mode: effMode },
}));
const result = await postOutboundTest(outbound, effMode);
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
return result.success ? result : null;
},
[postOutboundTest],
);
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
@@ -358,14 +382,18 @@ export function useXraySetting(): UseXraySettingResult {
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
restartResult,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
@@ -384,14 +412,18 @@ export function useXraySetting(): UseXraySettingResult {
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
restartResult,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
+6 -3
View File
@@ -40,7 +40,7 @@ const DONATE_URL = 'https://donate.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__';
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs' | 'outbound';
const iconByName: Record<IconName, ComponentType> = {
dashboard: DashboardOutlined,
@@ -52,6 +52,7 @@ const iconByName: Record<IconName, ComponentType> = {
cluster: ClusterOutlined,
logout: LogoutOutlined,
apidocs: ApiOutlined,
outbound: UploadOutlined,
};
function readCollapsed(): boolean {
@@ -137,6 +138,7 @@ export default function AppSidebar() {
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
@@ -162,7 +164,6 @@ export default function AppSidebar() {
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(() => [
{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
{ key: '/xray#routing', icon: <SwapOutlined />, label: t('pages.xray.Routings') },
{ key: '/xray#outbound', icon: <UploadOutlined />, label: t('pages.xray.Outbounds') },
{ key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
{ key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
{ key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
@@ -176,7 +177,9 @@ export default function AppSidebar() {
? `/xray${hash || '#basic'}`
: (pathname === '' ? '/' : pathname);
const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
// The Outbounds top-level item lives on /xray#outbound, so don't auto-open the
// Xray Configs submenu for it.
const openSubmenu = settingsActive ? '/settings' : xrayActive && hash !== '#outbound' ? '/xray' : null;
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
useEffect(() => {
if (openSubmenu) {
+41 -1
View File
@@ -3,6 +3,7 @@ import { RandomUtil, Wireguard } from '@/utils';
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
import type { MtprotoInboundSettings } from '@/schemas/protocols/inbound/mtproto';
import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
@@ -200,6 +201,43 @@ export function createDefaultMixedInboundSettings(): MixedInboundSettings {
};
}
function domainToHex(domain: string): string {
return Array.from(new TextEncoder().encode(domain))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// generateMtprotoSecret builds an "ee" FakeTLS secret: the marker, 16 random
// bytes (32 hex chars), then the domain encoded as hex. Mirrors the Go
// model.GenerateFakeTLSSecret; the backend re-derives it on save so this is
// only for immediate display in the form.
export function generateMtprotoSecret(domain: string): string {
return `ee${RandomUtil.randomSeq(32, { type: 'hex' })}${domainToHex(domain)}`;
}
// mtprotoSecretForDomain rewrites only the domain suffix of an existing secret,
// preserving its 16-byte random middle when valid (generating one otherwise).
// Mirrors the Go model.HealMtprotoSecret so editing the FakeTLS domain doesn't
// needlessly rotate the secret's identity.
export function mtprotoSecretForDomain(currentSecret: string, domain: string): string {
let body = currentSecret;
if (body.startsWith('ee') || body.startsWith('dd')) {
body = body.slice(2);
}
const middle = /^[0-9a-f]{32}/i.test(body)
? body.slice(0, 32)
: RandomUtil.randomSeq(32, { type: 'hex' });
return `ee${middle}${domainToHex(domain)}`;
}
export function createDefaultMtprotoInboundSettings(): MtprotoInboundSettings {
const fakeTlsDomain = 'www.cloudflare.com';
return {
fakeTlsDomain,
secret: generateMtprotoSecret(fakeTlsDomain),
};
}
export function createDefaultTunnelInboundSettings(): TunnelInboundSettings {
return {
portMap: {},
@@ -261,7 +299,8 @@ export type AnyInboundSettings =
| MixedInboundSettings
| TunInboundSettings
| TunnelInboundSettings
| WireguardInboundSettings;
| WireguardInboundSettings
| MtprotoInboundSettings;
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
switch (protocol) {
@@ -275,6 +314,7 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
case 'tunnel': return createDefaultTunnelInboundSettings();
case 'tun': return createDefaultTunInboundSettings();
case 'wireguard': return createDefaultWireguardInboundSettings();
case 'mtproto': return createDefaultMtprotoInboundSettings();
default: return null;
}
}
+24
View File
@@ -680,6 +680,28 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
return url.toString();
}
export interface GenMtprotoLinkInput {
inbound: Inbound;
address: string;
port?: number;
remark?: string;
}
// Builds a Telegram proxy deep link for an mtproto inbound:
// tg://proxy?server=<addr>&port=<port>&secret=<ee FakeTLS secret>.
export function genMtprotoLink(input: GenMtprotoLinkInput): string {
const { inbound, address, port = inbound.port, remark = '' } = input;
if (inbound.protocol !== 'mtproto') return '';
const secret = inbound.settings.secret ?? '';
if (secret.length === 0) return '';
const url = new URL('tg://proxy');
url.searchParams.set('server', address);
url.searchParams.set('port', String(port));
url.searchParams.set('secret', secret);
url.hash = encodeURIComponent(remark);
return url.toString();
}
export interface GenWireguardLinkInput {
settings: WireguardInboundSettings;
address: string;
@@ -867,6 +889,8 @@ export function genLink(input: GenLinkInput): string {
clientAuth: client.auth ?? '',
externalProxy,
});
case 'mtproto':
return genMtprotoLink({ inbound, address, port, remark });
default:
return '';
}
@@ -0,0 +1,34 @@
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
function defaultCertificate(): Record<string, unknown> {
return {
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
ocspStapling: 3600,
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
};
}
export function createTlsSettingsWithDefaultCert(): Record<string, unknown> {
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
tls.certificates = [defaultCertificate()];
return tls;
}
export function createHysteriaTlsSettingsWithDefaultCert(): Record<string, unknown> {
const tls = createTlsSettingsWithDefaultCert();
tls.alpn = ['h3'];
const settings = tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings)
? { ...(tls.settings as Record<string, unknown>) }
: {};
settings.fingerprint = '';
tls.settings = settings;
return tls;
}
@@ -95,6 +95,20 @@ function dropZeroNumbers(obj: Record<string, unknown>, keys: readonly string[]):
}
}
function normalizeTlsForWire(raw: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = { ...raw };
if (out.fingerprint === '') delete out.fingerprint;
const settings = out.settings;
if (isRecord(settings)) {
const settingsOut: Record<string, unknown> = { ...settings };
if (settingsOut.fingerprint === '') delete settingsOut.fingerprint;
out.settings = settingsOut;
}
return out;
}
export function normalizeXhttpForWire(
raw: Record<string, unknown>,
side: StreamWireSide,
@@ -211,6 +225,11 @@ export function normalizeStreamSettingsForWire(
out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side);
}
const tls = out.tlsSettings;
if (isRecord(tls)) {
out.tlsSettings = normalizeTlsForWire(tls);
}
const sockopt = out.sockopt;
if (isRecord(sockopt)) {
const normalized = normalizeSockoptForWire(sockopt);
+68
View File
@@ -1085,6 +1085,74 @@ export const sections: readonly Section[] = [
],
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
},
{
method: 'GET',
path: '/panel/api/xray/outbound-subs',
summary: 'List all outbound subscriptions (remote URLs that supply additional outbounds), newest first.',
},
{
method: 'POST',
path: '/panel/api/xray/outbound-subs',
summary: 'Create an outbound subscription. The URL is fetched, parsed into outbounds with stable tags, and merged additively into the running Xray config.',
params: [
{ name: 'remark', in: 'body (form)', type: 'string', desc: 'Optional display label.' },
{ name: 'url', in: 'body (form)', type: 'string', desc: 'Subscription URL (required). Must be a public http(s) address; private/internal targets are blocked unless allowPrivate is true.' },
{ name: 'tagPrefix', in: 'body (form)', type: 'string', desc: 'Prefix for generated outbound tags. Defaults to "sub<id>-".' },
{ name: 'updateInterval', in: 'body (form)', type: 'integer', desc: 'Seconds between auto-refreshes. Default 600.' },
{ name: 'enabled', in: 'body (form)', type: 'boolean', desc: 'Whether the subscription is active. Default true.' },
{ name: 'allowPrivate', in: 'body (form)', type: 'boolean', desc: 'Allow the URL to point at a private/internal/loopback address (localhost/LAN). Default false (SSRF guard blocks private targets).' },
{ name: 'prepend', in: 'body (form)', type: 'boolean', desc: 'Place this subscription\'s outbounds before the manual template outbounds (so one can become the default). Default false.' },
],
},
{
method: 'POST',
path: '/panel/api/xray/outbound-subs/:id',
summary: 'Update an existing outbound subscription by id. Accepts the same form fields as create.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
],
},
{
method: 'DELETE',
path: '/panel/api/xray/outbound-subs/:id',
summary: 'Delete an outbound subscription by id.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
],
},
{
method: 'POST',
path: '/panel/api/xray/outbound-subs/:id/del',
summary: 'Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
],
},
{
method: 'POST',
path: '/panel/api/xray/outbound-subs/:id/refresh',
summary: 'Force an immediate re-fetch of the subscription and return the parsed outbounds. Signals Xray to reload.',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
],
},
{
method: 'POST',
path: '/panel/api/xray/outbound-subs/:id/move',
summary: 'Reorder a subscription one step up or down in priority (controls its position in the merged outbounds).',
params: [
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
{ name: 'dir', in: 'body (form)', type: 'string', desc: '"up" to raise priority, anything else to lower it.' },
],
},
{
method: 'POST',
path: '/panel/api/xray/outbound-subs/parse',
summary: 'Preview a subscription URL: fetch and parse it into outbounds without persisting anything.',
params: [
{ name: 'url', in: 'body (form)', type: 'string', desc: 'Subscription URL to preview (required).' },
],
},
],
},
@@ -36,7 +36,7 @@ import { antdRule } from '@/utils/zodForm';
import { Protocols } from '@/schemas/primitives';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
import { SniffingSchema } from '@/schemas/primitives/sniffing';
import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
@@ -54,6 +54,7 @@ import {
HttpFields,
HysteriaFields,
MixedFields,
MtprotoFields,
ShadowsocksFields,
TunFields,
TunnelFields,
@@ -351,22 +352,11 @@ export default function InboundFormModal({
// snap back to TCP so the standard network selector has a valid
// starting point.
if (next === Protocols.HYSTERIA) {
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
tls.certificates = [{
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
}];
form.setFieldValue('streamSettings', {
network: 'hysteria',
security: 'tls',
hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
tlsSettings: tls,
tlsSettings: createHysteriaTlsSettingsWithDefaultCert(),
// Hysteria2 needs an obfs wrapper on the FinalMask side; seed
// it with salamander + a random password so the listener boots
// with a usable default. Re-selecting Hysteria from another
@@ -589,6 +579,8 @@ export default function InboundFormModal({
{protocol === Protocols.HTTP && <HttpFields />}
{protocol === Protocols.MIXED && <MixedFields mixedUdpOn={mixedUdpOn} />}
{protocol === Protocols.MTPROTO && <MtprotoFields />}
{protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
@@ -894,6 +886,7 @@ export default function InboundFormModal({
Protocols.TUNNEL,
Protocols.TUN,
Protocols.WIREGUARD,
Protocols.MTPROTO,
] as string[]).includes(protocol) || isFallbackHost
? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
: []),
@@ -5,4 +5,5 @@ export { default as WireguardFields } from './wireguard';
export { default as HysteriaFields } from './hysteria';
export { default as HttpFields } from './http';
export { default as MixedFields } from './mixed';
export { default as MtprotoFields } from './mtproto';
export { default as VlessFields } from './vless';
@@ -0,0 +1,40 @@
import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, Space } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults';
export default function MtprotoFields() {
const { t } = useTranslation();
const form = Form.useFormInstance();
return (
<>
<Form.Item name={['settings', 'fakeTlsDomain']} label={t('pages.inbounds.form.fakeTlsDomain')}>
<Input
placeholder="www.cloudflare.com"
onChange={(e) => {
const current = (form.getFieldValue(['settings', 'secret']) as string) ?? '';
form.setFieldValue(['settings', 'secret'], mtprotoSecretForDomain(current, e.target.value));
}}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.form.mtprotoSecret')}>
<Space.Compact block>
<Form.Item name={['settings', 'secret']} noStyle>
<Input readOnly style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={() => {
const domain = form.getFieldValue(['settings', 'fakeTlsDomain']);
form.setFieldValue(['settings', 'secret'], generateMtprotoSecret(domain as string));
}}
/>
</Space.Compact>
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
<Alert type="info" showIcon message={t('pages.inbounds.form.mtprotoHint')} />
</Form.Item>
</>
);
}
@@ -5,7 +5,7 @@ import type { MessageInstance } from 'antd/es/message/interface';
import { HttpUtil, RandomUtil } from '@/utils';
import { getRandomRealityTarget } from '@/models/reality-targets';
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
@@ -160,19 +160,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
if (next === 'tls') {
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
tls.certificates = [{
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
ocspStapling: 3600,
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
}];
cleaned.tlsSettings = tls;
cleaned.tlsSettings = createTlsSettingsWithDefaultCert();
}
if (next === 'reality') {
const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
@@ -625,6 +625,35 @@ export default function InboundInfoModal({
</dl>
)}
{inbound.protocol === Protocols.MTPROTO && inbound.settings && (
<dl className="info-list info-list-block">
<div className="info-row">
<dt>{t('pages.inbounds.form.fakeTlsDomain')}</dt>
<dd><Tag color="green" className="value-tag">{inbound.settings.fakeTlsDomain as string}</Tag></dd>
</div>
<div className="info-row">
<dt>{t('pages.inbounds.form.mtprotoSecret')}</dt>
<dd className="value-block">
<code className="value-code">{inbound.settings.secret as string}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(inbound.settings.secret as string, t)} />
</Tooltip>
</dd>
</div>
{links.length > 0 && (
<div className="info-row">
<dt>{t('pages.inbounds.copyLink')}</dt>
<dd className="value-block">
<code className="value-code">{links[0].link}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(links[0].link, t)} />
</Tooltip>
</dd>
</div>
)}
</dl>
)}
{dbInbound.isMixed && inbound.settings && (
<dl className="info-list info-list-block">
<div className="info-row">
+15
View File
@@ -60,13 +60,17 @@ export default function XrayPage() {
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
restartResult,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
@@ -99,6 +103,11 @@ export default function XrayPage() {
if (outbound) await testOutbound(idx, outbound, mode);
}
async function onTestSubscription(outbound: Record<string, unknown>, mode: string) {
const tag = typeof outbound?.tag === 'string' ? outbound.tag : '';
if (tag) await testSubscriptionOutbound(tag, outbound, mode);
}
function onAddOutbound(outbound: Record<string, unknown>) {
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
@@ -214,6 +223,7 @@ export default function XrayPage() {
setTemplateSettings={setTemplateSettings}
inboundTags={inboundTags}
clientReverseTags={clientReverseTags}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
/>
);
@@ -224,14 +234,18 @@ export default function XrayPage() {
setTemplateSettings={setTemplateSettings}
outboundsTraffic={outboundsTraffic}
outboundTestStates={outboundTestStates}
subscriptionTestStates={subscriptionTestStates}
testingAll={testingAll}
inboundTags={inboundTags}
subscriptionOutbounds={subscriptionOutbounds}
isMobile={isMobile}
onResetTraffic={resetOutboundsTraffic}
onTest={onTestOutbound}
onTestSubscription={onTestSubscription}
onTestAll={testAllOutbounds}
onShowWarp={() => setWarpOpen(true)}
onShowNord={() => setNordOpen(true)}
onRefreshXrayData={fetchAll}
/>
);
case 'balancer':
@@ -240,6 +254,7 @@ export default function XrayPage() {
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
clientReverseTags={clientReverseTags}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
/>
);
@@ -18,6 +18,7 @@ interface BalancersTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
clientReverseTags: string[];
subscriptionOutboundTags?: string[];
isMobile: boolean;
}
@@ -90,6 +91,7 @@ export default function BalancersTab({
templateSettings,
setTemplateSettings,
clientReverseTags,
subscriptionOutboundTags,
isMobile,
}: BalancersTabProps) {
const { t } = useTranslation();
@@ -118,8 +120,11 @@ export default function BalancersTab({
for (const tag of clientReverseTags || []) {
if (tag) tags.add(tag);
}
for (const tag of subscriptionOutboundTags || []) {
if (tag) tags.add(tag);
}
return [...tags];
}, [templateSettings?.outbounds, clientReverseTags]);
}, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
const otherTags = useMemo(() => {
if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);
@@ -209,3 +209,17 @@
.outbound-test-popover .dot-fail {
color: #e04141;
}
.subscription-outbounds-head {
margin-bottom: 8px;
}
.subscription-outbounds-title {
font-weight: 600;
margin-bottom: 2px;
}
.subscription-outbounds-desc {
font-size: 12px;
opacity: 0.7;
}
@@ -3,22 +3,40 @@ import { useTranslation } from 'react-i18next';
import {
Button,
Col,
Dropdown,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Radio,
Row,
Space,
Switch,
Table,
Tag,
Tooltip,
message,
} from 'antd';
import {
PlusOutlined,
CloudOutlined,
ApiOutlined,
MoreOutlined,
RetweetOutlined,
PlayCircleOutlined,
ReloadOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
CheckCircleOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import OutboundFormModal from './OutboundFormModal';
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import './OutboundsTab.css';
@@ -26,20 +44,40 @@ import './OutboundsTab.css';
import type { OutboundRow } from './outbounds-tab-types';
import { useOutboundColumns } from './useOutboundColumns';
import OutboundCardList from './OutboundCardList';
import SubscriptionOutbounds from './SubscriptionOutbounds';
interface OutboundSub {
id: number;
remark?: string;
url?: string;
enabled?: boolean;
allowPrivate?: boolean;
prepend?: boolean;
priority?: number;
tagPrefix?: string;
updateInterval?: number;
lastUpdated?: number;
lastError?: string;
outboundCount?: number;
}
interface OutboundsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
subscriptionTestStates: Record<string, OutboundTestState>;
testingAll: boolean;
inboundTags: string[];
subscriptionOutbounds?: unknown[];
isMobile: boolean;
onResetTraffic: (tag: string) => void;
onTest: (index: number, mode: string) => void;
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
onTestAll: (mode: string) => void;
onShowWarp: () => void;
onShowNord: () => void;
onRefreshXrayData?: () => void;
}
export default function OutboundsTab({
@@ -47,23 +85,49 @@ export default function OutboundsTab({
setTemplateSettings,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
inboundTags: _inboundTags,
subscriptionOutbounds,
isMobile,
onResetTraffic,
onTest,
onTestSubscription,
onTestAll,
onShowWarp,
onShowNord,
onRefreshXrayData,
}: OutboundsTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp');
const [modalOpen, setModalOpen] = useState(false);
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [existingTags, setExistingTags] = useState<string[]>([]);
// Subscription manager (CRUD + reorder + refresh + preview)
const [subDrawerOpen, setSubDrawerOpen] = useState(false);
const [subs, setSubs] = useState<OutboundSub[]>([]);
const [subsLoading, setSubsLoading] = useState(false);
const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
const [editingSubId, setEditingSubId] = useState<number | null>(null);
const [savingSub, setSavingSub] = useState(false);
const [refreshingId, setRefreshingId] = useState<number | null>(null);
const [refreshingAll, setRefreshingAll] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [previewing, setPreviewing] = useState(false);
const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(null);
// Convenience: expose hours/minutes for the interval input
const intervalHours = Math.floor((newSub.updateInterval || 600) / 3600);
const intervalMinutes = Math.floor(((newSub.updateInterval || 600) % 3600) / 60);
function setIntervalHM(h: number, m: number) {
const secs = Math.max(60, (h || 0) * 3600 + (m || 0) * 60);
setNewSub((prev) => ({ ...prev, updateInterval: secs }));
}
const outbounds = useMemo(
() => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
[templateSettings?.outbounds],
@@ -89,6 +153,11 @@ export default function OutboundsTab({
setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg));
setModalOpen(true);
}
function openSubManager() {
setSubDrawerOpen(true);
loadSubs();
}
function openEdit(idx: number) {
setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
setEditingIndex(idx);
@@ -147,6 +216,169 @@ export default function OutboundsTab({
});
}
// --- Subscription management (minimal inline UI) ---
async function loadSubs() {
setSubsLoading(true);
try {
const r = await HttpUtil.get('/panel/api/xray/outbound-subs');
if (r?.success) setSubs(Array.isArray(r.obj) ? r.obj : []);
} catch {
messageApi.error(t('pages.xray.outboundSub.toastLoadFailed'));
} finally {
setSubsLoading(false);
}
}
function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; prepend?: boolean }) {
return {
remark: src.remark ?? '',
url: src.url ?? '',
tagPrefix: src.tagPrefix ?? '',
updateInterval: src.updateInterval ?? 600,
enabled: src.enabled ?? true,
allowPrivate: src.allowPrivate ?? false,
prepend: src.prepend ?? false,
};
}
function resetSubForm() {
setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
setEditingSubId(null);
setPreviewData(null);
}
function openEditSub(sub: OutboundSub) {
setNewSub({
remark: sub.remark ?? '',
url: sub.url ?? '',
tagPrefix: sub.tagPrefix ?? '',
updateInterval: sub.updateInterval ?? 600,
enabled: sub.enabled ?? true,
allowPrivate: sub.allowPrivate ?? false,
prepend: sub.prepend ?? false,
});
setEditingSubId(sub.id);
setPreviewData(null);
}
async function saveSub() {
if (!newSub.url.trim()) {
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
return;
}
setSavingSub(true);
try {
const url = editingSubId != null
? `/panel/api/xray/outbound-subs/${editingSubId}`
: '/panel/api/xray/outbound-subs';
const r = await HttpUtil.post<OutboundSub>(url, subBody(newSub));
if (r?.success) {
messageApi.success(t(editingSubId != null ? 'pages.xray.outboundSub.toastUpdated' : 'pages.xray.outboundSub.toastAdded'));
const createdId = editingSubId == null ? r.obj?.id : undefined;
resetSubForm();
await loadSubs();
if (createdId) await refreshOne(createdId);
onRefreshXrayData?.();
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
} finally {
setSavingSub(false);
}
}
async function previewSub() {
if (!newSub.url.trim()) {
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
return;
}
setPreviewing(true);
setPreviewData(null);
try {
const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>('/panel/api/xray/outbound-subs/parse', { url: newSub.url, allowPrivate: newSub.allowPrivate });
if (r?.success && Array.isArray(r.obj)) {
setPreviewData(r.obj);
if (r.obj.length === 0) messageApi.info(t('pages.xray.outboundSub.previewEmpty'));
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.previewEmpty'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.previewEmpty'));
} finally {
setPreviewing(false);
}
}
async function toggleEnabled(sub: OutboundSub) {
setBusyId(sub.id);
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${sub.id}`, subBody({ ...sub, enabled: !sub.enabled }));
if (r?.success) {
await loadSubs();
onRefreshXrayData?.();
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
} finally {
setBusyId(null);
}
}
async function moveSub(id: number, dir: 'up' | 'down') {
setBusyId(id);
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/move`, { dir });
if (r?.success) {
await loadSubs();
onRefreshXrayData?.();
}
} catch {
/* ignore */
} finally {
setBusyId(null);
}
}
async function refreshOne(id: number) {
setRefreshingId(id);
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/refresh`);
if (r?.success) {
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
await loadSubs();
onRefreshXrayData?.();
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastRefreshFailed'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastRefreshFailed'));
} finally {
setRefreshingId(null);
}
}
async function refreshAllSubs() {
if (subs.length === 0) return;
setRefreshingAll(true);
try {
for (const s of subs) {
try { await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`); } catch { /* continue */ }
}
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
await loadSubs();
onRefreshXrayData?.();
} finally {
setRefreshingAll(false);
}
}
async function deleteOne(id: number) {
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/del`);
if (r?.success) {
messageApi.success(t('pages.xray.outboundSub.toastDeleted'));
await loadSubs();
onRefreshXrayData?.();
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastDeleteFailed'));
}
}
const columns = useOutboundColumns({
testMode,
rows,
@@ -164,6 +396,7 @@ export default function OutboundsTab({
return (
<>
{modalContextHolder}
{messageContextHolder}
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Row gutter={[12, 12]} align="middle" justify="space-between">
<Col xs={24} sm={12}>
@@ -171,12 +404,20 @@ export default function OutboundsTab({
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{!isMobile && t('pages.xray.Outbounds')}
</Button>
<Button type="primary" icon={<CloudOutlined />} onClick={onShowWarp}>
WARP
</Button>
<Button type="primary" icon={<ApiOutlined />} onClick={onShowNord}>
NordVPN
<Button icon={<CloudOutlined />} onClick={openSubManager}>
{t('pages.xray.outboundSub.manage')}
</Button>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
],
}}
>
<Button icon={<MoreOutlined />}>{t('more')}</Button>
</Dropdown>
</Space>
</Col>
<Col xs={24} sm={12} className="toolbar-right">
@@ -232,7 +473,182 @@ export default function OutboundsTab({
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
{/* Subscription outbounds (read-only, merged at runtime) */}
{Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && (
<SubscriptionOutbounds
subscriptionOutbounds={subscriptionOutbounds}
outboundsTraffic={outboundsTraffic}
subscriptionTestStates={subscriptionTestStates}
testMode={testMode}
isMobile={isMobile}
onTestSubscription={onTestSubscription}
/>
)}
</Space>
<Modal
title={t('pages.xray.outboundSub.title')}
open={subDrawerOpen}
onCancel={() => setSubDrawerOpen(false)}
footer={null}
width={isMobile ? '100%' : 640}
destroyOnHidden
>
<Space orientation="vertical" style={{ width: '100%' }} size="large">
<div>
{editingSubId != null && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<Tag color="blue">{t('edit')}</Tag>
<span style={{ fontWeight: 600 }}>{newSub.remark || newSub.url}</span>
</div>
)}
<Form layout="vertical" size="small">
<Form.Item label={t('pages.xray.outboundSub.remark')}>
<Input value={newSub.remark} onChange={(e) => setNewSub({ ...newSub, remark: e.target.value })} placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} />
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.url')} required>
<Input value={newSub.url} onChange={(e) => setNewSub({ ...newSub, url: e.target.value })} placeholder={t('pages.xray.outboundSub.urlPlaceholder')} />
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.tagPrefix')}>
<Input value={newSub.tagPrefix} onChange={(e) => setNewSub({ ...newSub, tagPrefix: e.target.value })} placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} />
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.interval')}>
<Space>
<InputNumber
min={0}
value={intervalHours}
onChange={(v) => setIntervalHM(Number(v) || 0, intervalMinutes)}
style={{ width: 80 }}
/> {t('pages.xray.outboundSub.hours')}
<InputNumber
min={0}
max={59}
value={intervalMinutes}
onChange={(v) => setIntervalHM(intervalHours, Number(v) || 0)}
style={{ width: 80 }}
/> {t('pages.xray.outboundSub.minutes')}
</Space>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.xray.outboundSub.intervalHint')}
</div>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.enabled')}>
<Switch checked={newSub.enabled} onChange={(v) => setNewSub({ ...newSub, enabled: v })} />
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.allowPrivate')}>
<Switch checked={newSub.allowPrivate} onChange={(v) => setNewSub({ ...newSub, allowPrivate: v })} />
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.xray.outboundSub.allowPrivateHint')}
</div>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.prepend')}>
<Switch checked={newSub.prepend} onChange={(v) => setNewSub({ ...newSub, prepend: v })} />
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.xray.outboundSub.prependHint')}
</div>
</Form.Item>
<Space wrap>
<Button type="primary" onClick={saveSub} loading={savingSub} icon={editingSubId != null ? <EditOutlined /> : <PlusOutlined />}>
{editingSubId != null ? t('save') : t('pages.xray.outboundSub.addButton')}
</Button>
<Button onClick={previewSub} loading={previewing} icon={<EyeOutlined />}>
{t('pages.xray.outboundSub.preview')}
</Button>
{editingSubId != null && <Button onClick={resetSubForm}>{t('cancel')}</Button>}
</Space>
{previewData && previewData.length > 0 && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>{previewData.length} · {t('pages.xray.Outbounds')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 120, overflow: 'auto' }}>
{previewData.map((o, i) => (
<Tag key={i}>{o?.tag || '—'}{o?.protocol ? ` · ${o.protocol}` : ''}</Tag>
))}
</div>
</div>
)}
</Form>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
{t('pages.xray.outboundSub.active')}
<Button size="small" icon={<ReloadOutlined />} onClick={loadSubs} loading={subsLoading} />
{subs.length > 0 && (
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={refreshAllSubs} loading={refreshingAll}>
{t('pages.xray.outboundSub.refreshAll')}
</Button>
)}
</div>
{subs.length === 0 ? (
<div style={{ color: '#888' }}>{t('pages.xray.outboundSub.empty')}</div>
) : (
<Table
size="small"
dataSource={subs}
rowKey={(r) => r.id}
pagination={false}
scroll={{ x: true }}
columns={[
{
title: '',
key: 'order',
width: 56,
render: (_: unknown, r: OutboundSub, index: number) => (
<Space size={0}>
<Button type="text" size="small" icon={<ArrowUpOutlined />} disabled={index === 0 || busyId === r.id} onClick={() => moveSub(r.id, 'up')} />
<Button type="text" size="small" icon={<ArrowDownOutlined />} disabled={index === subs.length - 1 || busyId === r.id} onClick={() => moveSub(r.id, 'down')} />
</Space>
),
},
{
title: t('pages.xray.outboundSub.colRemark'),
key: 'remark',
render: (_: unknown, r: OutboundSub) => (
<div>
<div>{r.remark || <em>{t('pages.xray.outboundSub.auto')}</em>}</div>
{r.tagPrefix && <div style={{ fontSize: 11, color: '#888' }}>{r.tagPrefix}</div>}
</div>
),
},
{ title: t('pages.xray.Outbounds'), dataIndex: 'outboundCount', key: 'outboundCount', align: 'center', render: (v) => v ?? 0 },
{
title: t('status'),
key: 'status',
align: 'center',
render: (_: unknown, r: OutboundSub) => (r.lastError
? <Tooltip title={r.lastError}><WarningOutlined style={{ color: '#e04141' }} /></Tooltip>
: <Tooltip title={t('pages.xray.outboundSub.statusOk')}><CheckCircleOutlined style={{ color: '#008771' }} /></Tooltip>),
},
{ title: t('pages.xray.outboundSub.colLastFetch'), dataIndex: 'lastUpdated', key: 'lastUpdated', render: (v: number) => v ? new Date(v * 1000).toLocaleString() : t('pages.xray.outboundSub.never') },
{
title: t('pages.xray.outboundSub.colEnabled'),
key: 'enabled',
align: 'center',
render: (_: unknown, r: OutboundSub) => <Switch size="small" checked={!!r.enabled} loading={busyId === r.id} onChange={() => toggleEnabled(r)} />,
},
{
title: '',
key: 'actions',
render: (_: unknown, r: OutboundSub) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditSub(r)} title={t('edit')} />
<Button size="small" icon={<ReloadOutlined />} loading={refreshingId === r.id} onClick={() => refreshOne(r.id)} title={t('pages.xray.outboundSub.refreshNow')} />
<Popconfirm title={t('pages.xray.outboundSub.deleteConfirm')} okText={t('delete')} cancelText={t('cancel')} onConfirm={() => deleteOne(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]}
/>
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
{t('pages.xray.outboundSub.restartHint')}
</div>
</div>
</Space>
</Modal>
</>
);
}
@@ -0,0 +1,207 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Popover, Table, Tag, Tooltip } from 'antd';
import {
ThunderboltOutlined,
CheckCircleFilled,
CloseCircleFilled,
LoadingOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import { isUdpOutbound } from '@/hooks/useXraySetting';
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import {
hasBreakdown,
isTesting,
isUntestable,
outboundAddresses,
showSecurity,
testResult,
trafficFor,
} from './outbounds-tab-helpers';
interface SubscriptionOutboundsProps {
subscriptionOutbounds: unknown[];
outboundsTraffic: OutboundTrafficRow[];
subscriptionTestStates: Record<string, OutboundTestState>;
testMode: 'tcp' | 'http';
isMobile: boolean;
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
}
// Read-only view of outbounds imported from active subscriptions. They are not
// part of the editable template (so no edit/delete/move), but traffic is matched
// by tag and they can be latency-tested via the same backend endpoint.
export default function SubscriptionOutbounds({
subscriptionOutbounds,
outboundsTraffic,
subscriptionTestStates,
testMode,
isMobile,
onTestSubscription,
}: SubscriptionOutboundsProps) {
const { t } = useTranslation();
const rows = useMemo<OutboundRow[]>(
() => (subscriptionOutbounds || []).map((o, i) => ({ ...(o as object), key: i }) as OutboundRow),
[subscriptionOutbounds],
);
if (rows.length === 0) return null;
const identityCell = (record: OutboundRow) => (
<div className="identity-cell">
<Tooltip title={record.tag}>
<span className="tag-name">{record.tag || '—'}</span>
</Tooltip>
<div className="protocol-line">
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
</>
)}
</div>
</div>
);
const addressCell = (record: OutboundRow) => {
const addrs = outboundAddresses(record);
return (
<div className="address-list">
{addrs.length === 0 ? (
<span className="empty">—</span>
) : (
addrs.map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))
)}
</div>
);
};
const trafficCell = (record: OutboundRow) => {
const tr = trafficFor(outboundsTraffic, record);
return (
<>
<span className="traffic-up">↑ {SizeFormatter.sizeFormat(tr.up)}</span>
<span className="traffic-sep" />
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(tr.down)}</span>
</>
);
};
const latencyCell = (record: OutboundRow) => {
const key = record.tag || '';
const r = testResult(subscriptionTestStates, key);
if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
return (
<Popover
placement="topLeft"
rootClassName="outbound-test-popover"
content={
<div className="timing-breakdown">
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
</div>
{hasBreakdown(r) && (
<>
{(r.endpoints || []).map((ep) => (
<div key={ep.address} className="endpoint-row">
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
<span className="ep-addr">{ep.address}</span>
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
</div>
))}
</>
)}
</div>
}
>
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
</Popover>
);
};
const testButton = (record: OutboundRow) => {
const key = record.tag || '';
return (
<Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
<Button
type="primary"
shape="circle"
size={isMobile ? 'small' : undefined}
loading={isTesting(subscriptionTestStates, key)}
disabled={!record.tag || isUntestable(record, testMode) || isTesting(subscriptionTestStates, key)}
icon={<ThunderboltOutlined />}
onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
/>
</Tooltip>
);
};
const header = (
<div className="subscription-outbounds-head">
<div className="subscription-outbounds-title">{t('pages.xray.outboundSub.fromSubsTitle')}</div>
<div className="subscription-outbounds-desc">{t('pages.xray.outboundSub.fromSubsDesc')}</div>
</div>
);
if (isMobile) {
return (
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
{header}
{rows.map((record, index) => (
<div key={record.key} className="outbound-card">
<div className="card-head">
<div className="card-identity">
<span className="card-num">{index + 1}</span>
{identityCell(record)}
</div>
{testButton(record)}
</div>
{outboundAddresses(record).length > 0 && addressCell(record)}
<div className="card-foot">
{trafficCell(record)}
<span className="card-test">{latencyCell(record)}</span>
</div>
</div>
))}
</div>
);
}
const columns: ColumnsType<OutboundRow> = [
{
title: '#',
key: 'num',
align: 'center',
width: 60,
render: (_v, _record, index) => <span className="row-index">{index + 1}</span>,
},
{ title: t('pages.xray.outbound.tag'), key: 'identity', align: 'left', render: (_v, record) => identityCell(record) },
{ title: t('pages.inbounds.address'), key: 'address', align: 'left', render: (_v, record) => addressCell(record) },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200, render: (_v, record) => trafficCell(record) },
{ title: t('pages.nodes.latency'), key: 'testResult', align: 'left', width: 140, render: (_v, record) => latencyCell(record) },
{ title: t('check'), key: 'test', align: 'center', width: 80, render: (_v, record) => testButton(record) },
];
return (
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
{header}
<Table columns={columns} dataSource={rows} rowKey={(r) => r.key} pagination={false} size="small" />
</div>
);
}
@@ -53,10 +53,10 @@ export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRo
return { up: tr?.up || 0, down: tr?.down || 0 };
}
export function isTesting(states: Record<number, OutboundTestState>, idx: number): boolean {
export function isTesting<K extends string | number>(states: Record<K, OutboundTestState>, idx: K): boolean {
return !!states?.[idx]?.testing;
}
export function testResult(states: Record<number, OutboundTestState>, idx: number) {
export function testResult<K extends string | number>(states: Record<K, OutboundTestState>, idx: K) {
return states?.[idx]?.result || null;
}
@@ -20,6 +20,7 @@ interface RoutingTabProps {
setTemplateSettings: SetTemplate;
inboundTags: string[];
clientReverseTags: string[];
subscriptionOutboundTags?: string[];
isMobile: boolean;
}
@@ -28,6 +29,7 @@ export default function RoutingTab({
setTemplateSettings,
inboundTags,
clientReverseTags,
subscriptionOutboundTags,
isMobile,
}: RoutingTabProps) {
const { t } = useTranslation();
@@ -116,8 +118,11 @@ export default function RoutingTab({
for (const tag of clientReverseTags || []) {
if (tag) out.add(tag);
}
for (const tag of subscriptionOutboundTags || []) {
if (tag) out.add(tag);
}
return [...out];
}, [templateSettings?.outbounds, clientReverseTags]);
}, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
const balancerTagOptions = useMemo(() => {
const out: string[] = [''];
@@ -11,6 +11,7 @@ export const ProtocolSchema = z.enum([
'mixed',
'tunnel',
'tun',
'mtproto',
]);
export type Protocol = z.infer<typeof ProtocolSchema>;
@@ -31,4 +32,5 @@ export const Protocols = Object.freeze({
MIXED: 'mixed',
TUNNEL: 'tunnel',
TUN: 'tun',
MTPROTO: 'mtproto',
});
@@ -3,6 +3,7 @@ import { z } from 'zod';
import { HttpInboundSettingsSchema } from './http';
import { HysteriaInboundSettingsSchema } from './hysteria';
import { MixedInboundSettingsSchema } from './mixed';
import { MtprotoInboundSettingsSchema } from './mtproto';
import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
import { TrojanInboundSettingsSchema } from './trojan';
import { TunInboundSettingsSchema } from './tun';
@@ -14,6 +15,7 @@ import { WireguardInboundSettingsSchema } from './wireguard';
export * from './http';
export * from './hysteria';
export * from './mixed';
export * from './mtproto';
export * from './shadowsocks';
export * from './trojan';
export * from './tun';
@@ -38,5 +40,6 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
z.object({ protocol: z.literal('mixed'), settings: MixedInboundSettingsSchema }),
z.object({ protocol: z.literal('tunnel'), settings: TunnelInboundSettingsSchema }),
z.object({ protocol: z.literal('tun'), settings: TunInboundSettingsSchema }),
z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }),
]);
export type InboundSettings = z.infer<typeof InboundSettingsSchema>;
@@ -0,0 +1,10 @@
import { z } from 'zod';
// MTProto (Telegram) inbound. Served by an mtg sidecar process, not Xray, so
// it has no clients and no stream settings. `secret` is the FakeTLS secret
// (ee-prefixed); the backend rebuilds it to match `fakeTlsDomain` on save.
export const MtprotoInboundSettingsSchema = z.object({
fakeTlsDomain: z.string().default('www.cloudflare.com'),
secret: z.string().default(''),
});
export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>;
@@ -22,6 +22,9 @@ export const UtlsFingerprintSchema = z.enum([
]);
export type UtlsFingerprint = z.infer<typeof UtlsFingerprintSchema>;
export const TlsFingerprintSchema = z.union([UtlsFingerprintSchema, z.literal('')]);
export type TlsFingerprint = z.infer<typeof TlsFingerprintSchema>;
export const AlpnSchema = z.enum(['h3', 'h2', 'http/1.1']);
export type Alpn = z.infer<typeof AlpnSchema>;
@@ -51,7 +54,7 @@ export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]);
export type TlsCert = z.infer<typeof TlsCertSchema>;
export const TlsClientSettingsSchema = z.object({
fingerprint: UtlsFingerprintSchema.default('chrome'),
fingerprint: TlsFingerprintSchema.default('chrome'),
echConfigList: z.string().default(''),
pinnedPeerCertSha256: z.array(z.string()).default([]),
});
+5
View File
@@ -40,6 +40,11 @@ export const XrayConfigPayloadSchema = z.object({
inboundTags: z.array(z.string()).optional(),
clientReverseTags: z.array(z.string()).optional(),
outboundTestUrl: z.string().optional(),
// Subscription outbounds are injected at runtime (not persisted in xraySetting).
// They are provided here so the UI can display them and use their tags in
// balancers / routing rules.
subscriptionOutbounds: z.array(z.unknown()).optional(),
subscriptionOutboundTags: z.array(z.string()).optional(),
}).loose();
export const OutboundTrafficRowSchema = z.object({
@@ -504,6 +504,174 @@ exports[`protocol capability predicates > mixed-basic :: xhttp/tls 1`] = `
}
`;
exports[`protocol capability predicates > mtproto-basic :: grpc/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: grpc/reality 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: grpc/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: httpupgrade/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: httpupgrade/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: kcp/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: tcp/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: tcp/reality 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: tcp/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: ws/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: ws/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: xhttp/none 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: xhttp/reality 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > mtproto-basic :: xhttp/tls 1`] = `
{
"canEnableReality": false,
"canEnableStream": false,
"canEnableTls": false,
"canEnableTlsFlow": false,
"canEnableVisionSeed": false,
"isSS2022": false,
"isSSMultiUser": true,
}
`;
exports[`protocol capability predicates > shadowsocks-2022 :: grpc/none 1`] = `
{
"canEnableReality": false,
@@ -59,6 +59,16 @@ exports[`InboundSettingsSchema fixtures > parses mixed-basic byte-stably 1`] = `
}
`;
exports[`InboundSettingsSchema fixtures > parses mtproto-basic byte-stably 1`] = `
{
"protocol": "mtproto",
"settings": {
"fakeTlsDomain": "www.cloudflare.com",
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
},
}
`;
exports[`InboundSettingsSchema fixtures > parses shadowsocks-2022 byte-stably 1`] = `
{
"protocol": "shadowsocks",
@@ -0,0 +1,7 @@
{
"protocol": "mtproto",
"settings": {
"fakeTlsDomain": "www.cloudflare.com",
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d"
}
}
@@ -16,6 +16,7 @@ import {
createDefaultVmessInboundSettings,
createDefaultWireguardInboundSettings,
} from '@/lib/xray/inbound-defaults';
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
import { HttpInboundSettingsSchema } from '@/schemas/protocols/inbound/http';
import { HysteriaClientSchema, HysteriaInboundSettingsSchema } from '@/schemas/protocols/inbound/hysteria';
import { MixedInboundSettingsSchema } from '@/schemas/protocols/inbound/mixed';
@@ -147,3 +148,18 @@ describe('createDefault*InboundSettings factories', () => {
expect(WireguardInboundSettingsSchema.parse(s)).toEqual(s);
});
});
describe('createHysteriaTlsSettingsWithDefaultCert', () => {
it('defaults Hysteria TLS to uTLS None and h3 ALPN', () => {
const tls = createHysteriaTlsSettingsWithDefaultCert();
expect(tls.alpn).toEqual(['h3']);
expect((tls.settings as Record<string, unknown>).fingerprint).toBe('');
expect(tls.certificates).toEqual([
expect.objectContaining({
useFile: true,
certificateFile: '',
keyFile: '',
}),
]);
});
});
@@ -9,6 +9,7 @@ import {
normalizeXhttpForWire,
validateRealityTarget,
} from '@/lib/xray/stream-wire-normalize';
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
describe('validateRealityTarget', () => {
@@ -150,6 +151,28 @@ describe('normalizeStreamSettingsForWire reality', () => {
});
});
describe('normalizeStreamSettingsForWire tls', () => {
it('drops empty uTLS fingerprints from inbound and outbound TLS shapes', () => {
const out = normalizeStreamSettingsForWire({
network: 'hysteria',
security: 'tls',
tlsSettings: {
fingerprint: '',
settings: {
fingerprint: '',
echConfigList: '',
},
},
}, { side: 'inbound' });
const tls = out.tlsSettings as Record<string, unknown>;
const settings = tls.settings as Record<string, unknown>;
expect(tls).not.toHaveProperty('fingerprint');
expect(settings).not.toHaveProperty('fingerprint');
expect(settings.echConfigList).toBe('');
});
});
describe('inbound formValuesToWirePayload integration', () => {
it('emits lean stream-one xhttp + sockopt on save', () => {
const values = {
@@ -209,6 +232,51 @@ describe('inbound formValuesToWirePayload integration', () => {
const realitySettings = reality.settings as Record<string, unknown>;
expect(realitySettings.publicKey).toBe('pub');
});
it('accepts Hysteria TLS with uTLS None and omits fingerprint on save', () => {
const values = {
remark: 'hy2',
enable: true,
port: 443,
listen: '',
tag: 'hy2-443',
expiryTime: 0,
sniffing: { enabled: false },
up: 0,
down: 0,
total: 0,
trafficReset: 'never',
lastTrafficResetTime: 0,
nodeId: null,
protocol: 'hysteria',
settings: { version: 2, clients: [] },
streamSettings: {
network: 'hysteria',
security: 'tls',
hysteriaSettings: {
version: 2,
auth: 'auth',
udpIdleTimeout: 60,
},
tlsSettings: {
alpn: ['h3'],
settings: {
fingerprint: '',
},
},
},
};
const parsed = InboundFormSchema.safeParse(values);
expect(parsed.success).toBe(true);
if (!parsed.success) throw parsed.error;
const payload = formValuesToWirePayload(parsed.data);
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
const tls = stream.tlsSettings as Record<string, unknown>;
const settings = tls.settings as Record<string, unknown>;
expect(settings).not.toHaveProperty('fingerprint');
});
});
describe('freedom outbound sockopt wire payload', () => {
+9
View File
@@ -1192,8 +1192,17 @@ install_x-ui() {
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
mv bin/xray-linux-$(arch) bin/xray-linux-arm
chmod +x bin/xray-linux-arm
if [[ -f bin/mtg-linux-$(arch) ]]; then
mv bin/mtg-linux-$(arch) bin/mtg-linux-arm
chmod +x bin/mtg-linux-arm
fi
fi
chmod +x x-ui bin/xray-linux-$(arch)
if [[ -f bin/mtg-linux-arm ]]; then
chmod +x bin/mtg-linux-arm
elif [[ -f bin/mtg-linux-$(arch) ]]; then
chmod +x bin/mtg-linux-$(arch)
fi
# Update x-ui cli and se set permission
mv -f /usr/bin/x-ui-temp /usr/bin/x-ui
+348
View File
@@ -0,0 +1,348 @@
package mtproto
import (
"bufio"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
)
// Instance is the desired runtime configuration of one mtproto inbound.
type Instance struct {
Id int
Tag string
Listen string
Port int
Secret string
}
func (inst Instance) bindTo() string {
listen := inst.Listen
if listen == "" {
listen = "0.0.0.0"
}
return fmt.Sprintf("%s:%d", listen, inst.Port)
}
func (inst Instance) fingerprint() string {
return fmt.Sprintf("%s|%s", inst.bindTo(), inst.Secret)
}
// Traffic is a per-inbound traffic delta scraped from an mtg metrics endpoint.
type Traffic struct {
Tag string
Up int64
Down int64
}
type managed struct {
proc *Process
tag string
fingerprint string
metricsPort int
lastUp int64
lastDown int64
haveLast bool
}
// Manager owns the set of running mtg processes keyed by inbound id.
type Manager struct {
mu sync.Mutex
procs map[int]*managed
}
var (
managerOnce sync.Once
manager *Manager
)
// GetManager returns the process-wide mtg manager singleton.
func GetManager() *Manager {
managerOnce.Do(func() {
manager = &Manager{procs: map[int]*managed{}}
})
return manager
}
// InstanceFromInbound derives a desired Instance from an mtproto inbound,
// healing the FakeTLS secret so it always matches the configured domain.
// Returns false when the inbound is not a usable mtproto inbound.
func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
if ib == nil || ib.Protocol != model.MTProto {
return Instance{}, false
}
settings := ib.Settings
if healed, ok := model.HealMtprotoSecret(settings); ok {
settings = healed
}
var parsed struct {
Secret string `json:"secret"`
}
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return Instance{}, false
}
if parsed.Secret == "" {
return Instance{}, false
}
return Instance{
Id: ib.Id,
Tag: ib.Tag,
Listen: ib.Listen,
Port: ib.Port,
Secret: parsed.Secret,
}, true
}
// Ensure starts the mtg process for an instance, or restarts it when its
// configuration changed. A no-op when the desired process is already running.
func (m *Manager) Ensure(inst Instance) error {
m.mu.Lock()
defer m.mu.Unlock()
return m.ensureLocked(inst)
}
func (m *Manager) ensureLocked(inst Instance) error {
fp := inst.fingerprint()
if cur, ok := m.procs[inst.Id]; ok {
if cur.fingerprint == fp && cur.proc.IsRunning() {
cur.tag = inst.Tag
return nil
}
cur.proc.Stop()
delete(m.procs, inst.Id)
}
metricsPort, err := freeLocalPort()
if err != nil {
return err
}
cfgPath := configPathForID(inst.Id)
if err := writeConfig(cfgPath, inst.Secret, inst.bindTo(), metricsPort); err != nil {
return err
}
proc := newProcess(cfgPath)
if err := proc.Start(); err != nil {
return err
}
m.procs[inst.Id] = &managed{
proc: proc,
tag: inst.Tag,
fingerprint: fp,
metricsPort: metricsPort,
}
logger.Info("mtproto: started mtg for inbound", inst.Id, "on", inst.bindTo())
return nil
}
// Remove stops and forgets the mtg process for an inbound id.
func (m *Manager) Remove(id int) {
m.mu.Lock()
defer m.mu.Unlock()
if cur, ok := m.procs[id]; ok {
cur.proc.Stop()
delete(m.procs, id)
_ = os.Remove(configPathForID(id))
logger.Info("mtproto: stopped mtg for inbound", id)
}
}
// Reconcile drives the running set toward the desired instances: it stops
// processes that are no longer wanted and (re)starts the rest. Used at boot
// and periodically to recover from crashes.
func (m *Manager) Reconcile(desired []Instance) {
m.mu.Lock()
defer m.mu.Unlock()
want := make(map[int]struct{}, len(desired))
for _, inst := range desired {
want[inst.Id] = struct{}{}
}
for id, cur := range m.procs {
if _, ok := want[id]; !ok {
cur.proc.Stop()
delete(m.procs, id)
_ = os.Remove(configPathForID(id))
}
}
for _, inst := range desired {
if err := m.ensureLocked(inst); err != nil {
logger.Warning("mtproto: reconcile failed for inbound", inst.Id, ":", err)
}
}
}
// StopAll stops every managed mtg process. Called on panel shutdown.
func (m *Manager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for id, cur := range m.procs {
_ = cur.proc.Stop()
_ = os.Remove(configPathForID(id))
delete(m.procs, id)
}
}
// CollectTraffic scrapes each running mtg metrics endpoint and returns the
// per-inbound byte deltas since the previous scrape.
func (m *Manager) CollectTraffic() []Traffic {
// Snapshot the state we need under the lock, then release before doing
// network I/O so that Ensure/Reconcile/Remove are not blocked.
type snap struct {
id int
metricsPort int
tag string
haveLast bool
lastUp int64
lastDown int64
}
m.mu.Lock()
snaps := make([]snap, 0, len(m.procs))
for id, cur := range m.procs {
if cur.proc == nil || !cur.proc.IsRunning() {
continue
}
snaps = append(snaps, snap{
id: id,
metricsPort: cur.metricsPort,
tag: cur.tag,
haveLast: cur.haveLast,
lastUp: cur.lastUp,
lastDown: cur.lastDown,
})
}
m.mu.Unlock()
out := make([]Traffic, 0, len(snaps))
for _, s := range snaps {
up, down, ok := scrapeTraffic(s.metricsPort)
if !ok {
continue
}
var du, dd int64
if s.haveLast {
du = up - s.lastUp
dd = down - s.lastDown
if du < 0 {
du = 0
}
if dd < 0 {
dd = 0
}
}
// Re-acquire lock to persist the new baseline, but only if the entry
// still exists (it may have been removed during the scrape).
m.mu.Lock()
if cur, ok := m.procs[s.id]; ok {
cur.lastUp = up
cur.lastDown = down
cur.haveLast = true
}
m.mu.Unlock()
if s.haveLast && (du > 0 || dd > 0) {
out = append(out, Traffic{Tag: s.tag, Up: du, Down: dd})
}
}
return out
}
func freeLocalPort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
func writeConfig(path, secret, bindTo string, metricsPort int) error {
if err := os.MkdirAll(configDir(), 0o750); err != nil {
return err
}
content := fmt.Sprintf("secret = %q\nbind-to = %q\n\n[stats.prometheus]\nenabled = true\nbind-to = \"127.0.0.1:%d\"\nhttp-path = \"/metrics\"\nmetric-prefix = \"mtg\"\n",
secret, bindTo, metricsPort)
return os.WriteFile(path, []byte(content), 0o640)
}
// scrapeTraffic reads the mtg Prometheus metrics endpoint and sums byte
// counters by direction. mtg exposes a traffic counter labelled with a
// direction; "to_telegram" is treated as upload and "to_client" as download.
// Best-effort: an unreachable endpoint or unrecognised format yields ok=false.
func scrapeTraffic(port int) (up int64, down int64, ok bool) {
client := http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/metrics", port))
if err != nil {
return 0, 0, false
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
found := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' || !strings.Contains(line, "traffic") {
continue
}
name, labels, value, perr := parseMetricLine(line)
if perr != nil || !strings.HasPrefix(name, "mtg") {
continue
}
switch labels["direction"] {
case "to_telegram", "egress", "up":
up += int64(value)
case "to_client", "ingress", "down":
down += int64(value)
default:
down += int64(value)
}
found = true
}
if err := scanner.Err(); err != nil {
logger.Debug("mtproto: metrics scan error:", err)
}
return up, down, found
}
func parseMetricLine(line string) (name string, labels map[string]string, value float64, err error) {
labels = map[string]string{}
rest := line
if brace := strings.IndexByte(line, '{'); brace >= 0 {
name = line[:brace]
end := strings.IndexByte(line, '}')
if end < brace {
return "", nil, 0, fmt.Errorf("malformed metric line")
}
for _, kv := range strings.Split(line[brace+1:end], ",") {
eq := strings.IndexByte(kv, '=')
if eq < 0 {
continue
}
labels[strings.TrimSpace(kv[:eq])] = strings.Trim(strings.TrimSpace(kv[eq+1:]), `"`)
}
rest = strings.TrimSpace(line[end+1:])
} else {
fields := strings.Fields(line)
if len(fields) < 2 {
return "", nil, 0, fmt.Errorf("malformed metric line")
}
name = fields[0]
rest = fields[1]
}
valFields := strings.Fields(rest)
if len(valFields) == 0 {
return "", nil, 0, fmt.Errorf("missing metric value")
}
value, err = strconv.ParseFloat(valFields[0], 64)
if err != nil {
return "", nil, 0, err
}
return name, labels, value, nil
}
+56
View File
@@ -0,0 +1,56 @@
package mtproto
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
func TestParseMetricLine(t *testing.T) {
name, labels, val, err := parseMetricLine(`mtg_traffic{direction="to_client"} 12345`)
if err != nil {
t.Fatal(err)
}
if name != "mtg_traffic" {
t.Fatalf("name=%q", name)
}
if labels["direction"] != "to_client" {
t.Fatalf("labels=%v", labels)
}
if val != 12345 {
t.Fatalf("val=%v", val)
}
name2, _, val2, err2 := parseMetricLine(`mtg_concurrency 7`)
if err2 != nil {
t.Fatal(err2)
}
if name2 != "mtg_concurrency" || val2 != 7 {
t.Fatalf("got %q %v", name2, val2)
}
}
func TestInstanceFromInbound(t *testing.T) {
ib := &model.Inbound{
Id: 3,
Tag: "inbound-3",
Listen: "0.0.0.0",
Port: 8443,
Protocol: model.MTProto,
Settings: `{"fakeTlsDomain":"example.com","secret":""}`,
}
inst, ok := InstanceFromInbound(ib)
if !ok {
t.Fatal("expected a usable instance")
}
if inst.Secret == "" {
t.Fatal("secret should be healed to a non-empty value")
}
if inst.Port != 8443 || inst.Id != 3 {
t.Fatalf("bad instance %+v", inst)
}
if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
t.Fatal("non-mtproto inbound should not produce an instance")
}
}
+201
View File
@@ -0,0 +1,201 @@
// Package mtproto manages mtg (github.com/9seconds/mtg) sidecar processes that
// serve MTProto FakeTLS proxies. Xray-core has no mtproto protocol, so mtproto
// inbounds are run as standalone mtg processes — one process per inbound —
// entirely outside the Xray config and lifecycle.
package mtproto
import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/config"
"github.com/mhsanaei/3x-ui/v3/logger"
)
// GetBinaryName returns the mtg binary filename for the current OS and arch,
// matching the naming scheme used for the Xray binary. On Windows the ".exe"
// extension is appended so a natural "mtg-windows-amd64.exe" is found.
func GetBinaryName() string {
name := fmt.Sprintf("mtg-%s-%s", runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" {
name += ".exe"
}
return name
}
// GetBinaryPath returns the full path to the mtg binary, alongside the Xray binary.
func GetBinaryPath() string {
return config.GetBinFolderPath() + "/" + GetBinaryName()
}
func configDir() string {
return config.GetBinFolderPath() + "/mtproto"
}
func configPathForID(id int) string {
return fmt.Sprintf("%s/mtg-%d.toml", configDir(), id)
}
var (
gracefulStopTimeout = 5 * time.Second
forceStopTimeout = 2 * time.Second
)
type lastLineWriter struct {
mu sync.Mutex
lastLine string
}
func (w *lastLineWriter) Write(p []byte) (int, error) {
line := strings.TrimSpace(string(p))
if line != "" {
w.mu.Lock()
w.lastLine = line
w.mu.Unlock()
}
return len(p), nil
}
func (w *lastLineWriter) LastLine() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.lastLine
}
// Process wraps a single mtg process invocation for one mtproto inbound.
type Process struct {
cmd *exec.Cmd
done chan struct{}
configPath string
logWriter *lastLineWriter
exitErr error
intentionalStop atomic.Bool
}
func newProcess(configPath string) *Process {
return &Process{
configPath: configPath,
logWriter: &lastLineWriter{},
}
}
// IsRunning reports whether the mtg process is currently running.
func (p *Process) IsRunning() bool {
if p.cmd == nil || p.cmd.Process == nil {
return false
}
if p.done != nil {
select {
case <-p.done:
return false
default:
}
}
if p.cmd.ProcessState == nil {
return true
}
return false
}
// GetResult returns the last log line or the exit error from the mtg process.
func (p *Process) GetResult() string {
if line := p.logWriter.LastLine(); line != "" {
return line
}
if p.exitErr != nil {
return p.exitErr.Error()
}
return ""
}
// Start launches the mtg process against its generated config file.
func (p *Process) Start() error {
if p.IsRunning() {
return errors.New("mtg is already running")
}
cmd := exec.Command(GetBinaryPath(), "run", p.configPath)
cmd.Stdout = p.logWriter
cmd.Stderr = p.logWriter
p.cmd = cmd
p.done = make(chan struct{})
p.exitErr = nil
p.intentionalStop.Store(false)
if err := cmd.Start(); err != nil {
close(p.done)
p.cmd = nil
return err
}
attachChildLifetime(cmd)
go p.wait(cmd)
return nil
}
func (p *Process) wait(cmd *exec.Cmd) {
defer close(p.done)
err := cmd.Wait()
if err == nil || p.intentionalStop.Load() {
return
}
if runtime.GOOS == "windows" {
if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
p.exitErr = err
return
}
}
logger.Error("mtproto: mtg process exited:", err)
p.exitErr = err
}
// Stop terminates the running mtg process gracefully, falling back to a kill.
func (p *Process) Stop() error {
if !p.IsRunning() {
return errors.New("mtg is not running")
}
p.intentionalStop.Store(true)
if runtime.GOOS == "windows" {
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return p.waitForExit(forceStopTimeout)
}
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
if errors.Is(err, os.ErrProcessDone) {
return p.waitForExit(forceStopTimeout)
}
return err
}
if err := p.waitForExit(gracefulStopTimeout); err == nil {
return nil
}
logger.Warning("mtproto: mtg did not stop after SIGTERM, killing process")
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
return err
}
return p.waitForExit(forceStopTimeout)
}
func (p *Process) waitForExit(timeout time.Duration) error {
if p.done == nil {
return nil
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-p.done:
return nil
case <-timer.C:
return fmt.Errorf("timed out waiting for mtg process to stop after %s", timeout)
}
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package mtproto
import "os/exec"
func attachChildLifetime(_ *exec.Cmd) {}
+66
View File
@@ -0,0 +1,66 @@
//go:build windows
package mtproto
import (
"os/exec"
"sync"
"unsafe"
"github.com/mhsanaei/3x-ui/v3/logger"
"golang.org/x/sys/windows"
)
var (
killOnExitJobOnce sync.Once
killOnExitJob windows.Handle
killOnExitJobErr error
)
func ensureKillOnExitJob() (windows.Handle, error) {
killOnExitJobOnce.Do(func() {
h, err := windows.CreateJobObject(nil, nil)
if err != nil {
killOnExitJobErr = err
return
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
}
_, err = windows.SetInformationJobObject(
h,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
)
if err != nil {
windows.CloseHandle(h)
killOnExitJobErr = err
return
}
killOnExitJob = h
})
return killOnExitJob, killOnExitJobErr
}
func attachChildLifetime(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
job, err := ensureKillOnExitJob()
if err != nil {
logger.Warning("mtproto: kill-on-exit job unavailable:", err)
return
}
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
if err != nil {
logger.Warning("mtproto: OpenProcess for job attach failed:", err)
return
}
defer windows.CloseHandle(h)
if err := windows.AssignProcessToJobObject(job, h); err != nil {
logger.Warning("mtproto: AssignProcessToJobObject failed:", err)
}
}
+5 -2
View File
@@ -221,8 +221,11 @@ func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client
}
var inboundSettings map[string]any
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
if encryption, ok := inboundSettings["encryption"].(string); ok && encryption != "" {
proxy["packet-encoding"] = encryption
if encryption, ok := inboundSettings["encryption"].(string); ok {
encryption = strings.TrimSpace(encryption)
if encryption != "" && encryption != "none" {
proxy["encryption"] = encryption
}
}
case model.Trojan:
proxy["type"] = "trojan"
+59
View File
@@ -3,6 +3,8 @@ package sub
import (
"reflect"
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
func TestEnsureUniqueProxyNames(t *testing.T) {
@@ -113,3 +115,60 @@ func TestApplyTransport_HTTPUpgrade(t *testing.T) {
t.Fatalf("headers.Host = %v, want example.com", headers["Host"])
}
}
func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testing.T) {
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
encryption := "mlkem768x25519plus.native.0rtt.client"
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "pq",
Settings: `{"encryption":"` + encryption + `"}`,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
stream := map[string]any{
"network": "xhttp",
"xhttpSettings": map[string]any{
"path": "/",
"mode": "auto",
},
"security": "reality",
"realitySettings": map[string]any{
"publicKey": "pub",
"serverName": "example.com",
"shortId": "abcd",
},
}
proxy := svc.buildProxy(inbound, client, stream, "")
if proxy["encryption"] != encryption {
t.Fatalf("encryption = %v, want %q", proxy["encryption"], encryption)
}
}
func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "plain",
Settings: `{"encryption":"none"}`,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
stream := map[string]any{
"network": "tcp",
"security": "none",
"tcpSettings": map[string]any{
"header": map[string]any{"type": "none"},
},
}
proxy := svc.buildProxy(inbound, client, stream, "")
if _, ok := proxy["encryption"]; ok {
t.Fatalf("plain vless encryption should be omitted for mihomo: %#v", proxy)
}
}
+32 -3
View File
@@ -374,10 +374,38 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
return s.genShadowsocksLink(inbound, email)
case "hysteria":
return s.genHysteriaLink(inbound, email)
case "mtproto":
return s.genMtprotoLink(inbound, email)
}
return ""
}
// genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound:
// tg://proxy?server=<addr>&port=<port>&secret=<ee FakeTLS secret>.
func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string {
if inbound.Protocol != model.MTProto {
return ""
}
settings := map[string]any{}
json.Unmarshal([]byte(inbound.Settings), &settings)
secret, _ := settings["secret"].(string)
if secret == "" {
if healed, ok := model.HealMtprotoSecret(inbound.Settings); ok {
_ = json.Unmarshal([]byte(healed), &settings)
secret, _ = settings["secret"].(string)
}
}
if secret == "" {
return ""
}
params := map[string]string{
"server": s.resolveInboundAddress(inbound),
"port": fmt.Sprintf("%d", inbound.Port),
"secret": secret,
}
return buildLinkWithParams("tg://proxy", params, s.genRemark(inbound, email, ""))
}
// Protocol link generators are intentionally ordered as:
// vmess -> vless -> trojan -> shadowsocks -> hysteria.
func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
@@ -743,9 +771,10 @@ func (s *SubService) loadNodes() {
}
// resolveInboundAddress picks the host an external client should connect to:
// 1. node-managed inbound -> the node's address
// 2. an explicit, client-reachable bind Listen -> that Listen
// 3. otherwise the subscriber's request host (s.address)
// 1. node-managed inbound -> the node's address
// 2. an explicit, client-reachable bind Listen -> that Listen
// 3. otherwise the subscriber's request host (s.address)
//
// A loopback/wildcard bind or a unix-domain-socket listen is a server-side
// detail and is never advertised; External Proxy remains the way to advertise
// an arbitrary endpoint. Mirrors the frontend's resolveAddr so the panel QR and
+809
View File
@@ -0,0 +1,809 @@
// Package link provides parsers for VPN share links (vmess://, vless://, etc.)
// and subscription bodies (typically base64-encoded newline lists of such links).
// The output shape matches the wire format used by the panel's Xray template
// outbounds array so that parsed objects can be injected directly.
package link
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
)
// Outbound is the minimal shape we emit for each parsed link.
// Extra fields (mux, etc.) are carried inside settings/streamSettings.
type Outbound map[string]any
// ParseResult holds a parsed outbound together with a stable identity string
// that can be used to correlate the same logical server across refreshes
// (even if the remark changes).
type ParseResult struct {
Outbound Outbound
Identity string
}
// ParseSubscriptionBody accepts the raw body returned by a subscription URL.
// It handles the common case where the body is a base64-encoded blob of
// newline-separated links, and also tolerates an already-decoded text body.
// It returns the list of successfully parsed outbounds (in order) and their
// corresponding identities.
func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
text := strings.TrimSpace(string(body))
if text == "" {
return nil, nil, nil
}
// Try base64 decode first (standard and URL-safe variants).
if decoded, ok := tryBase64(text); ok {
text = strings.TrimSpace(decoded)
}
lines := splitLines(text)
var outbounds []Outbound
var identities []string
for _, ln := range lines {
ln = strings.TrimSpace(ln)
if ln == "" || strings.HasPrefix(ln, "#") {
continue
}
res, err := ParseLink(ln)
if err != nil || res == nil {
// Ignore unparseable lines (comments, unsupported protocols, etc.)
continue
}
outbounds = append(outbounds, res.Outbound)
identities = append(identities, res.Identity)
}
return outbounds, identities, nil
}
func tryBase64(s string) (string, bool) {
// Remove whitespace that some providers insert.
clean := strings.Map(func(r rune) rune {
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
return -1
}
return r
}, s)
// Common padding fix
for len(clean)%4 != 0 {
clean += "="
}
// Standard
if b, err := base64.StdEncoding.DecodeString(clean); err == nil {
return string(b), true
}
// URL-safe (no padding)
if b, err := base64.RawURLEncoding.DecodeString(clean); err == nil {
return string(b), true
}
// URL-safe with padding
if b, err := base64.URLEncoding.DecodeString(clean); err == nil {
return string(b), true
}
return "", false
}
func splitLines(s string) []string {
// Accept \n, \r\n, and also some providers use literal \n in the text.
s = strings.ReplaceAll(s, `\n`, "\n")
return strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == '\r' })
}
// ParseLink parses a single share link and returns the outbound object plus
// a stable identity for tag correlation. Supported schemes:
// - vmess://
// - vless://
// - trojan://
// - ss:// (modern and legacy)
// - hysteria2:// (also hy2://)
// - wireguard:// (also wg://)
func ParseLink(link string) (*ParseResult, error) {
link = strings.TrimSpace(link)
switch {
case strings.HasPrefix(link, "vmess://"):
return parseVmess(link)
case strings.HasPrefix(link, "vless://"):
return parseVless(link)
case strings.HasPrefix(link, "trojan://"):
return parseTrojan(link)
case strings.HasPrefix(link, "ss://"):
return parseShadowsocks(link)
case strings.HasPrefix(link, "hysteria2://"), strings.HasPrefix(link, "hy2://"):
return parseHysteria2(link)
case strings.HasPrefix(link, "wireguard://"), strings.HasPrefix(link, "wg://"):
return parseWireguard(link)
default:
return nil, fmt.Errorf("unsupported link scheme")
}
}
// --- vmess ---
func parseVmess(link string) (*ParseResult, error) {
b64 := strings.TrimPrefix(link, "vmess://")
// vmess:// base64(json)
raw, err := base64.StdEncoding.DecodeString(padBase64(b64))
if err != nil {
// Some providers use raw URL-safe
raw, err = base64.RawURLEncoding.DecodeString(b64)
}
if err != nil {
return nil, fmt.Errorf("vmess decode: %w", err)
}
var j map[string]any
if err := json.Unmarshal(raw, &j); err != nil {
return nil, fmt.Errorf("vmess json: %w", err)
}
identity := vmessIdentity(j)
network := getString(j, "net", "tcp")
security := "none"
if tls, _ := j["tls"].(string); tls == "tls" {
security = "tls"
}
stream := buildStream(network, security)
// Map known fields (best effort, matching frontend parser coverage)
switch network {
case "ws":
if host, ok := j["host"].(string); ok {
setWS(stream, host, getString(j, "path", "/"))
}
case "grpc":
svc := getString(j, "path", "")
if auth, ok := j["authority"].(string); ok && auth != "" {
(stream["grpcSettings"].(map[string]any))["authority"] = auth
}
(stream["grpcSettings"].(map[string]any))["serviceName"] = svc
(stream["grpcSettings"].(map[string]any))["multiMode"] = getString(j, "type", "") == "multi"
case "httpupgrade":
setHTTPUpgrade(stream, getString(j, "host", ""), getString(j, "path", "/"))
case "xhttp":
xh := stream["xhttpSettings"].(map[string]any)
xh["host"] = getString(j, "host", "")
xh["path"] = getString(j, "path", "/")
if m := getString(j, "mode", ""); m != "" {
xh["mode"] = m
}
// xhttp advanced keys are passed through if present in the json
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} {
if v, ok := j[k]; ok {
xh[k] = v
}
}
case "tcp":
if getString(j, "type", "") == "http" {
stream["tcpSettings"] = map[string]any{
"header": map[string]any{
"type": "http",
"request": map[string]any{
"version": "1.1",
"method": "GET",
"path": splitComma(getString(j, "path", "/")),
"headers": map[string]any{"Host": splitComma(getString(j, "host", ""))},
},
},
}
}
}
if security == "tls" {
tls := stream["tlsSettings"].(map[string]any)
tls["serverName"] = getString(j, "sni", "")
tls["fingerprint"] = getString(j, "fp", "")
if alpn := getString(j, "alpn", ""); alpn != "" {
tls["alpn"] = splitComma(alpn)
}
}
port := num(j["port"])
ob := Outbound{
"protocol": "vmess",
"tag": getString(j, "ps", ""),
"settings": map[string]any{
"vnext": []any{
map[string]any{
"address": getString(j, "add", ""),
"port": port,
"users": []any{
map[string]any{
"id": getString(j, "id", ""),
"security": getString(j, "scy", "auto"),
},
},
},
},
},
"streamSettings": stream,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
func vmessIdentity(j map[string]any) string {
// Remove ps (remark) for identity
core := map[string]any{}
for k, v := range j {
if k == "ps" {
continue
}
core[k] = v
}
b, _ := json.Marshal(core)
return "vmess:" + string(b)
}
// --- vless / trojan (URL forms) ---
func parseVless(link string) (*ParseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
if u.Scheme != "vless" {
return nil, fmt.Errorf("not vless")
}
id := u.User.Username()
host := u.Hostname()
port := defaultPort(u.Port(), 443)
params := u.Query()
network := params.Get("type")
if network == "" {
network = "tcp"
}
security := params.Get("security")
if security == "" {
security = "none"
}
stream := buildStream(network, security)
applyTransport(stream, params)
applySecurity(stream, params)
applyFinalMask(stream, params)
identity := "vless:" + u.Scheme + "://" + id + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
ob := Outbound{
"protocol": "vless",
"tag": decodeHash(u.Fragment),
"settings": map[string]any{
"address": host,
"port": port,
"id": id,
"flow": params.Get("flow"),
"encryption": firstNonEmpty(params.Get("encryption"), "none"),
},
"streamSettings": stream,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
func parseTrojan(link string) (*ParseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
if u.Scheme != "trojan" {
return nil, fmt.Errorf("not trojan")
}
pw := u.User.Username()
host := u.Hostname()
port := defaultPort(u.Port(), 443)
params := u.Query()
network := params.Get("type")
if network == "" {
network = "tcp"
}
security := params.Get("security")
if security == "" {
security = "tls"
}
stream := buildStream(network, security)
applyTransport(stream, params)
applySecurity(stream, params)
applyFinalMask(stream, params)
identity := "trojan:" + u.Scheme + "://" + pw + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
ob := Outbound{
"protocol": "trojan",
"tag": decodeHash(u.Fragment),
"settings": map[string]any{
"servers": []any{
map[string]any{"address": host, "port": port, "password": pw},
},
},
"streamSettings": stream,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// --- shadowsocks ---
func parseShadowsocks(link string) (*ParseResult, error) {
// Two shapes:
// ss://base64(method:pass)@host:port#remark
// ss://base64(method:pass@host:port)#remark
remark := ""
if i := strings.Index(link, "#"); i >= 0 {
remark, _ = url.QueryUnescape(link[i+1:])
link = link[:i]
}
core := strings.TrimPrefix(link, "ss://")
at := strings.Index(core, "@")
if at >= 0 {
// modern
userB64 := core[:at]
hp := core[at+1:]
userInfo, err := base64DecodeFlexible(userB64)
if err != nil {
userInfo = userB64 // not b64, rare
}
colon := strings.LastIndex(hp, ":")
if colon < 0 {
return nil, fmt.Errorf("bad ss host:port")
}
host := hp[:colon]
port, _ := strconv.Atoi(hp[colon+1:])
method, pass := splitMethodPass(userInfo)
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
ob := Outbound{
"protocol": "shadowsocks",
"tag": remark,
"settings": map[string]any{
"servers": []any{
map[string]any{"address": host, "port": port, "password": pass, "method": method},
},
},
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// legacy: whole thing b64
dec, err := base64DecodeFlexible(core)
if err != nil {
return nil, err
}
at = strings.Index(dec, "@")
if at < 0 {
return nil, fmt.Errorf("bad legacy ss")
}
userInfo := dec[:at]
hp := dec[at+1:]
colon := strings.LastIndex(hp, ":")
if colon < 0 {
return nil, fmt.Errorf("bad legacy ss hp")
}
host := hp[:colon]
port, _ := strconv.Atoi(hp[colon+1:])
method, pass := splitMethodPass(userInfo)
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
ob := Outbound{
"protocol": "shadowsocks",
"tag": remark,
"settings": map[string]any{
"servers": []any{
map[string]any{"address": host, "port": port, "password": pass, "method": method},
},
},
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
func splitMethodPass(userInfo string) (string, string) {
colon := strings.Index(userInfo, ":")
if colon < 0 {
return "2022-blake3-aes-128-gcm", userInfo // guess
}
return userInfo[:colon], userInfo[colon+1:]
}
// --- hysteria2 ---
func parseHysteria2(link string) (*ParseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
if u.Scheme != "hysteria2" && u.Scheme != "hy2" {
return nil, fmt.Errorf("not hysteria2")
}
auth := u.User.Username()
host := u.Hostname()
port := defaultPort(u.Port(), 443)
params := u.Query()
stream := map[string]any{
"network": "hysteria",
"security": "tls",
"hysteriaSettings": map[string]any{
"version": 2,
"auth": auth,
"udpIdleTimeout": 60,
},
"tlsSettings": map[string]any{
"serverName": params.Get("sni"),
"alpn": splitCommaOrDefault(params.Get("alpn"), []string{"h3"}),
"fingerprint": params.Get("fp"),
"echConfigList": params.Get("ech"),
"verifyPeerCertByName": "",
"pinnedPeerCertSha256": params.Get("pinSHA256"),
},
}
applyFinalMask(stream, params)
identity := "hysteria2:" + auth + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
ob := Outbound{
"protocol": "hysteria",
"tag": decodeHash(u.Fragment),
"settings": map[string]any{"address": host, "port": port, "version": 2},
"streamSettings": stream,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// --- wireguard ---
func parseWireguard(link string) (*ParseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, err
}
if u.Scheme != "wireguard" && u.Scheme != "wg" {
return nil, fmt.Errorf("not wireguard")
}
secret, _ := url.QueryUnescape(u.User.Username())
params := u.Query()
host := u.Hostname()
portStr := u.Port()
endpoint := host
if portStr != "" {
endpoint = host + ":" + portStr
}
addrRaw := firstParam(params, "address", "ip")
allowedRaw := firstParam(params, "allowedips", "allowed_ips")
addrs := splitComma(addrRaw)
if len(addrs) == 0 {
addrs = []string{"0.0.0.0/0", "::/0"}
}
allowed := splitComma(allowedRaw)
if len(allowed) == 0 {
allowed = []string{"0.0.0.0/0", "::/0"}
}
peer := map[string]any{
"publicKey": firstParam(params, "publickey", "publicKey", "public_key", "peerPublicKey"),
"endpoint": endpoint,
"allowedIPs": allowed,
}
if psk := firstParam(params, "presharedkey", "preshared_key", "pre-shared-key", "psk"); psk != "" {
peer["preSharedKey"] = psk
}
if ka := firstParam(params, "keepalive", "persistentkeepalive", "persistent_keepalive"); ka != "" {
if n, err := strconv.Atoi(ka); err == nil {
peer["keepAlive"] = n
}
}
settings := map[string]any{
"secretKey": secret,
"address": addrs,
"peers": []any{peer},
}
if mtu := params.Get("mtu"); mtu != "" {
if n, err := strconv.Atoi(mtu); err == nil {
settings["mtu"] = n
}
}
if res := params.Get("reserved"); res != "" {
parts := splitComma(res)
var iv []int
for _, p := range parts {
if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
iv = append(iv, n)
}
}
if len(iv) > 0 {
settings["reserved"] = iv
}
}
identity := "wireguard:" + secret + "@" + endpoint + "?" + canonicalQuery(params)
ob := Outbound{
"protocol": "wireguard",
"tag": decodeHash(u.Fragment),
"settings": settings,
}
return &ParseResult{Outbound: ob, Identity: identity}, nil
}
// --- helpers ---
func buildStream(network, security string) map[string]any {
stream := map[string]any{"network": network, "security": security}
switch network {
case "tcp":
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
case "kcp":
stream["kcpSettings"] = map[string]any{
"mtu": 1350, "tti": 20, "uplinkCapacity": 5, "downlinkCapacity": 20,
"cwndMultiplier": 1, "maxSendingWindow": 2097152,
}
case "ws":
stream["wsSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}, "heartbeatPeriod": 0}
case "grpc":
stream["grpcSettings"] = map[string]any{"serviceName": "", "authority": "", "multiMode": false}
case "httpupgrade":
stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
case "xhttp":
stream["xhttpSettings"] = map[string]any{
"path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
"xPaddingBytes": "100-1000", "scMaxEachPostBytes": "1000000",
}
default:
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
}
if security == "tls" {
stream["tlsSettings"] = map[string]any{
"serverName": "", "alpn": []any{}, "fingerprint": "",
"echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
}
} else if security == "reality" {
stream["realitySettings"] = map[string]any{
"publicKey": "", "fingerprint": "chrome", "serverName": "",
"shortId": "", "spiderX": "", "mldsa65Verify": "",
}
}
return stream
}
func setWS(stream map[string]any, host, path string) {
ws := stream["wsSettings"].(map[string]any)
ws["host"] = host
ws["path"] = path
}
func setHTTPUpgrade(stream map[string]any, host, path string) {
h := stream["httpupgradeSettings"].(map[string]any)
h["host"] = host
h["path"] = path
}
func applyTransport(stream map[string]any, p url.Values) {
net := stream["network"].(string)
host := p.Get("host")
path := firstNonEmpty(p.Get("path"), "/")
switch net {
case "ws":
setWS(stream, host, path)
case "grpc":
gs := stream["grpcSettings"].(map[string]any)
gs["serviceName"] = firstNonEmpty(p.Get("serviceName"), p.Get("path"))
gs["authority"] = p.Get("authority")
gs["multiMode"] = p.Get("mode") == "multi"
case "httpupgrade":
setHTTPUpgrade(stream, host, path)
case "xhttp":
xh := stream["xhttpSettings"].(map[string]any)
xh["host"] = host
xh["path"] = path
if m := p.Get("mode"); m != "" {
xh["mode"] = m
}
// A few advanced xhttp fields that are commonly carried
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"} {
if v := p.Get(k); v != "" {
xh[k] = v
}
}
case "tcp":
if p.Get("headerType") == "http" || p.Get("type") == "http" {
stream["tcpSettings"] = map[string]any{
"header": map[string]any{
"type": "http",
"request": map[string]any{
"version": "1.1",
"method": "GET",
"path": splitComma(path),
"headers": map[string]any{"Host": splitComma(host)},
},
},
}
}
}
}
func applySecurity(stream map[string]any, p url.Values) {
sec := stream["security"].(string)
if sec == "tls" {
tls := stream["tlsSettings"].(map[string]any)
tls["serverName"] = p.Get("sni")
tls["fingerprint"] = p.Get("fp")
if alpn := p.Get("alpn"); alpn != "" {
tls["alpn"] = splitComma(alpn)
}
tls["echConfigList"] = p.Get("ech")
tls["pinnedPeerCertSha256"] = p.Get("pcs")
} else if sec == "reality" {
re := stream["realitySettings"].(map[string]any)
re["serverName"] = p.Get("sni")
re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome")
re["publicKey"] = p.Get("pbk")
re["shortId"] = p.Get("sid")
re["spiderX"] = p.Get("spx")
re["mldsa65Verify"] = p.Get("pqv")
}
}
func applyFinalMask(stream map[string]any, p url.Values) {
if fm := p.Get("fm"); fm != "" {
var parsed any
if json.Unmarshal([]byte(fm), &parsed) == nil {
stream["finalmask"] = parsed
}
}
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
func firstParam(p url.Values, keys ...string) string {
for _, k := range keys {
if v := p.Get(k); v != "" {
return v
}
}
return ""
}
func canonicalQuery(p url.Values) string {
// Sort keys for stable identity
keys := make([]string, 0, len(p))
for k := range p {
keys = append(keys, k)
}
// simple sort
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[j] < keys[i] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
parts := make([]string, 0, len(keys))
for _, k := range keys {
for _, v := range p[k] {
parts = append(parts, k+"="+v)
}
}
return strings.Join(parts, "&")
}
func decodeHash(h string) string {
if h == "" {
return ""
}
if dec, err := url.QueryUnescape(h); err == nil {
return dec
}
return h
}
func defaultPort(p string, def int) int {
if p == "" {
return def
}
n, err := strconv.Atoi(p)
if err != nil || n <= 0 {
return def
}
return n
}
func num(v any) int {
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
case int64:
return int(x)
case string:
n, _ := strconv.Atoi(x)
return n
}
return 0
}
func getString(m map[string]any, key, def string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return def
}
func splitComma(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func splitCommaOrDefault(s string, def []string) []string {
if s == "" {
return def
}
return splitComma(s)
}
func padBase64(s string) string {
for len(s)%4 != 0 {
s += "="
}
return s
}
func base64DecodeFlexible(s string) (string, error) {
s = padBase64(s)
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
return string(b), nil
}
if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")); err == nil {
return string(b), nil
}
return "", fmt.Errorf("base64 decode failed")
}
// SlugRemark turns a free-form remark into a conservative DNS-ish tag segment.
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
func SlugRemark(remark string) string {
s := strings.ToLower(strings.TrimSpace(remark))
s = slugRe.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if s == "" {
return ""
}
// collapse runs of dashes
for strings.Contains(s, "--") {
s = strings.ReplaceAll(s, "--", "-")
}
return s
}
// SuggestTag builds a tag from a prefix and a remark (or index fallback).
// It is intended for initial assignment; stability is handled by the service layer.
func SuggestTag(prefix, remark string, idx int) string {
base := SlugRemark(remark)
if base == "" {
base = fmt.Sprintf("%d", idx)
}
p := strings.TrimSuffix(prefix, "-")
if p != "" {
return p + "-" + base
}
return base
}
+62
View File
@@ -0,0 +1,62 @@
package link
import (
"strings"
"testing"
)
func TestParseVmessLink(t *testing.T) {
// vmess:// + base64 of:
// {"v":"2","ps":"test","add":"1.2.3.4","port":443,"id":"uuid","aid":"0","net":"ws","type":"","host":"ex.com","path":"/","tls":"tls"}
link := "vmess://eyJ2IjoiMiIsInBzIjoidGVzdCIsImFkZCI6IjEuMi4zLjQiLCJwb3J0Ijo0NDMsImlkIjoidXVpZCIsImFpZCI6IjAiLCJuZXQiOiJ3cyIsInR5cGUiOiIiLCJob3N0IjoiZXguY29tIiwicGF0aCI6Ii8iLCJ0bHMiOiJ0bHMifQ=="
res, err := ParseLink(link)
if err != nil {
t.Fatalf("parse vmess: %v", err)
}
if res.Outbound["protocol"] != "vmess" {
t.Errorf("expected vmess protocol, got %v", res.Outbound["protocol"])
}
if res.Outbound["tag"] != "test" {
t.Errorf("expected tag 'test', got %v", res.Outbound["tag"])
}
}
func TestParseVlessLink(t *testing.T) {
link := "vless://uuid@1.2.3.4:443?type=ws&security=tls&path=/&host=ex.com#node1"
res, err := ParseLink(link)
if err != nil {
t.Fatalf("parse vless: %v", err)
}
if res.Outbound["protocol"] != "vless" {
t.Fatalf("bad protocol")
}
if res.Outbound["tag"] != "node1" {
t.Errorf("tag mismatch: %v", res.Outbound["tag"])
}
}
func TestParseSubscriptionBody_Base64(t *testing.T) {
// base64 of the two joined links:
// vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B
b64 := "dmxlc3M6Ly91QGg6NDQzP3R5cGU9dGNwI0EKdmxlc3M6Ly91MkBoMjo0NDM/dHlwZT10Y3AjQg=="
obs, ids, err := ParseSubscriptionBody([]byte(b64))
if err != nil {
t.Fatalf("parse sub body: %v", err)
}
if len(obs) != 2 {
t.Fatalf("expected 2 outbounds, got %d", len(obs))
}
if !strings.HasPrefix(ids[0], "vless:") || !strings.HasPrefix(ids[1], "vless:") {
t.Errorf("bad identities: %v", ids)
}
}
func TestSlugAndSuggest(t *testing.T) {
if SlugRemark("Hello World!") != "hello-world" {
t.Errorf("slug failed")
}
tag := SuggestTag("hk-", " SG 01 !! ", 0)
if tag != "hk-sg-01" {
t.Errorf("suggest tag got %q", tag)
}
}
+173 -4
View File
@@ -2,6 +2,7 @@ package controller
import (
"encoding/json"
"fmt"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/web/service"
@@ -14,10 +15,11 @@ type XraySettingController struct {
XraySettingService service.XraySettingService
SettingService service.SettingService
InboundService service.InboundService
OutboundService service.OutboundService
XrayService service.XrayService
WarpService service.WarpService
NordService service.NordService
OutboundService service.OutboundService
XrayService service.XrayService
WarpService service.WarpService
NordService service.NordService
OutboundSubscriptionService service.OutboundSubscriptionService
}
// NewXraySettingController creates a new XraySettingController and initializes its routes.
@@ -40,6 +42,16 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g.POST("/update", a.updateSetting)
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
g.POST("/testOutbound", a.testOutbound)
// Outbound subscription (remote outbound lists)
g.GET("/outbound-subs", a.listOutboundSubs)
g.POST("/outbound-subs", a.createOutboundSub)
g.POST("/outbound-subs/:id/refresh", a.refreshOutboundSub)
g.POST("/outbound-subs/:id/move", a.moveOutboundSub)
g.POST("/outbound-subs/:id", a.updateOutboundSub)
g.DELETE("/outbound-subs/:id", a.deleteOutboundSub)
g.POST("/outbound-subs/:id/del", a.deleteOutboundSub) // axios-friendly alias
g.POST("/outbound-subs/parse", a.parseOutboundSubURL) // preview without saving
}
// getXraySetting retrieves the Xray configuration template, inbound tags, and outbound test URL.
@@ -85,6 +97,17 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
"clientReverseTags": json.RawMessage(clientReverseTags),
"outboundTestUrl": outboundTestUrl,
}
// Surface subscription outbounds (and their tags) so the frontend can:
// - show them as read-only items in the Outbounds tab
// - let users pick them in balancers and routing rules
// These are not part of the editable template; they are injected at runtime.
if subObs, err := a.OutboundSubscriptionService.AllActiveOutbounds(); err == nil && len(subObs) > 0 {
xrayResponse["subscriptionOutbounds"] = subObs
}
if subTags, err := a.OutboundSubscriptionService.AllActiveOutboundTags(); err == nil && len(subTags) > 0 {
xrayResponse["subscriptionOutboundTags"] = subTags
}
result, err := json.Marshal(xrayResponse)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
@@ -227,3 +250,149 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
jsonObj(c, result, nil)
}
// --- Outbound Subscription handlers ---
func (a *XraySettingController) listOutboundSubs(c *gin.Context) {
list, err := a.OutboundSubscriptionService.List()
if err != nil {
jsonMsg(c, "Failed to list outbound subscriptions", err)
return
}
jsonObj(c, list, nil)
}
func (a *XraySettingController) createOutboundSub(c *gin.Context) {
remark := c.PostForm("remark")
rawURL := c.PostForm("url")
prefix := c.PostForm("tagPrefix")
enabled := c.PostForm("enabled") != "false"
allowPrivate := c.PostForm("allowPrivate") == "true"
prepend := c.PostForm("prepend") == "true"
intervalStr := c.PostForm("updateInterval")
interval := 600
if intervalStr != "" {
if v, err := parseIntSafe(intervalStr); err == nil && v > 0 {
interval = v
}
}
sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, enabled, interval, allowPrivate, prepend)
if err != nil {
jsonMsg(c, "Failed to create outbound subscription", err)
return
}
jsonObj(c, sub, nil)
}
func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
id := c.Param("id")
var subID int
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
jsonMsg(c, "Invalid id", err)
return
}
remark := c.PostForm("remark")
rawURL := c.PostForm("url")
prefix := c.PostForm("tagPrefix")
enabled := c.PostForm("enabled") != "false"
allowPrivate := c.PostForm("allowPrivate") == "true"
prepend := c.PostForm("prepend") == "true"
intervalStr := c.PostForm("updateInterval")
interval := 600
if intervalStr != "" {
if v, err := parseIntSafe(intervalStr); err == nil && v > 0 {
interval = v
}
}
if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, enabled, interval, allowPrivate, prepend); err != nil {
jsonMsg(c, "Failed to update outbound subscription", err)
return
}
jsonObj(c, "", nil)
}
func (a *XraySettingController) deleteOutboundSub(c *gin.Context) {
id := c.Param("id")
var subID int
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
jsonMsg(c, "Invalid id", err)
return
}
if err := a.OutboundSubscriptionService.Delete(subID); err != nil {
jsonMsg(c, "Failed to delete outbound subscription", err)
return
}
// Signal that xray should drop this subscription's outbounds on next reload.
a.XrayService.SetToNeedRestart()
jsonObj(c, "", nil)
}
func (a *XraySettingController) refreshOutboundSub(c *gin.Context) {
id := c.Param("id")
var subID int
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
jsonMsg(c, "Invalid id", err)
return
}
obs, err := a.OutboundSubscriptionService.Refresh(subID)
if err != nil {
jsonMsg(c, "Refresh failed", err)
return
}
// Signal that xray should pick up the new outbounds on next restart/reload
a.XrayService.SetToNeedRestart()
jsonObj(c, obs, nil)
}
func (a *XraySettingController) moveOutboundSub(c *gin.Context) {
id := c.Param("id")
var subID int
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
jsonMsg(c, "Invalid id", err)
return
}
up := c.PostForm("dir") == "up"
if err := a.OutboundSubscriptionService.Move(subID, up); err != nil {
jsonMsg(c, "Failed to reorder outbound subscription", err)
return
}
// Order affects the merged outbounds, so xray needs a reload.
a.XrayService.SetToNeedRestart()
jsonObj(c, "", nil)
}
// parseOutboundSubURL is a preview endpoint: it fetches + parses the provided
// URL but does not persist anything. Useful for the "add subscription" flow
// so the user can see the resulting outbounds (and assigned tags) before saving.
func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) {
rawURL := c.PostForm("url")
if rawURL == "" {
jsonMsg(c, "url is required", common.NewError("missing url"))
return
}
allowPrivate := c.PostForm("allowPrivate") == "true"
// Use a throw-away service instance; it only needs the settingService for proxy.
svc := service.OutboundSubscriptionService{}
// We don't have a direct "fetch once" that returns without storing, so we
// temporarily create a disabled row, refresh it, then delete. Cleaner would
// be to expose a pure ParseURL on the service, but this keeps the surface small.
tmp, err := svc.Create("preview", rawURL, "", false, 600, allowPrivate, false)
if err != nil {
jsonMsg(c, "Failed to preview subscription", err)
return
}
obs, err := svc.Refresh(tmp.Id)
// best-effort cleanup
_ = svc.Delete(tmp.Id)
if err != nil {
jsonMsg(c, "Failed to fetch/parse subscription", err)
return
}
jsonObj(c, obs, nil)
}
func parseIntSafe(s string) (int, error) {
var v int
_, err := fmt.Sscanf(s, "%d", &v)
return v, err
}
+62
View File
@@ -0,0 +1,62 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/mtproto"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/mhsanaei/3x-ui/v3/xray"
)
// MtprotoJob reconciles the running mtg sidecar processes against the enabled
// mtproto inbounds in the database, restarts any that crashed, and folds the
// per-inbound traffic scraped from each mtg metrics endpoint into the usual
// inbound traffic accounting.
type MtprotoJob struct {
inboundService service.InboundService
}
// NewMtprotoJob creates a new mtproto reconcile/traffic job instance.
func NewMtprotoJob() *MtprotoJob {
return new(MtprotoJob)
}
// Run reconciles desired mtproto inbounds with running mtg processes and
// records traffic deltas.
func (j *MtprotoJob) Run() {
inbounds, err := j.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("mtproto job: get inbounds failed:", err)
return
}
var desired []mtproto.Instance
for _, ib := range inbounds {
if ib.Protocol != model.MTProto || !ib.Enable || ib.NodeID != nil {
continue
}
if inst, ok := mtproto.InstanceFromInbound(ib); ok {
desired = append(desired, inst)
}
}
mgr := mtproto.GetManager()
mgr.Reconcile(desired)
deltas := mgr.CollectTraffic()
if len(deltas) == 0 {
return
}
traffics := make([]*xray.Traffic, 0, len(deltas))
for _, d := range deltas {
traffics = append(traffics, &xray.Traffic{
IsInbound: true,
Tag: d.Tag,
Up: d.Up,
Down: d.Down,
})
}
if _, _, err := j.inboundService.AddTraffic(traffics, nil); err != nil {
logger.Warning("mtproto job: add traffic failed:", err)
}
}
+48
View File
@@ -0,0 +1,48 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/mhsanaei/3x-ui/v3/web/websocket"
)
// OutboundSubscriptionJob periodically re-fetches enabled outbound subscriptions,
// updates the stored outbounds (with stable tags), and signals that xray
// should be reloaded so the new outbounds take effect.
type OutboundSubscriptionJob struct {
subService *service.OutboundSubscriptionService
xraySvc *service.XrayService
}
// NewOutboundSubscriptionJob creates the job (zero-value services are populated
// on first Run via method calls, same pattern as other jobs).
func NewOutboundSubscriptionJob() *OutboundSubscriptionJob {
return &OutboundSubscriptionJob{
subService: &service.OutboundSubscriptionService{},
xraySvc: &service.XrayService{},
}
}
// Run is invoked by the cron scheduler.
func (j *OutboundSubscriptionJob) Run() {
if j.subService == nil {
j.subService = &service.OutboundSubscriptionService{}
}
if j.xraySvc == nil {
j.xraySvc = &service.XrayService{}
}
count, err := j.subService.RefreshAllEnabled()
if err != nil {
logger.Warning("outbound subscription auto-update error:", err)
return
}
if count > 0 {
logger.Infof("Refreshed %d outbound subscription(s)", count)
// Ask the xray manager to restart/reload on the next 30s check.
j.xraySvc.SetToNeedRestart()
// Also broadcast an invalidate so the UI can refresh the xray setting
// view (new outbounds will be visible after the reload cycle).
websocket.BroadcastInvalidate(websocket.MessageTypeOutbounds)
}
}
+18
View File
@@ -8,6 +8,7 @@ import (
"sync"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/mtproto"
"github.com/mhsanaei/3x-ui/v3/xray"
)
@@ -44,6 +45,13 @@ func (l *Local) withAPI(fn func(api *xray.XrayAPI) error) error {
}
func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
if ib.Protocol == model.MTProto {
inst, ok := mtproto.InstanceFromInbound(ib)
if !ok {
return nil
}
return mtproto.GetManager().Ensure(inst)
}
body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ")
if err != nil {
return err
@@ -54,6 +62,10 @@ func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
}
func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
if ib.Protocol == model.MTProto {
mtproto.GetManager().Remove(ib.Id)
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.DelInbound(ib.Tag)
})
@@ -68,12 +80,18 @@ func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound)
}
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
if ib.Protocol == model.MTProto {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.AddUser(string(ib.Protocol), ib.Tag, userMap)
})
}
func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
if ib.Protocol == model.MTProto {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.RemoveUser(ib.Tag, email)
})
+4 -1
View File
@@ -101,7 +101,10 @@ func TestAllAPIsPostgresScale(t *testing.T) {
run("GetInboundsSlim", func() error { _, err := inboundSvc.GetInboundsSlim(userId); return err })
run("GetInboundDetail", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
run("GetInboundOptions", func() error { _, err := inboundSvc.GetInboundOptions(userId); return err })
run("ListPaged", func() error { _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25}); return err })
run("ListPaged", func() error {
_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25})
return err
})
run("ListPaged+search", func() error {
_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25, Search: "user-0012345"})
return err
+55 -1
View File
@@ -621,6 +621,17 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
}
}
// normalizeMtprotoSecret rebuilds an mtproto inbound's FakeTLS secret so it is
// always valid and matches the configured domain before the row is persisted.
func (s *InboundService) normalizeMtprotoSecret(inbound *model.Inbound) {
if inbound.Protocol != model.MTProto {
return
}
if healed, ok := model.HealMtprotoSecret(inbound.Settings); ok {
inbound.Settings = healed
}
}
// AddInbound creates a new inbound configuration.
// It validates port uniqueness, client email uniqueness, and required fields,
// then saves the inbound to the database and optionally adds it to the running Xray instance.
@@ -628,6 +639,7 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
s.normalizeMtprotoSecret(inbound)
conflict, err := s.checkPortConflict(inbound, 0)
if err != nil {
@@ -943,6 +955,7 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
s.normalizeMtprotoSecret(inbound)
conflict, err := s.checkPortConflict(inbound, inbound.Id)
if err != nil {
@@ -1828,7 +1841,14 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
// from the node arriving after a central disable would otherwise
// overwrite enable=false back to true, letting the client accumulate
// far more traffic than their limit before being disabled again.
enableExpr := "enable AND ?"
//
// We use a dialect-aware expression (see database.ClientTrafficEnableMergeExpr)
// because the old "enable AND ?" form (and naive CASE with :: casts)
// caused type mismatches on PostgreSQL after public API inbound updates
// (which go through updateClientTraffics + SyncInbound and can touch
// client_traffics rows) and would also break on SQLite due to PG-only
// ::boolean syntax.
enableExpr := database.ClientTrafficEnableMergeExpr()
if err := tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
@@ -3543,6 +3563,40 @@ func (s *InboundService) MigrationRequirements() {
}
}
// Normalize "enable" columns to boolean on Postgres. Legacy SQLite data
// (0/1 integers), partial migrations, or mixed write paths (public API
// inbound updates that flow through UpdateClientStat + client syncs, plus
// node traffic merge deltas) can leave the column as integer or with mixed
// interpretation. This (combined with the dialect-aware
// ClientTrafficEnableMergeExpr) prevents type problems in the node traffic
// sync merge (SetRemoteTraffic) and makes the sync robust even when
// inbounds are updated via the public API (incl. ones carrying
// externalProxy in streamSettings). The same expression is also safe on
// SQLite (no PG :: casts).
if database.IsPostgres() {
// Use DO block so it is idempotent and doesn't fail if already boolean.
normalizeBool := func(table, col string) {
tx.Exec(fmt.Sprintf(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '%s' AND column_name = '%s'
AND data_type <> 'boolean'
) THEN
ALTER TABLE %s ALTER COLUMN %s
TYPE boolean USING (CASE WHEN %s::text IN ('1','true','t','yes') THEN true ELSE false END);
END IF;
END $$;`, table, col, table, col, col))
}
normalizeBool("inbounds", "enable")
normalizeBool("client_traffics", "enable")
normalizeBool("nodes", "enable")
normalizeBool("clients", "enable")
normalizeBool("api_tokens", "enabled")
normalizeBool("outbound_subscriptions", "enabled")
}
// Fix inbounds based problems
var inbounds []*model.Inbound
err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan", "shadowsocks", "hysteria"}).Find(&inbounds).Error
+540
View File
@@ -0,0 +1,540 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/util/link"
)
// OutboundSubscriptionService manages remote outbound subscriptions.
type OutboundSubscriptionService struct {
settingService SettingService
}
// NewOutboundSubscriptionService returns a service for managing outbound subscriptions.
func NewOutboundSubscriptionService() *OutboundSubscriptionService {
return &OutboundSubscriptionService{}
}
// List returns all subscriptions (newest first).
func (s *OutboundSubscriptionService) List() ([]*model.OutboundSubscription, error) {
db := database.GetDB()
var subs []*model.OutboundSubscription
if err := db.Model(&model.OutboundSubscription{}).Order("priority asc, id asc").Find(&subs).Error; err != nil {
return nil, err
}
for _, sub := range subs {
sub.OutboundCount = countOutbounds(sub.LastFetchedOutbounds)
// Don't ship the heavy raw blobs to the list view.
sub.LastFetchedOutbounds = ""
sub.LinkIdentities = ""
}
return subs, nil
}
// countOutbounds returns the number of outbounds in a stored LastFetchedOutbounds
// JSON array (0 for empty/invalid).
func countOutbounds(raw string) int {
if strings.TrimSpace(raw) == "" {
return 0
}
var arr []any
if json.Unmarshal([]byte(raw), &arr) != nil {
return 0
}
return len(arr)
}
// Get returns a single subscription by id.
func (s *OutboundSubscriptionService) Get(id int) (*model.OutboundSubscription, error) {
db := database.GetDB()
var sub model.OutboundSubscription
if err := db.First(&sub, id).Error; err != nil {
return nil, err
}
return &sub, nil
}
// Create persists a new subscription. It does not fetch immediately; the caller
// can call Refresh on the returned id if desired.
var defaultPrefixRe = regexp.MustCompile(`^sub(\d+)-$`)
// defaultPrefixNumber returns the smallest positive integer N that is not already
// in use as a "subN-" tag prefix among the given subscriptions. This is used to
// auto-name a subscription's outbounds when the user leaves the prefix blank, so
// deleting a subscription frees its number for reuse instead of letting the
// number grow forever with the auto-increment DB id. A subscription with a blank
// prefix reserves its own id (it falls back to id-based "sub<id>-" tags).
func defaultPrefixNumber(subs []*model.OutboundSubscription, excludeId int) int {
used := map[int]bool{}
for _, sub := range subs {
if sub.Id == excludeId {
continue
}
if sub.TagPrefix == "" {
used[sub.Id] = true
continue
}
if m := defaultPrefixRe.FindStringSubmatch(sub.TagPrefix); m != nil {
if n, err := strconv.Atoi(m[1]); err == nil {
used[n] = true
}
}
}
n := 1
for used[n] {
n++
}
return n
}
// nextDefaultSubPrefix builds the default "subN-" prefix for a new/edited
// subscription, picking the smallest free N (excludeId skips a subscription's
// own current prefix when editing).
func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) string {
var subs []*model.OutboundSubscription
_ = database.GetDB().Find(&subs).Error
return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId))
}
func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) (*model.OutboundSubscription, error) {
cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
if err != nil {
return nil, common.NewError("invalid subscription URL:", err)
}
if cleanURL == "" {
return nil, common.NewError("subscription URL is required")
}
if updateInterval <= 0 {
updateInterval = 600
}
prefix := strings.TrimSpace(tagPrefix)
if prefix == "" {
prefix = s.nextDefaultSubPrefix(0)
}
// New subscriptions go to the end of the priority order.
var count int64
database.GetDB().Model(&model.OutboundSubscription{}).Count(&count)
sub := &model.OutboundSubscription{
Remark: strings.TrimSpace(remark),
Url: cleanURL,
Enabled: enabled,
AllowPrivate: allowPrivate,
Prepend: prepend,
Priority: int(count),
TagPrefix: prefix,
UpdateInterval: updateInterval,
}
if err := database.GetDB().Create(sub).Error; err != nil {
return nil, err
}
return sub, nil
}
// Update updates editable fields.
func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) error {
sub, err := s.Get(id)
if err != nil {
return err
}
cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
if err != nil {
return common.NewError("invalid subscription URL:", err)
}
if cleanURL == "" {
return common.NewError("subscription URL is required")
}
if updateInterval <= 0 {
updateInterval = 600
}
prefix := strings.TrimSpace(tagPrefix)
if prefix == "" {
prefix = s.nextDefaultSubPrefix(sub.Id)
}
sub.Remark = strings.TrimSpace(remark)
sub.Url = cleanURL
sub.Enabled = enabled
sub.AllowPrivate = allowPrivate
sub.Prepend = prepend
sub.TagPrefix = prefix
sub.UpdateInterval = updateInterval
return database.GetDB().Save(sub).Error
}
// Delete removes a subscription.
func (s *OutboundSubscriptionService) Delete(id int) error {
return database.GetDB().Delete(&model.OutboundSubscription{}, id).Error
}
// GetLastOutbounds returns the last successfully fetched outbounds for a subscription
// (as raw interface slice ready for JSON merge). Returns nil slice when none.
func (s *OutboundSubscriptionService) GetLastOutbounds(id int) ([]any, error) {
sub, err := s.Get(id)
if err != nil {
return nil, err
}
if strings.TrimSpace(sub.LastFetchedOutbounds) == "" {
return nil, nil
}
var arr []any
if err := json.Unmarshal([]byte(sub.LastFetchedOutbounds), &arr); err != nil {
return nil, err
}
return arr, nil
}
// Refresh fetches the subscription URL, parses the links, assigns stable tags,
// persists the results, and returns the generated outbounds.
func (s *OutboundSubscriptionService) Refresh(id int) ([]any, error) {
sub, err := s.Get(id)
if err != nil {
return nil, err
}
outbounds, err := s.fetchAndStore(sub)
return outbounds, err
}
// RefreshAllEnabled fetches every enabled subscription whose due time has passed
// (lastUpdated + updateInterval <= now). It returns the number of subscriptions
// that were actually refreshed.
func (s *OutboundSubscriptionService) RefreshAllEnabled() (int, error) {
db := database.GetDB()
var subs []*model.OutboundSubscription
if err := db.Where("enabled = ?", true).Find(&subs).Error; err != nil {
return 0, err
}
now := time.Now().Unix()
refreshed := 0
for _, sub := range subs {
due := sub.LastUpdated + int64(sub.UpdateInterval)
if sub.LastUpdated == 0 || due <= now {
if _, err := s.fetchAndStore(sub); err != nil {
logger.Warningf("outbound sub %d (%s) refresh failed: %v", sub.Id, sub.Remark, err)
// continue with others
} else {
refreshed++
}
}
}
return refreshed, nil
}
// fetchAndStore does the actual network + parse + stability + persist work.
func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscription) ([]any, error) {
// Re-sanitize on every fetch (handles legacy rows + defense in depth against
// any direct DB tampering). Private targets are blocked unless this
// subscription was explicitly created with AllowPrivate.
cleanURL, err := SanitizePublicHTTPURL(sub.Url, sub.AllowPrivate)
if err != nil {
s.recordError(sub, err)
return nil, err
}
if cleanURL == "" {
return nil, common.NewError("subscription has no valid URL")
}
sub.Url = cleanURL // persist the cleaned version
client := s.settingService.NewProxiedHTTPClient(30 * time.Second)
// Re-validate every redirect hop: the initial host is checked above, but a
// redirect could still point at a private/internal address (SSRF). Cap the
// redirect chain as well.
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
if sub.AllowPrivate {
return nil
}
ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
defer cancel()
return rejectPrivateHost(ctx, req.URL.Hostname())
}
req, err := http.NewRequest("GET", sub.Url, nil)
if err != nil {
s.recordError(sub, err)
return nil, err
}
req.Header.Set("User-Agent", "3x-ui-outbound-sub/1.0")
resp, err := client.Do(req)
if err != nil {
s.recordError(sub, err)
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
err := fmt.Errorf("http %d", resp.StatusCode)
s.recordError(sub, err)
return nil, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
s.recordError(sub, err)
return nil, err
}
parsed, identities, err := link.ParseSubscriptionBody(body)
if err != nil {
s.recordError(sub, err)
return nil, err
}
// Load previous identities -> tags for stability
prev := map[string]string{}
if strings.TrimSpace(sub.LinkIdentities) != "" {
_ = json.Unmarshal([]byte(sub.LinkIdentities), &prev)
}
// Also load previous outbounds so we can reuse tags even for identities we
// temporarily lost (defensive).
prevTagByIndex := map[int]string{}
if strings.TrimSpace(sub.LastFetchedOutbounds) != "" {
var prevObs []any
if json.Unmarshal([]byte(sub.LastFetchedOutbounds), &prevObs) == nil {
for i, o := range prevObs {
if m, ok := o.(map[string]any); ok {
if tag, _ := m["tag"].(string); tag != "" {
prevTagByIndex[i] = tag
}
}
}
}
}
// Assign tags with stability (identity reuse, positional fallback, then a
// fresh allocation), keeping tags unique within this batch. Extracted into a
// pure function so it can be unit-tested without network/DB. Tags are written
// back into the parsed outbounds in place.
assigned := assignStableTags(parsed, identities, prev, prevTagByIndex, sub.Id, sub.TagPrefix)
// Persist identities for next time
newIdent := map[string]string{}
for i, id := range identities {
newIdent[id] = assigned[i]
}
identJSON, _ := json.Marshal(newIdent)
// Persist the outbounds (as compact JSON array)
obsJSON, _ := json.Marshal(parsed)
sub.LastFetchedOutbounds = string(obsJSON)
sub.LinkIdentities = string(identJSON)
sub.LastUpdated = time.Now().Unix()
sub.LastError = ""
if err := database.GetDB().Save(sub).Error; err != nil {
return nil, err
}
// Return as []any for the config merger
result := make([]any, len(parsed))
for i := range parsed {
result[i] = parsed[i]
}
return result, nil
}
func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) {
sub.LastError = err.Error()
_ = database.GetDB().Model(sub).Update("last_error", sub.LastError).Error
}
// assignStableTags assigns a tag to each parsed outbound, preferring stability:
// 1. reuse the tag previously mapped to the link's identity (prev),
// 2. else reuse the tag at the same position from the last fetch (prevTagByIndex),
// 3. else allocate a fresh tag from the prefix + remark (link.SuggestTag).
//
// Tags are kept unique within the batch by appending "-N" on collision, and are
// written back into parsed[i]["tag"]. The returned slice holds the assigned tags
// in order. When tagPrefix is empty a "sub<subID>-" prefix is used for fresh tags.
func assignStableTags(parsed []link.Outbound, identities []string, prev map[string]string, prevTagByIndex map[int]string, subID int, tagPrefix string) []string {
used := map[string]bool{} // uniqueness within this refresh batch
assigned := make([]string, len(parsed))
for i := range parsed {
id := ""
if i < len(identities) {
id = identities[i]
}
candidate := ""
if old, ok := prev[id]; ok && old != "" {
candidate = old
}
if candidate == "" {
// try to reuse by rough positional match from previous fetch (best effort)
if old, ok := prevTagByIndex[i]; ok && old != "" {
candidate = old
}
}
if candidate == "" {
// fresh allocation
prefix := tagPrefix
if prefix == "" {
prefix = fmt.Sprintf("sub%d-", subID)
}
remark := ""
if m, ok := parsed[i]["tag"].(string); ok {
remark = m
}
candidate = link.SuggestTag(prefix, remark, i)
}
// ensure local uniqueness inside this batch
final := candidate
for k := 1; used[final]; k++ {
final = fmt.Sprintf("%s-%d", candidate, k)
}
used[final] = true
assigned[i] = final
// write back the tag into the outbound
parsed[i]["tag"] = final
}
return assigned
}
// AllActiveOutbounds returns the concatenation of the last-fetched outbounds
// for every enabled subscription. This is the set that should be merged into
// the final Xray config. Order: subscription creation order (by id asc) so
// that later subscriptions can shadow earlier ones if the admin uses colliding
// prefixes (last writer wins inside xray, but we try to keep tags unique).
func (s *OutboundSubscriptionService) AllActiveOutbounds() ([]any, error) {
prepend, appendList, err := s.activeOutboundsSplit()
if err != nil {
return nil, err
}
return append(prepend, appendList...), nil
}
// activeOutboundsSplit returns the active subscription outbounds split into those
// that should be placed BEFORE the manual template outbounds (Prepend) and those
// placed AFTER. Within each group, subscriptions are ordered by Priority (then id)
// so the admin can control the merged order.
func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, appendList []any, err error) {
db := database.GetDB()
var subs []*model.OutboundSubscription
if err := db.Where("enabled = ?", true).Order("priority asc, id asc").Find(&subs).Error; err != nil {
return nil, nil, err
}
for _, sub := range subs {
if strings.TrimSpace(sub.LastFetchedOutbounds) == "" {
continue
}
var arr []any
if err := json.Unmarshal([]byte(sub.LastFetchedOutbounds), &arr); err != nil {
logger.Warningf("outbound sub %d has corrupt LastFetchedOutbounds: %v", sub.Id, err)
continue
}
if sub.Prepend {
prepend = append(prepend, arr...)
} else {
appendList = append(appendList, arr...)
}
}
return prepend, appendList, nil
}
// Move shifts a subscription one step up or down in the priority order and
// re-normalizes all priorities to a 0..n-1 sequence.
func (s *OutboundSubscriptionService) Move(id int, up bool) error {
db := database.GetDB()
var subs []*model.OutboundSubscription
if err := db.Order("priority asc, id asc").Find(&subs).Error; err != nil {
return err
}
idx := -1
for i, sub := range subs {
if sub.Id == id {
idx = i
break
}
}
if idx == -1 {
return common.NewError("subscription not found")
}
swap := idx + 1
if up {
swap = idx - 1
}
if swap < 0 || swap >= len(subs) {
return nil // already at the edge
}
subs[idx], subs[swap] = subs[swap], subs[idx]
for i, sub := range subs {
if sub.Priority != i {
if err := db.Model(sub).Update("priority", i).Error; err != nil {
return err
}
}
}
return nil
}
// AllActiveOutboundTags returns only the tags of active subscription outbounds.
// Useful for populating balancer / routing selectors without shipping full objects.
func (s *OutboundSubscriptionService) AllActiveOutboundTags() ([]string, error) {
obs, err := s.AllActiveOutbounds()
if err != nil {
return nil, err
}
tags := make([]string, 0, len(obs))
for _, o := range obs {
if m, ok := o.(map[string]any); ok {
if t, _ := m["tag"].(string); t != "" {
tags = append(tags, t)
}
}
}
return tags, nil
}
/*
Tag stability strategy (important for balancers and routing rules)
When a subscription is refreshed we try very hard to keep the *same* tag for the
same logical outbound so that existing balancers and routing rules keep working.
How we do it:
- On every successful parse we compute a stable "identity" for each link
(the core of the URI with the remark fragment removed, or for vmess the inner
JSON without the "ps" field).
- We persist a map identity -> tag in the LinkIdentities column.
- On the next refresh, if we see the same identity again we reuse the previous tag,
even if the remark changed or minor parameters moved.
- Only when we have never seen the identity before do we allocate a fresh tag
using the user-supplied TagPrefix + slug(remark) (or an index fallback).
- Within one refresh we still deduplicate with -N suffixes.
Consequences for balancers / routing:
- If you use an *exact* tag in a balancer selector or a routing rule, that
specific server will continue to be used after refreshes (as long as the
provider still returns a link that produces the same identity).
- If you use a *prefix/wildcard* selector (e.g. "hk-*", "sg-.*"), then any
*new* servers that the subscription later returns will automatically be
eligible for that balancer on the next Xray reload — this is the recommended
way to "subscribe to a pool".
- When a server disappears from the subscription, its tag simply stops
existing in the final outbounds array. The balancer will have fewer
candidates. If you configured a `fallbackTag` on the balancer, Xray will use
it. Otherwise connections that would have used the missing member may fail
or be routed by the next rule.
- If the provider rotates credentials/UUIDs/hosts for a server, the identity
changes → we treat it as a brand new outbound and give it a new tag. Any
balancer/rule that referenced the *old* tag will no longer see it. This is
an inherent limitation of subscription-based outbounds.
We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription
outbounds are always injected at runtime in GetXrayConfig.
*/
+117
View File
@@ -0,0 +1,117 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/link"
)
func TestDefaultPrefixNumber(t *testing.T) {
mk := func(id int, prefix string) *model.OutboundSubscription {
return &model.OutboundSubscription{Id: id, TagPrefix: prefix}
}
cases := []struct {
name string
subs []*model.OutboundSubscription
excludeId int
want int
}{
{"no subscriptions starts at 1", nil, 0, 1},
{"sequential prefixes give the next", []*model.OutboundSubscription{mk(1, "sub1-"), mk(2, "sub2-")}, 0, 3},
{"reuses the lowest freed number", []*model.OutboundSubscription{mk(2, "sub2-")}, 0, 1},
{"legacy blank prefix reserves its id", []*model.OutboundSubscription{mk(1, ""), mk(5, "sub3-")}, 0, 2},
{"custom prefixes are ignored", []*model.OutboundSubscription{mk(1, "hk-"), mk(2, "jp-")}, 0, 1},
{"excludes the edited subscription", []*model.OutboundSubscription{mk(5, "sub2-")}, 5, 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := defaultPrefixNumber(c.subs, c.excludeId); got != c.want {
t.Fatalf("got %d, want %d", got, c.want)
}
})
}
}
func TestAssignStableTags(t *testing.T) {
t.Run("reuses the tag mapped to a known identity", func(t *testing.T) {
parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
prev := map[string]string{"id-abc": "sub1-keepme"}
got := assignStableTags(parsed, []string{"id-abc"}, prev, nil, 1, "")
if got[0] != "sub1-keepme" {
t.Fatalf("got %q, want sub1-keepme", got[0])
}
if parsed[0]["tag"] != "sub1-keepme" {
t.Fatalf("tag was not written back into the outbound: %v", parsed[0]["tag"])
}
})
t.Run("falls back to the previous tag at the same position", func(t *testing.T) {
parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
got := assignStableTags(parsed, []string{"id-new"}, map[string]string{}, map[int]string{0: "sub1-oldpos"}, 1, "")
if got[0] != "sub1-oldpos" {
t.Fatalf("got %q, want sub1-oldpos", got[0])
}
})
t.Run("allocates a fresh tag with the default sub<id>- prefix", func(t *testing.T) {
parsed := []link.Outbound{{"tag": "Tokyo"}}
got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 7, "")
want := link.SuggestTag("sub7-", "Tokyo", 0)
if got[0] != want {
t.Fatalf("got %q, want %q", got[0], want)
}
})
t.Run("uses a custom prefix for fresh tags", func(t *testing.T) {
parsed := []link.Outbound{{"tag": "Tokyo"}}
got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 1, "hk-")
want := link.SuggestTag("hk-", "Tokyo", 0)
if got[0] != want {
t.Fatalf("got %q, want %q", got[0], want)
}
})
t.Run("disambiguates colliding tags with a -N suffix", func(t *testing.T) {
parsed := []link.Outbound{{"tag": "Same"}, {"tag": "Same"}}
got := assignStableTags(parsed, []string{"id1", "id2"}, nil, nil, 1, "p-")
base := link.SuggestTag("p-", "Same", 0)
if got[0] != base {
t.Fatalf("got[0] = %q, want %q", got[0], base)
}
if got[1] != base+"-1" {
t.Fatalf("got[1] = %q, want %q", got[1], base+"-1")
}
})
}
// TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes covers the SSRF guard used
// when fetching subscription URLs. All rejected cases use literal IPs or bad
// schemes so the test never performs real DNS resolution.
func TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes(t *testing.T) {
rejected := []string{
"http://127.0.0.1/sub", // loopback
"http://10.0.0.1/x", // private
"http://192.168.1.1", // private
"http://169.254.169.254/latest/meta-data", // link-local (cloud metadata)
"http://[::1]:8080/sub", // IPv6 loopback
"http://0.0.0.0", // unspecified
"ftp://example.com/x", // unsupported scheme
"file:///etc/passwd", // unsupported scheme
}
for _, raw := range rejected {
if _, err := SanitizePublicHTTPURL(raw, false); err == nil {
t.Errorf("expected %q to be rejected, got nil error", raw)
}
}
t.Run("allows a public literal IP without DNS", func(t *testing.T) {
got, err := SanitizePublicHTTPURL("http://8.8.8.8/sub", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "http://8.8.8.8/sub" {
t.Fatalf("got %q, want http://8.8.8.8/sub", got)
}
})
}
+2
View File
@@ -22,6 +22,8 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string)
switch protocol {
case model.Hysteria, model.WireGuard:
return transportUDP
case model.MTProto:
return transportTCP
}
var bits transportBits
+45
View File
@@ -122,6 +122,9 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
if inbound.NodeID != nil {
continue
}
if inbound.Protocol == model.MTProto {
continue
}
settings := map[string]any{}
json.Unmarshal([]byte(inbound.Settings), &settings)
@@ -251,9 +254,51 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
inboundConfig := inbound.GenXrayInboundConfig()
xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
}
// Merge subscription-derived outbounds (if any) into the final outbounds array.
// These are additive: each subscription is placed before or after the template
// outbounds based on its Prepend flag, ordered by Priority. Tags assigned by the
// subscription service are kept stable across refreshes so that balancers and
// routing rules continue to work.
subSvc := &OutboundSubscriptionService{}
if prepend, appendList, err := subSvc.activeOutboundsSplit(); err == nil && (len(prepend) > 0 || len(appendList) > 0) {
mergeSubscriptionOutbounds(xrayConfig, prepend, appendList)
}
return xrayConfig, nil
}
// mergeSubscriptionOutbounds appends the subscription outbounds to the
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
// template so that manually configured outbounds are never overwritten.
//
// Safety: if we cannot parse the template's outbounds array, we leave
// OutboundConfigs exactly as it came from the template (we do not inject
// subscription outbounds). This prevents us from accidentally dropping the
// user's manually configured outbounds when the template is in a weird state.
func mergeSubscriptionOutbounds(cfg *xray.Config, prepend, appendList []any) {
if len(prepend) == 0 && len(appendList) == 0 {
return
}
var templateOutbounds []any
if len(cfg.OutboundConfigs) > 0 {
if err := json.Unmarshal(cfg.OutboundConfigs, &templateOutbounds); err != nil {
// Corrupt template outbounds — do not touch the field at all.
// The user will see problems on Xray start / next save.
return
}
}
merged := make([]any, 0, len(prepend)+len(templateOutbounds)+len(appendList))
merged = append(merged, prepend...)
merged = append(merged, templateOutbounds...)
merged = append(merged, appendList...)
combined, err := json.MarshalIndent(merged, "", " ")
if err != nil {
return
}
cfg.OutboundConfigs = json_util.RawMessage(combined)
}
// resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
// absolute paths under config.GetLogFolder(), so Xray writes those files
// alongside the panel's other logs regardless of the working directory the
+55
View File
@@ -501,6 +501,9 @@
"accounts": "الحسابات",
"allowTransparent": "السماح بالشفاف",
"encryptionMethod": "طريقة التشفير",
"fakeTlsDomain": "نطاق FakeTLS (SNI)",
"mtprotoSecret": "المفتاح السري",
"mtprotoHint": "يتم تقديم MTProto عبر عملية mtg منفصلة وليس Xray. إعدادات النقل والعملاء لا تنطبق هنا — شارك الرابط أدناه مع تيليجرام.",
"visionTestseed": "Vision testseed",
"version": "الإصدار",
"udpIdleTimeout": "UDP idle timeout (ثانية)",
@@ -1353,6 +1356,58 @@
"privateKey": "المفتاح الخاص",
"load": "الحمل"
},
"OutboundSubscriptions": "اشتراكات الصادرات",
"OutboundSubscriptionsDesc": "استورد الصادرات من روابط اشتراك بعيدة (vmess/vless/trojan/ss/...). الوسوم بتفضل ثابتة عشان تستخدمها في موازنات التحميل وقواعد التوجيه. التحديثات بتتم تلقائياً.",
"outboundSub": {
"manage": "الاشتراكات",
"title": "اشتراكات الصادرات",
"remark": "ملاحظة (اختياري)",
"remarkPlaceholder": "مثلاً نودز هونج كونج",
"url": "رابط الاشتراك",
"urlPlaceholder": "https://... (قائمة روابط بصيغة base64)",
"tagPrefix": "بادئة الوسم",
"tagPrefixPlaceholder": "hk-",
"interval": "فاصل التحديث",
"hours": "س",
"minutes": "د",
"intervalHint": "الافتراضي 10 دقايق. المهمة اللي بتشتغل في الخلفية بتشيك بشكل متكرر؛ كل اشتراك بيعيد الجلب لما يعدّي الفاصل الخاص بيه بس.",
"enabled": "مفعّل",
"allowPrivate": "السماح بالعناوين الخاصة",
"allowPrivateHint": "اسمح بعناوين localhost / الشبكة المحلية (LAN) / عناوين IP الخاصة لرابط الاشتراك ده. متعطّل افتراضياً لدواعي الأمان — فعّله بس لو المصدر المحلي موثوق.",
"prepend": "قبل الصادرات اليدوية",
"prependHint": "حُط صادرات الاشتراك ده قبل الصادرات اللي ضبطتها بإيدك، عشان واحد منها يقدر يبقى الافتراضي.",
"preview": "معاينة",
"previewEmpty": "مفيش صادرات على الرابط ده.",
"refreshAll": "حدّث الكل",
"statusOk": "تمام",
"toastUpdated": "تم تحديث الاشتراك",
"addButton": "إضافة",
"active": "الاشتراكات النشطة",
"empty": "مفيش اشتراكات لسه. أضف واحد من فوق.",
"colRemark": "ملاحظة",
"colPrefix": "بادئة",
"colInterval": "الفاصل",
"colLastFetch": "آخر جلب",
"colEnabled": "مفعّل",
"auto": "تلقائي",
"never": "أبداً",
"yes": "نعم",
"no": "لا",
"refreshNow": "حدّث الآن",
"lastError": "آخر خطأ",
"deleteConfirm": "تحذف الاشتراك ده؟",
"restartHint": "بعد الإضافة أو التحديث، أعد تشغيل Xray (أو استنى إعادة التحميل التلقائي اللي جاية) عشان تفعّل الصادرات.",
"fromSubsTitle": "من اشتراكات الصادرات (للقراءة فقط)",
"fromSubsDesc": "مستوردة من اشتراكاتك النشطة. تقدر تديرها من لوحة الاشتراكات اللي فوق.",
"toastLoadFailed": "فشل تحميل الاشتراكات",
"toastUrlRequired": "رابط الاشتراك مطلوب",
"toastAdded": "تمت إضافة الاشتراك",
"toastAddFailed": "فشلت إضافة الاشتراك",
"toastRefreshed": "تم التحديث",
"toastRefreshFailed": "فشل التحديث",
"toastDeleted": "تم الحذف",
"toastDeleteFailed": "فشل الحذف"
},
"balancer": {
"addBalancer": "أضف موازن تحميل",
"editBalancer": "عدل موازن التحميل",
+55
View File
@@ -502,6 +502,9 @@
"accounts": "Accounts",
"allowTransparent": "Allow transparent",
"encryptionMethod": "Encryption method",
"fakeTlsDomain": "FakeTLS domain (SNI)",
"mtprotoSecret": "Secret",
"mtprotoHint": "MTProto is served by a separate mtg process, not Xray. Stream settings and clients do not apply here — share the link below with Telegram.",
"visionTestseed": "Vision testseed",
"version": "Version",
"udpIdleTimeout": "UDP idle timeout (s)",
@@ -1196,6 +1199,8 @@
"Inbounds": "Inbounds",
"InboundsDesc": "Accepting the specific clients.",
"Outbounds": "Outbounds",
"OutboundSubscriptions": "Outbound Subscriptions",
"OutboundSubscriptionsDesc": "Import outbounds from remote subscription URLs (vmess/vless/trojan/ss/...). Tags are kept stable for use in balancers and routing rules. Updates are automatic.",
"Balancers": "Balancers",
"balancerTagRequired": "Tag is required",
"balancerSelectorRequired": "Pick at least one outbound",
@@ -1354,6 +1359,56 @@
"privateKey": "Private Key",
"load": "Load"
},
"outboundSub": {
"manage": "Subscriptions",
"title": "Outbound Subscriptions",
"remark": "Remark (optional)",
"remarkPlaceholder": "e.g. HK nodes",
"url": "Subscription URL",
"urlPlaceholder": "https://... (base64 list of links)",
"tagPrefix": "Tag prefix",
"tagPrefixPlaceholder": "hk-",
"interval": "Update interval",
"hours": "h",
"minutes": "min",
"intervalHint": "Default 10 minutes. The background job checks frequently; each subscription only re-fetches when its own interval has passed.",
"enabled": "Enabled",
"allowPrivate": "Allow private address",
"allowPrivateHint": "Permit localhost / LAN / private IPs for this subscription's URL. Off by default for security — enable only for a trusted local source.",
"prepend": "Before manual outbounds",
"prependHint": "Place this subscription's outbounds before your manual ones, so one can become the default.",
"preview": "Preview",
"previewEmpty": "No outbounds found at this URL.",
"refreshAll": "Refresh all",
"statusOk": "OK",
"toastUpdated": "Subscription updated",
"addButton": "Add",
"active": "Active subscriptions",
"empty": "No subscriptions yet. Add one above.",
"colRemark": "Remark",
"colPrefix": "Prefix",
"colInterval": "Interval",
"colLastFetch": "Last fetch",
"colEnabled": "Enabled",
"auto": "auto",
"never": "never",
"yes": "Yes",
"no": "No",
"refreshNow": "Refresh now",
"lastError": "Last error",
"deleteConfirm": "Delete this subscription?",
"restartHint": "After adding or refreshing, restart Xray (or wait for the next auto-reload) to make the outbounds active.",
"fromSubsTitle": "From outbound subscriptions (read-only)",
"fromSubsDesc": "Imported from your active subscriptions. Manage them in the Subscriptions panel above.",
"toastLoadFailed": "Failed to load subscriptions",
"toastUrlRequired": "Subscription URL is required",
"toastAdded": "Subscription added",
"toastAddFailed": "Failed to add subscription",
"toastRefreshed": "Refreshed",
"toastRefreshFailed": "Refresh failed",
"toastDeleted": "Deleted",
"toastDeleteFailed": "Delete failed"
},
"balancer": {
"addBalancer": "Add Balancer",
"editBalancer": "Edit Balancer",
+55
View File
@@ -501,6 +501,9 @@
"accounts": "Cuentas",
"allowTransparent": "Permitir transparente",
"encryptionMethod": "Método de cifrado",
"fakeTlsDomain": "Dominio FakeTLS (SNI)",
"mtprotoSecret": "Secreto",
"mtprotoHint": "MTProto se sirve mediante un proceso mtg independiente, no Xray. Los ajustes de transporte y los clientes no aplican aquí; comparte el enlace de abajo con Telegram.",
"visionTestseed": "Vision testseed",
"version": "Versión",
"udpIdleTimeout": "UDP idle timeout (s)",
@@ -1353,6 +1356,58 @@
"privateKey": "Clave privada",
"load": "Carga"
},
"OutboundSubscriptions": "Suscripciones de salida",
"OutboundSubscriptionsDesc": "Importa salidas desde URLs de suscripción remotas (vmess/vless/trojan/ss/...). Las etiquetas se mantienen estables para usarlas en balanceadores y reglas de enrutamiento. Las actualizaciones son automáticas.",
"outboundSub": {
"manage": "Suscripciones",
"title": "Suscripciones de salida",
"remark": "Notas (opcional)",
"remarkPlaceholder": "p. ej. nodos HK",
"url": "URL de suscripción",
"urlPlaceholder": "https://... (lista de enlaces en base64)",
"tagPrefix": "Prefijo de etiqueta",
"tagPrefixPlaceholder": "hk-",
"interval": "Intervalo de actualización",
"hours": "h",
"minutes": "min",
"intervalHint": "Por defecto 10 minutos. La tarea en segundo plano comprueba con frecuencia; cada suscripción solo vuelve a descargarse cuando ha transcurrido su propio intervalo.",
"enabled": "Habilitado",
"allowPrivate": "Permitir direcciones privadas",
"allowPrivateHint": "Permite localhost, la red local (LAN) y las IP privadas en la URL de esta suscripción. Desactivado por defecto por seguridad; actívalo solo para una fuente local de confianza.",
"prepend": "Antes de las salidas manuales",
"prependHint": "Coloca las salidas de esta suscripción antes de las configuradas manualmente, de modo que una de ellas pueda convertirse en la predeterminada.",
"preview": "Vista previa",
"previewEmpty": "No se encontraron salidas en esta URL.",
"refreshAll": "Actualizar todo",
"statusOk": "Correcto",
"toastUpdated": "Suscripción actualizada",
"addButton": "Añadir",
"active": "Suscripciones activas",
"empty": "Aún no hay suscripciones. Añade una arriba.",
"colRemark": "Notas",
"colPrefix": "Prefijo",
"colInterval": "Intervalo",
"colLastFetch": "Última descarga",
"colEnabled": "Habilitado",
"auto": "auto",
"never": "nunca",
"yes": "Sí",
"no": "No",
"refreshNow": "Actualizar ahora",
"lastError": "Último error",
"deleteConfirm": "¿Eliminar esta suscripción?",
"restartHint": "Después de añadir o actualizar, reinicia Xray (o espera a la próxima recarga automática) para activar las salidas.",
"fromSubsTitle": "Desde suscripciones de salida (solo lectura)",
"fromSubsDesc": "Importadas desde tus suscripciones activas. Gestiónalas en el panel de Suscripciones de arriba.",
"toastLoadFailed": "No se pudieron cargar las suscripciones",
"toastUrlRequired": "La URL de suscripción es obligatoria",
"toastAdded": "Suscripción añadida",
"toastAddFailed": "No se pudo añadir la suscripción",
"toastRefreshed": "Actualizada",
"toastRefreshFailed": "Error al actualizar",
"toastDeleted": "Eliminada",
"toastDeleteFailed": "Error al eliminar"
},
"balancer": {
"addBalancer": "Agregar equilibrador",
"editBalancer": "Editar balanceador",
+55
View File
@@ -501,6 +501,9 @@
"accounts": "حساب‌ها",
"allowTransparent": "اجازه شفاف",
"encryptionMethod": "روش رمزنگاری",
"fakeTlsDomain": "دامنه FakeTLS (SNI)",
"mtprotoSecret": "کلید مخفی",
"mtprotoHint": "پروتکل MTProto توسط یک پردازش جداگانه mtg ارائه می‌شود، نه Xray. تنظیمات انتقال و کلاینت‌ها اینجا کاربرد ندارند — لینک زیر را با تلگرام به اشتراک بگذارید.",
"visionTestseed": "Vision testseed",
"version": "نسخه",
"udpIdleTimeout": "UDP idle timeout (s)",
@@ -1353,6 +1356,58 @@
"privateKey": "کلید خصوصی",
"load": "فشار سرور"
},
"OutboundSubscriptions": "سابسکریپشن‌های خروجی",
"OutboundSubscriptionsDesc": "خروجی‌ها را از آدرس‌های سابسکریپشن راه‌دور (vmess/vless/trojan/ss/...) وارد کنید. تگ‌ها ثابت می‌مانند تا در بالانسرها و قوانین مسیریابی قابل استفاده باشند. به‌روزرسانی‌ها به‌صورت خودکار انجام می‌شوند.",
"outboundSub": {
"manage": "سابسکریپشن‌ها",
"title": "سابسکریپشن‌های خروجی",
"remark": "نام (اختیاری)",
"remarkPlaceholder": "مثلاً نودهای هنگ‌کنگ",
"url": "آدرس سابسکریپشن",
"urlPlaceholder": "https://... (فهرست base64 از لینک‌ها)",
"tagPrefix": "پیشوند تگ",
"tagPrefixPlaceholder": "hk-",
"interval": "بازه به‌روزرسانی",
"hours": "ساعت",
"minutes": "دقیقه",
"intervalHint": "پیش‌فرض ۱۰ دقیقه. وظیفهٔ پس‌زمینه به‌طور مکرر بررسی می‌کند؛ هر سابسکریپشن فقط زمانی دوباره دریافت می‌شود که بازهٔ خودش سپری شده باشد.",
"enabled": "فعال",
"allowPrivate": "اجازهٔ آدرس خصوصی",
"allowPrivateHint": "اجازه به localhost / شبکهٔ محلی (LAN) / IPهای خصوصی برای آدرس این سابسکریپشن. به‌دلایل امنیتی به‌طور پیش‌فرض غیرفعال است؛ فقط برای یک منبع محلی مورد اعتماد فعال کنید.",
"prepend": "پیش از خروجی‌های دستی",
"prependHint": "خروجی‌های این سابسکریپشن را پیش از خروجی‌های دستی شما قرار می‌دهد تا یکی از آن‌ها بتواند پیش‌فرض شود.",
"preview": "پیش‌نمایش",
"previewEmpty": "هیچ خروجی‌ای در این آدرس یافت نشد.",
"refreshAll": "تازه‌سازی همه",
"statusOk": "موفق",
"toastUpdated": "سابسکریپشن به‌روزرسانی شد",
"addButton": "افزودن",
"active": "سابسکریپشن‌های فعال",
"empty": "هنوز سابسکریپشنی وجود ندارد. از بالا یکی اضافه کنید.",
"colRemark": "نام",
"colPrefix": "پیشوند",
"colInterval": "بازه",
"colLastFetch": "آخرین دریافت",
"colEnabled": "فعال",
"auto": "خودکار",
"never": "هرگز",
"yes": "بله",
"no": "خیر",
"refreshNow": "تازه‌سازی اکنون",
"lastError": "آخرین خطا",
"deleteConfirm": "این سابسکریپشن حذف شود؟",
"restartHint": "پس از افزودن یا تازه‌سازی، برای فعال‌شدن خروجی‌ها Xray را راه‌اندازی مجدد کنید (یا منتظر بارگذاری مجدد خودکار بعدی بمانید).",
"fromSubsTitle": "از سابسکریپشن‌های خروجی (فقط‌خواندنی)",
"fromSubsDesc": "از سابسکریپشن‌های فعال شما وارد شده‌اند. آن‌ها را از پنل سابسکریپشن‌ها در بالا مدیریت کنید.",
"toastLoadFailed": "بارگذاری سابسکریپشن‌ها ناموفق بود",
"toastUrlRequired": "آدرس سابسکریپشن الزامی است",
"toastAdded": "سابسکریپشن افزوده شد",
"toastAddFailed": "افزودن سابسکریپشن ناموفق بود",
"toastRefreshed": "تازه‌سازی شد",
"toastRefreshFailed": "تازه‌سازی ناموفق بود",
"toastDeleted": "حذف شد",
"toastDeleteFailed": "حذف ناموفق بود"
},
"balancer": {
"addBalancer": "افزودن بالانسر",
"editBalancer": "ویرایش بالانسر",
+55
View File
@@ -501,6 +501,9 @@
"accounts": "Akun",
"allowTransparent": "Izinkan transparan",
"encryptionMethod": "Metode enkripsi",
"fakeTlsDomain": "Domain FakeTLS (SNI)",
"mtprotoSecret": "Secret",
"mtprotoHint": "MTProto dijalankan oleh proses mtg terpisah, bukan Xray. Pengaturan stream dan klien tidak berlaku di sini — bagikan tautan di bawah ke Telegram.",
"visionTestseed": "Vision testseed",
"version": "Versi",
"udpIdleTimeout": "UDP idle timeout (d)",
@@ -1353,6 +1356,58 @@
"privateKey": "Kunci Privat",
"load": "Beban"
},
"OutboundSubscriptions": "Langganan Outbound",
"OutboundSubscriptionsDesc": "Impor outbound dari URL langganan jarak jauh (vmess/vless/trojan/ss/...). Tag dijaga tetap stabil untuk digunakan pada penyeimbang dan aturan routing. Pembaruan berjalan otomatis.",
"outboundSub": {
"manage": "Langganan",
"title": "Langganan Outbound",
"remark": "Catatan (opsional)",
"remarkPlaceholder": "mis. node HK",
"url": "URL langganan",
"urlPlaceholder": "https://... (daftar tautan base64)",
"tagPrefix": "Awalan tag",
"tagPrefixPlaceholder": "hk-",
"interval": "Interval pembaruan",
"hours": "j",
"minutes": "mnt",
"intervalHint": "Default 10 menit. Tugas latar belakang memeriksa secara berkala; setiap langganan hanya diambil ulang ketika intervalnya sendiri telah terlewati.",
"enabled": "Aktif",
"allowPrivate": "Izinkan alamat privat",
"allowPrivateHint": "Izinkan localhost / LAN / IP privat untuk URL langganan ini. Nonaktif secara default demi keamanan — aktifkan hanya untuk sumber lokal yang tepercaya.",
"prepend": "Sebelum outbound manual",
"prependHint": "Tempatkan outbound dari langganan ini sebelum outbound manual Anda, sehingga salah satunya dapat menjadi default.",
"preview": "Pratinjau",
"previewEmpty": "Tidak ada outbound yang ditemukan di URL ini.",
"refreshAll": "Segarkan semua",
"statusOk": "OK",
"toastUpdated": "Langganan diperbarui",
"addButton": "Tambah",
"active": "Langganan aktif",
"empty": "Belum ada langganan. Tambahkan satu di atas.",
"colRemark": "Catatan",
"colPrefix": "Awalan",
"colInterval": "Interval",
"colLastFetch": "Pengambilan terakhir",
"colEnabled": "Aktif",
"auto": "otomatis",
"never": "tidak pernah",
"yes": "Ya",
"no": "Tidak",
"refreshNow": "Segarkan sekarang",
"lastError": "Kesalahan terakhir",
"deleteConfirm": "Hapus langganan ini?",
"restartHint": "Setelah menambahkan atau menyegarkan, mulai ulang Xray (atau tunggu muat ulang otomatis berikutnya) agar outbound menjadi aktif.",
"fromSubsTitle": "Dari langganan outbound (hanya-baca)",
"fromSubsDesc": "Diimpor dari langganan aktif Anda. Kelola di panel Langganan di atas.",
"toastLoadFailed": "Gagal memuat langganan",
"toastUrlRequired": "URL langganan wajib diisi",
"toastAdded": "Langganan ditambahkan",
"toastAddFailed": "Gagal menambahkan langganan",
"toastRefreshed": "Disegarkan",
"toastRefreshFailed": "Gagal menyegarkan",
"toastDeleted": "Dihapus",
"toastDeleteFailed": "Gagal menghapus"
},
"balancer": {
"addBalancer": "Tambahkan Penyeimbang",
"editBalancer": "Sunting Penyeimbang",
+55
View File
@@ -501,6 +501,9 @@
"accounts": "アカウント",
"allowTransparent": "透過を許可",
"encryptionMethod": "暗号化方式",
"fakeTlsDomain": "FakeTLS ドメイン (SNI)",
"mtprotoSecret": "シークレット",
"mtprotoHint": "MTProto は Xray ではなく独立した mtg プロセスで提供されます。ストリーム設定とクライアントはここでは適用されません。下のリンクを Telegram で共有してください。",
"visionTestseed": "Vision testseed",
"version": "バージョン",
"udpIdleTimeout": "UDP idle timeout (秒)",
@@ -1353,6 +1356,58 @@
"privateKey": "秘密鍵",
"load": "負荷"
},
"OutboundSubscriptions": "アウトバウンドサブスクリプション",
"OutboundSubscriptionsDesc": "リモートのサブスクリプションURL(vmess/vless/trojan/ss/...)からアウトバウンドをインポートします。タグはバランサーやルーティングルールで使えるように安定して保持されます。更新は自動的に行われます。",
"outboundSub": {
"manage": "サブスクリプション",
"title": "アウトバウンドサブスクリプション",
"remark": "備考(任意)",
"remarkPlaceholder": "例: 香港ノード",
"url": "サブスクリプションURL",
"urlPlaceholder": "https://...(リンクのbase64リスト)",
"tagPrefix": "タグのプレフィックス",
"tagPrefixPlaceholder": "hk-",
"interval": "更新間隔",
"hours": "時間",
"minutes": "分",
"intervalHint": "デフォルトは10分です。バックグラウンドジョブは頻繁にチェックしますが、各サブスクリプションは自身の間隔が経過したときにのみ再取得されます。",
"enabled": "有効",
"allowPrivate": "プライベートアドレスを許可",
"allowPrivateHint": "このサブスクリプションのURLに対して、localhost・LAN・プライベートIPへのアクセスを許可します。セキュリティのため既定では無効です。信頼できるローカルソースの場合のみ有効にしてください。",
"prepend": "手動アウトバウンドの前に配置",
"prependHint": "このサブスクリプションのアウトバウンドを、手動で設定したアウトバウンドより前に配置します。これにより、いずれかをデフォルトにできます。",
"preview": "プレビュー",
"previewEmpty": "このURLにはアウトバウンドが見つかりませんでした。",
"refreshAll": "すべて更新",
"statusOk": "OK",
"toastUpdated": "サブスクリプションを更新しました",
"addButton": "追加",
"active": "有効なサブスクリプション",
"empty": "サブスクリプションはまだありません。上から追加してください。",
"colRemark": "備考",
"colPrefix": "プレフィックス",
"colInterval": "間隔",
"colLastFetch": "最終取得",
"colEnabled": "有効",
"auto": "自動",
"never": "なし",
"yes": "はい",
"no": "いいえ",
"refreshNow": "今すぐ更新",
"lastError": "最後のエラー",
"deleteConfirm": "このサブスクリプションを削除しますか?",
"restartHint": "追加または更新した後、アウトバウンドを有効にするにはXrayを再起動してください(または次の自動リロードをお待ちください)。",
"fromSubsTitle": "アウトバウンドサブスクリプションから(読み取り専用)",
"fromSubsDesc": "有効なサブスクリプションからインポートされています。上のサブスクリプションパネルで管理してください。",
"toastLoadFailed": "サブスクリプションの読み込みに失敗しました",
"toastUrlRequired": "サブスクリプションURLは必須です",
"toastAdded": "サブスクリプションを追加しました",
"toastAddFailed": "サブスクリプションの追加に失敗しました",
"toastRefreshed": "更新しました",
"toastRefreshFailed": "更新に失敗しました",
"toastDeleted": "削除しました",
"toastDeleteFailed": "削除に失敗しました"
},
"balancer": {
"addBalancer": "負荷分散追加",
"editBalancer": "負荷分散編集",
+55
View File
@@ -501,6 +501,9 @@
"accounts": "Contas",
"allowTransparent": "Permitir transparente",
"encryptionMethod": "Método de criptografia",
"fakeTlsDomain": "Domínio FakeTLS (SNI)",
"mtprotoSecret": "Segredo",
"mtprotoHint": "O MTProto é servido por um processo mtg separado, não pelo Xray. As configurações de transporte e os clientes não se aplicam aqui — compartilhe o link abaixo com o Telegram.",
"visionTestseed": "Vision testseed",
"version": "Versão",
"udpIdleTimeout": "UDP idle timeout (s)",
@@ -1353,6 +1356,58 @@
"privateKey": "Chave Privada",
"load": "Carga"
},
"OutboundSubscriptions": "Assinaturas de Saída",
"OutboundSubscriptionsDesc": "Importe saídas a partir de URLs de assinatura remotas (vmess/vless/trojan/ss/...). As tags são mantidas estáveis para uso em balanceadores e regras de roteamento. As atualizações são automáticas.",
"outboundSub": {
"manage": "Assinaturas",
"title": "Assinaturas de Saída",
"remark": "Observação (opcional)",
"remarkPlaceholder": "ex.: nós de HK",
"url": "URL da assinatura",
"urlPlaceholder": "https://... (lista de links em base64)",
"tagPrefix": "Prefixo da tag",
"tagPrefixPlaceholder": "hk-",
"interval": "Intervalo de atualização",
"hours": "h",
"minutes": "min",
"intervalHint": "Padrão de 10 minutos. A tarefa em segundo plano verifica com frequência; cada assinatura só é buscada novamente quando o seu próprio intervalo é atingido.",
"enabled": "Ativado",
"allowPrivate": "Permitir endereço privado",
"allowPrivateHint": "Permite localhost / LAN / IPs privados para a URL desta assinatura. Desativado por padrão por segurança — ative apenas para uma fonte local confiável.",
"prepend": "Antes das saídas manuais",
"prependHint": "Coloca as saídas desta assinatura antes das suas saídas configuradas manualmente, para que uma delas possa se tornar a padrão.",
"preview": "Pré-visualizar",
"previewEmpty": "Nenhuma saída encontrada nesta URL.",
"refreshAll": "Atualizar todas",
"statusOk": "OK",
"toastUpdated": "Assinatura atualizada",
"addButton": "Adicionar",
"active": "Assinaturas ativas",
"empty": "Nenhuma assinatura ainda. Adicione uma acima.",
"colRemark": "Observação",
"colPrefix": "Prefixo",
"colInterval": "Intervalo",
"colLastFetch": "Última busca",
"colEnabled": "Ativado",
"auto": "auto",
"never": "nunca",
"yes": "Sim",
"no": "Não",
"refreshNow": "Atualizar agora",
"lastError": "Último erro",
"deleteConfirm": "Excluir esta assinatura?",
"restartHint": "Após adicionar ou atualizar, reinicie o Xray (ou aguarde o próximo recarregamento automático) para ativar as saídas.",
"fromSubsTitle": "De assinaturas de saída (somente leitura)",
"fromSubsDesc": "Importadas das suas assinaturas ativas. Gerencie-as no painel de Assinaturas acima.",
"toastLoadFailed": "Falha ao carregar as assinaturas",
"toastUrlRequired": "A URL da assinatura é obrigatória",
"toastAdded": "Assinatura adicionada",
"toastAddFailed": "Falha ao adicionar a assinatura",
"toastRefreshed": "Atualizado",
"toastRefreshFailed": "Falha na atualização",
"toastDeleted": "Excluído",
"toastDeleteFailed": "Falha ao excluir"
},
"balancer": {
"addBalancer": "Adicionar Balanceador",
"editBalancer": "Editar Balanceador",
+55
View File
@@ -501,6 +501,9 @@
"accounts": "Аккаунты",
"allowTransparent": "Разрешить прозрачный",
"encryptionMethod": "Метод шифрования",
"fakeTlsDomain": "Домен FakeTLS (SNI)",
"mtprotoSecret": "Секрет",
"mtprotoHint": "MTProto обслуживается отдельным процессом mtg, а не Xray. Настройки транспорта и клиенты здесь не применяются — поделитесь ссылкой ниже в Telegram.",
"visionTestseed": "Vision testseed",
"version": "Версия",
"udpIdleTimeout": "UDP idle timeout (с)",
@@ -1353,6 +1356,58 @@
"privateKey": "Приватный ключ",
"load": "Нагрузка"
},
"OutboundSubscriptions": "Подписки исходящих",
"OutboundSubscriptionsDesc": "Импорт исходящих из удалённых URL подписок (vmess/vless/trojan/ss/...). Теги остаются неизменными для использования в балансировщиках и правилах маршрутизации. Обновление выполняется автоматически.",
"outboundSub": {
"manage": "Подписки",
"title": "Подписки исходящих",
"remark": "Примечание (необязательно)",
"remarkPlaceholder": "напр. узлы HK",
"url": "URL подписки",
"urlPlaceholder": "https://... (список ссылок в base64)",
"tagPrefix": "Префикс тега",
"tagPrefixPlaceholder": "hk-",
"interval": "Интервал обновления",
"hours": "ч",
"minutes": "мин",
"intervalHint": "По умолчанию 10 минут. Фоновая задача проверяет подписки часто, но каждая подписка обновляется только после того, как пройдёт её собственный интервал.",
"enabled": "Включено",
"allowPrivate": "Разрешить приватные адреса",
"allowPrivateHint": "Разрешить localhost, LAN и приватные IP-адреса для URL этой подписки. По умолчанию отключено в целях безопасности — включайте только для доверенного локального источника.",
"prepend": "Перед ручными исходящими",
"prependHint": "Размещать исходящие этой подписки перед вашими настроенными вручную, чтобы один из них мог стать исходящим по умолчанию.",
"preview": "Предпросмотр",
"previewEmpty": "По этому URL исходящих не найдено.",
"refreshAll": "Обновить все",
"statusOk": "OK",
"toastUpdated": "Подписка обновлена",
"addButton": "Добавить",
"active": "Активные подписки",
"empty": "Подписок пока нет. Добавьте одну выше.",
"colRemark": "Примечание",
"colPrefix": "Префикс",
"colInterval": "Интервал",
"colLastFetch": "Последнее обновление",
"colEnabled": "Включено",
"auto": "авто",
"never": "никогда",
"yes": "Да",
"no": "Нет",
"refreshNow": "Обновить сейчас",
"lastError": "Последняя ошибка",
"deleteConfirm": "Удалить эту подписку?",
"restartHint": "После добавления или обновления перезапустите Xray (или дождитесь следующей автоперезагрузки), чтобы исходящие стали активными.",
"fromSubsTitle": "Из подписок исходящих (только для чтения)",
"fromSubsDesc": "Импортировано из ваших активных подписок. Управление ими доступно на панели «Подписки» выше.",
"toastLoadFailed": "Не удалось загрузить подписки",
"toastUrlRequired": "Укажите URL подписки",
"toastAdded": "Подписка добавлена",
"toastAddFailed": "Не удалось добавить подписку",
"toastRefreshed": "Обновлено",
"toastRefreshFailed": "Не удалось обновить",
"toastDeleted": "Удалено",
"toastDeleteFailed": "Не удалось удалить"
},
"balancer": {
"addBalancer": "Создать балансировщик",
"editBalancer": "Редактировать балансировщик",
+55
View File
@@ -501,6 +501,9 @@
"accounts": "Hesaplar",
"allowTransparent": "Şeffafa izin ver",
"encryptionMethod": "Şifreleme yöntemi",
"fakeTlsDomain": "FakeTLS alan adı (SNI)",
"mtprotoSecret": "Gizli anahtar",
"mtprotoHint": "MTProto, Xray değil ayrı bir mtg işlemi tarafından sunulur. Aktarım ayarları ve istemciler burada geçerli değildir — aşağıdaki bağlantıyı Telegram ile paylaşın.",
"visionTestseed": "Vision testseed",
"version": "Sürüm",
"udpIdleTimeout": "UDP idle timeout (s)",
@@ -1353,6 +1356,58 @@
"privateKey": "Özel Anahtar",
"load": "Yük"
},
"OutboundSubscriptions": "Çıkış Abonelikleri",
"OutboundSubscriptionsDesc": "Uzak abonelik URL'lerinden (vmess/vless/trojan/ss/...) çıkış noktalarını içe aktarın. Etiketler, dengeleyiciler ve yönlendirme kurallarında kullanılmak üzere sabit tutulur. Güncellemeler otomatiktir.",
"outboundSub": {
"manage": "Abonelikler",
"title": "Çıkış Abonelikleri",
"remark": "Açıklama (isteğe bağlı)",
"remarkPlaceholder": "örn. HK düğümleri",
"url": "Abonelik URL'si",
"urlPlaceholder": "https://... (bağlantıların base64 listesi)",
"tagPrefix": "Etiket öneki",
"tagPrefixPlaceholder": "hk-",
"interval": "Güncelleme aralığı",
"hours": "sa",
"minutes": "dk",
"intervalHint": "Varsayılan 10 dakika. Arka plan görevi sık sık denetler; her abonelik yalnızca kendi aralığı dolduğunda yeniden indirir.",
"enabled": "Etkin",
"allowPrivate": "Özel adrese izin ver",
"allowPrivateHint": "Bu aboneliğin URL'si için localhost / LAN / özel IP'lere izin verir. Güvenlik nedeniyle varsayılan olarak kapalıdır; yalnızca güvenilir bir yerel kaynak için etkinleştirin.",
"prepend": "Manuel çıkışlardan önce",
"prependHint": "Bu aboneliğin çıkışlarını manuel olarak eklediklerinizden önce yerleştirir; böylece bunlardan biri varsayılan olabilir.",
"preview": "Önizleme",
"previewEmpty": "Bu URL'de çıkış bulunamadı.",
"refreshAll": "Tümünü yenile",
"statusOk": "Tamam",
"toastUpdated": "Abonelik güncellendi",
"addButton": "Ekle",
"active": "Aktif abonelikler",
"empty": "Henüz abonelik yok. Yukarıdan bir tane ekleyin.",
"colRemark": "Açıklama",
"colPrefix": "Önek",
"colInterval": "Aralık",
"colLastFetch": "Son indirme",
"colEnabled": "Etkin",
"auto": "otomatik",
"never": "asla",
"yes": "Evet",
"no": "Hayır",
"refreshNow": "Şimdi yenile",
"lastError": "Son hata",
"deleteConfirm": "Bu abonelik silinsin mi?",
"restartHint": "Ekledikten veya yeniledikten sonra, çıkış noktalarını etkinleştirmek için Xray'i yeniden başlatın (ya da bir sonraki otomatik yeniden yüklemeyi bekleyin).",
"fromSubsTitle": "Çıkış aboneliklerinden (salt okunur)",
"fromSubsDesc": "Aktif aboneliklerinizden içe aktarıldı. Bunları yukarıdaki Abonelikler panelinden yönetin.",
"toastLoadFailed": "Abonelikler yüklenemedi",
"toastUrlRequired": "Abonelik URL'si gerekli",
"toastAdded": "Abonelik eklendi",
"toastAddFailed": "Abonelik eklenemedi",
"toastRefreshed": "Yenilendi",
"toastRefreshFailed": "Yenileme başarısız",
"toastDeleted": "Silindi",
"toastDeleteFailed": "Silme başarısız"
},
"balancer": {
"addBalancer": "Dengeleyici Ekle",
"editBalancer": "Dengeleyiciyi Düzenle",
+55
View File
@@ -501,6 +501,9 @@
"accounts": "Акаунти",
"allowTransparent": "Дозволити прозорий",
"encryptionMethod": "Метод шифрування",
"fakeTlsDomain": "Домен FakeTLS (SNI)",
"mtprotoSecret": "Секрет",
"mtprotoHint": "MTProto обслуговується окремим процесом mtg, а не Xray. Налаштування транспорту та клієнти тут не застосовуються — поділіться посиланням нижче в Telegram.",
"visionTestseed": "Vision testseed",
"version": "Версія",
"udpIdleTimeout": "UDP idle timeout (с)",
@@ -1353,6 +1356,58 @@
"privateKey": "Приватний ключ",
"load": "Навантаження"
},
"OutboundSubscriptions": "Підписки вихідних",
"OutboundSubscriptionsDesc": "Імпортуйте вихідні з віддалених URL підписок (vmess/vless/trojan/ss/...). Теги залишаються стабільними для використання в балансувальниках і правилах маршрутизації. Оновлення відбувається автоматично.",
"outboundSub": {
"manage": "Підписки",
"title": "Підписки вихідних",
"remark": "Примітка (необов'язково)",
"remarkPlaceholder": "напр. вузли HK",
"url": "URL підписки",
"urlPlaceholder": "https://... (список посилань у base64)",
"tagPrefix": "Префікс тегу",
"tagPrefixPlaceholder": "hk-",
"interval": "Інтервал оновлення",
"hours": "год",
"minutes": "хв",
"intervalHint": "За замовчуванням 10 хвилин. Фонове завдання перевіряє часто; кожна підписка повторно завантажується лише після того, як мине її власний інтервал.",
"enabled": "Увімкнено",
"allowPrivate": "Дозволити приватні адреси",
"allowPrivateHint": "Дозволити localhost / LAN / приватні IP-адреси для URL цієї підписки. З міркувань безпеки вимкнено за замовчуванням — вмикайте лише для довіреного локального джерела.",
"prepend": "Перед ручними вихідними",
"prependHint": "Розмістити вихідні цієї підписки перед вашими ручними, щоб один із них міг стати типовим.",
"preview": "Попередній перегляд",
"previewEmpty": "За цим URL вихідних не знайдено.",
"refreshAll": "Оновити всі",
"statusOk": "OK",
"toastUpdated": "Підписку оновлено",
"addButton": "Додати",
"active": "Активні підписки",
"empty": "Підписок поки немає. Додайте одну вище.",
"colRemark": "Примітка",
"colPrefix": "Префікс",
"colInterval": "Інтервал",
"colLastFetch": "Останнє завантаження",
"colEnabled": "Увімкнено",
"auto": "авто",
"never": "ніколи",
"yes": "Так",
"no": "Ні",
"refreshNow": "Оновити зараз",
"lastError": "Остання помилка",
"deleteConfirm": "Видалити цю підписку?",
"restartHint": "Після додавання або оновлення перезапустіть Xray (або зачекайте наступного автоматичного перезавантаження), щоб вихідні стали активними.",
"fromSubsTitle": "З підписок вихідних (лише для читання)",
"fromSubsDesc": "Імпортовано з ваших активних підписок. Керуйте ними на панелі «Підписки» вище.",
"toastLoadFailed": "Не вдалося завантажити підписки",
"toastUrlRequired": "Потрібен URL підписки",
"toastAdded": "Підписку додано",
"toastAddFailed": "Не вдалося додати підписку",
"toastRefreshed": "Оновлено",
"toastRefreshFailed": "Не вдалося оновити",
"toastDeleted": "Видалено",
"toastDeleteFailed": "Не вдалося видалити"
},
"balancer": {
"addBalancer": "Додати балансир",
"editBalancer": "Редагувати балансир",
+55
View File
@@ -501,6 +501,9 @@
"accounts": "Tài khoản",
"allowTransparent": "Cho phép trong suốt",
"encryptionMethod": "Phương thức mã hóa",
"fakeTlsDomain": "Tên miền FakeTLS (SNI)",
"mtprotoSecret": "Khóa bí mật",
"mtprotoHint": "MTProto được phục vụ bởi một tiến trình mtg riêng, không phải Xray. Cài đặt truyền tải và máy khách không áp dụng ở đây — hãy chia sẻ liên kết bên dưới với Telegram.",
"visionTestseed": "Vision testseed",
"version": "Phiên bản",
"udpIdleTimeout": "UDP idle timeout (s)",
@@ -1353,6 +1356,58 @@
"privateKey": "Khóa riêng",
"load": "Tải"
},
"OutboundSubscriptions": "Đăng ký Outbound",
"OutboundSubscriptionsDesc": "Nhập các outbound từ URL đăng ký từ xa (vmess/vless/trojan/ss/...). Tag được giữ ổn định để dùng trong bộ cân bằng tải và quy tắc định tuyến. Cập nhật diễn ra tự động.",
"outboundSub": {
"manage": "Đăng ký",
"title": "Đăng ký Outbound",
"remark": "Ghi chú (tùy chọn)",
"remarkPlaceholder": "ví dụ node HK",
"url": "URL đăng ký",
"urlPlaceholder": "https://... (danh sách liên kết base64)",
"tagPrefix": "Tiền tố tag",
"tagPrefixPlaceholder": "hk-",
"interval": "Khoảng cập nhật",
"hours": "giờ",
"minutes": "phút",
"intervalHint": "Mặc định 10 phút. Tác vụ nền kiểm tra thường xuyên; mỗi đăng ký chỉ tải lại khi khoảng thời gian riêng của nó đã trôi qua.",
"enabled": "Đã kích hoạt",
"allowPrivate": "Cho phép địa chỉ riêng tư",
"allowPrivateHint": "Cho phép localhost / mạng LAN / IP riêng tư đối với URL của đăng ký này. Mặc định tắt vì lý do bảo mật — chỉ bật khi nguồn cục bộ đáng tin cậy.",
"prepend": "Trước các outbound thủ công",
"prependHint": "Đặt các outbound của đăng ký này trước các outbound bạn cấu hình thủ công, để một trong số đó có thể trở thành mặc định.",
"preview": "Xem trước",
"previewEmpty": "Không tìm thấy outbound nào tại URL này.",
"refreshAll": "Cập nhật tất cả",
"statusOk": "OK",
"toastUpdated": "Đã cập nhật đăng ký",
"addButton": "Thêm",
"active": "Đăng ký đang hoạt động",
"empty": "Chưa có đăng ký nào. Hãy thêm một mục ở trên.",
"colRemark": "Ghi chú",
"colPrefix": "Tiền tố",
"colInterval": "Khoảng",
"colLastFetch": "Lần tải gần nhất",
"colEnabled": "Đã kích hoạt",
"auto": "tự động",
"never": "không bao giờ",
"yes": "Có",
"no": "Không",
"refreshNow": "Cập nhật ngay",
"lastError": "Lỗi gần nhất",
"deleteConfirm": "Xóa đăng ký này?",
"restartHint": "Sau khi thêm hoặc cập nhật, hãy khởi động lại Xray (hoặc chờ lần tự động tải lại tiếp theo) để kích hoạt các outbound.",
"fromSubsTitle": "Từ đăng ký outbound (chỉ đọc)",
"fromSubsDesc": "Được nhập từ các đăng ký đang hoạt động của bạn. Hãy quản lý chúng trong bảng Đăng ký ở trên.",
"toastLoadFailed": "Không thể tải danh sách đăng ký",
"toastUrlRequired": "URL đăng ký là bắt buộc",
"toastAdded": "Đã thêm đăng ký",
"toastAddFailed": "Không thể thêm đăng ký",
"toastRefreshed": "Đã cập nhật",
"toastRefreshFailed": "Cập nhật thất bại",
"toastDeleted": "Đã xóa",
"toastDeleteFailed": "Xóa thất bại"
},
"balancer": {
"addBalancer": "Thêm cân bằng",
"editBalancer": "Chỉnh sửa cân bằng",
+55
View File
@@ -501,6 +501,9 @@
"accounts": "账户",
"allowTransparent": "允许透明",
"encryptionMethod": "加密方法",
"fakeTlsDomain": "FakeTLS 域名 (SNI)",
"mtprotoSecret": "密钥",
"mtprotoHint": "MTProto 由独立的 mtg 进程提供服务,而非 Xray。传输设置和客户端在此不适用——请将下方链接分享到 Telegram。",
"visionTestseed": "Vision testseed",
"version": "版本",
"udpIdleTimeout": "UDP 空闲超时 (s)",
@@ -1353,6 +1356,58 @@
"privateKey": "私钥",
"load": "负载"
},
"OutboundSubscriptions": "出站订阅",
"OutboundSubscriptionsDesc": "从远程订阅 URL(vmess/vless/trojan/ss/…)导入出站。标签保持稳定,可用于负载均衡器和路由规则。更新会自动进行。",
"outboundSub": {
"manage": "订阅",
"title": "出站订阅",
"remark": "备注(可选)",
"remarkPlaceholder": "如:香港节点",
"url": "订阅 URL",
"urlPlaceholder": "https://...(base64 编码的链接列表)",
"tagPrefix": "标签前缀",
"tagPrefixPlaceholder": "hk-",
"interval": "更新间隔",
"hours": "时",
"minutes": "分",
"intervalHint": "默认 10 分钟。后台任务会频繁检查;每个订阅仅在自身间隔到期后才重新拉取。",
"enabled": "启用",
"allowPrivate": "允许私有地址",
"allowPrivateHint": "允许此订阅 URL 使用 localhost / 局域网(LAN)/ 私有 IP 地址。出于安全考虑默认关闭,仅在使用可信的本地来源时才开启。",
"prepend": "置于手动出站之前",
"prependHint": "将此订阅的出站排在手动配置的出站之前,使其中之一可成为默认出站。",
"preview": "预览",
"previewEmpty": "在该 URL 未找到任何出站。",
"refreshAll": "全部刷新",
"statusOk": "正常",
"toastUpdated": "订阅已更新",
"addButton": "添加",
"active": "已启用的订阅",
"empty": "暂无订阅。请在上方添加。",
"colRemark": "备注",
"colPrefix": "前缀",
"colInterval": "间隔",
"colLastFetch": "上次拉取",
"colEnabled": "启用",
"auto": "自动",
"never": "从未",
"yes": "是",
"no": "否",
"refreshNow": "立即刷新",
"lastError": "上次错误",
"deleteConfirm": "删除此订阅?",
"restartHint": "添加或刷新后,请重启 Xray(或等待下一次自动重载)以使出站生效。",
"fromSubsTitle": "来自出站订阅(只读)",
"fromSubsDesc": "从已启用的订阅中导入。请在上方的订阅面板中管理它们。",
"toastLoadFailed": "加载订阅失败",
"toastUrlRequired": "订阅 URL 为必填项",
"toastAdded": "订阅已添加",
"toastAddFailed": "添加订阅失败",
"toastRefreshed": "已刷新",
"toastRefreshFailed": "刷新失败",
"toastDeleted": "已删除",
"toastDeleteFailed": "删除失败"
},
"balancer": {
"addBalancer": "添加负载均衡",
"editBalancer": "编辑负载均衡",
+55
View File
@@ -501,6 +501,9 @@
"accounts": "帳號",
"allowTransparent": "允許透明",
"encryptionMethod": "加密方法",
"fakeTlsDomain": "FakeTLS 網域 (SNI)",
"mtprotoSecret": "金鑰",
"mtprotoHint": "MTProto 由獨立的 mtg 程序提供服務,而非 Xray。傳輸設定與用戶端在此不適用——請將下方連結分享至 Telegram。",
"visionTestseed": "Vision testseed",
"version": "版本",
"udpIdleTimeout": "UDP 閒置逾時 (s)",
@@ -1353,6 +1356,58 @@
"privateKey": "私密金鑰",
"load": "負載"
},
"OutboundSubscriptions": "出站訂閱",
"OutboundSubscriptionsDesc": "從遠端訂閱 URL(vmess/vless/trojan/ss/...)匯入出站。標籤會保持穩定,以便在負載均衡與路由規則中使用。系統會自動更新。",
"outboundSub": {
"manage": "訂閱",
"title": "出站訂閱",
"remark": "備註(選填)",
"remarkPlaceholder": "例如:香港節點",
"url": "訂閱 URL",
"urlPlaceholder": "https://...(base64 連結清單)",
"tagPrefix": "標籤前綴",
"tagPrefixPlaceholder": "hk-",
"interval": "更新間隔",
"hours": "時",
"minutes": "分",
"intervalHint": "預設為 10 分鐘。背景工作會頻繁檢查;每個訂閱只在自己的間隔到期後才重新抓取。",
"enabled": "啟用",
"allowPrivate": "允許私有位址",
"allowPrivateHint": "允許此訂閱的 URL 使用 localhost/區域網路(LAN)/私有 IP。基於安全考量預設為關閉,請僅在來源為受信任的本機時才啟用。",
"prepend": "置於手動出站之前",
"prependHint": "將此訂閱的出站排在您手動設定的出站之前,讓其中之一可成為預設出站。",
"preview": "預覽",
"previewEmpty": "在此 URL 找不到任何出站。",
"refreshAll": "全部重新整理",
"statusOk": "正常",
"toastUpdated": "訂閱已更新",
"addButton": "新增",
"active": "啟用中的訂閱",
"empty": "尚無訂閱。請從上方新增。",
"colRemark": "備註",
"colPrefix": "前綴",
"colInterval": "間隔",
"colLastFetch": "上次抓取",
"colEnabled": "啟用",
"auto": "自動",
"never": "從不",
"yes": "是",
"no": "否",
"refreshNow": "立即重新整理",
"lastError": "上次錯誤",
"deleteConfirm": "確定要刪除此訂閱嗎?",
"restartHint": "新增或重新整理後,請重新啟動 Xray(或等待下次自動重新載入),讓出站生效。",
"fromSubsTitle": "來自出站訂閱(唯讀)",
"fromSubsDesc": "從您啟用中的訂閱匯入。請於上方的「訂閱」面板中管理。",
"toastLoadFailed": "載入訂閱失敗",
"toastUrlRequired": "訂閱 URL 為必填",
"toastAdded": "訂閱已新增",
"toastAddFailed": "新增訂閱失敗",
"toastRefreshed": "已重新整理",
"toastRefreshFailed": "重新整理失敗",
"toastDeleted": "已刪除",
"toastDeleteFailed": "刪除失敗"
},
"balancer": {
"addBalancer": "新增負載均衡",
"editBalancer": "編輯負載均衡",
+10
View File
@@ -17,6 +17,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/config"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/mtproto"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/web/controller"
"github.com/mhsanaei/3x-ui/v3/web/job"
@@ -281,6 +282,11 @@ func (s *Server) startTask(restartXray bool) {
s.cron.AddJob("@every 5s", job.NewXrayTrafficJob())
}()
// Reconcile mtproto (mtg) sidecars and scrape their traffic
mtJob := job.NewMtprotoJob()
s.cron.AddJob("@every 10s", mtJob)
go mtJob.Run()
// check client ips from log file every 10 sec
s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
@@ -288,6 +294,9 @@ func (s *Server) startTask(restartXray bool) {
s.cron.AddJob("@every 5s", job.NewNodeTrafficSyncJob())
// Outbound subscription auto-refresh (respects per-sub updateInterval)
s.cron.AddJob("@every 5m", job.NewOutboundSubscriptionJob())
// check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob())
@@ -465,6 +474,7 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
s.cancel()
if stopXray {
s.xrayService.StopXray()
mtproto.GetManager().StopAll()
}
if s.cron != nil {
s.cron.Stop()