mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-05 20:55:29 +03:00
Compare commits
5 Commits
98ba88037c
...
0daedd3db9
| Author | SHA1 | Date | |
|---|---|---|---|
| 0daedd3db9 | |||
| 21e01cc1e6 | |||
| 46684dd164 | |||
| 1ca5924a44 | |||
| af3c808444 |
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
@@ -1315,7 +1315,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"http",
|
||||
"mixed",
|
||||
"tunnel",
|
||||
"tun"
|
||||
"tun",
|
||||
"mtproto"
|
||||
],
|
||||
"example": "vless",
|
||||
"type": "string"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} 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([]),
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package mtproto
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func attachChildLifetime(_ *exec.Cmd) {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "عدل موازن التحميل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ویرایش بالانسر",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "負荷分散編集",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Редактировать балансировщик",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Редагувати балансир",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "编辑负载均衡",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user