Merge branch 'main' into fix/node-tag-unique-scope

This commit is contained in:
Sanaei 2026-05-13 15:07:12 +02:00 committed by GitHub
commit 41faaa2b25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
99 changed files with 3382 additions and 409 deletions

View file

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

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

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

View file

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

View file

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

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22

View file

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

View file

@ -21,12 +21,8 @@ const (
Shadowsocks Protocol = "shadowsocks" Shadowsocks Protocol = "shadowsocks"
Mixed Protocol = "mixed" Mixed Protocol = "mixed"
WireGuard Protocol = "wireguard" WireGuard Protocol = "wireguard"
// UI stores Hysteria v1 and v2 both as "hysteria" and uses Hysteria Protocol = "hysteria"
// settings.version to discriminate. Imports from outside the panel Hysteria2 Protocol = "hysteria2"
// can carry the literal "hysteria2" string, so IsHysteria below
// accepts both.
Hysteria Protocol = "hysteria"
Hysteria2 Protocol = "hysteria2"
) )
// IsHysteria returns true for both "hysteria" and "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. // User represents a user account in the 3x-ui panel.
type User struct { type User struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"` Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
LoginEpoch int64 `json:"-" gorm:"default:0"`
} }
// Inbound represents an Xray inbound configuration with traffic statistics and settings. // Inbound represents an Xray inbound configuration with traffic statistics and settings.
@ -128,15 +125,16 @@ type Setting struct {
// endpoint over HTTP using the per-node ApiToken to populate the runtime // endpoint over HTTP using the per-node ApiToken to populate the runtime
// status fields below. // status fields below.
type Node struct { type Node struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" form:"name" gorm:"uniqueIndex"` Name string `json:"name" form:"name" gorm:"uniqueIndex"`
Remark string `json:"remark" form:"remark"` Remark string `json:"remark" form:"remark"`
Scheme string `json:"scheme" form:"scheme"` Scheme string `json:"scheme" form:"scheme"`
Address string `json:"address" form:"address"` Address string `json:"address" form:"address"`
Port int `json:"port" form:"port"` Port int `json:"port" form:"port"`
BasePath string `json:"basePath" form:"basePath"` BasePath string `json:"basePath" form:"basePath"`
ApiToken string `json:"apiToken" form:"apiToken"` ApiToken string `json:"apiToken" form:"apiToken"`
Enable bool `json:"enable" form:"enable" gorm:"default:true"` Enable bool `json:"enable" form:"enable" gorm:"default:true"`
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
// Heartbeat-updated fields. UpdatedAt advances on every probe even when // Heartbeat-updated fields. UpdatedAt advances on every probe even when
// the row is otherwise unchanged so the UI's "last seen" tooltip is // the row is otherwise unchanged so the UI's "last seen" tooltip is

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue'; import { Modal, message } from 'ant-design-vue';
import { import {
@ -8,13 +8,27 @@ import {
CopyOutlined, CopyOutlined,
EyeOutlined, EyeOutlined,
EyeInvisibleOutlined, EyeInvisibleOutlined,
SearchOutlined,
ExpandOutlined,
CompressOutlined,
ApiOutlined,
SafetyCertificateOutlined,
CloudServerOutlined,
ClusterOutlined,
GlobalOutlined,
SaveOutlined,
SettingOutlined,
WifiOutlined,
LinkOutlined,
NodeIndexOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js'; import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import AppSidebar from '@/components/AppSidebar.vue'; import AppSidebar from '@/components/AppSidebar.vue';
import { HttpUtil, ClipboardManager } from '@/utils/index.js'; import { HttpUtil, ClipboardManager } from '@/utils/index.js';
import { sections } from './endpoints.js'; import { sections as allSections } from './endpoints.js';
import EndpointSection from './EndpointSection.vue'; import EndpointSection from './EndpointSection.vue';
import CodeBlock from './CodeBlock.vue';
const { t } = useI18n(); const { t } = useI18n();
@ -26,11 +40,69 @@ const tokenLoading = ref(false);
const tokenRotating = ref(false); const tokenRotating = ref(false);
const tokenVisible = 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 \\ const curlExample = `curl -X GET \\
-H "Authorization: Bearer YOUR_API_TOKEN" \\ -H "Authorization: Bearer YOUR_API_TOKEN" \\
-H "Accept: application/json" \\ -H "Accept: application/json" \\
https://your-panel.example.com/panel/api/inbounds/list`; https://your-panel.example.com/panel/api/inbounds/list`;
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() { async function loadApiToken() {
tokenLoading.value = true; tokenLoading.value = true;
try { try {
@ -64,7 +136,7 @@ function regenerateApiToken() {
async function copyApiToken() { async function copyApiToken() {
if (!apiToken.value) return; if (!apiToken.value) return;
const ok = await ClipboardManager.copy(apiToken.value); const ok = await ClipboardManager.copyText(apiToken.value);
if (ok) message.success(t('success')); if (ok) message.success(t('success'));
} }
@ -73,8 +145,33 @@ function scrollToSection(id) {
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); 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(() => { onMounted(() => {
loadApiToken(); loadApiToken();
scrollObserver = onScroll;
window.addEventListener('scroll', scrollObserver, { passive: true });
onScroll();
});
onBeforeUnmount(() => {
if (scrollObserver) {
window.removeEventListener('scroll', scrollObserver);
}
}); });
</script> </script>
@ -93,6 +190,7 @@ onMounted(() => {
cookie, or with the <code>Authorization: Bearer &lt;token&gt;</code> header below. Every endpoint cookie, or with the <code>Authorization: Bearer &lt;token&gt;</code> header below. Every endpoint
returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted. returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
</p> </p>
</header> </header>
<a-card class="token-card" size="small"> <a-card class="token-card" size="small">
@ -101,7 +199,7 @@ onMounted(() => {
<KeyOutlined /> <KeyOutlined />
<span>API Token</span> <span>API Token</span>
</div> </div>
<a-space size="small" wrap> <div class="token-actions">
<a-button size="small" @click="tokenVisible = !tokenVisible"> <a-button size="small" @click="tokenVisible = !tokenVisible">
<template #icon> <template #icon>
<EyeInvisibleOutlined v-if="tokenVisible" /> <EyeInvisibleOutlined v-if="tokenVisible" />
@ -121,7 +219,7 @@ onMounted(() => {
</template> </template>
Regenerate Regenerate
</a-button> </a-button>
</a-space> </div>
</div> </div>
<a-spin :spinning="tokenLoading" size="small"> <a-spin :spinning="tokenLoading" size="small">
<pre <pre
@ -135,18 +233,59 @@ onMounted(() => {
</a-card> </a-card>
<a-card class="curl-card" size="small" title="Quick example"> <a-card class="curl-card" size="small" title="Quick example">
<pre class="code-block">{{ curlExample }}</pre> <CodeBlock :code="curlExample" lang="text" />
</a-card> </a-card>
<div class="toolbar">
<a-input-search
v-model:value="searchQuery"
placeholder="Search endpoints by path, method, or description…"
allow-clear
class="search-bar"
>
<template #prefix><SearchOutlined /></template>
</a-input-search>
<span class="match-count" v-if="searchQuery">
{{ visibleEndpoints }} / {{ endpointCount }} endpoints
</span>
<a-space size="small">
<a-button size="small" @click="expandAll">
<template #icon><ExpandOutlined /></template>
Expand all
</a-button>
<a-button size="small" @click="collapseAll">
<template #icon><CompressOutlined /></template>
Collapse all
</a-button>
</a-space>
</div>
<nav class="toc-nav"> <nav class="toc-nav">
<span class="toc-label">On this page:</span> <span class="toc-label">On this page:</span>
<a v-for="s in sections" :key="s.id" class="toc-link" :href="`#${s.id}`" <div class="toc-links">
@click.prevent="scrollToSection(s.id)"> <a
{{ s.title }} v-for="s in sections"
</a> :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> </nav>
<EndpointSection v-for="s in sections" :key="s.id" :section="s" /> <EndpointSection
v-for="s in sections"
:key="s.id"
:section="s"
:icon="sectionIcons[s.id]"
:collapsed="isCollapsed(s.id)"
@toggle="toggleSection(s.id)"
/>
</div> </div>
</a-layout-content> </a-layout-content>
</a-layout> </a-layout>
@ -194,20 +333,25 @@ onMounted(() => {
} }
.docs-header { .docs-header {
margin-bottom: 18px; margin-bottom: 20px;
padding: 24px;
background: var(--bg-card);
border: 1px solid rgba(128, 128, 128, 0.12);
border-radius: 10px;
} }
.docs-title { .docs-title {
font-size: 26px; font-size: 28px;
font-weight: 700; font-weight: 800;
margin: 0 0 8px; margin: 0 0 8px;
color: rgba(0, 0, 0, 0.88); color: rgba(0, 0, 0, 0.88);
letter-spacing: -0.3px;
} }
.docs-lead { .docs-lead {
margin: 0; margin: 0;
color: rgba(0, 0, 0, 0.65); color: rgba(0, 0, 0, 0.65);
line-height: 1.6; line-height: 1.65;
font-size: 14px; font-size: 14px;
} }
@ -231,7 +375,8 @@ onMounted(() => {
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 8px; margin-bottom: 10px;
min-height: 32px;
} }
.token-card-title { .token-card-title {
@ -242,6 +387,13 @@ onMounted(() => {
font-size: 14px; font-size: 14px;
} }
.token-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.token-value { .token-value {
background: rgba(128, 128, 128, 0.08); background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15); border: 1px solid rgba(128, 128, 128, 0.15);
@ -275,35 +427,110 @@ onMounted(() => {
overflow-x: auto; overflow-x: auto;
} }
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.search-bar {
flex: 1;
min-width: 200px;
max-width: 480px;
}
.match-count {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
white-space: nowrap;
}
.toc-nav { .toc-nav {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: flex-start;
gap: 8px 14px; gap: 8px 12px;
padding: 12px 16px; padding: 12px 16px;
background: rgba(128, 128, 128, 0.08); background: var(--bg-card);
border-radius: 6px; border: 1px solid rgba(128, 128, 128, 0.12);
border-radius: 8px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.toc-label { .toc-label {
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.6px;
color: rgba(0, 0, 0, 0.5); color: rgba(0, 0, 0, 0.5);
padding-top: 3px;
flex-shrink: 0;
}
.toc-links {
display: flex;
flex-wrap: wrap;
gap: 6px;
} }
.toc-link { .toc-link {
color: #1677ff; display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 20px;
font-size: 12.5px;
color: rgba(0, 0, 0, 0.65);
background: rgba(128, 128, 128, 0.06);
border: 1px solid transparent;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
font-size: 13px; transition: all 0.2s;
white-space: nowrap;
} }
.toc-link:hover { .toc-link:hover {
color: #4096ff; background: rgba(22, 119, 255, 0.08);
text-decoration: underline; color: #1677ff;
border-color: rgba(22, 119, 255, 0.2);
}
.toc-link.active {
background: rgba(22, 119, 255, 0.12);
color: #1677ff;
border-color: rgba(22, 119, 255, 0.3);
font-weight: 600;
}
.toc-icon {
font-size: 13px;
opacity: 0.8;
}
.toc-text {
font-size: 12.5px;
}
.toc-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 10.5px;
font-weight: 700;
background: rgba(22, 119, 255, 0.12);
color: #1677ff;
line-height: 1;
}
.toc-link.active .toc-badge {
background: #1677ff;
color: #fff;
} }
</style> </style>
@ -312,16 +539,40 @@ body.dark .docs-title {
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} }
html[data-theme='ultra-dark'] .docs-title {
color: rgba(255, 255, 255, 0.95);
}
body.dark .docs-header {
background: #252526;
border-color: rgba(255, 255, 255, 0.08);
}
html[data-theme='ultra-dark'] .docs-header {
background: #0a0a0a;
border-color: rgba(255, 255, 255, 0.06);
}
body.dark .docs-lead, body.dark .docs-lead,
body.dark .token-hint { body.dark .token-hint {
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
} }
html[data-theme='ultra-dark'] .docs-lead,
html[data-theme='ultra-dark'] .token-hint {
color: rgba(255, 255, 255, 0.75);
}
body.dark .docs-lead code, body.dark .docs-lead code,
body.dark .token-hint code { body.dark .token-hint code {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
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 .token-value,
body.dark .code-block { body.dark .code-block {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
@ -329,11 +580,58 @@ body.dark .code-block {
color: rgba(255, 255, 255, 0.88); 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 { body.dark .toc-nav {
background: rgba(255, 255, 255, 0.04); background: #252526;
border-color: rgba(255, 255, 255, 0.08);
}
html[data-theme='ultra-dark'] .toc-nav {
background: #0a0a0a;
border-color: rgba(255, 255, 255, 0.06);
} }
body.dark .toc-label { body.dark .toc-label {
color: rgba(255, 255, 255, 0.55); color: rgba(255, 255, 255, 0.55);
} }
html[data-theme='ultra-dark'] .toc-label {
color: rgba(255, 255, 255, 0.6);
}
body.dark .toc-link {
color: rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.06);
}
html[data-theme='ultra-dark'] .toc-link {
background: rgba(255, 255, 255, 0.04);
}
body.dark .toc-link:hover {
background: rgba(88, 166, 255, 0.12);
color: #58a6ff;
border-color: rgba(88, 166, 255, 0.25);
}
body.dark .toc-link.active {
background: rgba(88, 166, 255, 0.15);
color: #58a6ff;
border-color: rgba(88, 166, 255, 0.35);
}
body.dark .toc-badge {
background: rgba(88, 166, 255, 0.15);
color: #58a6ff;
}
body.dark .toc-link.active .toc-badge {
background: #58a6ff;
color: #0d1117;
}
</style> </style>

View file

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

View file

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

View file

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

View file

@ -1,3 +1,28 @@
export function safeInlineHtml(input) {
if (!input) return '';
const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const open = '<code>';
const close = '</code>';
let out = '';
let i = 0;
while (i < input.length) {
const oIdx = input.indexOf(open, i);
if (oIdx === -1) {
out += escape(input.slice(i));
break;
}
out += escape(input.slice(i, oIdx));
const cIdx = input.indexOf(close, oIdx + open.length);
if (cIdx === -1) {
out += escape(input.slice(oIdx));
break;
}
out += '<code>' + escape(input.slice(oIdx + open.length, cIdx)) + '</code>';
i = cIdx + close.length;
}
return out;
}
export const sections = [ export const sections = [
{ {
id: 'auth', id: 'auth',
@ -17,11 +42,14 @@ export const sections = [
body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}', body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}',
response: response:
'{\n "success": true,\n "msg": "Logged in successfully"\n}', '{\n "success": true,\n "msg": "Logged in successfully"\n}',
errorResponse:
'{\n "success": false,\n "msg": "Wrong username or password"\n}',
}, },
{ {
method: 'GET', method: 'POST',
path: '/logout', path: '/logout',
summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.', summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.',
response: '{\n "success": true\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -41,9 +69,9 @@ export const sections = [
{ {
id: 'inbounds', id: 'inbounds',
title: 'Inbounds API', title: 'Inbounds',
description: description:
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.', 'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour forwarded headers only when the request comes from a configured trusted proxy.',
endpoints: [ endpoints: [
{ {
method: 'GET', method: 'GET',
@ -67,6 +95,7 @@ export const sections = [
params: [ params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' }, { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
], ],
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -75,6 +104,7 @@ export const sections = [
params: [ params: [
{ name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' }, { name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
], ],
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
}, },
{ {
method: 'POST', method: 'POST',
@ -82,6 +112,8 @@ export const sections = [
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).', summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
body: body:
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}', '{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
errorResponse:
'{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
}, },
{ {
method: 'POST', method: 'POST',
@ -161,6 +193,14 @@ export const sections = [
body: body:
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}', '{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
}, },
{
method: 'POST',
path: '/panel/api/inbounds/:id/resetTraffic',
summary: 'Zero out upload + download counters for a single inbound. Does not touch per-client counters.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
],
},
{ {
method: 'POST', method: 'POST',
path: '/panel/api/inbounds/:id/resetClientTraffic/:email', path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
@ -209,6 +249,7 @@ export const sections = [
method: 'POST', method: 'POST',
path: '/panel/api/inbounds/lastOnline', path: '/panel/api/inbounds/lastOnline',
summary: 'Map of client email → last-seen unix timestamp.', summary: 'Map of client email → last-seen unix timestamp.',
response: '{\n "success": true,\n "obj": [\n { "email": "user1", "lastOnline": 1700000000 },\n { "email": "user2", "lastOnline": 1699999000 }\n ]\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -256,7 +297,7 @@ export const sections = [
{ {
id: 'server', id: 'server',
title: 'Server API', title: 'Server',
description: description:
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.', 'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
endpoints: [ endpoints: [
@ -264,6 +305,7 @@ export const sections = [
method: 'GET', method: 'GET',
path: '/panel/api/server/status', path: '/panel/api/server/status',
summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.', summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.',
response: '{\n "success": true,\n "obj": {\n "cpu": 12.5,\n "mem": { "current": 2147483648, "total": 8589934592 },\n "swap": { "current": 0, "total": 4294967296 },\n "disk": { "current": 53687091200, "total": 268435456000 },\n "netIO": { "up": 1073741824, "down": 2147483648 },\n "xray": { "state": "running", "version": "v25.10.31" },\n "tcpCount": 42,\n "load": { "load1": 0.5, "load5": 0.3, "load15": 0.2 }\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -278,7 +320,36 @@ export const sections = [
path: '/panel/api/server/history/:metric/:bucket', path: '/panel/api/server/history/:metric/:bucket',
summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.', summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.',
params: [ params: [
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | swap | netIn | netOut | tcpCount | udpCount | load1 | online.' }, { name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | netUp | netDown | online | load1 | load5 | load15.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
],
response: '{\n "success": true,\n "obj": [\n { "t": 1700000000, "v": 12.5 },\n { "t": 1700000002, "v": 13.1 }\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/server/xrayMetricsState',
summary: 'Xray runtime metrics state — whether the xray config has a `metrics` block, which expvar keys are flowing, and the current snapshot values for each. Returns an empty state when metrics are not configured.',
},
{
method: 'GET',
path: '/panel/api/server/xrayMetricsHistory/:metric/:bucket',
summary: 'Time-series history for one Xray runtime metric over the last ~6 hours. Same {t, v} shape as /history/:metric/:bucket.',
params: [
{ name: 'metric', in: 'path', type: 'string', desc: 'xrAlloc | xrSys | xrHeapObjects | xrNumGC | xrPauseNs.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
],
},
{
method: 'GET',
path: '/panel/api/server/xrayObservatory',
summary: 'Latest snapshot from the Xray observatory — per-outbound latency, health status, and last-probe time. Only populated when the Xray config has an observatory configured.',
},
{
method: 'GET',
path: '/panel/api/server/xrayObservatoryHistory/:tag/:bucket',
summary: 'Time-series of observatory probe results for one outbound tag. Same {t, v} shape as the other history endpoints.',
params: [
{ name: 'tag', in: 'path', type: 'string', desc: 'Outbound tag from the observatory config.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' }, { name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
], ],
}, },
@ -286,6 +357,7 @@ export const sections = [
method: 'GET', method: 'GET',
path: '/panel/api/server/getXrayVersion', path: '/panel/api/server/getXrayVersion',
summary: 'List Xray binary versions available for install on this host.', summary: 'List Xray binary versions available for install on this host.',
response: '{\n "success": true,\n "obj": ["v25.10.31", "v25.9.15", "v25.8.1"]\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -295,7 +367,8 @@ export const sections = [
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getConfigJson', path: '/panel/api/server/getConfigJson',
summary: 'Return the assembled Xray config thats currently running on this host.', summary: 'Return the assembled Xray config that\u2019s currently running on this host.',
response: '{\n "success": true,\n "obj": {\n "log": { "loglevel": "warning" },\n "inbounds": [...],\n "outbounds": [...],\n "routing": { "rules": [...] }\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -306,36 +379,45 @@ export const sections = [
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewUUID', path: '/panel/api/server/getNewUUID',
summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.', summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
response: '{\n "success": true,\n "obj": "550e8400-e29b-41d4-a716-446655440000"\n}',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewX25519Cert', path: '/panel/api/server/getNewX25519Cert',
summary: 'Generate a new X25519 keypair for Reality.', summary: 'Generate a new X25519 keypair for Reality.',
response: '{\n "success": true,\n "obj": {\n "privateKey": "uN9qLfV3zH8w...",\n "publicKey": "5v8xPqR2sM7k..."\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewmldsa65', path: '/panel/api/server/getNewmldsa65',
summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.', summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
response: '{\n "success": true,\n "obj": {\n "privateKey": "mdsa65priv...",\n "publicKey": "mdsa65pub...",\n "seed": "random-seed..."\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewmlkem768', path: '/panel/api/server/getNewmlkem768',
summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.', summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
response: '{\n "success": true,\n "obj": {\n "clientKey": "mlkem768-client...",\n "serverKey": "mlkem768-server..."\n }\n}',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/server/getNewVlessEnc', path: '/panel/api/server/getNewVlessEnc',
summary: 'Generate 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', method: 'POST',
path: '/panel/api/server/stopXrayService', path: '/panel/api/server/stopXrayService',
summary: 'Stop the Xray binary. All proxies go offline immediately.', summary: 'Stop the Xray binary. All proxies go offline immediately.',
errorResponse:
'{\n "success": false,\n "msg": "Xray is not running"\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/server/restartXrayService', path: '/panel/api/server/restartXrayService',
summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.', summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
errorResponse:
'{\n "success": false,\n "msg": "Xray config is invalid: ..."\n}',
}, },
{ {
method: 'POST', method: 'POST',
@ -354,6 +436,10 @@ export const sections = [
method: 'POST', method: 'POST',
path: '/panel/api/server/updateGeofile', path: '/panel/api/server/updateGeofile',
summary: 'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.', summary: 'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
params: [
{ name: 'fileName', in: 'body (form)', type: 'string', desc: 'Filename to update (e.g. geoip.dat, geosite.dat). Omit to update all defaults.' },
],
body: 'fileName=geoip.dat',
}, },
{ {
method: 'POST', method: 'POST',
@ -366,11 +452,12 @@ export const sections = [
{ {
method: 'POST', method: 'POST',
path: '/panel/api/server/logs/:count', path: '/panel/api/server/logs/:count',
summary: 'Return the last N lines of the panels own log.', summary: 'Return the last N lines of the panel\u2019s own log.',
params: [ params: [
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' }, { name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
], ],
body: '{\n "level": "info",\n "syslog": false\n}', body: '{\n "level": "info",\n "syslog": false\n}',
response: '{\n "success": true,\n "obj": "2025/01/01 12:00:00 [INFO] Server started\\n2025/01/01 12:00:01 [INFO] Xray is running"\n}',
}, },
{ {
method: 'POST', method: 'POST',
@ -378,24 +465,38 @@ export const sections = [
summary: 'Return the last N lines of the Xray process log.', summary: 'Return the last N lines of the Xray process log.',
params: [ params: [
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' }, { name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
{ name: 'filter', in: 'body (form)', type: 'string', desc: 'Keyword filter — only lines containing this string.' },
{ name: 'showDirect', in: 'body (form)', type: 'string', desc: '"true" to include direct (freedom) traffic lines.' },
{ name: 'showBlocked', in: 'body (form)', type: 'string', desc: '"true" to include blocked (blackhole) traffic lines.' },
{ name: 'showProxy', in: 'body (form)', type: 'string', desc: '"true" to include proxy traffic lines.' },
], ],
body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
response: '{\n "success": true,\n "obj": "2025/01/01 12:00:00 rejected vless proxy example.com reason: no valid user\\n2025/01/01 12:00:01 direct freedom ok"\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/server/importDB', path: '/panel/api/server/importDB',
summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.', summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.',
params: [
{ name: 'db', in: 'body (multipart)', type: 'file', desc: 'SQLite database file to upload.' },
],
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/server/getNewEchCert', path: '/panel/api/server/getNewEchCert',
summary: 'Generate a new ECH (Encrypted Client Hello) keypair. Body picks the algorithm.', summary: 'Generate a new ECH (Encrypted Client Hello) keypair and config list for the given SNI.',
params: [
{ name: 'sni', in: 'body (form)', type: 'string', desc: 'Server Name Indication to generate the ECH config for.' },
],
body: 'sni=example.com',
response: '{\n "success": true,\n "obj": {\n "echKeySet": "...",\n "echServerKeys": [...],\n "echConfigList": "..."\n }\n}',
}, },
], ],
}, },
{ {
id: 'nodes', id: 'nodes',
title: 'Nodes API', title: 'Nodes',
description: description:
'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.', 'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
endpoints: [ endpoints: [
@ -403,6 +504,7 @@ export const sections = [
method: 'GET', method: 'GET',
path: '/panel/api/nodes/list', path: '/panel/api/nodes/list',
summary: 'List every configured node with its connection details, health, and last heartbeat patch.', summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "status": "online",\n "cpu": 23.5,\n "mem": 45.1\n }\n ]\n}',
}, },
{ {
method: 'GET', method: 'GET',
@ -422,10 +524,11 @@ export const sections = [
{ {
method: 'POST', method: 'POST',
path: '/panel/api/nodes/update/:id', path: '/panel/api/nodes/update/:id',
summary: 'Replace a nodes connection details. Same body shape as /add.', summary: 'Replace a node\u2019s connection details. Same body shape as /add.',
params: [ params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' }, { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
], ],
body: '{\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
}, },
{ {
method: 'POST', method: 'POST',
@ -448,6 +551,8 @@ export const sections = [
method: 'POST', method: 'POST',
path: '/panel/api/nodes/test', path: '/panel/api/nodes/test',
summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.', summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
body: '{\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "cpu": 12.5,\n "mem": 45.2\n }\n}',
}, },
{ {
method: 'POST', method: 'POST',
@ -463,8 +568,8 @@ export const sections = [
summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.', summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.',
params: [ params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' }, { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
{ name: 'metric', in: 'path', type: 'string', desc: 'Metric key (cpu, mem, netIn, …).' }, { name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem.' },
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds.' }, { name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
], ],
}, },
], ],
@ -472,7 +577,7 @@ export const sections = [
{ {
id: 'customGeo', id: 'customGeo',
title: 'Custom Geo API', title: 'Custom Geo',
description: description:
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.', 'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
endpoints: [ endpoints: [
@ -531,12 +636,234 @@ export const sections = [
description: 'Operations that interact with the configured Telegram bot.', description: 'Operations that interact with the configured Telegram bot.',
endpoints: [ endpoints: [
{ {
method: 'GET', method: 'POST',
path: '/panel/api/backuptotgbot', path: '/panel/api/backuptotgbot',
summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.', summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
}, },
], ],
}, },
{
id: 'settings',
title: 'Settings',
description:
'Panel configuration, 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 = { export const methodColors = {
@ -545,4 +872,5 @@ export const methodColors = {
PUT: 'orange', PUT: 'orange',
PATCH: 'orange', PATCH: 'orange',
DELETE: 'red', DELETE: 'red',
WS: 'purple',
}; };

View file

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

View file

@ -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>

View file

@ -12,6 +12,7 @@ import {
SizeFormatter, SizeFormatter,
Wireguard, Wireguard,
} from '@/utils'; } from '@/utils';
import { getRandomRealityTarget } from '@/models/reality-targets';
import { import {
Inbound, Inbound,
Protocols, Protocols,
@ -339,11 +340,9 @@ function clearMldsa65() {
inbound.value.stream.reality.settings.mldsa65Verify = ''; inbound.value.stream.reality.settings.mldsa65Verify = '';
} }
// Reality target/SNI randomizer only available if the helper is loaded
function randomizeRealityTarget() { function randomizeRealityTarget() {
if (!inbound.value?.stream?.reality) return; if (!inbound.value?.stream?.reality) return;
if (typeof window.getRandomRealityTarget !== 'function') return; const t = getRandomRealityTarget();
const t = window.getRandomRealityTarget();
inbound.value.stream.reality.target = t.target; inbound.value.stream.reality.target = t.target;
inbound.value.stream.reality.serverNames = t.sni; inbound.value.stream.reality.serverNames = t.sni;
} }
@ -393,16 +392,29 @@ async function fetchDefaultCertSettings() {
} }
// === VLESS encryption helpers ======================================= // === VLESS encryption helpers =======================================
// `xray vlessenc` returns both X25519 and ML-KEM-768 variants every // `xray vlessenc` returns both X25519 and ML-KEM-768 auth variants every
// call; the user clicks one of two buttons to pick which block goes // call; the user clicks one button to pick which block goes into
// into decryption/encryption. // decryption/encryption. Both generated strings share the same hybrid
async function getNewVlessEnc(authLabel) { // mlkem768x25519plus prefix; the auth choice is the final key block.
if (!authLabel || !inbound.value?.settings) return; 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; saving.value = true;
try { try {
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc'); const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
if (!msg?.success) return; 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; if (!block) return;
inbound.value.settings.decryption = block.decryption; inbound.value.settings.decryption = block.decryption;
inbound.value.settings.encryption = block.encryption; inbound.value.settings.encryption = block.encryption;
@ -417,6 +429,17 @@ function clearVlessEnc() {
inbound.value.settings.encryption = 'none'; 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 ========================= // === SS method change tracks legacy semantics =========================
function onSSMethodChange() { function onSSMethodChange() {
inbound.value.settings.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method); inbound.value.settings.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
@ -731,14 +754,17 @@ watch(
</a-form-item> </a-form-item>
<a-form-item label=" "> <a-form-item label=" ">
<a-space :size="8" wrap> <a-space :size="8" wrap>
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('X25519, not Post-Quantum')"> <a-button type="primary" :loading="saving" @click="getNewVlessEnc('x25519')">
X25519 X25519 auth
</a-button> </a-button>
<a-button type="primary" :loading="saving" @click="getNewVlessEnc('ML-KEM-768, Post-Quantum')"> <a-button type="primary" :loading="saving" @click="getNewVlessEnc('mlkem768')">
ML-KEM-768 ML-KEM-768 auth
</a-button> </a-button>
<a-button danger @click="clearVlessEnc">Clear</a-button> <a-button danger @click="clearVlessEnc">Clear</a-button>
</a-space> </a-space>
<a-typography-text type="secondary" class="vless-auth-state">
Selected: {{ selectedVlessAuth }}
</a-typography-text>
</a-form-item> </a-form-item>
</a-form> </a-form>
@ -1644,6 +1670,74 @@ watch(
</a-select> </a-select>
</a-form-item> </a-form-item>
</template> </template>
<!-- ====== Hysteria Masquerade ====== -->
<!-- Per https://xtls.github.io/config/transports/hysteria.html#masqobject -->
<template v-if="protocol === Protocols.HYSTERIA">
<a-form-item label="Masquerade">
<a-switch v-model:checked="inbound.stream.hysteria.masqueradeSwitch" />
</a-form-item>
<template v-if="inbound.stream.hysteria.masqueradeSwitch">
<a-form-item label="Type">
<a-select v-model:value="inbound.stream.hysteria.masquerade.type" :style="{ width: '50%' }">
<a-select-option value="proxy">Proxy</a-select-option>
<a-select-option value="file">File</a-select-option>
<a-select-option value="string">String</a-select-option>
</a-select>
</a-form-item>
<!-- Proxy type: url / rewriteHost / insecure -->
<template v-if="inbound.stream.hysteria.masquerade.type === 'proxy'">
<a-form-item label="URL">
<a-input v-model:value="inbound.stream.hysteria.masquerade.url" placeholder="https://example.com" />
</a-form-item>
<a-form-item label="Rewrite Host">
<a-switch v-model:checked="inbound.stream.hysteria.masquerade.rewriteHost" />
</a-form-item>
<a-form-item label="Insecure">
<a-switch v-model:checked="inbound.stream.hysteria.masquerade.insecure" />
</a-form-item>
</template>
<!-- File type: dir -->
<a-form-item v-if="inbound.stream.hysteria.masquerade.type === 'file'" label="Directory">
<a-input v-model:value="inbound.stream.hysteria.masquerade.dir" placeholder="/path/to/www" />
</a-form-item>
<!-- String type: content / statusCode / headers -->
<template v-if="inbound.stream.hysteria.masquerade.type === 'string'">
<a-form-item label="Content">
<a-textarea v-model:value="inbound.stream.hysteria.masquerade.content"
:auto-size="{ minRows: 2, maxRows: 6 }" />
</a-form-item>
<a-form-item label="Status Code">
<a-input-number v-model:value="inbound.stream.hysteria.masquerade.statusCode" :min="100" :max="599"
placeholder="200" />
</a-form-item>
<a-form-item label="Headers">
<a-button size="small" @click="inbound.stream.hysteria.masquerade.addHeader('', '')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item v-if="inbound.stream.hysteria.masquerade.headers.length > 0" :wrapper-col="{ span: 24 }">
<a-input-group v-for="(h, idx) in inbound.stream.hysteria.masquerade.headers" :key="`mh-${idx}`"
compact class="mb-8">
<a-input :style="{ width: '45%' }" v-model:value="h.name" placeholder="Name">
<template #addonBefore>{{ idx + 1 }}</template>
</a-input>
<a-input :style="{ width: '45%' }" v-model:value="h.value" placeholder="Value" />
<a-button @click="inbound.stream.hysteria.masquerade.removeHeader(idx)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</a-form-item>
</template>
</template>
</template>
</a-form> </a-form>
<!-- ====== FinalMask (TCP/UDP masks + QUIC params) ====== --> <!-- ====== FinalMask (TCP/UDP masks + QUIC params) ====== -->
@ -1741,6 +1835,11 @@ watch(
color: #ff4d4f; color: #ff4d4f;
} }
.vless-auth-state {
display: block;
margin-top: 6px;
}
.json-editor { .json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px; font-size: 12px;

View file

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { import {
PlusOutlined, PlusOutlined,
@ -67,9 +67,29 @@ const emit = defineEmits([
]); ]);
// ============ Toolbar / search & filter ============================= // ============ Toolbar / search & filter =============================
const enableFilter = ref(false); const FILTER_STATE_KEY = 'inboundsFilterState';
const searchKey = ref(''); const savedFilterState = (() => {
const filterBy = ref(''); try {
return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
} catch (_e) {
return {};
}
})();
const enableFilter = ref(!!savedFilterState.enableFilter);
const searchKey = ref(savedFilterState.searchKey || '');
const filterBy = ref(savedFilterState.filterBy || '');
const protocolFilter = ref(savedFilterState.protocolFilter || '');
const nodeFilter = ref(savedFilterState.nodeFilter || '');
watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
enableFilter: enableFilter.value,
searchKey: searchKey.value,
filterBy: filterBy.value,
protocolFilter: protocolFilter.value,
nodeFilter: nodeFilter.value,
}));
});
// Toggle the filter mode flip cleans the other input. // Toggle the filter mode flip cleans the other input.
function onToggleFilter() { function onToggleFilter() {
@ -77,6 +97,35 @@ function onToggleFilter() {
else filterBy.value = ''; else filterBy.value = '';
} }
const protocolOptions = computed(() => {
const values = new Set(props.dbInbounds.map((i) => i.protocol).filter(Boolean));
return [...values].sort();
});
const nodeOptions = computed(() => {
const values = new Map();
if (props.dbInbounds.some((i) => i.nodeId == null)) {
values.set('local', t('pages.inbounds.localPanel'));
}
for (const dbInbound of props.dbInbounds) {
if (dbInbound.nodeId == null) continue;
const node = props.nodesById.get(dbInbound.nodeId);
values.set(String(dbInbound.nodeId), node?.name || `#${dbInbound.nodeId}`);
}
return [...values.entries()].map(([value, label]) => ({ value, label }));
});
function applySecondaryFilters(rows) {
return rows.filter((dbInbound) => {
if (protocolFilter.value && dbInbound.protocol !== protocolFilter.value) return false;
if (nodeFilter.value) {
const nodeValue = dbInbound.nodeId == null ? 'local' : String(dbInbound.nodeId);
if (nodeValue !== nodeFilter.value) return false;
}
return true;
});
}
// ============ Search / filter projection ============================= // ============ Search / filter projection =============================
// Mirrors the legacy logic: when searching, keep inbounds that match // Mirrors the legacy logic: when searching, keep inbounds that match
// anywhere (deep search); when filtering, keep inbounds that have at // anywhere (deep search); when filtering, keep inbounds that have at
@ -99,7 +148,7 @@ function projectInbound(dbInbound, predicate) {
const visibleInbounds = computed(() => { const visibleInbounds = computed(() => {
if (enableFilter.value) { if (enableFilter.value) {
if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds]; if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
const out = []; const out = [];
for (const dbInbound of props.dbInbounds) { for (const dbInbound of props.dbInbounds) {
const c = props.clientCount[dbInbound.id]; const c = props.clientCount[dbInbound.id];
@ -107,15 +156,65 @@ const visibleInbounds = computed(() => {
const list = c[filterBy.value]; const list = c[filterBy.value];
out.push(projectInbound(dbInbound, (client) => list.includes(client.email))); out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
} }
return out; return applySecondaryFilters(out);
} }
if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds]; if (ObjectUtil.isEmpty(searchKey.value)) return applySecondaryFilters([...props.dbInbounds]);
const out = []; const out = [];
for (const dbInbound of props.dbInbounds) { for (const dbInbound of props.dbInbounds) {
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue; if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value))); out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
} }
return out; return applySecondaryFilters(out);
});
// ============ Sorting =================================================
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 ================================================= // ============ Columns =================================================
@ -128,23 +227,23 @@ const hasAnyRemark = computed(() =>
const desktopColumns = computed(() => { const desktopColumns = computed(() => {
const cols = [ 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.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) { 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) { 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( cols.push(
{ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, sortableCol({ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, 'port'),
{ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
{ title: t('clients'), key: 'clients', align: 'left', width: 50 }, sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
{ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, sortableCol({ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, 'allTimeInbound'),
{ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
); );
return cols; return cols;
}); });
@ -269,13 +368,25 @@ function showQrCodeMenu(dbInbound) {
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button> <a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
<a-radio-button value="online">{{ t('online') }}</a-radio-button> <a-radio-button value="online">{{ t('online') }}</a-radio-button>
</a-radio-group> </a-radio-group>
<a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
:size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
<a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
{{ protocol }}
</a-select-option>
</a-select>
<a-select v-if="nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
:placeholder="t('pages.inbounds.node')" :size="isMobile ? 'small' : 'middle'" :style="{ width: '170px' }">
<a-select-option v-for="node in nodeOptions" :key="node.value" :value="node.value">
{{ node.label }}
</a-select-option>
</a-select>
</div> </div>
<!-- ====================== Mobile: card list ======================= --> <!-- ====================== Mobile: card list ======================= -->
<div v-if="isMobile" class="inbound-cards"> <div v-if="isMobile" class="inbound-cards">
<div v-if="visibleInbounds.length === 0" class="card-empty"></div> <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 --> <!-- Header: chevron (multi-user only) + remark + enable + actions -->
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)"> <div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
<RightOutlined v-if="record.isMultiUser()" class="card-expand" <RightOutlined v-if="record.isMultiUser()" class="card-expand"
@ -345,8 +456,8 @@ function showQrCodeMenu(dbInbound) {
<div class="stat-row"> <div class="stat-row">
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span> <span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
<a-tag color="purple">{{ record.protocol }}</a-tag> <a-tag color="purple">{{ record.protocol }}</a-tag>
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS"> <template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag> <a-tag color="green">{{ record.isHysteria ? 'UDP' : record.toInbound().stream.network }}</a-tag>
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag> <a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag> <a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template> </template>
@ -380,7 +491,7 @@ function showQrCodeMenu(dbInbound) {
</div> </div>
<div v-if="clientCount[record.id]" class="stat-row"> <div v-if="clientCount[record.id]" class="stat-row">
<span class="stat-label">{{ t('clients') }}</span> <span class="stat-label">{{ t('clients') }}</span>
<a-tag color="green">{{ clientCount[record.id].clients }}</a-tag> <a-tag color="green" class="client-count-tag">{{ clientCount[record.id].clients }}</a-tag>
<a-tag v-if="clientCount[record.id].online.length" color="blue"> <a-tag v-if="clientCount[record.id].online.length" color="blue">
{{ clientCount[record.id].online.length }} {{ t('online') }} {{ clientCount[record.id].online.length }} {{ t('online') }}
</a-tag> </a-tag>
@ -407,7 +518,7 @@ function showQrCodeMenu(dbInbound) {
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients"> <div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff" <ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
:page-size="pageSize" :page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)" @edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
@info-client="(p) => emit('info-client', p)" @info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)" @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@ -419,9 +530,9 @@ function showQrCodeMenu(dbInbound) {
</div> </div>
<!-- ====================== Desktop: a-table ======================== --> <!-- ====================== Desktop: a-table ======================== -->
<a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id" <a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
:pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small" :pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')"> :row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
<!-- Per-inbound client list, expanded by clicking the row's <!-- Per-inbound client list, expanded by clicking the row's
default expand chevron. Hidden via row-class-name for default expand chevron. Hidden via row-class-name for
non-multi-user inbounds (matches legacy behavior). --> 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" <ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients" :traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
:total-client-count="clientCount[record.id]?.clients || 0"
@edit-client="(p) => emit('edit-client', p)" @edit-client="(p) => emit('edit-client', p)"
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)" @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@ -520,8 +632,8 @@ function showQrCodeMenu(dbInbound) {
<template v-else-if="column.key === 'protocol'"> <template v-else-if="column.key === 'protocol'">
<div class="protocol-tags"> <div class="protocol-tags">
<a-tag color="purple">{{ record.protocol }}</a-tag> <a-tag color="purple">{{ record.protocol }}</a-tag>
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS"> <template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
<a-tag color="green">{{ record.toInbound().stream.network }}</a-tag> <a-tag color="green">{{ record.isHysteria ? 'UDP' : record.toInbound().stream.network }}</a-tag>
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag> <a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag> <a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
</template> </template>
@ -531,14 +643,14 @@ function showQrCodeMenu(dbInbound) {
<!-- ============== Clients tag + popovers ============== --> <!-- ============== Clients tag + popovers ============== -->
<template v-else-if="column.key === 'clients'"> <template v-else-if="column.key === 'clients'">
<template v-if="clientCount[record.id]"> <template v-if="clientCount[record.id]">
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag> <a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].clients }}</a-tag>
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')"> <a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
<template #content> <template #content>
<div class="client-email-list"> <div class="client-email-list">
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
</div> </div>
</template> </template>
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag> <a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
</a-popover> </a-popover>
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')"> <a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
<template #content> <template #content>
@ -546,7 +658,7 @@ function showQrCodeMenu(dbInbound) {
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
</div> </div>
</template> </template>
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length <a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
}}</a-tag> }}</a-tag>
</a-popover> </a-popover>
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')"> <a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
@ -555,7 +667,7 @@ function showQrCodeMenu(dbInbound) {
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
</div> </div>
</template> </template>
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length <a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
}}</a-tag> }}</a-tag>
</a-popover> </a-popover>
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')"> <a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
@ -564,7 +676,7 @@ function showQrCodeMenu(dbInbound) {
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
</div> </div>
</template> </template>
<a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag> <a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
</a-popover> </a-popover>
</template> </template>
</template> </template>
@ -630,7 +742,7 @@ function showQrCodeMenu(dbInbound) {
} }
.filter-bar.mobile>* { .filter-bar.mobile>* {
margin-bottom: 4px; margin-bottom: 4px;
} }
.protocol-tags { .protocol-tags {
@ -639,6 +751,10 @@ function showQrCodeMenu(dbInbound) {
gap: 4px; gap: 4px;
} }
.client-count-tag {
font-variant-numeric: tabular-nums;
}
.row-action-trigger { .row-action-trigger {
font-size: 20px; font-size: 20px;
cursor: pointer; cursor: pointer;

View file

@ -21,6 +21,7 @@ import InboundList from './InboundList.vue';
import InboundFormModal from './InboundFormModal.vue'; import InboundFormModal from './InboundFormModal.vue';
import ClientFormModal from './ClientFormModal.vue'; import ClientFormModal from './ClientFormModal.vue';
import ClientBulkModal from './ClientBulkModal.vue'; import ClientBulkModal from './ClientBulkModal.vue';
import CopyClientsModal from './CopyClientsModal.vue';
import InboundInfoModal from './InboundInfoModal.vue'; import InboundInfoModal from './InboundInfoModal.vue';
import QrCodeModal from './QrCodeModal.vue'; import QrCodeModal from './QrCodeModal.vue';
import TextModal from '@/components/TextModal.vue'; import TextModal from '@/components/TextModal.vue';
@ -88,6 +89,8 @@ const clientIndex = ref(null);
const bulkOpen = ref(false); const bulkOpen = ref(false);
const bulkDbInbound = ref(null); const bulkDbInbound = ref(null);
const copyOpen = ref(false);
const copyDbInbound = ref(null);
// === Info / QR-code modals =========================================== // === Info / QR-code modals ===========================================
const infoOpen = ref(false); const infoOpen = ref(false);
@ -393,7 +396,7 @@ function confirmResetTraffic(dbInbound) {
okText: 'Reset', okText: 'Reset',
cancelText: 'Cancel', cancelText: 'Cancel',
onOk: async () => { onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllTraffics`); const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
if (msg?.success) await refresh(); if (msg?.success) await refresh();
}, },
}); });
@ -515,10 +518,8 @@ function onRowAction({ key, dbInbound }) {
exportInboundClipboard(dbInbound); exportInboundClipboard(dbInbound);
break; break;
case 'copyClients': case 'copyClients':
// Copy-clients-from-inbound is a tiny dedicated modal in legacy copyDbInbound.value = dbInbound;
// (lets you tick clients to copy across inbounds). Defer to a copyOpen.value = true;
// future commit surface a friendly message for now.
message.info('Copy clients across inbounds — coming soon');
break; break;
case 'delete': case 'delete':
confirmDelete(dbInbound); confirmDelete(dbInbound);
@ -663,6 +664,8 @@ function onRowAction({ key, dbInbound }) {
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" /> :ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable" <ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" /> :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" <InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff" :remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings" :ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { import {
EditOutlined, EditOutlined,
@ -7,6 +7,8 @@ import {
PlusOutlined, PlusOutlined,
ThunderboltOutlined, ThunderboltOutlined,
ExclamationCircleOutlined, ExclamationCircleOutlined,
EyeOutlined,
EyeInvisibleOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import NodeHistoryPanel from './NodeHistoryPanel.vue'; import NodeHistoryPanel from './NodeHistoryPanel.vue';
@ -26,8 +28,6 @@ const emit = defineEmits([
const { t } = useI18n(); 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(() => const dataSource = computed(() =>
props.nodes.map((n) => ({ props.nodes.map((n) => ({
...n, ...n,
@ -36,6 +36,8 @@ const dataSource = computed(() =>
})), })),
); );
const showAddress = ref(false);
function statusColor(status) { function statusColor(status) {
switch (status) { switch (status) {
case 'online': return 'green'; case 'online': return 'green';
@ -97,9 +99,19 @@ function formatPct(p) {
</template> </template>
</a-table-column> </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 }"> <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> </template>
</a-table-column> </a-table-column>
@ -203,4 +215,29 @@ function formatPct(p) {
font-size: 12px; font-size: 12px;
opacity: 0.65; 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> </style>

View file

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

View file

@ -52,10 +52,9 @@ async function sendUpdateUser() {
try { try {
const msg = await HttpUtil.post('/panel/setting/updateUser', user); const msg = await HttpUtil.post('/panel/setting/updateUser', user);
if (msg?.success) { if (msg?.success) {
// Force re-login at the standard logout path; basePath is handled await HttpUtil.post('/logout');
// by the Go router so a relative redirect is correct here. const basePath = window.X_UI_BASE_PATH || '/';
const basePath = window.X_UI_BASE_PATH || ''; window.location.replace(basePath);
window.location.replace(`${basePath}logout`);
} }
} finally { } finally {
updating.value = false; updating.value = false;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

26
go.mod
View file

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

52
go.sum
View file

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

View file

@ -81,11 +81,7 @@ func runWebServer() {
case syscall.SIGHUP: case syscall.SIGHUP:
logger.Info("Received SIGHUP signal. Restarting servers...") logger.Info("Received SIGHUP signal. Restarting servers...")
// --- FIX FOR TELEGRAM BOT CONFLICT (409): Stop bot before restart --- err := server.StopPanelOnly()
service.StopBot()
// --
err := server.Stop()
if err != nil { if err != nil {
logger.Debug("Error stopping web server:", err) logger.Debug("Error stopping web server:", err)
} }
@ -96,7 +92,7 @@ func runWebServer() {
server = web.NewServer() server = web.NewServer()
global.SetWebServer(server) global.SetWebServer(server)
err = server.Start() err = server.StartPanelOnly()
if err != nil { if err != nil {
log.Fatalf("Error restarting web server: %v", err) log.Fatalf("Error restarting web server: %v", err)
return return

View file

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

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

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

View file

@ -29,25 +29,11 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) *
return a return a
} }
// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests
// to hide the existence of API endpoints from unauthorized users.
//
// Two auth paths are accepted:
// 1. Authorization: Bearer <apiToken> — used by remote central panels
// polling this instance as a node. Matches via constant-time compare.
// Sets c.Set("api_authed", true) so CSRFMiddleware can short-circuit.
// 2. Existing session cookie — used by browsers logged into the panel UI.
//
// Anything else falls through to a 404 so the API endpoints remain hidden.
func (a *APIController) checkAPIAuth(c *gin.Context) { func (a *APIController) checkAPIAuth(c *gin.Context) {
auth := c.GetHeader("Authorization") auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") { if strings.HasPrefix(auth, "Bearer ") {
tok := strings.TrimPrefix(auth, "Bearer ") tok := strings.TrimPrefix(auth, "Bearer ")
if a.settingService.MatchApiToken(tok) { if a.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 { if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u) session.SetAPIAuthUser(c, u)
} }
@ -57,7 +43,11 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
} }
} }
if !session.IsLogin(c) { 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 return
} }
c.Next() c.Next()
@ -85,7 +75,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
NewCustomGeoController(api.Group("/custom-geo"), customGeo) NewCustomGeoController(api.Group("/custom-geo"), customGeo)
// Extra routes // Extra routes
api.GET("/backuptotgbot", a.BackuptoTgbot) api.POST("/backuptotgbot", a.BackuptoTgbot)
} }
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins. // BackuptoTgbot sends a backup of the panel data to Telegram bot admins.

View file

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

View file

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

View file

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

View file

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

View file

@ -76,7 +76,13 @@ func (a *SettingController) updateSetting(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return return
} }
oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
err = a.settingService.UpdateAllSetting(allSetting) err = a.settingService.UpdateAllSetting(allSetting)
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
err = bumpErr
}
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
} }

View file

@ -9,29 +9,75 @@ import (
"github.com/mhsanaei/3x-ui/v3/logger" "github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/entity" "github.com/mhsanaei/3x-ui/v3/web/entity"
"github.com/mhsanaei/3x-ui/v3/web/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// getRemoteIp extracts the real IP address from the request headers or remote address. // getRemoteIp extracts the real IP address from the request headers or remote address.
func getRemoteIp(c *gin.Context) string { func getRemoteIp(c *gin.Context) string {
if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok { remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr)
return ip if !ok {
return "unknown"
} }
if xff := c.GetHeader("X-Forwarded-For"); xff != "" { if isTrustedProxy(remoteIP) {
for _, part := range strings.Split(xff, ",") { if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
if ip, ok := extractTrustedIP(part); ok { return ip
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 remoteIP
return ip }
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) { func extractTrustedIP(value string) (string, bool) {

View file

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

View file

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

View file

@ -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. // AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
type AllSetting struct { type AllSetting struct {
// Web server settings // Web server settings
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
WebPort int `json:"webPort" form:"webPort"` // Web server port number WebPort int `json:"webPort" form:"webPort"` // Web server port number
WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server 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 WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server
WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge"` // Session maximum age in minutes
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers
// UI settings // UI settings
PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists PageSize int `json:"pageSize" form:"pageSize"` // Number of items per page in lists
@ -110,6 +111,20 @@ type AllSetting struct {
// JSON subscription routing rules // JSON subscription routing rules
} }
// AllSettingView is the browser-safe settings read model. Secret values
// are redacted from the embedded write model and represented by presence
// flags so the UI can show configured/not configured state.
type AllSettingView struct {
AllSetting
HasTgBotToken bool `json:"hasTgBotToken"`
HasTwoFactorToken bool `json:"hasTwoFactorToken"`
HasLdapPassword bool `json:"hasLdapPassword"`
HasApiToken bool `json:"hasApiToken"`
HasWarpSecret bool `json:"hasWarpSecret"`
HasNordSecret bool `json:"hasNordSecret"`
}
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values. // CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
func (s *AllSetting) CheckValid() error { func (s *AllSetting) CheckValid() error {
if s.WebListen != "" { if s.WebListen != "" {
@ -179,6 +194,19 @@ func (s *AllSetting) CheckValid() error {
s.SubClashPath += "/" s.SubClashPath += "/"
} }
for _, cidr := range strings.Split(s.TrustedProxyCIDRs, ",") {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if ip := net.ParseIP(cidr); ip != nil {
continue
}
if _, _, err := net.ParseCIDR(cidr); err != nil {
return common.NewError("trusted proxy CIDR is not valid:", cidr)
}
}
_, err := time.LoadLocation(s.TimeLocation) _, err := time.LoadLocation(s.TimeLocation)
if err != nil { if err != nil {
return common.NewError("time location not exist:", s.TimeLocation) return common.NewError("time location not exist:", s.TimeLocation)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -493,6 +494,11 @@ func (s *ServerService) sampleCPUUtilization() (float64, error) {
var xrayVersionsClient = &http.Client{Timeout: 10 * time.Second} var xrayVersionsClient = &http.Client{Timeout: 10 * time.Second}
const (
maxXrayArchiveBytes = 200 << 20
maxXrayBinaryBytes = 200 << 20
)
func (s *ServerService) GetXrayVersions() ([]string, error) { func (s *ServerService) GetXrayVersions() ([]string, error) {
const ( const (
XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases" XrayURL = "https://api.github.com/repos/XTLS/Xray-core/releases"
@ -601,28 +607,53 @@ func (s *ServerService) downloadXRay(version string) (string, error) {
fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch) fileName := fmt.Sprintf("Xray-%s-%s.zip", osName, arch)
url := fmt.Sprintf("https://github.com/XTLS/Xray-core/releases/download/%s/%s", version, fileName) url := fmt.Sprintf("https://github.com/XTLS/Xray-core/releases/download/%s/%s", version, fileName)
resp, err := http.Get(url) client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Get(url)
if err != nil { if err != nil {
return "", err return "", err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download xray: unexpected HTTP %d", resp.StatusCode)
}
if resp.ContentLength > maxXrayArchiveBytes {
return "", fmt.Errorf("download xray: archive exceeds %d bytes", maxXrayArchiveBytes)
}
os.Remove(fileName) file, err := os.CreateTemp("", "xray-*.zip")
file, err := os.Create(fileName)
if err != nil { if err != nil {
return "", err return "", err
} }
defer file.Close() path := file.Name()
ok := false
defer func() {
_ = file.Close()
if !ok {
_ = os.Remove(path)
}
}()
_, err = io.Copy(file, resp.Body) n, err := io.Copy(file, io.LimitReader(resp.Body, maxXrayArchiveBytes+1))
if err != nil { if err != nil {
return "", err return "", err
} }
if n > maxXrayArchiveBytes {
return "", fmt.Errorf("download xray: archive exceeds %d bytes", maxXrayArchiveBytes)
}
return fileName, nil ok = true
return path, nil
} }
func (s *ServerService) UpdateXray(version string) error { func (s *ServerService) UpdateXray(version string) error {
versions, err := s.GetXrayVersions()
if err != nil {
return err
}
if !slices.Contains(versions, version) {
return fmt.Errorf("xray version %q is not in the fetched release list", version)
}
// 1. Stop xray before doing anything // 1. Stop xray before doing anything
if err := s.StopXrayService(); err != nil { if err := s.StopXrayService(); err != nil {
logger.Warning("failed to stop xray before update:", err) logger.Warning("failed to stop xray before update:", err)
@ -657,15 +688,42 @@ func (s *ServerService) UpdateXray(version string) error {
return err return err
} }
defer zipFile.Close() defer zipFile.Close()
os.MkdirAll(filepath.Dir(fileName), 0755) if err := os.MkdirAll(filepath.Dir(fileName), 0755); err != nil {
os.Remove(fileName) return err
file, err := os.OpenFile(fileName, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0755) }
tmpFile, err := os.CreateTemp(filepath.Dir(fileName), ".xray-*")
if err != nil { if err != nil {
return err return err
} }
defer file.Close() tmpPath := tmpFile.Name()
_, err = io.Copy(file, zipFile) ok := false
return err 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 // 4. Extract correct binary
@ -1275,7 +1333,13 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
return nil, err 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 auths []map[string]string
var current map[string]string var current map[string]string
@ -1285,14 +1349,18 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
if current != nil { if current != nil {
auths = append(auths, current) auths = append(auths, current)
} }
label := strings.TrimSpace(strings.TrimPrefix(line, "Authentication:"))
current = map[string]string{ 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"`) { } else if strings.HasPrefix(line, `"decryption"`) || strings.HasPrefix(line, `"encryption"`) {
parts := strings.SplitN(line, ":", 2) parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 && current != nil { if len(parts) == 2 && current != nil {
key := strings.Trim(parts[0], `" `) 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 current[key] = val
} }
} }
@ -1302,9 +1370,19 @@ func (s *ServerService) GetNewVlessEnc() (any, error) {
auths = append(auths, current) auths = append(auths, current)
} }
return map[string]any{ return auths
"auths": auths, }
}, nil
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) { func (s *ServerService) GetNewUUID() (map[string]string, error) {

View 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"])
}
}

View file

@ -36,6 +36,7 @@ var defaultValueMap = map[string]string{
"apiToken": "", "apiToken": "",
"webBasePath": "/", "webBasePath": "/",
"sessionMaxAge": "360", "sessionMaxAge": "360",
"trustedProxyCIDRs": "127.0.0.1/32,::1/128",
"pageSize": "25", "pageSize": "25",
"expireDiff": "0", "expireDiff": "0",
"trafficDiff": "0", "trafficDiff": "0",
@ -199,6 +200,32 @@ func (s *SettingService) GetAllSetting() (*entity.AllSetting, error) {
return allSetting, nil return allSetting, nil
} }
func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
allSetting, err := s.GetAllSetting()
if err != nil {
return nil, err
}
view := &entity.AllSettingView{AllSetting: *allSetting}
view.HasTgBotToken = secretConfigured(allSetting.TgBotToken)
view.HasTwoFactorToken = secretConfigured(allSetting.TwoFactorToken)
view.HasLdapPassword = secretConfigured(allSetting.LdapPassword)
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
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 { func (s *SettingService) ResetSettings() error {
db := database.GetDB() db := database.GetDB()
err := db.Where("1 = 1").Delete(model.Setting{}).Error err := db.Where("1 = 1").Delete(model.Setting{}).Error
@ -286,7 +313,11 @@ func (s *SettingService) GetXrayOutboundTestUrl() (string, error) {
} }
func (s *SettingService) SetXrayOutboundTestUrl(url string) error { func (s *SettingService) SetXrayOutboundTestUrl(url string) error {
return s.setString("xrayOutboundTestUrl", url) clean, err := SanitizeHTTPURL(url)
if err != nil {
return err
}
return s.setString("xrayOutboundTestUrl", clean)
} }
func (s *SettingService) GetListen() (string, error) { func (s *SettingService) GetListen() (string, error) {
@ -417,6 +448,10 @@ func (s *SettingService) GetSessionMaxAge() (int, error) {
return s.getInt("sessionMaxAge") return s.getInt("sessionMaxAge")
} }
func (s *SettingService) GetTrustedProxyCIDRs() (string, error) {
return s.getString("trustedProxyCIDRs")
}
func (s *SettingService) GetRemarkModel() (string, error) { func (s *SettingService) GetRemarkModel() (string, error) {
return s.getString("remarkModel") return s.getString("remarkModel")
} }
@ -771,6 +806,12 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
} }
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := s.preserveRedactedSecrets(allSetting); err != nil {
return err
}
if err := validateSettingsURLs(allSetting); err != nil {
return err
}
if err := allSetting.CheckValid(); err != nil { if err := allSetting.CheckValid(); err != nil {
return err return err
} }
@ -791,6 +832,58 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
return common.Combine(errs...) return common.Combine(errs...)
} }
func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting) error {
if strings.TrimSpace(allSetting.TgBotToken) == "" {
value, err := s.GetTgBotToken()
if err != nil {
return err
}
allSetting.TgBotToken = value
}
if strings.TrimSpace(allSetting.LdapPassword) == "" {
value, err := s.GetLdapPassword()
if err != nil {
return err
}
allSetting.LdapPassword = value
}
if allSetting.TwoFactorEnable && strings.TrimSpace(allSetting.TwoFactorToken) == "" {
value, err := s.GetTwoFactorToken()
if err != nil {
return err
}
allSetting.TwoFactorToken = value
}
return nil
}
func validateSettingsURLs(allSetting *entity.AllSetting) error {
if allSetting.ExternalTrafficInformURI != "" {
u, err := SanitizeHTTPURL(allSetting.ExternalTrafficInformURI)
if err != nil {
return common.NewError("external traffic inform URI is invalid:", err)
}
allSetting.ExternalTrafficInformURI = u
}
if allSetting.TgBotAPIServer != "" {
u, err := SanitizeHTTPURL(allSetting.TgBotAPIServer)
if err != nil {
return common.NewError("telegram API server URL is invalid:", err)
}
allSetting.TgBotAPIServer = u
}
return nil
}
func (s *SettingService) UpdateSecret(key string, value string) error {
switch key {
case "tgBotToken", "ldapPassword", "twoFactorToken", "apiToken":
return s.saveSetting(key, strings.TrimSpace(value))
default:
return common.NewError("secret key is not replaceable:", key)
}
}
func (s *SettingService) GetDefaultXrayConfig() (any, error) { func (s *SettingService) GetDefaultXrayConfig() (any, error) {
var jsonData any var jsonData any
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData) err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)

View file

@ -0,0 +1,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)
}
}

View file

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

View file

@ -23,6 +23,7 @@ type trafficWriteRequest struct {
var ( var (
twMu sync.Mutex twMu sync.Mutex
twQueue chan *trafficWriteRequest twQueue chan *trafficWriteRequest
twCtx context.Context
twCancel context.CancelFunc twCancel context.CancelFunc
twDone chan struct{} twDone chan struct{}
) )
@ -37,16 +38,26 @@ var (
func StartTrafficWriter() { func StartTrafficWriter() {
twMu.Lock() twMu.Lock()
defer twMu.Unlock() defer twMu.Unlock()
if twQueue != nil {
return if twCancel != nil && twDone != nil {
select {
case <-twDone:
clearTrafficWriterState()
default:
return
}
} }
queue := make(chan *trafficWriteRequest, trafficWriterQueueSize) queue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{}) done := make(chan struct{})
twQueue = queue twQueue = queue
twCtx = ctx
twCancel = cancel twCancel = cancel
twDone = done twDone = done
go runTrafficWriter(queue, ctx, done)
go runTrafficWriter(ctx, queue, done)
} }
// StopTrafficWriter cancels the writer context and waits for the goroutine to // StopTrafficWriter cancels the writer context and waits for the goroutine to
@ -56,20 +67,30 @@ func StopTrafficWriter() {
twMu.Lock() twMu.Lock()
cancel := twCancel cancel := twCancel
done := twDone done := twDone
twQueue = nil if cancel == nil || done == nil {
twCancel = nil twMu.Unlock()
twDone = nil return
}
cancel()
twMu.Unlock() twMu.Unlock()
if cancel != nil { <-done
cancel()
} twMu.Lock()
if done != nil { if twDone == done {
<-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) defer close(done)
for { for {
select { select {
@ -99,18 +120,43 @@ func safeApply(fn func() error) (err error) {
} }
func submitTrafficWrite(fn func() error) error { func submitTrafficWrite(fn func() error) error {
req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
twMu.Lock() twMu.Lock()
queue := twQueue queue := twQueue
twMu.Unlock() ctx := twCtx
done := twDone
if queue == nil { if queue == nil || ctx == nil || done == nil {
twMu.Unlock()
return safeApply(fn) 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 { select {
case queue <- req: case queue <- req:
case <-time.After(trafficWriterSubmitTimeout): twMu.Unlock()
case <-timer.C:
twMu.Unlock()
return errors.New("traffic writer queue full") 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")
}
}
} }

View 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
View file

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

View file

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

View file

@ -32,10 +32,10 @@ type ObsTagSnapshot struct {
type XrayMetricsService struct { type XrayMetricsService struct {
settingService SettingService settingService SettingService
mu sync.RWMutex mu sync.RWMutex
state xrayMetricsState state xrayMetricsState
client *http.Client client *http.Client
obsByTag map[string]ObsTagSnapshot obsByTag map[string]ObsTagSnapshot
} }
var validObsTag = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`) var validObsTag = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`)

View file

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

View file

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

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "تم إعادة تعيين كل حركة المرور من العميل", "resetAllClientTrafficSuccess": "تم إعادة تعيين كل حركة المرور من العميل",
"resetAllTrafficSuccess": "تم إعادة تعيين كل حركة المرور", "resetAllTrafficSuccess": "تم إعادة تعيين كل حركة المرور",
"resetInboundClientTrafficSuccess": "تم إعادة تعيين حركة المرور", "resetInboundClientTrafficSuccess": "تم إعادة تعيين حركة المرور",
"resetInboundTrafficSuccess": "تم إعادة تعيين حركة مرور الداخل",
"trafficGetError": "خطأ في الحصول على حركات المرور", "trafficGetError": "خطأ في الحصول على حركات المرور",
"getNewX25519CertError": "حدث خطأ أثناء الحصول على شهادة X25519.", "getNewX25519CertError": "حدث خطأ أثناء الحصول على شهادة X25519.",
"getNewmldsa65Error": "حدث خطاء في الحصول على mldsa65.", "getNewmldsa65Error": "حدث خطاء في الحصول على mldsa65.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "All traffic from the client has been reset.", "resetAllClientTrafficSuccess": "All traffic from the client has been reset.",
"resetAllTrafficSuccess": "All traffic has been reset.", "resetAllTrafficSuccess": "All traffic has been reset.",
"resetInboundClientTrafficSuccess": "Traffic has been reset.", "resetInboundClientTrafficSuccess": "Traffic has been reset.",
"resetInboundTrafficSuccess": "Inbound traffic has been reset.",
"trafficGetError": "Error getting traffics.", "trafficGetError": "Error getting traffics.",
"getNewX25519CertError": "Error while obtaining the X25519 certificate.", "getNewX25519CertError": "Error while obtaining the X25519 certificate.",
"getNewmldsa65Error": "Error while obtaining mldsa65.", "getNewmldsa65Error": "Error while obtaining mldsa65.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "Todo el tráfico del cliente ha sido reiniciado", "resetAllClientTrafficSuccess": "Todo el tráfico del cliente ha sido reiniciado",
"resetAllTrafficSuccess": "Todo el tráfico ha sido reiniciado", "resetAllTrafficSuccess": "Todo el tráfico ha sido reiniciado",
"resetInboundClientTrafficSuccess": "El tráfico ha sido reiniciado", "resetInboundClientTrafficSuccess": "El tráfico ha sido reiniciado",
"resetInboundTrafficSuccess": "El tráfico de entrada ha sido reiniciado",
"trafficGetError": "Error al obtener los tráficos", "trafficGetError": "Error al obtener los tráficos",
"getNewX25519CertError": "Error al obtener el certificado X25519.", "getNewX25519CertError": "Error al obtener el certificado X25519.",
"getNewmldsa65Error": "Error al obtener el certificado mldsa65.", "getNewmldsa65Error": "Error al obtener el certificado mldsa65.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "تمام ترافیک کلاینت بازنشانی شد", "resetAllClientTrafficSuccess": "تمام ترافیک کلاینت بازنشانی شد",
"resetAllTrafficSuccess": "تمام ترافیک‌ها بازنشانی شدند", "resetAllTrafficSuccess": "تمام ترافیک‌ها بازنشانی شدند",
"resetInboundClientTrafficSuccess": "ترافیک بازنشانی شد", "resetInboundClientTrafficSuccess": "ترافیک بازنشانی شد",
"resetInboundTrafficSuccess": "ترافیک ورودی بازنشانی شد",
"trafficGetError": "خطا در دریافت ترافیک‌ها", "trafficGetError": "خطا در دریافت ترافیک‌ها",
"getNewX25519CertError": "خطا در دریافت گواهی X25519.", "getNewX25519CertError": "خطا در دریافت گواهی X25519.",
"getNewmldsa65Error": "خطا در دریافت گواهی mldsa65.", "getNewmldsa65Error": "خطا در دریافت گواهی mldsa65.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "Semua lalu lintas klien telah direset", "resetAllClientTrafficSuccess": "Semua lalu lintas klien telah direset",
"resetAllTrafficSuccess": "Semua lalu lintas telah direset", "resetAllTrafficSuccess": "Semua lalu lintas telah direset",
"resetInboundClientTrafficSuccess": "Lalu lintas telah direset", "resetInboundClientTrafficSuccess": "Lalu lintas telah direset",
"resetInboundTrafficSuccess": "Lalu lintas masuk telah direset",
"trafficGetError": "Gagal mendapatkan data lalu lintas", "trafficGetError": "Gagal mendapatkan data lalu lintas",
"getNewX25519CertError": "Terjadi kesalahan saat mendapatkan sertifikat X25519.", "getNewX25519CertError": "Terjadi kesalahan saat mendapatkan sertifikat X25519.",
"getNewmldsa65Error": "Terjadi kesalahan saat mendapatkan sertifikat mldsa65.", "getNewmldsa65Error": "Terjadi kesalahan saat mendapatkan sertifikat mldsa65.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "クライアントのすべてのトラフィックがリセットされました", "resetAllClientTrafficSuccess": "クライアントのすべてのトラフィックがリセットされました",
"resetAllTrafficSuccess": "すべてのトラフィックがリセットされました", "resetAllTrafficSuccess": "すべてのトラフィックがリセットされました",
"resetInboundClientTrafficSuccess": "トラフィックがリセットされました", "resetInboundClientTrafficSuccess": "トラフィックがリセットされました",
"resetInboundTrafficSuccess": "受信トラフィックがリセットされました",
"trafficGetError": "トラフィックの取得中にエラーが発生しました", "trafficGetError": "トラフィックの取得中にエラーが発生しました",
"getNewX25519CertError": "X25519証明書の取得中にエラーが発生しました。", "getNewX25519CertError": "X25519証明書の取得中にエラーが発生しました。",
"getNewmldsa65Error": "mldsa65証明書の取得中にエラーが発生しました。", "getNewmldsa65Error": "mldsa65証明書の取得中にエラーが発生しました。",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "Todo o tráfego do cliente foi reiniciado", "resetAllClientTrafficSuccess": "Todo o tráfego do cliente foi reiniciado",
"resetAllTrafficSuccess": "Todo o tráfego foi reiniciado", "resetAllTrafficSuccess": "Todo o tráfego foi reiniciado",
"resetInboundClientTrafficSuccess": "O tráfego foi reiniciado", "resetInboundClientTrafficSuccess": "O tráfego foi reiniciado",
"resetInboundTrafficSuccess": "O tráfego de entrada foi reiniciado",
"trafficGetError": "Erro ao obter tráfegos", "trafficGetError": "Erro ao obter tráfegos",
"getNewX25519CertError": "Erro ao obter o certificado X25519.", "getNewX25519CertError": "Erro ao obter o certificado X25519.",
"getNewmldsa65Error": "Erro ao obter o certificado mldsa65.", "getNewmldsa65Error": "Erro ao obter o certificado mldsa65.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "Весь трафик клиента сброшен", "resetAllClientTrafficSuccess": "Весь трафик клиента сброшен",
"resetAllTrafficSuccess": "Весь трафик сброшен", "resetAllTrafficSuccess": "Весь трафик сброшен",
"resetInboundClientTrafficSuccess": "Трафик сброшен", "resetInboundClientTrafficSuccess": "Трафик сброшен",
"resetInboundTrafficSuccess": "Входящий трафик сброшен",
"trafficGetError": "Ошибка получения данных о трафике", "trafficGetError": "Ошибка получения данных о трафике",
"getNewX25519CertError": "Ошибка при получении сертификата X25519.", "getNewX25519CertError": "Ошибка при получении сертификата X25519.",
"getNewmldsa65Error": "Ошибка при получении сертификата mldsa65.", "getNewmldsa65Error": "Ошибка при получении сертификата mldsa65.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "İstemcinin tüm trafiği sıfırlandı", "resetAllClientTrafficSuccess": "İstemcinin tüm trafiği sıfırlandı",
"resetAllTrafficSuccess": "Tüm trafik sıfırlandı", "resetAllTrafficSuccess": "Tüm trafik sıfırlandı",
"resetInboundClientTrafficSuccess": "Trafik sıfırlandı", "resetInboundClientTrafficSuccess": "Trafik sıfırlandı",
"resetInboundTrafficSuccess": "Gelen trafik sıfırlandı",
"trafficGetError": "Trafik bilgisi alınırken hata oluştu", "trafficGetError": "Trafik bilgisi alınırken hata oluştu",
"getNewX25519CertError": "X25519 sertifikası alınırken hata oluştu.", "getNewX25519CertError": "X25519 sertifikası alınırken hata oluştu.",
"getNewmldsa65Error": "mldsa65 sertifikası alınırken hata oluştu.", "getNewmldsa65Error": "mldsa65 sertifikası alınırken hata oluştu.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "Весь трафік клієнта скинуто", "resetAllClientTrafficSuccess": "Весь трафік клієнта скинуто",
"resetAllTrafficSuccess": "Весь трафік скинуто", "resetAllTrafficSuccess": "Весь трафік скинуто",
"resetInboundClientTrafficSuccess": "Трафік скинуто", "resetInboundClientTrafficSuccess": "Трафік скинуто",
"resetInboundTrafficSuccess": "Трафік вхідного потоку скинуто",
"trafficGetError": "Помилка отримання даних про трафік", "trafficGetError": "Помилка отримання даних про трафік",
"getNewX25519CertError": "Помилка при отриманні сертифіката X25519.", "getNewX25519CertError": "Помилка при отриманні сертифіката X25519.",
"getNewmldsa65Error": "Помилка при отриманні сертифіката mldsa65.", "getNewmldsa65Error": "Помилка при отриманні сертифіката mldsa65.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng client", "resetAllClientTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng client",
"resetAllTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng", "resetAllTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng",
"resetInboundClientTrafficSuccess": "Đã đặt lại lưu lượng", "resetInboundClientTrafficSuccess": "Đã đặt lại lưu lượng",
"resetInboundTrafficSuccess": "Đã đặt lại lưu lượng Inbound",
"trafficGetError": "Lỗi khi lấy thông tin lưu lượng", "trafficGetError": "Lỗi khi lấy thông tin lưu lượng",
"getNewX25519CertError": "Lỗi khi lấy chứng chỉ X25519.", "getNewX25519CertError": "Lỗi khi lấy chứng chỉ X25519.",
"getNewmldsa65Error": "Lỗi khi lấy chứng chỉ mldsa65.", "getNewmldsa65Error": "Lỗi khi lấy chứng chỉ mldsa65.",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "客户端所有流量已重置", "resetAllClientTrafficSuccess": "客户端所有流量已重置",
"resetAllTrafficSuccess": "所有流量已重置", "resetAllTrafficSuccess": "所有流量已重置",
"resetInboundClientTrafficSuccess": "流量已重置", "resetInboundClientTrafficSuccess": "流量已重置",
"resetInboundTrafficSuccess": "入站流量已重置",
"trafficGetError": "获取流量数据时出错", "trafficGetError": "获取流量数据时出错",
"getNewX25519CertError": "获取X25519证书时出错。", "getNewX25519CertError": "获取X25519证书时出错。",
"getNewmldsa65Error": "获取mldsa65证书时出错。", "getNewmldsa65Error": "获取mldsa65证书时出错。",

View file

@ -341,6 +341,7 @@
"resetAllClientTrafficSuccess": "客戶端所有流量已重置", "resetAllClientTrafficSuccess": "客戶端所有流量已重置",
"resetAllTrafficSuccess": "所有流量已重置", "resetAllTrafficSuccess": "所有流量已重置",
"resetInboundClientTrafficSuccess": "流量已重置", "resetInboundClientTrafficSuccess": "流量已重置",
"resetInboundTrafficSuccess": "入站流量已重置",
"trafficGetError": "取得流量資料時發生錯誤", "trafficGetError": "取得流量資料時發生錯誤",
"getNewX25519CertError": "取得X25519憑證時發生錯誤。", "getNewX25519CertError": "取得X25519憑證時發生錯誤。",
"getNewmldsa65Error": "取得mldsa65憑證時發生錯誤。", "getNewmldsa65Error": "取得mldsa65憑證時發生錯誤。",

View file

@ -259,11 +259,13 @@ func (s *Server) initRouter() (*gin.Engine, error) {
// startTask schedules background jobs (Xray checks, traffic jobs, cron // startTask schedules background jobs (Xray checks, traffic jobs, cron
// jobs) which the panel relies on for periodic maintenance and monitoring. // jobs) which the panel relies on for periodic maintenance and monitoring.
func (s *Server) startTask() { func (s *Server) startTask(restartXray bool) {
s.customGeoService.EnsureOnStartup() s.customGeoService.EnsureOnStartup()
err := s.xrayService.RestartXray(true) if restartXray {
if err != nil { err := s.xrayService.RestartXray(true)
logger.Warning("start xray failed:", err) if err != nil {
logger.Warning("start xray failed:", err)
}
} }
// Check whether xray is running every second // Check whether xray is running every second
s.cron.AddJob("@every 1s", job.NewCheckXrayRunningJob()) 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. // Start initializes and starts the web server with configured settings, routes, and background jobs.
func (s *Server) Start() (err error) { 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 // This is an anonymous function, no function name
defer func() { defer func() {
if err != nil { if err != nil {
@ -420,19 +431,25 @@ func (s *Server) Start() (err error) {
s.listener = listener s.listener = listener
s.httpServer = &http.Server{ 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() { go func() {
s.httpServer.Serve(listener) s.httpServer.Serve(listener)
}() }()
s.startTask() s.startTask(restartXray)
isTgbotenabled, err := s.settingService.GetTgbotEnabled() if startTgBot {
if (err == nil) && (isTgbotenabled) { isTgbotenabled, err := s.settingService.GetTgbotEnabled()
tgBot := s.tgbotService.NewTgbot() if (err == nil) && (isTgbotenabled) {
tgBot.Start(i18nFS) tgBot := s.tgbotService.NewTgbot()
tgBot.Start(i18nFS)
}
} }
return nil 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. // Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot.
func (s *Server) Stop() error { 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.cancel()
s.xrayService.StopXray() if stopXray {
s.xrayService.StopXray()
}
if s.cron != nil { if s.cron != nil {
s.cron.Stop() s.cron.Stop()
} }
service.StopTrafficWriter() if stopXray {
if s.tgbotService.IsRunning() { service.StopTrafficWriter()
}
if stopTgBot && s.tgbotService.IsRunning() {
s.tgbotService.Stop() s.tgbotService.Stop()
} }
// Gracefully stop WebSocket hub // Gracefully stop WebSocket hub

View file

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