diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0d08e261..e9b37e82 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,11 @@ updates: directory: "/" # Location of package manifests schedule: interval: "weekly" + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "npm" + directory: "/frontend" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..31a6a70b --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6b397b61..966c581b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,31 @@ name: "CodeQL Advanced" on: push: + branches: + - main tags-ignore: - "v*" + paths: + - "**.go" + - "go.mod" + - "go.sum" + - "**.js" + - "**.mjs" + - "**.cjs" + - "**.ts" + - "**.vue" + - "frontend/package-lock.json" pull_request: + paths: + - "**.go" + - "go.mod" + - "go.sum" + - "**.js" + - "**.mjs" + - "**.cjs" + - "**.ts" + - "**.vue" + - "frontend/package-lock.json" schedule: - cron: "18 2 * * 2" @@ -35,9 +57,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - # The Go binary embeds web/dist/ via //go:embed all:dist (web/web.go). - # web/dist/ is .gitignored, so CodeQL's autobuild for Go will fail with - # "pattern all:dist: no matching files found" unless vite emits it first. - name: Setup Node.js if: matrix.language == 'go' uses: actions/setup-node@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b031967..3b638eab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,17 @@ on: - "x-ui.service.arch" - "x-ui.service.rhel" pull_request: + paths: + - "**.js" + - "**.css" + - "**.html" + - "**.sh" + - "**.go" + - "go.mod" + - "go.sum" + - "x-ui.service.debian" + - "x-ui.service.arch" + - "x-ui.service.rhel" jobs: build: diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/DockerEntrypoint.sh b/DockerEntrypoint.sh index 2e5dd750..38786b14 100644 --- a/DockerEntrypoint.sh +++ b/DockerEntrypoint.sh @@ -22,7 +22,7 @@ EOF cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF' [Definition] -datepattern = ^%Y/%m/%d %H:%M:%S +datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnecting OLD IP\s*=\s*\s*\|\|\s*Timestamp\s*=\s*\d+ ignoreregex = EOF diff --git a/database/db.go b/database/db.go index 64d3765d..e1694e1a 100644 --- a/database/db.go +++ b/database/db.go @@ -40,6 +40,7 @@ func initModels() error { &model.HistoryOfSeeders{}, &model.CustomGeoResource{}, &model.Node{}, + &model.ApiToken{}, } for _, model := range models { if err := db.AutoMigrate(model); err != nil { @@ -86,43 +87,80 @@ func runSeeders(isUsersEmpty bool) error { hashSeeder := &model.HistoryOfSeeders{ SeederName: "UserPasswordHash", } - return db.Create(hashSeeder).Error - } else { - var seedersHistory []string - if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil { - log.Printf("Error fetching seeder history: %v", err) + if err := db.Create(hashSeeder).Error; err != nil { + return err + } + return seedApiTokens() + } + + var seedersHistory []string + if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil { + log.Printf("Error fetching seeder history: %v", err) + return err + } + + if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty { + var users []model.User + if err := db.Find(&users).Error; err != nil { + log.Printf("Error fetching users for password migration: %v", err) return err } - if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty { - var users []model.User - if err := db.Find(&users).Error; err != nil { - log.Printf("Error fetching users for password migration: %v", err) + for _, user := range users { + hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password) + if err != nil { + log.Printf("Error hashing password for user '%s': %v", user.Username, err) return err } - - for _, user := range users { - hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password) - if err != nil { - log.Printf("Error hashing password for user '%s': %v", user.Username, err) - return err - } - if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil { - log.Printf("Error updating password for user '%s': %v", user.Username, err) - return err - } + if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil { + log.Printf("Error updating password for user '%s': %v", user.Username, err) + return err } + } - hashSeeder := &model.HistoryOfSeeders{ - SeederName: "UserPasswordHash", - } - return db.Create(hashSeeder).Error + hashSeeder := &model.HistoryOfSeeders{ + SeederName: "UserPasswordHash", + } + if err := db.Create(hashSeeder).Error; err != nil { + return err } } + if !slices.Contains(seedersHistory, "ApiTokensTable") { + if err := seedApiTokens(); err != nil { + return err + } + } return nil } +// seedApiTokens copies the legacy `apiToken` setting into the new +// api_tokens table as a row named "default" so existing central panels +// keep working after the upgrade. Idempotent — records itself in +// history_of_seeders and only runs when api_tokens is empty. +func seedApiTokens() error { + empty, err := isTableEmpty("api_tokens") + if err != nil { + return err + } + if empty { + var legacy model.Setting + err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error + if err == nil && legacy.Value != "" { + row := &model.ApiToken{ + Name: "default", + Token: legacy.Value, + Enabled: true, + } + if err := db.Create(row).Error; err != nil { + log.Printf("Error migrating legacy apiToken: %v", err) + return err + } + } + } + return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error +} + // isTableEmpty returns true if the named table contains zero rows. func isTableEmpty(tableName string) (bool, error) { var count int64 diff --git a/database/model/model.go b/database/model/model.go index 56a76b6e..d71e0589 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -21,12 +21,8 @@ const ( Shadowsocks Protocol = "shadowsocks" Mixed Protocol = "mixed" WireGuard Protocol = "wireguard" - // UI stores Hysteria v1 and v2 both as "hysteria" and uses - // settings.version to discriminate. Imports from outside the panel - // can carry the literal "hysteria2" string, so IsHysteria below - // accepts both. - Hysteria Protocol = "hysteria" - Hysteria2 Protocol = "hysteria2" + Hysteria Protocol = "hysteria" + Hysteria2 Protocol = "hysteria2" ) // IsHysteria returns true for both "hysteria" and "hysteria2". @@ -38,9 +34,10 @@ func IsHysteria(p Protocol) bool { // User represents a user account in the 3x-ui panel. type User struct { - Id int `json:"id" gorm:"primaryKey;autoIncrement"` - Username string `json:"username"` - Password string `json:"password"` + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + Username string `json:"username"` + Password string `json:"password"` + LoginEpoch int64 `json:"-" gorm:"default:0"` } // Inbound represents an Xray inbound configuration with traffic statistics and settings. @@ -66,12 +63,7 @@ type Inbound struct { StreamSettings string `json:"streamSettings" form:"streamSettings"` Tag string `json:"tag" form:"tag" gorm:"unique"` Sniffing string `json:"sniffing" form:"sniffing"` - - // NodeID points at the remote panel (Node) where this inbound's xray - // actually runs. NULL means the inbound runs on the local xray (the - // pre-multi-node behaviour). Existing rows migrate to NULL with no - // backfill. - NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"` + NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"` } // OutboundTraffics tracks traffic statistics for Xray outbound connections. @@ -96,6 +88,14 @@ type HistoryOfSeeders struct { SeederName string `json:"seederName"` } +type ApiToken struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"uniqueIndex;not null"` + Token string `json:"token" gorm:"not null"` + Enabled bool `json:"enabled" gorm:"default:true"` + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` +} + // GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model. func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { listen := i.Listen @@ -128,15 +128,16 @@ type Setting struct { // endpoint over HTTP using the per-node ApiToken to populate the runtime // status fields below. type Node struct { - Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` - Name string `json:"name" form:"name" gorm:"uniqueIndex"` - Remark string `json:"remark" form:"remark"` - Scheme string `json:"scheme" form:"scheme"` - Address string `json:"address" form:"address"` - Port int `json:"port" form:"port"` - BasePath string `json:"basePath" form:"basePath"` - ApiToken string `json:"apiToken" form:"apiToken"` - Enable bool `json:"enable" form:"enable" gorm:"default:true"` + Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" form:"name" gorm:"uniqueIndex"` + Remark string `json:"remark" form:"remark"` + Scheme string `json:"scheme" form:"scheme"` + Address string `json:"address" form:"address"` + Port int `json:"port" form:"port"` + BasePath string `json:"basePath" form:"basePath"` + ApiToken string `json:"apiToken" form:"apiToken"` + Enable bool `json:"enable" form:"enable" gorm:"default:true"` + AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"` // Heartbeat-updated fields. UpdatedAt advances on every probe even when // the row is otherwise unchanged so the UI's "last seen" tooltip is diff --git a/frontend/api-docs.html b/frontend/api-docs.html index 9f35080a..65ee57f7 100644 --- a/frontend/api-docs.html +++ b/frontend/api-docs.html @@ -3,7 +3,7 @@ - 3x-ui · API Docs + API Docs
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index c1749c08..95c46dbb 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -19,9 +19,6 @@ export default [ globals: { ...globals.browser, ...globals.node, - // Legacy script tags inject a couple of helpers on window before - // the SPA boots; declared here so no-undef stops flagging them. - getRandomRealityTarget: 'readonly', }, }, rules: { diff --git a/frontend/inbounds.html b/frontend/inbounds.html index 57485c92..9e8861fc 100644 --- a/frontend/inbounds.html +++ b/frontend/inbounds.html @@ -3,7 +3,7 @@ - 3x-ui · Inbounds + Inbounds
diff --git a/frontend/index.html b/frontend/index.html index b2d45443..d13b400f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - 3x-ui + Overview
diff --git a/frontend/login.html b/frontend/login.html index ba8e1e05..658f8d10 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -4,7 +4,7 @@ - 3x-ui — Sign in + Sign in
diff --git a/frontend/nodes.html b/frontend/nodes.html index fb607b19..fec96dbc 100644 --- a/frontend/nodes.html +++ b/frontend/nodes.html @@ -3,7 +3,7 @@ - 3x-ui · Nodes + Nodes
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3bfbce76..365a2324 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "3x-ui-frontend", "version": "0.0.2", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.0.0" + }, "dependencies": { "@ant-design/icons-vue": "^7.0.1", "ant-design-vue": "^4.2.6", diff --git a/frontend/package.json b/frontend/package.json index bf0ca9c1..03c381f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,10 @@ "version": "0.0.2", "type": "module", "description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.0.0" + }, "scripts": { "dev": "vite", "build": "vite build", diff --git a/frontend/settings.html b/frontend/settings.html index da144ba7..0ef6413b 100644 --- a/frontend/settings.html +++ b/frontend/settings.html @@ -3,7 +3,7 @@ - 3x-ui · Settings + Settings
diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js index cae11195..2ea235c5 100644 --- a/frontend/src/api/axios-init.js +++ b/frontend/src/api/axios-init.js @@ -2,24 +2,16 @@ import axios from 'axios'; import qs from 'qs'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']); -// Public CSRF endpoint — works pre-login (the panel-scoped -// /panel/csrf-token sits behind checkLogin and would 401 a fresh -// login page that hasn't authenticated yet). const CSRF_TOKEN_PATH = '/csrf-token'; -// Cached session CSRF token. The legacy panel injects it via a -// tag rendered by Go; the new SPA pages -// fetch it once from /panel/csrf-token instead. Module-level so -// every axios POST sees the latest value. let csrfToken = null; let csrfFetchPromise = null; +let sessionExpired = false; function readMetaToken() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null; } -// Fetch the token via a bare fetch() (not axios) so the call doesn't -// recurse through this same interceptor. async function fetchCsrfToken() { try { const basePath = window.X_UI_BASE_PATH; @@ -91,19 +83,12 @@ export function setupAxios() { async (error) => { const status = error.response?.status; if (status === 401) { - // 401 → session is gone. In production, the panel routes - // are gated by Go's checkLogin which redirects to base_path - // serving the login page; a reload is enough. In dev, Vite - // serves /index.html directly at "/", so a reload would put - // the user right back on the dashboard and the interceptor - // would loop. Navigate to the dev login entry instead. - if (import.meta.env.DEV) { + if (!sessionExpired) { + sessionExpired = true; const basePath = window.X_UI_BASE_PATH || '/'; - window.location.href = `${basePath}login.html`; - } else { - window.location.reload(); + window.location.replace(basePath); } - return Promise.reject(error); + return new Promise(() => { }); } // 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once. const cfg = error.config; diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 7763b715..bf625f65 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -14,6 +14,7 @@ import { } from '@ant-design/icons-vue'; import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js'; +import { HttpUtil } from '@/utils'; const { t } = useI18n(); @@ -45,7 +46,7 @@ const tabs = computed(() => [ { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') }, { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') }, { key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') }, - { key: `${prefix}logout`, icon: 'logout', title: t('logout') }, + { key: 'logout', icon: 'logout', title: t('logout') }, ]); const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout')); @@ -55,7 +56,12 @@ const drawerOpen = ref(false); const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false')); const drawerWidth = 'min(82vw, 320px)'; -function openLink(key) { +async function openLink(key) { + if (key === 'logout') { + await HttpUtil.post('/logout'); + window.location.href = props.basePath || '/'; + return; + } if (key.startsWith('http')) { window.open(key); } else { diff --git a/frontend/src/components/Sparkline.vue b/frontend/src/components/Sparkline.vue index e928c620..679e92f7 100644 --- a/frontend/src/components/Sparkline.vue +++ b/frontend/src/components/Sparkline.vue @@ -17,7 +17,7 @@ const props = defineProps({ showAxes: { type: Boolean, default: false }, yTickStep: { type: Number, default: 25 }, tickCountX: { type: Number, default: 4 }, - paddingLeft: { type: Number, default: 32 }, + paddingLeft: { type: Number, default: 56 }, paddingRight: { type: Number, default: 6 }, paddingTop: { type: Number, default: 6 }, paddingBottom: { type: Number, default: 20 }, diff --git a/frontend/src/composables/useNodeList.js b/frontend/src/composables/useNodeList.js index ca4f416d..9d817bce 100644 --- a/frontend/src/composables/useNodeList.js +++ b/frontend/src/composables/useNodeList.js @@ -36,7 +36,9 @@ export function useNodeList() { return n != null && n.enable && n.status === 'online'; } + const hasActive = computed(() => nodes.value.some((n) => n.enable)); + onMounted(refresh); - return { nodes, fetched, refresh, byId, nameFor, isOnline }; + return { nodes, fetched, refresh, byId, nameFor, isOnline, hasActive }; } diff --git a/frontend/src/entries/api-docs.js b/frontend/src/entries/api-docs.js index e5a22856..852bbc41 100644 --- a/frontend/src/entries/api-docs.js +++ b/frontend/src/entries/api-docs.js @@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css'; import { setupAxios } from '@/api/axios-init.js'; import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue'; setupAxios(); +applyDocumentTitle(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/entries/inbounds.js b/frontend/src/entries/inbounds.js index 15b34d2e..8342f476 100644 --- a/frontend/src/entries/inbounds.js +++ b/frontend/src/entries/inbounds.js @@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css'; import { setupAxios } from '@/api/axios-init.js'; import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import InboundsPage from '@/pages/inbounds/InboundsPage.vue'; setupAxios(); +applyDocumentTitle(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/entries/index.js b/frontend/src/entries/index.js index 61d9dc12..8e14d2ae 100644 --- a/frontend/src/entries/index.js +++ b/frontend/src/entries/index.js @@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js'; // stored theme to / before Vue mounts. import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import IndexPage from '@/pages/index/IndexPage.vue'; setupAxios(); +applyDocumentTitle(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/entries/login.js b/frontend/src/entries/login.js index ec90a9fb..6b8b0fc2 100644 --- a/frontend/src/entries/login.js +++ b/frontend/src/entries/login.js @@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js'; // stored theme to / before Vue renders anything. import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import LoginPage from '@/pages/login/LoginPage.vue'; setupAxios(); +applyDocumentTitle(); // Toasts attach to a #message div the page provides — keeps theme // styling in sync with the rest of the panel. diff --git a/frontend/src/entries/nodes.js b/frontend/src/entries/nodes.js index 512643be..819fe9a9 100644 --- a/frontend/src/entries/nodes.js +++ b/frontend/src/entries/nodes.js @@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css'; import { setupAxios } from '@/api/axios-init.js'; import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import NodesPage from '@/pages/nodes/NodesPage.vue'; setupAxios(); +applyDocumentTitle(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/entries/settings.js b/frontend/src/entries/settings.js index 0c9a85a6..4a32bb7e 100644 --- a/frontend/src/entries/settings.js +++ b/frontend/src/entries/settings.js @@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js'; // stored theme to / before Vue mounts. import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import SettingsPage from '@/pages/settings/SettingsPage.vue'; setupAxios(); +applyDocumentTitle(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/entries/xray.js b/frontend/src/entries/xray.js index 90af3e55..ba203da0 100644 --- a/frontend/src/entries/xray.js +++ b/frontend/src/entries/xray.js @@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css'; import { setupAxios } from '@/api/axios-init.js'; import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import XrayPage from '@/pages/xray/XrayPage.vue'; setupAxios(); +applyDocumentTitle(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/models/dbinbound.js b/frontend/src/models/dbinbound.js index e2e0adfe..d7a9483e 100644 --- a/frontend/src/models/dbinbound.js +++ b/frontend/src/models/dbinbound.js @@ -70,6 +70,10 @@ export class DBInbound { return this.protocol === Protocols.WIREGUARD; } + get isHysteria() { + return this.protocol === Protocols.HYSTERIA; + } + get address() { let address = location.hostname; if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") { diff --git a/frontend/src/models/inbound.js b/frontend/src/models/inbound.js index cccd8d4d..fdfb4560 100644 --- a/frontend/src/models/inbound.js +++ b/frontend/src/models/inbound.js @@ -1,5 +1,6 @@ import dayjs from 'dayjs'; import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils'; +import { getRandomRealityTarget } from '@/models/reality-targets'; export const Protocols = { VMESS: 'vmess', @@ -687,8 +688,9 @@ export class HysteriaMasquerade extends XrayCommonClass { } static fromJson(json = {}) { + const type = ['proxy', 'file', 'string'].includes(json.type) ? json.type : 'proxy'; return new HysteriaMasquerade( - json.type, + type, json.dir, json.url, json.rewriteHost, @@ -896,9 +898,7 @@ export class RealityStreamSettings extends XrayCommonClass { super(); // If target/serverNames are not provided, use random values if (!target && !serverNames) { - const randomTarget = typeof getRandomRealityTarget !== 'undefined' - ? getRandomRealityTarget() - : { target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' }; + const randomTarget = getRandomRealityTarget(); target = randomTarget.target; serverNames = randomTarget.sni; } diff --git a/frontend/src/models/setting.js b/frontend/src/models/setting.js index 7efc9e7d..4bcbcefb 100644 --- a/frontend/src/models/setting.js +++ b/frontend/src/models/setting.js @@ -15,6 +15,7 @@ export class AllSetting { this.webKeyFile = ""; this.webBasePath = "/"; this.sessionMaxAge = 360; + this.trustedProxyCIDRs = "127.0.0.1/32,::1/128"; this.pageSize = 25; this.expireDiff = 0; this.trafficDiff = 0; @@ -56,6 +57,7 @@ export class AllSetting { this.subUpdates = 12; this.subEncrypt = true; this.subShowInfo = true; + this.subEmailInRemark = true; this.subURI = ""; this.subJsonURI = ""; this.subClashURI = ""; @@ -87,6 +89,12 @@ export class AllSetting { this.ldapDefaultTotalGB = 0; this.ldapDefaultExpiryDays = 0; this.ldapDefaultLimitIP = 0; + this.hasTgBotToken = false; + this.hasTwoFactorToken = false; + this.hasLdapPassword = false; + this.hasApiToken = false; + this.hasWarpSecret = false; + this.hasNordSecret = false; if (data == null) { return @@ -97,4 +105,4 @@ export class AllSetting { equals(other) { return ObjectUtil.equals(this, other); } -} \ No newline at end of file +} diff --git a/frontend/src/pages/api-docs/ApiDocsPage.vue b/frontend/src/pages/api-docs/ApiDocsPage.vue index 28fee7b4..70a31ebc 100644 --- a/frontend/src/pages/api-docs/ApiDocsPage.vue +++ b/frontend/src/pages/api-docs/ApiDocsPage.vue @@ -1,80 +1,143 @@ @@ -93,60 +156,81 @@ onMounted(() => { cookie, or with the Authorization: Bearer <token> header below. Every endpoint returns a uniform { success, msg, obj } envelope unless otherwise noted.

+
- API Token + API Tokens
- - - - {{ tokenVisible ? 'Hide' : 'Show' }} - - - - Copy - - - - Regenerate - - + + Manage tokens +
- -
{{ tokenVisible ? (apiToken || '—') : (apiToken ? '••••••••••••••••••••••••••••' : '—') }}
-

- Send it on every request as Authorization: Bearer <token>. Token-authenticated - callers skip CSRF and don't need a session cookie. Regenerating rotates the secret immediately — - running bots will need the new value. + Create, enable, or revoke named Bearer tokens in + Settings → Security. Send each request as + Authorization: Bearer <token>. Token-authenticated callers skip CSRF and don't + need a session cookie. Deleting a token revokes it immediately — running bots will need a new one.

-
{{ curlExample }}
+
+
+ + + + + {{ visibleEndpoints }} / {{ endpointCount }} endpoints + + + + + Expand all + + + + Collapse all + + +
+ - + @@ -194,20 +278,25 @@ onMounted(() => { } .docs-header { - margin-bottom: 18px; + margin-bottom: 20px; + padding: 24px; + background: var(--bg-card); + border: 1px solid rgba(128, 128, 128, 0.12); + border-radius: 10px; } .docs-title { - font-size: 26px; - font-weight: 700; + font-size: 28px; + font-weight: 800; margin: 0 0 8px; color: rgba(0, 0, 0, 0.88); + letter-spacing: -0.3px; } .docs-lead { margin: 0; color: rgba(0, 0, 0, 0.65); - line-height: 1.6; + line-height: 1.65; font-size: 14px; } @@ -231,7 +320,8 @@ onMounted(() => { justify-content: space-between; gap: 12px; flex-wrap: wrap; - margin-bottom: 8px; + margin-bottom: 10px; + min-height: 32px; } .token-card-title { @@ -242,18 +332,6 @@ onMounted(() => { font-size: 14px; } -.token-value { - background: rgba(128, 128, 128, 0.08); - border: 1px solid rgba(128, 128, 128, 0.15); - border-radius: 6px; - padding: 10px 12px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 13px; - margin: 0; - word-break: break-all; - white-space: pre-wrap; -} - .token-hint { margin: 10px 0 0; color: rgba(0, 0, 0, 0.55); @@ -275,35 +353,110 @@ onMounted(() => { overflow-x: auto; } +.toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.search-bar { + flex: 1; + min-width: 200px; + max-width: 480px; +} + +.match-count { + font-size: 12px; + color: rgba(0, 0, 0, 0.5); + white-space: nowrap; +} + .toc-nav { display: flex; flex-wrap: wrap; - align-items: center; - gap: 8px 14px; + align-items: flex-start; + gap: 8px 12px; padding: 12px 16px; - background: rgba(128, 128, 128, 0.08); - border-radius: 6px; + background: var(--bg-card); + border: 1px solid rgba(128, 128, 128, 0.12); + border-radius: 8px; margin-bottom: 16px; } .toc-label { - font-size: 12px; + font-size: 11px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.6px; color: rgba(0, 0, 0, 0.5); + padding-top: 3px; + flex-shrink: 0; +} + +.toc-links { + display: flex; + flex-wrap: wrap; + gap: 6px; } .toc-link { - color: #1677ff; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 20px; + font-size: 12.5px; + color: rgba(0, 0, 0, 0.65); + background: rgba(128, 128, 128, 0.06); + border: 1px solid transparent; text-decoration: none; cursor: pointer; - font-size: 13px; + transition: all 0.2s; + white-space: nowrap; } .toc-link:hover { - color: #4096ff; - text-decoration: underline; + background: rgba(22, 119, 255, 0.08); + color: #1677ff; + border-color: rgba(22, 119, 255, 0.2); +} + +.toc-link.active { + background: rgba(22, 119, 255, 0.12); + color: #1677ff; + border-color: rgba(22, 119, 255, 0.3); + font-weight: 600; +} + +.toc-icon { + font-size: 13px; + opacity: 0.8; +} + +.toc-text { + font-size: 12.5px; +} + +.toc-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + font-size: 10.5px; + font-weight: 700; + background: rgba(22, 119, 255, 0.12); + color: #1677ff; + line-height: 1; +} + +.toc-link.active .toc-badge { + background: #1677ff; + color: #fff; } @@ -312,28 +465,97 @@ body.dark .docs-title { color: rgba(255, 255, 255, 0.92); } +html[data-theme='ultra-dark'] .docs-title { + color: rgba(255, 255, 255, 0.95); +} + +body.dark .docs-header { + background: #252526; + border-color: rgba(255, 255, 255, 0.08); +} + +html[data-theme='ultra-dark'] .docs-header { + background: #0a0a0a; + border-color: rgba(255, 255, 255, 0.06); +} + body.dark .docs-lead, body.dark .token-hint { color: rgba(255, 255, 255, 0.7); } +html[data-theme='ultra-dark'] .docs-lead, +html[data-theme='ultra-dark'] .token-hint { + color: rgba(255, 255, 255, 0.75); +} + body.dark .docs-lead code, body.dark .token-hint code { background: rgba(255, 255, 255, 0.1); } -body.dark .token-value, +html[data-theme='ultra-dark'] .docs-lead code, +html[data-theme='ultra-dark'] .token-hint code { + background: rgba(255, 255, 255, 0.12); +} + body.dark .code-block { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.88); } +html[data-theme='ultra-dark'] .code-block { + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.08); +} + body.dark .toc-nav { - background: rgba(255, 255, 255, 0.04); + background: #252526; + border-color: rgba(255, 255, 255, 0.08); +} + +html[data-theme='ultra-dark'] .toc-nav { + background: #0a0a0a; + border-color: rgba(255, 255, 255, 0.06); } body.dark .toc-label { color: rgba(255, 255, 255, 0.55); } + +html[data-theme='ultra-dark'] .toc-label { + color: rgba(255, 255, 255, 0.6); +} + +body.dark .toc-link { + color: rgba(255, 255, 255, 0.65); + background: rgba(255, 255, 255, 0.06); +} + +html[data-theme='ultra-dark'] .toc-link { + background: rgba(255, 255, 255, 0.04); +} + +body.dark .toc-link:hover { + background: rgba(88, 166, 255, 0.12); + color: #58a6ff; + border-color: rgba(88, 166, 255, 0.25); +} + +body.dark .toc-link.active { + background: rgba(88, 166, 255, 0.15); + color: #58a6ff; + border-color: rgba(88, 166, 255, 0.35); +} + +body.dark .toc-badge { + background: rgba(88, 166, 255, 0.15); + color: #58a6ff; +} + +body.dark .toc-link.active .toc-badge { + background: #58a6ff; + color: #0d1117; +} diff --git a/frontend/src/pages/api-docs/CodeBlock.vue b/frontend/src/pages/api-docs/CodeBlock.vue new file mode 100644 index 00000000..446016c7 --- /dev/null +++ b/frontend/src/pages/api-docs/CodeBlock.vue @@ -0,0 +1,174 @@ + + + + + + + diff --git a/frontend/src/pages/api-docs/EndpointRow.vue b/frontend/src/pages/api-docs/EndpointRow.vue index 0b7fb300..5a811427 100644 --- a/frontend/src/pages/api-docs/EndpointRow.vue +++ b/frontend/src/pages/api-docs/EndpointRow.vue @@ -1,6 +1,7 @@ + + + + + + @@ -298,8 +306,12 @@ onMounted(loadInboundTags); + diff --git a/frontend/src/pages/settings/SecurityTab.vue b/frontend/src/pages/settings/SecurityTab.vue index d841c787..1ecb7080 100644 --- a/frontend/src/pages/settings/SecurityTab.vue +++ b/frontend/src/pages/settings/SecurityTab.vue @@ -52,10 +52,9 @@ async function sendUpdateUser() { try { const msg = await HttpUtil.post('/panel/setting/updateUser', user); if (msg?.success) { - // Force re-login at the standard logout path; basePath is handled - // by the Go router so a relative redirect is correct here. - const basePath = window.X_UI_BASE_PATH || ''; - window.location.replace(`${basePath}logout`); + await HttpUtil.post('/logout'); + const basePath = window.X_UI_BASE_PATH || '/'; + window.location.replace(basePath); } } finally { updating.value = false; @@ -76,34 +75,41 @@ function updateUser() { } } -// === API Token ========================================================= -// Surfaces the panel's API token so a remote central panel can register -// this instance as a node. Lazy-loaded on tab mount; rotation requires -// confirmation since it invalidates any cached value upstream. -const apiToken = ref(''); -const apiTokenLoading = ref(false); -const apiTokenRotating = ref(false); +const apiTokens = ref([]); +const apiTokensLoading = ref(false); +const visibleTokenIds = ref(new Set()); +const createOpen = ref(false); +const createName = ref(''); +const creating = ref(false); -async function loadApiToken() { - apiTokenLoading.value = true; +async function loadApiTokens() { + apiTokensLoading.value = true; try { - const msg = await HttpUtil.get('/panel/setting/getApiToken'); - if (msg?.success) apiToken.value = msg.obj || ''; + const msg = await HttpUtil.get('/panel/setting/apiTokens'); + if (msg?.success) apiTokens.value = Array.isArray(msg.obj) ? msg.obj : []; } finally { - apiTokenLoading.value = false; + apiTokensLoading.value = false; } } -async function copyApiToken() { - if (!apiToken.value) return; +function isTokenVisible(id) { + return visibleTokenIds.value.has(id); +} + +function toggleTokenVisibility(id) { + const next = new Set(visibleTokenIds.value); + if (next.has(id)) next.delete(id); else next.add(id); + visibleTokenIds.value = next; +} + +async function copyToken(token) { + if (!token) return; try { - await navigator.clipboard.writeText(apiToken.value); + await navigator.clipboard.writeText(token); message.success(t('copySuccess')); } catch (_e) { - // navigator.clipboard can be undefined on http:// — fall back to - // a transient input + execCommand path. const ta = document.createElement('textarea'); - ta.value = apiToken.value; + ta.value = token; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); @@ -112,28 +118,66 @@ async function copyApiToken() { } } -function regenerateApiToken() { +function openCreateModal() { + createName.value = ''; + createOpen.value = true; +} + +async function confirmCreateToken() { + const name = createName.value.trim(); + if (!name) { + message.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required'); + return; + } + creating.value = true; + try { + const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }); + if (msg?.success) { + createOpen.value = false; + await loadApiTokens(); + if (msg.obj?.id != null) { + const next = new Set(visibleTokenIds.value); + next.add(msg.obj.id); + visibleTokenIds.value = next; + } + } + } finally { + creating.value = false; + } +} + +function confirmDeleteToken(row) { Modal.confirm({ - title: t('pages.nodes.regenerateConfirm'), - okText: t('confirm'), + title: `${t('delete')} "${row.name}"?`, + content: t('pages.settings.security.apiTokenDeleteWarning') + || 'Any caller using this token will stop authenticating immediately.', + okText: t('delete'), cancelText: t('cancel'), okType: 'danger', onOk: async () => { - apiTokenRotating.value = true; - try { - const msg = await HttpUtil.post('/panel/setting/regenerateApiToken'); - if (msg?.success) { - apiToken.value = msg.obj || ''; - message.success(t('success')); - } - } finally { - apiTokenRotating.value = false; - } + const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`); + if (msg?.success) await loadApiTokens(); }, }); } -onMounted(loadApiToken); +async function toggleTokenEnabled(row) { + const target = !row.enabled; + const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }); + if (msg?.success) row.enabled = target; +} + +function maskToken(token) { + if (!token) return ''; + return '•'.repeat(Math.min(token.length, 24)); +} + +function formatTokenDate(ts) { + if (!ts) return ''; + return new Date(ts * 1000).toLocaleString(); +} + +onMounted(loadApiTokens); function toggleTwoFactor() { // Switch read-only — the actual flip happens after the modal succeeds. @@ -217,24 +261,144 @@ function toggleTwoFactor() { - - - - - - - - {{ t('copy') }} - - {{ t('pages.nodes.regenerate') }} +
+
+

{{ t('pages.nodes.apiTokenHint') }}

+ + + {{ t('pages.settings.security.apiTokenNew') || 'New token' }} - - +
+ + + + +
+
+
+ {{ row.name }} + {{ formatTokenDate(row.createdAt) }} +
+
+ + + {{ t('delete') }} + +
+
+
+ {{ isTokenVisible(row.id) ? row.token : maskToken(row.token) }} + + {{ isTokenVisible(row.id) + ? (t('pages.settings.security.hide') || 'Hide') + : (t('pages.settings.security.show') || 'Show') }} + + {{ t('copy') }} +
+
+
+
+ + + + + + + + + + diff --git a/frontend/src/pages/settings/SettingsPage.vue b/frontend/src/pages/settings/SettingsPage.vue index 5f2ddf39..3401e328 100644 --- a/frontend/src/pages/settings/SettingsPage.vue +++ b/frontend/src/pages/settings/SettingsPage.vue @@ -1,5 +1,5 @@