mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 13:14:11 +00:00
Merge branch 'main' into fix/node-tag-unique-scope
This commit is contained in:
commit
41faaa2b25
99 changed files with 3382 additions and 409 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -128,15 +125,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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -87,6 +88,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 +104,4 @@ export class AllSetting {
|
|||
equals(other) {
|
||||
return ObjectUtil.equals(this, other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
|
|
@ -8,13 +8,27 @@ import {
|
|||
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';
|
||||
import CodeBlock from './CodeBlock.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -26,11 +40,69 @@ 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 = {
|
||||
auth: SafetyCertificateOutlined,
|
||||
inbounds: NodeIndexOutlined,
|
||||
server: CloudServerOutlined,
|
||||
nodes: ClusterOutlined,
|
||||
customGeo: GlobalOutlined,
|
||||
backup: SaveOutlined,
|
||||
settings: SettingOutlined,
|
||||
xraySettings: 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`;
|
||||
|
||||
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 toggleSection(id) {
|
||||
const s = new Set(collapsedSections.value);
|
||||
if (s.has(id)) s.delete(id); else s.add(id);
|
||||
collapsedSections.value = s;
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
collapsedSections.value = new Set();
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
collapsedSections.value = new Set(allSections.map(s => s.id));
|
||||
}
|
||||
|
||||
async function loadApiToken() {
|
||||
tokenLoading.value = true;
|
||||
try {
|
||||
|
|
@ -64,7 +136,7 @@ function regenerateApiToken() {
|
|||
|
||||
async function copyApiToken() {
|
||||
if (!apiToken.value) return;
|
||||
const ok = await ClipboardManager.copy(apiToken.value);
|
||||
const ok = await ClipboardManager.copyText(apiToken.value);
|
||||
if (ok) message.success(t('success'));
|
||||
}
|
||||
|
||||
|
|
@ -73,8 +145,33 @@ function scrollToSection(id) {
|
|||
if (el) el.scrollIntoView({ behavior: 'smooth', 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 });
|
||||
onScroll();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (scrollObserver) {
|
||||
window.removeEventListener('scroll', scrollObserver);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -93,6 +190,7 @@ 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">
|
||||
|
|
@ -101,7 +199,7 @@ onMounted(() => {
|
|||
<KeyOutlined />
|
||||
<span>API Token</span>
|
||||
</div>
|
||||
<a-space size="small" wrap>
|
||||
<div class="token-actions">
|
||||
<a-button size="small" @click="tokenVisible = !tokenVisible">
|
||||
<template #icon>
|
||||
<EyeInvisibleOutlined v-if="tokenVisible" />
|
||||
|
|
@ -121,7 +219,7 @@ onMounted(() => {
|
|||
</template>
|
||||
Regenerate
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
<a-spin :spinning="tokenLoading" size="small">
|
||||
<pre
|
||||
|
|
@ -135,18 +233,59 @@ onMounted(() => {
|
|||
</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 +333,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 +375,8 @@ onMounted(() => {
|
|||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.token-card-title {
|
||||
|
|
@ -242,6 +387,13 @@ onMounted(() => {
|
|||
font-size: 14px;
|
||||
}
|
||||
|
||||
.token-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
|
|
@ -275,35 +427,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,16 +539,40 @@ 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);
|
||||
}
|
||||
|
||||
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 .token-value,
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
|
|
@ -329,11 +580,58 @@ body.dark .code-block {
|
|||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .token-value,
|
||||
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,3 +1,28 @@
|
|||
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',
|
||||
|
|
@ -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 a new VLESS encryption keypair.',
|
||||
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,8 +568,8 @@ 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.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -472,7 +577,7 @@ export const sections = [
|
|||
|
||||
{
|
||||
id: 'customGeo',
|
||||
title: 'Custom Geo API',
|
||||
title: 'Custom Geo',
|
||||
description:
|
||||
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
|
||||
endpoints: [
|
||||
|
|
@ -531,12 +636,234 @@ 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, user credentials, and API token management. 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.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/setting/getApiToken',
|
||||
summary: 'Return the current API Bearer token. The token is auto-generated on first read so existing installs upgrade transparently.',
|
||||
response: '{\n "success": true,\n "obj": "abcdef-12345-..."\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/setting/regenerateApiToken',
|
||||
summary: 'Rotate the API Bearer token. Any remote central panel that cached the old value will start failing heartbeats until updated with the new token.',
|
||||
response: '{\n "success": true,\n "obj": "new-token-string"\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'xraySettings',
|
||||
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 +872,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 '';
|
||||
|
|
|
|||
185
frontend/src/pages/inbounds/CopyClientsModal.vue
Normal file
185
frontend/src/pages/inbounds/CopyClientsModal.vue
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { HttpUtil, SizeFormatter, IntlUtil } from '@/utils';
|
||||
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
dbInbound: { type: Object, default: null },
|
||||
dbInbounds: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
const sourceInboundId = ref(null);
|
||||
const selectedEmails = ref([]);
|
||||
const flow = ref('');
|
||||
const saving = ref(false);
|
||||
|
||||
const sources = computed(() => {
|
||||
if (!props.dbInbound) return [];
|
||||
return props.dbInbounds
|
||||
.filter(
|
||||
(row) =>
|
||||
row.id !== props.dbInbound.id &&
|
||||
typeof row.isMultiUser === 'function' &&
|
||||
row.isMultiUser(),
|
||||
)
|
||||
.map((row) => {
|
||||
let count = 0;
|
||||
try { count = (row.toInbound().clients || []).length; } catch (_e) { /* ignore */ }
|
||||
return { id: row.id, label: `${row.remark || `#${row.id}`} (${row.protocol}, ${count})` };
|
||||
});
|
||||
});
|
||||
|
||||
const sourceInbound = computed(() => {
|
||||
if (!sourceInboundId.value) return null;
|
||||
return props.dbInbounds.find((r) => r.id === sourceInboundId.value) || null;
|
||||
});
|
||||
|
||||
const sourceClients = computed(() => {
|
||||
const sb = sourceInbound.value;
|
||||
if (!sb) return [];
|
||||
let list = [];
|
||||
try { list = sb.toInbound().clients || []; } catch (_e) { /* ignore */ }
|
||||
const stats = new Map((sb.clientStats || []).map((s) => [s.email, s]));
|
||||
return list
|
||||
.filter((c) => c.email)
|
||||
.map((c) => {
|
||||
const s = stats.get(c.email);
|
||||
const used = s ? (s.up || 0) + (s.down || 0) : 0;
|
||||
let expiryLabel = t('unlimited');
|
||||
if (c.expiryTime > 0) expiryLabel = IntlUtil.formatDate(c.expiryTime);
|
||||
else if (c.expiryTime < 0) expiryLabel = `${-c.expiryTime / 86400000}d`;
|
||||
return { email: c.email, trafficLabel: SizeFormatter.sizeFormat(used), expiryLabel };
|
||||
});
|
||||
});
|
||||
|
||||
const showFlow = computed(() => {
|
||||
if (!props.dbInbound) return false;
|
||||
try {
|
||||
const inb = props.dbInbound.toInbound();
|
||||
return !!(inb && typeof inb.canEnableTlsFlow === 'function' && inb.canEnableTlsFlow());
|
||||
} catch (_e) { return false; }
|
||||
});
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: t('pages.inbounds.email'), dataIndex: 'email', width: 280 },
|
||||
{ title: t('pages.inbounds.traffic'), dataIndex: 'trafficLabel', width: 140 },
|
||||
{ title: t('pages.inbounds.expireDate'), dataIndex: 'expiryLabel', width: 160 },
|
||||
]);
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedEmails.value,
|
||||
onChange: (keys) => { selectedEmails.value = keys; },
|
||||
}));
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.dbInbound) return t('pages.client.copyFromInbound');
|
||||
const target = props.dbInbound.remark || `#${props.dbInbound.id}`;
|
||||
return `${t('pages.client.copyToInbound')} ${target}`;
|
||||
});
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
sourceInboundId.value = null;
|
||||
selectedEmails.value = [];
|
||||
flow.value = '';
|
||||
saving.value = false;
|
||||
});
|
||||
|
||||
watch(sourceInboundId, () => {
|
||||
selectedEmails.value = [];
|
||||
});
|
||||
|
||||
function selectAll() {
|
||||
selectedEmails.value = sourceClients.value.map((c) => c.email);
|
||||
}
|
||||
function clearAll() {
|
||||
selectedEmails.value = [];
|
||||
}
|
||||
|
||||
async function ok() {
|
||||
if (!sourceInboundId.value) {
|
||||
message.error(t('pages.client.copySelectSourceFirst'));
|
||||
return;
|
||||
}
|
||||
if (!props.dbInbound) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
sourceInboundId: sourceInboundId.value,
|
||||
clientEmails: selectedEmails.value,
|
||||
};
|
||||
if (showFlow.value && flow.value) payload.flow = flow.value;
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/inbounds/${props.dbInbound.id}/copyClients`,
|
||||
payload,
|
||||
);
|
||||
if (!msg?.success) return;
|
||||
const obj = msg.obj || {};
|
||||
const addedCount = (obj.added || []).length;
|
||||
const errorList = obj.errors || [];
|
||||
if (addedCount > 0) {
|
||||
message.success(`${t('pages.client.copyResultSuccess')}: ${addedCount}`);
|
||||
} else {
|
||||
message.warning(t('pages.client.copyResultNone'));
|
||||
}
|
||||
if (errorList.length > 0) {
|
||||
message.error(`${t('pages.client.copyResultErrors')}: ${errorList.join('; ')}`);
|
||||
}
|
||||
emit('saved');
|
||||
emit('update:open', false);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (saving.value) return;
|
||||
emit('update:open', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :ok-text="t('pages.client.copySelected')" :cancel-text="t('close')"
|
||||
:confirm-loading="saving" :mask-closable="false" width="720px" @ok="ok" @cancel="close">
|
||||
<a-space direction="vertical" :style="{ width: '100%' }">
|
||||
<div>
|
||||
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copySource') }}</div>
|
||||
<a-select v-model:value="sourceInboundId" :style="{ width: '100%' }" allow-clear>
|
||||
<a-select-option v-for="item in sources" :key="item.id" :value="item.id">
|
||||
{{ item.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<div v-if="sourceInboundId">
|
||||
<a-space :style="{ marginBottom: '8px' }">
|
||||
<a-button size="small" @click="selectAll">{{ t('pages.client.selectAll') }}</a-button>
|
||||
<a-button size="small" @click="clearAll">{{ t('pages.client.clearAll') }}</a-button>
|
||||
</a-space>
|
||||
<a-table :columns="columns" :data-source="sourceClients" :pagination="false" size="small"
|
||||
:row-key="(r) => r.email" :row-selection="rowSelection" :scroll="{ y: 280 }" />
|
||||
</div>
|
||||
|
||||
<div v-if="showFlow">
|
||||
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copyFlowLabel') }}</div>
|
||||
<a-select v-model:value="flow" :style="{ width: '100%' }" allow-clear>
|
||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||
</a-select>
|
||||
<div :style="{ marginTop: '4px', fontSize: '12px', opacity: 0.7 }">
|
||||
{{ t('pages.client.copyFlowHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</a-space>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
SizeFormatter,
|
||||
Wireguard,
|
||||
} from '@/utils';
|
||||
import { getRandomRealityTarget } from '@/models/reality-targets';
|
||||
import {
|
||||
Inbound,
|
||||
Protocols,
|
||||
|
|
@ -339,11 +340,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;
|
||||
}
|
||||
|
|
@ -393,16 +392,29 @@ async function fetchDefaultCertSettings() {
|
|||
}
|
||||
|
||||
// === VLESS encryption helpers =======================================
|
||||
// `xray vlessenc` returns both X25519 and ML-KEM-768 variants every
|
||||
// call; the user clicks one of two buttons to pick which block goes
|
||||
// into decryption/encryption.
|
||||
async function getNewVlessEnc(authLabel) {
|
||||
if (!authLabel || !inbound.value?.settings) return;
|
||||
// `xray vlessenc` returns both X25519 and ML-KEM-768 auth variants every
|
||||
// call; the user clicks one button to pick which block goes into
|
||||
// decryption/encryption. Both generated strings share the same hybrid
|
||||
// mlkem768x25519plus prefix; the auth choice is the final key block.
|
||||
function normalizeVlessAuthLabel(label = '') {
|
||||
return label.toLowerCase().replace(/[-_\s]/g, '');
|
||||
}
|
||||
|
||||
function matchesVlessAuth(block, authId) {
|
||||
if (block?.id === authId) return true;
|
||||
const label = normalizeVlessAuthLabel(block?.label);
|
||||
if (authId === 'mlkem768') return label.includes('mlkem768');
|
||||
if (authId === 'x25519') return label.includes('x25519');
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getNewVlessEnc(authId) {
|
||||
if (!authId || !inbound.value?.settings) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
|
||||
if (!msg?.success) return;
|
||||
const block = (msg.obj?.auths || []).find((a) => a.label === authLabel);
|
||||
const block = (msg.obj?.auths || []).find((a) => matchesVlessAuth(a, authId));
|
||||
if (!block) return;
|
||||
inbound.value.settings.decryption = block.decryption;
|
||||
inbound.value.settings.encryption = block.encryption;
|
||||
|
|
@ -417,6 +429,17 @@ function clearVlessEnc() {
|
|||
inbound.value.settings.encryption = 'none';
|
||||
}
|
||||
|
||||
const selectedVlessAuth = computed(() => {
|
||||
const encryption = inbound.value?.settings?.encryption;
|
||||
if (!encryption || encryption === 'none') return 'None';
|
||||
|
||||
const parts = encryption.split('.').filter(Boolean);
|
||||
const authKey = parts[parts.length - 1] || '';
|
||||
if (!authKey) return 'Custom';
|
||||
|
||||
return authKey.length > 300 ? 'ML-KEM-768 auth' : 'X25519 auth';
|
||||
});
|
||||
|
||||
// === SS method change tracks legacy semantics =========================
|
||||
function onSSMethodChange() {
|
||||
inbound.value.settings.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
|
||||
|
|
@ -731,14 +754,17 @@ watch(
|
|||
</a-form-item>
|
||||
<a-form-item label=" ">
|
||||
<a-space :size="8" wrap>
|
||||
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('X25519, not Post-Quantum')">
|
||||
X25519
|
||||
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('x25519')">
|
||||
X25519 auth
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('ML-KEM-768, Post-Quantum')">
|
||||
ML-KEM-768
|
||||
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('mlkem768')">
|
||||
ML-KEM-768 auth
|
||||
</a-button>
|
||||
<a-button danger @click="clearVlessEnc">Clear</a-button>
|
||||
</a-space>
|
||||
<a-typography-text type="secondary" class="vless-auth-state">
|
||||
Selected: {{ selectedVlessAuth }}
|
||||
</a-typography-text>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
|
|
@ -1644,6 +1670,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) ====== -->
|
||||
|
|
@ -1741,6 +1835,11 @@ watch(
|
|||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.vless-auth-state {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.json-editor {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
PlusOutlined,
|
||||
|
|
@ -67,9 +67,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 +97,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 +148,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 +156,65 @@ 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 =================================================
|
||||
const sortState = ref({ column: null, order: null });
|
||||
|
||||
function sortableCol(col, key) {
|
||||
return {
|
||||
...col,
|
||||
sorter: true,
|
||||
showSorterTooltip: false,
|
||||
sortOrder: sortState.value.column === key ? sortState.value.order : null,
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
};
|
||||
}
|
||||
|
||||
const sortFns = {
|
||||
id: (a, b) => a.id - b.id,
|
||||
enable: (a, b) => Number(a.enable) - Number(b.enable),
|
||||
remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
|
||||
port: (a, b) => a.port - b.port,
|
||||
protocol: (a, b) => a.protocol.localeCompare(b.protocol),
|
||||
traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
|
||||
allTimeInbound: (a, b) => (a.allTime || 0) - (b.allTime || 0),
|
||||
expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
|
||||
node: (a, b) => {
|
||||
const nameA = props.nodesById.get(a.nodeId)?.name ?? (a.nodeId == null ? '\uffff' : `node #${a.nodeId}`);
|
||||
const nameB = props.nodesById.get(b.nodeId)?.name ?? (b.nodeId == null ? '\uffff' : `node #${b.nodeId}`);
|
||||
return nameA.localeCompare(nameB);
|
||||
},
|
||||
clients: (a, b) => (props.clientCount[a.id]?.clients || 0) - (props.clientCount[b.id]?.clients || 0),
|
||||
};
|
||||
|
||||
const sortedInbounds = computed(() => {
|
||||
const { column, order } = sortState.value;
|
||||
if (!column || !order) return visibleInbounds.value;
|
||||
const fn = sortFns[column];
|
||||
if (!fn) return visibleInbounds.value;
|
||||
const sorted = [...visibleInbounds.value].sort(fn);
|
||||
return order === 'descend' ? sorted.reverse() : sorted;
|
||||
});
|
||||
|
||||
function onTableChange(_pag, _filters, sorter) {
|
||||
sortState.value = {
|
||||
column: sorter?.columnKey || sorter?.field || null,
|
||||
order: sorter?.order || null,
|
||||
};
|
||||
}
|
||||
|
||||
watch([searchKey, filterBy], () => {
|
||||
sortState.value = { column: null, order: null };
|
||||
});
|
||||
|
||||
// ============ Columns =================================================
|
||||
|
|
@ -128,23 +227,23 @@ const hasAnyRemark = computed(() =>
|
|||
|
||||
const desktopColumns = computed(() => {
|
||||
const cols = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 },
|
||||
sortableCol({ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 }, 'id'),
|
||||
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
|
||||
{ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
|
||||
sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'),
|
||||
];
|
||||
if (hasAnyRemark.value) {
|
||||
cols.push({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 });
|
||||
cols.push(sortableCol({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, 'remark'));
|
||||
}
|
||||
if (props.nodesById.size > 0) {
|
||||
cols.push({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 });
|
||||
cols.push(sortableCol({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 }, 'node'));
|
||||
}
|
||||
cols.push(
|
||||
{ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
|
||||
{ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 },
|
||||
{ title: t('clients'), key: 'clients', align: 'left', width: 50 },
|
||||
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
|
||||
{ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 },
|
||||
{ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
|
||||
sortableCol({ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, 'port'),
|
||||
sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
|
||||
sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
|
||||
sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
|
||||
sortableCol({ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, 'allTimeInbound'),
|
||||
sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
|
||||
);
|
||||
return cols;
|
||||
});
|
||||
|
|
@ -269,13 +368,25 @@ 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="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 ======================= -->
|
||||
<div v-if="isMobile" class="inbound-cards">
|
||||
<div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
|
||||
|
||||
<div v-for="record in visibleInbounds" :key="record.id" class="inbound-card">
|
||||
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
|
||||
<!-- Header: chevron (multi-user only) + remark + enable + actions -->
|
||||
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
|
||||
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
|
||||
|
|
@ -345,8 +456,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>
|
||||
|
|
@ -380,7 +491,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>
|
||||
|
|
@ -407,7 +518,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)"
|
||||
|
|
@ -419,9 +530,9 @@ function showQrCodeMenu(dbInbound) {
|
|||
</div>
|
||||
|
||||
<!-- ====================== Desktop: a-table ======================== -->
|
||||
<a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
|
||||
:pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
||||
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
|
||||
<a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
|
||||
:pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
||||
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
|
||||
<!-- Per-inbound client list, expanded by clicking the row's
|
||||
default expand chevron. Hidden via row-class-name for
|
||||
non-multi-user inbounds (matches legacy behavior). -->
|
||||
|
|
@ -429,6 +540,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)"
|
||||
|
|
@ -520,8 +632,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>
|
||||
|
|
@ -531,14 +643,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>
|
||||
|
|
@ -546,7 +658,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')">
|
||||
|
|
@ -555,7 +667,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')">
|
||||
|
|
@ -564,7 +676,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>
|
||||
|
|
@ -630,7 +742,7 @@ function showQrCodeMenu(dbInbound) {
|
|||
}
|
||||
|
||||
.filter-bar.mobile>* {
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.protocol-tags {
|
||||
|
|
@ -639,6 +751,10 @@ function showQrCodeMenu(dbInbound) {
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-count-tag {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import InboundList from './InboundList.vue';
|
|||
import InboundFormModal from './InboundFormModal.vue';
|
||||
import ClientFormModal from './ClientFormModal.vue';
|
||||
import ClientBulkModal from './ClientBulkModal.vue';
|
||||
import CopyClientsModal from './CopyClientsModal.vue';
|
||||
import InboundInfoModal from './InboundInfoModal.vue';
|
||||
import QrCodeModal from './QrCodeModal.vue';
|
||||
import TextModal from '@/components/TextModal.vue';
|
||||
|
|
@ -88,6 +89,8 @@ const clientIndex = ref(null);
|
|||
|
||||
const bulkOpen = ref(false);
|
||||
const bulkDbInbound = ref(null);
|
||||
const copyOpen = ref(false);
|
||||
const copyDbInbound = ref(null);
|
||||
|
||||
// === Info / QR-code modals ===========================================
|
||||
const infoOpen = ref(false);
|
||||
|
|
@ -393,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();
|
||||
},
|
||||
});
|
||||
|
|
@ -515,10 +518,8 @@ function onRowAction({ key, dbInbound }) {
|
|||
exportInboundClipboard(dbInbound);
|
||||
break;
|
||||
case 'copyClients':
|
||||
// Copy-clients-from-inbound is a tiny dedicated modal in legacy
|
||||
// (lets you tick clients to copy across inbounds). Defer to a
|
||||
// future commit — surface a friendly message for now.
|
||||
message.info('Copy clients across inbounds — coming soon');
|
||||
copyDbInbound.value = dbInbound;
|
||||
copyOpen.value = true;
|
||||
break;
|
||||
case 'delete':
|
||||
confirmDelete(dbInbound);
|
||||
|
|
@ -663,6 +664,8 @@ function onRowAction({ key, dbInbound }) {
|
|||
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
||||
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
|
||||
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
|
||||
<CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
|
||||
@saved="refresh" />
|
||||
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
||||
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
||||
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
EditOutlined,
|
||||
|
|
@ -7,6 +7,8 @@ import {
|
|||
PlusOutlined,
|
||||
ThunderboltOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import NodeHistoryPanel from './NodeHistoryPanel.vue';
|
||||
|
||||
|
|
@ -26,8 +28,6 @@ const emit = defineEmits([
|
|||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Render the address column as a clickable URL so admins can jump to
|
||||
// the remote panel directly from the list.
|
||||
const dataSource = computed(() =>
|
||||
props.nodes.map((n) => ({
|
||||
...n,
|
||||
|
|
@ -36,6 +36,8 @@ const dataSource = computed(() =>
|
|||
})),
|
||||
);
|
||||
|
||||
const showAddress = ref(false);
|
||||
|
||||
function statusColor(status) {
|
||||
switch (status) {
|
||||
case 'online': return 'green';
|
||||
|
|
@ -97,9 +99,19 @@ function formatPct(p) {
|
|||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column :title="t('pages.nodes.address')" data-index="url" :ellipsis="true">
|
||||
<a-table-column data-index="url" :ellipsis="true">
|
||||
<template #title>
|
||||
<span class="address-header">
|
||||
{{ t('pages.nodes.address') }}
|
||||
<a-tooltip :title="t('pages.index.toggleIpVisibility')">
|
||||
<component :is="showAddress ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
|
||||
@click="showAddress = !showAddress" />
|
||||
</a-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<template #default="{ record }">
|
||||
<a :href="record.url" target="_blank" rel="noopener noreferrer">{{ record.url }}</a>
|
||||
<a :href="record.url" target="_blank" rel="noopener noreferrer"
|
||||
:class="showAddress ? 'address-visible' : 'address-hidden'">{{ record.url }}</a>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
|
|
@ -203,4 +215,29 @@ function formatPct(p) {
|
|||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.address-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ip-toggle-icon {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ip-toggle-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.address-hidden {
|
||||
filter: blur(5px);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.address-visible {
|
||||
filter: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
26
go.mod
26
go.mod
|
|
@ -22,9 +22,9 @@ 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
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
|
|
@ -69,13 +69,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 +86,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
|
||||
|
|
|
|||
52
go.sum
52
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=
|
||||
|
|
|
|||
8
main.go
8
main.go
|
|
@ -81,11 +81,7 @@ func runWebServer() {
|
|||
case syscall.SIGHUP:
|
||||
logger.Info("Received SIGHUP signal. Restarting servers...")
|
||||
|
||||
// --- FIX FOR TELEGRAM BOT CONFLICT (409): Stop bot before restart ---
|
||||
service.StopBot()
|
||||
// --
|
||||
|
||||
err := server.Stop()
|
||||
err := server.StopPanelOnly()
|
||||
if err != nil {
|
||||
logger.Debug("Error stopping web server:", err)
|
||||
}
|
||||
|
|
@ -96,7 +92,7 @@ func runWebServer() {
|
|||
|
||||
server = web.NewServer()
|
||||
global.SetWebServer(server)
|
||||
err = server.Start()
|
||||
err = server.StartPanelOnly()
|
||||
if err != nil {
|
||||
log.Fatalf("Error restarting web server: %v", err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
|
@ -29,25 +29,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 u, err := a.userService.GetFirstUser(); err == nil {
|
||||
session.SetAPIAuthUser(c, u)
|
||||
}
|
||||
|
|
@ -57,7 +43,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 +75,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
|
||||
|
|
|
|||
|
|
@ -76,7 +76,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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -110,6 +111,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 +194,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -869,6 +869,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 == "" {
|
||||
|
|
@ -1344,8 +1347,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
|
||||
|
|
@ -2063,6 +2069,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
|
||||
|
|
@ -1275,7 +1333,13 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(out.String(), "\n")
|
||||
return map[string]any{
|
||||
"auths": parseVlessEncAuths(out.String()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseVlessEncAuths(output string) []map[string]string {
|
||||
lines := strings.Split(output, "\n")
|
||||
var auths []map[string]string
|
||||
var current map[string]string
|
||||
|
||||
|
|
@ -1285,14 +1349,18 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
|
|||
if current != nil {
|
||||
auths = append(auths, current)
|
||||
}
|
||||
label := strings.TrimSpace(strings.TrimPrefix(line, "Authentication:"))
|
||||
current = map[string]string{
|
||||
"label": strings.TrimSpace(strings.TrimPrefix(line, "Authentication:")),
|
||||
"id": vlessEncAuthID(label),
|
||||
"label": label,
|
||||
}
|
||||
} else if strings.HasPrefix(line, `"decryption"`) || strings.HasPrefix(line, `"encryption"`) {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 && current != nil {
|
||||
key := strings.Trim(parts[0], `" `)
|
||||
val := strings.Trim(parts[1], `" `)
|
||||
val := strings.TrimSpace(parts[1])
|
||||
val = strings.TrimSuffix(val, ",")
|
||||
val = strings.Trim(val, `" `)
|
||||
current[key] = val
|
||||
}
|
||||
}
|
||||
|
|
@ -1302,9 +1370,19 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
|
|||
auths = append(auths, current)
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"auths": auths,
|
||||
}, nil
|
||||
return auths
|
||||
}
|
||||
|
||||
func vlessEncAuthID(label string) string {
|
||||
normalized := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(label))
|
||||
switch {
|
||||
case strings.Contains(normalized, "mlkem768"):
|
||||
return "mlkem768"
|
||||
case strings.Contains(normalized, "x25519"):
|
||||
return "x25519"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServerService) GetNewUUID() (map[string]string, error) {
|
||||
|
|
|
|||
82
web/service/server_vlessenc_test.go
Normal file
82
web/service/server_vlessenc_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseVlessEncAuthsAddsStableIDs(t *testing.T) {
|
||||
output := `
|
||||
Authentication: X25519, not Post-Quantum
|
||||
{
|
||||
"decryption": "mlkem768x25519plus.native.600s.server-x25519",
|
||||
"encryption": "mlkem768x25519plus.native.0rtt.client-x25519"
|
||||
}
|
||||
|
||||
Authentication: ML-KEM-768, Post-Quantum
|
||||
{
|
||||
"decryption": "mlkem768x25519plus.native.600s.server-mlkem",
|
||||
"encryption": "mlkem768x25519plus.native.0rtt.client-mlkem"
|
||||
}
|
||||
`
|
||||
|
||||
auths := parseVlessEncAuths(output)
|
||||
if len(auths) != 2 {
|
||||
t.Fatalf("expected 2 auth blocks, got %d", len(auths))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
index int
|
||||
id string
|
||||
label string
|
||||
decryption string
|
||||
encryption string
|
||||
}{
|
||||
{
|
||||
index: 0,
|
||||
id: "x25519",
|
||||
label: "X25519, not Post-Quantum",
|
||||
decryption: "mlkem768x25519plus.native.600s.server-x25519",
|
||||
encryption: "mlkem768x25519plus.native.0rtt.client-x25519",
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
id: "mlkem768",
|
||||
label: "ML-KEM-768, Post-Quantum",
|
||||
decryption: "mlkem768x25519plus.native.600s.server-mlkem",
|
||||
encryption: "mlkem768x25519plus.native.0rtt.client-mlkem",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
auth := auths[test.index]
|
||||
if auth["id"] != test.id {
|
||||
t.Errorf("auth[%d] id = %q, want %q", test.index, auth["id"], test.id)
|
||||
}
|
||||
if auth["label"] != test.label {
|
||||
t.Errorf("auth[%d] label = %q, want %q", test.index, auth["label"], test.label)
|
||||
}
|
||||
if auth["decryption"] != test.decryption {
|
||||
t.Errorf("auth[%d] decryption = %q, want %q", test.index, auth["decryption"], test.decryption)
|
||||
}
|
||||
if auth["encryption"] != test.encryption {
|
||||
t.Errorf("auth[%d] encryption = %q, want %q", test.index, auth["encryption"], test.encryption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVlessEncAuthsHandlesMissingTrailingComma(t *testing.T) {
|
||||
output := `
|
||||
Authentication: X25519, not Post-Quantum
|
||||
"decryption": "server"
|
||||
"encryption": "client"
|
||||
`
|
||||
|
||||
auths := parseVlessEncAuths(output)
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("expected 1 auth block, got %d", len(auths))
|
||||
}
|
||||
if auths[0]["decryption"] != "server" {
|
||||
t.Fatalf("decryption = %q, want server", auths[0]["decryption"])
|
||||
}
|
||||
if auths[0]["encryption"] != "client" {
|
||||
t.Fatalf("encryption = %q, want client", auths[0]["encryption"])
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ var defaultValueMap = map[string]string{
|
|||
"apiToken": "",
|
||||
"webBasePath": "/",
|
||||
"sessionMaxAge": "360",
|
||||
"trustedProxyCIDRs": "127.0.0.1/32,::1/128",
|
||||
"pageSize": "25",
|
||||
"expireDiff": "0",
|
||||
"trafficDiff": "0",
|
||||
|
|
@ -199,6 +200,32 @@ 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()))
|
||||
view.HasApiToken = secretConfigured(mustString(s.getString("apiToken")))
|
||||
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 +313,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 +448,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")
|
||||
}
|
||||
|
|
@ -771,6 +806,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 +832,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", "apiToken":
|
||||
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)
|
||||
|
|
|
|||
92
web/service/setting_security_test.go
Normal file
92
web/service/setting_security_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
)
|
||||
|
||||
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 := s.saveSetting("apiToken", "api-secret"); 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type trafficWriteRequest struct {
|
|||
var (
|
||||
twMu sync.Mutex
|
||||
twQueue chan *trafficWriteRequest
|
||||
twCtx context.Context
|
||||
twCancel context.CancelFunc
|
||||
twDone chan struct{}
|
||||
)
|
||||
|
|
@ -37,16 +38,26 @@ var (
|
|||
func StartTrafficWriter() {
|
||||
twMu.Lock()
|
||||
defer twMu.Unlock()
|
||||
if twQueue != nil {
|
||||
return
|
||||
|
||||
if twCancel != nil && twDone != nil {
|
||||
select {
|
||||
case <-twDone:
|
||||
clearTrafficWriterState()
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
queue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
|
||||
twQueue = queue
|
||||
twCtx = ctx
|
||||
twCancel = cancel
|
||||
twDone = done
|
||||
go runTrafficWriter(queue, ctx, done)
|
||||
|
||||
go runTrafficWriter(ctx, queue, done)
|
||||
}
|
||||
|
||||
// StopTrafficWriter cancels the writer context and waits for the goroutine to
|
||||
|
|
@ -56,20 +67,30 @@ func StopTrafficWriter() {
|
|||
twMu.Lock()
|
||||
cancel := twCancel
|
||||
done := twDone
|
||||
twQueue = nil
|
||||
twCancel = nil
|
||||
twDone = nil
|
||||
if cancel == nil || done == nil {
|
||||
twMu.Unlock()
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
twMu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
<-done
|
||||
|
||||
twMu.Lock()
|
||||
if twDone == done {
|
||||
clearTrafficWriterState()
|
||||
}
|
||||
twMu.Unlock()
|
||||
}
|
||||
|
||||
func runTrafficWriter(queue chan *trafficWriteRequest, ctx context.Context, done chan struct{}) {
|
||||
func clearTrafficWriterState() {
|
||||
twQueue = nil
|
||||
twCtx = nil
|
||||
twCancel = nil
|
||||
twDone = nil
|
||||
}
|
||||
|
||||
func runTrafficWriter(ctx context.Context, queue chan *trafficWriteRequest, done chan struct{}) {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
|
|
@ -99,18 +120,43 @@ func safeApply(fn func() error) (err error) {
|
|||
}
|
||||
|
||||
func submitTrafficWrite(fn func() error) error {
|
||||
req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
|
||||
|
||||
twMu.Lock()
|
||||
queue := twQueue
|
||||
twMu.Unlock()
|
||||
|
||||
if queue == nil {
|
||||
ctx := twCtx
|
||||
done := twDone
|
||||
if queue == nil || ctx == nil || done == nil {
|
||||
twMu.Unlock()
|
||||
return safeApply(fn)
|
||||
}
|
||||
req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
twMu.Unlock()
|
||||
return safeApply(fn)
|
||||
default:
|
||||
}
|
||||
|
||||
timer := time.NewTimer(trafficWriterSubmitTimeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case queue <- req:
|
||||
case <-time.After(trafficWriterSubmitTimeout):
|
||||
twMu.Unlock()
|
||||
case <-timer.C:
|
||||
twMu.Unlock()
|
||||
return errors.New("traffic writer queue full")
|
||||
}
|
||||
return <-req.done
|
||||
|
||||
select {
|
||||
case err := <-req.done:
|
||||
return err
|
||||
case <-done:
|
||||
select {
|
||||
case err := <-req.done:
|
||||
return err
|
||||
default:
|
||||
return errors.New("traffic writer stopped before write completed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
190
web/service/traffic_writer_test.go
Normal file
190
web/service/traffic_writer_test.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTrafficWriterStartStopStartAcceptsWrites(t *testing.T) {
|
||||
resetTrafficWriterForTest(t)
|
||||
|
||||
StartTrafficWriter()
|
||||
var writes atomic.Int32
|
||||
if err := submitTrafficWrite(func() error {
|
||||
writes.Add(1)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("first submitTrafficWrite: %v", err)
|
||||
}
|
||||
|
||||
StopTrafficWriter()
|
||||
StartTrafficWriter()
|
||||
if err := submitTrafficWrite(func() error {
|
||||
writes.Add(1)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("second submitTrafficWrite: %v", err)
|
||||
}
|
||||
|
||||
if got := writes.Load(); got != 2 {
|
||||
t.Fatalf("writes = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficWriterSubmitAfterStopRunsInline(t *testing.T) {
|
||||
resetTrafficWriterForTest(t)
|
||||
|
||||
StartTrafficWriter()
|
||||
StopTrafficWriter()
|
||||
|
||||
ran := make(chan struct{})
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- submitTrafficWrite(func() error {
|
||||
close(ran)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ran:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("submitTrafficWrite did not run after traffic writer stopped")
|
||||
}
|
||||
if err := waitTrafficWriterErr(t, errCh); err != nil {
|
||||
t.Fatalf("submitTrafficWrite after stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficWriterStopDrainsQueuedWrite(t *testing.T) {
|
||||
resetTrafficWriterForTest(t)
|
||||
|
||||
StartTrafficWriter()
|
||||
firstStarted := make(chan struct{})
|
||||
releaseFirst := make(chan struct{})
|
||||
firstErr := make(chan error, 1)
|
||||
go func() {
|
||||
firstErr <- submitTrafficWrite(func() error {
|
||||
close(firstStarted)
|
||||
<-releaseFirst
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitTrafficWriterSignal(t, firstStarted, "first write did not start")
|
||||
|
||||
secondRan := make(chan struct{})
|
||||
secondErr := make(chan error, 1)
|
||||
go func() {
|
||||
secondErr <- submitTrafficWrite(func() error {
|
||||
close(secondRan)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitTrafficWriterQueued(t)
|
||||
|
||||
stopDone := make(chan struct{})
|
||||
go func() {
|
||||
StopTrafficWriter()
|
||||
close(stopDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-stopDone:
|
||||
t.Fatal("StopTrafficWriter returned before in-flight write was released")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(releaseFirst)
|
||||
waitTrafficWriterSignal(t, stopDone, "StopTrafficWriter did not return")
|
||||
waitTrafficWriterSignal(t, secondRan, "queued write was not drained")
|
||||
|
||||
if err := waitTrafficWriterErr(t, firstErr); err != nil {
|
||||
t.Fatalf("first submitTrafficWrite: %v", err)
|
||||
}
|
||||
if err := waitTrafficWriterErr(t, secondErr); err != nil {
|
||||
t.Fatalf("second submitTrafficWrite: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficWriterConcurrentStopDuringSubmitDoesNotHang(t *testing.T) {
|
||||
resetTrafficWriterForTest(t)
|
||||
|
||||
StartTrafficWriter()
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- submitTrafficWrite(func() error {
|
||||
close(started)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitTrafficWriterSignal(t, started, "write did not start")
|
||||
|
||||
stopDone := make(chan struct{})
|
||||
go func() {
|
||||
StopTrafficWriter()
|
||||
close(stopDone)
|
||||
}()
|
||||
|
||||
close(release)
|
||||
waitTrafficWriterSignal(t, stopDone, "StopTrafficWriter hung during submit")
|
||||
if err := waitTrafficWriterErr(t, errCh); err != nil {
|
||||
t.Fatalf("submitTrafficWrite during stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func resetTrafficWriterForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
StopTrafficWriter()
|
||||
twMu.Lock()
|
||||
clearTrafficWriterState()
|
||||
twMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
StopTrafficWriter()
|
||||
twMu.Lock()
|
||||
clearTrafficWriterState()
|
||||
twMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func waitTrafficWriterQueued(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
twMu.Lock()
|
||||
queued := 0
|
||||
if twQueue != nil {
|
||||
queued = len(twQueue)
|
||||
}
|
||||
twMu.Unlock()
|
||||
if queued > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("write was not queued")
|
||||
}
|
||||
|
||||
func waitTrafficWriterSignal(t *testing.T, ch <-chan struct{}, msg string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func waitTrafficWriterErr(t *testing.T, ch <-chan error) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-ch:
|
||||
return err
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for traffic writer result")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "تمام ترافیک کلاینت بازنشانی شد",
|
||||
"resetAllTrafficSuccess": "تمام ترافیکها بازنشانی شدند",
|
||||
"resetInboundClientTrafficSuccess": "ترافیک بازنشانی شد",
|
||||
"resetInboundTrafficSuccess": "ترافیک ورودی بازنشانی شد",
|
||||
"trafficGetError": "خطا در دریافت ترافیکها",
|
||||
"getNewX25519CertError": "خطا در دریافت گواهی X25519.",
|
||||
"getNewmldsa65Error": "خطا در دریافت گواهی mldsa65.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "クライアントのすべてのトラフィックがリセットされました",
|
||||
"resetAllTrafficSuccess": "すべてのトラフィックがリセットされました",
|
||||
"resetInboundClientTrafficSuccess": "トラフィックがリセットされました",
|
||||
"resetInboundTrafficSuccess": "受信トラフィックがリセットされました",
|
||||
"trafficGetError": "トラフィックの取得中にエラーが発生しました",
|
||||
"getNewX25519CertError": "X25519証明書の取得中にエラーが発生しました。",
|
||||
"getNewmldsa65Error": "mldsa65証明書の取得中にエラーが発生しました。",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "Весь трафик клиента сброшен",
|
||||
"resetAllTrafficSuccess": "Весь трафик сброшен",
|
||||
"resetInboundClientTrafficSuccess": "Трафик сброшен",
|
||||
"resetInboundTrafficSuccess": "Входящий трафик сброшен",
|
||||
"trafficGetError": "Ошибка получения данных о трафике",
|
||||
"getNewX25519CertError": "Ошибка при получении сертификата X25519.",
|
||||
"getNewmldsa65Error": "Ошибка при получении сертификата mldsa65.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "Весь трафік клієнта скинуто",
|
||||
"resetAllTrafficSuccess": "Весь трафік скинуто",
|
||||
"resetInboundClientTrafficSuccess": "Трафік скинуто",
|
||||
"resetInboundTrafficSuccess": "Трафік вхідного потоку скинуто",
|
||||
"trafficGetError": "Помилка отримання даних про трафік",
|
||||
"getNewX25519CertError": "Помилка при отриманні сертифіката X25519.",
|
||||
"getNewmldsa65Error": "Помилка при отриманні сертифіката mldsa65.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "客户端所有流量已重置",
|
||||
"resetAllTrafficSuccess": "所有流量已重置",
|
||||
"resetInboundClientTrafficSuccess": "流量已重置",
|
||||
"resetInboundTrafficSuccess": "入站流量已重置",
|
||||
"trafficGetError": "获取流量数据时出错",
|
||||
"getNewX25519CertError": "获取X25519证书时出错。",
|
||||
"getNewmldsa65Error": "获取mldsa65证书时出错。",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@
|
|||
"resetAllClientTrafficSuccess": "客戶端所有流量已重置",
|
||||
"resetAllTrafficSuccess": "所有流量已重置",
|
||||
"resetInboundClientTrafficSuccess": "流量已重置",
|
||||
"resetInboundTrafficSuccess": "入站流量已重置",
|
||||
"trafficGetError": "取得流量資料時發生錯誤",
|
||||
"getNewX25519CertError": "取得X25519憑證時發生錯誤。",
|
||||
"getNewmldsa65Error": "取得mldsa65憑證時發生錯誤。",
|
||||
|
|
|
|||
56
web/web.go
56
web/web.go
|
|
@ -259,11 +259,13 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
|
||||
// startTask schedules background jobs (Xray checks, traffic jobs, cron
|
||||
// jobs) which the panel relies on for periodic maintenance and monitoring.
|
||||
func (s *Server) startTask() {
|
||||
func (s *Server) startTask(restartXray bool) {
|
||||
s.customGeoService.EnsureOnStartup()
|
||||
err := s.xrayService.RestartXray(true)
|
||||
if err != nil {
|
||||
logger.Warning("start xray failed:", err)
|
||||
if restartXray {
|
||||
err := s.xrayService.RestartXray(true)
|
||||
if err != nil {
|
||||
logger.Warning("start xray failed:", err)
|
||||
}
|
||||
}
|
||||
// Check whether xray is running every second
|
||||
s.cron.AddJob("@every 1s", job.NewCheckXrayRunningJob())
|
||||
|
|
@ -348,6 +350,15 @@ func (s *Server) startTask() {
|
|||
|
||||
// Start initializes and starts the web server with configured settings, routes, and background jobs.
|
||||
func (s *Server) Start() (err error) {
|
||||
return s.start(true, true)
|
||||
}
|
||||
|
||||
// StartPanelOnly initializes the panel during an in-process panel restart without cycling Xray.
|
||||
func (s *Server) StartPanelOnly() (err error) {
|
||||
return s.start(false, false)
|
||||
}
|
||||
|
||||
func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
||||
// This is an anonymous function, no function name
|
||||
defer func() {
|
||||
if err != nil {
|
||||
|
|
@ -420,19 +431,25 @@ func (s *Server) Start() (err error) {
|
|||
s.listener = listener
|
||||
|
||||
s.httpServer = &http.Server{
|
||||
Handler: engine,
|
||||
Handler: engine,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
s.httpServer.Serve(listener)
|
||||
}()
|
||||
|
||||
s.startTask()
|
||||
s.startTask(restartXray)
|
||||
|
||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||
if (err == nil) && (isTgbotenabled) {
|
||||
tgBot := s.tgbotService.NewTgbot()
|
||||
tgBot.Start(i18nFS)
|
||||
if startTgBot {
|
||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||
if (err == nil) && (isTgbotenabled) {
|
||||
tgBot := s.tgbotService.NewTgbot()
|
||||
tgBot.Start(i18nFS)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -440,13 +457,26 @@ func (s *Server) Start() (err error) {
|
|||
|
||||
// Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot.
|
||||
func (s *Server) Stop() error {
|
||||
return s.stop(true, true)
|
||||
}
|
||||
|
||||
// StopPanelOnly stops only panel-owned HTTP/background resources for an in-process panel restart.
|
||||
func (s *Server) StopPanelOnly() error {
|
||||
return s.stop(false, false)
|
||||
}
|
||||
|
||||
func (s *Server) stop(stopXray bool, stopTgBot bool) error {
|
||||
s.cancel()
|
||||
s.xrayService.StopXray()
|
||||
if stopXray {
|
||||
s.xrayService.StopXray()
|
||||
}
|
||||
if s.cron != nil {
|
||||
s.cron.Stop()
|
||||
}
|
||||
service.StopTrafficWriter()
|
||||
if s.tgbotService.IsRunning() {
|
||||
if stopXray {
|
||||
service.StopTrafficWriter()
|
||||
}
|
||||
if stopTgBot && s.tgbotService.IsRunning() {
|
||||
s.tgbotService.Stop()
|
||||
}
|
||||
// Gracefully stop WebSocket hub
|
||||
|
|
|
|||
2
x-ui.sh
2
x-ui.sh
|
|
@ -2092,7 +2092,7 @@ EOF
|
|||
|
||||
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
|
||||
[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
|
||||
|
|
|
|||
Loading…
Reference in a new issue