Merge branch 'main' into feat/api-token-install

This commit is contained in:
Sanaei 2026-05-13 19:06:20 +02:00 committed by GitHub
commit 68e8277f92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 3888 additions and 685 deletions

View file

@ -9,3 +9,11 @@ updates:
directory: "/" # Location of package manifests directory: "/" # Location of package manifests
schedule: schedule:
interval: "weekly" interval: "weekly"
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"

91
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,91 @@
name: CI
on:
pull_request:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.vue"
- "**.html"
- "**.css"
- "frontend/package.json"
- "frontend/package-lock.json"
- ".nvmrc"
push:
branches:
- main
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.vue"
- "**.html"
- "**.css"
- "frontend/package.json"
- "frontend/package-lock.json"
- ".nvmrc"
permissions:
contents: read
jobs:
go-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub web/dist for go:embed
run: mkdir -p web/dist && touch web/dist/.gitkeep
- name: Test
run: |
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test $(cat /tmp/go-packages.txt)
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Stub web/dist for go:embed
run: mkdir -p web/dist && touch web/dist/.gitkeep
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install
run: npm ci
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
- name: Build
run: npm run build
working-directory: frontend
- name: Audit
run: npm audit --audit-level=high
working-directory: frontend

View file

@ -2,9 +2,31 @@ name: "CodeQL Advanced"
on: on:
push: push:
branches:
- main
tags-ignore: tags-ignore:
- "v*" - "v*"
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.vue"
- "frontend/package-lock.json"
pull_request: pull_request:
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "**.js"
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.vue"
- "frontend/package-lock.json"
schedule: schedule:
- cron: "18 2 * * 2" - cron: "18 2 * * 2"
@ -35,9 +57,6 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v6
# The Go binary embeds web/dist/ via //go:embed all:dist (web/web.go).
# web/dist/ is .gitignored, so CodeQL's autobuild for Go will fail with
# "pattern all:dist: no matching files found" unless vite emits it first.
- name: Setup Node.js - name: Setup Node.js
if: matrix.language == 'go' if: matrix.language == 'go'
uses: actions/setup-node@v6 uses: actions/setup-node@v6

View file

@ -19,6 +19,17 @@ on:
- "x-ui.service.arch" - "x-ui.service.arch"
- "x-ui.service.rhel" - "x-ui.service.rhel"
pull_request: pull_request:
paths:
- "**.js"
- "**.css"
- "**.html"
- "**.sh"
- "**.go"
- "go.mod"
- "go.sum"
- "x-ui.service.debian"
- "x-ui.service.arch"
- "x-ui.service.rhel"
jobs: jobs:
build: build:

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22

View file

@ -22,7 +22,7 @@ EOF
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF' cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
[Definition] [Definition]
datepattern = ^%Y/%m/%d %H:%M:%S datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
ignoreregex = ignoreregex =
EOF EOF

View file

@ -40,6 +40,7 @@ func initModels() error {
&model.HistoryOfSeeders{}, &model.HistoryOfSeeders{},
&model.CustomGeoResource{}, &model.CustomGeoResource{},
&model.Node{}, &model.Node{},
&model.ApiToken{},
} }
for _, model := range models { for _, model := range models {
if err := db.AutoMigrate(model); err != nil { if err := db.AutoMigrate(model); err != nil {
@ -86,8 +87,12 @@ func runSeeders(isUsersEmpty bool) error {
hashSeeder := &model.HistoryOfSeeders{ hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash", SeederName: "UserPasswordHash",
} }
return db.Create(hashSeeder).Error if err := db.Create(hashSeeder).Error; err != nil {
} else { return err
}
return seedApiTokens()
}
var seedersHistory []string var seedersHistory []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil { if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
log.Printf("Error fetching seeder history: %v", err) log.Printf("Error fetching seeder history: %v", err)
@ -116,13 +121,46 @@ func runSeeders(isUsersEmpty bool) error {
hashSeeder := &model.HistoryOfSeeders{ hashSeeder := &model.HistoryOfSeeders{
SeederName: "UserPasswordHash", SeederName: "UserPasswordHash",
} }
return db.Create(hashSeeder).Error if err := db.Create(hashSeeder).Error; err != nil {
return err
} }
} }
if !slices.Contains(seedersHistory, "ApiTokensTable") {
if err := seedApiTokens(); err != nil {
return err
}
}
return nil return nil
} }
// seedApiTokens copies the legacy `apiToken` setting into the new
// api_tokens table as a row named "default" so existing central panels
// keep working after the upgrade. Idempotent — records itself in
// history_of_seeders and only runs when api_tokens is empty.
func seedApiTokens() error {
empty, err := isTableEmpty("api_tokens")
if err != nil {
return err
}
if empty {
var legacy model.Setting
err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error
if err == nil && legacy.Value != "" {
row := &model.ApiToken{
Name: "default",
Token: legacy.Value,
Enabled: true,
}
if err := db.Create(row).Error; err != nil {
log.Printf("Error migrating legacy apiToken: %v", err)
return err
}
}
}
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
}
// isTableEmpty returns true if the named table contains zero rows. // isTableEmpty returns true if the named table contains zero rows.
func isTableEmpty(tableName string) (bool, error) { func isTableEmpty(tableName string) (bool, error) {
var count int64 var count int64

View file

@ -21,10 +21,6 @@ const (
Shadowsocks Protocol = "shadowsocks" Shadowsocks Protocol = "shadowsocks"
Mixed Protocol = "mixed" Mixed Protocol = "mixed"
WireGuard Protocol = "wireguard" WireGuard Protocol = "wireguard"
// UI stores Hysteria v1 and v2 both as "hysteria" and uses
// settings.version to discriminate. Imports from outside the panel
// can carry the literal "hysteria2" string, so IsHysteria below
// accepts both.
Hysteria Protocol = "hysteria" Hysteria Protocol = "hysteria"
Hysteria2 Protocol = "hysteria2" Hysteria2 Protocol = "hysteria2"
) )
@ -41,6 +37,7 @@ type User struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
LoginEpoch int64 `json:"-" gorm:"default:0"`
} }
// Inbound represents an Xray inbound configuration with traffic statistics and settings. // Inbound represents an Xray inbound configuration with traffic statistics and settings.
@ -66,11 +63,6 @@ type Inbound struct {
StreamSettings string `json:"streamSettings" form:"streamSettings"` StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"` Tag string `json:"tag" form:"tag" gorm:"unique"`
Sniffing string `json:"sniffing" form:"sniffing"` Sniffing string `json:"sniffing" form:"sniffing"`
// NodeID points at the remote panel (Node) where this inbound's xray
// actually runs. NULL means the inbound runs on the local xray (the
// pre-multi-node behaviour). Existing rows migrate to NULL with no
// backfill.
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"` NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
} }
@ -96,6 +88,14 @@ type HistoryOfSeeders struct {
SeederName string `json:"seederName"` SeederName string `json:"seederName"`
} }
type ApiToken struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"uniqueIndex;not null"`
Token string `json:"token" gorm:"not null"`
Enabled bool `json:"enabled" gorm:"default:true"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
}
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model. // GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
listen := i.Listen listen := i.Listen
@ -137,6 +137,7 @@ type Node struct {
BasePath string `json:"basePath" form:"basePath"` BasePath string `json:"basePath" form:"basePath"`
ApiToken string `json:"apiToken" form:"apiToken"` ApiToken string `json:"apiToken" form:"apiToken"`
Enable bool `json:"enable" form:"enable" gorm:"default:true"` Enable bool `json:"enable" form:"enable" gorm:"default:true"`
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
// Heartbeat-updated fields. UpdatedAt advances on every probe even when // Heartbeat-updated fields. UpdatedAt advances on every probe even when
// the row is otherwise unchanged so the UI's "last seen" tooltip is // the row is otherwise unchanged so the UI's "last seen" tooltip is

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · API Docs</title> <title>API Docs</title>
</head> </head>
<body> <body>
<div id="message"></div> <div id="message"></div>

View file

@ -19,9 +19,6 @@ export default [
globals: { globals: {
...globals.browser, ...globals.browser,
...globals.node, ...globals.node,
// Legacy script tags inject a couple of helpers on window before
// the SPA boots; declared here so no-undef stops flagging them.
getRandomRealityTarget: 'readonly',
}, },
}, },
rules: { rules: {

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Inbounds</title> <title>Inbounds</title>
</head> </head>
<body> <body>
<div id="message"></div> <div id="message"></div>

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui</title> <title>Overview</title>
</head> </head>
<body> <body>
<div id="message"></div> <div id="message"></div>

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
<title>3x-ui — Sign in</title> <title>Sign in</title>
</head> </head>
<body> <body>
<div id="message"></div> <div id="message"></div>

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Nodes</title> <title>Nodes</title>
</head> </head>
<body> <body>
<div id="message"></div> <div id="message"></div>

View file

@ -7,6 +7,10 @@
"": { "": {
"name": "3x-ui-frontend", "name": "3x-ui-frontend",
"version": "0.0.2", "version": "0.0.2",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.1", "@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6", "ant-design-vue": "^4.2.6",

View file

@ -4,6 +4,10 @@
"version": "0.0.2", "version": "0.0.2",
"type": "module", "type": "module",
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).", "description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
"engines": {
"node": ">=22.0.0",
"npm": ">=10.0.0"
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Settings</title> <title>Settings</title>
</head> </head>
<body> <body>
<div id="message"></div> <div id="message"></div>

View file

@ -2,24 +2,16 @@ import axios from 'axios';
import qs from 'qs'; import qs from 'qs';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']); const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
// Public CSRF endpoint — works pre-login (the panel-scoped
// /panel/csrf-token sits behind checkLogin and would 401 a fresh
// login page that hasn't authenticated yet).
const CSRF_TOKEN_PATH = '/csrf-token'; const CSRF_TOKEN_PATH = '/csrf-token';
// Cached session CSRF token. The legacy panel injects it via a
// <meta name="csrf-token"> tag rendered by Go; the new SPA pages
// fetch it once from /panel/csrf-token instead. Module-level so
// every axios POST sees the latest value.
let csrfToken = null; let csrfToken = null;
let csrfFetchPromise = null; let csrfFetchPromise = null;
let sessionExpired = false;
function readMetaToken() { function readMetaToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null; return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
} }
// Fetch the token via a bare fetch() (not axios) so the call doesn't
// recurse through this same interceptor.
async function fetchCsrfToken() { async function fetchCsrfToken() {
try { try {
const basePath = window.X_UI_BASE_PATH; const basePath = window.X_UI_BASE_PATH;
@ -91,19 +83,12 @@ export function setupAxios() {
async (error) => { async (error) => {
const status = error.response?.status; const status = error.response?.status;
if (status === 401) { if (status === 401) {
// 401 → session is gone. In production, the panel routes if (!sessionExpired) {
// are gated by Go's checkLogin which redirects to base_path sessionExpired = true;
// serving the login page; a reload is enough. In dev, Vite
// serves /index.html directly at "/", so a reload would put
// the user right back on the dashboard and the interceptor
// would loop. Navigate to the dev login entry instead.
if (import.meta.env.DEV) {
const basePath = window.X_UI_BASE_PATH || '/'; const basePath = window.X_UI_BASE_PATH || '/';
window.location.href = `${basePath}login.html`; window.location.replace(basePath);
} else {
window.location.reload();
} }
return Promise.reject(error); return new Promise(() => { });
} }
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once. // 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
const cfg = error.config; const cfg = error.config;

View file

@ -14,6 +14,7 @@ import {
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js'; import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
import { HttpUtil } from '@/utils';
const { t } = useI18n(); const { t } = useI18n();
@ -45,7 +46,7 @@ const tabs = computed(() => [
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') }, { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') }, { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') }, { key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') }, { key: 'logout', icon: 'logout', title: t('logout') },
]); ]);
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout')); const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
@ -55,7 +56,12 @@ const drawerOpen = ref(false);
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false')); const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
const drawerWidth = 'min(82vw, 320px)'; const drawerWidth = 'min(82vw, 320px)';
function openLink(key) { async function openLink(key) {
if (key === 'logout') {
await HttpUtil.post('/logout');
window.location.href = props.basePath || '/';
return;
}
if (key.startsWith('http')) { if (key.startsWith('http')) {
window.open(key); window.open(key);
} else { } else {

View file

@ -17,7 +17,7 @@ const props = defineProps({
showAxes: { type: Boolean, default: false }, showAxes: { type: Boolean, default: false },
yTickStep: { type: Number, default: 25 }, yTickStep: { type: Number, default: 25 },
tickCountX: { type: Number, default: 4 }, tickCountX: { type: Number, default: 4 },
paddingLeft: { type: Number, default: 32 }, paddingLeft: { type: Number, default: 56 },
paddingRight: { type: Number, default: 6 }, paddingRight: { type: Number, default: 6 },
paddingTop: { type: Number, default: 6 }, paddingTop: { type: Number, default: 6 },
paddingBottom: { type: Number, default: 20 }, paddingBottom: { type: Number, default: 20 },

View file

@ -36,7 +36,9 @@ export function useNodeList() {
return n != null && n.enable && n.status === 'online'; return n != null && n.enable && n.status === 'online';
} }
const hasActive = computed(() => nodes.value.some((n) => n.enable));
onMounted(refresh); onMounted(refresh);
return { nodes, fetched, refresh, byId, nameFor, isOnline }; return { nodes, fetched, refresh, byId, nameFor, isOnline, hasActive };
} }

View file

@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js'; import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue'; import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
setupAxios(); setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message'); const messageContainer = document.getElementById('message');
if (messageContainer) { if (messageContainer) {

View file

@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js'; import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import InboundsPage from '@/pages/inbounds/InboundsPage.vue'; import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
setupAxios(); setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message'); const messageContainer = document.getElementById('message');
if (messageContainer) { if (messageContainer) {

View file

@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
// stored theme to <body>/<html> before Vue mounts. // stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import IndexPage from '@/pages/index/IndexPage.vue'; import IndexPage from '@/pages/index/IndexPage.vue';
setupAxios(); setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message'); const messageContainer = document.getElementById('message');
if (messageContainer) { if (messageContainer) {

View file

@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
// stored theme to <body>/<html> before Vue renders anything. // stored theme to <body>/<html> before Vue renders anything.
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import LoginPage from '@/pages/login/LoginPage.vue'; import LoginPage from '@/pages/login/LoginPage.vue';
setupAxios(); setupAxios();
applyDocumentTitle();
// Toasts attach to a #message div the page provides — keeps theme // Toasts attach to a #message div the page provides — keeps theme
// styling in sync with the rest of the panel. // styling in sync with the rest of the panel.

View file

@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js'; import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import NodesPage from '@/pages/nodes/NodesPage.vue'; import NodesPage from '@/pages/nodes/NodesPage.vue';
setupAxios(); setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message'); const messageContainer = document.getElementById('message');
if (messageContainer) { if (messageContainer) {

View file

@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
// stored theme to <body>/<html> before Vue mounts. // stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import SettingsPage from '@/pages/settings/SettingsPage.vue'; import SettingsPage from '@/pages/settings/SettingsPage.vue';
setupAxios(); setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message'); const messageContainer = document.getElementById('message');
if (messageContainer) { if (messageContainer) {

View file

@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js'; import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js'; import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js'; import { i18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import XrayPage from '@/pages/xray/XrayPage.vue'; import XrayPage from '@/pages/xray/XrayPage.vue';
setupAxios(); setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message'); const messageContainer = document.getElementById('message');
if (messageContainer) { if (messageContainer) {

View file

@ -70,6 +70,10 @@ export class DBInbound {
return this.protocol === Protocols.WIREGUARD; return this.protocol === Protocols.WIREGUARD;
} }
get isHysteria() {
return this.protocol === Protocols.HYSTERIA;
}
get address() { get address() {
let address = location.hostname; let address = location.hostname;
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") { if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {

View file

@ -1,5 +1,6 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils'; import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils';
import { getRandomRealityTarget } from '@/models/reality-targets';
export const Protocols = { export const Protocols = {
VMESS: 'vmess', VMESS: 'vmess',
@ -687,8 +688,9 @@ export class HysteriaMasquerade extends XrayCommonClass {
} }
static fromJson(json = {}) { static fromJson(json = {}) {
const type = ['proxy', 'file', 'string'].includes(json.type) ? json.type : 'proxy';
return new HysteriaMasquerade( return new HysteriaMasquerade(
json.type, type,
json.dir, json.dir,
json.url, json.url,
json.rewriteHost, json.rewriteHost,
@ -896,9 +898,7 @@ export class RealityStreamSettings extends XrayCommonClass {
super(); super();
// If target/serverNames are not provided, use random values // If target/serverNames are not provided, use random values
if (!target && !serverNames) { if (!target && !serverNames) {
const randomTarget = typeof getRandomRealityTarget !== 'undefined' const randomTarget = getRandomRealityTarget();
? getRandomRealityTarget()
: { target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' };
target = randomTarget.target; target = randomTarget.target;
serverNames = randomTarget.sni; serverNames = randomTarget.sni;
} }

View file

@ -15,6 +15,7 @@ export class AllSetting {
this.webKeyFile = ""; this.webKeyFile = "";
this.webBasePath = "/"; this.webBasePath = "/";
this.sessionMaxAge = 360; this.sessionMaxAge = 360;
this.trustedProxyCIDRs = "127.0.0.1/32,::1/128";
this.pageSize = 25; this.pageSize = 25;
this.expireDiff = 0; this.expireDiff = 0;
this.trafficDiff = 0; this.trafficDiff = 0;
@ -56,6 +57,7 @@ export class AllSetting {
this.subUpdates = 12; this.subUpdates = 12;
this.subEncrypt = true; this.subEncrypt = true;
this.subShowInfo = true; this.subShowInfo = true;
this.subEmailInRemark = true;
this.subURI = ""; this.subURI = "";
this.subJsonURI = ""; this.subJsonURI = "";
this.subClashURI = ""; this.subClashURI = "";
@ -87,6 +89,12 @@ export class AllSetting {
this.ldapDefaultTotalGB = 0; this.ldapDefaultTotalGB = 0;
this.ldapDefaultExpiryDays = 0; this.ldapDefaultExpiryDays = 0;
this.ldapDefaultLimitIP = 0; this.ldapDefaultLimitIP = 0;
this.hasTgBotToken = false;
this.hasTwoFactorToken = false;
this.hasLdapPassword = false;
this.hasApiToken = false;
this.hasWarpSecret = false;
this.hasNordSecret = false;
if (data == null) { if (data == null) {
return return

View file

@ -1,80 +1,143 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import { import {
KeyOutlined, KeyOutlined,
ReloadOutlined, SearchOutlined,
CopyOutlined, ExpandOutlined,
EyeOutlined, CompressOutlined,
EyeInvisibleOutlined, ApiOutlined,
SafetyCertificateOutlined,
CloudServerOutlined,
ClusterOutlined,
GlobalOutlined,
SaveOutlined,
SettingOutlined,
WifiOutlined,
LinkOutlined,
NodeIndexOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js'; import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import AppSidebar from '@/components/AppSidebar.vue'; import AppSidebar from '@/components/AppSidebar.vue';
import { HttpUtil, ClipboardManager } from '@/utils/index.js'; import { sections as allSections } from './endpoints.js';
import { sections } from './endpoints.js';
import EndpointSection from './EndpointSection.vue'; import EndpointSection from './EndpointSection.vue';
import CodeBlock from './CodeBlock.vue';
const { t } = useI18n();
const basePath = window.X_UI_BASE_PATH || ''; const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname; const requestUri = window.location.pathname;
const settingsHref = `${basePath}panel/settings#security`;
const apiToken = ref(''); const searchQuery = ref('');
const tokenLoading = ref(false); const collapsedSections = ref(new Set());
const tokenRotating = ref(false); const activeSection = ref('');
const tokenVisible = ref(false);
const sectionIcons = {
authentication: SafetyCertificateOutlined,
inbounds: NodeIndexOutlined,
server: CloudServerOutlined,
nodes: ClusterOutlined,
'custom-geo': GlobalOutlined,
backup: SaveOutlined,
settings: SettingOutlined,
'api-tokens': KeyOutlined,
'xray-settings': WifiOutlined,
subscription: LinkOutlined,
websocket: ApiOutlined,
};
const curlExample = `curl -X GET \\ const curlExample = `curl -X GET \\
-H "Authorization: Bearer YOUR_API_TOKEN" \\ -H "Authorization: Bearer YOUR_API_TOKEN" \\
-H "Accept: application/json" \\ -H "Accept: application/json" \\
https://your-panel.example.com/panel/api/inbounds/list`; https://your-panel.example.com/panel/api/inbounds/list`;
async function loadApiToken() { const sections = computed(() => {
tokenLoading.value = true; const q = searchQuery.value.toLowerCase().trim();
try { if (!q) return allSections;
const msg = await HttpUtil.get('/panel/setting/getApiToken'); return allSections
if (msg?.success) apiToken.value = msg.obj || ''; .map(s => {
} finally { const matching = s.endpoints.filter(e =>
tokenLoading.value = false; e.path.toLowerCase().includes(q) ||
} e.summary?.toLowerCase().includes(q) ||
e.method.toLowerCase().includes(q)
);
return { ...s, endpoints: matching };
})
.filter(s => s.endpoints.length > 0);
});
const endpointCount = computed(() =>
allSections.reduce((sum, s) => sum + s.endpoints.length, 0)
);
const visibleEndpoints = computed(() =>
sections.value.reduce((sum, s) => sum + s.endpoints.length, 0)
);
function isCollapsed(id) {
return collapsedSections.value.has(id);
} }
function regenerateApiToken() { function toggleSection(id) {
Modal.confirm({ const s = new Set(collapsedSections.value);
title: t('pages.nodes.regenerateConfirm'), if (s.has(id)) s.delete(id); else s.add(id);
okText: t('confirm'), collapsedSections.value = s;
cancelText: t('cancel'),
okType: 'danger',
onOk: async () => {
tokenRotating.value = true;
try {
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
if (msg?.success) {
apiToken.value = msg.obj || '';
message.success(t('success'));
}
} finally {
tokenRotating.value = false;
}
},
});
} }
async function copyApiToken() { function expandAll() {
if (!apiToken.value) return; collapsedSections.value = new Set();
const ok = await ClipboardManager.copyText(apiToken.value); }
if (ok) message.success(t('success'));
function collapseAll() {
collapsedSections.value = new Set(allSections.map(s => s.id));
} }
function scrollToSection(id) { function scrollToSection(id) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (window.location.hash !== `#${id}`) {
history.replaceState(null, '', `#${id}`);
}
}
function scrollToHash() {
const id = window.location.hash.slice(1);
if (!id) return;
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
}
let scrollObserver = null;
function onScroll() {
const toc = document.querySelector('.toc-nav');
const tocHeight = toc ? toc.offsetHeight : 56;
let current = '';
for (const s of sections.value) {
const el = document.getElementById(s.id);
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.top <= tocHeight + 20) {
current = s.id;
}
}
activeSection.value = current;
} }
onMounted(() => { onMounted(() => {
loadApiToken(); scrollObserver = onScroll;
window.addEventListener('scroll', scrollObserver, { passive: true });
window.addEventListener('hashchange', scrollToHash);
requestAnimationFrame(() => {
scrollToHash();
onScroll();
});
});
onBeforeUnmount(() => {
if (scrollObserver) {
window.removeEventListener('scroll', scrollObserver);
}
window.removeEventListener('hashchange', scrollToHash);
}); });
</script> </script>
@ -93,60 +156,81 @@ onMounted(() => {
cookie, or with the <code>Authorization: Bearer &lt;token&gt;</code> header below. Every endpoint cookie, or with the <code>Authorization: Bearer &lt;token&gt;</code> header below. Every endpoint
returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted. returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
</p> </p>
</header> </header>
<a-card class="token-card" size="small"> <a-card class="token-card" size="small">
<div class="token-card-head"> <div class="token-card-head">
<div class="token-card-title"> <div class="token-card-title">
<KeyOutlined /> <KeyOutlined />
<span>API Token</span> <span>API Tokens</span>
</div> </div>
<a-space size="small" wrap> <a-button type="primary" size="small" :href="settingsHref">
<a-button size="small" @click="tokenVisible = !tokenVisible"> Manage tokens
<template #icon>
<EyeInvisibleOutlined v-if="tokenVisible" />
<EyeOutlined v-else />
</template>
{{ tokenVisible ? 'Hide' : 'Show' }}
</a-button> </a-button>
<a-button size="small" :disabled="!apiToken" @click="copyApiToken">
<template #icon>
<CopyOutlined />
</template>
Copy
</a-button>
<a-button size="small" danger :loading="tokenRotating" @click="regenerateApiToken">
<template #icon>
<ReloadOutlined />
</template>
Regenerate
</a-button>
</a-space>
</div> </div>
<a-spin :spinning="tokenLoading" size="small">
<pre
class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
</a-spin>
<p class="token-hint"> <p class="token-hint">
Send it on every request as <code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated Create, enable, or revoke named Bearer tokens in
callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately <a :href="settingsHref">Settings Security</a>. Send each request as
running bots will need the new value. <code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated callers skip CSRF and don't
need a session cookie. Deleting a token revokes it immediately running bots will need a new one.
</p> </p>
</a-card> </a-card>
<a-card class="curl-card" size="small" title="Quick example"> <a-card class="curl-card" size="small" title="Quick example">
<pre class="code-block">{{ curlExample }}</pre> <CodeBlock :code="curlExample" lang="text" />
</a-card> </a-card>
<div class="toolbar">
<a-input-search
v-model:value="searchQuery"
placeholder="Search endpoints by path, method, or description…"
allow-clear
class="search-bar"
>
<template #prefix><SearchOutlined /></template>
</a-input-search>
<span class="match-count" v-if="searchQuery">
{{ visibleEndpoints }} / {{ endpointCount }} endpoints
</span>
<a-space size="small">
<a-button size="small" @click="expandAll">
<template #icon><ExpandOutlined /></template>
Expand all
</a-button>
<a-button size="small" @click="collapseAll">
<template #icon><CompressOutlined /></template>
Collapse all
</a-button>
</a-space>
</div>
<nav class="toc-nav"> <nav class="toc-nav">
<span class="toc-label">On this page:</span> <span class="toc-label">On this page:</span>
<a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`" <div class="toc-links">
@click.prevent="scrollToSection(s.id)"> <a
{{ s.title }} v-for="s in sections"
:key="s.id"
class="toc-link"
:class="{ active: activeSection === s.id }"
:href="`#${s.id}`"
@click.prevent="scrollToSection(s.id)"
>
<component :is="sectionIcons[s.id]" class="toc-icon" />
<span class="toc-text">{{ s.title }}</span>
<span class="toc-badge">{{ s.endpoints.length }}</span>
</a> </a>
</div>
</nav> </nav>
<EndpointSection v-for="s in sections" :key="s.id" :section="s" /> <EndpointSection
v-for="s in sections"
:key="s.id"
:section="s"
:icon="sectionIcons[s.id]"
:collapsed="isCollapsed(s.id)"
@toggle="toggleSection(s.id)"
/>
</div> </div>
</a-layout-content> </a-layout-content>
</a-layout> </a-layout>
@ -194,20 +278,25 @@ onMounted(() => {
} }
.docs-header { .docs-header {
margin-bottom: 18px; margin-bottom: 20px;
padding: 24px;
background: var(--bg-card);
border: 1px solid rgba(128, 128, 128, 0.12);
border-radius: 10px;
} }
.docs-title { .docs-title {
font-size: 26px; font-size: 28px;
font-weight: 700; font-weight: 800;
margin: 0 0 8px; margin: 0 0 8px;
color: rgba(0, 0, 0, 0.88); color: rgba(0, 0, 0, 0.88);
letter-spacing: -0.3px;
} }
.docs-lead { .docs-lead {
margin: 0; margin: 0;
color: rgba(0, 0, 0, 0.65); color: rgba(0, 0, 0, 0.65);
line-height: 1.6; line-height: 1.65;
font-size: 14px; font-size: 14px;
} }
@ -231,7 +320,8 @@ onMounted(() => {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 8px; margin-bottom: 10px;
min-height: 32px;
} }
.token-card-title { .token-card-title {
@ -242,18 +332,6 @@ onMounted(() => {
font-size: 14px; font-size: 14px;
} }
.token-value {
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 6px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
margin: 0;
word-break: break-all;
white-space: pre-wrap;
}
.token-hint { .token-hint {
margin: 10px 0 0; margin: 10px 0 0;
color: rgba(0, 0, 0, 0.55); color: rgba(0, 0, 0, 0.55);
@ -275,35 +353,110 @@ onMounted(() => {
overflow-x: auto; overflow-x: auto;
} }
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.search-bar {
flex: 1;
min-width: 200px;
max-width: 480px;
}
.match-count {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
white-space: nowrap;
}
.toc-nav { .toc-nav {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: flex-start;
gap: 8px 14px; gap: 8px 12px;
padding: 12px 16px; padding: 12px 16px;
background: rgba(128, 128, 128, 0.08); background: var(--bg-card);
border-radius: 6px; border: 1px solid rgba(128, 128, 128, 0.12);
border-radius: 8px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.toc-label { .toc-label {
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.6px;
color: rgba(0, 0, 0, 0.5); color: rgba(0, 0, 0, 0.5);
padding-top: 3px;
flex-shrink: 0;
}
.toc-links {
display: flex;
flex-wrap: wrap;
gap: 6px;
} }
.toc-link { .toc-link {
color: #1677ff; display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12.5px;
color: rgba(0, 0, 0, 0.65);
background: rgba(128, 128, 128, 0.06);
border: 1px solid transparent;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
font-size: 13px; transition: all 0.2s;
white-space: nowrap;
} }
.toc-link:hover { .toc-link:hover {
color: #4096ff; background: rgba(22, 119, 255, 0.08);
text-decoration: underline; color: #1677ff;
border-color: rgba(22, 119, 255, 0.2);
}
.toc-link.active {
background: rgba(22, 119, 255, 0.12);
color: #1677ff;
border-color: rgba(22, 119, 255, 0.3);
font-weight: 600;
}
.toc-icon {
font-size: 13px;
opacity: 0.8;
}
.toc-text {
font-size: 12.5px;
}
.toc-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 10.5px;
font-weight: 700;
background: rgba(22, 119, 255, 0.12);
color: #1677ff;
line-height: 1;
}
.toc-link.active .toc-badge {
background: #1677ff;
color: #fff;
} }
</style> </style>
@ -312,28 +465,97 @@ body.dark .docs-title {
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} }
html[data-theme='ultra-dark'] .docs-title {
color: rgba(255, 255, 255, 0.95);
}
body.dark .docs-header {
background: #252526;
border-color: rgba(255, 255, 255, 0.08);
}
html[data-theme='ultra-dark'] .docs-header {
background: #0a0a0a;
border-color: rgba(255, 255, 255, 0.06);
}
body.dark .docs-lead, body.dark .docs-lead,
body.dark .token-hint { body.dark .token-hint {
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
} }
html[data-theme='ultra-dark'] .docs-lead,
html[data-theme='ultra-dark'] .token-hint {
color: rgba(255, 255, 255, 0.75);
}
body.dark .docs-lead code, body.dark .docs-lead code,
body.dark .token-hint code { body.dark .token-hint code {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
body.dark .token-value, html[data-theme='ultra-dark'] .docs-lead code,
html[data-theme='ultra-dark'] .token-hint code {
background: rgba(255, 255, 255, 0.12);
}
body.dark .code-block { body.dark .code-block {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.88); color: rgba(255, 255, 255, 0.88);
} }
html[data-theme='ultra-dark'] .code-block {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
}
body.dark .toc-nav { body.dark .toc-nav {
background: rgba(255, 255, 255, 0.04); background: #252526;
border-color: rgba(255, 255, 255, 0.08);
}
html[data-theme='ultra-dark'] .toc-nav {
background: #0a0a0a;
border-color: rgba(255, 255, 255, 0.06);
} }
body.dark .toc-label { body.dark .toc-label {
color: rgba(255, 255, 255, 0.55); color: rgba(255, 255, 255, 0.55);
} }
html[data-theme='ultra-dark'] .toc-label {
color: rgba(255, 255, 255, 0.6);
}
body.dark .toc-link {
color: rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.06);
}
html[data-theme='ultra-dark'] .toc-link {
background: rgba(255, 255, 255, 0.04);
}
body.dark .toc-link:hover {
background: rgba(88, 166, 255, 0.12);
color: #58a6ff;
border-color: rgba(88, 166, 255, 0.25);
}
body.dark .toc-link.active {
background: rgba(88, 166, 255, 0.15);
color: #58a6ff;
border-color: rgba(88, 166, 255, 0.35);
}
body.dark .toc-badge {
background: rgba(88, 166, 255, 0.15);
color: #58a6ff;
}
body.dark .toc-link.active .toc-badge {
background: #58a6ff;
color: #0d1117;
}
</style> </style>

View file

@ -0,0 +1,174 @@
<script setup>
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { CopyOutlined, CheckOutlined } from '@ant-design/icons-vue';
const props = defineProps({
code: { type: String, default: '' },
lang: { type: String, default: 'json' },
});
const copied = ref(false);
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function highlightJson(str) {
const escaped = escapeHtml(str);
return escaped.replace(
/("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b|(true|false)|(null)|([{}[\]])/g,
(_m, key, colon, string, number, bool, nil) => {
if (colon) return `<span class="json-key">${key}</span>${colon}`;
if (string) return `<span class="json-string">${string}</span>`;
if (number) return `<span class="json-number">${number}</span>`;
if (bool) return `<span class="json-boolean">${bool}</span>`;
if (nil) return `<span class="json-null">${nil}</span>`;
return _m;
}
);
}
const highlighted = computed(() => {
if (props.lang === 'json') {
return highlightJson(props.code);
}
return escapeHtml(props.code);
});
async function copyCode() {
try {
await navigator.clipboard.writeText(props.code);
copied.value = true;
message.success('Copied');
setTimeout(() => { copied.value = false; }, 2000);
} catch {
message.error('Copy failed');
}
}
</script>
<template>
<div class="code-block-wrapper">
<div class="code-toolbar">
<span class="lang-badge">{{ lang.toUpperCase() }}</span>
<button class="copy-btn" :class="{ copied }" @click="copyCode" :title="copied ? 'Copied' : 'Copy'">
<CheckOutlined v-if="copied" />
<CopyOutlined v-else />
</button>
</div>
<pre class="code-block" :class="`lang-${lang}`"><code v-html="highlighted"></code></pre>
</div>
</template>
<style scoped>
.code-block-wrapper {
position: relative;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.15);
}
.code-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
background: rgba(128, 128, 128, 0.06);
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
}
.lang-badge {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0.4);
text-transform: uppercase;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 4px;
background: rgba(255, 255, 255, 0.7);
color: rgba(0, 0, 0, 0.45);
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.copy-btn:hover {
background: #fff;
color: #1677ff;
border-color: #1677ff;
}
.copy-btn.copied {
background: #52c41a;
color: #fff;
border-color: #52c41a;
}
.code-block {
background: rgba(128, 128, 128, 0.04);
padding: 10px 12px;
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
border: none;
border-radius: 0;
}
</style>
<style>
.json-key { color: #0550ae; }
.json-string { color: #116329; }
.json-number { color: #9a6700; }
.json-boolean { color: #cf222e; }
.json-null { color: #8250df; }
body.dark .code-block-wrapper {
border-color: rgba(255, 255, 255, 0.1);
}
body.dark .code-toolbar {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
}
body.dark .lang-badge {
color: rgba(255, 255, 255, 0.4);
}
body.dark .code-block {
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.88);
}
body.dark .json-key { color: #79c0ff; }
body.dark .json-string { color: #7ee787; }
body.dark .json-number { color: #d29922; }
body.dark .json-boolean { color: #ff7b72; }
body.dark .json-null { color: #d2a8ff; }
body.dark .copy-btn {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.45);
border-color: rgba(255, 255, 255, 0.12);
}
body.dark .copy-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #58a6ff;
border-color: #58a6ff;
}
</style>

View file

@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { methodColors } from './endpoints.js'; import { methodColors, safeInlineHtml } from './endpoints.js';
import CodeBlock from './CodeBlock.vue';
const props = defineProps({ const props = defineProps({
endpoint: { type: Object, required: true }, endpoint: { type: Object, required: true },
@ -24,7 +25,7 @@ const paramColumns = [
<code class="endpoint-path">{{ endpoint.path }}</code> <code class="endpoint-path">{{ endpoint.path }}</code>
</div> </div>
<p v-if="endpoint.summary" class="endpoint-summary">{{ endpoint.summary }}</p> <p v-if="endpoint.summary" class="endpoint-summary" v-html="safeInlineHtml(endpoint.summary)"></p>
<div v-if="hasParams" class="endpoint-block"> <div v-if="hasParams" class="endpoint-block">
<div class="block-label">Parameters</div> <div class="block-label">Parameters</div>
@ -33,27 +34,39 @@ const paramColumns = [
<div v-if="endpoint.body" class="endpoint-block"> <div v-if="endpoint.body" class="endpoint-block">
<div class="block-label">Request body</div> <div class="block-label">Request body</div>
<a-typography-paragraph :copyable="{ text: endpoint.body }"> <CodeBlock :code="endpoint.body" lang="json" />
<pre class="code-block">{{ endpoint.body }}</pre>
</a-typography-paragraph>
</div> </div>
<div v-if="endpoint.response" class="endpoint-block"> <div v-if="endpoint.response" class="endpoint-block">
<div class="block-label">Response</div> <div class="block-label">Response</div>
<a-typography-paragraph :copyable="{ text: endpoint.response }"> <CodeBlock :code="endpoint.response" lang="json" />
<pre class="code-block">{{ endpoint.response }}</pre> </div>
</a-typography-paragraph>
<div v-if="endpoint.errorResponse" class="endpoint-block">
<div class="block-label error-label">Error response</div>
<CodeBlock :code="endpoint.errorResponse" lang="json" />
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.endpoint-row { .endpoint-row {
padding: 12px 0; padding: 14px 0;
margin: 0;
transition: background 0.15s;
border-radius: 6px;
padding-left: 8px;
padding-right: 8px;
margin-left: -8px;
margin-right: -8px;
}
.endpoint-row:hover {
background: rgba(128, 128, 128, 0.03);
} }
.endpoint-row + .endpoint-row { .endpoint-row + .endpoint-row {
border-top: 1px solid rgba(128, 128, 128, 0.15); border-top: 1px solid rgba(128, 128, 128, 0.1);
} }
.endpoint-header { .endpoint-header {
@ -64,38 +77,52 @@ const paramColumns = [
} }
.method-tag { .method-tag {
font-weight: 600; font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
min-width: 60px; min-width: 56px;
text-align: center; text-align: center;
text-transform: uppercase;
border-radius: 4px;
padding: 2px 8px;
line-height: 1.6;
} }
.endpoint-path { .endpoint-path {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px; font-size: 13.5px;
word-break: break-all; word-break: break-all;
color: rgba(0, 0, 0, 0.8);
background: rgba(128, 128, 128, 0.06);
padding: 2px 8px;
border-radius: 4px;
} }
.endpoint-summary { .endpoint-summary {
margin: 8px 0 0; margin: 8px 0 0;
color: rgba(0, 0, 0, 0.65); color: rgba(0, 0, 0, 0.6);
line-height: 1.55; line-height: 1.6;
font-size: 13.5px;
} }
.endpoint-block { .endpoint-block {
margin-top: 12px; margin-top: 14px;
} }
.block-label { .block-label {
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.6px;
color: rgba(0, 0, 0, 0.5); color: rgba(0, 0, 0, 0.45);
margin-bottom: 6px; margin-bottom: 6px;
} }
.error-label {
color: #cf222e;
}
.code-block { .code-block {
background: rgba(128, 128, 128, 0.08); background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15); border: 1px solid rgba(128, 128, 128, 0.15);
@ -112,12 +139,29 @@ const paramColumns = [
</style> </style>
<style> <style>
body.dark .endpoint-row:hover {
background: rgba(255, 255, 255, 0.02);
}
body.dark .endpoint-row + .endpoint-row {
border-top-color: rgba(255, 255, 255, 0.08);
}
body.dark .endpoint-path {
color: rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.05);
}
body.dark .endpoint-summary { body.dark .endpoint-summary {
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.65);
} }
body.dark .block-label { body.dark .block-label {
color: rgba(255, 255, 255, 0.55); color: rgba(255, 255, 255, 0.45);
}
body.dark .error-label {
color: #ff7b72;
} }
body.dark .code-block { body.dark .code-block {

View file

@ -1,16 +1,57 @@
<script setup> <script setup>
import { computed } from 'vue';
import {
DownOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
import EndpointRow from './EndpointRow.vue'; import EndpointRow from './EndpointRow.vue';
import { safeInlineHtml } from './endpoints.js';
defineProps({ const props = defineProps({
section: { type: Object, required: true }, section: { type: Object, required: true },
icon: { type: Object, default: null },
collapsed: { type: Boolean, default: false },
}); });
const emit = defineEmits(['toggle']);
const endpointLabel = computed(() =>
props.section.endpoints.length === 1
? '1 endpoint'
: `${props.section.endpoints.length} endpoints`
);
</script> </script>
<template> <template>
<section :id="section.id" class="api-section"> <section :id="section.id" class="api-section">
<div class="section-header" @click="emit('toggle')">
<div class="section-header-left">
<DownOutlined v-if="!collapsed" class="collapse-icon" />
<RightOutlined v-else class="collapse-icon" />
<component v-if="icon" :is="icon" class="section-icon" />
<h2 class="section-title">{{ section.title }}</h2> <h2 class="section-title">{{ section.title }}</h2>
<p v-if="section.description" class="section-description">{{ section.description }}</p> </div>
<div class="endpoints"> <span class="endpoint-count">{{ endpointLabel }}</span>
</div>
<p v-if="section.description && !collapsed" class="section-description" v-html="safeInlineHtml(section.description)"></p>
<div v-if="section.subHeader && !collapsed" class="sub-header-block">
<div class="block-label">Response headers</div>
<a-table
:columns="[{ title: 'Header', dataIndex: 'name', key: 'name', width: 240 }, { title: 'Description', dataIndex: 'desc', key: 'desc' }]"
:data-source="section.subHeader"
:pagination="false"
size="small"
row-key="name"
>
<template #bodyCell="{ column, text }">
<span v-if="column.dataIndex === 'desc'" v-html="safeInlineHtml(text)"></span>
<template v-else>{{ text }}</template>
</template>
</a-table>
</div>
<div v-show="!collapsed" class="endpoints">
<EndpointRow v-for="(endpoint, idx) in section.endpoints" :key="idx" :endpoint="endpoint" /> <EndpointRow v-for="(endpoint, idx) in section.endpoints" :key="idx" :endpoint="endpoint" />
</div> </div>
</section> </section>
@ -19,24 +60,89 @@ defineProps({
<style scoped> <style scoped>
.api-section { .api-section {
background: #fff; background: #fff;
border: 1px solid rgba(128, 128, 128, 0.15); border: 1px solid rgba(128, 128, 128, 0.12);
border-radius: 8px; border-radius: 8px;
padding: 20px 24px; padding: 20px 24px;
margin-bottom: 20px; margin-bottom: 16px;
scroll-margin-top: 16px; transition: box-shadow 0.2s, border-color 0.2s;
}
.api-section:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
}
.section-header:hover .collapse-icon,
.section-header:hover .section-icon {
color: #1677ff;
}
.section-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.collapse-icon {
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
transition: color 0.2s;
}
.section-icon {
font-size: 18px;
color: rgba(0, 0, 0, 0.45);
transition: color 0.2s;
} }
.section-title { .section-title {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 700;
margin: 0; margin: 0;
color: rgba(0, 0, 0, 0.88); color: rgba(0, 0, 0, 0.88);
} }
.endpoint-count {
font-size: 11px;
font-weight: 600;
color: rgba(0, 0, 0, 0.45);
white-space: nowrap;
background: rgba(128, 128, 128, 0.08);
padding: 3px 10px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.section-description { .section-description {
margin: 6px 0 14px; margin: 12px 0 14px;
color: rgba(0, 0, 0, 0.65); color: rgba(0, 0, 0, 0.65);
line-height: 1.55; line-height: 1.6;
}
.sub-header-block {
margin-bottom: 14px;
}
.block-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(0, 0, 0, 0.5);
margin-bottom: 6px;
}
.endpoints {
padding-top: 8px;
border-top: 1px solid rgba(128, 128, 128, 0.1);
} }
.endpoints > :first-child { .endpoints > :first-child {
@ -47,19 +153,40 @@ defineProps({
<style> <style>
body.dark .api-section { body.dark .api-section {
background: #252526; background: #252526;
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.08);
}
body.dark .api-section:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
} }
html[data-theme='ultra-dark'] .api-section { html[data-theme='ultra-dark'] .api-section {
background: #0a0a0a; background: #0a0a0a;
border-color: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.06);
}
html[data-theme='ultra-dark'] .api-section:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
} }
body.dark .section-title { body.dark .section-title {
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} }
body.dark .section-icon {
color: rgba(255, 255, 255, 0.5);
}
body.dark .section-description { body.dark .section-description {
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
} }
body.dark .block-label {
color: rgba(255, 255, 255, 0.55);
}
body.dark .endpoint-count {
color: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.06);
}
</style> </style>

View file

@ -1,6 +1,31 @@
export function safeInlineHtml(input) {
if (!input) return '';
const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const open = '<code>';
const close = '</code>';
let out = '';
let i = 0;
while (i < input.length) {
const oIdx = input.indexOf(open, i);
if (oIdx === -1) {
out += escape(input.slice(i));
break;
}
out += escape(input.slice(i, oIdx));
const cIdx = input.indexOf(close, oIdx + open.length);
if (cIdx === -1) {
out += escape(input.slice(oIdx));
break;
}
out += '<code>' + escape(input.slice(oIdx + open.length, cIdx)) + '</code>';
i = cIdx + close.length;
}
return out;
}
export const sections = [ export const sections = [
{ {
id: 'auth', id: 'authentication',
title: 'Authentication', title: 'Authentication',
description: description:
'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.', 'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.',
@ -17,11 +42,14 @@ export const sections = [
body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}', body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}',
response: response:
'{\n "success": true,\n "msg": "Logged in successfully"\n}', '{\n "success": true,\n "msg": "Logged in successfully"\n}',
errorResponse:
'{\n "success": false,\n "msg": "Wrong username or password"\n}',
}, },
{ {
method: 'GET', method: 'POST',
path: '/logout', path: '/logout',
summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.', summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.',
response: '{\n "success": true\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -41,9 +69,9 @@ export const sections = [
{ {
id: 'inbounds', id: 'inbounds',
title: 'Inbounds API', title: 'Inbounds',
description: description:
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.', 'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour forwarded headers only when the request comes from a configured trusted proxy.',
endpoints: [ endpoints: [
{ {
method: 'GET', method: 'GET',
@ -67,6 +95,7 @@ export const sections = [
params: [ params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' }, { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
], ],
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -75,6 +104,7 @@ export const sections = [
params: [ params: [
{ name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' }, { name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
], ],
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
}, },
{ {
method: 'POST', method: 'POST',
@ -82,6 +112,8 @@ export const sections = [
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).', summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
body: body:
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}', '{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
errorResponse:
'{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
}, },
{ {
method: 'POST', method: 'POST',
@ -161,6 +193,14 @@ export const sections = [
body: body:
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}', '{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
}, },
{
method: 'POST',
path: '/panel/api/inbounds/:id/resetTraffic',
summary: 'Zero out upload + download counters for a single inbound. Does not touch per-client counters.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
],
},
{ {
method: 'POST', method: 'POST',
path: '/panel/api/inbounds/:id/resetClientTraffic/:email', path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
@ -209,6 +249,7 @@ export const sections = [
method: 'POST', method: 'POST',
path: '/panel/api/inbounds/lastOnline', path: '/panel/api/inbounds/lastOnline',
summary: 'Map of client email → last-seen unix timestamp.', summary: 'Map of client email → last-seen unix timestamp.',
response: '{\n "success": true,\n "obj": [\n { "email": "user1", "lastOnline": 1700000000 },\n { "email": "user2", "lastOnline": 1699999000 }\n ]\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -256,7 +297,7 @@ export const sections = [
{ {
id: 'server', id: 'server',
title: 'Server API', title: 'Server',
description: description:
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.', 'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
endpoints: [ endpoints: [
@ -264,6 +305,7 @@ export const sections = [
method: 'GET', method: 'GET',
path: '/panel/api/server/status', path: '/panel/api/server/status',
summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.', summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.',
response: '{\n "success": true,\n "obj": {\n "cpu": 12.5,\n "mem": { "current": 2147483648, "total": 8589934592 },\n "swap": { "current": 0, "total": 4294967296 },\n "disk": { "current": 53687091200, "total": 268435456000 },\n "netIO": { "up": 1073741824, "down": 2147483648 },\n "xray": { "state": "running", "version": "v25.10.31" },\n "tcpCount": 42,\n "load": { "load1": 0.5, "load5": 0.3, "load15": 0.2 }\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -278,7 +320,36 @@ export const sections = [
path: '/panel/api/server/history/:metric/:bucket', path: '/panel/api/server/history/:metric/:bucket',
summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.', summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.',
params: [ params: [
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | swap | netIn | netOut | tcpCount | udpCount | load1 | online.' }, { name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | netUp | netDown | online | load1 | load5 | load15.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
],
response: '{\n "success": true,\n "obj": [\n { "t": 1700000000, "v": 12.5 },\n { "t": 1700000002, "v": 13.1 }\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/server/xrayMetricsState',
summary: 'Xray runtime metrics state — whether the xray config has a `metrics` block, which expvar keys are flowing, and the current snapshot values for each. Returns an empty state when metrics are not configured.',
},
{
method: 'GET',
path: '/panel/api/server/xrayMetricsHistory/:metric/:bucket',
summary: 'Time-series history for one Xray runtime metric over the last ~6 hours. Same {t, v} shape as /history/:metric/:bucket.',
params: [
{ name: 'metric', in: 'path', type: 'string', desc: 'xrAlloc | xrSys | xrHeapObjects | xrNumGC | xrPauseNs.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
],
},
{
method: 'GET',
path: '/panel/api/server/xrayObservatory',
summary: 'Latest snapshot from the Xray observatory — per-outbound latency, health status, and last-probe time. Only populated when the Xray config has an observatory configured.',
},
{
method: 'GET',
path: '/panel/api/server/xrayObservatoryHistory/:tag/:bucket',
summary: 'Time-series of observatory probe results for one outbound tag. Same {t, v} shape as the other history endpoints.',
params: [
{ name: 'tag', in: 'path', type: 'string', desc: 'Outbound tag from the observatory config.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' }, { name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
], ],
}, },
@ -286,6 +357,7 @@ export const sections = [
method: 'GET', method: 'GET',
path: '/panel/api/server/getXrayVersion', path: '/panel/api/server/getXrayVersion',
summary: 'List Xray binary versions available for install on this host.', summary: 'List Xray binary versions available for install on this host.',
response: '{\n "success": true,\n "obj": ["v25.10.31", "v25.9.15", "v25.8.1"]\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -295,7 +367,8 @@ export const sections = [
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getConfigJson', path: '/panel/api/server/getConfigJson',
summary: 'Return the assembled Xray config thats currently running on this host.', summary: 'Return the assembled Xray config that\u2019s currently running on this host.',
response: '{\n "success": true,\n "obj": {\n "log": { "loglevel": "warning" },\n "inbounds": [...],\n "outbounds": [...],\n "routing": { "rules": [...] }\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -306,36 +379,45 @@ export const sections = [
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewUUID', path: '/panel/api/server/getNewUUID',
summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.', summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
response: '{\n "success": true,\n "obj": "550e8400-e29b-41d4-a716-446655440000"\n}',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewX25519Cert', path: '/panel/api/server/getNewX25519Cert',
summary: 'Generate a new X25519 keypair for Reality.', summary: 'Generate a new X25519 keypair for Reality.',
response: '{\n "success": true,\n "obj": {\n "privateKey": "uN9qLfV3zH8w...",\n "publicKey": "5v8xPqR2sM7k..."\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewmldsa65', path: '/panel/api/server/getNewmldsa65',
summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.', summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
response: '{\n "success": true,\n "obj": {\n "privateKey": "mdsa65priv...",\n "publicKey": "mdsa65pub...",\n "seed": "random-seed..."\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewmlkem768', path: '/panel/api/server/getNewmlkem768',
summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.', summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
response: '{\n "success": true,\n "obj": {\n "clientKey": "mlkem768-client...",\n "serverKey": "mlkem768-server..."\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewVlessEnc', path: '/panel/api/server/getNewVlessEnc',
summary: 'Generate VLESS encryption auth options. Returns auths with id, label, decryption, and encryption.', summary: 'Generate VLESS encryption auth options. Returns an auths array each with id, label, encryption, and decryption fields.',
response: '{\n "success": true,\n "obj": {\n "auths": [\n { "id": 0, "label": "Auth #0", "encryption": "aes-256-gcm", "decryption": "" }\n ]\n }\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/server/stopXrayService', path: '/panel/api/server/stopXrayService',
summary: 'Stop the Xray binary. All proxies go offline immediately.', summary: 'Stop the Xray binary. All proxies go offline immediately.',
errorResponse:
'{\n "success": false,\n "msg": "Xray is not running"\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/server/restartXrayService', path: '/panel/api/server/restartXrayService',
summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.', summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
errorResponse:
'{\n "success": false,\n "msg": "Xray config is invalid: ..."\n}',
}, },
{ {
method: 'POST', method: 'POST',
@ -354,6 +436,10 @@ export const sections = [
method: 'POST', method: 'POST',
path: '/panel/api/server/updateGeofile', path: '/panel/api/server/updateGeofile',
summary: 'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.', summary: 'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
params: [
{ name: 'fileName', in: 'body (form)', type: 'string', desc: 'Filename to update (e.g. geoip.dat, geosite.dat). Omit to update all defaults.' },
],
body: 'fileName=geoip.dat',
}, },
{ {
method: 'POST', method: 'POST',
@ -366,11 +452,12 @@ export const sections = [
{ {
method: 'POST', method: 'POST',
path: '/panel/api/server/logs/:count', path: '/panel/api/server/logs/:count',
summary: 'Return the last N lines of the panels own log.', summary: 'Return the last N lines of the panel\u2019s own log.',
params: [ params: [
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' }, { name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
], ],
body: '{\n "level": "info",\n "syslog": false\n}', body: '{\n "level": "info",\n "syslog": false\n}',
response: '{\n "success": true,\n "obj": "2025/01/01 12:00:00 [INFO] Server started\\n2025/01/01 12:00:01 [INFO] Xray is running"\n}',
}, },
{ {
method: 'POST', method: 'POST',
@ -378,24 +465,38 @@ export const sections = [
summary: 'Return the last N lines of the Xray process log.', summary: 'Return the last N lines of the Xray process log.',
params: [ params: [
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' }, { name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
{ name: 'filter', in: 'body (form)', type: 'string', desc: 'Keyword filter — only lines containing this string.' },
{ name: 'showDirect', in: 'body (form)', type: 'string', desc: '"true" to include direct (freedom) traffic lines.' },
{ name: 'showBlocked', in: 'body (form)', type: 'string', desc: '"true" to include blocked (blackhole) traffic lines.' },
{ name: 'showProxy', in: 'body (form)', type: 'string', desc: '"true" to include proxy traffic lines.' },
], ],
body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
response: '{\n "success": true,\n "obj": "2025/01/01 12:00:00 rejected vless proxy example.com reason: no valid user\\n2025/01/01 12:00:01 direct freedom ok"\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/server/importDB', path: '/panel/api/server/importDB',
summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.', summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.',
params: [
{ name: 'db', in: 'body (multipart)', type: 'file', desc: 'SQLite database file to upload.' },
],
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/server/getNewEchCert', path: '/panel/api/server/getNewEchCert',
summary: 'Generate a new ECH (Encrypted Client Hello) keypair. Body picks the algorithm.', summary: 'Generate a new ECH (Encrypted Client Hello) keypair and config list for the given SNI.',
params: [
{ name: 'sni', in: 'body (form)', type: 'string', desc: 'Server Name Indication to generate the ECH config for.' },
],
body: 'sni=example.com',
response: '{\n "success": true,\n "obj": {\n "echKeySet": "...",\n "echServerKeys": [...],\n "echConfigList": "..."\n }\n}',
}, },
], ],
}, },
{ {
id: 'nodes', id: 'nodes',
title: 'Nodes API', title: 'Nodes',
description: description:
'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.', 'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
endpoints: [ endpoints: [
@ -403,6 +504,7 @@ export const sections = [
method: 'GET', method: 'GET',
path: '/panel/api/nodes/list', path: '/panel/api/nodes/list',
summary: 'List every configured node with its connection details, health, and last heartbeat patch.', summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "status": "online",\n "cpu": 23.5,\n "mem": 45.1\n }\n ]\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -422,10 +524,11 @@ export const sections = [
{ {
method: 'POST', method: 'POST',
path: '/panel/api/nodes/update/:id', path: '/panel/api/nodes/update/:id',
summary: 'Replace a nodes connection details. Same body shape as /add.', summary: 'Replace a node\u2019s connection details. Same body shape as /add.',
params: [ params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' }, { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
], ],
body: '{\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
}, },
{ {
method: 'POST', method: 'POST',
@ -448,6 +551,8 @@ export const sections = [
method: 'POST', method: 'POST',
path: '/panel/api/nodes/test', path: '/panel/api/nodes/test',
summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.', summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
body: '{\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "cpu": 12.5,\n "mem": 45.2\n }\n}',
}, },
{ {
method: 'POST', method: 'POST',
@ -463,16 +568,16 @@ export const sections = [
summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.', summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.',
params: [ params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' }, { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
{ name: 'metric', in: 'path', type: 'string', desc: 'Metric key (cpu, mem, netIn, …).' }, { name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds.' }, { name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
], ],
}, },
], ],
}, },
{ {
id: 'customGeo', id: 'custom-geo',
title: 'Custom Geo API', title: 'Custom Geo',
description: description:
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.', 'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
endpoints: [ endpoints: [
@ -531,12 +636,268 @@ export const sections = [
description: 'Operations that interact with the configured Telegram bot.', description: 'Operations that interact with the configured Telegram bot.',
endpoints: [ endpoints: [
{ {
method: 'GET', method: 'POST',
path: '/panel/api/backuptotgbot', path: '/panel/api/backuptotgbot',
summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.', summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
}, },
], ],
}, },
{
id: 'settings',
title: 'Settings',
description:
'Panel configuration and user credentials. All endpoints live under /panel/setting and require a logged-in session or Bearer token.',
endpoints: [
{
method: 'POST',
path: '/panel/setting/all',
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
},
{
method: 'POST',
path: '/panel/setting/defaultSettings',
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
},
{
method: 'POST',
path: '/panel/setting/update',
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
},
{
method: 'POST',
path: '/panel/setting/updateUser',
summary: 'Change the panel admin username and password. Requires the current credentials for verification. The session is refreshed with the new values on success.',
params: [
{ name: 'oldUsername', in: 'body', type: 'string', desc: 'Current admin username.' },
{ name: 'oldPassword', in: 'body', type: 'string', desc: 'Current admin password.' },
{ name: 'newUsername', in: 'body', type: 'string', desc: 'Desired new username.' },
{ name: 'newPassword', in: 'body', type: 'string', desc: 'Desired new password.' },
],
body: '{\n "oldUsername": "admin",\n "oldPassword": "admin",\n "newUsername": "newadmin",\n "newPassword": "newpass"\n}',
},
{
method: 'POST',
path: '/panel/setting/restartPanel',
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
},
{
method: 'GET',
path: '/panel/setting/getDefaultJsonConfig',
summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
},
],
},
{
id: 'api-tokens',
title: 'API Tokens',
description:
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored plaintext so the SPA can show them on demand. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request.',
endpoints: [
{
method: 'GET',
path: '/panel/setting/apiTokens',
summary: 'List every API token, enabled or not.',
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "token": "abcdef-12345-...",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
},
{
method: 'POST',
path: '/panel/setting/apiTokens/create',
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.',
params: [
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
],
body: '{\n "name": "central-panel-a"\n}',
response: '{\n "success": true,\n "obj": {\n "id": 2,\n "name": "central-panel-a",\n "token": "new-token-string",\n "enabled": true,\n "createdAt": 1736000000\n }\n}',
errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}',
},
{
method: 'POST',
path: '/panel/setting/apiTokens/delete/:id',
summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
],
response: '{\n "success": true\n}',
},
{
method: 'POST',
path: '/panel/setting/apiTokens/setEnabled/:id',
summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
{ name: 'enabled', in: 'body', type: 'boolean', desc: 'New enabled state.' },
],
body: '{\n "enabled": false\n}',
response: '{\n "success": true\n}',
},
],
},
{
id: 'xray-settings',
title: 'Xray Settings',
description:
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.',
endpoints: [
{
method: 'POST',
path: '/panel/xray/',
summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.',
response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"inbound-443\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}',
},
{
method: 'GET',
path: '/panel/xray/getDefaultJsonConfig',
summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/setting/getDefaultJsonConfig).',
},
{
method: 'GET',
path: '/panel/xray/getOutboundsTraffic',
summary: 'Return traffic statistics for every outbound. Each outbound shows up/down/total counters.',
},
{
method: 'GET',
path: '/panel/xray/getXrayResult',
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
},
{
method: 'POST',
path: '/panel/xray/update',
summary: 'Save the Xray JSON config template and optionally the outbound test URL. Both are sent as form fields.',
params: [
{ name: 'xraySetting', in: 'body (form)', type: 'string', desc: 'Full Xray JSON config template.' },
{ name: 'outboundTestUrl', in: 'body (form)', type: 'string', desc: 'URL used for outbound reachability tests. Defaults to https://www.google.com/generate_204.' },
],
},
{
method: 'POST',
path: '/panel/xray/warp/:action',
summary: 'Manage Cloudflare Warp integration. The action parameter selects the operation.',
params: [
{ name: 'action', in: 'path', type: 'string', desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).' },
{ name: 'privateKey', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
{ name: 'publicKey', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
{ name: 'license', in: 'body (form)', type: 'string', desc: 'Required when action=license.' },
],
},
{
method: 'POST',
path: '/panel/xray/nord/:action',
summary: 'Manage NordVPN integration. The action parameter selects the operation.',
params: [
{ name: 'action', in: 'path', type: 'string', desc: 'countries — list available countries. servers — list servers in a country (sends countryId). reg — get NordVPN credentials (sends token). setKey — store NordVPN API key (sends key). data — return current NordVPN connection data. del — delete NordVPN data.' },
{ name: 'countryId', in: 'body (form)', type: 'string', desc: 'Required when action=servers.' },
{ name: 'token', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
{ name: 'key', in: 'body (form)', type: 'string', desc: 'Required when action=setKey.' },
],
},
{
method: 'POST',
path: '/panel/xray/resetOutboundsTraffic',
summary: 'Reset traffic counters for a specific outbound by tag.',
params: [
{ name: 'tag', in: 'body (form)', type: 'string', desc: 'Outbound tag to reset (e.g. "proxy", "direct").' },
],
body: 'tag=proxy',
},
{
method: 'POST',
path: '/panel/xray/testOutbound',
summary: 'Test an outbound configuration. Sends the outbound JSON (required), optionally all outbounds (to resolve sockopt.dialerProxy dependencies), and a mode flag.',
params: [
{ name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },
{ name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' },
{ name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for a fast dial-only probe (parallel-safe). Default/empty uses a full HTTP probe through a temp xray instance.' },
],
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
},
],
},
{
id: 'subscription',
title: 'Subscription Server',
description:
'A separate HTTP/HTTPS server that serves proxy subscription links (standard, JSON, and Clash) to clients. The server listens on its own port (default 10882) and is configured in Settings → Subscription. Paths are configurable; defaults are shown below. All subscription endpoints set response headers for client apps to read traffic/expiry info.',
subHeader: [
{ name: 'Subscription-Userinfo', desc: 'Traffic and expiry: <code>upload=N; download=N; total=N; expire=TS</code>' },
{ name: 'Profile-Title', desc: 'Base64-encoded subscription display name' },
{ name: 'Profile-Web-Page-Url', desc: 'Link to the subscription info page' },
{ name: 'Support-Url', desc: 'Support contact URL configured in settings' },
{ name: 'Profile-Update-Interval', desc: 'Suggested polling interval in minutes (e.g. <code>10</code>)' },
{ name: 'Announce', desc: 'Base64-encoded announcement string' },
{ name: 'Routing-Enable', desc: '<code>true</code> or <code>false</code> — whether routing rules are included' },
{ name: 'Routing', desc: 'Global routing rules for client apps that support them (e.g. Happ)' },
],
endpoints: [
{
method: 'GET',
path: '/{subPath}:subid',
summary: 'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. Default path: /sub/:subid.',
params: [
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
],
},
{
method: 'GET',
path: '/{jsonPath}:subid',
summary: 'Return subscription as a JSON array of proxy configs (one per enabled client). Only when JSON subscription is enabled in settings. Default path: /json/:subid.',
params: [
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
],
},
{
method: 'GET',
path: '/{clashPath}:subid',
summary: 'Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
params: [
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
],
},
],
},
{
id: 'websocket',
title: 'WebSocket',
description:
'Real-time status updates via WebSocket. Connect once at <code>ws://<panel>/ws</code> to receive a stream of JSON messages without polling. Requires an authenticated session cookie (Bearer token auth is not supported). Each message has a <code>type</code> field that identifies the payload shape.',
endpoints: [
{
method: 'GET',
path: '/ws',
summary: 'Upgrade an HTTP connection to a WebSocket. Requires an authenticated session cookie (Bearer token auth is not supported here). Returns 101 Switching Protocols on success. The server then pushes JSON messages described below.',
},
{
method: 'WS',
path: '→ type: status',
summary: 'Server health snapshot pushed every 2 seconds. Contains CPU, memory, swap, disk, network IO, load, and Xray state — same shape as <code>GET /panel/api/server/status</code>.',
response: '{\n "type": "status",\n "data": { "cpu": 12.5, "mem": { "current": 2147483648, "total": 8589934592 }, "xray": { "state": "running" } }\n}',
},
{
method: 'WS',
path: '→ type: xrayState',
summary: 'Xray process state change. Fired when Xray starts, stops, or encounters an error.',
response: '{\n "type": "xrayState",\n "data": "running"\n}',
},
{
method: 'WS',
path: '→ type: notification',
summary: 'In-panel toast notification. Fired on Xray stop/restart, DB import, panel restart, etc.',
response: '{\n "type": "notification",\n "title": "Xray service restarted",\n "body": "Xray has been restarted successfully",\n "severity": "success"\n}',
},
{
method: 'WS',
path: '→ type: invalidate',
summary: 'Instructs the UI to re-fetch a resource. Fired when another admin session modifies data (e.g. toggling inbound enable).',
response: '{\n "type": "invalidate",\n "resource": "inbounds"\n}',
},
],
},
]; ];
export const methodColors = { export const methodColors = {
@ -545,4 +906,5 @@ export const methodColors = {
PUT: 'orange', PUT: 'orange',
PATCH: 'orange', PATCH: 'orange',
DELETE: 'red', DELETE: 'red',
WS: 'purple',
}; };

View file

@ -32,6 +32,7 @@ const props = defineProps({
lastOnlineMap: { type: Object, default: () => ({}) }, lastOnlineMap: { type: Object, default: () => ({}) },
isDarkTheme: { type: Boolean, default: false }, isDarkTheme: { type: Boolean, default: false },
pageSize: { type: Number, default: 0 }, pageSize: { type: Number, default: 0 },
totalClientCount: { type: Number, default: 0 },
}); });
const emit = defineEmits([ const emit = defineEmits([
@ -138,7 +139,7 @@ function statsExpColor(email) {
return PURPLE; return PURPLE;
} }
const isRemovable = computed(() => clients.value.length > 1); const isRemovable = computed(() => (props.totalClientCount || clients.value.length) > 1);
function totalGbDisplay(client) { function totalGbDisplay(client) {
if (!client.totalGB || client.totalGB <= 0) return ''; if (!client.totalGB || client.totalGB <= 0) return '';

View file

@ -12,6 +12,7 @@ import {
SizeFormatter, SizeFormatter,
Wireguard, Wireguard,
} from '@/utils'; } from '@/utils';
import { getRandomRealityTarget } from '@/models/reality-targets';
import { import {
Inbound, Inbound,
Protocols, Protocols,
@ -69,6 +70,7 @@ const inbound = ref(null);
const dbForm = ref(null); const dbForm = ref(null);
const saving = ref(false); const saving = ref(false);
const advancedJson = ref({ stream: '', sniffing: '', settings: '' }); const advancedJson = ref({ stream: '', sniffing: '', settings: '' });
const activeTabKey = ref('basic');
// Cached default cert/key paths from /panel/setting/defaultSettings // Cached default cert/key paths from /panel/setting/defaultSettings
// powers the "Set default cert" button on the TLS form. // powers the "Set default cert" button on the TLS form.
const defaultCert = ref(''); const defaultCert = ref('');
@ -240,9 +242,60 @@ watch(() => props.open, (next) => {
dbForm.value = freshDbForm(); dbForm.value = freshDbForm();
primeAdvancedJson(); primeAdvancedJson();
} }
activeTabKey.value = 'basic';
fetchDefaultCertSettings(); fetchDefaultCertSettings();
}); });
function applyAdvancedJsonToBasic() {
if (!inbound.value) return true;
let parsedSettings;
let parsedStream;
let parsedSniffing;
try {
parsedSettings = advancedJson.value.settings.trim()
? JSON.parse(advancedJson.value.settings)
: inbound.value.settings?.toJson?.();
} catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return false; }
try {
parsedStream = advancedJson.value.stream.trim()
? JSON.parse(advancedJson.value.stream)
: inbound.value.stream?.toJson?.();
} catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return false; }
try {
parsedSniffing = advancedJson.value.sniffing.trim()
? JSON.parse(advancedJson.value.sniffing)
: inbound.value.sniffing?.toJson?.();
} catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return false; }
try {
inbound.value = Inbound.fromJson({
port: inbound.value.port,
listen: inbound.value.listen,
protocol: inbound.value.protocol,
settings: parsedSettings,
streamSettings: parsedStream,
tag: inbound.value.tag,
sniffing: parsedSniffing,
clientStats: inbound.value.clientStats,
});
} catch (e) {
message.error(`Advanced JSON: ${e.message}`);
return false;
}
return true;
}
let isRevertingTab = false;
watch(activeTabKey, (next, prev) => {
if (isRevertingTab) { isRevertingTab = false; return; }
if (prev === 'advanced' && next !== 'advanced') {
if (!applyAdvancedJsonToBasic()) {
isRevertingTab = true;
activeTabKey.value = 'advanced';
}
}
});
// In add mode, switching protocol restamps settings + re-syncs port. // In add mode, switching protocol restamps settings + re-syncs port.
function onProtocolChange(next) { function onProtocolChange(next) {
if (props.mode === 'edit' || !inbound.value) return; if (props.mode === 'edit' || !inbound.value) return;
@ -339,11 +392,9 @@ function clearMldsa65() {
inbound.value.stream.reality.settings.mldsa65Verify = ''; inbound.value.stream.reality.settings.mldsa65Verify = '';
} }
// Reality target/SNI randomizer only available if the helper is loaded
function randomizeRealityTarget() { function randomizeRealityTarget() {
if (!inbound.value?.stream?.reality) return; if (!inbound.value?.stream?.reality) return;
if (typeof window.getRandomRealityTarget !== 'function') return; const t = getRandomRealityTarget();
const t = window.getRandomRealityTarget();
inbound.value.stream.reality.target = t.target; inbound.value.stream.reality.target = t.target;
inbound.value.stream.reality.serverNames = t.sni; inbound.value.stream.reality.serverNames = t.sni;
} }
@ -573,7 +624,7 @@ watch(
<template> <template>
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :confirm-loading="saving" <a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :confirm-loading="saving"
:mask-closable="false" width="780px" @ok="submit" @cancel="close"> :mask-closable="false" width="780px" @ok="submit" @cancel="close">
<a-tabs v-if="inbound && dbForm" default-active-key="basic"> <a-tabs v-if="inbound && dbForm" v-model:active-key="activeTabKey">
<!-- ============================== BASICS ============================== --> <!-- ============================== BASICS ============================== -->
<a-tab-pane key="basic" :tab="t('pages.xray.basicTemplate')"> <a-tab-pane key="basic" :tab="t('pages.xray.basicTemplate')">
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }"> <a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
@ -583,7 +634,7 @@ watch(
<a-form-item :label="t('pages.inbounds.remark')"> <a-form-item :label="t('pages.inbounds.remark')">
<a-input v-model:value="dbForm.remark" /> <a-input v-model:value="dbForm.remark" />
</a-form-item> </a-form-item>
<a-form-item :label="t('pages.inbounds.deployTo')"> <a-form-item v-if="selectableNodes.length > 0" :label="t('pages.inbounds.deployTo')">
<a-select v-model:value="dbForm.nodeId" :disabled="mode === 'edit'" <a-select v-model:value="dbForm.nodeId" :disabled="mode === 'edit'"
:placeholder="t('pages.inbounds.localPanel')" allow-clear> :placeholder="t('pages.inbounds.localPanel')" allow-clear>
<a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option> <a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
@ -1671,6 +1722,74 @@ watch(
</a-select> </a-select>
</a-form-item> </a-form-item>
</template> </template>
<!-- ====== Hysteria Masquerade ====== -->
<!-- Per https://xtls.github.io/config/transports/hysteria.html#masqobject -->
<template v-if="protocol === Protocols.HYSTERIA">
<a-form-item label="Masquerade">
<a-switch v-model:checked="inbound.stream.hysteria.masqueradeSwitch" />
</a-form-item>
<template v-if="inbound.stream.hysteria.masqueradeSwitch">
<a-form-item label="Type">
<a-select v-model:value="inbound.stream.hysteria.masquerade.type" :style="{ width: '50%' }">
<a-select-option value="proxy">Proxy</a-select-option>
<a-select-option value="file">File</a-select-option>
<a-select-option value="string">String</a-select-option>
</a-select>
</a-form-item>
<!-- Proxy type: url / rewriteHost / insecure -->
<template v-if="inbound.stream.hysteria.masquerade.type === 'proxy'">
<a-form-item label="URL">
<a-input v-model:value="inbound.stream.hysteria.masquerade.url" placeholder="https://example.com" />
</a-form-item>
<a-form-item label="Rewrite Host">
<a-switch v-model:checked="inbound.stream.hysteria.masquerade.rewriteHost" />
</a-form-item>
<a-form-item label="Insecure">
<a-switch v-model:checked="inbound.stream.hysteria.masquerade.insecure" />
</a-form-item>
</template>
<!-- File type: dir -->
<a-form-item v-if="inbound.stream.hysteria.masquerade.type === 'file'" label="Directory">
<a-input v-model:value="inbound.stream.hysteria.masquerade.dir" placeholder="/path/to/www" />
</a-form-item>
<!-- String type: content / statusCode / headers -->
<template v-if="inbound.stream.hysteria.masquerade.type === 'string'">
<a-form-item label="Content">
<a-textarea v-model:value="inbound.stream.hysteria.masquerade.content"
:auto-size="{ minRows: 2, maxRows: 6 }" />
</a-form-item>
<a-form-item label="Status Code">
<a-input-number v-model:value="inbound.stream.hysteria.masquerade.statusCode" :min="100" :max="599"
placeholder="200" />
</a-form-item>
<a-form-item label="Headers">
<a-button size="small" @click="inbound.stream.hysteria.masquerade.addHeader('', '')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item v-if="inbound.stream.hysteria.masquerade.headers.length > 0" :wrapper-col="{ span: 24 }">
<a-input-group v-for="(h, idx) in inbound.stream.hysteria.masquerade.headers" :key="`mh-${idx}`"
compact class="mb-8">
<a-input :style="{ width: '45%' }" v-model:value="h.name" placeholder="Name">
<template #addonBefore>{{ idx + 1 }}</template>
</a-input>
<a-input :style="{ width: '45%' }" v-model:value="h.value" placeholder="Value" />
<a-button @click="inbound.stream.hysteria.masquerade.removeHeader(idx)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</a-form-item>
</template>
</template>
</template>
</a-form> </a-form>
<!-- ====== FinalMask (TCP/UDP masks + QUIC params) ====== --> <!-- ====== FinalMask (TCP/UDP masks + QUIC params) ====== -->

View file

@ -49,6 +49,7 @@ const props = defineProps({
// Map node id -> node row, supplied by the parent page so each // Map node id -> node row, supplied by the parent page so each
// inbound row can render its node name without an extra fetch. // inbound row can render its node name without an extra fetch.
nodesById: { type: Map, default: () => new Map() }, nodesById: { type: Map, default: () => new Map() },
hasActiveNode: { type: Boolean, default: false },
}); });
const emit = defineEmits([ const emit = defineEmits([
@ -67,9 +68,29 @@ const emit = defineEmits([
]); ]);
// ============ Toolbar / search & filter ============================= // ============ Toolbar / search & filter =============================
const enableFilter = ref(false); const FILTER_STATE_KEY = 'inboundsFilterState';
const searchKey = ref(''); const savedFilterState = (() => {
const filterBy = ref(''); try {
return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
} catch (_e) {
return {};
}
})();
const enableFilter = ref(!!savedFilterState.enableFilter);
const searchKey = ref(savedFilterState.searchKey || '');
const filterBy = ref(savedFilterState.filterBy || '');
const protocolFilter = ref(savedFilterState.protocolFilter || '');
const nodeFilter = ref(savedFilterState.nodeFilter || '');
watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
enableFilter: enableFilter.value,
searchKey: searchKey.value,
filterBy: filterBy.value,
protocolFilter: protocolFilter.value,
nodeFilter: nodeFilter.value,
}));
});
// Toggle the filter mode flip cleans the other input. // Toggle the filter mode flip cleans the other input.
function onToggleFilter() { function onToggleFilter() {
@ -77,6 +98,35 @@ function onToggleFilter() {
else filterBy.value = ''; else filterBy.value = '';
} }
const protocolOptions = computed(() => {
const values = new Set(props.dbInbounds.map((i) => i.protocol).filter(Boolean));
return [...values].sort();
});
const nodeOptions = computed(() => {
const values = new Map();
if (props.dbInbounds.some((i) => i.nodeId == null)) {
values.set('local', t('pages.inbounds.localPanel'));
}
for (const dbInbound of props.dbInbounds) {
if (dbInbound.nodeId == null) continue;
const node = props.nodesById.get(dbInbound.nodeId);
values.set(String(dbInbound.nodeId), node?.name || `#${dbInbound.nodeId}`);
}
return [...values.entries()].map(([value, label]) => ({ value, label }));
});
function applySecondaryFilters(rows) {
return rows.filter((dbInbound) => {
if (protocolFilter.value && dbInbound.protocol !== protocolFilter.value) return false;
if (nodeFilter.value) {
const nodeValue = dbInbound.nodeId == null ? 'local' : String(dbInbound.nodeId);
if (nodeValue !== nodeFilter.value) return false;
}
return true;
});
}
// ============ Search / filter projection ============================= // ============ Search / filter projection =============================
// Mirrors the legacy logic: when searching, keep inbounds that match // Mirrors the legacy logic: when searching, keep inbounds that match
// anywhere (deep search); when filtering, keep inbounds that have at // anywhere (deep search); when filtering, keep inbounds that have at
@ -99,7 +149,7 @@ function projectInbound(dbInbound, predicate) {
const visibleInbounds = computed(() => { const visibleInbounds = computed(() => {
if (enableFilter.value) { if (enableFilter.value) {
if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds]; if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
const out = []; const out = [];
for (const dbInbound of props.dbInbounds) { for (const dbInbound of props.dbInbounds) {
const c = props.clientCount[dbInbound.id]; const c = props.clientCount[dbInbound.id];
@ -107,15 +157,15 @@ const visibleInbounds = computed(() => {
const list = c[filterBy.value]; const list = c[filterBy.value];
out.push(projectInbound(dbInbound, (client) => list.includes(client.email))); out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
} }
return out; return applySecondaryFilters(out);
} }
if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds]; if (ObjectUtil.isEmpty(searchKey.value)) return applySecondaryFilters([...props.dbInbounds]);
const out = []; const out = [];
for (const dbInbound of props.dbInbounds) { for (const dbInbound of props.dbInbounds) {
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue; if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value))); out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
} }
return out; return applySecondaryFilters(out);
}); });
// ============ Sorting ================================================= // ============ Sorting =================================================
@ -185,7 +235,7 @@ const desktopColumns = computed(() => {
if (hasAnyRemark.value) { if (hasAnyRemark.value) {
cols.push(sortableCol({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, 'remark')); cols.push(sortableCol({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, 'remark'));
} }
if (props.nodesById.size > 0) { if (props.hasActiveNode) {
cols.push(sortableCol({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 }, 'node')); cols.push(sortableCol({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 }, 'node'));
} }
cols.push( cols.push(
@ -319,6 +369,18 @@ function showQrCodeMenu(dbInbound) {
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button> <a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
<a-radio-button value="online">{{ t('online') }}</a-radio-button> <a-radio-button value="online">{{ t('online') }}</a-radio-button>
</a-radio-group> </a-radio-group>
<a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
:size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
<a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
{{ protocol }}
</a-select-option>
</a-select>
<a-select v-if="hasActiveNode && nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
:placeholder="t('pages.inbounds.node')" :size="isMobile ? 'small' : 'middle'" :style="{ width: '170px' }">
<a-select-option v-for="node in nodeOptions" :key="node.value" :value="node.value">
{{ node.label }}
</a-select-option>
</a-select>
</div> </div>
<!-- ====================== Mobile: card list ======================= --> <!-- ====================== Mobile: card list ======================= -->
@ -395,8 +457,8 @@ function showQrCodeMenu(dbInbound) {
<div class="stat-row"> <div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span> <span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
<a-tag color="purple">{{ record.protocol }}</a-tag> <a-tag color="purple">{{ record.protocol }}</a-tag>
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS"> <template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag> <a-tag color="green">{{ record.isHysteria ? 'UDP' : record.toInbound().stream.network }}</a-tag>
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag> <a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag> <a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template> </template>
@ -405,7 +467,7 @@ function showQrCodeMenu(dbInbound) {
<span class="stat-label">{{ t('pages.inbounds.port') }}</span> <span class="stat-label">{{ t('pages.inbounds.port') }}</span>
<a-tag>{{ record.port }}</a-tag> <a-tag>{{ record.port }}</a-tag>
</div> </div>
<div v-if="nodesById.size > 0" class="stat-row"> <div v-if="hasActiveNode" class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.node') }}</span> <span class="stat-label">{{ t('pages.inbounds.node') }}</span>
<a-tag v-if="record.nodeId == null" color="default"> <a-tag v-if="record.nodeId == null" color="default">
{{ t('pages.inbounds.localPanel') }} {{ t('pages.inbounds.localPanel') }}
@ -430,7 +492,7 @@ function showQrCodeMenu(dbInbound) {
</div> </div>
<div v-if="clientCount[record.id]" class="stat-row"> <div v-if="clientCount[record.id]" class="stat-row">
<span class="stat-label">{{ t('clients') }}</span> <span class="stat-label">{{ t('clients') }}</span>
<a-tag color="green">{{ clientCount[record.id].clients }}</a-tag> <a-tag color="green" class="client-count-tag">{{ clientCount[record.id].clients }}</a-tag>
<a-tag v-if="clientCount[record.id].online.length" color="blue"> <a-tag v-if="clientCount[record.id].online.length" color="blue">
{{ clientCount[record.id].online.length }} {{ t('online') }} {{ clientCount[record.id].online.length }} {{ t('online') }}
</a-tag> </a-tag>
@ -457,7 +519,7 @@ function showQrCodeMenu(dbInbound) {
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients"> <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff" <ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
:page-size="pageSize" :page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)" @edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
@info-client="(p) => emit('info-client', p)" @info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)" @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@ -479,6 +541,7 @@ function showQrCodeMenu(dbInbound) {
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile" <ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients" :traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
:total-client-count="clientCount[record.id]?.clients || 0"
@edit-client="(p) => emit('edit-client', p)" @edit-client="(p) => emit('edit-client', p)"
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)" @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@ -570,8 +633,8 @@ function showQrCodeMenu(dbInbound) {
<template v-else-if="column.key === 'protocol'"> <template v-else-if="column.key === 'protocol'">
<div class="protocol-tags"> <div class="protocol-tags">
<a-tag color="purple">{{ record.protocol }}</a-tag> <a-tag color="purple">{{ record.protocol }}</a-tag>
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS"> <template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag> <a-tag color="green">{{ record.isHysteria ? 'UDP' : record.toInbound().stream.network }}</a-tag>
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag> <a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag> <a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template> </template>
@ -581,14 +644,14 @@ function showQrCodeMenu(dbInbound) {
<!-- ============== Clients tag + popovers ============== --> <!-- ============== Clients tag + popovers ============== -->
<template v-else-if="column.key === 'clients'"> <template v-else-if="column.key === 'clients'">
<template v-if="clientCount[record.id]"> <template v-if="clientCount[record.id]">
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag> <a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].clients }}</a-tag>
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')"> <a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
<template #content> <template #content>
<div class="client-email-list"> <div class="client-email-list">
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
</div> </div>
</template> </template>
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag> <a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
</a-popover> </a-popover>
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')"> <a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
<template #content> <template #content>
@ -596,7 +659,7 @@ function showQrCodeMenu(dbInbound) {
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
</div> </div>
</template> </template>
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length <a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
}}</a-tag> }}</a-tag>
</a-popover> </a-popover>
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')"> <a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
@ -605,7 +668,7 @@ function showQrCodeMenu(dbInbound) {
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
</div> </div>
</template> </template>
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length <a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
}}</a-tag> }}</a-tag>
</a-popover> </a-popover>
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')"> <a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
@ -614,7 +677,7 @@ function showQrCodeMenu(dbInbound) {
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
</div> </div>
</template> </template>
<a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag> <a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
</a-popover> </a-popover>
</template> </template>
</template> </template>
@ -689,6 +752,10 @@ function showQrCodeMenu(dbInbound) {
gap: 4px; gap: 4px;
} }
.client-count-tag {
font-variant-numeric: tabular-nums;
}
.row-action-trigger { .row-action-trigger {
font-size: 20px; font-size: 20px;
cursor: pointer; cursor: pointer;

View file

@ -66,7 +66,7 @@ useWebSocket({
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
// Node list lives on the central panel; the Inbounds page consumes // Node list lives on the central panel; the Inbounds page consumes
// the idnode map for the new "Node" column. Fetched once on mount. // the idnode map for the new "Node" column. Fetched once on mount.
const { byId: nodesById } = useNodeList(); const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
const basePath = window.X_UI_BASE_PATH || ''; const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname; const requestUri = window.location.pathname;
@ -396,7 +396,7 @@ function confirmResetTraffic(dbInbound) {
okText: 'Reset', okText: 'Reset',
cancelText: 'Cancel', cancelText: 'Cancel',
onOk: async () => { onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllTraffics`); const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
if (msg?.success) await refresh(); if (msg?.success) await refresh();
}, },
}); });
@ -647,7 +647,8 @@ function onRowAction({ key, dbInbound }) {
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients" <InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff" :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile" :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh" :sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
@refresh="refresh"
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction" @add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient" @edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient" @reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"

View file

@ -53,7 +53,9 @@ function parseLogLine(line) {
service = 'X-UI:'; service = 'X-UI:';
} }
return { date, time, levelText, levelClass, service, body }; const stamp = [date, time].filter(Boolean).join(' ');
return { date, time, stamp, levelText, levelClass, service, body };
} }
const parsedLogs = computed(() => logs.value.map(parseLogLine)); const parsedLogs = computed(() => logs.value.map(parseLogLine));
@ -133,33 +135,25 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
<template v-else-if="isMobile"> <template v-else-if="isMobile">
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card"> <div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
<div class="log-card-head"> <div class="log-card-head">
<span v-if="log.date || log.time" class="log-time"> <span v-if="log.stamp" class="log-time">
<span v-if="log.time">{{ log.time }}</span> <span v-if="log.time">{{ log.time }}</span>{{ log.time && log.date ? ' ' : '' }}<span v-if="log.date" class="log-date">{{ log.date }}</span>
<span v-if="log.date" class="log-date">{{ log.date }}</span>
</span> </span>
<span v-if="log.levelText" class="log-level-badge" :class="log.levelClass"> <span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
{{ log.levelText }} {{ log.levelText }}
</span> </span>
</div> </div>
<div v-if="log.body || log.service" class="log-body"> <div v-if="log.body || log.service" class="log-body">
<b v-if="log.service">{{ log.service }}</b> <b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
</div> </div>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line"> <div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
<span v-if="log.date || log.time" class="log-stamp"> <span v-if="log.stamp" class="log-stamp">{{ log.stamp }}</span>{{ log.stamp && log.levelText ? ' ' : '' }}<span v-if="log.levelText" class="log-level" :class="log.levelClass">{{ log.levelText }}</span>
{{ log.date }}<template v-if="log.date && log.time"> </template>{{ log.time }}
</span>
<span v-if="log.levelText" class="log-level" :class="log.levelClass">
{{ log.levelText }}
</span>
<template v-if="log.body || log.service"> <template v-if="log.body || log.service">
<span> - </span> <span> - </span>
<b v-if="log.service">{{ log.service }} </b> <b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span>{{ log.body }}</span>
<span>{{ log.body }}</span>
</template> </template>
</div> </div>
</template> </template>

View file

@ -28,6 +28,7 @@ function defaultForm() {
basePath: '/', basePath: '/',
apiToken: '', apiToken: '',
enable: true, enable: true,
allowPrivateAddress: false,
}; };
} }
@ -69,6 +70,7 @@ function buildPayload() {
basePath: form.basePath?.trim() || '/', basePath: form.basePath?.trim() || '/',
apiToken: form.apiToken?.trim() || '', apiToken: form.apiToken?.trim() || '',
enable: !!form.enable, enable: !!form.enable,
allowPrivateAddress: !!form.allowPrivateAddress,
}; };
} }
@ -161,6 +163,11 @@ async function onSave() {
</a-col> </a-col>
</a-row> </a-row>
<a-form-item label="Allow private address">
<a-switch v-model:checked="form.allowPrivateAddress" />
<div class="hint">Enable only for nodes on a private network or VPN.</div>
</a-form-item>
<a-form-item :label="t('pages.nodes.apiToken')" required> <a-form-item :label="t('pages.nodes.apiToken')" required>
<a-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" /> <a-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" />
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div> <div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>

View file

@ -153,6 +153,14 @@ onMounted(loadInboundTags);
</template> </template>
</SettingListItem> </SettingListItem>
<SettingListItem paddings="small">
<template #title>Trusted proxy CIDRs</template>
<template #description>Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers.</template>
<template #control>
<a-input v-model:value="allSetting.trustedProxyCIDRs" placeholder="127.0.0.1/32,::1/128" />
</template>
</SettingListItem>
<SettingListItem paddings="small"> <SettingListItem paddings="small">
<template #title>{{ t('pages.settings.pageSize') }}</template> <template #title>{{ t('pages.settings.pageSize') }}</template>
<template #description>{{ t('pages.settings.pageSizeDesc') }}</template> <template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
@ -298,8 +306,12 @@ onMounted(loadInboundTags);
<SettingListItem paddings="small"> <SettingListItem paddings="small">
<template #title>{{ t('password') }}</template> <template #title>{{ t('password') }}</template>
<template #description>
{{ allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.' }}
</template>
<template #control> <template #control>
<a-input-password v-model:value="allSetting.ldapPassword" /> <a-input-password v-model:value="allSetting.ldapPassword"
:placeholder="allSetting.hasLdapPassword ? 'Configured - enter a new value to replace' : ''" />
</template> </template>
</SettingListItem> </SettingListItem>

View file

@ -52,10 +52,9 @@ async function sendUpdateUser() {
try { try {
const msg = await HttpUtil.post('/panel/setting/updateUser', user); const msg = await HttpUtil.post('/panel/setting/updateUser', user);
if (msg?.success) { if (msg?.success) {
// Force re-login at the standard logout path; basePath is handled await HttpUtil.post('/logout');
// by the Go router so a relative redirect is correct here. const basePath = window.X_UI_BASE_PATH || '/';
const basePath = window.X_UI_BASE_PATH || ''; window.location.replace(basePath);
window.location.replace(`${basePath}logout`);
} }
} finally { } finally {
updating.value = false; updating.value = false;
@ -76,34 +75,41 @@ function updateUser() {
} }
} }
// === API Token ========================================================= const apiTokens = ref([]);
// Surfaces the panel's API token so a remote central panel can register const apiTokensLoading = ref(false);
// this instance as a node. Lazy-loaded on tab mount; rotation requires const visibleTokenIds = ref(new Set());
// confirmation since it invalidates any cached value upstream. const createOpen = ref(false);
const apiToken = ref(''); const createName = ref('');
const apiTokenLoading = ref(false); const creating = ref(false);
const apiTokenRotating = ref(false);
async function loadApiToken() { async function loadApiTokens() {
apiTokenLoading.value = true; apiTokensLoading.value = true;
try { try {
const msg = await HttpUtil.get('/panel/setting/getApiToken'); const msg = await HttpUtil.get('/panel/setting/apiTokens');
if (msg?.success) apiToken.value = msg.obj || ''; if (msg?.success) apiTokens.value = Array.isArray(msg.obj) ? msg.obj : [];
} finally { } finally {
apiTokenLoading.value = false; apiTokensLoading.value = false;
} }
} }
async function copyApiToken() { function isTokenVisible(id) {
if (!apiToken.value) return; return visibleTokenIds.value.has(id);
}
function toggleTokenVisibility(id) {
const next = new Set(visibleTokenIds.value);
if (next.has(id)) next.delete(id); else next.add(id);
visibleTokenIds.value = next;
}
async function copyToken(token) {
if (!token) return;
try { try {
await navigator.clipboard.writeText(apiToken.value); await navigator.clipboard.writeText(token);
message.success(t('copySuccess')); message.success(t('copySuccess'));
} catch (_e) { } catch (_e) {
// navigator.clipboard can be undefined on http:// fall back to
// a transient input + execCommand path.
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.value = apiToken.value; ta.value = token;
document.body.appendChild(ta); document.body.appendChild(ta);
ta.select(); ta.select();
document.execCommand('copy'); document.execCommand('copy');
@ -112,28 +118,66 @@ async function copyApiToken() {
} }
} }
function regenerateApiToken() { function openCreateModal() {
createName.value = '';
createOpen.value = true;
}
async function confirmCreateToken() {
const name = createName.value.trim();
if (!name) {
message.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
return;
}
creating.value = true;
try {
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name });
if (msg?.success) {
createOpen.value = false;
await loadApiTokens();
if (msg.obj?.id != null) {
const next = new Set(visibleTokenIds.value);
next.add(msg.obj.id);
visibleTokenIds.value = next;
}
}
} finally {
creating.value = false;
}
}
function confirmDeleteToken(row) {
Modal.confirm({ Modal.confirm({
title: t('pages.nodes.regenerateConfirm'), title: `${t('delete')} "${row.name}"?`,
okText: t('confirm'), content: t('pages.settings.security.apiTokenDeleteWarning')
|| 'Any caller using this token will stop authenticating immediately.',
okText: t('delete'),
cancelText: t('cancel'), cancelText: t('cancel'),
okType: 'danger', okType: 'danger',
onOk: async () => { onOk: async () => {
apiTokenRotating.value = true; const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`);
try { if (msg?.success) await loadApiTokens();
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
if (msg?.success) {
apiToken.value = msg.obj || '';
message.success(t('success'));
}
} finally {
apiTokenRotating.value = false;
}
}, },
}); });
} }
onMounted(loadApiToken); async function toggleTokenEnabled(row) {
const target = !row.enabled;
const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target });
if (msg?.success) row.enabled = target;
}
function maskToken(token) {
if (!token) return '';
return '•'.repeat(Math.min(token.length, 24));
}
function formatTokenDate(ts) {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString();
}
onMounted(loadApiTokens);
function toggleTwoFactor() { function toggleTwoFactor() {
// Switch read-only the actual flip happens after the modal succeeds. // Switch read-only the actual flip happens after the modal succeeds.
@ -217,24 +261,144 @@ function toggleTwoFactor() {
</a-collapse-panel> </a-collapse-panel>
<a-collapse-panel key="3" :header="t('pages.nodes.apiToken')"> <a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
<SettingListItem paddings="small"> <div class="api-token-section">
<template #title>{{ t('pages.nodes.apiToken') }}</template> <div class="api-token-header">
<template #description>{{ t('pages.nodes.apiTokenHint') }}</template> <p class="api-token-hint">{{ t('pages.nodes.apiTokenHint') }}</p>
<template #control> <a-button type="primary" size="small" @click="openCreateModal">
<a-input-password :value="apiToken" readonly :loading="apiTokenLoading" style="min-width: 240px" /> + {{ t('pages.settings.security.apiTokenNew') || 'New token' }}
</template>
</SettingListItem>
<a-list-item>
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
<a-button :disabled="!apiToken" @click="copyApiToken">{{ t('copy') }}</a-button>
<a-button danger :loading="apiTokenRotating" @click="regenerateApiToken">
{{ t('pages.nodes.regenerate') }}
</a-button> </a-button>
</a-space> </div>
</a-list-item>
<a-spin :spinning="apiTokensLoading">
<a-empty v-if="!apiTokens.length && !apiTokensLoading"
:description="t('pages.settings.security.apiTokenEmpty') || 'No tokens yet'" />
<div v-for="row in apiTokens" :key="row.id" class="api-token-row" :class="{ disabled: !row.enabled }">
<div class="api-token-row-head">
<div class="api-token-name-wrap">
<span class="api-token-name">{{ row.name }}</span>
<span class="api-token-created">{{ formatTokenDate(row.createdAt) }}</span>
</div>
<div class="api-token-actions">
<a-switch size="small" :checked="row.enabled" @change="toggleTokenEnabled(row)" />
<a-button size="small" danger type="text" @click="confirmDeleteToken(row)">
{{ t('delete') }}
</a-button>
</div>
</div>
<div class="api-token-value-wrap">
<code class="api-token-value">{{ isTokenVisible(row.id) ? row.token : maskToken(row.token) }}</code>
<a-button size="small" @click="toggleTokenVisibility(row.id)">
{{ isTokenVisible(row.id)
? (t('pages.settings.security.hide') || 'Hide')
: (t('pages.settings.security.show') || 'Show') }}
</a-button>
<a-button size="small" @click="copyToken(row.token)">{{ t('copy') }}</a-button>
</div>
</div>
</a-spin>
</div>
</a-collapse-panel> </a-collapse-panel>
</a-collapse> </a-collapse>
<a-modal v-model:open="createOpen" :title="t('pages.settings.security.apiTokenNew') || 'New API token'"
:confirm-loading="creating" :ok-text="t('confirm')" :cancel-text="t('cancel')" @ok="confirmCreateToken">
<a-form layout="vertical">
<a-form-item :label="t('pages.settings.security.apiTokenName') || 'Name'" required>
<a-input v-model:value="createName" maxlength="64"
:placeholder="t('pages.settings.security.apiTokenNamePlaceholder') || 'e.g. central-panel-a'"
@keyup.enter="confirmCreateToken" />
</a-form-item>
</a-form>
</a-modal>
<TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token" <TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token"
:type="tfa.type" @confirm="onTfaConfirm" /> :type="tfa.type" @confirm="onTfaConfirm" />
</template> </template>
<style scoped>
.api-token-section {
padding: 8px 20px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.api-token-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.api-token-hint {
margin: 0;
font-size: 12.5px;
opacity: 0.7;
flex: 1;
min-width: 200px;
}
.api-token-row {
border: 1px solid rgba(128, 128, 128, 0.18);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
transition: opacity 0.15s;
}
.api-token-row.disabled {
opacity: 0.55;
}
.api-token-row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.api-token-name-wrap {
display: flex;
flex-direction: column;
gap: 2px;
}
.api-token-name {
font-weight: 600;
font-size: 13.5px;
}
.api-token-created {
font-size: 11px;
opacity: 0.55;
}
.api-token-actions {
display: flex;
align-items: center;
gap: 8px;
}
.api-token-value-wrap {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.api-token-value {
flex: 1;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
padding: 4px 8px;
background: rgba(128, 128, 128, 0.08);
border-radius: 4px;
word-break: break-all;
}
</style>

View file

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Modal } from 'ant-design-vue'; import { Modal } from 'ant-design-vue';
import { import {
@ -152,6 +152,35 @@ const confAlerts = computed(() => {
}); });
const alertVisible = ref(true); const alertVisible = ref(true);
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
const slugToKey = (slug) => {
const i = tabSlugs.indexOf(slug);
return i >= 0 ? String(i + 1) : '1';
};
const keyToSlug = (key) => tabSlugs[Number(key) - 1] || tabSlugs[0];
const activeTabKey = ref(slugToKey(window.location.hash.slice(1)));
function onTabChange(key) {
activeTabKey.value = key;
const slug = keyToSlug(key);
if (window.location.hash !== `#${slug}`) {
history.replaceState(null, '', `#${slug}`);
}
}
function syncTabFromHash() {
activeTabKey.value = slugToKey(window.location.hash.slice(1));
}
onMounted(() => {
window.addEventListener('hashchange', syncTabFromHash);
});
onBeforeUnmount(() => {
window.removeEventListener('hashchange', syncTabFromHash);
});
</script> </script>
<template> <template>
@ -199,7 +228,7 @@ const alertVisible = ref(true);
</a-col> </a-col>
<a-col :span="24"> <a-col :span="24">
<a-tabs default-active-key="1"> <a-tabs :active-key="activeTabKey" @change="onTabChange">
<a-tab-pane key="1" class="tab-pane"> <a-tab-pane key="1" class="tab-pane">
<template #tab> <template #tab>
<SettingOutlined /> <SettingOutlined />

View file

@ -112,6 +112,14 @@ function normalizeSubPath() {
</template> </template>
</SettingListItem> </SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.settings.subEmailInRemark') }}</template>
<template #description>{{ t('pages.settings.subEmailInRemarkDesc') }}</template>
<template #control>
<a-switch v-model:checked="allSetting.subEmailInRemark" />
</template>
</SettingListItem>
<a-divider>{{ t('pages.settings.subTitle') }}</a-divider> <a-divider>{{ t('pages.settings.subTitle') }}</a-divider>
<SettingListItem paddings="small"> <SettingListItem paddings="small">

View file

@ -23,9 +23,12 @@ defineProps({
<SettingListItem paddings="small"> <SettingListItem paddings="small">
<template #title>{{ t('pages.settings.telegramToken') }}</template> <template #title>{{ t('pages.settings.telegramToken') }}</template>
<template #description>{{ t('pages.settings.telegramTokenDesc') }}</template> <template #description>
{{ allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc') }}
</template>
<template #control> <template #control>
<a-input v-model:value="allSetting.tgBotToken" type="text" /> <a-input-password v-model:value="allSetting.tgBotToken"
:placeholder="allSetting.hasTgBotToken ? 'Configured - enter a new token to replace' : ''" />
</template> </template>
</SettingListItem> </SettingListItem>

View file

@ -38,18 +38,24 @@ function buildTotp() {
watch(() => props.open, (next) => { watch(() => props.open, (next) => {
if (!next) return; if (!next) return;
enteredCode.value = ''; enteredCode.value = '';
totp = null;
qrValue.value = '';
if (props.token) { if (props.token) {
buildTotp(); buildTotp();
} }
}); });
function close(success) { function close(success, code = '') {
emit('confirm', success); emit('confirm', success, code);
emit('update:open', false); emit('update:open', false);
enteredCode.value = ''; enteredCode.value = '';
} }
function onOk() { function onOk() {
if (props.type === 'confirm' && !props.token) {
close(true, enteredCode.value);
return;
}
if (!totp) return; if (!totp) return;
if (totp.generate() === enteredCode.value) { if (totp.generate() === enteredCode.value) {
close(true); close(true);

View file

@ -40,7 +40,6 @@ const subUrl = subData.subUrl || '';
const subJsonUrl = subData.subJsonUrl || ''; const subJsonUrl = subData.subJsonUrl || '';
const subClashUrl = subData.subClashUrl || ''; const subClashUrl = subData.subClashUrl || '';
const subTitle = subData.subTitle || ''; const subTitle = subData.subTitle || '';
const subSupportUrl = subData.subSupportUrl || '';
const links = Array.isArray(subData.links) ? subData.links : []; const links = Array.isArray(subData.links) ? subData.links : [];
// Panel's "Calendar Type" setting; controls whether expiry / lastOnline // Panel's "Calendar Type" setting; controls whether expiry / lastOnline
// render in Gregorian or Jalali on this standalone subscription page. // render in Gregorian or Jalali on this standalone subscription page.

View file

@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps({ defineProps({
open: { type: Boolean, default: false }, open: { type: Boolean, default: false },
}); });

View file

@ -80,8 +80,17 @@ watch(() => props.open, (next) => {
primeAdvancedJson(); primeAdvancedJson();
}); });
watch(activeKey, (key) => { let isRevertingTab = false;
if (key === '2') primeAdvancedJson(); watch(activeKey, (key, prev) => {
if (isRevertingTab) { isRevertingTab = false; return; }
if (key === '2') {
primeAdvancedJson();
} else if (key === '1' && prev === '2') {
if (!applyAdvancedJsonToForm()) {
isRevertingTab = true;
activeKey.value = '2';
}
}
}); });
function primeAdvancedJson() { function primeAdvancedJson() {
@ -93,6 +102,33 @@ function primeAdvancedJson() {
} }
} }
function applyAdvancedJsonToForm() {
const raw = advancedJson.value.trim();
if (!raw) return true;
let currentJson = '';
try {
currentJson = JSON.stringify(outbound.value?.toJson() ?? {}, null, 2);
} catch (_e) { /* fall through */ }
if (raw === currentJson.trim()) return true;
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
message.error(`JSON: ${e.message}`);
return false;
}
try {
const fallbackTag = outbound.value?.tag;
const next = Outbound.fromJson(parsed);
if (!next.tag && fallbackTag) next.tag = fallbackTag;
outbound.value = next;
return true;
} catch (e) {
message.error(`JSON: ${e.message}`);
return false;
}
}
function close() { emit('update:open', false); } function close() { emit('update:open', false); }
function onProtocolChange(next) { function onProtocolChange(next) {
@ -131,27 +167,15 @@ const tagHelp = computed(() => {
// ============== Submit ============== // ============== Submit ==============
function onOk() { function onOk() {
if (!outbound.value) return; if (!outbound.value) return;
if (activeKey.value === '2' && !applyAdvancedJsonToForm()) return;
if (!outbound.value.tag?.trim()) { if (!outbound.value.tag?.trim()) {
message.error(t('somethingWentWrong')); message.error('Tag is required');
return; return;
} }
if (duplicateTag.value) { if (duplicateTag.value) {
message.error(t('somethingWentWrong')); message.error('Tag already used by another outbound');
return; return;
} }
// If user spent time in the JSON tab, prefer that body round-trip
// it through Outbound.fromJson so the wire shape stays consistent.
if (activeKey.value === '2' && advancedJson.value.trim()) {
try {
const parsed = JSON.parse(advancedJson.value);
const built = Outbound.fromJson(parsed);
emit('confirm', built.toJson());
return;
} catch (e) {
message.error(`JSON: ${e.message}`);
return;
}
}
emit('confirm', outbound.value.toJson()); emit('confirm', outbound.value.toJson());
} }

View file

@ -10,6 +10,7 @@ import {
ClusterOutlined, ClusterOutlined,
ArrowUpOutlined, ArrowUpOutlined,
ArrowDownOutlined, ArrowDownOutlined,
HolderOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { Modal } from 'ant-design-vue'; import { Modal } from 'ant-design-vue';
@ -22,9 +23,11 @@ const { t } = useI18n();
// "lead value + N more" pill per criterion (matches the legacy pill // "lead value + N more" pill per criterion (matches the legacy pill
// layout); full lists surface via tooltip on hover. // layout); full lists surface via tooltip on hover.
// //
// Reorder uses up/down buttons in the action menu rather than the // Reorder via Pointer Events on the grip icon unified mouse +
// jQuery-Sortable drag handle the legacy panel used same effect, // touch + pen path so the same code works on desktop and mobile
// no extra dep. The mobile column layout drops source/network/ // (HTML5 drag doesn't fire from touch on iOS Safari, hence the
// switch). Up/down buttons in the action menu stay as a keyboard
// fallback. The mobile column layout drops source/network/
// destination criteria for readability. // destination criteria for readability.
const props = defineProps({ const props = defineProps({
@ -162,6 +165,58 @@ function moveDown(idx) {
[rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]]; [rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]];
} }
const draggedIndex = ref(null);
const dropTargetIndex = ref(null);
let dragStartY = 0;
let dragMoved = false;
function onHandlePointerDown(idx, ev) {
if (ev.button != null && ev.button !== 0) return;
ev.preventDefault();
draggedIndex.value = idx;
dropTargetIndex.value = idx;
dragStartY = ev.clientY;
dragMoved = false;
document.addEventListener('pointermove', onDragPointerMove);
document.addEventListener('pointerup', onDragPointerUp);
document.addEventListener('pointercancel', onDragPointerUp);
}
function onDragPointerMove(ev) {
if (draggedIndex.value == null) return;
if (!dragMoved && Math.abs(ev.clientY - dragStartY) < 5) return;
dragMoved = true;
const el = document.elementFromPoint(ev.clientX, ev.clientY);
if (!el) return;
const tr = el.closest('tr[data-row-key]');
if (!tr) return;
const idx = Number(tr.getAttribute('data-row-key'));
if (Number.isFinite(idx)) dropTargetIndex.value = idx;
}
function onDragPointerUp() {
document.removeEventListener('pointermove', onDragPointerMove);
document.removeEventListener('pointerup', onDragPointerUp);
document.removeEventListener('pointercancel', onDragPointerUp);
const from = draggedIndex.value;
const to = dropTargetIndex.value;
draggedIndex.value = null;
dropTargetIndex.value = null;
if (!dragMoved || from == null || to == null || from === to) return;
const rules = props.templateSettings.routing.rules;
const [moved] = rules.splice(from, 1);
rules.splice(to, 0, moved);
}
function rowProps(_record, index) {
const classes = [];
if (draggedIndex.value === index) classes.push('row-dragging');
if (dropTargetIndex.value === index && draggedIndex.value !== index) {
classes.push(index > draggedIndex.value ? 'drop-after' : 'drop-before');
}
return { class: classes.join(' ') };
}
// === Columns ========================================================= // === Columns =========================================================
// Computed so titles re-render after a locale swap. // Computed so titles re-render after a locale swap.
const desktopColumns = computed(() => [ const desktopColumns = computed(() => [
@ -170,14 +225,31 @@ const desktopColumns = computed(() => [
{ title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' }, { title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
{ title: 'Destination', align: 'left', key: 'destination' }, { title: 'Destination', align: 'left', key: 'destination' },
{ title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' }, { title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
{ title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'target' }, { title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'outbound' },
{ title: t('pages.xray.Balancers'), align: 'left', width: 150, key: 'balancer' },
]); ]);
const mobileColumns = computed(() => [ const columns = computed(() => desktopColumns.value);
{ title: '#', align: 'center', width: 70, key: 'action' },
{ title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' }, function ruleCriteriaChips(rule) {
{ title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' }, const chips = [];
]); if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value)); if (rule.ip) chips.push({ label: 'IP', value: rule.ip });
if (rule.port) chips.push({ label: 'Port', value: rule.port });
if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
if (rule.network) chips.push({ label: 'L4', value: rule.network });
if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol });
if (rule.user) chips.push({ label: 'User', value: rule.user });
if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute });
return chips;
}
function chipPreview(value) {
const parts = csv(value);
if (parts.length === 0) return '';
if (parts.length === 1) return parts[0];
return `${parts[0]} +${parts.length - 1}`;
}
</script> </script>
<template> <template>
@ -189,12 +261,84 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
{{ t('pages.xray.Routings') }} {{ t('pages.xray.Routings') }}
</a-button> </a-button>
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" <!-- Mobile: stacked cards. The desktop a-table doesn't fit on a
:scroll="isMobile ? {} : { x: 1000 }" size="small" class="routing-table"> phone (~520px of columns alone), so render each rule as a
compact card with the routing summary + criteria chips. -->
<div v-if="isMobile" class="rule-list">
<div v-for="(rule, index) in rows" :key="rule.key" class="rule-card" :class="{
'row-dragging': draggedIndex === index,
'drop-before': dropTargetIndex === index && draggedIndex != null && index < draggedIndex,
'drop-after': dropTargetIndex === index && draggedIndex != null && index > draggedIndex,
}" :data-row-key="index">
<div class="rule-card-head">
<HolderOutlined class="drag-handle" @pointerdown="onHandlePointerDown(index, $event)" />
<span class="rule-number">#{{ index + 1 }}</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="rule-flow">
<div class="flow-side">
<span class="flow-label">{{ t('pages.xray.Inbounds') }}</span>
<a-tag v-if="rule.inboundTag" color="blue" class="flow-tag">
{{ chipPreview(rule.inboundTag) }}
</a-tag>
<span v-else class="criterion-empty">any</span>
</div>
<span class="flow-arrow"></span>
<div class="flow-side flow-side-target">
<span class="flow-label">{{
rule.balancerTag ? (t('pages.xray.balancer') || 'Balancer') : t('pages.xray.Outbounds')
}}</span>
<a-tag v-if="rule.outboundTag" color="green" class="flow-tag">
<ExportOutlined /> {{ rule.outboundTag }}
</a-tag>
<a-tag v-else-if="rule.balancerTag" color="purple" class="flow-tag">
<ClusterOutlined /> {{ rule.balancerTag }}
</a-tag>
<span v-else class="criterion-empty"></span>
</div>
</div>
<div v-if="ruleCriteriaChips(rule).length" class="rule-criteria">
<a-tooltip v-for="chip in ruleCriteriaChips(rule)" :key="chip.label" :title="`${chip.label}: ${chip.value}`">
<span class="criterion-chip">
<span class="criterion-chip-label">{{ chip.label }}</span>
<span class="criterion-chip-value">{{ chipPreview(chip.value) }}</span>
</span>
</a-tooltip>
</div>
</div>
<div v-if="!rows.length" class="rule-empty"></div>
</div>
<a-table v-else :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
:scroll="{ x: 1150 }" size="small" class="routing-table" :custom-row="rowProps">
<template #bodyCell="{ column, record, index }"> <template #bodyCell="{ column, record, index }">
<!-- ============== # / actions ============== --> <!-- ============== # / actions ============== -->
<template v-if="column.key === 'action'"> <template v-if="column.key === 'action'">
<div class="action-cell"> <div class="action-cell">
<HolderOutlined class="drag-handle" :title="t('drag') || 'Drag to reorder'"
@pointerdown="onHandlePointerDown(index, $event)" />
<span class="row-index">{{ index + 1 }}</span> <span class="row-index">{{ index + 1 }}</span>
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button shape="circle" size="small"> <a-button shape="circle" size="small">
@ -333,18 +477,25 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
</div> </div>
</template> </template>
<!-- ============== Outbound / balancer target ============== --> <!-- ============== Outbound ============== -->
<template v-else-if="column.key === 'target'"> <template v-else-if="column.key === 'outbound'">
<div class="target-cell"> <div class="target-cell">
<div v-if="record.outboundTag" class="target-row"> <div v-if="record.outboundTag" class="target-row">
<ExportOutlined class="target-icon" /> <ExportOutlined class="target-icon" />
<a-tag color="green">{{ record.outboundTag }}</a-tag> <a-tag color="green">{{ record.outboundTag }}</a-tag>
</div> </div>
<span v-else class="criterion-empty"></span>
</div>
</template>
<!-- ============== Balancer ============== -->
<template v-else-if="column.key === 'balancer'">
<div class="target-cell">
<div v-if="record.balancerTag" class="target-row"> <div v-if="record.balancerTag" class="target-row">
<ClusterOutlined class="target-icon" /> <ClusterOutlined class="target-icon" />
<a-tag color="purple">{{ record.balancerTag }}</a-tag> <a-tag color="purple">{{ record.balancerTag }}</a-tag>
</div> </div>
<span v-if="!record.outboundTag && !record.balancerTag" class="criterion-empty"></span> <span v-else class="criterion-empty"></span>
</div> </div>
</template> </template>
</template> </template>
@ -362,6 +513,36 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
gap: 6px; gap: 6px;
} }
.drag-handle {
cursor: grab;
opacity: 0.35;
font-size: 14px;
padding: 4px;
margin: -4px;
touch-action: none;
transition: opacity 0.15s;
}
.drag-handle:hover {
opacity: 0.8;
}
.drag-handle:active {
cursor: grabbing;
}
:deep(.row-dragging) {
opacity: 0.4;
}
:deep(.drop-before > td) {
box-shadow: inset 0 2px 0 0 #1677ff;
}
:deep(.drop-after > td) {
box-shadow: inset 0 -2px 0 0 #1677ff;
}
.row-index { .row-index {
font-weight: 500; font-weight: 500;
opacity: 0.7; opacity: 0.7;
@ -429,4 +610,136 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
.danger { .danger {
color: #ff4d4f; color: #ff4d4f;
} }
/* === Mobile card list ====================================== */
.rule-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.rule-card {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--bg-card, #fff);
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 8px;
transition: opacity 0.15s, box-shadow 0.15s;
}
.rule-card.row-dragging {
opacity: 0.4;
}
.rule-card.drop-before {
box-shadow: inset 0 2px 0 0 #1677ff;
}
.rule-card.drop-after {
box-shadow: inset 0 -2px 0 0 #1677ff;
}
.rule-card-head {
display: flex;
align-items: center;
gap: 8px;
}
.rule-number {
font-weight: 600;
font-size: 13px;
opacity: 0.75;
flex: 1;
}
.rule-flow {
display: flex;
align-items: center;
gap: 8px;
}
.flow-side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.flow-side-target {
align-items: flex-end;
text-align: right;
}
.flow-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
}
.flow-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
}
.flow-arrow {
font-size: 16px;
opacity: 0.45;
flex-shrink: 0;
}
.rule-criteria {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding-top: 6px;
border-top: 1px dashed rgba(128, 128, 128, 0.2);
}
.criterion-chip {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 1px 6px;
font-size: 11px;
background: rgba(128, 128, 128, 0.08);
border-radius: 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.criterion-chip-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.6;
}
.criterion-chip-value {
font-weight: 500;
}
.rule-empty {
padding: 24px;
text-align: center;
opacity: 0.4;
}
:global(body.dark) .rule-card {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
:global(body.dark) .criterion-chip {
background: rgba(255, 255, 255, 0.06);
}
</style> </style>

View file

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue'; import { Modal, message } from 'ant-design-vue';
import { import {
@ -186,9 +186,6 @@ function onRemoveRoutingRules({ prefix }) {
); );
} }
// `message` is used by some of the in-progress UX flows (kept around
// because future provisioning errors will surface through it).
void message;
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const basePath = window.X_UI_BASE_PATH || ''; const basePath = window.X_UI_BASE_PATH || '';
@ -208,6 +205,51 @@ function confirmRestart() {
onOk: () => restartXray(), onOk: () => restartXray(),
}); });
} }
const tabKeys = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
const slugByKey = {
'tpl-basic': 'basic',
'tpl-routing': 'routing',
'tpl-outbound': 'outbound',
'tpl-balancer': 'balancer',
'tpl-dns': 'dns',
'tpl-advanced': 'advanced',
};
const keyBySlug = Object.fromEntries(Object.entries(slugByKey).map(([k, v]) => [v, k]));
const activeTabKey = ref(keyBySlug[window.location.hash.slice(1)] || tabKeys[0]);
function onTabChange(key) {
activeTabKey.value = key;
const slug = slugByKey[key];
if (slug && window.location.hash !== `#${slug}`) {
history.replaceState(null, '', `#${slug}`);
}
}
function onSaveAll() {
try {
JSON.parse(xraySetting.value);
} catch (e) {
message.error(`Advanced JSON: ${e.message}`);
activeTabKey.value = 'tpl-advanced';
return;
}
saveAll();
}
function syncTabFromHash() {
const key = keyBySlug[window.location.hash.slice(1)];
if (key) activeTabKey.value = key;
}
onMounted(() => {
window.addEventListener('hashchange', syncTabFromHash);
});
onBeforeUnmount(() => {
window.removeEventListener('hashchange', syncTabFromHash);
});
</script> </script>
<template> <template>
@ -234,7 +276,7 @@ function confirmRestart() {
<a-row class="header-row"> <a-row class="header-row">
<a-col :xs="24" :sm="14" class="header-actions"> <a-col :xs="24" :sm="14" class="header-actions">
<a-space direction="horizontal"> <a-space direction="horizontal">
<a-button type="primary" :disabled="saveDisabled" @click="saveAll"> <a-button type="primary" :disabled="saveDisabled" @click="onSaveAll">
{{ t('pages.xray.save') }} {{ t('pages.xray.save') }}
</a-button> </a-button>
<a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart"> <a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart">
@ -259,7 +301,7 @@ function confirmRestart() {
<!-- Tabs --> <!-- Tabs -->
<a-col :span="24"> <a-col :span="24">
<a-tabs default-active-key="tpl-basic"> <a-tabs :active-key="activeTabKey" @change="onTabChange">
<a-tab-pane key="tpl-basic" class="tab-pane"> <a-tab-pane key="tpl-basic" class="tab-pane">
<template #tab> <template #tab>
<SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span> <SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>

View file

@ -75,6 +75,13 @@ export class HttpUtil {
} }
} }
export function applyDocumentTitle() {
const host = window.location.hostname;
if (!host) return;
const current = document.title.trim();
document.title = current ? `${host} - ${current}` : host;
}
export class PromiseUtil { export class PromiseUtil {
static async sleep(timeout) { static async sleep(timeout) {
await new Promise(resolve => { await new Promise(resolve => {

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>3x-ui · Xray</title> <title>Xray Config</title>
</head> </head>
<body> <body>
<div id="message"></div> <div id="message"></div>

27
go.mod
View file

@ -22,10 +22,11 @@ require (
github.com/xlzd/gotp v0.1.0 github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.260327.0 github.com/xtls/xray-core v1.260327.0
go.uber.org/atomic v1.11.0 go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.51.0
golang.org/x/sys v0.43.0 golang.org/x/sys v0.44.0
golang.org/x/text v0.36.0 golang.org/x/text v0.37.0
google.golang.org/grpc v1.81.0 google.golang.org/grpc v1.81.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
@ -69,13 +70,13 @@ require (
github.com/pires/go-proxyproto v0.12.0 // indirect github.com/pires/go-proxyproto v0.12.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.1 // indirect
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagernet/sing v0.8.9 // indirect github.com/sagernet/sing v0.8.10 // indirect
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
@ -86,16 +87,16 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.26.0 // indirect golang.org/x/arch v0.27.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/mod v0.35.0 // indirect golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.53.0 // indirect golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect golang.org/x/tools v0.45.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect

54
go.sum
View file

@ -148,16 +148,16 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs= github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU= github.com/sagernet/sing v0.8.10 h1:V5VZffy8rm4dtBVKIpKa8vibRR2SiJprtu/10DFUalU=
github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= github.com/sagernet/sing v0.8.10/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM= github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8= github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY= github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
@ -175,10 +175,10 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
@ -225,16 +225,16 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4= golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -242,22 +242,22 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@ -265,6 +265,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -11,17 +11,25 @@ import (
"github.com/mhsanaei/3x-ui/v3/config" "github.com/mhsanaei/3x-ui/v3/config"
"github.com/op/go-logging" "github.com/op/go-logging"
"gopkg.in/natefinch/lumberjack.v2"
) )
const ( const (
maxLogBufferSize = 10240 // Maximum log entries kept in memory maxLogBufferSize = 10240 // Maximum log entries kept in memory
logFileName = "3xui.log" // Log file name logFileName = "3xui.log" // Log file name
timeFormat = "2006/01/02 15:04:05" // Log timestamp format timeFormat = "2006/01/02 15:04:05" // Log timestamp format
// On-disk rotation limits — single file capped, old segments pruned automatically.
maxLogFileMB = 10 // rotate active log when larger than this
maxLogBackups = 5 // rotated files retained (beyond current segment)
maxLogAgeDays = 7 // remove rotated backups older than this (0 disables time-based pruning)
compressRotated = true
) )
var ( var (
logger *logging.Logger logger *logging.Logger
logFile *os.File fileRotate *lumberjack.Logger // nil when file backend disabled
// logBuffer maintains recent log entries in memory for web UI retrieval // logBuffer maintains recent log entries in memory for web UI retrieval
logBuffer []struct { logBuffer []struct {
@ -81,8 +89,8 @@ func initDefaultBackend() logging.Backend {
return logging.NewBackendFormatter(backend, newFormatter(includeTime)) return logging.NewBackendFormatter(backend, newFormatter(includeTime))
} }
// initFileBackend creates the file logging backend. // initFileBackend creates the file logging backend with size/agebounded rotation
// Creates log directory and truncates log file on startup for fresh logs. // so log volume cannot grow without limit on disk.
func initFileBackend() logging.Backend { func initFileBackend() logging.Backend {
logDir := config.GetLogFolder() logDir := config.GetLogFolder()
if err := os.MkdirAll(logDir, 0o750); err != nil { if err := os.MkdirAll(logDir, 0o750); err != nil {
@ -91,19 +99,16 @@ func initFileBackend() logging.Backend {
} }
logPath := filepath.Join(logDir, logFileName) logPath := filepath.Join(logDir, logFileName)
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o660) fileRotate = &lumberjack.Logger{
if err != nil { Filename: logPath,
fmt.Fprintf(os.Stderr, "failed to open log file %s: %v\n", logPath, err) MaxSize: maxLogFileMB,
return nil MaxBackups: maxLogBackups,
MaxAge: maxLogAgeDays,
LocalTime: true,
Compress: compressRotated,
} }
// Close previous log file if exists backend := logging.NewLogBackend(fileRotate, "", 0)
if logFile != nil {
_ = logFile.Close()
}
logFile = file
backend := logging.NewLogBackend(file, "", 0)
return logging.NewBackendFormatter(backend, newFormatter(true)) return logging.NewBackendFormatter(backend, newFormatter(true))
} }
@ -116,12 +121,12 @@ func newFormatter(withTime bool) logging.Formatter {
return logging.MustStringFormatter(format) return logging.MustStringFormatter(format)
} }
// CloseLogger closes the log file and cleans up resources. // CloseLogger closes the rotating log writer and cleans up resources.
// Should be called during application shutdown. // Should be called during application shutdown.
func CloseLogger() { func CloseLogger() {
if logFile != nil { if fileRotate != nil {
_ = logFile.Close() _ = fileRotate.Close()
logFile = nil fileRotate = nil
} }
} }

View file

@ -447,6 +447,9 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
if udpIdleTimeout, ok := hyStream["udpIdleTimeout"].(float64); ok { if udpIdleTimeout, ok := hyStream["udpIdleTimeout"].(float64); ok {
outHyStream["udpIdleTimeout"] = int(udpIdleTimeout) outHyStream["udpIdleTimeout"] = int(udpIdleTimeout)
} }
if masquerade, ok := hyStream["masquerade"].(map[string]any); ok {
outHyStream["masquerade"] = masquerade
}
newStream["hysteriaSettings"] = outHyStream newStream["hysteriaSettings"] = outHyStream
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok { if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {

View file

@ -28,6 +28,7 @@ type SubService struct {
showInfo bool showInfo bool
remarkModel string remarkModel string
datepicker string datepicker string
emailInRemark bool
inboundService service.InboundService inboundService service.InboundService
settingService service.SettingService settingService service.SettingService
// nodesByID is populated per request from the Node table so // nodesByID is populated per request from the Node table so
@ -76,6 +77,12 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
if err != nil { if err != nil {
s.datepicker = "gregorian" s.datepicker = "gregorian"
} }
s.emailInRemark, err = s.settingService.GetSubEmailInRemark()
if err != nil {
s.emailInRemark = true
}
seenEmails := make(map[string]struct{}) seenEmails := make(map[string]struct{})
for _, inbound := range inbounds { for _, inbound := range inbounds {
clients, err := s.inboundService.GetClients(inbound) clients, err := s.inboundService.GetClients(inbound)
@ -886,7 +893,7 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
'e': "", 'e': "",
'o': "", 'o': "",
} }
if len(email) > 0 { if len(email) > 0 && s.emailInRemark {
orders['e'] = email orders['e'] = email
} }
if len(inbound.Remark) > 0 { if len(inbound.Remark) > 0 {

80
util/netsafe/netsafe.go Normal file
View file

@ -0,0 +1,80 @@
package netsafe
import (
"context"
"fmt"
"net"
"regexp"
"strings"
"time"
)
func IsBlockedIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
}
type allowPrivateCtxKey struct{}
func ContextWithAllowPrivate(ctx context.Context, allow bool) context.Context {
return context.WithValue(ctx, allowPrivateCtxKey{}, allow)
}
func AllowPrivateFromContext(ctx context.Context) bool {
v, _ := ctx.Value(allowPrivateCtxKey{}).(bool)
return v
}
var defaultDialer = &net.Dialer{Timeout: 10 * time.Second}
func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
allowPrivate := AllowPrivateFromContext(ctx)
var ips []net.IPAddr
if ip := net.ParseIP(host); ip != nil {
ips = []net.IPAddr{{IP: ip}}
} else {
ips, err = net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
}
var lastErr error
for _, ipAddr := range ips {
if !allowPrivate && IsBlockedIP(ipAddr.IP) {
lastErr = fmt.Errorf("blocked private/internal address %s", ipAddr.IP)
continue
}
conn, derr := defaultDialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
if derr == nil {
return conn, nil
}
lastErr = derr
}
if lastErr == nil {
lastErr = fmt.Errorf("no usable address for %s", host)
}
return nil, lastErr
}
var hostnamePattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
func NormalizeHost(addr string) (string, error) {
addr = strings.TrimSpace(addr)
if addr == "" {
return "", fmt.Errorf("address is required")
}
if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") {
addr = addr[1 : len(addr)-1]
}
if ip := net.ParseIP(addr); ip != nil {
return ip.String(), nil
}
if len(addr) > 253 || !hostnamePattern.MatchString(addr) {
return "", fmt.Errorf("invalid host %q", addr)
}
return addr, nil
}

View file

@ -19,6 +19,7 @@ type APIController struct {
nodeController *NodeController nodeController *NodeController
settingService service.SettingService settingService service.SettingService
userService service.UserService userService service.UserService
apiTokenService service.ApiTokenService
Tgbot service.Tgbot Tgbot service.Tgbot
} }
@ -29,25 +30,11 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
return a return a
} }
// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests
// to hide the existence of API endpoints from unauthorized users.
//
// Two auth paths are accepted:
// 1. Authorization: Bearer <apiToken> — used by remote central panels
// polling this instance as a node. Matches via constant-time compare.
// Sets c.Set("api_authed", true) so CSRFMiddleware can short-circuit.
// 2. Existing session cookie — used by browsers logged into the panel UI.
//
// Anything else falls through to a 404 so the API endpoints remain hidden.
func (a *APIController) checkAPIAuth(c *gin.Context) { func (a *APIController) checkAPIAuth(c *gin.Context) {
auth := c.GetHeader("Authorization") auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") { if strings.HasPrefix(auth, "Bearer ") {
tok := strings.TrimPrefix(auth, "Bearer ") tok := strings.TrimPrefix(auth, "Bearer ")
if a.settingService.MatchApiToken(tok) { if a.apiTokenService.Match(tok) {
// Handlers like InboundController.addInbound assume a logged-in
// user (inbound.UserId = user.Id). Bearer callers have no
// session, so attach the first user as a fallback. Single-user
// panels are the norm here.
if u, err := a.userService.GetFirstUser(); err == nil { if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u) session.SetAPIAuthUser(c, u)
} }
@ -57,7 +44,11 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
} }
} }
if !session.IsLogin(c) { if !session.IsLogin(c) {
if c.GetHeader("X-Requested-With") == "XMLHttpRequest" {
c.AbortWithStatus(http.StatusUnauthorized)
} else {
c.AbortWithStatus(http.StatusNotFound) c.AbortWithStatus(http.StatusNotFound)
}
return return
} }
c.Next() c.Next()
@ -85,7 +76,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
NewCustomGeoController(api.Group("/custom-geo"), customGeo) NewCustomGeoController(api.Group("/custom-geo"), customGeo)
// Extra routes // Extra routes
api.GET("/backuptotgbot", a.BackuptoTgbot) api.POST("/backuptotgbot", a.BackuptoTgbot)
} }
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins. // BackuptoTgbot sends a backup of the panel data to Telegram bot admins.

View file

@ -0,0 +1,160 @@
package controller
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
type routeDef struct {
Method string
Path string
}
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.js.
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
// buildDocSet parses frontend/src/pages/api-docs/endpoints.js and returns the
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
// placeholders (paths starting with /{...}) are skipped because they aren't
// registered on the main Gin engine.
func buildDocSet(t *testing.T) map[string]bool {
t.Helper()
controllerDir, err := filepath.Abs(".")
if err != nil {
t.Fatalf("failed to get current dir: %v", err)
}
endpointsPath := filepath.Join(controllerDir, "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.js")
data, err := os.ReadFile(endpointsPath)
if err != nil {
t.Fatalf("failed to read endpoints.js at %s: %v", endpointsPath, err)
}
docSet := make(map[string]bool)
for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
method, path := m[1], m[2]
if method == "WS" {
continue
}
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
continue
}
docSet[method+" "+path] = true
}
if len(docSet) == 0 {
t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
}
return docSet
}
func TestAPIRoutesDocumented(t *testing.T) {
docSet := buildDocSet(t)
controllerDir, err := filepath.Abs(".")
if err != nil {
t.Fatalf("failed to get current dir: %v", err)
}
var allRoutes []routeDef
entries, err := os.ReadDir(controllerDir)
if err != nil {
t.Fatalf("failed to read controller dir: %v", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name()))
if err != nil {
t.Fatalf("failed to read %s: %v", entry.Name(), err)
}
src := string(data)
// Determine the base path for this file based on its initRouter patterns
basePath := ""
switch entry.Name() {
case "index.go":
basePath = ""
case "xui.go":
basePath = "/panel"
case "api.go":
basePath = "/panel/api"
case "inbound.go":
basePath = "/panel/api/inbounds"
case "server.go":
basePath = "/panel/api/server"
case "node.go":
basePath = "/panel/api/nodes"
case "setting.go":
basePath = "/panel/setting"
case "xray_setting.go":
basePath = "/panel/xray"
case "custom_geo.go":
basePath = "/panel/api/custom-geo"
case "websocket.go":
basePath = ""
}
// Find all route registrations
matches := routePattern.FindAllStringSubmatch(src, -1)
for _, m := range matches {
method := m[2]
path := strings.TrimSpace(m[3])
if basePath == "" {
allRoutes = append(allRoutes, routeDef{Method: method, Path: path})
} else {
fullPath := basePath + path
allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath})
}
}
}
// The WebSocket route /ws is registered in web/web.go (not a controller file)
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
missingFromDocs := 0
foundInDoc := 0
sourceSet := make(map[string]bool)
for _, r := range allRoutes {
key := r.Method + " " + r.Path
// Skip SPA page routes (these are UI pages, not API endpoints)
spaPages := map[string]bool{
"/": true, "/panel/": true, "/panel/inbounds": true,
"/panel/nodes": true, "/panel/settings": true,
"/panel/xray": true, "/panel/api-docs": true,
}
if spaPages[r.Path] {
continue
}
// Skip /panel/csrf-token (documented under auth as /csrf-token)
if r.Path == "/panel/csrf-token" {
continue
}
// Skip Chrome DevTools route
if strings.Contains(r.Path, ".well-known") {
continue
}
sourceSet[key] = true
if docSet[key] {
foundInDoc++
} else {
missingFromDocs++
t.Errorf("Route not documented in endpoints.js: %s %s", r.Method, r.Path)
}
}
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
if missingFromDocs > 0 {
t.Errorf("Found %d undocumented route(s). Update endpoints.js to match.", missingFromDocs)
}
}

View file

@ -57,7 +57,11 @@ func serveDistPage(c *gin.Context, name string) {
} }
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`) csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
script := `<script>window.X_UI_BASE_PATH="` + escapedBase + `"` nonceAttr := ""
if nonce := c.GetString("csp_nonce"); nonce != "" {
nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
}
script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
if name != "login.html" { if name != "login.html" {
escapedVer := jsEscape.Replace(config.GetVersion()) escapedVer := jsEscape.Replace(config.GetVersion())
script += `;window.X_UI_CUR_VER="` + escapedVer + `"` script += `;window.X_UI_CUR_VER="` + escapedVer + `"`

View file

@ -77,6 +77,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/:id/copyClients", a.copyInboundClients) g.POST("/:id/copyClients", a.copyInboundClients)
g.POST("/:id/delClient/:clientId", a.delInboundClient) g.POST("/:id/delClient/:clientId", a.delInboundClient)
g.POST("/updateClient/:clientId", a.updateInboundClient) g.POST("/updateClient/:clientId", a.updateInboundClient)
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic) g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
g.POST("/resetAllTraffics", a.resetAllTraffics) g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics) g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
@ -441,6 +442,24 @@ func (a *InboundController) resetClientTraffic(c *gin.Context) {
} }
} }
// resetInboundTraffic resets traffic counters for a specific inbound.
func (a *InboundController) resetInboundTraffic(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
err = a.inboundService.ResetInboundTraffic(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
} else {
a.xrayService.SetToNeedRestart()
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil)
}
// resetAllTraffics resets all traffic counters across all inbounds. // resetAllTraffics resets all traffic counters across all inbounds.
func (a *InboundController) resetAllTraffics(c *gin.Context) { func (a *InboundController) resetAllTraffics(c *gin.Context) {
err := a.inboundService.ResetAllTraffics() err := a.inboundService.ResetAllTraffics()
@ -582,6 +601,7 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
// controller layer means the service interface stays HTTP-agnostic — service // controller layer means the service interface stays HTTP-agnostic — service
// methods receive a plain host string instead of a *gin.Context. // methods receive a plain host string instead of a *gin.Context.
func resolveHost(c *gin.Context) string { func resolveHost(c *gin.Context) string {
if isTrustedForwardedRequest(c) {
if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" { if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
if i := strings.Index(h, ","); i >= 0 { if i := strings.Index(h, ","); i >= 0 {
h = strings.TrimSpace(h[:i]) h = strings.TrimSpace(h[:i])
@ -594,6 +614,7 @@ func resolveHost(c *gin.Context) string {
if h := c.GetHeader("X-Real-IP"); h != "" { if h := c.GetHeader("X-Real-IP"); h != "" {
return h return h
} }
}
if h, _, err := net.SplitHostPort(c.Request.Host); err == nil { if h, _, err := net.SplitHostPort(c.Request.Host); err == nil {
return h return h
} }

View file

@ -39,15 +39,10 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
// initRouter sets up the routes for index, login, logout, and two-factor authentication. // initRouter sets up the routes for index, login, logout, and two-factor authentication.
func (a *IndexController) initRouter(g *gin.RouterGroup) { func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index) g.GET("/", a.index)
g.GET("/logout", a.logout)
// Public CSRF endpoint — the SPA login page (served by Vite in
// dev or by serveDistPage in prod) needs a token to POST /login,
// but the panel-side /panel/csrf-token sits behind checkLogin.
// EnsureCSRFToken creates a session token even for anonymous
// callers, so any pre-login flow can bootstrap from here.
g.GET("/csrf-token", a.csrfToken) g.GET("/csrf-token", a.csrfToken)
g.POST("/login", middleware.CSRFMiddleware(), a.login) g.POST("/login", middleware.CSRFMiddleware(), a.login)
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable) g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
} }
@ -140,7 +135,6 @@ func loginFailureReason(err error) string {
return "invalid credentials" return "invalid credentials"
} }
// logout handles user logout by clearing the session and redirecting to the login page.
func (a *IndexController) logout(c *gin.Context) { func (a *IndexController) logout(c *gin.Context) {
user := session.GetLoginUser(c) user := session.GetLoginUser(c)
if user != nil { if user != nil {
@ -150,7 +144,7 @@ func (a *IndexController) logout(c *gin.Context) {
logger.Warning("Unable to clear session on logout:", err) logger.Warning("Unable to clear session on logout:", err)
} }
c.Header("Cache-Control", "no-store") c.Header("Cache-Control", "no-store")
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path")) c.JSON(http.StatusOK, gin.H{"success": true})
} }
// csrfToken returns the session CSRF token. Public — the login page // csrfToken returns the session CSRF token. Public — the login page

View file

@ -2,6 +2,7 @@ package controller
import ( import (
"errors" "errors"
"strconv"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/util/crypto" "github.com/mhsanaei/3x-ui/v3/util/crypto"
@ -25,6 +26,7 @@ type SettingController struct {
settingService service.SettingService settingService service.SettingService
userService service.UserService userService service.UserService
panelService service.PanelService panelService service.PanelService
apiTokenService service.ApiTokenService
} }
// NewSettingController creates a new SettingController and initializes its routes. // NewSettingController creates a new SettingController and initializes its routes.
@ -44,8 +46,10 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
g.POST("/updateUser", a.updateUser) g.POST("/updateUser", a.updateUser)
g.POST("/restartPanel", a.restartPanel) g.POST("/restartPanel", a.restartPanel)
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig) g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.GET("/getApiToken", a.getApiToken) g.GET("/apiTokens", a.listApiTokens)
g.POST("/regenerateApiToken", a.regenerateApiToken) g.POST("/apiTokens/create", a.createApiToken)
g.POST("/apiTokens/delete/:id", a.deleteApiToken)
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
} }
// getAllSetting retrieves all current settings. // getAllSetting retrieves all current settings.
@ -76,7 +80,13 @@ func (a *SettingController) updateSetting(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return return
} }
oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
err = a.settingService.UpdateAllSetting(allSetting) err = a.settingService.UpdateAllSetting(allSetting)
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
err = bumpErr
}
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
} }
@ -124,26 +134,56 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
jsonObj(c, defaultJsonConfig, nil) jsonObj(c, defaultJsonConfig, nil)
} }
// getApiToken returns the panel's API token used by remote central type apiTokenCreateForm struct {
// panels to authenticate as Bearer tokens. The token is auto-generated Name string `json:"name" form:"name"`
// on first read so existing installs upgrade transparently. }
func (a *SettingController) getApiToken(c *gin.Context) {
tok, err := a.settingService.GetApiToken() type apiTokenEnabledForm struct {
Enabled bool `json:"enabled" form:"enabled"`
}
func (a *SettingController) listApiTokens(c *gin.Context) {
rows, err := a.apiTokenService.List()
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err) jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return return
} }
jsonObj(c, tok, nil) jsonObj(c, rows, nil)
} }
// regenerateApiToken rotates the API token. Any central panel that had func (a *SettingController) createApiToken(c *gin.Context) {
// the old value cached will start failing heartbeats until it is updated form := &apiTokenCreateForm{}
// with the new token — that's intentional, it's the whole point of rotation. if err := c.ShouldBind(form); err != nil {
func (a *SettingController) regenerateApiToken(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
tok, err := a.settingService.RegenerateApiToken() return
}
row, err := a.apiTokenService.Create(form.Name)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return return
} }
jsonObj(c, tok, nil) jsonObj(c, row, nil)
}
func (a *SettingController) deleteApiToken(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.Delete(id))
}
func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
form := &apiTokenEnabledForm{}
if bindErr := c.ShouldBind(form); bindErr != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled))
} }

View file

@ -9,12 +9,19 @@ import (
"github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/entity" "github.com/mhsanaei/3x-ui/v3/web/entity"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// getRemoteIp extracts the real IP address from the request headers or remote address. // getRemoteIp extracts the real IP address from the request headers or remote address.
func getRemoteIp(c *gin.Context) string { func getRemoteIp(c *gin.Context) string {
remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr)
if !ok {
return "unknown"
}
if isTrustedProxy(remoteIP) {
if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok { if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
return ip return ip
} }
@ -26,12 +33,51 @@ func getRemoteIp(c *gin.Context) string {
} }
} }
} }
if ip, ok := extractTrustedIP(c.Request.RemoteAddr); ok {
return ip
} }
return "unknown" return remoteIP
}
func isTrustedForwardedRequest(c *gin.Context) bool {
remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr)
return ok && isTrustedProxy(remoteIP)
}
func isTrustedProxy(ip string) bool {
addr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
trusted := trustedProxyCIDRs()
for _, value := range strings.Split(trusted, ",") {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if prefix, err := netip.ParsePrefix(value); err == nil {
if prefix.Contains(addr) {
return true
}
continue
}
if proxyIP, err := netip.ParseAddr(value); err == nil && proxyIP.Unmap() == addr.Unmap() {
return true
}
}
return false
}
func trustedProxyCIDRs() (trusted string) {
trusted = "127.0.0.1/32,::1/128"
defer func() {
_ = recover()
}()
settingService := service.SettingService{}
if value, err := settingService.GetTrustedProxyCIDRs(); err == nil && strings.TrimSpace(value) != "" {
trusted = value
}
return trusted
} }
func extractTrustedIP(value string) (string, bool) { func extractTrustedIP(value string) (string, bool) {

View file

@ -0,0 +1,34 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestGetRemoteIpIgnoresForwardedHeadersFromUntrustedRemote(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Request.RemoteAddr = "203.0.113.10:12345"
c.Request.Header.Set("X-Real-IP", "198.51.100.9")
c.Request.Header.Set("X-Forwarded-For", "198.51.100.8")
if got := getRemoteIp(c); got != "203.0.113.10" {
t.Fatalf("remote IP = %q, want request remote address", got)
}
}
func TestGetRemoteIpHonorsForwardedHeadersFromTrustedLoopbackProxy(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Request.RemoteAddr = "127.0.0.1:12345"
c.Request.Header.Set("X-Forwarded-For", "198.51.100.8, 127.0.0.1")
if got := getRemoteIp(c); got != "198.51.100.8" {
t.Fatalf("remote IP = %q, want forwarded client IP", got)
}
}

View file

@ -213,6 +213,11 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
// Load the test URL from server settings to prevent SSRF via user-controlled URLs // Load the test URL from server settings to prevent SSRF via user-controlled URLs
testURL, _ := a.SettingService.GetXrayOutboundTestUrl() testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode) result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode)
if err != nil { if err != nil {

View file

@ -28,6 +28,7 @@ type AllSetting struct {
WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server
WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers
// UI settings // UI settings
PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists
@ -74,6 +75,7 @@ type AllSetting struct {
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` // Restart Xray when clients are auto-disabled by expiry/traffic limit RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` // Restart Xray when clients are auto-disabled by expiry/traffic limit
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` // Encrypt subscription responses SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` // Encrypt subscription responses
SubShowInfo bool `json:"subShowInfo" form:"subShowInfo"` // Show client information in subscriptions SubShowInfo bool `json:"subShowInfo" form:"subShowInfo"` // Show client information in subscriptions
SubEmailInRemark bool `json:"subEmailInRemark" form:"subEmailInRemark"` // Include email in subscription remark/name
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
@ -110,6 +112,20 @@ type AllSetting struct {
// JSON subscription routing rules // JSON subscription routing rules
} }
// AllSettingView is the browser-safe settings read model. Secret values
// are redacted from the embedded write model and represented by presence
// flags so the UI can show configured/not configured state.
type AllSettingView struct {
AllSetting
HasTgBotToken bool `json:"hasTgBotToken"`
HasTwoFactorToken bool `json:"hasTwoFactorToken"`
HasLdapPassword bool `json:"hasLdapPassword"`
HasApiToken bool `json:"hasApiToken"`
HasWarpSecret bool `json:"hasWarpSecret"`
HasNordSecret bool `json:"hasNordSecret"`
}
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values. // CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
func (s *AllSetting) CheckValid() error { func (s *AllSetting) CheckValid() error {
if s.WebListen != "" { if s.WebListen != "" {
@ -179,6 +195,19 @@ func (s *AllSetting) CheckValid() error {
s.SubClashPath += "/" s.SubClashPath += "/"
} }
for _, cidr := range strings.Split(s.TrustedProxyCIDRs, ",") {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if ip := net.ParseIP(cidr); ip != nil {
continue
}
if _, _, err := net.ParseCIDR(cidr); err != nil {
return common.NewError("trusted proxy CIDR is not valid:", cidr)
}
}
_, err := time.LoadLocation(s.TimeLocation) _, err := time.LoadLocation(s.TimeLocation)
if err != nil { if err != nil {
return common.NewError("time location not exist:", s.TimeLocation) return common.NewError("time location not exist:", s.TimeLocation)

View file

@ -152,6 +152,11 @@ func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traf
logger.Warning("get ExternalTrafficInformURI failed:", err) logger.Warning("get ExternalTrafficInformURI failed:", err)
return return
} }
informURL, err = service.SanitizePublicHTTPURL(informURL, false)
if err != nil {
logger.Warning("ExternalTrafficInformURI blocked:", err)
return
}
requestBody, err := json.Marshal(map[string]any{"clientTraffics": clientTraffics, "inboundTraffics": inboundTraffics}) requestBody, err := json.Marshal(map[string]any{"clientTraffics": clientTraffics, "inboundTraffics": inboundTraffics})
if err != nil { if err != nil {
logger.Warning("parse client/inbound traffic failed:", err) logger.Warning("parse client/inbound traffic failed:", err)

View file

@ -1,6 +1,8 @@
package middleware package middleware
import ( import (
"crypto/rand"
"encoding/base64"
"net/http" "net/http"
"github.com/mhsanaei/3x-ui/v3/web/session" "github.com/mhsanaei/3x-ui/v3/web/session"
@ -11,10 +13,12 @@ import (
// SecurityHeadersMiddleware adds browser hardening headers to panel responses. // SecurityHeadersMiddleware adds browser hardening headers to panel responses.
func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc { func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
nonce := newCSPNonce()
c.Set("csp_nonce", nonce)
c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY") c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "no-referrer") c.Header("Referrer-Policy", "no-referrer")
c.Header("Content-Security-Policy", "frame-ancestors 'none'; base-uri 'self'; form-action 'self'") c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'nonce-"+nonce+"'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
if directHTTPS { if directHTTPS {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains") c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
} }
@ -22,6 +26,14 @@ func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc {
} }
} }
func newCSPNonce() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return ""
}
return base64.RawStdEncoding.EncodeToString(b[:])
}
// CSRFMiddleware rejects unsafe requests that do not include the session CSRF token. // CSRFMiddleware rejects unsafe requests that do not include the session CSRF token.
// Bearer-token-authenticated callers (api_authed flag set by APIController.checkAPIAuth) // Bearer-token-authenticated callers (api_authed flag set by APIController.checkAPIAuth)
// short-circuit the CSRF check — they are not browser sessions, so the // short-circuit the CSRF check — they are not browser sessions, so the

View file

@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@ -16,6 +17,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
) )
const remoteHTTPTimeout = 10 * time.Second const remoteHTTPTimeout = 10 * time.Second
@ -25,6 +27,7 @@ var remoteHTTPClient = &http.Client{
MaxIdleConns: 64, MaxIdleConns: 64,
MaxIdleConnsPerHost: 4, MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second, IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
}, },
} }
@ -50,7 +53,18 @@ func NewRemote(n *model.Node) *Remote {
func (r *Remote) Name() string { return "node:" + r.node.Name } func (r *Remote) Name() string { return "node:" + r.node.Name }
func (r *Remote) baseURL() string { func (r *Remote) baseURL() (string, error) {
addr, err := netsafe.NormalizeHost(r.node.Address)
if err != nil {
return "", err
}
scheme := r.node.Scheme
if scheme != "http" && scheme != "https" {
scheme = "https"
}
if r.node.Port <= 0 || r.node.Port > 65535 {
return "", fmt.Errorf("invalid node port %d", r.node.Port)
}
bp := r.node.BasePath bp := r.node.BasePath
if bp == "" { if bp == "" {
bp = "/" bp = "/"
@ -58,7 +72,12 @@ func (r *Remote) baseURL() string {
if !strings.HasSuffix(bp, "/") { if !strings.HasSuffix(bp, "/") {
bp += "/" bp += "/"
} }
return fmt.Sprintf("%s://%s:%d%s", r.node.Scheme, r.node.Address, r.node.Port, bp) u := &url.URL{
Scheme: scheme,
Host: net.JoinHostPort(addr, strconv.Itoa(r.node.Port)),
Path: bp,
}
return u.String(), nil
} }
func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelope, error) { func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelope, error) {
@ -66,7 +85,11 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
return nil, errors.New("node has no API token configured") return nil, errors.New("node has no API token configured")
} }
target := r.baseURL() + strings.TrimPrefix(path, "/") base, err := r.baseURL()
if err != nil {
return nil, err
}
target := base + strings.TrimPrefix(path, "/")
var ( var (
reqBody io.Reader reqBody io.Reader
@ -78,15 +101,15 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
reqBody = strings.NewReader(b.Encode()) reqBody = strings.NewReader(b.Encode())
contentType = "application/x-www-form-urlencoded" contentType = "application/x-www-form-urlencoded"
default: default:
buf, err := json.Marshal(b) buf, jerr := json.Marshal(b)
if err != nil { if jerr != nil {
return nil, fmt.Errorf("marshal body: %w", err) return nil, fmt.Errorf("marshal body: %w", jerr)
} }
reqBody = bytes.NewReader(buf) reqBody = bytes.NewReader(buf)
contentType = "application/json" contentType = "application/json"
} }
cctx, cancel := context.WithTimeout(ctx, remoteHTTPTimeout) cctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(ctx, r.node.AllowPrivateAddress), remoteHTTPTimeout)
defer cancel() defer cancel()
req, err := http.NewRequestWithContext(cctx, method, target, reqBody) req, err := http.NewRequestWithContext(cctx, method, target, reqBody)
if err != nil { if err != nil {
@ -311,7 +334,7 @@ func wireInbound(ib *model.Inbound) url.Values {
v.Set("port", strconv.Itoa(ib.Port)) v.Set("port", strconv.Itoa(ib.Port))
v.Set("protocol", string(ib.Protocol)) v.Set("protocol", string(ib.Protocol))
v.Set("settings", ib.Settings) v.Set("settings", ib.Settings)
v.Set("streamSettings", ib.StreamSettings) v.Set("streamSettings", sanitizeStreamSettingsForRemote(ib.StreamSettings))
v.Set("tag", ib.Tag) v.Set("tag", ib.Tag)
v.Set("sniffing", ib.Sniffing) v.Set("sniffing", ib.Sniffing)
if ib.TrafficReset != "" { if ib.TrafficReset != "" {
@ -319,3 +342,44 @@ func wireInbound(ib *model.Inbound) url.Values {
} }
return v return v
} }
// sanitizeStreamSettingsForRemote strips file-based TLS certificate paths
// from the StreamSettings before sending to a remote node. File paths
// (certificateFile / keyFile) are local to the main panel's filesystem
// and will cause Xray on the remote node to crash if they don't exist there.
// Inline certificate content (certificate / key) is kept intact.
func sanitizeStreamSettingsForRemote(streamSettings string) string {
if streamSettings == "" {
return streamSettings
}
var stream map[string]any
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return streamSettings
}
tlsSettings, ok := stream["tlsSettings"].(map[string]any)
if !ok {
return streamSettings
}
certificates, ok := tlsSettings["certificates"].([]any)
if !ok {
return streamSettings
}
for _, cert := range certificates {
c, ok := cert.(map[string]any)
if !ok {
continue
}
delete(c, "certificateFile")
delete(c, "keyFile")
}
out, err := json.Marshal(stream)
if err != nil {
return streamSettings
}
return string(out)
}

119
web/service/api_token.go Normal file
View file

@ -0,0 +1,119 @@
package service
import (
"crypto/subtle"
"errors"
"strings"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/util/random"
)
type ApiTokenService struct{}
const apiTokenLength = 48
type ApiTokenView struct {
Id int `json:"id"`
Name string `json:"name"`
Token string `json:"token"`
Enabled bool `json:"enabled"`
CreatedAt int64 `json:"createdAt"`
}
func toView(t *model.ApiToken) *ApiTokenView {
return &ApiTokenView{
Id: t.Id,
Name: t.Name,
Token: t.Token,
Enabled: t.Enabled,
CreatedAt: t.CreatedAt,
}
}
func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
db := database.GetDB()
var rows []*model.ApiToken
if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]*ApiTokenView, 0, len(rows))
for _, r := range rows {
out = append(out, toView(r))
}
return out, nil
}
func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, common.NewError("token name is required")
}
if len(name) > 64 {
return nil, common.NewError("token name must be 64 characters or fewer")
}
db := database.GetDB()
var count int64
if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewError("a token with that name already exists")
}
row := &model.ApiToken{
Name: name,
Token: random.Seq(apiTokenLength),
Enabled: true,
}
if err := db.Create(row).Error; err != nil {
return nil, err
}
return toView(row), nil
}
func (s *ApiTokenService) Delete(id int) error {
if id <= 0 {
return common.NewError("invalid token id")
}
db := database.GetDB()
return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
}
func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
if id <= 0 {
return common.NewError("invalid token id")
}
db := database.GetDB()
res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("token not found")
}
return nil
}
// Match returns true when the presented bearer token matches any enabled
// row in api_tokens. Uses constant-time compare per row so a remote
// attacker can't time-attack tokens byte-by-byte.
func (s *ApiTokenService) Match(presented string) bool {
if presented == "" {
return false
}
db := database.GetDB()
var rows []*model.ApiToken
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
return false
}
presentedBytes := []byte(presented)
matched := false
for _, r := range rows {
if subtle.ConstantTimeCompare([]byte(r.Token), presentedBytes) == 1 {
matched = true
}
}
return matched
}

View file

@ -1,4 +1,21 @@
{ {
"api": {
"services": [
"HandlerService",
"LoggerService",
"StatsService"
],
"tag": "api"
},
"inbounds": [{
"listen": "127.0.0.1",
"port": 62789,
"protocol": "tunnel",
"settings": {
"address": "127.0.0.1"
},
"tag": "api"
}],
"log": { "log": {
"access": "none", "access": "none",
"dnsLog": false, "dnsLog": false,
@ -6,39 +23,21 @@
"loglevel": "warning", "loglevel": "warning",
"maskAddress": "" "maskAddress": ""
}, },
"api": { "metrics": {
"tag": "api", "listen": "127.0.0.1:11111",
"services": [ "tag": "metrics_out"
"HandlerService",
"LoggerService",
"StatsService"
]
}, },
"inbounds": [ "outbounds": [{
{
"tag": "api",
"listen": "127.0.0.1",
"port": 62789,
"protocol": "tunnel",
"settings": {
"address": "127.0.0.1"
}
}
],
"outbounds": [
{
"tag": "direct",
"protocol": "freedom", "protocol": "freedom",
"settings": { "settings": {
"domainStrategy": "AsIs", "domainStrategy": "AsIs"
"redirect": "", },
"noises": [] "tag": "direct"
}
}, },
{ {
"tag": "blocked",
"protocol": "blackhole", "protocol": "blackhole",
"settings": {} "settings": {},
"tag": "blocked"
} }
], ],
"policy": { "policy": {
@ -57,33 +56,28 @@
}, },
"routing": { "routing": {
"domainStrategy": "AsIs", "domainStrategy": "AsIs",
"rules": [ "rules": [{
{
"type": "field",
"inboundTag": [ "inboundTag": [
"api" "api"
], ],
"outboundTag": "api" "outboundTag": "api",
"type": "field"
}, },
{ {
"type": "field",
"outboundTag": "blocked",
"ip": [ "ip": [
"geoip:private" "geoip:private"
] ],
"outboundTag": "blocked",
"type": "field"
}, },
{ {
"type": "field",
"outboundTag": "blocked", "outboundTag": "blocked",
"protocol": [ "protocol": [
"bittorrent" "bittorrent"
] ],
"type": "field"
} }
] ]
}, },
"stats": {}, "stats": {}
"metrics": {
"tag": "metrics_out",
"listen": "127.0.0.1:11111"
}
} }

View file

@ -18,6 +18,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/database" "github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
) )
const ( const (
@ -164,8 +165,7 @@ func CustomGeoLocalFileNeedsRepair(path string) bool {
} }
func isBlockedIP(ip net.IP) bool { func isBlockedIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || return netsafe.IsBlockedIP(ip)
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
} }
// checkSSRFDefault validates that the given host does not resolve to a private/internal IP. // checkSSRFDefault validates that the given host does not resolve to a private/internal IP.

View file

@ -849,6 +849,9 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
// Secure client ID // Secure client ID
for _, client := range clients { for _, client := range clients {
if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required")
}
switch oldInbound.Protocol { switch oldInbound.Protocol {
case "trojan": case "trojan":
if client.Password == "" { if client.Password == "" {
@ -1317,8 +1320,11 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
if newClientId == "" || clientIndex == -1 { if newClientId == "" || clientIndex == -1 {
return false, common.NewError("empty client ID") return false, common.NewError("empty client ID")
} }
if strings.TrimSpace(clients[0].Email) == "" {
return false, common.NewError("client email is required")
}
if len(clients[0].Email) > 0 && clients[0].Email != oldEmail { if clients[0].Email != oldEmail {
existEmail, err := s.checkEmailsExistForClients(clients) existEmail, err := s.checkEmailsExistForClients(clients)
if err != nil { if err != nil {
return false, err return false, err
@ -2036,6 +2042,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
traffics[traffic_index].Up = 0 traffics[traffic_index].Up = 0
if !traffic.Enable { if !traffic.Enable {
traffics[traffic_index].Enable = true traffics[traffic_index].Enable = true
c["enable"] = true
clientsToAdd = append(clientsToAdd, clientsToAdd = append(clientsToAdd,
struct { struct {
protocol string protocol string

View file

@ -5,7 +5,9 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -13,6 +15,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/database" "github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/common" "github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
"github.com/mhsanaei/3x-ui/v3/web/runtime" "github.com/mhsanaei/3x-ui/v3/web/runtime"
) )
@ -34,6 +37,7 @@ var nodeHTTPClient = &http.Client{
MaxIdleConns: 64, MaxIdleConns: 64,
MaxIdleConnsPerHost: 4, MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second, IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
}, },
} }
@ -69,14 +73,15 @@ func normalizeBasePath(p string) string {
func (s *NodeService) normalize(n *model.Node) error { func (s *NodeService) normalize(n *model.Node) error {
n.Name = strings.TrimSpace(n.Name) n.Name = strings.TrimSpace(n.Name)
n.Address = strings.TrimSpace(n.Address)
n.ApiToken = strings.TrimSpace(n.ApiToken) n.ApiToken = strings.TrimSpace(n.ApiToken)
if n.Name == "" { if n.Name == "" {
return common.NewError("node name is required") return common.NewError("node name is required")
} }
if n.Address == "" { addr, err := netsafe.NormalizeHost(n.Address)
return common.NewError("node address is required") if err != nil {
return common.NewError(err.Error())
} }
n.Address = addr
if n.Port <= 0 || n.Port > 65535 { if n.Port <= 0 || n.Port > 65535 {
return common.NewError("node port must be 1-65535") return common.NewError("node port must be 1-65535")
} }
@ -113,6 +118,7 @@ func (s *NodeService) Update(id int, in *model.Node) error {
"base_path": in.BasePath, "base_path": in.BasePath,
"api_token": in.ApiToken, "api_token": in.ApiToken,
"enable": in.Enable, "enable": in.Enable,
"allow_private_address": in.AllowPrivateAddress,
} }
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil { if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err return err
@ -174,10 +180,29 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) { func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()} patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
url := fmt.Sprintf("%s://%s:%d%spanel/api/server/status",
n.Scheme, n.Address, n.Port, normalizeBasePath(n.BasePath))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) addr, err := netsafe.NormalizeHost(n.Address)
if err != nil {
patch.LastError = err.Error()
return patch, err
}
scheme := n.Scheme
if scheme != "http" && scheme != "https" {
scheme = "https"
}
if n.Port <= 0 || n.Port > 65535 {
patch.LastError = "node port must be 1-65535"
return patch, errors.New(patch.LastError)
}
probeURL := &url.URL{
Scheme: scheme,
Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)),
Path: normalizeBasePath(n.BasePath) + "panel/api/server/status",
}
req, err := http.NewRequestWithContext(
netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
http.MethodGet, probeURL.String(), nil)
if err != nil { if err != nil {
patch.LastError = err.Error() patch.LastError = err.Error()
return patch, err return patch, err

View file

@ -3,6 +3,7 @@ package service
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
@ -28,6 +29,11 @@ type PanelUpdateInfo struct {
UpdateAvailable bool `json:"updateAvailable"` UpdateAvailable bool `json:"updateAvailable"`
} }
const (
panelUpdaterURL = "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh"
maxPanelUpdaterBytes = 2 << 20
)
func (s *PanelService) RestartPanel(delay time.Duration) error { func (s *PanelService) RestartPanel(delay time.Duration) error {
p, err := os.FindProcess(syscall.Getpid()) p, err := os.FindProcess(syscall.Getpid())
if err != nil { if err != nil {
@ -67,13 +73,14 @@ func (s *PanelService) StartUpdate() error {
if err != nil { if err != nil {
return fmt.Errorf("bash is required to run the panel updater: %w", err) return fmt.Errorf("bash is required to run the panel updater: %w", err)
} }
curl, err := exec.LookPath("curl")
scriptPath, err := downloadPanelUpdater()
if err != nil { if err != nil {
return fmt.Errorf("curl is required to download the panel updater: %w", err) return err
} }
mainFolder, serviceFolder := resolveUpdateFolders() mainFolder, serviceFolder := resolveUpdateFolders()
updateScript := fmt.Sprintf("set -o pipefail; %s -fLs https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh | %s", shellQuote(curl), shellQuote(bash)) updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath))
if systemdRun, err := exec.LookPath("systemd-run"); err == nil { if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix()) unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix())
@ -88,6 +95,7 @@ func (s *PanelService) StartUpdate() error {
output := strings.TrimSpace(string(out)) output := strings.TrimSpace(string(out))
if !strings.Contains(output, "System has not been booted with systemd") && if !strings.Contains(output, "System has not been booted with systemd") &&
!strings.Contains(output, "Failed to connect to bus") { !strings.Contains(output, "Failed to connect to bus") {
_ = os.Remove(scriptPath)
return fmt.Errorf("failed to start panel update job: %w: %s", err, output) return fmt.Errorf("failed to start panel update job: %w: %s", err, output)
} }
logger.Warning("systemd-run is unavailable, falling back to detached update process:", output) logger.Warning("systemd-run is unavailable, falling back to detached update process:", output)
@ -104,6 +112,7 @@ func (s *PanelService) StartUpdate() error {
) )
setDetachedProcess(cmd) setDetachedProcess(cmd)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
_ = os.Remove(scriptPath)
return fmt.Errorf("failed to start panel update job: %w", err) return fmt.Errorf("failed to start panel update job: %w", err)
} }
if err := cmd.Process.Release(); err != nil { if err := cmd.Process.Release(); err != nil {
@ -113,6 +122,44 @@ func (s *PanelService) StartUpdate() error {
return nil return nil
} }
func downloadPanelUpdater() (string, error) {
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(panelUpdaterURL)
if err != nil {
return "", fmt.Errorf("download panel updater: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download panel updater: unexpected HTTP %d", resp.StatusCode)
}
file, err := os.CreateTemp("", "3x-ui-update-*.sh")
if err != nil {
return "", err
}
path := file.Name()
ok := false
defer func() {
_ = file.Close()
if !ok {
_ = os.Remove(path)
}
}()
n, err := io.Copy(file, io.LimitReader(resp.Body, maxPanelUpdaterBytes+1))
if err != nil {
return "", fmt.Errorf("write panel updater: %w", err)
}
if n > maxPanelUpdaterBytes {
return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes)
}
if err := file.Chmod(0700); err != nil {
return "", err
}
ok = true
return path, nil
}
func fetchLatestPanelVersion() (string, error) { func fetchLatestPanelVersion() (string, error) {
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest") resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest")

View file

@ -14,6 +14,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -493,6 +494,11 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) {
var xrayVersionsClient = &http.Client{Timeout: 10 * time.Second} var xrayVersionsClient = &http.Client{Timeout: 10 * time.Second}
const (
maxXrayArchiveBytes = 200 << 20
maxXrayBinaryBytes = 200 << 20
)
func (s *ServerService) GetXrayVersions() ([]string, error) { func (s *ServerService) GetXrayVersions() ([]string, error) {
const ( const (
XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases" XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases"
@ -601,28 +607,53 @@ func (s *ServerService) downloadXRay(version string) (string, error) {
fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch) fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch)
url := fmt.Sprintf("https://github.com/XTLS/Xray-core/releases/download/%s/%s", version, fileName) url := fmt.Sprintf("https://github.com/XTLS/Xray-core/releases/download/%s/%s", version, fileName)
resp, err := http.Get(url) client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Get(url)
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download xray: unexpected HTTP %d", resp.StatusCode)
}
if resp.ContentLength > maxXrayArchiveBytes {
return "", fmt.Errorf("download xray: archive exceeds %d bytes", maxXrayArchiveBytes)
}
os.Remove(fileName) file, err := os.CreateTemp("", "xray-*.zip")
file, err := os.Create(fileName)
if err != nil { if err != nil {
return "", err return "", err
} }
defer file.Close() path := file.Name()
ok := false
defer func() {
_ = file.Close()
if !ok {
_ = os.Remove(path)
}
}()
_, err = io.Copy(file, resp.Body) n, err := io.Copy(file, io.LimitReader(resp.Body, maxXrayArchiveBytes+1))
if err != nil { if err != nil {
return "", err return "", err
} }
if n > maxXrayArchiveBytes {
return "", fmt.Errorf("download xray: archive exceeds %d bytes", maxXrayArchiveBytes)
}
return fileName, nil ok = true
return path, nil
} }
func (s *ServerService) UpdateXray(version string) error { func (s *ServerService) UpdateXray(version string) error {
versions, err := s.GetXrayVersions()
if err != nil {
return err
}
if !slices.Contains(versions, version) {
return fmt.Errorf("xray version %q is not in the fetched release list", version)
}
// 1. Stop xray before doing anything // 1. Stop xray before doing anything
if err := s.StopXrayService(); err != nil { if err := s.StopXrayService(); err != nil {
logger.Warning("failed to stop xray before update:", err) logger.Warning("failed to stop xray before update:", err)
@ -657,16 +688,43 @@ func (s *ServerService) UpdateXray(version string) error {
return err return err
} }
defer zipFile.Close() defer zipFile.Close()
os.MkdirAll(filepath.Dir(fileName), 0755) if err := os.MkdirAll(filepath.Dir(fileName), 0755); err != nil {
os.Remove(fileName) return err
file, err := os.OpenFile(fileName, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0755) }
tmpFile, err := os.CreateTemp(filepath.Dir(fileName), ".xray-*")
if err != nil { if err != nil {
return err return err
} }
defer file.Close() tmpPath := tmpFile.Name()
_, err = io.Copy(file, zipFile) ok := false
defer func() {
_ = tmpFile.Close()
if !ok {
_ = os.Remove(tmpPath)
}
}()
n, err := io.Copy(tmpFile, io.LimitReader(zipFile, maxXrayBinaryBytes+1))
if err != nil {
return err return err
} }
if n > maxXrayBinaryBytes {
return fmt.Errorf("xray binary exceeds %d bytes", maxXrayBinaryBytes)
}
if err := tmpFile.Chmod(0755); err != nil {
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
if runtime.GOOS == "windows" {
_ = os.Remove(fileName)
}
if err := os.Rename(tmpPath, fileName); err != nil {
return err
}
ok = true
return nil
}
// 4. Extract correct binary // 4. Extract correct binary
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {

View file

@ -1,7 +1,6 @@
package service package service
import ( import (
"crypto/subtle"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"errors" "errors"
@ -36,6 +35,7 @@ var defaultValueMap = map[string]string{
"apiToken": "", "apiToken": "",
"webBasePath": "/", "webBasePath": "/",
"sessionMaxAge": "360", "sessionMaxAge": "360",
"trustedProxyCIDRs": "127.0.0.1/32,::1/128",
"pageSize": "25", "pageSize": "25",
"expireDiff": "0", "expireDiff": "0",
"trafficDiff": "0", "trafficDiff": "0",
@ -70,6 +70,7 @@ var defaultValueMap = map[string]string{
"subUpdates": "12", "subUpdates": "12",
"subEncrypt": "true", "subEncrypt": "true",
"subShowInfo": "true", "subShowInfo": "true",
"subEmailInRemark": "true",
"subURI": "", "subURI": "",
"subJsonPath": "/json/", "subJsonPath": "/json/",
"subJsonURI": "", "subJsonURI": "",
@ -199,6 +200,35 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
return allSetting, nil return allSetting, nil
} }
func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
allSetting, err := s.GetAllSetting()
if err != nil {
return nil, err
}
view := &entity.AllSettingView{AllSetting: *allSetting}
view.HasTgBotToken = secretConfigured(allSetting.TgBotToken)
view.HasTwoFactorToken = secretConfigured(allSetting.TwoFactorToken)
view.HasLdapPassword = secretConfigured(allSetting.LdapPassword)
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
var apiTokenCount int64
if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
view.HasApiToken = apiTokenCount > 0
}
view.TgBotToken = ""
view.TwoFactorToken = ""
view.LdapPassword = ""
return view, nil
}
func secretConfigured(value string) bool {
return strings.TrimSpace(value) != ""
}
func mustString(value string, _ error) string {
return value
}
func (s *SettingService) ResetSettings() error { func (s *SettingService) ResetSettings() error {
db := database.GetDB() db := database.GetDB()
err := db.Where("1 = 1").Delete(model.Setting{}).Error err := db.Where("1 = 1").Delete(model.Setting{}).Error
@ -286,7 +316,11 @@ func (s *SettingService) GetXrayOutboundTestUrl() (string, error) {
} }
func (s *SettingService) SetXrayOutboundTestUrl(url string) error { func (s *SettingService) SetXrayOutboundTestUrl(url string) error {
return s.setString("xrayOutboundTestUrl", url) clean, err := SanitizeHTTPURL(url)
if err != nil {
return err
}
return s.setString("xrayOutboundTestUrl", clean)
} }
func (s *SettingService) GetListen() (string, error) { func (s *SettingService) GetListen() (string, error) {
@ -417,6 +451,10 @@ func (s *SettingService) GetSessionMaxAge() (int, error) {
return s.getInt("sessionMaxAge") return s.getInt("sessionMaxAge")
} }
func (s *SettingService) GetTrustedProxyCIDRs() (string, error) {
return s.getString("trustedProxyCIDRs")
}
func (s *SettingService) GetRemarkModel() (string, error) { func (s *SettingService) GetRemarkModel() (string, error) {
return s.getString("remarkModel") return s.getString("remarkModel")
} }
@ -432,48 +470,6 @@ func (s *SettingService) GetSecret() ([]byte, error) {
return []byte(secret), err return []byte(secret), err
} }
// GetApiToken returns the panel's API token, lazily generating one on
// first read so existing installs upgrade transparently. The token is
// stored plaintext to match how the existing tg/ldap secrets are kept.
func (s *SettingService) GetApiToken() (string, error) {
tok, err := s.getString("apiToken")
if err != nil {
return "", err
}
if tok == "" {
tok = random.Seq(48)
if saveErr := s.saveSetting("apiToken", tok); saveErr != nil {
logger.Warning("save apiToken failed:", saveErr)
return "", saveErr
}
}
return tok, nil
}
// RegenerateApiToken rotates the API token, invalidating any central
// panel that has the old value cached.
func (s *SettingService) RegenerateApiToken() (string, error) {
tok := random.Seq(48)
if err := s.saveSetting("apiToken", tok); err != nil {
return "", err
}
return tok, nil
}
// MatchApiToken returns true when the supplied bearer token matches the
// stored API token. Uses constant-time compare so a remote attacker
// can't time-attack the token byte-by-byte.
func (s *SettingService) MatchApiToken(presented string) bool {
if presented == "" {
return false
}
stored, err := s.getString("apiToken")
if err != nil || stored == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(stored), []byte(presented)) == 1
}
func (s *SettingService) SetBasePath(basePath string) error { func (s *SettingService) SetBasePath(basePath string) error {
if !strings.HasPrefix(basePath, "/") { if !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath basePath = "/" + basePath
@ -597,6 +593,10 @@ func (s *SettingService) GetSubShowInfo() (bool, error) {
return s.getBool("subShowInfo") return s.getBool("subShowInfo")
} }
func (s *SettingService) GetSubEmailInRemark() (bool, error) {
return s.getBool("subEmailInRemark")
}
func (s *SettingService) GetPageSize() (int, error) { func (s *SettingService) GetPageSize() (int, error) {
return s.getInt("pageSize") return s.getInt("pageSize")
} }
@ -771,6 +771,12 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
} }
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := s.preserveRedactedSecrets(allSetting); err != nil {
return err
}
if err := validateSettingsURLs(allSetting); err != nil {
return err
}
if err := allSetting.CheckValid(); err != nil { if err := allSetting.CheckValid(); err != nil {
return err return err
} }
@ -791,6 +797,58 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
return common.Combine(errs...) return common.Combine(errs...)
} }
func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting) error {
if strings.TrimSpace(allSetting.TgBotToken) == "" {
value, err := s.GetTgBotToken()
if err != nil {
return err
}
allSetting.TgBotToken = value
}
if strings.TrimSpace(allSetting.LdapPassword) == "" {
value, err := s.GetLdapPassword()
if err != nil {
return err
}
allSetting.LdapPassword = value
}
if allSetting.TwoFactorEnable && strings.TrimSpace(allSetting.TwoFactorToken) == "" {
value, err := s.GetTwoFactorToken()
if err != nil {
return err
}
allSetting.TwoFactorToken = value
}
return nil
}
func validateSettingsURLs(allSetting *entity.AllSetting) error {
if allSetting.ExternalTrafficInformURI != "" {
u, err := SanitizeHTTPURL(allSetting.ExternalTrafficInformURI)
if err != nil {
return common.NewError("external traffic inform URI is invalid:", err)
}
allSetting.ExternalTrafficInformURI = u
}
if allSetting.TgBotAPIServer != "" {
u, err := SanitizeHTTPURL(allSetting.TgBotAPIServer)
if err != nil {
return common.NewError("telegram API server URL is invalid:", err)
}
allSetting.TgBotAPIServer = u
}
return nil
}
func (s *SettingService) UpdateSecret(key string, value string) error {
switch key {
case "tgBotToken", "ldapPassword", "twoFactorToken":
return s.saveSetting(key, strings.TrimSpace(value))
default:
return common.NewError("secret key is not replaceable:", key)
}
}
func (s *SettingService) GetDefaultXrayConfig() (any, error) { func (s *SettingService) GetDefaultXrayConfig() (any, error) {
var jsonData any var jsonData any
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData) err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)

View file

@ -0,0 +1,93 @@
package service
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
func setupSettingTestDB(t *testing.T) {
t.Helper()
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := database.CloseDB(); err != nil {
t.Fatal(err)
}
})
}
func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
setupSettingTestDB(t)
s := &SettingService{}
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
t.Fatal(err)
}
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
t.Fatal(err)
}
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
t.Fatal(err)
}
if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
view, err := s.GetAllSettingView()
if err != nil {
t.Fatal(err)
}
if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" {
t.Fatalf("settings view leaked secrets: %#v", view)
}
if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken {
t.Fatalf("settings view did not report configured secret flags: %#v", view)
}
}
func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
setupSettingTestDB(t)
s := &SettingService{}
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
t.Fatal(err)
}
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
t.Fatal(err)
}
if err := s.saveSetting("twoFactorEnable", "true"); err != nil {
t.Fatal(err)
}
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
t.Fatal(err)
}
view, err := s.GetAllSettingView()
if err != nil {
t.Fatal(err)
}
settings := &view.AllSetting
if err := s.UpdateAllSetting(settings); err != nil {
t.Fatal(err)
}
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
t.Fatalf("tg token = %q, want preserved secret", got)
}
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
t.Fatalf("ldap password = %q, want preserved secret", got)
}
if got, _ := s.GetTwoFactorToken(); got != "totp-secret" {
t.Fatalf("2fa token = %q, want preserved secret", got)
}
}
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
t.Fatal("expected localhost URL to be blocked")
}
if got, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", true); err != nil || got != "http://127.0.0.1:8080/hook" {
t.Fatalf("allowPrivate result = %q, %v", got, err)
}
}

View file

@ -341,15 +341,12 @@ func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*tel
// Validate API server URL if provided // Validate API server URL if provided
if apiServerUrl != "" { if apiServerUrl != "" {
if !strings.HasPrefix(apiServerUrl, "http") { safeURL, err := SanitizePublicHTTPURL(apiServerUrl, false)
logger.Warning("Invalid http(s) URL for API server, using default") if err != nil {
logger.Warningf("Invalid or blocked API server URL, using default: %v", err)
apiServerUrl = "" apiServerUrl = ""
} else { } else {
_, err := url.Parse(apiServerUrl) apiServerUrl = safeURL
if err != nil {
logger.Warningf("Can't parse API server URL, using default: %v", err)
apiServerUrl = ""
}
} }
} }

82
web/service/url_safety.go Normal file
View file

@ -0,0 +1,82 @@
package service
import (
"context"
"fmt"
"net"
"net/url"
"strings"
"time"
)
// SanitizeHTTPURL validates and normalizes an http(s) URL without resolving
// DNS. Use SanitizePublicHTTPURL at the point of an outbound request.
func SanitizeHTTPURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
u, err := url.Parse(raw)
if err != nil {
return "", err
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("unsupported URL scheme %q", u.Scheme)
}
if u.Host == "" || u.Hostname() == "" {
return "", fmt.Errorf("URL host is required")
}
clean := &url.URL{
Scheme: u.Scheme,
Host: u.Host,
Path: u.Path,
RawPath: u.RawPath,
RawQuery: u.RawQuery,
Fragment: u.Fragment,
}
return clean.String(), nil
}
// SanitizePublicHTTPURL validates and normalizes an http(s) URL, then blocks
// private/internal targets unless the caller explicitly allows them.
func SanitizePublicHTTPURL(raw string, allowPrivate bool) (string, error) {
clean, err := SanitizeHTTPURL(raw)
if err != nil || clean == "" {
return clean, err
}
if allowPrivate {
return clean, nil
}
u, err := url.Parse(clean)
if err != nil {
return "", err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := rejectPrivateHost(ctx, u.Hostname()); err != nil {
return "", err
}
return clean, nil
}
func rejectPrivateHost(ctx context.Context, hostname string) error {
if ip := net.ParseIP(hostname); ip != nil {
if isBlockedIP(ip) {
return fmt.Errorf("blocked private/internal address %s", ip.String())
}
return nil
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
if err != nil {
return fmt.Errorf("cannot resolve host %s: %w", hostname, err)
}
if len(ips) == 0 {
return fmt.Errorf("host %s has no IP addresses", hostname)
}
for _, ipAddr := range ips {
if isBlockedIP(ipAddr.IP) {
return fmt.Errorf("host %s resolves to blocked private/internal address %s", hostname, ipAddr.IP.String())
}
}
return nil
}

View file

@ -102,6 +102,14 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
return user, nil return user, nil
} }
func (s *UserService) BumpLoginEpoch() error {
db := database.GetDB()
return db.Model(model.User{}).
Where("1 = 1").
Update("login_epoch", gorm.Expr("login_epoch + 1")).
Error
}
func (s *UserService) UpdateUser(id int, username string, password string) error { func (s *UserService) UpdateUser(id int, username string, password string) error {
db := database.GetDB() db := database.GetDB()
hashedPassword, err := crypto.HashPasswordAsBcrypt(password) hashedPassword, err := crypto.HashPasswordAsBcrypt(password)
@ -122,7 +130,11 @@ func (s *UserService) UpdateUser(id int, username string, password string) error
return db.Model(model.User{}). return db.Model(model.User{}).
Where("id = ?", id). Where("id = ?", id).
Updates(map[string]any{"username": username, "password": hashedPassword}). Updates(map[string]any{
"username": username,
"password": hashedPassword,
"login_epoch": gorm.Expr("login_epoch + 1"),
}).
Error Error
} }
@ -150,5 +162,6 @@ func (s *UserService) UpdateFirstUser(username string, password string) error {
} }
user.Username = username user.Username = username
user.Password = hashedPassword user.Password = hashedPassword
user.LoginEpoch++
return db.Save(user).Error return db.Save(user).Error
} }

View file

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/logger"
@ -14,6 +15,7 @@ import (
const ( const (
loginUserKey = "LOGIN_USER" loginUserKey = "LOGIN_USER"
loginEpochKey = "LOGIN_EPOCH"
apiAuthUserKey = "api_auth_user" apiAuthUserKey = "api_auth_user"
sessionCookieName = "3x-ui" sessionCookieName = "3x-ui"
) )
@ -27,7 +29,8 @@ func SetLoginUser(c *gin.Context, user *model.User) error {
return nil return nil
} }
s := sessions.Default(c) s := sessions.Default(c)
s.Set(loginUserKey, *user) s.Set(loginUserKey, user.Id)
s.Set(loginEpochKey, user.LoginEpoch)
return s.Save() return s.Save()
} }
@ -49,21 +52,113 @@ func GetLoginUser(c *gin.Context) *model.User {
if obj == nil { if obj == nil {
return nil return nil
} }
user, ok := obj.(model.User) userID, ok := sessionUserID(obj)
if !ok { if !ok {
s.Delete(loginUserKey) s.Delete(loginUserKey)
s.Delete(loginEpochKey)
if err := s.Save(); err != nil { if err := s.Save(); err != nil {
logger.Warning("session: failed to drop stale user payload:", err) logger.Warning("session: failed to drop stale user payload:", err)
} }
return nil return nil
} }
return &user if legacyUserID, ok := legacySessionUserID(obj); ok {
s.Set(loginUserKey, legacyUserID)
if err := s.Save(); err != nil {
logger.Warning("session: failed to migrate legacy user payload:", err)
}
}
user, err := getUserByID(userID)
if err != nil {
logger.Warning("session: failed to load user:", err)
s.Delete(loginUserKey)
s.Delete(loginEpochKey)
if saveErr := s.Save(); saveErr != nil {
logger.Warning("session: failed to drop missing user:", saveErr)
}
return nil
}
if !sessionEpochMatches(s.Get(loginEpochKey), user.LoginEpoch) {
s.Delete(loginUserKey)
s.Delete(loginEpochKey)
if saveErr := s.Save(); saveErr != nil {
logger.Warning("session: failed to drop stale epoch:", saveErr)
}
return nil
}
return user
}
func sessionEpochMatches(cookieVal any, userEpoch int64) bool {
var got int64
switch v := cookieVal.(type) {
case nil:
case int64:
got = v
case int:
got = int64(v)
case int32:
got = int64(v)
case float64:
got = int64(v)
default:
return false
}
return got == userEpoch
} }
func IsLogin(c *gin.Context) bool { func IsLogin(c *gin.Context) bool {
return GetLoginUser(c) != nil return GetLoginUser(c) != nil
} }
func sessionUserID(obj any) (int, bool) {
switch v := obj.(type) {
case int:
return v, v > 0
case int64:
return int(v), v > 0
case int32:
return int(v), v > 0
case float64:
id := int(v)
return id, v == float64(id) && id > 0
case model.User:
return v.Id, v.Id > 0
case *model.User:
if v == nil {
return 0, false
}
return v.Id, v.Id > 0
default:
return 0, false
}
}
func legacySessionUserID(obj any) (int, bool) {
switch v := obj.(type) {
case model.User:
return v.Id, v.Id > 0
case *model.User:
if v == nil {
return 0, false
}
return v.Id, v.Id > 0
default:
return 0, false
}
}
func getUserByID(id int) (*model.User, error) {
db := database.GetDB()
if db == nil {
return nil, http.ErrServerClosed
}
user := &model.User{}
if err := db.Model(model.User{}).Where("id = ?", id).First(user).Error; err != nil {
return nil, err
}
return user, nil
}
func ClearSession(c *gin.Context) error { func ClearSession(c *gin.Context) error {
s := sessions.Default(c) s := sessions.Default(c)
s.Clear() s.Clear()

View file

@ -0,0 +1,47 @@
package session
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
func TestSetLoginUserStoresOnlyUserID(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(sessions.Sessions(sessionCookieName, cookie.NewStore([]byte("01234567890123456789012345678901"))))
router.GET("/", func(c *gin.Context) {
if err := SetLoginUser(c, &model.User{Id: 7, Username: "admin", Password: "hash"}); err != nil {
t.Fatal(err)
}
got := sessions.Default(c).Get(loginUserKey)
if got != 7 {
t.Fatalf("stored session payload = %#v, want user id only", got)
}
c.Status(http.StatusNoContent)
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
}
func TestSessionUserIDSupportsLegacyUserPayload(t *testing.T) {
id, ok := sessionUserID(model.User{Id: 11, Username: "admin", Password: "hash"})
if !ok || id != 11 {
t.Fatalf("legacy session payload resolved to (%d, %v), want (11, true)", id, ok)
}
id, ok = sessionUserID(&model.User{Id: 12, Username: "admin", Password: "hash"})
if !ok || id != 12 {
t.Fatalf("legacy pointer session payload resolved to (%d, %v), want (12, true)", id, ok)
}
}

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "تم إعادة تعيين كل حركة المرور من العميل", "resetAllClientTrafficSuccess": "تم إعادة تعيين كل حركة المرور من العميل",
"resetAllTrafficSuccess": "تم إعادة تعيين كل حركة المرور", "resetAllTrafficSuccess": "تم إعادة تعيين كل حركة المرور",
"resetInboundClientTrafficSuccess": "تم إعادة تعيين حركة المرور", "resetInboundClientTrafficSuccess": "تم إعادة تعيين حركة المرور",
"resetInboundTrafficSuccess": "تم إعادة تعيين حركة مرور الداخل",
"trafficGetError": "خطأ في الحصول على حركات المرور", "trafficGetError": "خطأ في الحصول على حركات المرور",
"getNewX25519CertError": "حدث خطأ أثناء الحصول على شهادة X25519.", "getNewX25519CertError": "حدث خطأ أثناء الحصول على شهادة X25519.",
"getNewmldsa65Error": "حدث خطاء في الحصول على mldsa65.", "getNewmldsa65Error": "حدث خطاء في الحصول على mldsa65.",
@ -546,6 +547,8 @@
"subEncryptDesc": "المحتوى اللي هيترجع من خدمة الاشتراك هيكون مشفر بـ Base64.", "subEncryptDesc": "المحتوى اللي هيترجع من خدمة الاشتراك هيكون مشفر بـ Base64.",
"subShowInfo": "اظهر معلومات الاستخدام", "subShowInfo": "اظهر معلومات الاستخدام",
"subShowInfoDesc": "هيظهر الترافيك المتبقي والتاريخ في تطبيقات العملاء.", "subShowInfoDesc": "هيظهر الترافيك المتبقي والتاريخ في تطبيقات العملاء.",
"subEmailInRemark": "تضمين البريد الإلكتروني في الاسم",
"subEmailInRemarkDesc": "تضمين بريد العميل الإلكتروني في اسم ملف تعريف الاشتراك.",
"subURI": "مسار البروكسي العكسي", "subURI": "مسار البروكسي العكسي",
"subURIDesc": "مسار URI لرابط الاشتراك عشان تستخدمه ورا البروكسي.", "subURIDesc": "مسار URI لرابط الاشتراك عشان تستخدمه ورا البروكسي.",
"externalTrafficInformEnable": "تنبيه الترافيك الخارجي", "externalTrafficInformEnable": "تنبيه الترافيك الخارجي",
@ -588,7 +591,15 @@
"twoFactorModalChangeCredentialsStep": "أدخل الرمز من التطبيق لتغيير بيانات اعتماد المسؤول.", "twoFactorModalChangeCredentialsStep": "أدخل الرمز من التطبيق لتغيير بيانات اعتماد المسؤول.",
"twoFactorModalSetSuccess": "تم إنشاء المصادقة الثنائية بنجاح", "twoFactorModalSetSuccess": "تم إنشاء المصادقة الثنائية بنجاح",
"twoFactorModalDeleteSuccess": "تم حذف المصادقة الثنائية بنجاح", "twoFactorModalDeleteSuccess": "تم حذف المصادقة الثنائية بنجاح",
"twoFactorModalError": "رمز خاطئ" "twoFactorModalError": "رمز خاطئ",
"show": "إظهار",
"hide": "إخفاء",
"apiTokenNew": "رمز جديد",
"apiTokenName": "الاسم",
"apiTokenNamePlaceholder": "مثل central-panel-a",
"apiTokenNameRequired": "الاسم مطلوب",
"apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
"apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا."
}, },
"toasts": { "toasts": {
"modifySettings": "تم تغيير المعلمات.", "modifySettings": "تم تغيير المعلمات.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "All traffic from the client has been reset.", "resetAllClientTrafficSuccess": "All traffic from the client has been reset.",
"resetAllTrafficSuccess": "All traffic has been reset.", "resetAllTrafficSuccess": "All traffic has been reset.",
"resetInboundClientTrafficSuccess": "Traffic has been reset.", "resetInboundClientTrafficSuccess": "Traffic has been reset.",
"resetInboundTrafficSuccess": "Inbound traffic has been reset.",
"trafficGetError": "Error getting traffics.", "trafficGetError": "Error getting traffics.",
"getNewX25519CertError": "Error while obtaining the X25519 certificate.", "getNewX25519CertError": "Error while obtaining the X25519 certificate.",
"getNewmldsa65Error": "Error while obtaining mldsa65.", "getNewmldsa65Error": "Error while obtaining mldsa65.",
@ -546,6 +547,8 @@
"subEncryptDesc": "The returned content of subscription service will be Base64 encoded.", "subEncryptDesc": "The returned content of subscription service will be Base64 encoded.",
"subShowInfo": "Show Usage Info", "subShowInfo": "Show Usage Info",
"subShowInfoDesc": "The remaining traffic and date will be displayed in the client apps.", "subShowInfoDesc": "The remaining traffic and date will be displayed in the client apps.",
"subEmailInRemark": "Include Email in Name",
"subEmailInRemarkDesc": "Include the client email in the subscription profile name.",
"subURI": "Reverse Proxy URI", "subURI": "Reverse Proxy URI",
"subURIDesc": "The URI path of the subscription URL for use behind proxies.", "subURIDesc": "The URI path of the subscription URL for use behind proxies.",
"externalTrafficInformEnable": "External Traffic Inform", "externalTrafficInformEnable": "External Traffic Inform",
@ -588,7 +591,15 @@
"twoFactorModalChangeCredentialsStep": "Enter the code from the application to change administrator credentials.", "twoFactorModalChangeCredentialsStep": "Enter the code from the application to change administrator credentials.",
"twoFactorModalSetSuccess": "Two-factor authentication has been successfully established", "twoFactorModalSetSuccess": "Two-factor authentication has been successfully established",
"twoFactorModalDeleteSuccess": "Two-factor authentication has been successfully deleted", "twoFactorModalDeleteSuccess": "Two-factor authentication has been successfully deleted",
"twoFactorModalError": "Wrong code" "twoFactorModalError": "Wrong code",
"show": "Show",
"hide": "Hide",
"apiTokenNew": "New token",
"apiTokenName": "Name",
"apiTokenNamePlaceholder": "e.g. central-panel-a",
"apiTokenNameRequired": "Name is required",
"apiTokenEmpty": "No tokens yet — create one to authenticate bots or remote panels.",
"apiTokenDeleteWarning": "Any caller using this token will stop authenticating immediately."
}, },
"toasts": { "toasts": {
"modifySettings": "The parameters have been changed.", "modifySettings": "The parameters have been changed.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "Todo el tráfico del cliente ha sido reiniciado", "resetAllClientTrafficSuccess": "Todo el tráfico del cliente ha sido reiniciado",
"resetAllTrafficSuccess": "Todo el tráfico ha sido reiniciado", "resetAllTrafficSuccess": "Todo el tráfico ha sido reiniciado",
"resetInboundClientTrafficSuccess": "El tráfico ha sido reiniciado", "resetInboundClientTrafficSuccess": "El tráfico ha sido reiniciado",
"resetInboundTrafficSuccess": "El tráfico de entrada ha sido reiniciado",
"trafficGetError": "Error al obtener los tráficos", "trafficGetError": "Error al obtener los tráficos",
"getNewX25519CertError": "Error al obtener el certificado X25519.", "getNewX25519CertError": "Error al obtener el certificado X25519.",
"getNewmldsa65Error": "Error al obtener el certificado mldsa65.", "getNewmldsa65Error": "Error al obtener el certificado mldsa65.",
@ -546,6 +547,8 @@
"subEncryptDesc": "Encriptar las configuraciones devueltas en la suscripción.", "subEncryptDesc": "Encriptar las configuraciones devueltas en la suscripción.",
"subShowInfo": "Mostrar información de uso", "subShowInfo": "Mostrar información de uso",
"subShowInfoDesc": "Mostrar tráfico restante y fecha después del nombre de configuración.", "subShowInfoDesc": "Mostrar tráfico restante y fecha después del nombre de configuración.",
"subEmailInRemark": "Incluir Email en el nombre",
"subEmailInRemarkDesc": "Incluir el correo del cliente en el nombre del perfil de suscripción.",
"subURI": "URI de proxy inverso", "subURI": "URI de proxy inverso",
"externalTrafficInformEnable": "Informe de tráfico externo", "externalTrafficInformEnable": "Informe de tráfico externo",
"externalTrafficInformEnableDesc": "Informar a la API externa sobre cada actualización de tráfico.", "externalTrafficInformEnableDesc": "Informar a la API externa sobre cada actualización de tráfico.",
@ -588,7 +591,15 @@
"twoFactorModalChangeCredentialsStep": "Ingrese el código de la aplicación para cambiar las credenciales del administrador.", "twoFactorModalChangeCredentialsStep": "Ingrese el código de la aplicación para cambiar las credenciales del administrador.",
"twoFactorModalSetSuccess": "La autenticación de dos factores se ha establecido con éxito", "twoFactorModalSetSuccess": "La autenticación de dos factores se ha establecido con éxito",
"twoFactorModalDeleteSuccess": "La autenticación de dos factores se ha eliminado con éxito", "twoFactorModalDeleteSuccess": "La autenticación de dos factores se ha eliminado con éxito",
"twoFactorModalError": "Código incorrecto" "twoFactorModalError": "Código incorrecto",
"show": "Mostrar",
"hide": "Ocultar",
"apiTokenNew": "Nuevo token",
"apiTokenName": "Nombre",
"apiTokenNamePlaceholder": "por ejemplo central-panel-a",
"apiTokenNameRequired": "El nombre es obligatorio",
"apiTokenEmpty": "Aún no hay tokens — crea uno para autenticar bots o paneles remotos.",
"apiTokenDeleteWarning": "Cualquier cliente que use este token dejará de autenticarse inmediatamente."
}, },
"toasts": { "toasts": {
"modifySettings": "Los parámetros han sido modificados.", "modifySettings": "Los parámetros han sido modificados.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "تمام ترافیک کلاینت بازنشانی شد", "resetAllClientTrafficSuccess": "تمام ترافیک کلاینت بازنشانی شد",
"resetAllTrafficSuccess": "تمام ترافیک‌ها بازنشانی شدند", "resetAllTrafficSuccess": "تمام ترافیک‌ها بازنشانی شدند",
"resetInboundClientTrafficSuccess": "ترافیک بازنشانی شد", "resetInboundClientTrafficSuccess": "ترافیک بازنشانی شد",
"resetInboundTrafficSuccess": "ترافیک ورودی بازنشانی شد",
"trafficGetError": "خطا در دریافت ترافیک‌ها", "trafficGetError": "خطا در دریافت ترافیک‌ها",
"getNewX25519CertError": "خطا در دریافت گواهی X25519.", "getNewX25519CertError": "خطا در دریافت گواهی X25519.",
"getNewmldsa65Error": "خطا در دریافت گواهی mldsa65.", "getNewmldsa65Error": "خطا در دریافت گواهی mldsa65.",
@ -546,6 +547,8 @@
"subEncryptDesc": "کدگذاری خواهدشد Base64 محتوای برگشتی سرویس سابسکریپشن برپایه", "subEncryptDesc": "کدگذاری خواهدشد Base64 محتوای برگشتی سرویس سابسکریپشن برپایه",
"subShowInfo": "نمایش اطلاعات مصرف", "subShowInfo": "نمایش اطلاعات مصرف",
"subShowInfoDesc": "ترافیک و زمان باقی‌مانده را در برنامه‌های کاربری نمایش می‌دهد", "subShowInfoDesc": "ترافیک و زمان باقی‌مانده را در برنامه‌های کاربری نمایش می‌دهد",
"subEmailInRemark": "گنجاندن ایمیل در نام",
"subEmailInRemarkDesc": "ایمیل کاربر در نام پروفایل اشتراک گنجانده می‌شود.",
"subURI": "پروکسی معکوس URI مسیر", "subURI": "پروکسی معکوس URI مسیر",
"subURIDesc": "سابسکریپشن را برای استفاده در پشت پراکسی‌ها تغییر می‌دهد URI مسیر", "subURIDesc": "سابسکریپشن را برای استفاده در پشت پراکسی‌ها تغییر می‌دهد URI مسیر",
"externalTrafficInformEnable": "اطلاع رسانی خارجی مصرف ترافیک", "externalTrafficInformEnable": "اطلاع رسانی خارجی مصرف ترافیک",
@ -588,7 +591,15 @@
"twoFactorModalChangeCredentialsStep": "برای تغییر اعتبارنامه‌های مدیر، کد را از برنامه وارد کنید.", "twoFactorModalChangeCredentialsStep": "برای تغییر اعتبارنامه‌های مدیر، کد را از برنامه وارد کنید.",
"twoFactorModalSetSuccess": "احراز هویت دو مرحله‌ای با موفقیت برقرار شد", "twoFactorModalSetSuccess": "احراز هویت دو مرحله‌ای با موفقیت برقرار شد",
"twoFactorModalDeleteSuccess": "احراز هویت دو مرحله‌ای با موفقیت حذف شد", "twoFactorModalDeleteSuccess": "احراز هویت دو مرحله‌ای با موفقیت حذف شد",
"twoFactorModalError": "کد نادرست" "twoFactorModalError": "کد نادرست",
"show": "نمایش",
"hide": "پنهان",
"apiTokenNew": "توکن جدید",
"apiTokenName": "نام",
"apiTokenNamePlaceholder": "مثلاً central-panel-a",
"apiTokenNameRequired": "نام الزامی است",
"apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت ربات‌ها یا پنل‌های راه دور یکی بسازید.",
"apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده می‌کند بلافاصله احراز هویتش قطع می‌شود."
}, },
"toasts": { "toasts": {
"modifySettings": "پارامترها تغییر کرده‌اند.", "modifySettings": "پارامترها تغییر کرده‌اند.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "Semua lalu lintas klien telah direset", "resetAllClientTrafficSuccess": "Semua lalu lintas klien telah direset",
"resetAllTrafficSuccess": "Semua lalu lintas telah direset", "resetAllTrafficSuccess": "Semua lalu lintas telah direset",
"resetInboundClientTrafficSuccess": "Lalu lintas telah direset", "resetInboundClientTrafficSuccess": "Lalu lintas telah direset",
"resetInboundTrafficSuccess": "Lalu lintas masuk telah direset",
"trafficGetError": "Gagal mendapatkan data lalu lintas", "trafficGetError": "Gagal mendapatkan data lalu lintas",
"getNewX25519CertError": "Terjadi kesalahan saat mendapatkan sertifikat X25519.", "getNewX25519CertError": "Terjadi kesalahan saat mendapatkan sertifikat X25519.",
"getNewmldsa65Error": "Terjadi kesalahan saat mendapatkan sertifikat mldsa65.", "getNewmldsa65Error": "Terjadi kesalahan saat mendapatkan sertifikat mldsa65.",
@ -546,6 +547,8 @@
"subEncryptDesc": "Konten yang dikembalikan dari layanan langganan akan dienkripsi Base64.", "subEncryptDesc": "Konten yang dikembalikan dari layanan langganan akan dienkripsi Base64.",
"subShowInfo": "Tampilkan Info Penggunaan", "subShowInfo": "Tampilkan Info Penggunaan",
"subShowInfoDesc": "Sisa traffic dan tanggal akan ditampilkan di aplikasi klien.", "subShowInfoDesc": "Sisa traffic dan tanggal akan ditampilkan di aplikasi klien.",
"subEmailInRemark": "Sertakan Email dalam Nama",
"subEmailInRemarkDesc": "Sertakan email klien dalam nama profil langganan.",
"subURI": "URI Proxy Terbalik", "subURI": "URI Proxy Terbalik",
"subURIDesc": "Path URI dari URL langganan untuk digunakan di belakang proxy.", "subURIDesc": "Path URI dari URL langganan untuk digunakan di belakang proxy.",
"externalTrafficInformEnable": "Informasikan API eksternal pada setiap pembaruan lalu lintas.", "externalTrafficInformEnable": "Informasikan API eksternal pada setiap pembaruan lalu lintas.",
@ -588,7 +591,15 @@
"twoFactorModalChangeCredentialsStep": "Masukkan kode dari aplikasi untuk mengubah kredensial administrator.", "twoFactorModalChangeCredentialsStep": "Masukkan kode dari aplikasi untuk mengubah kredensial administrator.",
"twoFactorModalSetSuccess": "Autentikasi dua faktor telah berhasil dibuat", "twoFactorModalSetSuccess": "Autentikasi dua faktor telah berhasil dibuat",
"twoFactorModalDeleteSuccess": "Autentikasi dua faktor telah berhasil dihapus", "twoFactorModalDeleteSuccess": "Autentikasi dua faktor telah berhasil dihapus",
"twoFactorModalError": "Kode salah" "twoFactorModalError": "Kode salah",
"show": "Tampilkan",
"hide": "Sembunyikan",
"apiTokenNew": "Token baru",
"apiTokenName": "Nama",
"apiTokenNamePlaceholder": "misalnya central-panel-a",
"apiTokenNameRequired": "Nama wajib diisi",
"apiTokenEmpty": "Belum ada token — buat satu untuk mengautentikasi bot atau panel jarak jauh.",
"apiTokenDeleteWarning": "Setiap pemanggil yang menggunakan token ini akan berhenti terautentikasi segera."
}, },
"toasts": { "toasts": {
"modifySettings": "Parameter telah diubah.", "modifySettings": "Parameter telah diubah.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "クライアントのすべてのトラフィックがリセットされました", "resetAllClientTrafficSuccess": "クライアントのすべてのトラフィックがリセットされました",
"resetAllTrafficSuccess": "すべてのトラフィックがリセットされました", "resetAllTrafficSuccess": "すべてのトラフィックがリセットされました",
"resetInboundClientTrafficSuccess": "トラフィックがリセットされました", "resetInboundClientTrafficSuccess": "トラフィックがリセットされました",
"resetInboundTrafficSuccess": "受信トラフィックがリセットされました",
"trafficGetError": "トラフィックの取得中にエラーが発生しました", "trafficGetError": "トラフィックの取得中にエラーが発生しました",
"getNewX25519CertError": "X25519証明書の取得中にエラーが発生しました。", "getNewX25519CertError": "X25519証明書の取得中にエラーが発生しました。",
"getNewmldsa65Error": "mldsa65証明書の取得中にエラーが発生しました。", "getNewmldsa65Error": "mldsa65証明書の取得中にエラーが発生しました。",
@ -546,6 +547,8 @@
"subEncryptDesc": "サブスクリプションサービスが返す内容をBase64エンコードする", "subEncryptDesc": "サブスクリプションサービスが返す内容をBase64エンコードする",
"subShowInfo": "利用情報を表示", "subShowInfo": "利用情報を表示",
"subShowInfoDesc": "クライアントアプリで残りのトラフィックと日付情報を表示する", "subShowInfoDesc": "クライアントアプリで残りのトラフィックと日付情報を表示する",
"subEmailInRemark": "名前にメールを含める",
"subEmailInRemarkDesc": "サブスクリプションプロファイル名にクライアントのメールアドレスを含めます。",
"subURI": "リバースプロキシURI", "subURI": "リバースプロキシURI",
"subURIDesc": "プロキシ後ろのサブスクリプションURLのURIパスに使用する", "subURIDesc": "プロキシ後ろのサブスクリプションURLのURIパスに使用する",
"externalTrafficInformEnable": "外部トラフィック情報", "externalTrafficInformEnable": "外部トラフィック情報",
@ -588,7 +591,15 @@
"twoFactorModalChangeCredentialsStep": "管理者の認証情報を変更するには、アプリケーションからコードを入力してください。", "twoFactorModalChangeCredentialsStep": "管理者の認証情報を変更するには、アプリケーションからコードを入力してください。",
"twoFactorModalSetSuccess": "二要素認証が正常に設定されました", "twoFactorModalSetSuccess": "二要素認証が正常に設定されました",
"twoFactorModalDeleteSuccess": "二要素認証が正常に削除されました", "twoFactorModalDeleteSuccess": "二要素認証が正常に削除されました",
"twoFactorModalError": "コードが間違っています" "twoFactorModalError": "コードが間違っています",
"show": "表示",
"hide": "非表示",
"apiTokenNew": "新規トークン",
"apiTokenName": "名前",
"apiTokenNamePlaceholder": "例: central-panel-a",
"apiTokenNameRequired": "名前は必須です",
"apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
"apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。"
}, },
"toasts": { "toasts": {
"modifySettings": "パラメーターが変更されました。", "modifySettings": "パラメーターが変更されました。",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "Todo o tráfego do cliente foi reiniciado", "resetAllClientTrafficSuccess": "Todo o tráfego do cliente foi reiniciado",
"resetAllTrafficSuccess": "Todo o tráfego foi reiniciado", "resetAllTrafficSuccess": "Todo o tráfego foi reiniciado",
"resetInboundClientTrafficSuccess": "O tráfego foi reiniciado", "resetInboundClientTrafficSuccess": "O tráfego foi reiniciado",
"resetInboundTrafficSuccess": "O tráfego de entrada foi reiniciado",
"trafficGetError": "Erro ao obter tráfegos", "trafficGetError": "Erro ao obter tráfegos",
"getNewX25519CertError": "Erro ao obter o certificado X25519.", "getNewX25519CertError": "Erro ao obter o certificado X25519.",
"getNewmldsa65Error": "Erro ao obter o certificado mldsa65.", "getNewmldsa65Error": "Erro ao obter o certificado mldsa65.",
@ -546,6 +547,8 @@
"subEncryptDesc": "O conteúdo retornado pelo serviço de assinatura será codificado em Base64.", "subEncryptDesc": "O conteúdo retornado pelo serviço de assinatura será codificado em Base64.",
"subShowInfo": "Mostrar Informações de Uso", "subShowInfo": "Mostrar Informações de Uso",
"subShowInfoDesc": "O tráfego restante e a data serão exibidos nos aplicativos de cliente.", "subShowInfoDesc": "O tráfego restante e a data serão exibidos nos aplicativos de cliente.",
"subEmailInRemark": "Incluir Email no nome",
"subEmailInRemarkDesc": "Incluir o email do cliente no nome do perfil de assinatura.",
"subURI": "URI de Proxy Reverso", "subURI": "URI de Proxy Reverso",
"subURIDesc": "O caminho URI da URL de assinatura para uso por trás de proxies.", "subURIDesc": "O caminho URI da URL de assinatura para uso por trás de proxies.",
"externalTrafficInformEnable": "Informações de tráfego externo", "externalTrafficInformEnable": "Informações de tráfego externo",
@ -588,7 +591,15 @@
"twoFactorModalChangeCredentialsStep": "Insira o código do aplicativo para alterar as credenciais do administrador.", "twoFactorModalChangeCredentialsStep": "Insira o código do aplicativo para alterar as credenciais do administrador.",
"twoFactorModalSetSuccess": "A autenticação de dois fatores foi estabelecida com sucesso", "twoFactorModalSetSuccess": "A autenticação de dois fatores foi estabelecida com sucesso",
"twoFactorModalDeleteSuccess": "A autenticação de dois fatores foi excluída com sucesso", "twoFactorModalDeleteSuccess": "A autenticação de dois fatores foi excluída com sucesso",
"twoFactorModalError": "Código incorreto" "twoFactorModalError": "Código incorreto",
"show": "Mostrar",
"hide": "Ocultar",
"apiTokenNew": "Novo token",
"apiTokenName": "Nome",
"apiTokenNamePlaceholder": "ex.: central-panel-a",
"apiTokenNameRequired": "O nome é obrigatório",
"apiTokenEmpty": "Nenhum token ainda — crie um para autenticar bots ou painéis remotos.",
"apiTokenDeleteWarning": "Qualquer cliente usando este token deixará de se autenticar imediatamente."
}, },
"toasts": { "toasts": {
"modifySettings": "Os parâmetros foram alterados.", "modifySettings": "Os parâmetros foram alterados.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "Весь трафик клиента сброшен", "resetAllClientTrafficSuccess": "Весь трафик клиента сброшен",
"resetAllTrafficSuccess": "Весь трафик сброшен", "resetAllTrafficSuccess": "Весь трафик сброшен",
"resetInboundClientTrafficSuccess": "Трафик сброшен", "resetInboundClientTrafficSuccess": "Трафик сброшен",
"resetInboundTrafficSuccess": "Входящий трафик сброшен",
"trafficGetError": "Ошибка получения данных о трафике", "trafficGetError": "Ошибка получения данных о трафике",
"getNewX25519CertError": "Ошибка при получении сертификата X25519.", "getNewX25519CertError": "Ошибка при получении сертификата X25519.",
"getNewmldsa65Error": "Ошибка при получении сертификата mldsa65.", "getNewmldsa65Error": "Ошибка при получении сертификата mldsa65.",
@ -546,6 +547,8 @@
"subEncryptDesc": "Шифровать возвращенные конфиги в подписке", "subEncryptDesc": "Шифровать возвращенные конфиги в подписке",
"subShowInfo": "Показать информацию об использовании", "subShowInfo": "Показать информацию об использовании",
"subShowInfoDesc": "Отображать остаток трафика и дату окончания после имени конфигурации", "subShowInfoDesc": "Отображать остаток трафика и дату окончания после имени конфигурации",
"subEmailInRemark": "Включать Email в название",
"subEmailInRemarkDesc": "Включать email клиента в название профиля подписки.",
"subURI": "URI обратного прокси", "subURI": "URI обратного прокси",
"subURIDesc": "Изменить базовый URI URL-адреса подписки для использования за прокси-серверами", "subURIDesc": "Изменить базовый URI URL-адреса подписки для использования за прокси-серверами",
"externalTrafficInformEnable": "Информация о внешнем трафике", "externalTrafficInformEnable": "Информация о внешнем трафике",
@ -588,7 +591,15 @@
"twoFactorModalChangeCredentialsStep": "Введите код из приложения, чтобы изменить учетные данные администратора.", "twoFactorModalChangeCredentialsStep": "Введите код из приложения, чтобы изменить учетные данные администратора.",
"twoFactorModalSetSuccess": "Двухфакторная аутентификация была успешно установлена", "twoFactorModalSetSuccess": "Двухфакторная аутентификация была успешно установлена",
"twoFactorModalDeleteSuccess": "Двухфакторная аутентификация была успешно удалена", "twoFactorModalDeleteSuccess": "Двухфакторная аутентификация была успешно удалена",
"twoFactorModalError": "Неверный код" "twoFactorModalError": "Неверный код",
"show": "Показать",
"hide": "Скрыть",
"apiTokenNew": "Новый токен",
"apiTokenName": "Имя",
"apiTokenNamePlaceholder": "например, central-panel-a",
"apiTokenNameRequired": "Имя обязательно",
"apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
"apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию."
}, },
"toasts": { "toasts": {
"modifySettings": "Настройки изменены", "modifySettings": "Настройки изменены",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "İstemcinin tüm trafiği sıfırlandı", "resetAllClientTrafficSuccess": "İstemcinin tüm trafiği sıfırlandı",
"resetAllTrafficSuccess": "Tüm trafik sıfırlandı", "resetAllTrafficSuccess": "Tüm trafik sıfırlandı",
"resetInboundClientTrafficSuccess": "Trafik sıfırlandı", "resetInboundClientTrafficSuccess": "Trafik sıfırlandı",
"resetInboundTrafficSuccess": "Gelen trafik sıfırlandı",
"trafficGetError": "Trafik bilgisi alınırken hata oluştu", "trafficGetError": "Trafik bilgisi alınırken hata oluştu",
"getNewX25519CertError": "X25519 sertifikası alınırken hata oluştu.", "getNewX25519CertError": "X25519 sertifikası alınırken hata oluştu.",
"getNewmldsa65Error": "mldsa65 sertifikası alınırken hata oluştu.", "getNewmldsa65Error": "mldsa65 sertifikası alınırken hata oluştu.",
@ -546,6 +547,8 @@
"subEncryptDesc": "Abonelik hizmetinin döndürülen içeriği Base64 ile şifrelenir.", "subEncryptDesc": "Abonelik hizmetinin döndürülen içeriği Base64 ile şifrelenir.",
"subShowInfo": "Kullanım Bilgisini Göster", "subShowInfo": "Kullanım Bilgisini Göster",
"subShowInfoDesc": "Kalan trafik ve tarih müşteri uygulamalarında görüntülenir.", "subShowInfoDesc": "Kalan trafik ve tarih müşteri uygulamalarında görüntülenir.",
"subEmailInRemark": "Ada Email Ekle",
"subEmailInRemarkDesc": "Abonelik profil adına istemcinin e-postasını dahil edin.",
"subURI": "Ters Proxy URI", "subURI": "Ters Proxy URI",
"subURIDesc": "Proxy arkasında kullanılacak abonelik URL'sinin URI yolu.", "subURIDesc": "Proxy arkasında kullanılacak abonelik URL'sinin URI yolu.",
"externalTrafficInformEnable": "Harici Trafik Bilgisi", "externalTrafficInformEnable": "Harici Trafik Bilgisi",
@ -588,7 +591,15 @@
"twoFactorModalChangeCredentialsStep": "Yönetici kimlik bilgilerini değiştirmek için uygulamadaki kodu girin.", "twoFactorModalChangeCredentialsStep": "Yönetici kimlik bilgilerini değiştirmek için uygulamadaki kodu girin.",
"twoFactorModalSetSuccess": "İki faktörlü kimlik doğrulama başarıyla kuruldu", "twoFactorModalSetSuccess": "İki faktörlü kimlik doğrulama başarıyla kuruldu",
"twoFactorModalDeleteSuccess": "İki faktörlü kimlik doğrulama başarıyla silindi", "twoFactorModalDeleteSuccess": "İki faktörlü kimlik doğrulama başarıyla silindi",
"twoFactorModalError": "Yanlış kod" "twoFactorModalError": "Yanlış kod",
"show": "Göster",
"hide": "Gizle",
"apiTokenNew": "Yeni token",
"apiTokenName": "Ad",
"apiTokenNamePlaceholder": "örn. central-panel-a",
"apiTokenNameRequired": "Ad zorunludur",
"apiTokenEmpty": "Henüz token yok — bot veya uzak panelleri doğrulamak için bir tane oluşturun.",
"apiTokenDeleteWarning": "Bu tokenı kullanan tüm istemciler anında kimlik doğrulamasını kaybeder."
}, },
"toasts": { "toasts": {
"modifySettings": "Parametreler değiştirildi.", "modifySettings": "Parametreler değiştirildi.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "Весь трафік клієнта скинуто", "resetAllClientTrafficSuccess": "Весь трафік клієнта скинуто",
"resetAllTrafficSuccess": "Весь трафік скинуто", "resetAllTrafficSuccess": "Весь трафік скинуто",
"resetInboundClientTrafficSuccess": "Трафік скинуто", "resetInboundClientTrafficSuccess": "Трафік скинуто",
"resetInboundTrafficSuccess": "Трафік вхідного потоку скинуто",
"trafficGetError": "Помилка отримання даних про трафік", "trafficGetError": "Помилка отримання даних про трафік",
"getNewX25519CertError": "Помилка при отриманні сертифіката X25519.", "getNewX25519CertError": "Помилка при отриманні сертифіката X25519.",
"getNewmldsa65Error": "Помилка при отриманні сертифіката mldsa65.", "getNewmldsa65Error": "Помилка при отриманні сертифіката mldsa65.",
@ -546,6 +547,8 @@
"subEncryptDesc": "Повернений вміст послуги підписки матиме кодування Base64.", "subEncryptDesc": "Повернений вміст послуги підписки матиме кодування Base64.",
"subShowInfo": "Показати інформацію про використання", "subShowInfo": "Показати інформацію про використання",
"subShowInfoDesc": "Залишок трафіку та дата відображатимуться в клієнтських програмах.", "subShowInfoDesc": "Залишок трафіку та дата відображатимуться в клієнтських програмах.",
"subEmailInRemark": "Включати Email до назви",
"subEmailInRemarkDesc": "Включати email клієнта до назви профілю підписки.",
"subURI": "URI зворотного проксі", "subURI": "URI зворотного проксі",
"subURIDesc": "URI до URL-адреси підписки для використання за проксі.", "subURIDesc": "URI до URL-адреси підписки для використання за проксі.",
"externalTrafficInformEnable": "Інформація про зовнішній трафік", "externalTrafficInformEnable": "Інформація про зовнішній трафік",
@ -588,7 +591,15 @@
"twoFactorModalChangeCredentialsStep": "Введіть код з додатку, щоб змінити облікові дані адміністратора.", "twoFactorModalChangeCredentialsStep": "Введіть код з додатку, щоб змінити облікові дані адміністратора.",
"twoFactorModalSetSuccess": "Двофакторна аутентифікація була успішно встановлена", "twoFactorModalSetSuccess": "Двофакторна аутентифікація була успішно встановлена",
"twoFactorModalDeleteSuccess": "Двофакторна аутентифікація була успішно видалена", "twoFactorModalDeleteSuccess": "Двофакторна аутентифікація була успішно видалена",
"twoFactorModalError": "Невірний код" "twoFactorModalError": "Невірний код",
"show": "Показати",
"hide": "Сховати",
"apiTokenNew": "Новий токен",
"apiTokenName": "Назва",
"apiTokenNamePlaceholder": "наприклад, central-panel-a",
"apiTokenNameRequired": "Назва обов'язкова",
"apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
"apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію."
}, },
"toasts": { "toasts": {
"modifySettings": "Параметри було змінено.", "modifySettings": "Параметри було змінено.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng client", "resetAllClientTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng client",
"resetAllTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng", "resetAllTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng",
"resetInboundClientTrafficSuccess": "Đã đặt lại lưu lượng", "resetInboundClientTrafficSuccess": "Đã đặt lại lưu lượng",
"resetInboundTrafficSuccess": "Đã đặt lại lưu lượng Inbound",
"trafficGetError": "Lỗi khi lấy thông tin lưu lượng", "trafficGetError": "Lỗi khi lấy thông tin lưu lượng",
"getNewX25519CertError": "Lỗi khi lấy chứng chỉ X25519.", "getNewX25519CertError": "Lỗi khi lấy chứng chỉ X25519.",
"getNewmldsa65Error": "Lỗi khi lấy chứng chỉ mldsa65.", "getNewmldsa65Error": "Lỗi khi lấy chứng chỉ mldsa65.",
@ -546,6 +547,8 @@
"subEncryptDesc": "Mã hóa các cấu hình được trả về trong gói đăng ký", "subEncryptDesc": "Mã hóa các cấu hình được trả về trong gói đăng ký",
"subShowInfo": "Hiển thị thông tin sử dụng", "subShowInfo": "Hiển thị thông tin sử dụng",
"subShowInfoDesc": "Hiển thị lưu lượng truy cập còn lại và ngày sau tên cấu hình", "subShowInfoDesc": "Hiển thị lưu lượng truy cập còn lại và ngày sau tên cấu hình",
"subEmailInRemark": "Thêm Email vào tên",
"subEmailInRemarkDesc": "Thêm email của client vào tên hồ sơ đăng ký.",
"subURI": "URI proxy trung gian", "subURI": "URI proxy trung gian",
"subURIDesc": "Thay đổi URI cơ sở của URL gói đăng ký để sử dụng cho proxy trung gian", "subURIDesc": "Thay đổi URI cơ sở của URL gói đăng ký để sử dụng cho proxy trung gian",
"externalTrafficInformEnable": "Thông báo giao thông bên ngoài", "externalTrafficInformEnable": "Thông báo giao thông bên ngoài",
@ -588,7 +591,15 @@
"twoFactorModalChangeCredentialsStep": "Nhập mã từ ứng dụng để thay đổi thông tin xác thực quản trị viên.", "twoFactorModalChangeCredentialsStep": "Nhập mã từ ứng dụng để thay đổi thông tin xác thực quản trị viên.",
"twoFactorModalSetSuccess": "Xác thực hai yếu tố đã được thiết lập thành công", "twoFactorModalSetSuccess": "Xác thực hai yếu tố đã được thiết lập thành công",
"twoFactorModalDeleteSuccess": "Xác thực hai yếu tố đã được xóa thành công", "twoFactorModalDeleteSuccess": "Xác thực hai yếu tố đã được xóa thành công",
"twoFactorModalError": "Mã sai" "twoFactorModalError": "Mã sai",
"show": "Hiển thị",
"hide": "Ẩn",
"apiTokenNew": "Token mới",
"apiTokenName": "Tên",
"apiTokenNamePlaceholder": "ví dụ: central-panel-a",
"apiTokenNameRequired": "Tên là bắt buộc",
"apiTokenEmpty": "Chưa có token nào — tạo một token để xác thực bot hoặc panel từ xa.",
"apiTokenDeleteWarning": "Mọi client đang dùng token này sẽ ngừng xác thực ngay lập tức."
}, },
"toasts": { "toasts": {
"modifySettings": "Các tham số đã được thay đổi.", "modifySettings": "Các tham số đã được thay đổi.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "客户端所有流量已重置", "resetAllClientTrafficSuccess": "客户端所有流量已重置",
"resetAllTrafficSuccess": "所有流量已重置", "resetAllTrafficSuccess": "所有流量已重置",
"resetInboundClientTrafficSuccess": "流量已重置", "resetInboundClientTrafficSuccess": "流量已重置",
"resetInboundTrafficSuccess": "入站流量已重置",
"trafficGetError": "获取流量数据时出错", "trafficGetError": "获取流量数据时出错",
"getNewX25519CertError": "获取X25519证书时出错。", "getNewX25519CertError": "获取X25519证书时出错。",
"getNewmldsa65Error": "获取mldsa65证书时出错。", "getNewmldsa65Error": "获取mldsa65证书时出错。",
@ -546,6 +547,8 @@
"subEncryptDesc": "订阅服务返回的内容将采用 Base64 编码", "subEncryptDesc": "订阅服务返回的内容将采用 Base64 编码",
"subShowInfo": "显示使用信息", "subShowInfo": "显示使用信息",
"subShowInfoDesc": "客户端应用中将显示剩余流量和日期信息", "subShowInfoDesc": "客户端应用中将显示剩余流量和日期信息",
"subEmailInRemark": "在名称中包含邮箱",
"subEmailInRemarkDesc": "在订阅配置名称中包含客户端邮箱。",
"subURI": "反向代理 URI", "subURI": "反向代理 URI",
"subURIDesc": "用于代理后面的订阅 URL 的 URI 路径", "subURIDesc": "用于代理后面的订阅 URL 的 URI 路径",
"externalTrafficInformEnable": "外部交通通知", "externalTrafficInformEnable": "外部交通通知",
@ -588,7 +591,15 @@
"twoFactorModalChangeCredentialsStep": "输入应用程序中的代码以更改管理员凭据。", "twoFactorModalChangeCredentialsStep": "输入应用程序中的代码以更改管理员凭据。",
"twoFactorModalSetSuccess": "双因素认证已成功建立", "twoFactorModalSetSuccess": "双因素认证已成功建立",
"twoFactorModalDeleteSuccess": "双因素认证已成功删除", "twoFactorModalDeleteSuccess": "双因素认证已成功删除",
"twoFactorModalError": "验证码错误" "twoFactorModalError": "验证码错误",
"show": "显示",
"hide": "隐藏",
"apiTokenNew": "新建令牌",
"apiTokenName": "名称",
"apiTokenNamePlaceholder": "例如 central-panel-a",
"apiTokenNameRequired": "名称必填",
"apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。",
"apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。"
}, },
"toasts": { "toasts": {
"modifySettings": "参数已更改。", "modifySettings": "参数已更改。",

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