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('pages.nodes.apiToken') }}
- {{ t('pages.nodes.apiTokenHint') }}
-
-
-
-
-
-
- {{ t('copy') }}
-
- {{ t('pages.nodes.regenerate') }}
+
+
+
+
+
+
+
+
+
+ {{ 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 @@
@@ -199,7 +228,7 @@ const alertVisible = ref(true);
-
+
diff --git a/frontend/src/pages/xray/XrayPage.vue b/frontend/src/pages/xray/XrayPage.vue
index 696a872d..375b985c 100644
--- a/frontend/src/pages/xray/XrayPage.vue
+++ b/frontend/src/pages/xray/XrayPage.vue
@@ -1,5 +1,5 @@
@@ -259,7 +293,7 @@ function confirmRestart() {
-
+
{{ t('pages.xray.basicTemplate') }}
diff --git a/web/controller/api.go b/web/controller/api.go
index 9344541a..e066af77 100644
--- a/web/controller/api.go
+++ b/web/controller/api.go
@@ -19,6 +19,7 @@ type APIController struct {
nodeController *NodeController
settingService service.SettingService
userService service.UserService
+ apiTokenService service.ApiTokenService
Tgbot service.Tgbot
}
@@ -33,7 +34,7 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
tok := strings.TrimPrefix(auth, "Bearer ")
- if a.settingService.MatchApiToken(tok) {
+ if a.apiTokenService.Match(tok) {
if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u)
}
diff --git a/web/controller/setting.go b/web/controller/setting.go
index dd21eebb..1003f783 100644
--- a/web/controller/setting.go
+++ b/web/controller/setting.go
@@ -2,6 +2,7 @@ package controller
import (
"errors"
+ "strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/util/crypto"
@@ -22,9 +23,10 @@ type updateUserForm struct {
// SettingController handles settings and user management operations.
type SettingController struct {
- settingService service.SettingService
- userService service.UserService
- panelService service.PanelService
+ settingService service.SettingService
+ userService service.UserService
+ panelService service.PanelService
+ apiTokenService service.ApiTokenService
}
// NewSettingController creates a new SettingController and initializes its routes.
@@ -44,8 +46,10 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
g.POST("/updateUser", a.updateUser)
g.POST("/restartPanel", a.restartPanel)
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
- g.GET("/getApiToken", a.getApiToken)
- g.POST("/regenerateApiToken", a.regenerateApiToken)
+ g.GET("/apiTokens", a.listApiTokens)
+ g.POST("/apiTokens/create", a.createApiToken)
+ g.POST("/apiTokens/delete/:id", a.deleteApiToken)
+ g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
}
// getAllSetting retrieves all current settings.
@@ -130,26 +134,56 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
jsonObj(c, defaultJsonConfig, nil)
}
-// getApiToken returns the panel's API token used by remote central
-// panels to authenticate as Bearer tokens. The token is auto-generated
-// on first read so existing installs upgrade transparently.
-func (a *SettingController) getApiToken(c *gin.Context) {
- tok, err := a.settingService.GetApiToken()
+type apiTokenCreateForm struct {
+ Name string `json:"name" form:"name"`
+}
+
+type apiTokenEnabledForm struct {
+ Enabled bool `json:"enabled" form:"enabled"`
+}
+
+func (a *SettingController) listApiTokens(c *gin.Context) {
+ rows, err := a.apiTokenService.List()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
- jsonObj(c, tok, nil)
+ jsonObj(c, rows, nil)
}
-// regenerateApiToken rotates the API token. Any central panel that had
-// the old value cached will start failing heartbeats until it is updated
-// with the new token — that's intentional, it's the whole point of rotation.
-func (a *SettingController) regenerateApiToken(c *gin.Context) {
- tok, err := a.settingService.RegenerateApiToken()
+func (a *SettingController) createApiToken(c *gin.Context) {
+ form := &apiTokenCreateForm{}
+ if err := c.ShouldBind(form); err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
+ return
+ }
+ row, err := a.apiTokenService.Create(form.Name)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
- jsonObj(c, tok, nil)
+ jsonObj(c, row, nil)
+}
+
+func (a *SettingController) deleteApiToken(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
+ return
+ }
+ jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.Delete(id))
+}
+
+func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
+ return
+ }
+ form := &apiTokenEnabledForm{}
+ if bindErr := c.ShouldBind(form); bindErr != nil {
+ jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr)
+ return
+ }
+ jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled))
}
diff --git a/web/service/api_token.go b/web/service/api_token.go
new file mode 100644
index 00000000..fbde1a47
--- /dev/null
+++ b/web/service/api_token.go
@@ -0,0 +1,119 @@
+package service
+
+import (
+ "crypto/subtle"
+ "errors"
+ "strings"
+
+ "github.com/mhsanaei/3x-ui/v3/database"
+ "github.com/mhsanaei/3x-ui/v3/database/model"
+ "github.com/mhsanaei/3x-ui/v3/util/common"
+ "github.com/mhsanaei/3x-ui/v3/util/random"
+)
+
+type ApiTokenService struct{}
+
+const apiTokenLength = 48
+
+type ApiTokenView struct {
+ Id int `json:"id"`
+ Name string `json:"name"`
+ Token string `json:"token"`
+ Enabled bool `json:"enabled"`
+ CreatedAt int64 `json:"createdAt"`
+}
+
+func toView(t *model.ApiToken) *ApiTokenView {
+ return &ApiTokenView{
+ Id: t.Id,
+ Name: t.Name,
+ Token: t.Token,
+ Enabled: t.Enabled,
+ CreatedAt: t.CreatedAt,
+ }
+}
+
+func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
+ db := database.GetDB()
+ var rows []*model.ApiToken
+ if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
+ return nil, err
+ }
+ out := make([]*ApiTokenView, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, toView(r))
+ }
+ return out, nil
+}
+
+func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return nil, common.NewError("token name is required")
+ }
+ if len(name) > 64 {
+ return nil, common.NewError("token name must be 64 characters or fewer")
+ }
+ db := database.GetDB()
+ var count int64
+ if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
+ return nil, err
+ }
+ if count > 0 {
+ return nil, common.NewError("a token with that name already exists")
+ }
+ row := &model.ApiToken{
+ Name: name,
+ Token: random.Seq(apiTokenLength),
+ Enabled: true,
+ }
+ if err := db.Create(row).Error; err != nil {
+ return nil, err
+ }
+ return toView(row), nil
+}
+
+func (s *ApiTokenService) Delete(id int) error {
+ if id <= 0 {
+ return common.NewError("invalid token id")
+ }
+ db := database.GetDB()
+ return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
+}
+
+func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
+ if id <= 0 {
+ return common.NewError("invalid token id")
+ }
+ db := database.GetDB()
+ res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ return errors.New("token not found")
+ }
+ return nil
+}
+
+// Match returns true when the presented bearer token matches any enabled
+// row in api_tokens. Uses constant-time compare per row so a remote
+// attacker can't time-attack tokens byte-by-byte.
+func (s *ApiTokenService) Match(presented string) bool {
+ if presented == "" {
+ return false
+ }
+ db := database.GetDB()
+ var rows []*model.ApiToken
+ if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
+ return false
+ }
+ presentedBytes := []byte(presented)
+ matched := false
+ for _, r := range rows {
+ if subtle.ConstantTimeCompare([]byte(r.Token), presentedBytes) == 1 {
+ matched = true
+ }
+ }
+ return matched
+}
diff --git a/web/service/setting.go b/web/service/setting.go
index fe0da73d..a6cbffdb 100644
--- a/web/service/setting.go
+++ b/web/service/setting.go
@@ -1,7 +1,6 @@
package service
import (
- "crypto/subtle"
_ "embed"
"encoding/json"
"errors"
@@ -211,7 +210,10 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
view.HasLdapPassword = secretConfigured(allSetting.LdapPassword)
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
- view.HasApiToken = secretConfigured(mustString(s.getString("apiToken")))
+ var apiTokenCount int64
+ if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
+ view.HasApiToken = apiTokenCount > 0
+ }
view.TgBotToken = ""
view.TwoFactorToken = ""
view.LdapPassword = ""
@@ -467,48 +469,6 @@ func (s *SettingService) GetSecret() ([]byte, error) {
return []byte(secret), err
}
-// GetApiToken returns the panel's API token, lazily generating one on
-// first read so existing installs upgrade transparently. The token is
-// stored plaintext to match how the existing tg/ldap secrets are kept.
-func (s *SettingService) GetApiToken() (string, error) {
- tok, err := s.getString("apiToken")
- if err != nil {
- return "", err
- }
- if tok == "" {
- tok = random.Seq(48)
- if saveErr := s.saveSetting("apiToken", tok); saveErr != nil {
- logger.Warning("save apiToken failed:", saveErr)
- return "", saveErr
- }
- }
- return tok, nil
-}
-
-// RegenerateApiToken rotates the API token, invalidating any central
-// panel that has the old value cached.
-func (s *SettingService) RegenerateApiToken() (string, error) {
- tok := random.Seq(48)
- if err := s.saveSetting("apiToken", tok); err != nil {
- return "", err
- }
- return tok, nil
-}
-
-// MatchApiToken returns true when the supplied bearer token matches the
-// stored API token. Uses constant-time compare so a remote attacker
-// can't time-attack the token byte-by-byte.
-func (s *SettingService) MatchApiToken(presented string) bool {
- if presented == "" {
- return false
- }
- stored, err := s.getString("apiToken")
- if err != nil || stored == "" {
- return false
- }
- return subtle.ConstantTimeCompare([]byte(stored), []byte(presented)) == 1
-}
-
func (s *SettingService) SetBasePath(basePath string) error {
if !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath
@@ -877,7 +837,7 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error {
func (s *SettingService) UpdateSecret(key string, value string) error {
switch key {
- case "tgBotToken", "ldapPassword", "twoFactorToken", "apiToken":
+ case "tgBotToken", "ldapPassword", "twoFactorToken":
return s.saveSetting(key, strings.TrimSpace(value))
default:
return common.NewError("secret key is not replaceable:", key)
diff --git a/web/service/setting_security_test.go b/web/service/setting_security_test.go
index f4a2255b..598b289a 100644
--- a/web/service/setting_security_test.go
+++ b/web/service/setting_security_test.go
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
+ "github.com/mhsanaei/3x-ui/v3/database/model"
)
func setupSettingTestDB(t *testing.T) {
@@ -31,7 +32,7 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
t.Fatal(err)
}
- if err := s.saveSetting("apiToken", "api-secret"); err != nil {
+ if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json
index b1cc7726..cc0c1a51 100644
--- a/web/translation/ar-EG.json
+++ b/web/translation/ar-EG.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "أدخل الرمز من التطبيق لتغيير بيانات اعتماد المسؤول.",
"twoFactorModalSetSuccess": "تم إنشاء المصادقة الثنائية بنجاح",
"twoFactorModalDeleteSuccess": "تم حذف المصادقة الثنائية بنجاح",
- "twoFactorModalError": "رمز خاطئ"
+ "twoFactorModalError": "رمز خاطئ",
+ "show": "إظهار",
+ "hide": "إخفاء",
+ "apiTokenNew": "رمز جديد",
+ "apiTokenName": "الاسم",
+ "apiTokenNamePlaceholder": "مثل central-panel-a",
+ "apiTokenNameRequired": "الاسم مطلوب",
+ "apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
+ "apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا."
},
"toasts": {
"modifySettings": "تم تغيير المعلمات.",
diff --git a/web/translation/en-US.json b/web/translation/en-US.json
index b07c89f8..bf3889d4 100644
--- a/web/translation/en-US.json
+++ b/web/translation/en-US.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Enter the code from the application to change administrator credentials.",
"twoFactorModalSetSuccess": "Two-factor authentication has been successfully established",
"twoFactorModalDeleteSuccess": "Two-factor authentication has been successfully deleted",
- "twoFactorModalError": "Wrong code"
+ "twoFactorModalError": "Wrong code",
+ "show": "Show",
+ "hide": "Hide",
+ "apiTokenNew": "New token",
+ "apiTokenName": "Name",
+ "apiTokenNamePlaceholder": "e.g. central-panel-a",
+ "apiTokenNameRequired": "Name is required",
+ "apiTokenEmpty": "No tokens yet — create one to authenticate bots or remote panels.",
+ "apiTokenDeleteWarning": "Any caller using this token will stop authenticating immediately."
},
"toasts": {
"modifySettings": "The parameters have been changed.",
diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json
index 848ec2ac..9dcc100d 100644
--- a/web/translation/es-ES.json
+++ b/web/translation/es-ES.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Ingrese el código de la aplicación para cambiar las credenciales del administrador.",
"twoFactorModalSetSuccess": "La autenticación de dos factores se ha establecido con éxito",
"twoFactorModalDeleteSuccess": "La autenticación de dos factores se ha eliminado con éxito",
- "twoFactorModalError": "Código incorrecto"
+ "twoFactorModalError": "Código incorrecto",
+ "show": "Mostrar",
+ "hide": "Ocultar",
+ "apiTokenNew": "Nuevo token",
+ "apiTokenName": "Nombre",
+ "apiTokenNamePlaceholder": "por ejemplo central-panel-a",
+ "apiTokenNameRequired": "El nombre es obligatorio",
+ "apiTokenEmpty": "Aún no hay tokens — crea uno para autenticar bots o paneles remotos.",
+ "apiTokenDeleteWarning": "Cualquier cliente que use este token dejará de autenticarse inmediatamente."
},
"toasts": {
"modifySettings": "Los parámetros han sido modificados.",
diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json
index b50d5e40..da768679 100644
--- a/web/translation/fa-IR.json
+++ b/web/translation/fa-IR.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "برای تغییر اعتبارنامههای مدیر، کد را از برنامه وارد کنید.",
"twoFactorModalSetSuccess": "احراز هویت دو مرحلهای با موفقیت برقرار شد",
"twoFactorModalDeleteSuccess": "احراز هویت دو مرحلهای با موفقیت حذف شد",
- "twoFactorModalError": "کد نادرست"
+ "twoFactorModalError": "کد نادرست",
+ "show": "نمایش",
+ "hide": "پنهان",
+ "apiTokenNew": "توکن جدید",
+ "apiTokenName": "نام",
+ "apiTokenNamePlaceholder": "مثلاً central-panel-a",
+ "apiTokenNameRequired": "نام الزامی است",
+ "apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت رباتها یا پنلهای راه دور یکی بسازید.",
+ "apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده میکند بلافاصله احراز هویتش قطع میشود."
},
"toasts": {
"modifySettings": "پارامترها تغییر کردهاند.",
diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json
index 950da889..a30f14af 100644
--- a/web/translation/id-ID.json
+++ b/web/translation/id-ID.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Masukkan kode dari aplikasi untuk mengubah kredensial administrator.",
"twoFactorModalSetSuccess": "Autentikasi dua faktor telah berhasil dibuat",
"twoFactorModalDeleteSuccess": "Autentikasi dua faktor telah berhasil dihapus",
- "twoFactorModalError": "Kode salah"
+ "twoFactorModalError": "Kode salah",
+ "show": "Tampilkan",
+ "hide": "Sembunyikan",
+ "apiTokenNew": "Token baru",
+ "apiTokenName": "Nama",
+ "apiTokenNamePlaceholder": "misalnya central-panel-a",
+ "apiTokenNameRequired": "Nama wajib diisi",
+ "apiTokenEmpty": "Belum ada token — buat satu untuk mengautentikasi bot atau panel jarak jauh.",
+ "apiTokenDeleteWarning": "Setiap pemanggil yang menggunakan token ini akan berhenti terautentikasi segera."
},
"toasts": {
"modifySettings": "Parameter telah diubah.",
diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json
index 67ffa15c..d952bfd2 100644
--- a/web/translation/ja-JP.json
+++ b/web/translation/ja-JP.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "管理者の認証情報を変更するには、アプリケーションからコードを入力してください。",
"twoFactorModalSetSuccess": "二要素認証が正常に設定されました",
"twoFactorModalDeleteSuccess": "二要素認証が正常に削除されました",
- "twoFactorModalError": "コードが間違っています"
+ "twoFactorModalError": "コードが間違っています",
+ "show": "表示",
+ "hide": "非表示",
+ "apiTokenNew": "新規トークン",
+ "apiTokenName": "名前",
+ "apiTokenNamePlaceholder": "例: central-panel-a",
+ "apiTokenNameRequired": "名前は必須です",
+ "apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
+ "apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。"
},
"toasts": {
"modifySettings": "パラメーターが変更されました。",
diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json
index 8a273716..bcbf8efc 100644
--- a/web/translation/pt-BR.json
+++ b/web/translation/pt-BR.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Insira o código do aplicativo para alterar as credenciais do administrador.",
"twoFactorModalSetSuccess": "A autenticação de dois fatores foi estabelecida com sucesso",
"twoFactorModalDeleteSuccess": "A autenticação de dois fatores foi excluída com sucesso",
- "twoFactorModalError": "Código incorreto"
+ "twoFactorModalError": "Código incorreto",
+ "show": "Mostrar",
+ "hide": "Ocultar",
+ "apiTokenNew": "Novo token",
+ "apiTokenName": "Nome",
+ "apiTokenNamePlaceholder": "ex.: central-panel-a",
+ "apiTokenNameRequired": "O nome é obrigatório",
+ "apiTokenEmpty": "Nenhum token ainda — crie um para autenticar bots ou painéis remotos.",
+ "apiTokenDeleteWarning": "Qualquer cliente usando este token deixará de se autenticar imediatamente."
},
"toasts": {
"modifySettings": "Os parâmetros foram alterados.",
diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json
index cd8fe0fd..f5ac4b2d 100644
--- a/web/translation/ru-RU.json
+++ b/web/translation/ru-RU.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Введите код из приложения, чтобы изменить учетные данные администратора.",
"twoFactorModalSetSuccess": "Двухфакторная аутентификация была успешно установлена",
"twoFactorModalDeleteSuccess": "Двухфакторная аутентификация была успешно удалена",
- "twoFactorModalError": "Неверный код"
+ "twoFactorModalError": "Неверный код",
+ "show": "Показать",
+ "hide": "Скрыть",
+ "apiTokenNew": "Новый токен",
+ "apiTokenName": "Имя",
+ "apiTokenNamePlaceholder": "например, central-panel-a",
+ "apiTokenNameRequired": "Имя обязательно",
+ "apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
+ "apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию."
},
"toasts": {
"modifySettings": "Настройки изменены",
diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json
index 9d90bfbe..fa7684ae 100644
--- a/web/translation/tr-TR.json
+++ b/web/translation/tr-TR.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Yönetici kimlik bilgilerini değiştirmek için uygulamadaki kodu girin.",
"twoFactorModalSetSuccess": "İki faktörlü kimlik doğrulama başarıyla kuruldu",
"twoFactorModalDeleteSuccess": "İki faktörlü kimlik doğrulama başarıyla silindi",
- "twoFactorModalError": "Yanlış kod"
+ "twoFactorModalError": "Yanlış kod",
+ "show": "Göster",
+ "hide": "Gizle",
+ "apiTokenNew": "Yeni token",
+ "apiTokenName": "Ad",
+ "apiTokenNamePlaceholder": "örn. central-panel-a",
+ "apiTokenNameRequired": "Ad zorunludur",
+ "apiTokenEmpty": "Henüz token yok — bot veya uzak panelleri doğrulamak için bir tane oluşturun.",
+ "apiTokenDeleteWarning": "Bu tokenı kullanan tüm istemciler anında kimlik doğrulamasını kaybeder."
},
"toasts": {
"modifySettings": "Parametreler değiştirildi.",
diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json
index 6da1b90c..16cce701 100644
--- a/web/translation/uk-UA.json
+++ b/web/translation/uk-UA.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Введіть код з додатку, щоб змінити облікові дані адміністратора.",
"twoFactorModalSetSuccess": "Двофакторна аутентифікація була успішно встановлена",
"twoFactorModalDeleteSuccess": "Двофакторна аутентифікація була успішно видалена",
- "twoFactorModalError": "Невірний код"
+ "twoFactorModalError": "Невірний код",
+ "show": "Показати",
+ "hide": "Сховати",
+ "apiTokenNew": "Новий токен",
+ "apiTokenName": "Назва",
+ "apiTokenNamePlaceholder": "наприклад, central-panel-a",
+ "apiTokenNameRequired": "Назва обов'язкова",
+ "apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
+ "apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію."
},
"toasts": {
"modifySettings": "Параметри було змінено.",
diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json
index 4c024d86..82f457b5 100644
--- a/web/translation/vi-VN.json
+++ b/web/translation/vi-VN.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "Nhập mã từ ứng dụng để thay đổi thông tin xác thực quản trị viên.",
"twoFactorModalSetSuccess": "Xác thực hai yếu tố đã được thiết lập thành công",
"twoFactorModalDeleteSuccess": "Xác thực hai yếu tố đã được xóa thành công",
- "twoFactorModalError": "Mã sai"
+ "twoFactorModalError": "Mã sai",
+ "show": "Hiển thị",
+ "hide": "Ẩn",
+ "apiTokenNew": "Token mới",
+ "apiTokenName": "Tên",
+ "apiTokenNamePlaceholder": "ví dụ: central-panel-a",
+ "apiTokenNameRequired": "Tên là bắt buộc",
+ "apiTokenEmpty": "Chưa có token nào — tạo một token để xác thực bot hoặc panel từ xa.",
+ "apiTokenDeleteWarning": "Mọi client đang dùng token này sẽ ngừng xác thực ngay lập tức."
},
"toasts": {
"modifySettings": "Các tham số đã được thay đổi.",
diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json
index 06769181..821d6df1 100644
--- a/web/translation/zh-CN.json
+++ b/web/translation/zh-CN.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "输入应用程序中的代码以更改管理员凭据。",
"twoFactorModalSetSuccess": "双因素认证已成功建立",
"twoFactorModalDeleteSuccess": "双因素认证已成功删除",
- "twoFactorModalError": "验证码错误"
+ "twoFactorModalError": "验证码错误",
+ "show": "显示",
+ "hide": "隐藏",
+ "apiTokenNew": "新建令牌",
+ "apiTokenName": "名称",
+ "apiTokenNamePlaceholder": "例如 central-panel-a",
+ "apiTokenNameRequired": "名称必填",
+ "apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。",
+ "apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。"
},
"toasts": {
"modifySettings": "参数已更改。",
diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json
index 5963b9cb..d410080f 100644
--- a/web/translation/zh-TW.json
+++ b/web/translation/zh-TW.json
@@ -589,7 +589,15 @@
"twoFactorModalChangeCredentialsStep": "輸入應用程式中的代碼以更改管理員憑證。",
"twoFactorModalSetSuccess": "雙重身份驗證已成功建立",
"twoFactorModalDeleteSuccess": "雙重身份驗證已成功刪除",
- "twoFactorModalError": "驗證碼錯誤"
+ "twoFactorModalError": "驗證碼錯誤",
+ "show": "顯示",
+ "hide": "隱藏",
+ "apiTokenNew": "新增令牌",
+ "apiTokenName": "名稱",
+ "apiTokenNamePlaceholder": "例如 central-panel-a",
+ "apiTokenNameRequired": "名稱必填",
+ "apiTokenEmpty": "尚無令牌 — 建立一個以認證機器人或遠端面板。",
+ "apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。"
},
"toasts": {
"modifySettings": "參數已更改。",