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 2d44d104..d71e0589 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -88,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 diff --git a/frontend/src/pages/api-docs/ApiDocsPage.vue b/frontend/src/pages/api-docs/ApiDocsPage.vue index c53ae553..70a31ebc 100644 --- a/frontend/src/pages/api-docs/ApiDocsPage.vue +++ b/frontend/src/pages/api-docs/ApiDocsPage.vue @@ -1,13 +1,7 @@ @@ -197,38 +163,17 @@ onBeforeUnmount(() => {
- API Token -
-
- - - {{ tokenVisible ? 'Hide' : 'Show' }} - - - - Copy - - - - Regenerate - + API Tokens
+ + 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.

@@ -387,25 +332,6 @@ onBeforeUnmount(() => { font-size: 14px; } -.token-actions { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.token-value { - background: rgba(128, 128, 128, 0.08); - border: 1px solid rgba(128, 128, 128, 0.15); - 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); @@ -573,14 +499,12 @@ html[data-theme='ultra-dark'] .token-hint code { background: rgba(255, 255, 255, 0.12); } -body.dark .token-value, body.dark .code-block { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.1); 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); diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 24259bbb..70415ce5 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/frontend/src/pages/api-docs/endpoints.js @@ -25,7 +25,7 @@ export function safeInlineHtml(input) { export const sections = [ { - id: 'auth', + id: 'authentication', title: 'Authentication', description: 'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.', @@ -576,7 +576,7 @@ export const sections = [ }, { - id: 'customGeo', + id: 'custom-geo', title: 'Custom Geo', description: 'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.', @@ -647,7 +647,7 @@ export const sections = [ 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.', + 'Panel configuration and user credentials. All endpoints live under /panel/setting and require a logged-in session or Bearer token.', endpoints: [ { method: 'POST', @@ -688,23 +688,57 @@ export const sections = [ path: '/panel/setting/getDefaultJsonConfig', summary: 'Return the built-in default Xray JSON config template that ships with this panel version.', }, + ], + }, + + { + id: 'api-tokens', + title: 'API Tokens', + description: + 'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored plaintext so the SPA can show them on demand. Send one as Authorization: Bearer <token> on any /panel/api/* request.', + endpoints: [ { 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}', + path: '/panel/setting/apiTokens', + summary: 'List every API token, enabled or not.', + response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "token": "abcdef-12345-...",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}', }, { method: 'POST', - path: '/panel/setting/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}', + path: '/panel/setting/apiTokens/create', + summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.', + params: [ + { name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' }, + ], + body: '{\n "name": "central-panel-a"\n}', + response: '{\n "success": true,\n "obj": {\n "id": 2,\n "name": "central-panel-a",\n "token": "new-token-string",\n "enabled": true,\n "createdAt": 1736000000\n }\n}', + errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}', + }, + { + method: 'POST', + path: '/panel/setting/apiTokens/delete/:id', + summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.', + params: [ + { name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' }, + ], + response: '{\n "success": true\n}', + }, + { + method: 'POST', + path: '/panel/setting/apiTokens/setEnabled/:id', + summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.', + params: [ + { name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' }, + { name: 'enabled', in: 'body', type: 'boolean', desc: 'New enabled state.' }, + ], + body: '{\n "enabled": false\n}', + response: '{\n "success": true\n}', }, ], }, { - id: 'xraySettings', + id: 'xray-settings', title: 'Xray Settings', description: 'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.', diff --git a/frontend/src/pages/settings/SecurityTab.vue b/frontend/src/pages/settings/SecurityTab.vue index bb9a2f73..1ecb7080 100644 --- a/frontend/src/pages/settings/SecurityTab.vue +++ b/frontend/src/pages/settings/SecurityTab.vue @@ -75,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'); @@ -111,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. @@ -216,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 @@