mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
Merge branch 'main' into feat/api-token-install
This commit is contained in:
commit
68e8277f92
104 changed files with 3888 additions and 685 deletions
8
.github/dependabot.yml
vendored
8
.github/dependabot.yml
vendored
|
|
@ -9,3 +9,11 @@ updates:
|
|||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
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
91
.github/workflows/ci.yml
vendored
Normal 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
|
||||
25
.github/workflows/codeql.yml
vendored
25
.github/workflows/codeql.yml
vendored
|
|
@ -2,9 +2,31 @@ name: "CodeQL Advanced"
|
|||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags-ignore:
|
||||
- "v*"
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "**.vue"
|
||||
- "frontend/package-lock.json"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "**.vue"
|
||||
- "frontend/package-lock.json"
|
||||
schedule:
|
||||
- cron: "18 2 * * 2"
|
||||
|
||||
|
|
@ -35,9 +57,6 @@ jobs:
|
|||
- name: Checkout repository
|
||||
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
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-node@v6
|
||||
|
|
|
|||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
|
|
@ -19,6 +19,17 @@ on:
|
|||
- "x-ui.service.arch"
|
||||
- "x-ui.service.rhel"
|
||||
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:
|
||||
build:
|
||||
|
|
|
|||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
22
|
||||
|
|
@ -22,7 +22,7 @@ EOF
|
|||
|
||||
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
|
||||
[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+
|
||||
ignoreregex =
|
||||
EOF
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ func initModels() error {
|
|||
&model.HistoryOfSeeders{},
|
||||
&model.CustomGeoResource{},
|
||||
&model.Node{},
|
||||
&model.ApiToken{},
|
||||
}
|
||||
for _, model := range models {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
|
|
@ -86,43 +87,80 @@ func runSeeders(isUsersEmpty bool) error {
|
|||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
return db.Create(hashSeeder).Error
|
||||
} else {
|
||||
var seedersHistory []string
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
||||
log.Printf("Error fetching seeder history: %v", err)
|
||||
if err := db.Create(hashSeeder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return seedApiTokens()
|
||||
}
|
||||
|
||||
var seedersHistory []string
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
||||
log.Printf("Error fetching seeder history: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
||||
var users []model.User
|
||||
if err := db.Find(&users).Error; err != nil {
|
||||
log.Printf("Error fetching users for password migration: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
||||
var users []model.User
|
||||
if err := db.Find(&users).Error; err != nil {
|
||||
log.Printf("Error fetching users for password migration: %v", err)
|
||||
for _, user := range users {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||
log.Printf("Error updating password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||
log.Printf("Error updating password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
return db.Create(hashSeeder).Error
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
if err := db.Create(hashSeeder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "ApiTokensTable") {
|
||||
if err := seedApiTokens(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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.
|
||||
func isTableEmpty(tableName string) (bool, error) {
|
||||
var count int64
|
||||
|
|
|
|||
|
|
@ -21,12 +21,8 @@ const (
|
|||
Shadowsocks Protocol = "shadowsocks"
|
||||
Mixed Protocol = "mixed"
|
||||
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"
|
||||
Hysteria2 Protocol = "hysteria2"
|
||||
Hysteria Protocol = "hysteria"
|
||||
Hysteria2 Protocol = "hysteria2"
|
||||
)
|
||||
|
||||
// IsHysteria returns true for both "hysteria" and "hysteria2".
|
||||
|
|
@ -38,9 +34,10 @@ func IsHysteria(p Protocol) bool {
|
|||
|
||||
// User represents a user account in the 3x-ui panel.
|
||||
type User struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
LoginEpoch int64 `json:"-" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
||||
|
|
@ -66,12 +63,7 @@ type Inbound struct {
|
|||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
||||
|
|
@ -96,6 +88,14 @@ type HistoryOfSeeders struct {
|
|||
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.
|
||||
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||
listen := i.Listen
|
||||
|
|
@ -128,15 +128,16 @@ type Setting struct {
|
|||
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
||||
// status fields below.
|
||||
type Node struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
ApiToken string `json:"apiToken" form:"apiToken"`
|
||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
ApiToken string `json:"apiToken" form:"apiToken"`
|
||||
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
|
||||
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · API Docs</title>
|
||||
<title>API Docs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@ export default [
|
|||
globals: {
|
||||
...globals.browser,
|
||||
...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: {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Inbounds</title>
|
||||
<title>Inbounds</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui</title>
|
||||
<title>Overview</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>3x-ui — Sign in</title>
|
||||
<title>Sign in</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Nodes</title>
|
||||
<title>Nodes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
|
|
|||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
|
|
@ -7,6 +7,10 @@
|
|||
"": {
|
||||
"name": "3x-ui-frontend",
|
||||
"version": "0.0.2",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
"version": "0.0.2",
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Settings</title>
|
||||
<title>Settings</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
|
|
|||
|
|
@ -2,24 +2,16 @@ import axios from 'axios';
|
|||
import qs from 'qs';
|
||||
|
||||
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';
|
||||
|
||||
// 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 csrfFetchPromise = null;
|
||||
let sessionExpired = false;
|
||||
|
||||
function readMetaToken() {
|
||||
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() {
|
||||
try {
|
||||
const basePath = window.X_UI_BASE_PATH;
|
||||
|
|
@ -91,19 +83,12 @@ export function setupAxios() {
|
|||
async (error) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
// 401 → session is gone. In production, the panel routes
|
||||
// are gated by Go's checkLogin which redirects to base_path
|
||||
// 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) {
|
||||
if (!sessionExpired) {
|
||||
sessionExpired = true;
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.href = `${basePath}login.html`;
|
||||
} else {
|
||||
window.location.reload();
|
||||
window.location.replace(basePath);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
return new Promise(() => { });
|
||||
}
|
||||
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
|
||||
const cfg = error.config;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -45,7 +46,7 @@ const tabs = computed(() => [
|
|||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||
{ 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'));
|
||||
|
|
@ -55,7 +56,12 @@ const drawerOpen = ref(false);
|
|||
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
|
||||
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')) {
|
||||
window.open(key);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const props = defineProps({
|
|||
showAxes: { type: Boolean, default: false },
|
||||
yTickStep: { type: Number, default: 25 },
|
||||
tickCountX: { type: Number, default: 4 },
|
||||
paddingLeft: { type: Number, default: 32 },
|
||||
paddingLeft: { type: Number, default: 56 },
|
||||
paddingRight: { type: Number, default: 6 },
|
||||
paddingTop: { type: Number, default: 6 },
|
||||
paddingBottom: { type: Number, default: 20 },
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export function useNodeList() {
|
|||
return n != null && n.enable && n.status === 'online';
|
||||
}
|
||||
|
||||
const hasActive = computed(() => nodes.value.some((n) => n.enable));
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
return { nodes, fetched, refresh, byId, nameFor, isOnline };
|
||||
return { nodes, fetched, refresh, byId, nameFor, isOnline, hasActive };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
|
|||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
|
|||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
|
|||
// stored theme to <body>/<html> before Vue mounts.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import IndexPage from '@/pages/index/IndexPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
|
|||
// stored theme to <body>/<html> before Vue renders anything.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import LoginPage from '@/pages/login/LoginPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
// Toasts attach to a #message div the page provides — keeps theme
|
||||
// styling in sync with the rest of the panel.
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
|
|||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import NodesPage from '@/pages/nodes/NodesPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js';
|
|||
// stored theme to <body>/<html> before Vue mounts.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import SettingsPage from '@/pages/settings/SettingsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css';
|
|||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import XrayPage from '@/pages/xray/XrayPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ export class DBInbound {
|
|||
return this.protocol === Protocols.WIREGUARD;
|
||||
}
|
||||
|
||||
get isHysteria() {
|
||||
return this.protocol === Protocols.HYSTERIA;
|
||||
}
|
||||
|
||||
get address() {
|
||||
let address = location.hostname;
|
||||
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import dayjs from 'dayjs';
|
||||
import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils';
|
||||
import { getRandomRealityTarget } from '@/models/reality-targets';
|
||||
|
||||
export const Protocols = {
|
||||
VMESS: 'vmess',
|
||||
|
|
@ -687,8 +688,9 @@ export class HysteriaMasquerade extends XrayCommonClass {
|
|||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
const type = ['proxy', 'file', 'string'].includes(json.type) ? json.type : 'proxy';
|
||||
return new HysteriaMasquerade(
|
||||
json.type,
|
||||
type,
|
||||
json.dir,
|
||||
json.url,
|
||||
json.rewriteHost,
|
||||
|
|
@ -896,9 +898,7 @@ export class RealityStreamSettings extends XrayCommonClass {
|
|||
super();
|
||||
// If target/serverNames are not provided, use random values
|
||||
if (!target && !serverNames) {
|
||||
const randomTarget = typeof getRandomRealityTarget !== 'undefined'
|
||||
? getRandomRealityTarget()
|
||||
: { target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' };
|
||||
const randomTarget = getRandomRealityTarget();
|
||||
target = randomTarget.target;
|
||||
serverNames = randomTarget.sni;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export class AllSetting {
|
|||
this.webKeyFile = "";
|
||||
this.webBasePath = "/";
|
||||
this.sessionMaxAge = 360;
|
||||
this.trustedProxyCIDRs = "127.0.0.1/32,::1/128";
|
||||
this.pageSize = 25;
|
||||
this.expireDiff = 0;
|
||||
this.trafficDiff = 0;
|
||||
|
|
@ -56,6 +57,7 @@ export class AllSetting {
|
|||
this.subUpdates = 12;
|
||||
this.subEncrypt = true;
|
||||
this.subShowInfo = true;
|
||||
this.subEmailInRemark = true;
|
||||
this.subURI = "";
|
||||
this.subJsonURI = "";
|
||||
this.subClashURI = "";
|
||||
|
|
@ -87,6 +89,12 @@ export class AllSetting {
|
|||
this.ldapDefaultTotalGB = 0;
|
||||
this.ldapDefaultExpiryDays = 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) {
|
||||
return
|
||||
|
|
@ -97,4 +105,4 @@ export class AllSetting {
|
|||
equals(other) {
|
||||
return ObjectUtil.equals(this, other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +1,143 @@
|
|||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import {
|
||||
KeyOutlined,
|
||||
ReloadOutlined,
|
||||
CopyOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
SearchOutlined,
|
||||
ExpandOutlined,
|
||||
CompressOutlined,
|
||||
ApiOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
GlobalOutlined,
|
||||
SaveOutlined,
|
||||
SettingOutlined,
|
||||
WifiOutlined,
|
||||
LinkOutlined,
|
||||
NodeIndexOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import { HttpUtil, ClipboardManager } from '@/utils/index.js';
|
||||
import { sections } from './endpoints.js';
|
||||
import { sections as allSections } from './endpoints.js';
|
||||
import EndpointSection from './EndpointSection.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
import CodeBlock from './CodeBlock.vue';
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
const settingsHref = `${basePath}panel/settings#security`;
|
||||
|
||||
const apiToken = ref('');
|
||||
const tokenLoading = ref(false);
|
||||
const tokenRotating = ref(false);
|
||||
const tokenVisible = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const collapsedSections = ref(new Set());
|
||||
const activeSection = ref('');
|
||||
|
||||
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 \\
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \\
|
||||
-H "Accept: application/json" \\
|
||||
https://your-panel.example.com/panel/api/inbounds/list`;
|
||||
|
||||
async function loadApiToken() {
|
||||
tokenLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getApiToken');
|
||||
if (msg?.success) apiToken.value = msg.obj || '';
|
||||
} finally {
|
||||
tokenLoading.value = false;
|
||||
}
|
||||
const sections = computed(() => {
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
if (!q) return allSections;
|
||||
return allSections
|
||||
.map(s => {
|
||||
const matching = s.endpoints.filter(e =>
|
||||
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() {
|
||||
Modal.confirm({
|
||||
title: t('pages.nodes.regenerateConfirm'),
|
||||
okText: t('confirm'),
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
function toggleSection(id) {
|
||||
const s = new Set(collapsedSections.value);
|
||||
if (s.has(id)) s.delete(id); else s.add(id);
|
||||
collapsedSections.value = s;
|
||||
}
|
||||
|
||||
async function copyApiToken() {
|
||||
if (!apiToken.value) return;
|
||||
const ok = await ClipboardManager.copyText(apiToken.value);
|
||||
if (ok) message.success(t('success'));
|
||||
function expandAll() {
|
||||
collapsedSections.value = new Set();
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
collapsedSections.value = new Set(allSections.map(s => s.id));
|
||||
}
|
||||
|
||||
function scrollToSection(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(() => {
|
||||
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>
|
||||
|
||||
|
|
@ -93,60 +156,81 @@ onMounted(() => {
|
|||
cookie, or with the <code>Authorization: Bearer <token></code> header below. Every endpoint
|
||||
returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
|
||||
</p>
|
||||
|
||||
</header>
|
||||
|
||||
<a-card class="token-card" size="small">
|
||||
<div class="token-card-head">
|
||||
<div class="token-card-title">
|
||||
<KeyOutlined />
|
||||
<span>API Token</span>
|
||||
<span>API Tokens</span>
|
||||
</div>
|
||||
<a-space size="small" wrap>
|
||||
<a-button size="small" @click="tokenVisible = !tokenVisible">
|
||||
<template #icon>
|
||||
<EyeInvisibleOutlined v-if="tokenVisible" />
|
||||
<EyeOutlined v-else />
|
||||
</template>
|
||||
{{ tokenVisible ? 'Hide' : 'Show' }}
|
||||
</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>
|
||||
<a-button type="primary" size="small" :href="settingsHref">
|
||||
Manage tokens
|
||||
</a-button>
|
||||
</div>
|
||||
<a-spin :spinning="tokenLoading" size="small">
|
||||
<pre
|
||||
class="token-value">{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}</pre>
|
||||
</a-spin>
|
||||
<p class="token-hint">
|
||||
Send it on every request as <code>Authorization: Bearer <token></code>. Token-authenticated
|
||||
callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately —
|
||||
running bots will need the new value.
|
||||
Create, enable, or revoke named Bearer tokens in
|
||||
<a :href="settingsHref">Settings → Security</a>. Send each request as
|
||||
<code>Authorization: Bearer <token></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>
|
||||
</a-card>
|
||||
|
||||
<a-card class="curl-card" size="small" title="Quick example">
|
||||
<pre class="code-block">{{ curlExample }}</pre>
|
||||
<CodeBlock :code="curlExample" lang="text" />
|
||||
</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">
|
||||
<span class="toc-label">On this page:</span>
|
||||
<a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`"
|
||||
@click.prevent="scrollToSection(s.id)">
|
||||
{{ s.title }}
|
||||
</a>
|
||||
<div class="toc-links">
|
||||
<a
|
||||
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>
|
||||
</div>
|
||||
</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>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
|
@ -194,20 +278,25 @@ onMounted(() => {
|
|||
}
|
||||
|
||||
.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 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.docs-lead {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.6;
|
||||
line-height: 1.65;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +320,8 @@ onMounted(() => {
|
|||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.token-card-title {
|
||||
|
|
@ -242,18 +332,6 @@ onMounted(() => {
|
|||
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 {
|
||||
margin: 10px 0 0;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
|
|
@ -275,35 +353,110 @@ onMounted(() => {
|
|||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 14px;
|
||||
align-items: flex-start;
|
||||
gap: 8px 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toc-label {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding-top: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toc-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toc-link:hover {
|
||||
color: #4096ff;
|
||||
text-decoration: underline;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
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>
|
||||
|
||||
|
|
@ -312,28 +465,97 @@ body.dark .docs-title {
|
|||
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 .token-hint {
|
||||
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 .token-hint code {
|
||||
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 {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
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 {
|
||||
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 {
|
||||
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>
|
||||
|
|
|
|||
174
frontend/src/pages/api-docs/CodeBlock.vue
Normal file
174
frontend/src/pages/api-docs/CodeBlock.vue
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { methodColors } from './endpoints.js';
|
||||
import { methodColors, safeInlineHtml } from './endpoints.js';
|
||||
import CodeBlock from './CodeBlock.vue';
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: Object, required: true },
|
||||
|
|
@ -24,7 +25,7 @@ const paramColumns = [
|
|||
<code class="endpoint-path">{{ endpoint.path }}</code>
|
||||
</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 class="block-label">Parameters</div>
|
||||
|
|
@ -33,27 +34,39 @@ const paramColumns = [
|
|||
|
||||
<div v-if="endpoint.body" class="endpoint-block">
|
||||
<div class="block-label">Request body</div>
|
||||
<a-typography-paragraph :copyable="{ text: endpoint.body }">
|
||||
<pre class="code-block">{{ endpoint.body }}</pre>
|
||||
</a-typography-paragraph>
|
||||
<CodeBlock :code="endpoint.body" lang="json" />
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.response" class="endpoint-block">
|
||||
<div class="block-label">Response</div>
|
||||
<a-typography-paragraph :copyable="{ text: endpoint.response }">
|
||||
<pre class="code-block">{{ endpoint.response }}</pre>
|
||||
</a-typography-paragraph>
|
||||
<CodeBlock :code="endpoint.response" lang="json" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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 {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
|
|
@ -64,38 +77,52 @@ const paramColumns = [
|
|||
}
|
||||
|
||||
.method-tag {
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 60px;
|
||||
min-width: 56px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
font-size: 13.5px;
|
||||
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 {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.55;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
line-height: 1.6;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.endpoint-block {
|
||||
margin-top: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.error-label {
|
||||
color: #cf222e;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
|
|
@ -112,12 +139,29 @@ const paramColumns = [
|
|||
</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 {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,57 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import EndpointRow from './EndpointRow.vue';
|
||||
import { safeInlineHtml } from './endpoints.js';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
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>
|
||||
|
||||
<template>
|
||||
<section :id="section.id" class="api-section">
|
||||
<h2 class="section-title">{{ section.title }}</h2>
|
||||
<p v-if="section.description" class="section-description">{{ section.description }}</p>
|
||||
<div class="endpoints">
|
||||
<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>
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -19,24 +60,89 @@ defineProps({
|
|||
<style scoped>
|
||||
.api-section {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
scroll-margin-top: 16px;
|
||||
margin-bottom: 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 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
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 {
|
||||
margin: 6px 0 14px;
|
||||
margin: 12px 0 14px;
|
||||
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 {
|
||||
|
|
@ -47,19 +153,40 @@ defineProps({
|
|||
<style>
|
||||
body.dark .api-section {
|
||||
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 {
|
||||
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 {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .section-icon {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
body.dark .section-description {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,31 @@
|
|||
export function safeInlineHtml(input) {
|
||||
if (!input) return '';
|
||||
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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 = [
|
||||
{
|
||||
id: 'auth',
|
||||
id: 'authentication',
|
||||
title: 'Authentication',
|
||||
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/*.',
|
||||
|
|
@ -17,11 +42,14 @@ export const sections = [
|
|||
body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}',
|
||||
response:
|
||||
'{\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',
|
||||
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',
|
||||
|
|
@ -41,9 +69,9 @@ export const sections = [
|
|||
|
||||
{
|
||||
id: 'inbounds',
|
||||
title: 'Inbounds API',
|
||||
title: 'Inbounds',
|
||||
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: [
|
||||
{
|
||||
method: 'GET',
|
||||
|
|
@ -67,6 +95,7 @@ export const sections = [
|
|||
params: [
|
||||
{ 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',
|
||||
|
|
@ -75,6 +104,7 @@ export const sections = [
|
|||
params: [
|
||||
{ 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',
|
||||
|
|
@ -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).',
|
||||
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}',
|
||||
errorResponse:
|
||||
'{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
|
|
@ -161,6 +193,14 @@ export const sections = [
|
|||
body:
|
||||
'{\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',
|
||||
path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
|
||||
|
|
@ -209,6 +249,7 @@ export const sections = [
|
|||
method: 'POST',
|
||||
path: '/panel/api/inbounds/lastOnline',
|
||||
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',
|
||||
|
|
@ -256,7 +297,7 @@ export const sections = [
|
|||
|
||||
{
|
||||
id: 'server',
|
||||
title: 'Server API',
|
||||
title: 'Server',
|
||||
description:
|
||||
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
|
||||
endpoints: [
|
||||
|
|
@ -264,6 +305,7 @@ export const sections = [
|
|||
method: 'GET',
|
||||
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.',
|
||||
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',
|
||||
|
|
@ -278,7 +320,36 @@ export const sections = [
|
|||
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.',
|
||||
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.' },
|
||||
],
|
||||
},
|
||||
|
|
@ -286,6 +357,7 @@ export const sections = [
|
|||
method: 'GET',
|
||||
path: '/panel/api/server/getXrayVersion',
|
||||
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',
|
||||
|
|
@ -295,7 +367,8 @@ export const sections = [
|
|||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getConfigJson',
|
||||
summary: 'Return the assembled Xray config that’s 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',
|
||||
|
|
@ -306,36 +379,45 @@ export const sections = [
|
|||
method: 'GET',
|
||||
path: '/panel/api/server/getNewUUID',
|
||||
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',
|
||||
path: '/panel/api/server/getNewX25519Cert',
|
||||
summary: 'Generate a new X25519 keypair for Reality.',
|
||||
response: '{\n "success": true,\n "obj": {\n "privateKey": "uN9qLfV3zH8w...",\n "publicKey": "5v8xPqR2sM7k..."\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewmldsa65',
|
||||
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',
|
||||
path: '/panel/api/server/getNewmlkem768',
|
||||
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',
|
||||
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',
|
||||
path: '/panel/api/server/stopXrayService',
|
||||
summary: 'Stop the Xray binary. All proxies go offline immediately.',
|
||||
errorResponse:
|
||||
'{\n "success": false,\n "msg": "Xray is not running"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/restartXrayService',
|
||||
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',
|
||||
|
|
@ -354,6 +436,10 @@ export const sections = [
|
|||
method: 'POST',
|
||||
path: '/panel/api/server/updateGeofile',
|
||||
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',
|
||||
|
|
@ -366,11 +452,12 @@ export const sections = [
|
|||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/logs/:count',
|
||||
summary: 'Return the last N lines of the panel’s own log.',
|
||||
summary: 'Return the last N lines of the panel\u2019s own log.',
|
||||
params: [
|
||||
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
|
||||
],
|
||||
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',
|
||||
|
|
@ -378,24 +465,38 @@ export const sections = [
|
|||
summary: 'Return the last N lines of the Xray process log.',
|
||||
params: [
|
||||
{ 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',
|
||||
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.',
|
||||
params: [
|
||||
{ name: 'db', in: 'body (multipart)', type: 'file', desc: 'SQLite database file to upload.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
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',
|
||||
title: 'Nodes API',
|
||||
title: 'Nodes',
|
||||
description:
|
||||
'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
|
||||
endpoints: [
|
||||
|
|
@ -403,6 +504,7 @@ export const sections = [
|
|||
method: 'GET',
|
||||
path: '/panel/api/nodes/list',
|
||||
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',
|
||||
|
|
@ -422,10 +524,11 @@ export const sections = [
|
|||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/update/:id',
|
||||
summary: 'Replace a node’s connection details. Same body shape as /add.',
|
||||
summary: 'Replace a node\u2019s connection details. Same body shape as /add.',
|
||||
params: [
|
||||
{ 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',
|
||||
|
|
@ -448,6 +551,8 @@ export const sections = [
|
|||
method: 'POST',
|
||||
path: '/panel/api/nodes/test',
|
||||
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',
|
||||
|
|
@ -463,16 +568,16 @@ export const sections = [
|
|||
summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
{ name: 'metric', in: 'path', type: 'string', desc: 'Metric key (cpu, mem, netIn, …).' },
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds.' },
|
||||
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem.' },
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'customGeo',
|
||||
title: 'Custom Geo API',
|
||||
id: 'custom-geo',
|
||||
title: 'Custom Geo',
|
||||
description:
|
||||
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
|
||||
endpoints: [
|
||||
|
|
@ -531,12 +636,268 @@ export const sections = [
|
|||
description: 'Operations that interact with the configured Telegram bot.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
method: 'POST',
|
||||
path: '/panel/api/backuptotgbot',
|
||||
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 <token></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 = {
|
||||
|
|
@ -545,4 +906,5 @@ export const methodColors = {
|
|||
PUT: 'orange',
|
||||
PATCH: 'orange',
|
||||
DELETE: 'red',
|
||||
WS: 'purple',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const props = defineProps({
|
|||
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||
isDarkTheme: { type: Boolean, default: false },
|
||||
pageSize: { type: Number, default: 0 },
|
||||
totalClientCount: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
|
|
@ -138,7 +139,7 @@ function statsExpColor(email) {
|
|||
return PURPLE;
|
||||
}
|
||||
|
||||
const isRemovable = computed(() => clients.value.length > 1);
|
||||
const isRemovable = computed(() => (props.totalClientCount || clients.value.length) > 1);
|
||||
|
||||
function totalGbDisplay(client) {
|
||||
if (!client.totalGB || client.totalGB <= 0) return '';
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
SizeFormatter,
|
||||
Wireguard,
|
||||
} from '@/utils';
|
||||
import { getRandomRealityTarget } from '@/models/reality-targets';
|
||||
import {
|
||||
Inbound,
|
||||
Protocols,
|
||||
|
|
@ -69,6 +70,7 @@ const inbound = ref(null);
|
|||
const dbForm = ref(null);
|
||||
const saving = ref(false);
|
||||
const advancedJson = ref({ stream: '', sniffing: '', settings: '' });
|
||||
const activeTabKey = ref('basic');
|
||||
// Cached default cert/key paths from /panel/setting/defaultSettings —
|
||||
// powers the "Set default cert" button on the TLS form.
|
||||
const defaultCert = ref('');
|
||||
|
|
@ -240,9 +242,60 @@ watch(() => props.open, (next) => {
|
|||
dbForm.value = freshDbForm();
|
||||
primeAdvancedJson();
|
||||
}
|
||||
activeTabKey.value = 'basic';
|
||||
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.
|
||||
function onProtocolChange(next) {
|
||||
if (props.mode === 'edit' || !inbound.value) return;
|
||||
|
|
@ -339,11 +392,9 @@ function clearMldsa65() {
|
|||
inbound.value.stream.reality.settings.mldsa65Verify = '';
|
||||
}
|
||||
|
||||
// Reality target/SNI randomizer — only available if the helper is loaded
|
||||
function randomizeRealityTarget() {
|
||||
if (!inbound.value?.stream?.reality) return;
|
||||
if (typeof window.getRandomRealityTarget !== 'function') return;
|
||||
const t = window.getRandomRealityTarget();
|
||||
const t = getRandomRealityTarget();
|
||||
inbound.value.stream.reality.target = t.target;
|
||||
inbound.value.stream.reality.serverNames = t.sni;
|
||||
}
|
||||
|
|
@ -573,7 +624,7 @@ watch(
|
|||
<template>
|
||||
<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">
|
||||
<a-tabs v-if="inbound && dbForm" default-active-key="basic">
|
||||
<a-tabs v-if="inbound && dbForm" v-model:active-key="activeTabKey">
|
||||
<!-- ============================== BASICS ============================== -->
|
||||
<a-tab-pane key="basic" :tab="t('pages.xray.basicTemplate')">
|
||||
<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-input v-model:value="dbForm.remark" />
|
||||
</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'"
|
||||
:placeholder="t('pages.inbounds.localPanel')" allow-clear>
|
||||
<a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
|
||||
|
|
@ -1671,6 +1722,74 @@ watch(
|
|||
</a-select>
|
||||
</a-form-item>
|
||||
</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>
|
||||
|
||||
<!-- ====== FinalMask (TCP/UDP masks + QUIC params) ====== -->
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const props = defineProps({
|
|||
// Map node id -> node row, supplied by the parent page so each
|
||||
// inbound row can render its node name without an extra fetch.
|
||||
nodesById: { type: Map, default: () => new Map() },
|
||||
hasActiveNode: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
|
|
@ -67,9 +68,29 @@ const emit = defineEmits([
|
|||
]);
|
||||
|
||||
// ============ Toolbar / search & filter =============================
|
||||
const enableFilter = ref(false);
|
||||
const searchKey = ref('');
|
||||
const filterBy = ref('');
|
||||
const FILTER_STATE_KEY = 'inboundsFilterState';
|
||||
const savedFilterState = (() => {
|
||||
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.
|
||||
function onToggleFilter() {
|
||||
|
|
@ -77,6 +98,35 @@ function onToggleFilter() {
|
|||
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 =============================
|
||||
// Mirrors the legacy logic: when searching, keep inbounds that match
|
||||
// anywhere (deep search); when filtering, keep inbounds that have at
|
||||
|
|
@ -99,7 +149,7 @@ function projectInbound(dbInbound, predicate) {
|
|||
|
||||
const visibleInbounds = computed(() => {
|
||||
if (enableFilter.value) {
|
||||
if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds];
|
||||
if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
|
||||
const out = [];
|
||||
for (const dbInbound of props.dbInbounds) {
|
||||
const c = props.clientCount[dbInbound.id];
|
||||
|
|
@ -107,15 +157,15 @@ const visibleInbounds = computed(() => {
|
|||
const list = c[filterBy.value];
|
||||
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 = [];
|
||||
for (const dbInbound of props.dbInbounds) {
|
||||
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
|
||||
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
|
||||
}
|
||||
return out;
|
||||
return applySecondaryFilters(out);
|
||||
});
|
||||
|
||||
// ============ Sorting =================================================
|
||||
|
|
@ -185,7 +235,7 @@ const desktopColumns = computed(() => {
|
|||
if (hasAnyRemark.value) {
|
||||
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(
|
||||
|
|
@ -319,6 +369,18 @@ function showQrCodeMenu(dbInbound) {
|
|||
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
|
||||
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
|
||||
</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>
|
||||
|
||||
<!-- ====================== Mobile: card list ======================= -->
|
||||
|
|
@ -395,8 +457,8 @@ function showQrCodeMenu(dbInbound) {
|
|||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
|
||||
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
||||
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
|
||||
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
|
||||
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
|
||||
<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.isReality" color="blue">Reality</a-tag>
|
||||
</template>
|
||||
|
|
@ -405,7 +467,7 @@ function showQrCodeMenu(dbInbound) {
|
|||
<span class="stat-label">{{ t('pages.inbounds.port') }}</span>
|
||||
<a-tag>{{ record.port }}</a-tag>
|
||||
</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>
|
||||
<a-tag v-if="record.nodeId == null" color="default">
|
||||
{{ t('pages.inbounds.localPanel') }}
|
||||
|
|
@ -430,7 +492,7 @@ function showQrCodeMenu(dbInbound) {
|
|||
</div>
|
||||
<div v-if="clientCount[record.id]" class="stat-row">
|
||||
<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">
|
||||
{{ clientCount[record.id].online.length }} {{ t('online') }}
|
||||
</a-tag>
|
||||
|
|
@ -457,7 +519,7 @@ function showQrCodeMenu(dbInbound) {
|
|||
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
|
||||
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
|
||||
: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)"
|
||||
@info-client="(p) => emit('info-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"
|
||||
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
|
||||
: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)"
|
||||
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-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'">
|
||||
<div class="protocol-tags">
|
||||
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
||||
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
|
||||
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
|
||||
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
|
||||
<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.isReality" color="blue">Reality</a-tag>
|
||||
</template>
|
||||
|
|
@ -581,14 +644,14 @@ function showQrCodeMenu(dbInbound) {
|
|||
<!-- ============== Clients tag + popovers ============== -->
|
||||
<template v-else-if="column.key === 'clients'">
|
||||
<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')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</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 v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
|
||||
<template #content>
|
||||
|
|
@ -596,7 +659,7 @@ function showQrCodeMenu(dbInbound) {
|
|||
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</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-popover>
|
||||
<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>
|
||||
</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-popover>
|
||||
<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>
|
||||
</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>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -680,7 +743,7 @@ function showQrCodeMenu(dbInbound) {
|
|||
}
|
||||
|
||||
.filter-bar.mobile>* {
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.protocol-tags {
|
||||
|
|
@ -689,6 +752,10 @@ function showQrCodeMenu(dbInbound) {
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-count-tag {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ useWebSocket({
|
|||
const { isMobile } = useMediaQuery();
|
||||
// Node list lives on the central panel; the Inbounds page consumes
|
||||
// the id→node 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 requestUri = window.location.pathname;
|
||||
|
|
@ -396,7 +396,7 @@ function confirmResetTraffic(dbInbound) {
|
|||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
|
@ -647,7 +647,8 @@ function onRowAction({ key, dbInbound }) {
|
|||
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
|
||||
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
|
||||
: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"
|
||||
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
||||
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ function parseLogLine(line) {
|
|||
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));
|
||||
|
|
@ -133,33 +135,25 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
|
|||
<template v-else-if="isMobile">
|
||||
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-card">
|
||||
<div class="log-card-head">
|
||||
<span v-if="log.date || log.time" class="log-time">
|
||||
<span v-if="log.time">{{ log.time }}</span>
|
||||
<span v-if="log.date" class="log-date">{{ log.date }}</span>
|
||||
<span v-if="log.stamp" class="log-time">
|
||||
<span v-if="log.time">{{ log.time }}</span>{{ log.time && log.date ? ' ' : '' }}<span v-if="log.date" class="log-date">{{ log.date }}</span>
|
||||
</span>
|
||||
<span v-if="log.levelText" class="log-level-badge" :class="log.levelClass">
|
||||
{{ log.levelText }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="log.body || log.service" class="log-body">
|
||||
<b v-if="log.service">{{ log.service }}</b>
|
||||
<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
|
||||
<b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span v-if="log.body" class="log-body-text">{{ log.body }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="(log, idx) in parsedLogs" :key="idx" class="log-line">
|
||||
<span v-if="log.date || log.time" class="log-stamp">
|
||||
{{ 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>
|
||||
<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>
|
||||
<template v-if="log.body || log.service">
|
||||
<span> - </span>
|
||||
<b v-if="log.service">{{ log.service }} </b>
|
||||
<span>{{ log.body }}</span>
|
||||
<b v-if="log.service">{{ log.service }}</b>{{ log.service && log.body ? ' ' : '' }}<span>{{ log.body }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ function defaultForm() {
|
|||
basePath: '/',
|
||||
apiToken: '',
|
||||
enable: true,
|
||||
allowPrivateAddress: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +70,7 @@ function buildPayload() {
|
|||
basePath: form.basePath?.trim() || '/',
|
||||
apiToken: form.apiToken?.trim() || '',
|
||||
enable: !!form.enable,
|
||||
allowPrivateAddress: !!form.allowPrivateAddress,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +163,11 @@ async function onSave() {
|
|||
</a-col>
|
||||
</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-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" />
|
||||
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
|
||||
|
|
|
|||
|
|
@ -153,6 +153,14 @@ onMounted(loadInboundTags);
|
|||
</template>
|
||||
</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">
|
||||
<template #title>{{ t('pages.settings.pageSize') }}</template>
|
||||
<template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
|
||||
|
|
@ -298,8 +306,12 @@ onMounted(loadInboundTags);
|
|||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('password') }}</template>
|
||||
<template #description>
|
||||
{{ allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.' }}
|
||||
</template>
|
||||
<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>
|
||||
</SettingListItem>
|
||||
|
||||
|
|
|
|||
|
|
@ -52,10 +52,9 @@ async function sendUpdateUser() {
|
|||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/updateUser', user);
|
||||
if (msg?.success) {
|
||||
// Force re-login at the standard logout path; basePath is handled
|
||||
// by the Go router so a relative redirect is correct here.
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
window.location.replace(`${basePath}logout`);
|
||||
await HttpUtil.post('/logout');
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.replace(basePath);
|
||||
}
|
||||
} finally {
|
||||
updating.value = false;
|
||||
|
|
@ -76,34 +75,41 @@ function updateUser() {
|
|||
}
|
||||
}
|
||||
|
||||
// === API Token =========================================================
|
||||
// Surfaces the panel's API token so a remote central panel can register
|
||||
// this instance as a node. Lazy-loaded on tab mount; rotation requires
|
||||
// confirmation since it invalidates any cached value upstream.
|
||||
const apiToken = ref('');
|
||||
const apiTokenLoading = ref(false);
|
||||
const apiTokenRotating = ref(false);
|
||||
const apiTokens = ref([]);
|
||||
const apiTokensLoading = ref(false);
|
||||
const visibleTokenIds = ref(new Set());
|
||||
const createOpen = ref(false);
|
||||
const createName = ref('');
|
||||
const creating = ref(false);
|
||||
|
||||
async function loadApiToken() {
|
||||
apiTokenLoading.value = true;
|
||||
async function loadApiTokens() {
|
||||
apiTokensLoading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/setting/getApiToken');
|
||||
if (msg?.success) apiToken.value = msg.obj || '';
|
||||
const msg = await HttpUtil.get('/panel/setting/apiTokens');
|
||||
if (msg?.success) apiTokens.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
} finally {
|
||||
apiTokenLoading.value = false;
|
||||
apiTokensLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyApiToken() {
|
||||
if (!apiToken.value) return;
|
||||
function isTokenVisible(id) {
|
||||
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 {
|
||||
await navigator.clipboard.writeText(apiToken.value);
|
||||
await navigator.clipboard.writeText(token);
|
||||
message.success(t('copySuccess'));
|
||||
} catch (_e) {
|
||||
// navigator.clipboard can be undefined on http:// — fall back to
|
||||
// a transient input + execCommand path.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = apiToken.value;
|
||||
ta.value = token;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
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({
|
||||
title: t('pages.nodes.regenerateConfirm'),
|
||||
okText: t('confirm'),
|
||||
title: `${t('delete')} "${row.name}"?`,
|
||||
content: t('pages.settings.security.apiTokenDeleteWarning')
|
||||
|| 'Any caller using this token will stop authenticating immediately.',
|
||||
okText: t('delete'),
|
||||
cancelText: t('cancel'),
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
apiTokenRotating.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
|
||||
if (msg?.success) {
|
||||
apiToken.value = msg.obj || '';
|
||||
message.success(t('success'));
|
||||
}
|
||||
} finally {
|
||||
apiTokenRotating.value = false;
|
||||
}
|
||||
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`);
|
||||
if (msg?.success) await loadApiTokens();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
// Switch read-only — the actual flip happens after the modal succeeds.
|
||||
|
|
@ -217,24 +261,144 @@ function toggleTwoFactor() {
|
|||
</a-collapse-panel>
|
||||
|
||||
<a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.nodes.apiToken') }}</template>
|
||||
<template #description>{{ t('pages.nodes.apiTokenHint') }}</template>
|
||||
<template #control>
|
||||
<a-input-password :value="apiToken" readonly :loading="apiTokenLoading" style="min-width: 240px" />
|
||||
</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') }}
|
||||
<div class="api-token-section">
|
||||
<div class="api-token-header">
|
||||
<p class="api-token-hint">{{ t('pages.nodes.apiTokenHint') }}</p>
|
||||
<a-button type="primary" size="small" @click="openCreateModal">
|
||||
+ {{ t('pages.settings.security.apiTokenNew') || 'New token' }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-list-item>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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"
|
||||
:type="tfa.type" @confirm="onTfaConfirm" />
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import {
|
||||
|
|
@ -152,6 +152,35 @@ const confAlerts = computed(() => {
|
|||
});
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -199,7 +228,7 @@ const alertVisible = ref(true);
|
|||
</a-col>
|
||||
|
||||
<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">
|
||||
<template #tab>
|
||||
<SettingOutlined />
|
||||
|
|
|
|||
|
|
@ -112,6 +112,14 @@ function normalizeSubPath() {
|
|||
</template>
|
||||
</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>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
|
|
|
|||
|
|
@ -23,9 +23,12 @@ defineProps({
|
|||
|
||||
<SettingListItem paddings="small">
|
||||
<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>
|
||||
<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>
|
||||
</SettingListItem>
|
||||
|
||||
|
|
|
|||
|
|
@ -38,18 +38,24 @@ function buildTotp() {
|
|||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
enteredCode.value = '';
|
||||
totp = null;
|
||||
qrValue.value = '';
|
||||
if (props.token) {
|
||||
buildTotp();
|
||||
}
|
||||
});
|
||||
|
||||
function close(success) {
|
||||
emit('confirm', success);
|
||||
function close(success, code = '') {
|
||||
emit('confirm', success, code);
|
||||
emit('update:open', false);
|
||||
enteredCode.value = '';
|
||||
}
|
||||
|
||||
function onOk() {
|
||||
if (props.type === 'confirm' && !props.token) {
|
||||
close(true, enteredCode.value);
|
||||
return;
|
||||
}
|
||||
if (!totp) return;
|
||||
if (totp.generate() === enteredCode.value) {
|
||||
close(true);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ const subUrl = subData.subUrl || '';
|
|||
const subJsonUrl = subData.subJsonUrl || '';
|
||||
const subClashUrl = subData.subClashUrl || '';
|
||||
const subTitle = subData.subTitle || '';
|
||||
const subSupportUrl = subData.subSupportUrl || '';
|
||||
const links = Array.isArray(subData.links) ? subData.links : [];
|
||||
// Panel's "Calendar Type" setting; controls whether expiry / lastOnline
|
||||
// render in Gregorian or Jalali on this standalone subscription page.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
|||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -80,8 +80,17 @@ watch(() => props.open, (next) => {
|
|||
primeAdvancedJson();
|
||||
});
|
||||
|
||||
watch(activeKey, (key) => {
|
||||
if (key === '2') primeAdvancedJson();
|
||||
let isRevertingTab = false;
|
||||
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() {
|
||||
|
|
@ -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 onProtocolChange(next) {
|
||||
|
|
@ -131,27 +167,15 @@ const tagHelp = computed(() => {
|
|||
// ============== Submit ==============
|
||||
function onOk() {
|
||||
if (!outbound.value) return;
|
||||
if (activeKey.value === '2' && !applyAdvancedJsonToForm()) return;
|
||||
if (!outbound.value.tag?.trim()) {
|
||||
message.error(t('somethingWentWrong'));
|
||||
message.error('Tag is required');
|
||||
return;
|
||||
}
|
||||
if (duplicateTag.value) {
|
||||
message.error(t('somethingWentWrong'));
|
||||
message.error('Tag already used by another outbound');
|
||||
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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ClusterOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
HolderOutlined,
|
||||
} from '@ant-design/icons-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
|
||||
// layout); full lists surface via tooltip on hover.
|
||||
//
|
||||
// Reorder uses up/down buttons in the action menu rather than the
|
||||
// jQuery-Sortable drag handle the legacy panel used — same effect,
|
||||
// no extra dep. The mobile column layout drops source/network/
|
||||
// Reorder via Pointer Events on the grip icon — unified mouse +
|
||||
// touch + pen path so the same code works on desktop and mobile
|
||||
// (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.
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -162,6 +165,58 @@ function moveDown(idx) {
|
|||
[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 =========================================================
|
||||
// Computed so titles re-render after a locale swap.
|
||||
const desktopColumns = computed(() => [
|
||||
|
|
@ -170,14 +225,31 @@ const desktopColumns = computed(() => [
|
|||
{ title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
|
||||
{ title: 'Destination', align: 'left', key: 'destination' },
|
||||
{ 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(() => [
|
||||
{ title: '#', align: 'center', width: 70, key: 'action' },
|
||||
{ title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' },
|
||||
{ title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' },
|
||||
]);
|
||||
const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
|
||||
const columns = computed(() => desktopColumns.value);
|
||||
|
||||
function ruleCriteriaChips(rule) {
|
||||
const chips = [];
|
||||
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -189,12 +261,84 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||
{{ t('pages.xray.Routings') }}
|
||||
</a-button>
|
||||
|
||||
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
|
||||
:scroll="isMobile ? {} : { x: 1000 }" size="small" class="routing-table">
|
||||
<!-- Mobile: stacked cards. The desktop a-table doesn't fit on a
|
||||
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 }">
|
||||
<!-- ============== # / actions ============== -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<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>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button shape="circle" size="small">
|
||||
|
|
@ -228,7 +372,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||
<span class="criterion-label">IP</span>
|
||||
<span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
|
||||
<span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1
|
||||
}}</span>
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
|
||||
|
|
@ -259,7 +403,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||
<span class="criterion-label">L4</span>
|
||||
<span class="criterion-value">{{ csv(record.network)[0] }}</span>
|
||||
<span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1
|
||||
}}</span>
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
|
||||
|
|
@ -267,7 +411,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||
<span class="criterion-label">Protocol</span>
|
||||
<span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
|
||||
<span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1
|
||||
}}</span>
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
|
||||
|
|
@ -295,7 +439,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||
<span class="criterion-label">Domain</span>
|
||||
<span class="criterion-value">{{ csv(record.domain)[0] }}</span>
|
||||
<span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1
|
||||
}}</span>
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
|
||||
|
|
@ -303,7 +447,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||
<span class="criterion-label">Port</span>
|
||||
<span class="criterion-value">{{ csv(record.port)[0] }}</span>
|
||||
<span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1
|
||||
}}</span>
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
|
||||
|
|
@ -326,25 +470,32 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||
<span class="criterion-label">User</span>
|
||||
<span class="criterion-value">{{ csv(record.user)[0] }}</span>
|
||||
<span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1
|
||||
}}</span>
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============== Outbound / balancer target ============== -->
|
||||
<template v-else-if="column.key === 'target'">
|
||||
<!-- ============== Outbound ============== -->
|
||||
<template v-else-if="column.key === 'outbound'">
|
||||
<div class="target-cell">
|
||||
<div v-if="record.outboundTag" class="target-row">
|
||||
<ExportOutlined class="target-icon" />
|
||||
<a-tag color="green">{{ record.outboundTag }}</a-tag>
|
||||
</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">
|
||||
<ClusterOutlined class="target-icon" />
|
||||
<a-tag color="purple">{{ record.balancerTag }}</a-tag>
|
||||
</div>
|
||||
<span v-if="!record.outboundTag && !record.balancerTag" class="criterion-empty">—</span>
|
||||
<span v-else class="criterion-empty">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -362,6 +513,36 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||
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 {
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
|
|
@ -429,4 +610,136 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
|||
.danger {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
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 basePath = window.X_UI_BASE_PATH || '';
|
||||
|
|
@ -208,6 +205,51 @@ function confirmRestart() {
|
|||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -234,7 +276,7 @@ function confirmRestart() {
|
|||
<a-row class="header-row">
|
||||
<a-col :xs="24" :sm="14" class="header-actions">
|
||||
<a-space direction="horizontal">
|
||||
<a-button type="primary" :disabled="saveDisabled" @click="saveAll">
|
||||
<a-button type="primary" :disabled="saveDisabled" @click="onSaveAll">
|
||||
{{ t('pages.xray.save') }}
|
||||
</a-button>
|
||||
<a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart">
|
||||
|
|
@ -259,7 +301,7 @@ function confirmRestart() {
|
|||
|
||||
<!-- Tabs -->
|
||||
<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">
|
||||
<template #tab>
|
||||
<SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
static async sleep(timeout) {
|
||||
await new Promise(resolve => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>3x-ui · Xray</title>
|
||||
<title>Xray Config</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
|
|
|
|||
27
go.mod
27
go.mod
|
|
@ -22,10 +22,11 @@ require (
|
|||
github.com/xlzd/gotp v0.1.0
|
||||
github.com/xtls/xray-core v1.260327.0
|
||||
go.uber.org/atomic v1.11.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/sys v0.44.0
|
||||
golang.org/x/text v0.37.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/gorm v1.31.1
|
||||
)
|
||||
|
|
@ -69,13 +70,13 @@ require (
|
|||
github.com/pires/go-proxyproto v0.12.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
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/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/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
|
|
@ -86,16 +87,16 @@ require (
|
|||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/arch v0.26.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/arch v0.27.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sync v0.20.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/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
|
||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
|
|
|
|||
54
go.sum
54
go.sum
|
|
@ -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/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/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
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/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/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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU=
|
||||
github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
|
||||
github.com/sagernet/sing v0.8.10 h1:V5VZffy8rm4dtBVKIpKa8vibRR2SiJprtu/10DFUalU=
|
||||
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/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
|
||||
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
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.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
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/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
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=
|
||||
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=
|
||||
golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4=
|
||||
golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
|
||||
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
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/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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.2.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.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
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/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
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/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/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
|
||||
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 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
|
||||
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/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
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/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
|
|
@ -11,17 +11,25 @@ import (
|
|||
|
||||
"github.com/mhsanaei/3x-ui/v3/config"
|
||||
"github.com/op/go-logging"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
const (
|
||||
maxLogBufferSize = 10240 // Maximum log entries kept in memory
|
||||
logFileName = "3xui.log" // Log file name
|
||||
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 (
|
||||
logger *logging.Logger
|
||||
logFile *os.File
|
||||
logger *logging.Logger
|
||||
fileRotate *lumberjack.Logger // nil when file backend disabled
|
||||
|
||||
// logBuffer maintains recent log entries in memory for web UI retrieval
|
||||
logBuffer []struct {
|
||||
|
|
@ -81,8 +89,8 @@ func initDefaultBackend() logging.Backend {
|
|||
return logging.NewBackendFormatter(backend, newFormatter(includeTime))
|
||||
}
|
||||
|
||||
// initFileBackend creates the file logging backend.
|
||||
// Creates log directory and truncates log file on startup for fresh logs.
|
||||
// initFileBackend creates the file logging backend with size/age‑bounded rotation
|
||||
// so log volume cannot grow without limit on disk.
|
||||
func initFileBackend() logging.Backend {
|
||||
logDir := config.GetLogFolder()
|
||||
if err := os.MkdirAll(logDir, 0o750); err != nil {
|
||||
|
|
@ -91,19 +99,16 @@ func initFileBackend() logging.Backend {
|
|||
}
|
||||
|
||||
logPath := filepath.Join(logDir, logFileName)
|
||||
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o660)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to open log file %s: %v\n", logPath, err)
|
||||
return nil
|
||||
fileRotate = &lumberjack.Logger{
|
||||
Filename: logPath,
|
||||
MaxSize: maxLogFileMB,
|
||||
MaxBackups: maxLogBackups,
|
||||
MaxAge: maxLogAgeDays,
|
||||
LocalTime: true,
|
||||
Compress: compressRotated,
|
||||
}
|
||||
|
||||
// Close previous log file if exists
|
||||
if logFile != nil {
|
||||
_ = logFile.Close()
|
||||
}
|
||||
logFile = file
|
||||
|
||||
backend := logging.NewLogBackend(file, "", 0)
|
||||
backend := logging.NewLogBackend(fileRotate, "", 0)
|
||||
return logging.NewBackendFormatter(backend, newFormatter(true))
|
||||
}
|
||||
|
||||
|
|
@ -116,12 +121,12 @@ func newFormatter(withTime bool) logging.Formatter {
|
|||
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.
|
||||
func CloseLogger() {
|
||||
if logFile != nil {
|
||||
_ = logFile.Close()
|
||||
logFile = nil
|
||||
if fileRotate != nil {
|
||||
_ = fileRotate.Close()
|
||||
fileRotate = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -447,6 +447,9 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
|||
if udpIdleTimeout, ok := hyStream["udpIdleTimeout"].(float64); ok {
|
||||
outHyStream["udpIdleTimeout"] = int(udpIdleTimeout)
|
||||
}
|
||||
if masquerade, ok := hyStream["masquerade"].(map[string]any); ok {
|
||||
outHyStream["masquerade"] = masquerade
|
||||
}
|
||||
newStream["hysteriaSettings"] = outHyStream
|
||||
|
||||
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type SubService struct {
|
|||
showInfo bool
|
||||
remarkModel string
|
||||
datepicker string
|
||||
emailInRemark bool
|
||||
inboundService service.InboundService
|
||||
settingService service.SettingService
|
||||
// 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 {
|
||||
s.datepicker = "gregorian"
|
||||
}
|
||||
|
||||
s.emailInRemark, err = s.settingService.GetSubEmailInRemark()
|
||||
if err != nil {
|
||||
s.emailInRemark = true
|
||||
}
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
for _, inbound := range inbounds {
|
||||
clients, err := s.inboundService.GetClients(inbound)
|
||||
|
|
@ -886,7 +893,7 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
|
|||
'e': "",
|
||||
'o': "",
|
||||
}
|
||||
if len(email) > 0 {
|
||||
if len(email) > 0 && s.emailInRemark {
|
||||
orders['e'] = email
|
||||
}
|
||||
if len(inbound.Remark) > 0 {
|
||||
|
|
|
|||
80
util/netsafe/netsafe.go
Normal file
80
util/netsafe/netsafe.go
Normal 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
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ type APIController struct {
|
|||
nodeController *NodeController
|
||||
settingService service.SettingService
|
||||
userService service.UserService
|
||||
apiTokenService service.ApiTokenService
|
||||
Tgbot service.Tgbot
|
||||
}
|
||||
|
||||
|
|
@ -29,25 +30,11 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
|
|||
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) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
tok := strings.TrimPrefix(auth, "Bearer ")
|
||||
if a.settingService.MatchApiToken(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 a.apiTokenService.Match(tok) {
|
||||
if u, err := a.userService.GetFirstUser(); err == nil {
|
||||
session.SetAPIAuthUser(c, u)
|
||||
}
|
||||
|
|
@ -57,7 +44,11 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
if !session.IsLogin(c) {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
if c.GetHeader("X-Requested-With") == "XMLHttpRequest" {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
} else {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
}
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
|
|
@ -85,7 +76,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
|
|||
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
||||
|
||||
// Extra routes
|
||||
api.GET("/backuptotgbot", a.BackuptoTgbot)
|
||||
api.POST("/backuptotgbot", a.BackuptoTgbot)
|
||||
}
|
||||
|
||||
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins.
|
||||
|
|
|
|||
160
web/controller/api_docs_test.go
Normal file
160
web/controller/api_docs_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +57,11 @@ func serveDistPage(c *gin.Context, name string) {
|
|||
}
|
||||
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" {
|
||||
escapedVer := jsEscape.Replace(config.GetVersion())
|
||||
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
|||
g.POST("/:id/copyClients", a.copyInboundClients)
|
||||
g.POST("/:id/delClient/:clientId", a.delInboundClient)
|
||||
g.POST("/updateClient/:clientId", a.updateInboundClient)
|
||||
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
|
||||
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
|
||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||
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.
|
||||
func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
||||
err := a.inboundService.ResetAllTraffics()
|
||||
|
|
@ -582,17 +601,19 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
|
|||
// controller layer means the service interface stays HTTP-agnostic — service
|
||||
// methods receive a plain host string instead of a *gin.Context.
|
||||
func resolveHost(c *gin.Context) string {
|
||||
if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
|
||||
if i := strings.Index(h, ","); i >= 0 {
|
||||
h = strings.TrimSpace(h[:i])
|
||||
if isTrustedForwardedRequest(c) {
|
||||
if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
|
||||
if i := strings.Index(h, ","); i >= 0 {
|
||||
h = strings.TrimSpace(h[:i])
|
||||
}
|
||||
if hp, _, err := net.SplitHostPort(h); err == nil {
|
||||
return hp
|
||||
}
|
||||
return h
|
||||
}
|
||||
if hp, _, err := net.SplitHostPort(h); err == nil {
|
||||
return hp
|
||||
if h := c.GetHeader("X-Real-IP"); h != "" {
|
||||
return h
|
||||
}
|
||||
return h
|
||||
}
|
||||
if h := c.GetHeader("X-Real-IP"); h != "" {
|
||||
return h
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(c.Request.Host); err == nil {
|
||||
return h
|
||||
|
|
|
|||
|
|
@ -39,15 +39,10 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
|
|||
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
|
||||
func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
||||
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.POST("/login", middleware.CSRFMiddleware(), a.login)
|
||||
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
|
||||
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +135,6 @@ func loginFailureReason(err error) string {
|
|||
return "invalid credentials"
|
||||
}
|
||||
|
||||
// logout handles user logout by clearing the session and redirecting to the login page.
|
||||
func (a *IndexController) logout(c *gin.Context) {
|
||||
user := session.GetLoginUser(c)
|
||||
if user != nil {
|
||||
|
|
@ -150,7 +144,7 @@ func (a *IndexController) logout(c *gin.Context) {
|
|||
logger.Warning("Unable to clear session on logout:", err)
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package controller
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||
|
|
@ -22,9 +23,10 @@ type updateUserForm struct {
|
|||
|
||||
// SettingController handles settings and user management operations.
|
||||
type SettingController struct {
|
||||
settingService service.SettingService
|
||||
userService service.UserService
|
||||
panelService service.PanelService
|
||||
settingService service.SettingService
|
||||
userService service.UserService
|
||||
panelService service.PanelService
|
||||
apiTokenService service.ApiTokenService
|
||||
}
|
||||
|
||||
// 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("/restartPanel", a.restartPanel)
|
||||
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
||||
g.GET("/getApiToken", a.getApiToken)
|
||||
g.POST("/regenerateApiToken", a.regenerateApiToken)
|
||||
g.GET("/apiTokens", a.listApiTokens)
|
||||
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.
|
||||
|
|
@ -76,7 +80,13 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
|||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -124,26 +134,56 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
|
|||
jsonObj(c, defaultJsonConfig, nil)
|
||||
}
|
||||
|
||||
// getApiToken returns the panel's API token used by remote central
|
||||
// panels to authenticate as Bearer tokens. The token is auto-generated
|
||||
// on first read so existing installs upgrade transparently.
|
||||
func (a *SettingController) getApiToken(c *gin.Context) {
|
||||
tok, err := a.settingService.GetApiToken()
|
||||
type apiTokenCreateForm struct {
|
||||
Name string `json:"name" form:"name"`
|
||||
}
|
||||
|
||||
type apiTokenEnabledForm struct {
|
||||
Enabled bool `json:"enabled" form:"enabled"`
|
||||
}
|
||||
|
||||
func (a *SettingController) listApiTokens(c *gin.Context) {
|
||||
rows, err := a.apiTokenService.List()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, tok, nil)
|
||||
jsonObj(c, rows, nil)
|
||||
}
|
||||
|
||||
// regenerateApiToken rotates the API token. Any central panel that had
|
||||
// the old value cached will start failing heartbeats until it is updated
|
||||
// with the new token — that's intentional, it's the whole point of rotation.
|
||||
func (a *SettingController) regenerateApiToken(c *gin.Context) {
|
||||
tok, err := a.settingService.RegenerateApiToken()
|
||||
func (a *SettingController) createApiToken(c *gin.Context) {
|
||||
form := &apiTokenCreateForm{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
row, err := a.apiTokenService.Create(form.Name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,29 +9,75 @@ import (
|
|||
|
||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// getRemoteIp extracts the real IP address from the request headers or remote address.
|
||||
func getRemoteIp(c *gin.Context) string {
|
||||
if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
|
||||
return ip
|
||||
remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr)
|
||||
if !ok {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
for _, part := range strings.Split(xff, ",") {
|
||||
if ip, ok := extractTrustedIP(part); ok {
|
||||
return ip
|
||||
if isTrustedProxy(remoteIP) {
|
||||
if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
|
||||
return ip
|
||||
}
|
||||
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
for _, part := range strings.Split(xff, ",") {
|
||||
if ip, ok := extractTrustedIP(part); ok {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ip, ok := extractTrustedIP(c.Request.RemoteAddr); ok {
|
||||
return ip
|
||||
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
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
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) {
|
||||
|
|
|
|||
34
web/controller/util_test.go
Normal file
34
web/controller/util_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
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)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -21,13 +21,14 @@ type Msg struct {
|
|||
// AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
|
||||
type AllSetting struct {
|
||||
// Web server settings
|
||||
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
|
||||
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
|
||||
WebPort int `json:"webPort" form:"webPort"` // Web server port number
|
||||
WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate 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
|
||||
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes
|
||||
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
|
||||
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
|
||||
WebPort int `json:"webPort" form:"webPort"` // Web server port number
|
||||
WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate 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
|
||||
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
|
||||
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
|
||||
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` // Encrypt subscription responses
|
||||
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
|
||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
|
||||
|
|
@ -110,6 +112,20 @@ type AllSetting struct {
|
|||
// 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.
|
||||
func (s *AllSetting) CheckValid() error {
|
||||
if s.WebListen != "" {
|
||||
|
|
@ -179,6 +195,19 @@ func (s *AllSetting) CheckValid() error {
|
|||
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)
|
||||
if err != nil {
|
||||
return common.NewError("time location not exist:", s.TimeLocation)
|
||||
|
|
|
|||
|
|
@ -152,6 +152,11 @@ func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traf
|
|||
logger.Warning("get ExternalTrafficInformURI failed:", err)
|
||||
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})
|
||||
if err != nil {
|
||||
logger.Warning("parse client/inbound traffic failed:", err)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/web/session"
|
||||
|
|
@ -11,10 +13,12 @@ import (
|
|||
// SecurityHeadersMiddleware adds browser hardening headers to panel responses.
|
||||
func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
nonce := newCSPNonce()
|
||||
c.Set("csp_nonce", nonce)
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
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 {
|
||||
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.
|
||||
// Bearer-token-authenticated callers (api_authed flag set by APIController.checkAPIAuth)
|
||||
// short-circuit the CSRF check — they are not browser sessions, so the
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
|
@ -16,6 +17,7 @@ import (
|
|||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
|
||||
)
|
||||
|
||||
const remoteHTTPTimeout = 10 * time.Second
|
||||
|
|
@ -25,6 +27,7 @@ var remoteHTTPClient = &http.Client{
|
|||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
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) 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
|
||||
if bp == "" {
|
||||
bp = "/"
|
||||
|
|
@ -58,7 +72,12 @@ func (r *Remote) baseURL() string {
|
|||
if !strings.HasSuffix(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) {
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
target := r.baseURL() + strings.TrimPrefix(path, "/")
|
||||
base, err := r.baseURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := base + strings.TrimPrefix(path, "/")
|
||||
|
||||
var (
|
||||
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())
|
||||
contentType = "application/x-www-form-urlencoded"
|
||||
default:
|
||||
buf, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal body: %w", err)
|
||||
buf, jerr := json.Marshal(b)
|
||||
if jerr != nil {
|
||||
return nil, fmt.Errorf("marshal body: %w", jerr)
|
||||
}
|
||||
reqBody = bytes.NewReader(buf)
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, remoteHTTPTimeout)
|
||||
cctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(ctx, r.node.AllowPrivateAddress), remoteHTTPTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, method, target, reqBody)
|
||||
if err != nil {
|
||||
|
|
@ -311,7 +334,7 @@ func wireInbound(ib *model.Inbound) url.Values {
|
|||
v.Set("port", strconv.Itoa(ib.Port))
|
||||
v.Set("protocol", string(ib.Protocol))
|
||||
v.Set("settings", ib.Settings)
|
||||
v.Set("streamSettings", ib.StreamSettings)
|
||||
v.Set("streamSettings", sanitizeStreamSettingsForRemote(ib.StreamSettings))
|
||||
v.Set("tag", ib.Tag)
|
||||
v.Set("sniffing", ib.Sniffing)
|
||||
if ib.TrafficReset != "" {
|
||||
|
|
@ -319,3 +342,44 @@ func wireInbound(ib *model.Inbound) url.Values {
|
|||
}
|
||||
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
119
web/service/api_token.go
Normal 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
|
||||
}
|
||||
|
|
@ -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": {
|
||||
"access": "none",
|
||||
"dnsLog": false,
|
||||
|
|
@ -6,39 +23,21 @@
|
|||
"loglevel": "warning",
|
||||
"maskAddress": ""
|
||||
},
|
||||
"api": {
|
||||
"tag": "api",
|
||||
"services": [
|
||||
"HandlerService",
|
||||
"LoggerService",
|
||||
"StatsService"
|
||||
]
|
||||
"metrics": {
|
||||
"listen": "127.0.0.1:11111",
|
||||
"tag": "metrics_out"
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "api",
|
||||
"listen": "127.0.0.1",
|
||||
"port": 62789,
|
||||
"protocol": "tunnel",
|
||||
"settings": {
|
||||
"address": "127.0.0.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "direct",
|
||||
"outbounds": [{
|
||||
"protocol": "freedom",
|
||||
"settings": {
|
||||
"domainStrategy": "AsIs",
|
||||
"redirect": "",
|
||||
"noises": []
|
||||
}
|
||||
"domainStrategy": "AsIs"
|
||||
},
|
||||
"tag": "direct"
|
||||
},
|
||||
{
|
||||
"tag": "blocked",
|
||||
"protocol": "blackhole",
|
||||
"settings": {}
|
||||
"settings": {},
|
||||
"tag": "blocked"
|
||||
}
|
||||
],
|
||||
"policy": {
|
||||
|
|
@ -57,33 +56,28 @@
|
|||
},
|
||||
"routing": {
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": [
|
||||
{
|
||||
"type": "field",
|
||||
"rules": [{
|
||||
"inboundTag": [
|
||||
"api"
|
||||
],
|
||||
"outboundTag": "api"
|
||||
"outboundTag": "api",
|
||||
"type": "field"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"outboundTag": "blocked",
|
||||
"ip": [
|
||||
"geoip:private"
|
||||
]
|
||||
],
|
||||
"outboundTag": "blocked",
|
||||
"type": "field"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"outboundTag": "blocked",
|
||||
"protocol": [
|
||||
"bittorrent"
|
||||
]
|
||||
],
|
||||
"type": "field"
|
||||
}
|
||||
]
|
||||
},
|
||||
"stats": {},
|
||||
"metrics": {
|
||||
"tag": "metrics_out",
|
||||
"listen": "127.0.0.1:11111"
|
||||
}
|
||||
}
|
||||
"stats": {}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/netsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -164,8 +165,7 @@ func CustomGeoLocalFileNeedsRepair(path string) bool {
|
|||
}
|
||||
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
|
||||
return netsafe.IsBlockedIP(ip)
|
||||
}
|
||||
|
||||
// checkSSRFDefault validates that the given host does not resolve to a private/internal IP.
|
||||
|
|
|
|||
|
|
@ -849,6 +849,9 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
|
|||
|
||||
// Secure client ID
|
||||
for _, client := range clients {
|
||||
if strings.TrimSpace(client.Email) == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
switch oldInbound.Protocol {
|
||||
case "trojan":
|
||||
if client.Password == "" {
|
||||
|
|
@ -1317,8 +1320,11 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
|||
if newClientId == "" || clientIndex == -1 {
|
||||
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)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
@ -2036,6 +2042,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
|
|||
traffics[traffic_index].Up = 0
|
||||
if !traffic.Enable {
|
||||
traffics[traffic_index].Enable = true
|
||||
c["enable"] = true
|
||||
clientsToAdd = append(clientsToAdd,
|
||||
struct {
|
||||
protocol string
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -13,6 +15,7 @@ import (
|
|||
"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/netsafe"
|
||||
"github.com/mhsanaei/3x-ui/v3/web/runtime"
|
||||
)
|
||||
|
||||
|
|
@ -34,6 +37,7 @@ var nodeHTTPClient = &http.Client{
|
|||
MaxIdleConns: 64,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
DialContext: netsafe.SSRFGuardedDialContext,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -69,14 +73,15 @@ func normalizeBasePath(p string) string {
|
|||
|
||||
func (s *NodeService) normalize(n *model.Node) error {
|
||||
n.Name = strings.TrimSpace(n.Name)
|
||||
n.Address = strings.TrimSpace(n.Address)
|
||||
n.ApiToken = strings.TrimSpace(n.ApiToken)
|
||||
if n.Name == "" {
|
||||
return common.NewError("node name is required")
|
||||
}
|
||||
if n.Address == "" {
|
||||
return common.NewError("node address is required")
|
||||
addr, err := netsafe.NormalizeHost(n.Address)
|
||||
if err != nil {
|
||||
return common.NewError(err.Error())
|
||||
}
|
||||
n.Address = addr
|
||||
if n.Port <= 0 || n.Port > 65535 {
|
||||
return common.NewError("node port must be 1-65535")
|
||||
}
|
||||
|
|
@ -105,14 +110,15 @@ func (s *NodeService) Update(id int, in *model.Node) error {
|
|||
return err
|
||||
}
|
||||
updates := map[string]any{
|
||||
"name": in.Name,
|
||||
"remark": in.Remark,
|
||||
"scheme": in.Scheme,
|
||||
"address": in.Address,
|
||||
"port": in.Port,
|
||||
"base_path": in.BasePath,
|
||||
"api_token": in.ApiToken,
|
||||
"enable": in.Enable,
|
||||
"name": in.Name,
|
||||
"remark": in.Remark,
|
||||
"scheme": in.Scheme,
|
||||
"address": in.Address,
|
||||
"port": in.Port,
|
||||
"base_path": in.BasePath,
|
||||
"api_token": in.ApiToken,
|
||||
"enable": in.Enable,
|
||||
"allow_private_address": in.AllowPrivateAddress,
|
||||
}
|
||||
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
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) {
|
||||
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 {
|
||||
patch.LastError = err.Error()
|
||||
return patch, err
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package service
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
|
@ -28,6 +29,11 @@ type PanelUpdateInfo struct {
|
|||
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 {
|
||||
p, err := os.FindProcess(syscall.Getpid())
|
||||
if err != nil {
|
||||
|
|
@ -67,13 +73,14 @@ func (s *PanelService) StartUpdate() error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("bash is required to run the panel updater: %w", err)
|
||||
}
|
||||
curl, err := exec.LookPath("curl")
|
||||
|
||||
scriptPath, err := downloadPanelUpdater()
|
||||
if err != nil {
|
||||
return fmt.Errorf("curl is required to download the panel updater: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
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))
|
||||
if !strings.Contains(output, "System has not been booted with systemd") &&
|
||||
!strings.Contains(output, "Failed to connect to bus") {
|
||||
_ = os.Remove(scriptPath)
|
||||
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)
|
||||
|
|
@ -104,6 +112,7 @@ func (s *PanelService) StartUpdate() error {
|
|||
)
|
||||
setDetachedProcess(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = os.Remove(scriptPath)
|
||||
return fmt.Errorf("failed to start panel update job: %w", err)
|
||||
}
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
|
|
@ -113,6 +122,44 @@ func (s *PanelService) StartUpdate() error {
|
|||
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) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
|
@ -493,6 +494,11 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) {
|
|||
|
||||
var xrayVersionsClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
const (
|
||||
maxXrayArchiveBytes = 200 << 20
|
||||
maxXrayBinaryBytes = 200 << 20
|
||||
)
|
||||
|
||||
func (s *ServerService) GetXrayVersions() ([]string, error) {
|
||||
const (
|
||||
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)
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
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.Create(fileName)
|
||||
file, err := os.CreateTemp("", "xray-*.zip")
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
if err := s.StopXrayService(); err != nil {
|
||||
logger.Warning("failed to stop xray before update:", err)
|
||||
|
|
@ -657,15 +688,42 @@ func (s *ServerService) UpdateXray(version string) error {
|
|||
return err
|
||||
}
|
||||
defer zipFile.Close()
|
||||
os.MkdirAll(filepath.Dir(fileName), 0755)
|
||||
os.Remove(fileName)
|
||||
file, err := os.OpenFile(fileName, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0755)
|
||||
if err := os.MkdirAll(filepath.Dir(fileName), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpFile, err := os.CreateTemp(filepath.Dir(fileName), ".xray-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = io.Copy(file, zipFile)
|
||||
return err
|
||||
tmpPath := tmpFile.Name()
|
||||
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
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
|
@ -36,6 +35,7 @@ var defaultValueMap = map[string]string{
|
|||
"apiToken": "",
|
||||
"webBasePath": "/",
|
||||
"sessionMaxAge": "360",
|
||||
"trustedProxyCIDRs": "127.0.0.1/32,::1/128",
|
||||
"pageSize": "25",
|
||||
"expireDiff": "0",
|
||||
"trafficDiff": "0",
|
||||
|
|
@ -70,6 +70,7 @@ var defaultValueMap = map[string]string{
|
|||
"subUpdates": "12",
|
||||
"subEncrypt": "true",
|
||||
"subShowInfo": "true",
|
||||
"subEmailInRemark": "true",
|
||||
"subURI": "",
|
||||
"subJsonPath": "/json/",
|
||||
"subJsonURI": "",
|
||||
|
|
@ -199,6 +200,35 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
|
|||
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 {
|
||||
db := database.GetDB()
|
||||
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 {
|
||||
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) {
|
||||
|
|
@ -417,6 +451,10 @@ func (s *SettingService) GetSessionMaxAge() (int, error) {
|
|||
return s.getInt("sessionMaxAge")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTrustedProxyCIDRs() (string, error) {
|
||||
return s.getString("trustedProxyCIDRs")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetRemarkModel() (string, error) {
|
||||
return s.getString("remarkModel")
|
||||
}
|
||||
|
|
@ -432,48 +470,6 @@ func (s *SettingService) GetSecret() ([]byte, error) {
|
|||
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 {
|
||||
if !strings.HasPrefix(basePath, "/") {
|
||||
basePath = "/" + basePath
|
||||
|
|
@ -597,6 +593,10 @@ func (s *SettingService) GetSubShowInfo() (bool, error) {
|
|||
return s.getBool("subShowInfo")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubEmailInRemark() (bool, error) {
|
||||
return s.getBool("subEmailInRemark")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetPageSize() (int, error) {
|
||||
return s.getInt("pageSize")
|
||||
}
|
||||
|
|
@ -771,6 +771,12 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, 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 {
|
||||
return err
|
||||
}
|
||||
|
|
@ -791,6 +797,58 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
|||
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) {
|
||||
var jsonData any
|
||||
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
|
||||
|
|
|
|||
93
web/service/setting_security_test.go
Normal file
93
web/service/setting_security_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -341,15 +341,12 @@ func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*tel
|
|||
|
||||
// Validate API server URL if provided
|
||||
if apiServerUrl != "" {
|
||||
if !strings.HasPrefix(apiServerUrl, "http") {
|
||||
logger.Warning("Invalid http(s) URL for API server, using default")
|
||||
safeURL, err := SanitizePublicHTTPURL(apiServerUrl, false)
|
||||
if err != nil {
|
||||
logger.Warningf("Invalid or blocked API server URL, using default: %v", err)
|
||||
apiServerUrl = ""
|
||||
} else {
|
||||
_, err := url.Parse(apiServerUrl)
|
||||
if err != nil {
|
||||
logger.Warningf("Can't parse API server URL, using default: %v", err)
|
||||
apiServerUrl = ""
|
||||
}
|
||||
apiServerUrl = safeURL
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
82
web/service/url_safety.go
Normal file
82
web/service/url_safety.go
Normal 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
|
||||
}
|
||||
|
|
@ -102,6 +102,14 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
|||
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 {
|
||||
db := database.GetDB()
|
||||
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{}).
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -150,5 +162,6 @@ func (s *UserService) UpdateFirstUser(username string, password string) error {
|
|||
}
|
||||
user.Username = username
|
||||
user.Password = hashedPassword
|
||||
user.LoginEpoch++
|
||||
return db.Save(user).Error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,10 +32,10 @@ type ObsTagSnapshot struct {
|
|||
type XrayMetricsService struct {
|
||||
settingService SettingService
|
||||
|
||||
mu sync.RWMutex
|
||||
state xrayMetricsState
|
||||
client *http.Client
|
||||
obsByTag map[string]ObsTagSnapshot
|
||||
mu sync.RWMutex
|
||||
state xrayMetricsState
|
||||
client *http.Client
|
||||
obsByTag map[string]ObsTagSnapshot
|
||||
}
|
||||
|
||||
var validObsTag = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ import (
|
|||
|
||||
const (
|
||||
loginUserKey = "LOGIN_USER"
|
||||
loginEpochKey = "LOGIN_EPOCH"
|
||||
apiAuthUserKey = "api_auth_user"
|
||||
sessionCookieName = "3x-ui"
|
||||
)
|
||||
|
|
@ -27,7 +29,8 @@ func SetLoginUser(c *gin.Context, user *model.User) error {
|
|||
return nil
|
||||
}
|
||||
s := sessions.Default(c)
|
||||
s.Set(loginUserKey, *user)
|
||||
s.Set(loginUserKey, user.Id)
|
||||
s.Set(loginEpochKey, user.LoginEpoch)
|
||||
return s.Save()
|
||||
}
|
||||
|
||||
|
|
@ -49,21 +52,113 @@ func GetLoginUser(c *gin.Context) *model.User {
|
|||
if obj == nil {
|
||||
return nil
|
||||
}
|
||||
user, ok := obj.(model.User)
|
||||
userID, ok := sessionUserID(obj)
|
||||
if !ok {
|
||||
s.Delete(loginUserKey)
|
||||
s.Delete(loginEpochKey)
|
||||
if err := s.Save(); err != nil {
|
||||
logger.Warning("session: failed to drop stale user payload:", err)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
s := sessions.Default(c)
|
||||
s.Clear()
|
||||
|
|
|
|||
47
web/session/session_test.go
Normal file
47
web/session/session_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "تم إعادة تعيين كل حركة المرور من العميل",
|
||||
"resetAllTrafficSuccess": "تم إعادة تعيين كل حركة المرور",
|
||||
"resetInboundClientTrafficSuccess": "تم إعادة تعيين حركة المرور",
|
||||
"resetInboundTrafficSuccess": "تم إعادة تعيين حركة مرور الداخل",
|
||||
"trafficGetError": "خطأ في الحصول على حركات المرور",
|
||||
"getNewX25519CertError": "حدث خطأ أثناء الحصول على شهادة X25519.",
|
||||
"getNewmldsa65Error": "حدث خطاء في الحصول على mldsa65.",
|
||||
|
|
@ -546,6 +547,8 @@
|
|||
"subEncryptDesc": "المحتوى اللي هيترجع من خدمة الاشتراك هيكون مشفر بـ Base64.",
|
||||
"subShowInfo": "اظهر معلومات الاستخدام",
|
||||
"subShowInfoDesc": "هيظهر الترافيك المتبقي والتاريخ في تطبيقات العملاء.",
|
||||
"subEmailInRemark": "تضمين البريد الإلكتروني في الاسم",
|
||||
"subEmailInRemarkDesc": "تضمين بريد العميل الإلكتروني في اسم ملف تعريف الاشتراك.",
|
||||
"subURI": "مسار البروكسي العكسي",
|
||||
"subURIDesc": "مسار URI لرابط الاشتراك عشان تستخدمه ورا البروكسي.",
|
||||
"externalTrafficInformEnable": "تنبيه الترافيك الخارجي",
|
||||
|
|
@ -588,7 +591,15 @@
|
|||
"twoFactorModalChangeCredentialsStep": "أدخل الرمز من التطبيق لتغيير بيانات اعتماد المسؤول.",
|
||||
"twoFactorModalSetSuccess": "تم إنشاء المصادقة الثنائية بنجاح",
|
||||
"twoFactorModalDeleteSuccess": "تم حذف المصادقة الثنائية بنجاح",
|
||||
"twoFactorModalError": "رمز خاطئ"
|
||||
"twoFactorModalError": "رمز خاطئ",
|
||||
"show": "إظهار",
|
||||
"hide": "إخفاء",
|
||||
"apiTokenNew": "رمز جديد",
|
||||
"apiTokenName": "الاسم",
|
||||
"apiTokenNamePlaceholder": "مثل central-panel-a",
|
||||
"apiTokenNameRequired": "الاسم مطلوب",
|
||||
"apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
|
||||
"apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا."
|
||||
},
|
||||
"toasts": {
|
||||
"modifySettings": "تم تغيير المعلمات.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "All traffic from the client has been reset.",
|
||||
"resetAllTrafficSuccess": "All traffic has been reset.",
|
||||
"resetInboundClientTrafficSuccess": "Traffic has been reset.",
|
||||
"resetInboundTrafficSuccess": "Inbound traffic has been reset.",
|
||||
"trafficGetError": "Error getting traffics.",
|
||||
"getNewX25519CertError": "Error while obtaining the X25519 certificate.",
|
||||
"getNewmldsa65Error": "Error while obtaining mldsa65.",
|
||||
|
|
@ -546,6 +547,8 @@
|
|||
"subEncryptDesc": "The returned content of subscription service will be Base64 encoded.",
|
||||
"subShowInfo": "Show Usage Info",
|
||||
"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",
|
||||
"subURIDesc": "The URI path of the subscription URL for use behind proxies.",
|
||||
"externalTrafficInformEnable": "External Traffic Inform",
|
||||
|
|
@ -588,7 +591,15 @@
|
|||
"twoFactorModalChangeCredentialsStep": "Enter the code from the application to change administrator credentials.",
|
||||
"twoFactorModalSetSuccess": "Two-factor authentication has been successfully established",
|
||||
"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": {
|
||||
"modifySettings": "The parameters have been changed.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "Todo el tráfico del cliente ha sido reiniciado",
|
||||
"resetAllTrafficSuccess": "Todo 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",
|
||||
"getNewX25519CertError": "Error al obtener el certificado X25519.",
|
||||
"getNewmldsa65Error": "Error al obtener el certificado mldsa65.",
|
||||
|
|
@ -546,6 +547,8 @@
|
|||
"subEncryptDesc": "Encriptar las configuraciones devueltas en la suscripción.",
|
||||
"subShowInfo": "Mostrar información de uso",
|
||||
"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",
|
||||
"externalTrafficInformEnable": "Informe de tráfico externo",
|
||||
"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.",
|
||||
"twoFactorModalSetSuccess": "La autenticación de dos factores se ha establecido 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": {
|
||||
"modifySettings": "Los parámetros han sido modificados.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "تمام ترافیک کلاینت بازنشانی شد",
|
||||
"resetAllTrafficSuccess": "تمام ترافیکها بازنشانی شدند",
|
||||
"resetInboundClientTrafficSuccess": "ترافیک بازنشانی شد",
|
||||
"resetInboundTrafficSuccess": "ترافیک ورودی بازنشانی شد",
|
||||
"trafficGetError": "خطا در دریافت ترافیکها",
|
||||
"getNewX25519CertError": "خطا در دریافت گواهی X25519.",
|
||||
"getNewmldsa65Error": "خطا در دریافت گواهی mldsa65.",
|
||||
|
|
@ -546,6 +547,8 @@
|
|||
"subEncryptDesc": "کدگذاری خواهدشد Base64 محتوای برگشتی سرویس سابسکریپشن برپایه",
|
||||
"subShowInfo": "نمایش اطلاعات مصرف",
|
||||
"subShowInfoDesc": "ترافیک و زمان باقیمانده را در برنامههای کاربری نمایش میدهد",
|
||||
"subEmailInRemark": "گنجاندن ایمیل در نام",
|
||||
"subEmailInRemarkDesc": "ایمیل کاربر در نام پروفایل اشتراک گنجانده میشود.",
|
||||
"subURI": "پروکسی معکوس URI مسیر",
|
||||
"subURIDesc": "سابسکریپشن را برای استفاده در پشت پراکسیها تغییر میدهد URI مسیر",
|
||||
"externalTrafficInformEnable": "اطلاع رسانی خارجی مصرف ترافیک",
|
||||
|
|
@ -588,7 +591,15 @@
|
|||
"twoFactorModalChangeCredentialsStep": "برای تغییر اعتبارنامههای مدیر، کد را از برنامه وارد کنید.",
|
||||
"twoFactorModalSetSuccess": "احراز هویت دو مرحلهای با موفقیت برقرار شد",
|
||||
"twoFactorModalDeleteSuccess": "احراز هویت دو مرحلهای با موفقیت حذف شد",
|
||||
"twoFactorModalError": "کد نادرست"
|
||||
"twoFactorModalError": "کد نادرست",
|
||||
"show": "نمایش",
|
||||
"hide": "پنهان",
|
||||
"apiTokenNew": "توکن جدید",
|
||||
"apiTokenName": "نام",
|
||||
"apiTokenNamePlaceholder": "مثلاً central-panel-a",
|
||||
"apiTokenNameRequired": "نام الزامی است",
|
||||
"apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت رباتها یا پنلهای راه دور یکی بسازید.",
|
||||
"apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده میکند بلافاصله احراز هویتش قطع میشود."
|
||||
},
|
||||
"toasts": {
|
||||
"modifySettings": "پارامترها تغییر کردهاند.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "Semua lalu lintas klien telah direset",
|
||||
"resetAllTrafficSuccess": "Semua lalu lintas telah direset",
|
||||
"resetInboundClientTrafficSuccess": "Lalu lintas telah direset",
|
||||
"resetInboundTrafficSuccess": "Lalu lintas masuk telah direset",
|
||||
"trafficGetError": "Gagal mendapatkan data lalu lintas",
|
||||
"getNewX25519CertError": "Terjadi kesalahan saat mendapatkan sertifikat X25519.",
|
||||
"getNewmldsa65Error": "Terjadi kesalahan saat mendapatkan sertifikat mldsa65.",
|
||||
|
|
@ -546,6 +547,8 @@
|
|||
"subEncryptDesc": "Konten yang dikembalikan dari layanan langganan akan dienkripsi Base64.",
|
||||
"subShowInfo": "Tampilkan Info Penggunaan",
|
||||
"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",
|
||||
"subURIDesc": "Path URI dari URL langganan untuk digunakan di belakang proxy.",
|
||||
"externalTrafficInformEnable": "Informasikan API eksternal pada setiap pembaruan lalu lintas.",
|
||||
|
|
@ -588,7 +591,15 @@
|
|||
"twoFactorModalChangeCredentialsStep": "Masukkan kode dari aplikasi untuk mengubah kredensial administrator.",
|
||||
"twoFactorModalSetSuccess": "Autentikasi dua faktor telah berhasil dibuat",
|
||||
"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": {
|
||||
"modifySettings": "Parameter telah diubah.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "クライアントのすべてのトラフィックがリセットされました",
|
||||
"resetAllTrafficSuccess": "すべてのトラフィックがリセットされました",
|
||||
"resetInboundClientTrafficSuccess": "トラフィックがリセットされました",
|
||||
"resetInboundTrafficSuccess": "受信トラフィックがリセットされました",
|
||||
"trafficGetError": "トラフィックの取得中にエラーが発生しました",
|
||||
"getNewX25519CertError": "X25519証明書の取得中にエラーが発生しました。",
|
||||
"getNewmldsa65Error": "mldsa65証明書の取得中にエラーが発生しました。",
|
||||
|
|
@ -546,6 +547,8 @@
|
|||
"subEncryptDesc": "サブスクリプションサービスが返す内容をBase64エンコードする",
|
||||
"subShowInfo": "利用情報を表示",
|
||||
"subShowInfoDesc": "クライアントアプリで残りのトラフィックと日付情報を表示する",
|
||||
"subEmailInRemark": "名前にメールを含める",
|
||||
"subEmailInRemarkDesc": "サブスクリプションプロファイル名にクライアントのメールアドレスを含めます。",
|
||||
"subURI": "リバースプロキシURI",
|
||||
"subURIDesc": "プロキシ後ろのサブスクリプションURLのURIパスに使用する",
|
||||
"externalTrafficInformEnable": "外部トラフィック情報",
|
||||
|
|
@ -588,7 +591,15 @@
|
|||
"twoFactorModalChangeCredentialsStep": "管理者の認証情報を変更するには、アプリケーションからコードを入力してください。",
|
||||
"twoFactorModalSetSuccess": "二要素認証が正常に設定されました",
|
||||
"twoFactorModalDeleteSuccess": "二要素認証が正常に削除されました",
|
||||
"twoFactorModalError": "コードが間違っています"
|
||||
"twoFactorModalError": "コードが間違っています",
|
||||
"show": "表示",
|
||||
"hide": "非表示",
|
||||
"apiTokenNew": "新規トークン",
|
||||
"apiTokenName": "名前",
|
||||
"apiTokenNamePlaceholder": "例: central-panel-a",
|
||||
"apiTokenNameRequired": "名前は必須です",
|
||||
"apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
|
||||
"apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。"
|
||||
},
|
||||
"toasts": {
|
||||
"modifySettings": "パラメーターが変更されました。",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "Todo o tráfego do cliente foi reiniciado",
|
||||
"resetAllTrafficSuccess": "Todo 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",
|
||||
"getNewX25519CertError": "Erro ao obter o certificado X25519.",
|
||||
"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.",
|
||||
"subShowInfo": "Mostrar Informações de Uso",
|
||||
"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",
|
||||
"subURIDesc": "O caminho URI da URL de assinatura para uso por trás de proxies.",
|
||||
"externalTrafficInformEnable": "Informações de tráfego externo",
|
||||
|
|
@ -588,7 +591,15 @@
|
|||
"twoFactorModalChangeCredentialsStep": "Insira o código do aplicativo para alterar as credenciais do administrador.",
|
||||
"twoFactorModalSetSuccess": "A autenticação de dois fatores foi estabelecida 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": {
|
||||
"modifySettings": "Os parâmetros foram alterados.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "Весь трафик клиента сброшен",
|
||||
"resetAllTrafficSuccess": "Весь трафик сброшен",
|
||||
"resetInboundClientTrafficSuccess": "Трафик сброшен",
|
||||
"resetInboundTrafficSuccess": "Входящий трафик сброшен",
|
||||
"trafficGetError": "Ошибка получения данных о трафике",
|
||||
"getNewX25519CertError": "Ошибка при получении сертификата X25519.",
|
||||
"getNewmldsa65Error": "Ошибка при получении сертификата mldsa65.",
|
||||
|
|
@ -546,6 +547,8 @@
|
|||
"subEncryptDesc": "Шифровать возвращенные конфиги в подписке",
|
||||
"subShowInfo": "Показать информацию об использовании",
|
||||
"subShowInfoDesc": "Отображать остаток трафика и дату окончания после имени конфигурации",
|
||||
"subEmailInRemark": "Включать Email в название",
|
||||
"subEmailInRemarkDesc": "Включать email клиента в название профиля подписки.",
|
||||
"subURI": "URI обратного прокси",
|
||||
"subURIDesc": "Изменить базовый URI URL-адреса подписки для использования за прокси-серверами",
|
||||
"externalTrafficInformEnable": "Информация о внешнем трафике",
|
||||
|
|
@ -588,7 +591,15 @@
|
|||
"twoFactorModalChangeCredentialsStep": "Введите код из приложения, чтобы изменить учетные данные администратора.",
|
||||
"twoFactorModalSetSuccess": "Двухфакторная аутентификация была успешно установлена",
|
||||
"twoFactorModalDeleteSuccess": "Двухфакторная аутентификация была успешно удалена",
|
||||
"twoFactorModalError": "Неверный код"
|
||||
"twoFactorModalError": "Неверный код",
|
||||
"show": "Показать",
|
||||
"hide": "Скрыть",
|
||||
"apiTokenNew": "Новый токен",
|
||||
"apiTokenName": "Имя",
|
||||
"apiTokenNamePlaceholder": "например, central-panel-a",
|
||||
"apiTokenNameRequired": "Имя обязательно",
|
||||
"apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
|
||||
"apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию."
|
||||
},
|
||||
"toasts": {
|
||||
"modifySettings": "Настройки изменены",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "İstemcinin tüm trafiği sıfırlandı",
|
||||
"resetAllTrafficSuccess": "Tüm trafik sıfırlandı",
|
||||
"resetInboundClientTrafficSuccess": "Trafik sıfırlandı",
|
||||
"resetInboundTrafficSuccess": "Gelen trafik sıfırlandı",
|
||||
"trafficGetError": "Trafik bilgisi alınırken hata oluştu",
|
||||
"getNewX25519CertError": "X25519 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.",
|
||||
"subShowInfo": "Kullanım Bilgisini Göster",
|
||||
"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",
|
||||
"subURIDesc": "Proxy arkasında kullanılacak abonelik URL'sinin URI yolu.",
|
||||
"externalTrafficInformEnable": "Harici Trafik Bilgisi",
|
||||
|
|
@ -588,7 +591,15 @@
|
|||
"twoFactorModalChangeCredentialsStep": "Yönetici kimlik bilgilerini değiştirmek için uygulamadaki kodu girin.",
|
||||
"twoFactorModalSetSuccess": "İki faktörlü kimlik doğrulama başarıyla kuruldu",
|
||||
"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": {
|
||||
"modifySettings": "Parametreler değiştirildi.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "Весь трафік клієнта скинуто",
|
||||
"resetAllTrafficSuccess": "Весь трафік скинуто",
|
||||
"resetInboundClientTrafficSuccess": "Трафік скинуто",
|
||||
"resetInboundTrafficSuccess": "Трафік вхідного потоку скинуто",
|
||||
"trafficGetError": "Помилка отримання даних про трафік",
|
||||
"getNewX25519CertError": "Помилка при отриманні сертифіката X25519.",
|
||||
"getNewmldsa65Error": "Помилка при отриманні сертифіката mldsa65.",
|
||||
|
|
@ -546,6 +547,8 @@
|
|||
"subEncryptDesc": "Повернений вміст послуги підписки матиме кодування Base64.",
|
||||
"subShowInfo": "Показати інформацію про використання",
|
||||
"subShowInfoDesc": "Залишок трафіку та дата відображатимуться в клієнтських програмах.",
|
||||
"subEmailInRemark": "Включати Email до назви",
|
||||
"subEmailInRemarkDesc": "Включати email клієнта до назви профілю підписки.",
|
||||
"subURI": "URI зворотного проксі",
|
||||
"subURIDesc": "URI до URL-адреси підписки для використання за проксі.",
|
||||
"externalTrafficInformEnable": "Інформація про зовнішній трафік",
|
||||
|
|
@ -588,7 +591,15 @@
|
|||
"twoFactorModalChangeCredentialsStep": "Введіть код з додатку, щоб змінити облікові дані адміністратора.",
|
||||
"twoFactorModalSetSuccess": "Двофакторна аутентифікація була успішно встановлена",
|
||||
"twoFactorModalDeleteSuccess": "Двофакторна аутентифікація була успішно видалена",
|
||||
"twoFactorModalError": "Невірний код"
|
||||
"twoFactorModalError": "Невірний код",
|
||||
"show": "Показати",
|
||||
"hide": "Сховати",
|
||||
"apiTokenNew": "Новий токен",
|
||||
"apiTokenName": "Назва",
|
||||
"apiTokenNamePlaceholder": "наприклад, central-panel-a",
|
||||
"apiTokenNameRequired": "Назва обов'язкова",
|
||||
"apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
|
||||
"apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію."
|
||||
},
|
||||
"toasts": {
|
||||
"modifySettings": "Параметри було змінено.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng client",
|
||||
"resetAllTrafficSuccess": "Đã đặt lại toàn bộ 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",
|
||||
"getNewX25519CertError": "Lỗi khi lấy chứng chỉ X25519.",
|
||||
"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ý",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
|
|
@ -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.",
|
||||
"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",
|
||||
"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": {
|
||||
"modifySettings": "Các tham số đã được thay đổi.",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue