From 36a01eb60d13329a9f3e94f0e25487a3f0145d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=98=AD?= Date: Sun, 20 Sep 2026 18:32:54 +0800 Subject: [PATCH] feat: The panel API key supports creating multiple tokens and enhance terminal capabilities (#13872) --- agent/app/api/v2/terminal.go | 10 + agent/app/api/v2/terminal_capabilities.go | 10 + agent/router/ro_host.go | 1 + agent/utils/terminal/auth_lease.go | 41 ++ agent/utils/terminal/registry.go | 22 +- agent/utils/terminal/session.go | 16 + core/app/api/v2/api_key.go | 110 ++++ core/app/api/v2/auth.go | 14 +- core/app/api/v2/setting.go | 12 +- core/app/auth/api_auth.go | 28 +- core/app/auth/api_key.go | 205 +++++++ core/app/auth/api_key_legacy_lock.go | 11 + core/app/auth/api_key_panel.go | 45 ++ core/app/auth/auth.go | 7 +- core/app/dto/api_key.go | 64 +++ core/app/dto/logs.go | 19 +- core/app/model/api_key.go | 34 ++ core/app/model/logs.go | 17 +- core/app/repo/api_key.go | 28 + core/app/service/api_key.go | 522 ++++++++++++++++++ core/app/service/auth.go | 2 +- core/app/service/setting.go | 4 + core/i18n/lang/en.yaml | 9 + core/i18n/lang/es-ES.yaml | 9 + core/i18n/lang/fa.yaml | 9 + core/i18n/lang/ja.yaml | 9 + core/i18n/lang/ko.yaml | 9 + core/i18n/lang/lo.yaml | 9 + core/i18n/lang/ms.yaml | 9 + core/i18n/lang/pt-BR.yaml | 9 + core/i18n/lang/ru.yaml | 9 + core/i18n/lang/tr.yaml | 9 + core/i18n/lang/zh-Hant.yaml | 9 + core/i18n/lang/zh.yaml | 9 + core/init/migration/migrate.go | 2 + core/init/migration/migrations/api_key.go | 17 + core/init/router/proxy.go | 16 +- core/middleware/operation.go | 26 + core/router/ro_base.go | 5 + core/utils/apikey_audit/audit.go | 88 +++ core/utils/apikey_migration/owner.go | 65 +++ core/utils/encrypt/api_key.go | 74 +++ core/utils/terminal_session/capabilities.go | 43 ++ core/utils/terminal_session/identity.go | 32 +- core/utils/terminal_session/revoke.go | 146 +++++ core/utils/xpack/helper/auth_helper.go | 20 +- frontend/src/api/interface/api-key.ts | 35 ++ frontend/src/api/interface/log.ts | 3 + frontend/src/api/modules/api-key.ts | 20 + .../components/api-key-management/editor.vue | 353 ++++++++++++ .../components/api-key-management/summary.vue | 47 ++ frontend/src/lang/modules/en.ts | 56 ++ frontend/src/lang/modules/es-es.ts | 56 ++ frontend/src/lang/modules/fa.ts | 56 ++ frontend/src/lang/modules/ja.ts | 54 ++ frontend/src/lang/modules/ko.ts | 53 ++ frontend/src/lang/modules/lo.ts | 50 ++ frontend/src/lang/modules/ms.ts | 57 ++ frontend/src/lang/modules/pt-br.ts | 56 ++ frontend/src/lang/modules/ru.ts | 57 ++ frontend/src/lang/modules/tr.ts | 58 ++ frontend/src/lang/modules/zh-Hant.ts | 49 ++ frontend/src/lang/modules/zh.ts | 49 ++ .../Sidebar/components/user-info/index.vue | 244 +------- frontend/src/routers/modules/setting.ts | 19 +- frontend/src/utils/api-key.ts | 34 ++ frontend/src/utils/qr-code-session.ts | 54 ++ .../host/file-management/upload/index.vue | 2 +- frontend/src/views/log/operation/index.vue | 20 +- frontend/src/views/setting/api-keys/index.vue | 314 +++++++++++ frontend/src/views/setting/index.vue | 6 +- 71 files changed, 3371 insertions(+), 295 deletions(-) create mode 100644 agent/app/api/v2/terminal_capabilities.go create mode 100644 agent/utils/terminal/auth_lease.go create mode 100644 core/app/api/v2/api_key.go create mode 100644 core/app/auth/api_key.go create mode 100644 core/app/auth/api_key_legacy_lock.go create mode 100644 core/app/auth/api_key_panel.go create mode 100644 core/app/dto/api_key.go create mode 100644 core/app/model/api_key.go create mode 100644 core/app/repo/api_key.go create mode 100644 core/app/service/api_key.go create mode 100644 core/init/migration/migrations/api_key.go create mode 100644 core/utils/apikey_audit/audit.go create mode 100644 core/utils/apikey_migration/owner.go create mode 100644 core/utils/encrypt/api_key.go create mode 100644 core/utils/terminal_session/capabilities.go create mode 100644 core/utils/terminal_session/revoke.go create mode 100644 frontend/src/api/interface/api-key.ts create mode 100644 frontend/src/api/modules/api-key.ts create mode 100644 frontend/src/components/api-key-management/editor.vue create mode 100644 frontend/src/components/api-key-management/summary.vue create mode 100644 frontend/src/utils/api-key.ts create mode 100644 frontend/src/utils/qr-code-session.ts create mode 100644 frontend/src/views/setting/api-keys/index.vue diff --git a/agent/app/api/v2/terminal.go b/agent/app/api/v2/terminal.go index 67b7f24c1..1d61349a5 100644 --- a/agent/app/api/v2/terminal.go +++ b/agent/app/api/v2/terminal.go @@ -243,6 +243,16 @@ func loadTerminalIdentity(c *gin.Context) (terminal.Identity, bool) { UserID: strings.TrimSpace(c.GetHeader(terminal.HeaderUserID)), AuthSessionID: strings.TrimSpace(c.GetHeader(terminal.HeaderAuthSessionID)), } + if value := c.GetHeader(terminal.HeaderAuthLeaseUntil); value != "" { + millis, err := strconv.ParseInt(value, 10, 64) + if err != nil || millis <= 0 { + return terminal.Identity{}, false + } + identity.AuthLeaseUntil = time.UnixMilli(millis) + if maximum := time.Now().Add(90 * time.Second); identity.AuthLeaseUntil.After(maximum) { + identity.AuthLeaseUntil = maximum + } + } return identity, identity.Valid() } diff --git a/agent/app/api/v2/terminal_capabilities.go b/agent/app/api/v2/terminal_capabilities.go new file mode 100644 index 000000000..881c5cafd --- /dev/null +++ b/agent/app/api/v2/terminal_capabilities.go @@ -0,0 +1,10 @@ +package v2 + +import ( + "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" + "github.com/gin-gonic/gin" +) + +func (b *BaseApi) TerminalCapabilities(c *gin.Context) { + helper.SuccessWithData(c, gin.H{"apiKeyLeaseVersion": 1}) +} diff --git a/agent/router/ro_host.go b/agent/router/ro_host.go index fbdae485a..61aee2760 100644 --- a/agent/router/ro_host.go +++ b/agent/router/ro_host.go @@ -11,6 +11,7 @@ func (s *HostRouter) InitRouter(Router *gin.RouterGroup) { hostRouter := Router.Group("hosts") baseApi := v2.ApiGroupApp.BaseApi Router.POST("/internal/terminal/sessions/revoke", baseApi.RevokeTerminalSessions) + Router.GET("/internal/terminal/capabilities", baseApi.TerminalCapabilities) { hostRouter.POST("", baseApi.CreateHost) hostRouter.POST("/info", baseApi.GetHostByID) diff --git a/agent/utils/terminal/auth_lease.go b/agent/utils/terminal/auth_lease.go new file mode 100644 index 000000000..73c77879c --- /dev/null +++ b/agent/utils/terminal/auth_lease.go @@ -0,0 +1,41 @@ +package terminal + +import "time" + +func (s *Session) renewAuthLease(until time.Time) bool { + s.mu.Lock() + defer s.mu.Unlock() + select { + case <-s.done: + return false + default: + } + now := time.Now() + if s.authLeaseExpired || (!s.authLeaseUntil.IsZero() && !now.Before(s.authLeaseUntil)) { + return false + } + if until.IsZero() { + return s.authLeaseUntil.IsZero() + } + if !now.Before(until) { + return false + } + s.authLeaseUntil = until + s.authLeaseVersion++ + version := s.authLeaseVersion + if s.authLeaseTimer != nil { + s.authLeaseTimer.Stop() + } + s.authLeaseTimer = time.AfterFunc(time.Until(until), func() { + s.mu.Lock() + expired := s.authLeaseVersion == version && !time.Now().Before(s.authLeaseUntil) + if expired { + s.authLeaseExpired = true + } + s.mu.Unlock() + if expired { + s.Close() + } + }) + return true +} diff --git a/agent/utils/terminal/registry.go b/agent/utils/terminal/registry.go index 15443a03f..ea69d98f0 100644 --- a/agent/utils/terminal/registry.go +++ b/agent/utils/terminal/registry.go @@ -3,27 +3,35 @@ package terminal import ( "errors" "sort" + "strings" "sync" + "time" ) -// sessions is the process wide registry of live sessions, keyed by id. -// Open stores, Close deletes. var sessions sync.Map var errSessionNotFound = errors.New("terminal session not found") const ( - HeaderUserID = "X-Panel-User-ID" - HeaderAuthSessionID = "X-Panel-Auth-Session-ID" + HeaderUserID = "X-Panel-User-ID" + HeaderAuthSessionID = "X-Panel-Auth-Session-ID" + HeaderAuthLeaseUntil = "X-Panel-Auth-Lease-Until" ) type Identity struct { - UserID string - AuthSessionID string + UserID string + AuthSessionID string + AuthLeaseUntil time.Time } func (i Identity) Valid() bool { - return i.UserID != "" && i.AuthSessionID != "" + if i.UserID == "" || i.AuthSessionID == "" { + return false + } + if strings.HasPrefix(i.AuthSessionID, "api-key:") && i.AuthLeaseUntil.IsZero() { + return false + } + return i.AuthLeaseUntil.IsZero() || time.Now().Before(i.AuthLeaseUntil) } func registerSession(s *Session) { diff --git a/agent/utils/terminal/session.go b/agent/utils/terminal/session.go index 93697a6b3..be8562539 100644 --- a/agent/utils/terminal/session.go +++ b/agent/utils/terminal/session.go @@ -73,6 +73,10 @@ type Session struct { grace *time.Timer revalidateCursor uint64 revalidatePending bool + authLeaseUntil time.Time + authLeaseTimer *time.Timer + authLeaseVersion uint64 + authLeaseExpired bool cols int rows int @@ -127,6 +131,11 @@ func serve(ws *websocket.Conn, sessionID string, opts SessionOptions, open func( if sessionID != "" { sess, ok := Lookup(sessionID, opts.Identity) if ok && sess.Kind == opts.Kind && sess.Target == opts.Target && sess.Persistent == opts.Persistent && sess.HostID == opts.HostID { + if !sess.renewAuthLease(opts.Identity.AuthLeaseUntil) { + sess.Close() + sendClose(ws, CloseCodeRevalidate, "terminal authorization expired") + return nil + } att, err := sess.Attach(ws, opts.Cols, opts.Rows) if err == nil { att.Run() @@ -197,6 +206,10 @@ func openBackend(backend sessionBackend, ring *ringBuffer, opts SessionOptions) } s.closeFn = sync.OnceFunc(s.doClose) registerSession(s) + if !s.renewAuthLease(opts.Identity.AuthLeaseUntil) { + s.Close() + return s + } go s.pump() go s.keepaliveLoop() go s.waitBackend() @@ -331,6 +344,9 @@ func (s *Session) doClose() { if s.grace != nil { s.grace.Stop() } + if s.authLeaseTimer != nil { + s.authLeaseTimer.Stop() + } s.mu.Unlock() if att != nil { att.close(websocket.CloseNormalClosure, "") diff --git a/core/app/api/v2/api_key.go b/core/app/api/v2/api_key.go new file mode 100644 index 000000000..288642f6b --- /dev/null +++ b/core/app/api/v2/api_key.go @@ -0,0 +1,110 @@ +package v2 + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/1Panel-dev/1Panel/core/app/api/v2/helper" + "github.com/1Panel-dev/1Panel/core/app/auth" + "github.com/1Panel-dev/1Panel/core/app/dto" + "github.com/1Panel-dev/1Panel/core/app/service" + "github.com/1Panel-dev/1Panel/core/buserr" + "github.com/gin-gonic/gin" +) + +func bindAPIKeyRequest(c *gin.Context, value interface{}) bool { + if _, err := auth.RequireAPIKeySession(c); err != nil { + helper.BadAuth(c, "ErrNotLogin", nil) + return false + } + decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + helper.BadRequest(c, err) + return false + } + if err := decoder.Decode(new(interface{})); err != io.EOF { + helper.BadRequest(c, errors.New("unexpected JSON data")) + return false + } + return true +} + +func apiKeyError(c *gin.Context, err error) { + var business buserr.BusinessError + if errors.As(err, &business) { + helper.ErrorWithDetail(c, http.StatusBadRequest, business.Msg, business.Err) + return + } + helper.InternalServer(c, err) +} + +func (b *BaseApi) SearchAPIKeys(c *gin.Context) { + var req dto.APIKeySearch + if !bindAPIKeyRequest(c, &req) { + return + } + result, err := service.NewAPIKeyService().Search(c, req) + if err != nil { + apiKeyError(c, err) + return + } + c.Header("Cache-Control", "no-store") + helper.SuccessWithData(c, result) +} + +func (b *BaseApi) CreateAPIKey(c *gin.Context) { + req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}} + if !bindAPIKeyRequest(c, &req) { + return + } + result, err := service.NewAPIKeyService().Create(c, req) + if err != nil { + apiKeyError(c, err) + return + } + c.Header("Cache-Control", "no-store") + helper.SuccessWithData(c, result) +} + +func (b *BaseApi) UpdateAPIKey(c *gin.Context) { + var req dto.APIKeyUpdate + if !bindAPIKeyRequest(c, &req) { + return + } + if err := service.NewAPIKeyService().Update(c, req); err != nil { + apiKeyError(c, err) + return + } + apiKeyMutationSuccess(c) +} + +func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) { + var req dto.APIKeyStatus + if !bindAPIKeyRequest(c, &req) { + return + } + if err := service.NewAPIKeyService().Status(c, req); err != nil { + apiKeyError(c, err) + return + } + apiKeyMutationSuccess(c) +} + +func (b *BaseApi) RevokeAPIKey(c *gin.Context) { + var req dto.APIKeyMutation + if !bindAPIKeyRequest(c, &req) { + return + } + if err := service.NewAPIKeyService().Revoke(c, req); err != nil { + apiKeyError(c, err) + return + } + apiKeyMutationSuccess(c) +} + +func apiKeyMutationSuccess(c *gin.Context) { + helper.SuccessWithData(c, gin.H{"terminalClosePending": c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")}) +} diff --git a/core/app/api/v2/auth.go b/core/app/api/v2/auth.go index 9cd5e4516..62ad8488a 100644 --- a/core/app/api/v2/auth.go +++ b/core/app/api/v2/auth.go @@ -10,6 +10,7 @@ import ( appauth "github.com/1Panel-dev/1Panel/core/app/auth" "github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/app/service" "github.com/1Panel-dev/1Panel/core/buserr" "github.com/1Panel-dev/1Panel/core/constant" "github.com/1Panel-dev/1Panel/core/global" @@ -423,8 +424,7 @@ func (b *BaseApi) MFAClose(c *gin.Context) { // @Router /core/auth/api/generate [post] // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"} func (b *BaseApi) GenerateApiKey(c *gin.Context) { - panelToken := c.GetHeader("1Panel-Token") - if panelToken != "" { + if _, err := appauth.RequireAPIKeySession(c); err != nil { helper.BadAuth(c, "ErrApiConfigDisable", nil) return } @@ -433,6 +433,8 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) { helper.InternalServer(c, err) return } + service.NewAPIKeyService().AuditLegacyChange(c) + c.Header("Cache-Control", "no-store") helper.SuccessWithData(c, apiKey) } @@ -446,8 +448,7 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) { // @Router /core/auth/api/update [post] // @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"} func (b *BaseApi) UpdateApiConfig(c *gin.Context) { - panelToken := c.GetHeader("1Panel-Token") - if panelToken != "" { + if _, err := appauth.RequireAPIKeySession(c); err != nil { helper.BadAuth(c, "ErrApiConfigDisable", nil) return } @@ -466,6 +467,7 @@ func (b *BaseApi) UpdateApiConfig(c *gin.Context) { helper.InternalServer(c, err) return } + service.NewAPIKeyService().AuditLegacyChange(c) helper.Success(c) } @@ -481,6 +483,10 @@ func (b *BaseApi) GetCurrentUser(c *gin.Context) { helper.InternalServer(c, err) return } + if c.GetBool("API_AUTH") || appauth.HasAPICredentials(c) { + userInfo.ApiKey = "" + } + c.Header("Cache-Control", "no-store") helper.SuccessWithData(c, userInfo) } diff --git a/core/app/api/v2/setting.go b/core/app/api/v2/setting.go index 6dacd5664..9f49514e4 100644 --- a/core/app/api/v2/setting.go +++ b/core/app/api/v2/setting.go @@ -62,7 +62,6 @@ func (b *BaseApi) UpdateFileDownloadPreference(c *gin.Context) { } func fileDownloadPreferenceUser(c *gin.Context) (psession.SessionUser, bool) { - // Preferences always belong to the authenticated session, never a request-supplied user ID. user, err := global.SESSION.Get(c) if err != nil || user.ID == "" { helper.BadAuth(c, "ErrNotLogin", buserr.New("ErrNotLogin")) @@ -137,7 +136,16 @@ func (b *BaseApi) GetSystemAvailable(c *gin.Context) { // @x-panel-log {"bodyKeys":["key","value"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"修改系统配置 [key] => [value]","formatEN":"update system setting [key] => [value]"} func (b *BaseApi) UpdateSetting(c *gin.Context) { var req dto.SettingUpdate - if err := helper.CheckBindAndValidate(&req, c); err != nil { + if err := c.ShouldBindJSON(&req); err != nil { + helper.BadRequest(c, err) + return + } + if appauth.IsAPICredentialSetting(req.Key) { + helper.BadRequest(c, buserr.New("ErrInvalidParams")) + return + } + if err := global.VALID.Struct(&req); err != nil { + helper.BadRequest(c, err) return } if req.Key == "SecurityEntrance" { diff --git a/core/app/auth/api_auth.go b/core/app/auth/api_auth.go index b8788373a..855d0463b 100644 --- a/core/app/auth/api_auth.go +++ b/core/app/auth/api_auth.go @@ -26,12 +26,21 @@ type APIAuthConfig struct { IpWhiteList string ApiTrustedProxies string ApiKeyValidityTime int + KeyID string + KeyName string + KeyRevision uint64 + KeyExpiresAt *time.Time + Owner APIKeyOwner } type APIAuthConfigLoader func(c *gin.Context) (APIAuthConfig, error) type APIAuthSuccessHandler func(c *gin.Context, config APIAuthConfig) -func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessHandler) gin.HandlerFunc { +func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessHandler, ownerProviders ...APIKeyOwnerProvider) gin.HandlerFunc { + var ownerProvider APIKeyOwnerProvider + if len(ownerProviders) > 0 { + ownerProvider = ownerProviders[0] + } return func(c *gin.Context) { if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") { c.Next() @@ -40,12 +49,18 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH panelToken := c.GetHeader("1Panel-Token") panelTimestamp := c.GetHeader("1Panel-Timestamp") - if panelToken == "" && panelTimestamp == "" { + if !HasAPICredentials(c) { c.Next() return } - config, err := loadConfig(c) + loader := loadConfig + if ownerProvider != nil { + loader = func(c *gin.Context) (APIAuthConfig, error) { + return loadMultiAPIKeyConfig(c, loadConfig, ownerProvider) + } + } + config, err := loader(c) if err != nil { var bizErr buserr.BusinessError if errors.As(err, &bizErr) && strings.HasPrefix(bizErr.Msg, "ErrApiConfig") { @@ -63,7 +78,7 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH helper.BadAuth(c, "ErrApiConfigKeyTimeInvalid", nil) return } - if !isValid1PanelToken(panelToken, panelTimestamp, config.ApiKey) { + if !IsValid1PanelTokenWithVersion(panelToken, panelTimestamp, config.ApiKey, c.GetHeader("1Panel-Signature-Version")) { helper.BadAuth(c, "ErrApiConfigKeyInvalid", nil) return } @@ -73,9 +88,11 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH } c.Set("API_AUTH", true) + c.Set("API_AUTH_CLIENT_IP", GetAPIClientIP(c, config.ApiTrustedProxies)) if onSuccess != nil { onSuccess(c, config) } + SetAPIKeyContext(c, config, ownerProvider) c.Next() } } @@ -141,6 +158,9 @@ func IsValid1PanelToken(panelToken string, panelTimestamp string, apiKey string) } func IsValid1PanelTokenWithVersion(panelToken string, panelTimestamp string, apiKey string, signatureVersion string) bool { + if apiKey == "" || panelToken == "" { + return false + } panelToken = strings.ToLower(strings.TrimSpace(panelToken)) version := strings.ToLower(strings.TrimSpace(signatureVersion)) switch version { diff --git a/core/app/auth/api_key.go b/core/app/auth/api_key.go new file mode 100644 index 000000000..0dbdc99ab --- /dev/null +++ b/core/app/auth/api_key.go @@ -0,0 +1,205 @@ +package auth + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/app/repo" + "github.com/1Panel-dev/1Panel/core/buserr" + "github.com/1Panel-dev/1Panel/core/constant" + "github.com/1Panel-dev/1Panel/core/global" + "github.com/1Panel-dev/1Panel/core/init/session/psession" + "github.com/1Panel-dev/1Panel/core/utils/encrypt" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const APIKeyOwnerPanelAdmin = "panel_admin" +const APIKeyOwnerEnterpriseUser = "enterprise_user" + +func IsAPICredentialSetting(key string) bool { + switch strings.ToLower(strings.TrimSpace(key)) { + case "apikey", "apiinterfacestatus", "ipwhitelist", "apitrustedproxies", "apikeyvaliditytime", "encryptkey": + return true + default: + return false + } +} + +type APIKeyOwner struct { + Type string + ID string + Name string + IsSuperAdmin bool +} + +type APIKeyOwnerProvider interface { + CurrentAPIKeyOwner(c *gin.Context) (APIKeyOwner, error) + ResolveAPIKeyOwner(c *gin.Context, ownerType, ownerID string) (APIKeyOwner, error) + LoadLegacyAPIKey(owner APIKeyOwner) (APIAuthConfig, error) + SaveLegacyAPIKey(owner APIKeyOwner, config APIAuthConfig) error +} + +type APIKeyBinding struct { + KeyID string + KeyName string + Owner APIKeyOwner + Revision uint64 + Fingerprint string +} + +func RequireAPIKeySession(c *gin.Context) (psession.SessionUser, error) { + if c == nil || c.GetBool("API_AUTH") || c.GetBool("LOCAL_REQUEST") || HasAPICredentials(c) || global.SESSION == nil { + return psession.SessionUser{}, buserr.New("ErrNotLogin") + } + u, err := global.SESSION.Get(c) + if err != nil || u.ID == "" || u.Name == "" { + return psession.SessionUser{}, buserr.New("ErrNotLogin") + } + c.Set(psession.GinContextSessionUserKey, u) + return u, nil +} + +func HasAPICredentials(c *gin.Context) bool { + return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != "" +} + +func LoadLegacyAPIKeyPolicy(owner APIKeyOwner) (model.LegacyAPIKeyPolicy, error) { + policy := model.LegacyAPIKeyPolicy{OwnerType: owner.Type, OwnerID: owner.ID, AllowAppBinding: true, Revision: 1} + var saved model.LegacyAPIKeyPolicy + err := repo.APIKeyOwnerQuery(global.DB, owner.Type, owner.ID).First(&saved).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return policy, nil + } + return saved, err +} + +func LegacyAPIKeyFingerprint(owner APIKeyOwner, config APIAuthConfig, policy model.LegacyAPIKeyPolicy) (string, uint64) { + data, _ := json.Marshal([]interface{}{owner.Type, owner.ID, config.ApiInterfaceStatus, config.ApiKey, config.IpWhiteList, config.ApiTrustedProxies, config.ApiKeyValidityTime, policy.AllowAppBinding, policy.Revision}) + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]), (binary.BigEndian.Uint64(hash[:8]) & ((1 << 53) - 1)) + 1 +} + +func APIKeySecret(key model.APIKey) (string, error) { + if key.SecretVersion != 1 || key.SecretCiphertext == "" { + return "", buserr.New("ErrApiConfigKeyInvalid") + } + master, err := encrypt.APIKeyEncryptionKey() + if err != nil { + return "", err + } + return encrypt.DecryptAPIKey(key.SecretCiphertext, key.ID, master) +} + +func ValidateAPIKeyState(key model.APIKey, now time.Time) error { + if key.Status != constant.StatusEnable { + return buserr.New("ErrApiConfigStatusInvalid") + } + if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) { + return buserr.New("ErrApiConfigKeyExpired") + } + return nil +} + +func configForAPIKey(key model.APIKey, secret string, owner APIKeyOwner) APIAuthConfig { + return APIAuthConfig{ApiInterfaceStatus: key.Status, ApiKey: secret, IpWhiteList: key.IPWhiteList, ApiTrustedProxies: key.APITrustedProxies, ApiKeyValidityTime: key.APIKeyValidityTime, KeyID: key.ID, KeyName: key.Name, KeyRevision: key.Revision, KeyExpiresAt: key.ExpiresAt, Owner: owner} +} + +func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) { + keyID := c.GetHeader("1Panel-Key-ID") + q := global.DB.Model(&model.APIKey{}).Select("id", "secret_ciphertext", "secret_version", "revision") + if keyID != "" { + q = q.Where("id = ?", keyID) + } else { + q = q.Where("status <> ?", "Revoked") + } + master, err := encrypt.APIKeyEncryptionKey() + if err != nil { + if keyID == "" { + return legacy(c) + } + return APIAuthConfig{}, err + } + var candidates []model.APIKey + var matched *model.APIKey + err = q.Order("id").FindInBatches(&candidates, 128, func(_ *gorm.DB, _ int) error { + for _, key := range candidates { + if key.SecretVersion != 1 { + continue + } + secret, err := encrypt.DecryptAPIKey(key.SecretCiphertext, key.ID, master) + if err != nil { + continue + } + if IsValid1PanelTokenWithVersion(c.GetHeader("1Panel-Token"), c.GetHeader("1Panel-Timestamp"), secret, c.GetHeader("1Panel-Signature-Version")) { + copy := key + matched = © + return errAPIKeyMatched + } + } + return nil + }).Error + if err != nil && !errors.Is(err, errAPIKeyMatched) { + return APIAuthConfig{}, err + } + if matched == nil { + if keyID != "" { + return APIAuthConfig{}, buserr.New("ErrApiConfigKeyInvalid") + } + return legacy(c) + } + key, err := repo.NewAPIKeyRepo().Get(matched.ID) + if err != nil || key.Revision != matched.Revision { + return APIAuthConfig{}, buserr.New("ErrApiConfigStatusInvalid") + } + if err = ValidateAPIKeyState(key, time.Now()); err != nil { + return APIAuthConfig{}, err + } + owner, err := provider.ResolveAPIKeyOwner(c, key.OwnerType, key.OwnerID) + if err != nil { + return APIAuthConfig{}, buserr.New("ErrApiConfigStatusInvalid") + } + secret, err := APIKeySecret(key) + if err != nil { + return APIAuthConfig{}, buserr.New("ErrApiConfigKeyInvalid") + } + return configForAPIKey(key, secret, owner), nil +} + +var errAPIKeyMatched = errors.New("API credential matched") + +func SetAPIKeyContext(c *gin.Context, config APIAuthConfig, provider APIKeyOwnerProvider) { + if config.KeyID != "" { + c.Set("API_AUTH_KEY_KIND", "apiKey") + c.Set("API_AUTH_KEY_ID", config.KeyID) + c.Set("API_AUTH_KEY_NAME", config.KeyName) + c.Set("API_AUTH_KEY_REVISION", config.KeyRevision) + c.Set("API_AUTH_OWNER_TYPE", config.Owner.Type) + c.Set("API_AUTH_OWNER_ID", config.Owner.ID) + if config.KeyExpiresAt != nil { + c.Set("API_AUTH_KEY_EXPIRES_AT", *config.KeyExpiresAt) + } + role := "COMMON_USER" + if config.Owner.IsSuperAdmin { + role = "ADMIN" + } + c.Set(psession.GinContextSessionUserKey, psession.SessionUser{ID: config.Owner.ID, Name: config.Owner.Name, Role: role}) + c.Set("API_AUTH_USERNAME", config.Owner.Name) + return + } + c.Set("API_AUTH_KEY_KIND", "legacy") + c.Set("API_AUTH_KEY_ID", "legacy") + c.Set("API_AUTH_KEY_NAME", "Legacy API Key") + if provider != nil { + if owner, err := provider.CurrentAPIKeyOwner(c); err == nil { + c.Set("API_AUTH_OWNER_TYPE", owner.Type) + c.Set("API_AUTH_OWNER_ID", owner.ID) + } + } +} diff --git a/core/app/auth/api_key_legacy_lock.go b/core/app/auth/api_key_legacy_lock.go new file mode 100644 index 000000000..893474935 --- /dev/null +++ b/core/app/auth/api_key_legacy_lock.go @@ -0,0 +1,11 @@ +package auth + +import "sync" + +var legacyAPIKeyMutationMu sync.Mutex + +func WithLegacyAPIKeyMutation(change func() error) error { + legacyAPIKeyMutationMu.Lock() + defer legacyAPIKeyMutationMu.Unlock() + return change() +} diff --git a/core/app/auth/api_key_panel.go b/core/app/auth/api_key_panel.go new file mode 100644 index 000000000..264660f88 --- /dev/null +++ b/core/app/auth/api_key_panel.go @@ -0,0 +1,45 @@ +package auth + +import ( + "github.com/1Panel-dev/1Panel/core/app/dto" + "github.com/1Panel-dev/1Panel/core/app/repo" + "github.com/1Panel-dev/1Panel/core/buserr" + "github.com/1Panel-dev/1Panel/core/init/session/psession" + "github.com/gin-gonic/gin" +) + +type PanelAPIKeyOwnerProvider struct{} + +func (p PanelAPIKeyOwnerProvider) CurrentAPIKeyOwner(c *gin.Context) (APIKeyOwner, error) { + value, ok := c.Get(psession.GinContextSessionUserKey) + u, valid := value.(psession.SessionUser) + if !ok || !valid || u.ID != psession.SuperAdminSessionUserID { + return APIKeyOwner{}, buserr.New("ErrNotLogin") + } + return p.ResolveAPIKeyOwner(c, APIKeyOwnerPanelAdmin, u.ID) +} + +func (PanelAPIKeyOwnerProvider) ResolveAPIKeyOwner(_ *gin.Context, ownerType, ownerID string) (APIKeyOwner, error) { + if ownerType != APIKeyOwnerPanelAdmin || ownerID != psession.SuperAdminSessionUserID { + return APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid") + } + name, err := repo.NewISettingRepo().GetValueByKey("UserName") + if err != nil || name == "" { + return APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid") + } + return APIKeyOwner{Type: ownerType, ID: ownerID, Name: name, IsSuperAdmin: true}, nil +} + +func (p PanelAPIKeyOwnerProvider) LoadLegacyAPIKey(owner APIKeyOwner) (APIAuthConfig, error) { + if _, err := p.ResolveAPIKeyOwner(nil, owner.Type, owner.ID); err != nil { + return APIAuthConfig{}, err + } + return LoadAPIAuthConfig(nil) +} + +func (p PanelAPIKeyOwnerProvider) SaveLegacyAPIKey(owner APIKeyOwner, config APIAuthConfig) error { + if _, err := p.ResolveAPIKeyOwner(nil, owner.Type, owner.ID); err != nil { + return err + } + return StoreLegacyAPIConfig(dto.ApiInterfaceConfig{ApiInterfaceStatus: config.ApiInterfaceStatus, ApiKey: config.ApiKey, IpWhiteList: config.IpWhiteList, ApiTrustedProxies: config.ApiTrustedProxies, ApiKeyValidityTime: config.ApiKeyValidityTime}) +} diff --git a/core/app/auth/auth.go b/core/app/auth/auth.go index 825c1edb4..93fe11885 100644 --- a/core/app/auth/auth.go +++ b/core/app/auth/auth.go @@ -341,12 +341,17 @@ func UpdateCurrentUserInfo(c *gin.Context, req dto.CurrentUserUpdate) error { func GenerateApiKey() (string, error) { apiKey := common.RandStr(32) - if err := repo.NewISettingRepo().Update("ApiKey", apiKey); err != nil { + if err := WithLegacyAPIKeyMutation(func() error { return repo.NewISettingRepo().Update("ApiKey", apiKey) }); err != nil { return "", err } return apiKey, nil } + func UpdateApiConfig(req dto.ApiInterfaceConfig) error { + return WithLegacyAPIKeyMutation(func() error { return StoreLegacyAPIConfig(req) }) +} + +func StoreLegacyAPIConfig(req dto.ApiInterfaceConfig) error { settingRepo := repo.NewISettingRepo() trustedProxies, err := NormalizeAPITrustedProxies(req.ApiTrustedProxies) if err != nil { diff --git a/core/app/dto/api_key.go b/core/app/dto/api_key.go new file mode 100644 index 000000000..3d1dc7339 --- /dev/null +++ b/core/app/dto/api_key.go @@ -0,0 +1,64 @@ +package dto + +import ( + "encoding/json" + "time" +) + +type APIKeyItem struct { + ID string `json:"id"` + Kind string `json:"kind"` + Name string `json:"name"` + Description string `json:"description"` + KeyHint string `json:"keyHint"` + Status string `json:"status"` + IPWhiteList string `json:"ipWhiteList"` + APITrustedProxies string `json:"apiTrustedProxies"` + APIKeyValidityTime int `json:"apiKeyValidityTime"` + ExpiresAt *time.Time `json:"expiresAt"` + AllowAppBinding bool `json:"allowAppBinding"` + Revision uint64 `json:"revision"` + CreatedAt *time.Time `json:"createdAt"` +} + +type APIKeySearch struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + ExcludeRevoked bool `json:"excludeRevoked"` +} +type APIKeyPage struct { + Items []APIKeyItem `json:"items"` + Total int `json:"total"` + Used int `json:"used"` + Limit int `json:"limit"` +} +type APIKeyFields struct { + Name string `json:"name"` + Description string `json:"description"` + IPWhiteList string `json:"ipWhiteList"` + APITrustedProxies string `json:"apiTrustedProxies"` + APIKeyValidityTime int `json:"apiKeyValidityTime"` + ExpiresAt json.RawMessage `json:"expiresAt"` + AllowAppBinding bool `json:"allowAppBinding"` +} +type APIKeyCreate struct { + RequestID string `json:"requestID"` + APIKeyFields +} +type APIKeyCreated struct { + Item APIKeyItem `json:"item"` + APIKey string `json:"apiKey"` + AlreadyCreated bool `json:"alreadyCreated,omitempty"` +} +type APIKeyMutation struct { + ID string `json:"id"` + Revision uint64 `json:"revision"` +} +type APIKeyUpdate struct { + APIKeyMutation + APIKeyFields +} +type APIKeyStatus struct { + APIKeyMutation + Status string `json:"status"` +} diff --git a/core/app/dto/logs.go b/core/app/dto/logs.go index ecee476ca..4c4417582 100644 --- a/core/app/dto/logs.go +++ b/core/app/dto/logs.go @@ -5,14 +5,17 @@ import ( ) type OperationLog struct { - ID uint `json:"id"` - Source string `json:"source"` - User string `json:"user"` - Node string `json:"node"` - IP string `json:"ip"` - Path string `json:"path"` - Method string `json:"method"` - UserAgent string `json:"userAgent"` + ID uint `json:"id"` + Source string `json:"source"` + User string `json:"user"` + APIKeyID string `json:"apiKeyID"` + APIKeyName string `json:"apiKeyName"` + AuthMethod string `json:"authMethod"` + Node string `json:"node"` + IP string `json:"ip"` + Path string `json:"path"` + Method string `json:"method"` + UserAgent string `json:"userAgent"` Latency time.Duration `json:"latency"` Status string `json:"status"` diff --git a/core/app/model/api_key.go b/core/app/model/api_key.go new file mode 100644 index 000000000..7fc83384e --- /dev/null +++ b/core/app/model/api_key.go @@ -0,0 +1,34 @@ +package model + +import "time" + +type APIKey struct { + ID string `gorm:"type:varchar(36);primaryKey" json:"-"` + OwnerType string `gorm:"not null;uniqueIndex:idx_api_key_request;uniqueIndex:idx_api_key_active_name;index:idx_api_key_owner" json:"-"` + OwnerID string `gorm:"not null;uniqueIndex:idx_api_key_request;uniqueIndex:idx_api_key_active_name;index:idx_api_key_owner" json:"-"` + Name string `gorm:"not null" json:"-"` + ActiveName *string `gorm:"uniqueIndex:idx_api_key_active_name" json:"-"` + Description string `json:"-"` + RequestID string `gorm:"not null;uniqueIndex:idx_api_key_request" json:"-"` + RequestHash string `json:"-"` + SecretCiphertext string `gorm:"type:text;not null" json:"-"` + SecretVersion int `gorm:"not null" json:"-"` + KeyHint string `json:"-"` + Status string `gorm:"not null;index" json:"-"` + IPWhiteList string `json:"-"` + APITrustedProxies string `json:"-"` + APIKeyValidityTime int `json:"-"` + ExpiresAt *time.Time `gorm:"index" json:"-"` + AllowAppBinding bool `json:"-"` + Revision uint64 `gorm:"not null" json:"-"` + CreatedAt time.Time `json:"-"` + UpdatedAt time.Time `json:"-"` + RevokedAt *time.Time `json:"-"` +} + +type LegacyAPIKeyPolicy struct { + OwnerType string `gorm:"primaryKey"` + OwnerID string `gorm:"primaryKey"` + AllowAppBinding bool + Revision uint64 +} diff --git a/core/app/model/logs.go b/core/app/model/logs.go index f4fc71b52..9f006b7e5 100644 --- a/core/app/model/logs.go +++ b/core/app/model/logs.go @@ -6,13 +6,16 @@ import ( type OperationLog struct { BaseModel - Source string `json:"source"` - User string `json:"user"` - IP string `json:"ip"` - Node string `json:"node"` - Path string `json:"path"` - Method string `json:"method"` - UserAgent string `json:"userAgent"` + Source string `json:"source"` + User string `json:"user"` + APIKeyID string `json:"apiKeyID"` + APIKeyName string `json:"apiKeyName"` + AuthMethod string `json:"authMethod"` + IP string `json:"ip"` + Node string `json:"node"` + Path string `json:"path"` + Method string `json:"method"` + UserAgent string `json:"userAgent"` Latency time.Duration `json:"latency"` Status string `json:"status"` diff --git a/core/app/repo/api_key.go b/core/app/repo/api_key.go new file mode 100644 index 000000000..bb88926cd --- /dev/null +++ b/core/app/repo/api_key.go @@ -0,0 +1,28 @@ +package repo + +import ( + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/global" + "gorm.io/gorm" +) + +type APIKeyRepo struct{} + +func NewAPIKeyRepo() *APIKeyRepo { return &APIKeyRepo{} } +func APIKeyOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB { + return db.Where("owner_type = ? AND owner_id = ?", ownerType, ownerID) +} +func (r *APIKeyRepo) Get(id string) (model.APIKey, error) { + var key model.APIKey + err := global.DB.Where("id = ?", id).First(&key).Error + return key, err +} +func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) { + items := make([]model.APIKey, 0) + q := APIKeyOwnerQuery(global.DB, ownerType, ownerID) + if excludeRevoked { + q = q.Where("status <> ?", "Revoked") + } + err := q.Order("created_at DESC, id DESC").Find(&items).Error + return items, err +} diff --git a/core/app/service/api_key.go b/core/app/service/api_key.go new file mode 100644 index 000000000..4aa670d1d --- /dev/null +++ b/core/app/service/api_key.go @@ -0,0 +1,522 @@ +package service + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/1Panel-dev/1Panel/core/app/auth" + "github.com/1Panel-dev/1Panel/core/app/dto" + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/app/repo" + "github.com/1Panel-dev/1Panel/core/buserr" + "github.com/1Panel-dev/1Panel/core/constant" + "github.com/1Panel-dev/1Panel/core/global" + "github.com/1Panel-dev/1Panel/core/utils/apikey_audit" + "github.com/1Panel-dev/1Panel/core/utils/common" + "github.com/1Panel-dev/1Panel/core/utils/encrypt" + terminalsession "github.com/1Panel-dev/1Panel/core/utils/terminal_session" + "github.com/1Panel-dev/1Panel/core/utils/xpack" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const APIKeyLimit = 20 + +var apiKeyMutationMu sync.Mutex + +type APIKeyService struct { + Provider auth.APIKeyOwnerProvider + RevokeTerminals terminalsession.RevokeFunc +} + +func NewAPIKeyService() *APIKeyService { + provider, _ := xpack.AuthProvider.(auth.APIKeyOwnerProvider) + return &APIKeyService{Provider: provider, RevokeTerminals: xpack.AuthProvider.RevokeTerminalSessions} +} + +func (s *APIKeyService) owner(c *gin.Context) (auth.APIKeyOwner, error) { + if _, err := auth.RequireAPIKeySession(c); err != nil { + return auth.APIKeyOwner{}, err + } + if s.Provider == nil { + return auth.APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid") + } + return s.Provider.CurrentAPIKeyOwner(c) +} + +func apiKeyItem(key model.APIKey) dto.APIKeyItem { + status := key.Status + if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) { + status = "Expired" + } + return dto.APIKeyItem{ID: key.ID, Kind: "apiKey", Name: key.Name, Description: key.Description, KeyHint: key.KeyHint, Status: status, IPWhiteList: key.IPWhiteList, APITrustedProxies: key.APITrustedProxies, APIKeyValidityTime: key.APIKeyValidityTime, ExpiresAt: key.ExpiresAt, AllowAppBinding: key.AllowAppBinding, Revision: key.Revision, CreatedAt: &key.CreatedAt} +} + +func (s *APIKeyService) legacy(owner auth.APIKeyOwner) (dto.APIKeyItem, auth.APIAuthConfig, model.LegacyAPIKeyPolicy, string, error) { + config, err := s.Provider.LoadLegacyAPIKey(owner) + if err != nil { + return dto.APIKeyItem{}, config, model.LegacyAPIKeyPolicy{}, "", err + } + policy, err := auth.LoadLegacyAPIKeyPolicy(owner) + if err != nil { + return dto.APIKeyItem{}, config, policy, "", err + } + fingerprint, revision := auth.LegacyAPIKeyFingerprint(owner, config, policy) + item := dto.APIKeyItem{ID: "legacy", Kind: "legacy", Name: "Legacy API Key", KeyHint: keyHint(config.ApiKey), Status: config.ApiInterfaceStatus, IPWhiteList: config.IpWhiteList, APITrustedProxies: config.ApiTrustedProxies, APIKeyValidityTime: config.ApiKeyValidityTime, AllowAppBinding: policy.AllowAppBinding, Revision: revision} + return item, config, policy, fingerprint, nil +} + +func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) { + owner, err := s.owner(c) + if err != nil { + return nil, err + } + if req.Page < 1 || req.PageSize < 1 || req.PageSize > 100 { + return nil, buserr.New("ErrInvalidParams") + } + keys, err := repo.NewAPIKeyRepo().List(owner.Type, owner.ID, req.ExcludeRevoked) + if err != nil { + return nil, err + } + items := make([]dto.APIKeyItem, 0, len(keys)+1) + legacy, config, _, _, err := s.legacy(owner) + if err != nil { + return nil, err + } + if config.ApiKey != "" { + items = append(items, legacy) + } + used := 0 + for _, key := range keys { + items = append(items, apiKeyItem(key)) + if key.Status != "Revoked" { + used++ + } + } + result := &dto.APIKeyPage{Items: []dto.APIKeyItem{}, Total: len(items), Used: used, Limit: APIKeyLimit} + start := (req.Page - 1) * req.PageSize + if start >= 0 && start < len(items) { + result.Items = items[start:min(start+req.PageSize, len(items))] + } + return result, nil +} + +func validateAPIKeyFields(fields *dto.APIKeyFields, create bool) (*time.Time, error) { + fields.Name = strings.TrimSpace(fields.Name) + fields.Description = strings.TrimSpace(fields.Description) + if fields.Name == "" || utf8.RuneCountInString(fields.Name) > 64 || utf8.RuneCountInString(fields.Description) > 256 || fields.APIKeyValidityTime < 0 || fields.APIKeyValidityTime > 1440 || len(fields.IPWhiteList) > 4096 || len(fields.APITrustedProxies) > 4096 { + return nil, buserr.New("ErrInvalidParams") + } + ips, err := common.HandleIPList(fields.IPWhiteList) + if err != nil || len(ips) == 0 { + return nil, buserr.New("ErrInvalidParams") + } + + fields.IPWhiteList = strings.Join(ips, "\n") + fields.APITrustedProxies, err = auth.NormalizeAPITrustedProxies(fields.APITrustedProxies) + if err != nil { + return nil, err + } + if len(fields.ExpiresAt) == 0 { + if !create { + return nil, buserr.New("ErrInvalidParams") + } + expires := time.Now().UTC().Add(90 * 24 * time.Hour) + return &expires, nil + } + if bytes.Equal(bytes.TrimSpace(fields.ExpiresAt), []byte("null")) { + return nil, nil + } + var expires time.Time + if err = json.Unmarshal(fields.ExpiresAt, &expires); err != nil || !expires.After(time.Now()) { + return nil, buserr.New("ErrInvalidParams") + } + return &expires, nil +} + +func keyHint(secret string) string { + if len(secret) < 8 { + return "" + } + return secret[:4] + "••••" + secret[len(secret)-4:] +} + +func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) { + owner, err := s.owner(c) + if err != nil { + return nil, err + } + if _, err = uuid.Parse(req.RequestID); err != nil { + return nil, buserr.New("ErrInvalidParams") + } + expires, err := validateAPIKeyFields(&req.APIKeyFields, true) + if err != nil { + return nil, err + } + encoded, _ := json.Marshal(req.APIKeyFields) + digest := sha256.Sum256(encoded) + requestHash := hex.EncodeToString(digest[:]) + master, err := encrypt.APIKeyEncryptionKey() + if err != nil { + return nil, err + } + apiKeyMutationMu.Lock() + var key model.APIKey + var secret string + repeated := false + err = global.DB.Transaction(func(tx *gorm.DB) error { + q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID) + err := q.Where("request_id = ?", req.RequestID).First(&key).Error + if err == nil { + if key.RequestHash != requestHash { + return buserr.New("ErrAPIKeyConflict") + } + repeated = true + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + var count int64 + if err = repo.APIKeyOwnerQuery(tx.Model(&model.APIKey{}), owner.Type, owner.ID).Where("status <> ?", "Revoked").Count(&count).Error; err != nil { + return err + } + if count >= APIKeyLimit { + return buserr.New("ErrAPIKeyLimit") + } + if err = ensureAPIKeyName(tx, owner, req.Name, ""); err != nil { + return err + } + bytes := make([]byte, 16) + if _, err = rand.Read(bytes); err != nil { + return err + } + secret = hex.EncodeToString(bytes) + key = model.APIKey{ID: uuid.NewString(), OwnerType: owner.Type, OwnerID: owner.ID, Name: req.Name, ActiveName: &req.Name, Description: req.Description, RequestID: req.RequestID, RequestHash: requestHash, SecretVersion: 1, KeyHint: keyHint(secret), Status: constant.StatusEnable, IPWhiteList: req.IPWhiteList, APITrustedProxies: req.APITrustedProxies, APIKeyValidityTime: req.APIKeyValidityTime, ExpiresAt: expires, AllowAppBinding: req.AllowAppBinding, Revision: 1} + key.SecretCiphertext, err = encrypt.EncryptAPIKey(secret, key.ID, master) + if err != nil { + return err + } + return tx.Create(&key).Error + }) + apiKeyMutationMu.Unlock() + if err != nil { + return nil, err + } + if !repeated { + recordAPIKeyEvent(c, owner, key.ID, key.Name, "create") + } + return &dto.APIKeyCreated{Item: apiKeyItem(key), APIKey: secret, AlreadyCreated: repeated}, nil +} + +func ensureAPIKeyName(tx *gorm.DB, owner auth.APIKeyOwner, name, id string) error { + var count int64 + err := repo.APIKeyOwnerQuery(tx.Model(&model.APIKey{}), owner.Type, owner.ID).Where("active_name = ? AND id <> ?", name, id).Count(&count).Error + if err != nil { + return err + } + if count > 0 { + return buserr.New("ErrAPIKeyNameExists") + } + return nil +} + +func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error { + owner, err := s.owner(c) + if err != nil { + return err + } + if req.ID == "legacy" { + return s.updateLegacy(c, owner, req.APIKeyMutation, &req.APIKeyFields, "update", "") + } + expires, err := validateAPIKeyFields(&req.APIKeyFields, false) + if err != nil { + return err + } + return s.mutate(c, owner, req.APIKeyMutation, "update", func(tx *gorm.DB, key *model.APIKey) error { + if err := ensureAPIKeyName(tx, owner, req.Name, key.ID); err != nil { + return err + } + key.Name = req.Name + key.ActiveName = &req.Name + key.Description = req.Description + key.IPWhiteList = req.IPWhiteList + key.APITrustedProxies = req.APITrustedProxies + key.APIKeyValidityTime = req.APIKeyValidityTime + key.ExpiresAt = expires + key.AllowAppBinding = req.AllowAppBinding + return nil + }) +} + +func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error { + owner, err := s.owner(c) + if err != nil { + return err + } + if req.Status != constant.StatusEnable && req.Status != constant.StatusDisable { + return buserr.New("ErrInvalidParams") + } + action := "disable" + if req.Status == constant.StatusEnable { + action = "enable" + } + if req.ID == "legacy" { + return s.updateLegacy(c, owner, req.APIKeyMutation, nil, action, req.Status) + } + return s.mutate(c, owner, req.APIKeyMutation, action, func(_ *gorm.DB, key *model.APIKey) error { + if req.Status == constant.StatusEnable && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) { + return buserr.New("ErrApiConfigKeyExpired") + } + key.Status = req.Status + return nil + }) +} + +func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error { + owner, err := s.owner(c) + if err != nil { + return err + } + if req.ID == "legacy" { + return s.updateLegacy(c, owner, req, nil, "revoke", constant.StatusDisable) + } + return s.mutate(c, owner, req, "revoke", func(_ *gorm.DB, key *model.APIKey) error { + now := time.Now() + key.Status = "Revoked" + key.RevokedAt = &now + key.ActiveName = nil + key.SecretCiphertext = "" + key.AllowAppBinding = false + return nil + }) +} + +func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error { + if req.ID == "" || req.Revision == 0 { + return buserr.New("ErrInvalidParams") + } + apiKeyMutationMu.Lock() + var key model.APIKey + closeTerminals := action == "disable" || action == "revoke" + err := global.DB.Transaction(func(tx *gorm.DB) error { + if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ?", req.ID).First(&key).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return buserr.New("ErrAPIKeyNotFound") + } + return err + } + if key.Revision != req.Revision || key.Status == "Revoked" { + return buserr.New("ErrAPIKeyConflict") + } + before := key + if err := change(tx, &key); err != nil { + return err + } + closeTerminals = closeTerminals || before.IPWhiteList != key.IPWhiteList || before.APITrustedProxies != key.APITrustedProxies || before.APIKeyValidityTime != key.APIKeyValidityTime || (key.ExpiresAt != nil && (before.ExpiresAt == nil || key.ExpiresAt.Before(*before.ExpiresAt))) + key.Revision++ + result := tx.Model(&model.APIKey{}).Where("id = ? AND revision = ?", key.ID, req.Revision).Select("*").Updates(&key) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return buserr.New("ErrAPIKeyConflict") + } + return nil + }) + apiKeyMutationMu.Unlock() + if err != nil { + return err + } + recordAPIKeyEvent(c, owner, key.ID, key.Name, action) + if closeTerminals { + s.revokeAPIKeyTerminals(c, owner, key.ID) + } + return nil +} + +func (s *APIKeyService) updateLegacy(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, fields *dto.APIKeyFields, action, status string) error { + return auth.WithLegacyAPIKeyMutation(func() error { + return s.updateLegacyLocked(c, owner, req, fields, action, status) + }) +} + +func (s *APIKeyService) updateLegacyLocked(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, fields *dto.APIKeyFields, action, status string) error { + item, config, policy, _, err := s.legacy(owner) + if err != nil { + return err + } + if config.ApiKey == "" { + return buserr.New("ErrAPIKeyNotFound") + } + if req.Revision == 0 || req.Revision != item.Revision { + return buserr.New("ErrAPIKeyConflict") + } + previousConfig := config + if fields != nil { + fields.Name = "Legacy API Key" + legacyWindow := fields.APIKeyValidityTime + if legacyWindow > 1440 && legacyWindow == config.ApiKeyValidityTime { + fields.APIKeyValidityTime = 1440 + } + expires, err := validateAPIKeyFields(fields, false) + fields.APIKeyValidityTime = legacyWindow + if err != nil { + return err + } + if expires != nil { + return buserr.New("ErrInvalidParams") + } + config.IpWhiteList = fields.IPWhiteList + config.ApiTrustedProxies = fields.APITrustedProxies + config.ApiKeyValidityTime = fields.APIKeyValidityTime + policy.AllowAppBinding = fields.AllowAppBinding + } + if status != "" { + config.ApiInterfaceStatus = status + } + if action == "revoke" { + config.ApiKey = "" + policy.AllowAppBinding = false + } + allowAppBinding := policy.AllowAppBinding + policy.AllowAppBinding = false + policy.Revision++ + if err = global.DB.Clauses(clause.OnConflict{UpdateAll: true}).Create(&policy).Error; err != nil { + return err + } + if err = s.Provider.SaveLegacyAPIKey(owner, config); err != nil { + return err + } + if allowAppBinding { + policy.AllowAppBinding = true + policy.Revision++ + if err = global.DB.Clauses(clause.OnConflict{UpdateAll: true}).Create(&policy).Error; err != nil { + return err + } + } + recordAPIKeyEvent(c, owner, "legacy", item.Name, action) + if action == "disable" || action == "revoke" || previousConfig.IpWhiteList != config.IpWhiteList || previousConfig.ApiTrustedProxies != config.ApiTrustedProxies || previousConfig.ApiKeyValidityTime != config.ApiKeyValidityTime { + s.revokeAPIKeyTerminals(c, owner, "legacy") + } + return nil +} + +func recordAPIKeyEvent(c *gin.Context, owner auth.APIKeyOwner, id, name, action string) { + if err := apikey_audit.Record(c, apikey_audit.Event{OwnerType: owner.Type, OwnerID: owner.ID, KeyID: id, KeyName: name, Action: action, Status: constant.StatusSuccess}); err != nil && global.LOG != nil { + global.LOG.Errorf("API key audit failed: %v", err) + } +} + +func (s *APIKeyService) AuditLegacyChange(c *gin.Context) { + owner, err := s.owner(c) + if err != nil { + if global.LOG != nil { + global.LOG.Errorf("legacy API key audit owner unavailable: %v", err) + } + return + } + recordAPIKeyEvent(c, owner, "legacy", "Legacy API Key", "update") +} + +func (s *APIKeyService) revokeAPIKeyTerminals(c *gin.Context, owner auth.APIKeyOwner, id string) { + sessionID := "api-key:" + id + if id == "legacy" { + sessionID = terminalsession.APIAuthSessionID(owner.ID) + } + if err := terminalsession.RevokeWithRetry("auth_session", owner.ID, sessionID, s.RevokeTerminals); err != nil { + c.Set("API_KEY_TERMINAL_CLOSE_PENDING", true) + } +} + +func (s *APIKeyService) PrepareAppBinding(c *gin.Context, id string) (*auth.APIKeyBinding, error) { + owner, err := s.owner(c) + if err != nil { + return nil, err + } + if id == "" { + id = "legacy" + } + if id == "legacy" { + item, config, _, fingerprint, err := s.legacy(owner) + if err != nil { + return nil, err + } + if config.ApiKey == "" || config.ApiInterfaceStatus != constant.StatusEnable { + return nil, buserr.New("ErrApiConfigStatusInvalid") + } + if !item.AllowAppBinding { + return nil, buserr.New("ErrAPIKeyAppBindingDisabled") + } + return &auth.APIKeyBinding{KeyID: id, KeyName: item.Name, Owner: owner, Revision: item.Revision, Fingerprint: fingerprint}, nil + } + key, err := repo.NewAPIKeyRepo().Get(id) + if err != nil || key.OwnerID != owner.ID || key.OwnerType != owner.Type { + return nil, buserr.New("ErrAPIKeyNotFound") + } + if err = auth.ValidateAPIKeyState(key, time.Now()); err != nil { + return nil, err + } + if !key.AllowAppBinding { + return nil, buserr.New("ErrAPIKeyAppBindingDisabled") + } + return &auth.APIKeyBinding{KeyID: key.ID, KeyName: key.Name, Owner: owner, Revision: key.Revision}, nil +} + +func (s *APIKeyService) ResolveAppBinding(c *gin.Context, binding auth.APIKeyBinding) (string, error) { + if s.Provider == nil { + return "", buserr.New("ErrApiConfigStatusInvalid") + } + owner, err := s.Provider.ResolveAPIKeyOwner(c, binding.Owner.Type, binding.Owner.ID) + if err != nil { + return "", buserr.New("ErrApiConfigStatusInvalid") + } + if binding.KeyID == "legacy" { + item, config, _, fingerprint, err := s.legacy(owner) + if err != nil { + return "", err + } + if binding.Fingerprint == "" || fingerprint != binding.Fingerprint || binding.Revision != item.Revision { + return "", buserr.New("ErrAPIKeyConflict") + } + if config.ApiKey == "" || config.ApiInterfaceStatus != constant.StatusEnable { + return "", buserr.New("ErrApiConfigStatusInvalid") + } + if !item.AllowAppBinding { + return "", buserr.New("ErrAPIKeyAppBindingDisabled") + } + if !auth.IsIPInWhiteList(auth.GetAPIClientIP(c, config.ApiTrustedProxies), config.IpWhiteList) { + return "", buserr.New("ErrApiConfigIPInvalid") + } + c.Set("API_AUTH_CLIENT_IP", auth.GetAPIClientIP(c, config.ApiTrustedProxies)) + return config.ApiKey, nil + } + key, err := repo.NewAPIKeyRepo().Get(binding.KeyID) + if err != nil || key.OwnerID != owner.ID || key.OwnerType != owner.Type { + return "", buserr.New("ErrAPIKeyNotFound") + } + if key.Revision != binding.Revision { + return "", buserr.New("ErrAPIKeyConflict") + } + if err = auth.ValidateAPIKeyState(key, time.Now()); err != nil { + return "", err + } + if !key.AllowAppBinding { + return "", buserr.New("ErrAPIKeyAppBindingDisabled") + } + if !auth.IsIPInWhiteList(auth.GetAPIClientIP(c, key.APITrustedProxies), key.IPWhiteList) { + return "", buserr.New("ErrApiConfigIPInvalid") + } + c.Set("API_AUTH_CLIENT_IP", auth.GetAPIClientIP(c, key.APITrustedProxies)) + return auth.APIKeySecret(key) +} diff --git a/core/app/service/auth.go b/core/app/service/auth.go index c284fc19d..0f649d6d2 100644 --- a/core/app/service/auth.go +++ b/core/app/service/auth.go @@ -69,7 +69,7 @@ func (u *AuthService) LogOut(c *gin.Context) error { } func CloseTerminalSessions(scope, userID, authSessionID string) { - if err := xpack.AuthProvider.RevokeTerminalSessions(scope, userID, authSessionID); err != nil { + if err := terminalsession.RevokeWithRetry(scope, userID, authSessionID, xpack.AuthProvider.RevokeTerminalSessions); err != nil { global.LOG.Warnf("revoke terminal sessions failed, scope=%s, err: %v", scope, err) } } diff --git a/core/app/service/setting.go b/core/app/service/setting.go index 9604675f7..b72af01c5 100644 --- a/core/app/service/setting.go +++ b/core/app/service/setting.go @@ -23,6 +23,7 @@ import ( "sync" "time" + "github.com/1Panel-dev/1Panel/core/app/auth" "github.com/1Panel-dev/1Panel/core/app/dto" "github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/repo" @@ -220,6 +221,9 @@ func sortShowMenus(menus []dto.ShowMenu) { } func (u *SettingService) Update(c *gin.Context, key, value string) error { + if auth.IsAPICredentialSetting(key) { + return buserr.New("ErrInvalidParams") + } oldVal, err := settingRepo.Get(repo.WithByKey(key)) if err != nil { return err diff --git a/core/i18n/lang/en.yaml b/core/i18n/lang/en.yaml index d1010fc80..e8caa548b 100644 --- a/core/i18n/lang/en.yaml +++ b/core/i18n/lang/en.yaml @@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: "Invalid API key: {{ .detail }}" ErrApiConfigIPInvalid: "The API request IP is not on the whitelist: {{ .detail }}" ErrApiConfigDisable: "This interface prohibits API calls: {{ .detail }}" ErrApiConfigKeyTimeInvalid: "Invalid API timestamp: {{ .detail }}" +ErrApiConfigKeyExpired: "The API Key has expired. Adjust its expiration date or replace the key in the panel’s API Key management." ErrPasskeyDisabled: "Passkey requires HTTPS to be enabled" ErrPasskeyNotConfigured: "No passkey configured" ErrPasskeyLimit: "Passkey limit reached (max 5)" @@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "The SAML2 logout request has expired." ErrSAML2LogoutInvalid: "The SAML2 logout message is invalid." ErrSAML2LocalCredentialsReadonly: "SAML2 usernames and passwords are managed by the identity provider and cannot be changed locally." ErrSAML2PasswordExpirationUnsupported: "SAML2 users do not use the local password expiration policy." + +# API key management +ErrAPIKeyLimit: "The limit of 20 unrevoked API keys per user has been reached" +ErrAPIKeyConflict: "The API key changed. Refresh and retry; repeated creation must use the original request parameters" +ErrAPIKeyNameExists: "An unrevoked API key with this name already exists" +ErrAPIKeyNotFound: "The API key does not exist or does not belong to the current user" +ErrAPIKeyAppBindingDisabled: "APP QR binding is not allowed for this API key" +ErrAPIKeyTerminalUpgradeRequired: "Unable to verify API key terminal authorization support. Check the node connection and upgrade its Agent before retrying." diff --git a/core/i18n/lang/es-ES.yaml b/core/i18n/lang/es-ES.yaml index 362582066..e9e3e4c7e 100644 --- a/core/i18n/lang/es-ES.yaml +++ b/core/i18n/lang/es-ES.yaml @@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'Clave API inválida: {{ .detail }}' ErrApiConfigIPInvalid: 'IP no permitida: {{ .detail }}' ErrApiConfigDisable: 'API no permitida: {{ .detail }}' ErrApiConfigKeyTimeInvalid: 'Timestamp inválido: {{ .detail }}' +ErrApiConfigKeyExpired: "La clave API ha caducado. Ajusta su fecha de caducidad o reemplázala en la gestión de claves API del panel." ErrPasskeyDisabled: "Passkey requiere que HTTPS esté habilitado" ErrPasskeyNotConfigured: "No hay Passkey configurado" ErrPasskeyLimit: "Límite de Passkey alcanzado (máximo 5)" @@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "La solicitud de cierre de sesión SAML2 ha caducado." ErrSAML2LogoutInvalid: "El mensaje de cierre de sesión SAML2 no es válido." ErrSAML2LocalCredentialsReadonly: "Los nombres de usuario y las contraseñas SAML2 los gestiona el proveedor de identidad y no pueden modificarse localmente." ErrSAML2PasswordExpirationUnsupported: "Los usuarios SAML2 no utilizan la política local de caducidad de contraseñas." + +# API key management +ErrAPIKeyLimit: "Se alcanzó el límite de 20 claves API no revocadas por usuario" +ErrAPIKeyConflict: "La clave API cambió. Actualice y reintente; al repetir la creación, use los parámetros originales" +ErrAPIKeyNameExists: "Ya existe una clave API no revocada con este nombre" +ErrAPIKeyNotFound: "La clave API no existe o no pertenece al usuario actual" +ErrAPIKeyAppBindingDisabled: "Esta clave API no permite vincular la APP mediante código QR" +ErrAPIKeyTerminalUpgradeRequired: "No se pudo verificar la autorización de terminal con clave API. Compruebe la conexión del nodo y actualice su Agent antes de reintentar." diff --git a/core/i18n/lang/fa.yaml b/core/i18n/lang/fa.yaml index 978f0d6c4..60c96c422 100644 --- a/core/i18n/lang/fa.yaml +++ b/core/i18n/lang/fa.yaml @@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: "کلید API نامعتبر است: {{ .detail }}" ErrApiConfigIPInvalid: "IP درخواست API در لیست سفید نیست: {{ .detail }}" ErrApiConfigDisable: "این رابط فراخوانی API را ممنوع می‌کند: {{ .detail }}" ErrApiConfigKeyTimeInvalid: "مهر زمانی API نامعتبر است: {{ .detail }}" +ErrApiConfigKeyExpired: "کلید API منقضی شده است. تاریخ انقضا را تغییر دهید یا کلید را در بخش مدیریت کلیدهای API پنل جایگزین کنید." ErrPasskeyDisabled: "کلید عبور نیاز به فعال بودن HTTPS دارد" ErrPasskeyNotConfigured: "هیچ کلید عبوری پیکربندی نشده است" ErrPasskeyLimit: "محدودیت کلید عبور رسیده است (حداکثر ۵)" @@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "درخواست خروج SAML2 منقضی شده است." ErrSAML2LogoutInvalid: "پیام خروج SAML2 نامعتبر است." ErrSAML2LocalCredentialsReadonly: "نام‌های کاربری و گذرواژه‌های SAML2 توسط ارائه‌دهنده هویت مدیریت می‌شوند و در محل قابل تغییر نیستند." ErrSAML2PasswordExpirationUnsupported: "کاربران SAML2 از سیاست محلی انقضای گذرواژه استفاده نمی‌کنند." + +# API key management +ErrAPIKeyLimit: "به سقف ۲۰ کلید API لغونشده برای هر کاربر رسیده‌اید" +ErrAPIKeyConflict: "کلید API تغییر کرده است. صفحه را تازه‌سازی و دوباره تلاش کنید؛ برای تکرار ایجاد، از پارامترهای اصلی استفاده کنید" +ErrAPIKeyNameExists: "یک کلید API لغونشده با این نام از قبل وجود دارد" +ErrAPIKeyNotFound: "کلید API وجود ندارد یا متعلق به کاربر فعلی نیست" +ErrAPIKeyAppBindingDisabled: "اتصال برنامه با کد QR برای این کلید API مجاز نیست" +ErrAPIKeyTerminalUpgradeRequired: "امکان بررسی پشتیبانی از مجوزدهی ترمینال با کلید API وجود ندارد. اتصال گره را بررسی و Agent آن را ارتقا دهید، سپس دوباره تلاش کنید." diff --git a/core/i18n/lang/ja.yaml b/core/i18n/lang/ja.yaml index b059f47e1..d1389a9e3 100644 --- a/core/i18n/lang/ja.yaml +++ b/core/i18n/lang/ja.yaml @@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'APIキーが無効です: {{ .detail }}' ErrApiConfigIPInvalid: 'IPが許可されていません: {{ .detail }}' ErrApiConfigDisable: 'APIは無効です: {{ .detail }}' ErrApiConfigKeyTimeInvalid: 'タイムスタンプが無効です: {{ .detail }}' +ErrApiConfigKeyExpired: "API Key の有効期限が切れています。パネルの API Key 管理で有効期限を変更するか、キーを交換してください。" ErrPasskeyDisabled: "Passkeyを使用するにはHTTPSを有効にする必要があります" ErrPasskeyNotConfigured: "Passkeyが設定されていません" ErrPasskeyLimit: "Passkeyの上限に達しました(最大5個)" @@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "SAML2 ログアウト要求の有効期限が切れま ErrSAML2LogoutInvalid: "SAML2 ログアウトメッセージが無効です。" ErrSAML2LocalCredentialsReadonly: "SAML2 のユーザー名とパスワードは IdP によって管理されるため、ローカルでは変更できません。" ErrSAML2PasswordExpirationUnsupported: "SAML2 ユーザーにはローカルのパスワード有効期限ポリシーが適用されません。" + +# API key management +ErrAPIKeyLimit: "ユーザーあたりの未失効 API キー数の上限(20 個)に達しました" +ErrAPIKeyConflict: "API キーが変更されました。更新して再試行してください。作成の再送には元のパラメーターを使用してください" +ErrAPIKeyNameExists: "同じ名前の未失効 API キーが既に存在します" +ErrAPIKeyNotFound: "API キーが存在しないか、現在のユーザーに属していません" +ErrAPIKeyAppBindingDisabled: "この API キーでは APP の QR コード連携が許可されていません" +ErrAPIKeyTerminalUpgradeRequired: "API キーによるターミナル認証への対応を確認できません。ノードの接続を確認し、Agent を更新して再試行してください。" diff --git a/core/i18n/lang/ko.yaml b/core/i18n/lang/ko.yaml index ac73a9811..4d9f0344e 100644 --- a/core/i18n/lang/ko.yaml +++ b/core/i18n/lang/ko.yaml @@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'API 키가 잘못됨: {{ .detail }}' ErrApiConfigIPInvalid: 'IP 허용되지 않음: {{ .detail }}' ErrApiConfigDisable: 'API 접근 금지: {{ .detail }}' ErrApiConfigKeyTimeInvalid: '타임스탬프 오류: {{ .detail }}' +ErrApiConfigKeyExpired: "API Key가 만료되었습니다. 패널의 API Key 관리에서 만료일을 조정하거나 키를 교체하세요." ErrPasskeyDisabled: "Passkey를 사용하려면 HTTPS를 활성화해야 합니다" ErrPasskeyNotConfigured: "Passkey가 구성되지 않았습니다" ErrPasskeyLimit: "Passkey 개수가 한도에 도달했습니다(최대 5개)" @@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "SAML2 로그아웃 요청이 만료되었습니다." ErrSAML2LogoutInvalid: "SAML2 로그아웃 메시지가 올바르지 않습니다." ErrSAML2LocalCredentialsReadonly: "SAML2 사용자 이름과 비밀번호는 ID 공급자가 관리하므로 로컬에서 변경할 수 없습니다." ErrSAML2PasswordExpirationUnsupported: "SAML2 사용자는 로컬 비밀번호 만료 정책을 사용하지 않습니다." + +# API key management +ErrAPIKeyLimit: "사용자당 폐기되지 않은 API Key 최대 개수(20개)에 도달했습니다" +ErrAPIKeyConflict: "API Key가 변경되었습니다. 새로 고친 후 다시 시도하세요. 생성 요청을 재전송할 때는 원래 매개변수를 사용하세요" +ErrAPIKeyNameExists: "같은 이름의 폐기되지 않은 API Key가 이미 있습니다" +ErrAPIKeyNotFound: "API Key가 없거나 현재 사용자 소유가 아닙니다" +ErrAPIKeyAppBindingDisabled: "이 API Key는 APP QR 코드 연결을 허용하지 않습니다" +ErrAPIKeyTerminalUpgradeRequired: "API Key 터미널 인증 지원 여부를 확인할 수 없습니다. 노드 연결을 확인하고 Agent를 업그레이드한 후 다시 시도하세요." diff --git a/core/i18n/lang/lo.yaml b/core/i18n/lang/lo.yaml index b6120b12a..8cb35c82a 100644 --- a/core/i18n/lang/lo.yaml +++ b/core/i18n/lang/lo.yaml @@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: "ຄີ API ບໍ່ຖືກຕ້ອງ: {{ .detail } ErrApiConfigIPInvalid: "IP ຂອງຄຳຮ້ອງຂໍ API ບໍ່ຢູ່ໃນລາຍຊື່ອະນຸຍາດ: {{ .detail }}" ErrApiConfigDisable: "ອິນເຕີເຟດນີ້ຫ້າມການເອີ້ນໃຊ້ API: {{ .detail }}" ErrApiConfigKeyTimeInvalid: "ເວລາປະທັບ API ບໍ່ຖືກຕ້ອງ: {{ .detail }}" +ErrApiConfigKeyExpired: "API Key ໝົດອາຍຸແລ້ວ. ກະລຸນາປັບວັນໝົດອາຍຸ ຫຼື ປ່ຽນກະແຈໃນສ່ວນຈັດການ API Key ຂອງແຜງ." ErrPasskeyDisabled: "Passkey ຕ້ອງເປີດໃຊ້ງານ HTTPS" ErrPasskeyNotConfigured: "ຍັງບໍ່ໄດ້ຕັ້ງຄ່າ Passkey" ErrPasskeyLimit: "ຈຳນວນ Passkey ຮອດຂີດຈຳກັດແລ້ວ (ສູງສຸດ 5)" @@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "ຄຳຮ້ອງຂໍອອກລະບົບ SAML2 ErrSAML2LogoutInvalid: "ຂໍ້ຄວາມອອກລະບົບ SAML2 ບໍ່ຖືກຕ້ອງ." ErrSAML2LocalCredentialsReadonly: "ຊື່ຜູ້ໃຊ້ ແລະລະຫັດຜ່ານ SAML2 ຖືກຈັດການໂດຍຜູ້ໃຫ້ບໍລິການຕົວຕົນ ແລະບໍ່ສາມາດປ່ຽນໃນເຄື່ອງໄດ້." ErrSAML2PasswordExpirationUnsupported: "ຜູ້ໃຊ້ SAML2 ບໍ່ໄດ້ໃຊ້ນະໂຍບາຍການໝົດອາຍຸລະຫັດຜ່ານທ້ອງຖິ່ນ." + +# API key management +ErrAPIKeyLimit: "ຮອດຂີດຈຳກັດ 20 API Key ທີ່ຍັງບໍ່ຖືກຖອນຕໍ່ຜູ້ໃຊ້ແລ້ວ" +ErrAPIKeyConflict: "API Key ມີການປ່ຽນແປງ. ໂຫຼດໃໝ່ແລ້ວລອງອີກ; ໃຊ້ພາລາມິເຕີເດີມເມື່ອສົ່ງຄຳຂໍສ້າງຊ້ຳ" +ErrAPIKeyNameExists: "ມີ API Key ທີ່ຍັງບໍ່ຖືກຖອນທີ່ໃຊ້ຊື່ນີ້ແລ້ວ" +ErrAPIKeyNotFound: "ບໍ່ພົບ API Key ຫຼືບໍ່ແມ່ນຂອງຜູ້ໃຊ້ປັດຈຸບັນ" +ErrAPIKeyAppBindingDisabled: "API Key ນີ້ບໍ່ອະນຸຍາດໃຫ້ຜູກ APP ຜ່ານລະຫັດ QR" +ErrAPIKeyTerminalUpgradeRequired: "ບໍ່ສາມາດຢືນຢັນການຮອງຮັບການອະນຸຍາດເທີມິນອນດ້ວຍ API Key. ກວດສອບການເຊື່ອມຕໍ່ໂນດ ແລະອັບເກຣດ Agent ກ່ອນລອງອີກ." diff --git a/core/i18n/lang/ms.yaml b/core/i18n/lang/ms.yaml index 21f92c7f7..b6195ca7a 100644 --- a/core/i18n/lang/ms.yaml +++ b/core/i18n/lang/ms.yaml @@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'Kunci API tidak sah: {{ .detail }}' ErrApiConfigIPInvalid: 'IP tidak dibenarkan: {{ .detail }}' ErrApiConfigDisable: 'API tidak dibenarkan: {{ .detail }}' ErrApiConfigKeyTimeInvalid: 'Cap masa tidak sah: {{ .detail }}' +ErrApiConfigKeyExpired: "Kunci API telah tamat tempoh. Laraskan tarikh luput atau gantikan kunci dalam pengurusan kunci API pada panel." ErrPasskeyDisabled: "Passkey memerlukan HTTPS diaktifkan" ErrPasskeyNotConfigured: "Tiada Passkey dikonfigurasikan" ErrPasskeyLimit: "Had Passkey telah dicapai (maksimum 5)" @@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "Permintaan log keluar SAML2 telah tamat tempoh." ErrSAML2LogoutInvalid: "Mesej log keluar SAML2 tidak sah." ErrSAML2LocalCredentialsReadonly: "Nama pengguna dan kata laluan SAML2 diurus oleh penyedia identiti dan tidak boleh diubah secara setempat." ErrSAML2PasswordExpirationUnsupported: "Pengguna SAML2 tidak menggunakan dasar tamat tempoh kata laluan setempat." + +# API key management +ErrAPIKeyLimit: "Had 20 kunci API yang belum dibatalkan bagi setiap pengguna telah dicapai" +ErrAPIKeyConflict: "Kunci API telah berubah. Muat semula dan cuba lagi; gunakan parameter asal untuk mengulangi penciptaan" +ErrAPIKeyNameExists: "Kunci API yang belum dibatalkan dengan nama ini sudah wujud" +ErrAPIKeyNotFound: "Kunci API tidak wujud atau bukan milik pengguna semasa" +ErrAPIKeyAppBindingDisabled: "Pemautan APP melalui kod QR tidak dibenarkan untuk kunci API ini" +ErrAPIKeyTerminalUpgradeRequired: "Sokongan pengesahan terminal melalui kunci API tidak dapat disahkan. Semak sambungan nod dan naik taraf Agent sebelum mencuba lagi." diff --git a/core/i18n/lang/pt-BR.yaml b/core/i18n/lang/pt-BR.yaml index 0c727822a..c9f76458f 100644 --- a/core/i18n/lang/pt-BR.yaml +++ b/core/i18n/lang/pt-BR.yaml @@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'Chave API inválida: {{ .detail }}' ErrApiConfigIPInvalid: 'IP sem permissão: {{ .detail }}' ErrApiConfigDisable: 'API bloqueada: {{ .detail }}' ErrApiConfigKeyTimeInvalid: 'Timestamp inválido: {{ .detail }}' +ErrApiConfigKeyExpired: "A chave API expirou. Ajuste sua data de expiração ou substitua a chave no gerenciamento de chaves API do painel." ErrPasskeyDisabled: "Passkey requer HTTPS habilitado" ErrPasskeyNotConfigured: "Nenhum Passkey configurado" ErrPasskeyLimit: "Limite de Passkeys atingido (máximo 5)" @@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "A solicitação de logout do SAML2 expirou." ErrSAML2LogoutInvalid: "A mensagem de logout do SAML2 é inválida." ErrSAML2LocalCredentialsReadonly: "Os nomes de usuário e as senhas SAML2 são gerenciados pelo provedor de identidade e não podem ser alterados localmente." ErrSAML2PasswordExpirationUnsupported: "Os usuários SAML2 não usam a política local de expiração de senha." + +# API key management +ErrAPIKeyLimit: "O limite de 20 chaves API não revogadas por usuário foi atingido" +ErrAPIKeyConflict: "A chave API foi alterada. Atualize e tente novamente; ao repetir a criação, use os parâmetros originais" +ErrAPIKeyNameExists: "Já existe uma chave API não revogada com esse nome" +ErrAPIKeyNotFound: "A chave API não existe ou não pertence ao usuário atual" +ErrAPIKeyAppBindingDisabled: "Esta chave API não permite vincular o APP por código QR" +ErrAPIKeyTerminalUpgradeRequired: "Não foi possível verificar o suporte à autorização de terminal por chave API. Verifique a conexão do nó e atualize o Agent antes de tentar novamente." diff --git a/core/i18n/lang/ru.yaml b/core/i18n/lang/ru.yaml index 50fe894a2..49e0295ee 100644 --- a/core/i18n/lang/ru.yaml +++ b/core/i18n/lang/ru.yaml @@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'Неверный API-ключ: {{ .detail }}' ErrApiConfigIPInvalid: 'IP не в списке: {{ .detail }}' ErrApiConfigDisable: 'Доступ по API запрещён: {{ .detail }}' ErrApiConfigKeyTimeInvalid: 'Неверная метка времени: {{ .detail }}' +ErrApiConfigKeyExpired: "Срок действия API-ключа истёк. Измените срок действия или замените ключ в разделе управления API-ключами панели." ErrPasskeyDisabled: "Для Passkey требуется включить HTTPS" ErrPasskeyNotConfigured: "Passkey не настроен" ErrPasskeyLimit: "Достигнут лимит Passkey (максимум 5)" @@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "Срок действия запроса на выход ErrSAML2LogoutInvalid: "Сообщение выхода SAML2 недействительно." ErrSAML2LocalCredentialsReadonly: "Имена пользователей и пароли SAML2 управляются поставщиком удостоверений и не могут быть изменены локально." ErrSAML2PasswordExpirationUnsupported: "Для пользователей SAML2 не применяется локальная политика срока действия паролей." + +# API key management +ErrAPIKeyLimit: "Достигнут лимит в 20 неотозванных ключей API на пользователя" +ErrAPIKeyConflict: "Ключ API изменён. Обновите данные и повторите попытку; при повторном создании используйте исходные параметры" +ErrAPIKeyNameExists: "Неотозванный ключ API с таким именем уже существует" +ErrAPIKeyNotFound: "Ключ API не существует или не принадлежит текущему пользователю" +ErrAPIKeyAppBindingDisabled: "Для этого ключа API привязка приложения через QR-код запрещена" +ErrAPIKeyTerminalUpgradeRequired: "Не удалось проверить поддержку авторизации терминала по ключу API. Проверьте подключение узла, обновите его Agent и повторите попытку." diff --git a/core/i18n/lang/tr.yaml b/core/i18n/lang/tr.yaml index 0f4ea12e6..c3669e253 100644 --- a/core/i18n/lang/tr.yaml +++ b/core/i18n/lang/tr.yaml @@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'Geçersiz API anahtarı: {{ .detail }}' ErrApiConfigIPInvalid: 'IP izinli değil: {{ .detail }}' ErrApiConfigDisable: 'API erişimi kapalı: {{ .detail }}' ErrApiConfigKeyTimeInvalid: 'Zaman damgası hatalı: {{ .detail }}' +ErrApiConfigKeyExpired: "API anahtarının süresi doldu. Panelin API anahtarı yönetiminde sona erme tarihini değiştirin veya anahtarı yenisiyle değiştirin." ErrPasskeyDisabled: "Passkey kullanmak için HTTPS etkinleştirilmelidir" ErrPasskeyNotConfigured: "Passkey yapılandırılmamış" ErrPasskeyLimit: "Passkey sınırına ulaşıldı (en fazla 5)" @@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "SAML2 oturum kapatma isteğinin süresi doldu." ErrSAML2LogoutInvalid: "SAML2 oturum kapatma mesajı geçersiz." ErrSAML2LocalCredentialsReadonly: "SAML2 kullanıcı adları ve parolaları kimlik sağlayıcı tarafından yönetilir ve yerel olarak değiştirilemez." ErrSAML2PasswordExpirationUnsupported: "SAML2 kullanıcıları yerel parola süresi dolma ilkesini kullanmaz." + +# API key management +ErrAPIKeyLimit: "Kullanıcı başına iptal edilmemiş 20 API anahtarı sınırına ulaşıldı" +ErrAPIKeyConflict: "API anahtarı değişti. Yenileyip tekrar deneyin; oluşturma isteğini tekrarlarken ilk parametreleri kullanın" +ErrAPIKeyNameExists: "Bu adda iptal edilmemiş bir API anahtarı zaten var" +ErrAPIKeyNotFound: "API anahtarı mevcut değil veya geçerli kullanıcıya ait değil" +ErrAPIKeyAppBindingDisabled: "Bu API anahtarı için QR koduyla APP bağlantısına izin verilmiyor" +ErrAPIKeyTerminalUpgradeRequired: "API anahtarıyla terminal yetkilendirme desteği doğrulanamadı. Düğüm bağlantısını kontrol edip Agent sürümünü yükselttikten sonra tekrar deneyin." diff --git a/core/i18n/lang/zh-Hant.yaml b/core/i18n/lang/zh-Hant.yaml index 52397dcf9..da947aac2 100644 --- a/core/i18n/lang/zh-Hant.yaml +++ b/core/i18n/lang/zh-Hant.yaml @@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: "API 金鑰錯誤: {{ .detail }}" ErrApiConfigIPInvalid: "API 請求 IP 不在白名單: {{ .detail }}" ErrApiConfigDisable: "此介面禁止使用 API 呼叫: {{ .detail }}" ErrApiConfigKeyTimeInvalid: "API 時間戳錯誤: {{ .detail }}" +ErrApiConfigKeyExpired: "API Key 已到期,請在面板的 API Key 管理中調整有效期限或更換金鑰。" ErrPasskeyDisabled: "需啟用 HTTPS 才能使用 Passkey" ErrPasskeyNotConfigured: "尚未設定 Passkey" ErrPasskeyLimit: "Passkey 數量已達上限(最多 5 個)" @@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "SAML2 登出要求已過期" ErrSAML2LogoutInvalid: "SAML2 登出訊息無效" ErrSAML2LocalCredentialsReadonly: "SAML2 使用者名稱和密碼由身分提供者管理,無法在本機修改。" ErrSAML2PasswordExpirationUnsupported: "SAML2 使用者不使用本機密碼到期原則。" + +# API key management +ErrAPIKeyLimit: "已達每個使用者 20 把未撤銷 API Key 的上限" +ErrAPIKeyConflict: "API Key 已變更,請重新整理後重試;重複建立請使用原請求參數" +ErrAPIKeyNameExists: "已存在同名且未撤銷的 API Key" +ErrAPIKeyNotFound: "API Key 不存在或不屬於目前使用者" +ErrAPIKeyAppBindingDisabled: "此 API Key 未允許 APP 掃碼綁定" +ErrAPIKeyTerminalUpgradeRequired: "無法確認節點支援 API Key 終端授權,請檢查節點連線並升級 Agent 後重試。" diff --git a/core/i18n/lang/zh.yaml b/core/i18n/lang/zh.yaml index c5416c4b6..6df0301c3 100644 --- a/core/i18n/lang/zh.yaml +++ b/core/i18n/lang/zh.yaml @@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: "API 接口密钥错误: {{ .detail }}" ErrApiConfigIPInvalid: "调用 API 接口 IP 不在白名单: {{ .detail }}" ErrApiConfigDisable: "此接口禁止使用 API 接口调用: {{ .detail }}" ErrApiConfigKeyTimeInvalid: "API 接口时间戳错误: {{ .detail }}" +ErrApiConfigKeyExpired: "API Key 已到期,请在面板的 API Key 管理中调整有效期或更换密钥。" ErrPasskeyDisabled: "需开启 HTTPS 才能使用 Passkey" ErrPasskeyNotConfigured: "尚未配置 Passkey" ErrPasskeyLimit: "Passkey 数量已达上限(最多 5 个)" @@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "SAML2 注销请求已过期" ErrSAML2LogoutInvalid: "SAML2 注销消息无效" ErrSAML2LocalCredentialsReadonly: "SAML2 用户名和密码由身份提供商管理,无法在本地修改。" ErrSAML2PasswordExpirationUnsupported: "SAML2 用户不使用本地密码过期策略。" + +# API key management +ErrAPIKeyLimit: "已达到每个用户 20 把未撤销 API Key 的上限" +ErrAPIKeyConflict: "API Key 已发生变化,请刷新后重试;重复创建请使用原请求参数" +ErrAPIKeyNameExists: "已存在同名的未撤销 API Key" +ErrAPIKeyNotFound: "API Key 不存在或不属于当前用户" +ErrAPIKeyAppBindingDisabled: "此 API Key 未允许 APP 扫码绑定" +ErrAPIKeyTerminalUpgradeRequired: "无法确认节点支持 API Key 终端授权,请检查节点连接并升级 Agent 后重试。" diff --git a/core/init/migration/migrate.go b/core/init/migration/migrate.go index a34a33dba..6b3ab9186 100644 --- a/core/init/migration/migrate.go +++ b/core/init/migration/migrate.go @@ -67,5 +67,7 @@ func coreMigrations() []*gormigrate.Migration { migrations.UpdateFirewallMenuPath, migrations.RemoveUpageHideMenu, migrations.MoveVirtualMachineMenuToXpack, + migrations.AddAPIKeys, + migrations.AddOperationLogAPIKey, } } diff --git a/core/init/migration/migrations/api_key.go b/core/init/migration/migrations/api_key.go new file mode 100644 index 000000000..911987c39 --- /dev/null +++ b/core/init/migration/migrations/api_key.go @@ -0,0 +1,17 @@ +package migrations + +import ( + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var AddAPIKeys = &gormigrate.Migration{ + ID: "20260915-api-keys", + Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.APIKey{}, &model.LegacyAPIKeyPolicy{}) }, +} + +var AddOperationLogAPIKey = &gormigrate.Migration{ + ID: "20260915-add-operation-log-api-key", + Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) }, +} diff --git a/core/init/router/proxy.go b/core/init/router/proxy.go index a7dc895b2..ca7e9893c 100644 --- a/core/init/router/proxy.go +++ b/core/init/router/proxy.go @@ -1,12 +1,14 @@ package router import ( + "context" "errors" "net/http" "net/url" "path" "strconv" "strings" + "time" "github.com/1Panel-dev/1Panel/core/app/api/v2/helper" baseRepo "github.com/1Panel-dev/1Panel/core/app/repo" @@ -16,6 +18,7 @@ import ( "github.com/1Panel-dev/1Panel/core/init/proxy" psessionUtils "github.com/1Panel-dev/1Panel/core/init/session/psession" "github.com/1Panel-dev/1Panel/core/middleware" + "github.com/1Panel-dev/1Panel/core/utils/req_helper/proxy_local" terminalsession "github.com/1Panel-dev/1Panel/core/utils/terminal_session" "github.com/1Panel-dev/1Panel/core/utils/xpack" "github.com/gin-gonic/gin" @@ -23,6 +26,10 @@ import ( var errInternalOnlyAgentEndpoint = errors.New("internal agent endpoint cannot be proxied") +var loadLocalTerminalCapabilities = func(ctx context.Context) (interface{}, error) { + return proxy_local.NewLocalClientWithContext(ctx, terminalsession.CapabilityPath, http.MethodGet, nil, nil, 5*time.Second) +} + func Proxy() gin.HandlerFunc { return func(c *gin.Context) { terminalsession.ClearForwardedHeaders(c) @@ -94,10 +101,17 @@ func isInternalOnlyAgentEndpoint(reqPath string) bool { return normalizedPath == "/api/v2/xpack/alert/offline/email" || normalizedPath == "/api/v2/xpack/alert/offline/webhook" || normalizedPath == "/api/v2/hosts/firewall/port" || - normalizedPath == "/api/v2/internal/terminal/sessions/revoke" + normalizedPath == "/api/v2/internal/terminal/sessions/revoke" || + normalizedPath == terminalsession.CapabilityPath } func proxyLocalAgent(c *gin.Context) { + if terminalsession.RequiresLeaseCapability(c) { + if err := terminalsession.CheckLeaseCapability(func() (interface{}, error) { return loadLocalTerminalCapabilities(c.Request.Context()) }); err != nil { + helper.ErrorWithDetail(c, http.StatusBadRequest, "ErrAPIKeyTerminalUpgradeRequired", nil) + return + } + } defer func() { if err := recover(); err != nil && err != http.ErrAbortHandler { global.LOG.Debug(err) diff --git a/core/middleware/operation.go b/core/middleware/operation.go index 8ad745969..5e35f203b 100644 --- a/core/middleware/operation.go +++ b/core/middleware/operation.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "github.com/1Panel-dev/1Panel/core/app/auth" "github.com/1Panel-dev/1Panel/core/app/model" "github.com/1Panel-dev/1Panel/core/app/repo" "github.com/1Panel-dev/1Panel/core/cmd/server/docs" @@ -92,6 +93,7 @@ func OperationLog() gin.HandlerFunc { formatMap[key] = bodyMap[key] } } + redactCredentialSetting(record.Path, bodyMap, formatMap) } needAgentResolve := len(operationDic.BeforeFunctions) != 0 && len(currentNode) != 0 && currentNode != "local" && !strings.HasPrefix(record.Path, "/core") allowCoreFallback := strings.HasPrefix(record.Path, "/core/xpack") || !ShouldProxyToAgent(c.Request.URL.Path) || len(currentNode) == 0 || currentNode == "local" @@ -112,7 +114,24 @@ func OperationLog() gin.HandlerFunc { c.Next() + if c.GetBool("API_KEY_AUDIT_RECORDED") { + return + } + record.User = LoadOperationUser(c) + if c.GetBool("API_AUTH") { + if clientIP := c.GetString("API_AUTH_CLIENT_IP"); clientIP != "" { + record.IP = clientIP + } + record.AuthMethod = "api_key" + record.APIKeyID = c.GetString("API_AUTH_KEY_ID") + record.APIKeyName = c.GetString("API_AUTH_KEY_NAME") + if c.GetString("API_AUTH_KEY_KIND") == "legacy" { + record.APIKeyID = "legacy:" + c.GetString("API_AUTH_OWNER_TYPE") + ":" + c.GetString("API_AUTH_OWNER_ID") + } + } else if record.User != "" { + record.AuthMethod = "session" + } if len(operationDic.BeforeFunctions) != 0 { if needAgentResolve { @@ -180,6 +199,13 @@ func OperationLog() gin.HandlerFunc { } } +func redactCredentialSetting(operationPath string, body, values map[string]interface{}) { + key, _ := body["key"].(string) + if operationPath == "/core/settings/update" && auth.IsAPICredentialSetting(key) { + values["value"] = "[REDACTED]" + } +} + func LoadOperationUser(c *gin.Context) string { sessionUser, ok := c.Get(psessionUtils.GinContextSessionUserKey) if ok { diff --git a/core/router/ro_base.go b/core/router/ro_base.go index 4abfe020e..0b5942512 100644 --- a/core/router/ro_base.go +++ b/core/router/ro_base.go @@ -35,6 +35,11 @@ func (s *BaseRouter) InitRouter(Router *gin.RouterGroup) { authRouter.POST("/api/generate", baseApi.GenerateApiKey) authRouter.POST("/api/update", baseApi.UpdateApiConfig) + authRouter.POST("/api/keys/search", baseApi.SearchAPIKeys) + authRouter.POST("/api/keys/create", baseApi.CreateAPIKey) + authRouter.POST("/api/keys/update", baseApi.UpdateAPIKey) + authRouter.POST("/api/keys/status", baseApi.SetAPIKeyStatus) + authRouter.POST("/api/keys/revoke", baseApi.RevokeAPIKey) authRouter.GET("/current", baseApi.GetCurrentUser) authRouter.POST("/current/update", baseApi.UpdateCurrentUser) diff --git a/core/utils/apikey_audit/audit.go b/core/utils/apikey_audit/audit.go new file mode 100644 index 000000000..655a6c2bf --- /dev/null +++ b/core/utils/apikey_audit/audit.go @@ -0,0 +1,88 @@ +package apikey_audit + +import ( + "errors" + "fmt" + "strings" + + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/app/repo" + "github.com/1Panel-dev/1Panel/core/constant" + "github.com/1Panel-dev/1Panel/core/init/session/psession" + "github.com/gin-gonic/gin" +) + +type Event struct { + KeyID string + KeyName string + Action string + User string + Status string + Message string + ClientIP string + OwnerType string + OwnerID string +} + +func Record(c *gin.Context, event Event) error { + record, err := Build(c, event) + if err != nil { + return err + } + if err := repo.NewILogRepo().CreateOperationLog(record); err != nil { + return err + } + if c != nil { + c.Set("API_KEY_AUDIT_RECORDED", true) + } + return nil +} + +func Build(c *gin.Context, event Event) (*model.OperationLog, error) { + labels := map[string][2]string{ + "create": {"创建 API Key", "Create API key"}, + "update": {"修改 API Key", "Update API key"}, + "enable": {"启用 API Key", "Enable API key"}, + "disable": {"停用 API Key", "Disable API key"}, + "revoke": {"撤销 API Key", "Revoke API key"}, + "app_binding": {"向 APP 交付 API Key", "Deliver API key to APP"}, + } + label, ok := labels[event.Action] + if !ok || event.KeyID == "" || (event.Status != constant.StatusSuccess && event.Status != constant.StatusFailed) { + return nil, errors.New("invalid API key audit event") + } + if event.KeyID == "legacy" { + if event.OwnerType == "" || event.OwnerID == "" { + return nil, errors.New("legacy audit event requires credential owner") + } + event.KeyID = "legacy:" + event.OwnerType + ":" + event.OwnerID + } + record := &model.OperationLog{ + Source: "auth", User: event.User, + AuthMethod: "session", Status: event.Status, Message: event.Message, + DetailZH: fmt.Sprintf("%s [%s] (%s)", label[0], event.KeyName, event.KeyID), + DetailEN: fmt.Sprintf("%s [%s] (%s)", label[1], event.KeyName, event.KeyID), + } + if event.Action == "app_binding" { + record.AuthMethod = "qr_exchange" + } + if c != nil { + if record.User == "" { + if value, exists := c.Get(psession.GinContextSessionUserKey); exists { + if user, ok := value.(psession.SessionUser); ok { + record.User = user.Name + } + } + } + if c.Request != nil { + record.Path = c.Request.URL.Path + record.Method = strings.ToLower(c.Request.Method) + record.UserAgent = c.Request.UserAgent() + record.IP = c.ClientIP() + } + } + if event.ClientIP != "" { + record.IP = event.ClientIP + } + return record, nil +} diff --git a/core/utils/apikey_migration/owner.go b/core/utils/apikey_migration/owner.go new file mode 100644 index 000000000..4aa4cc6f6 --- /dev/null +++ b/core/utils/apikey_migration/owner.go @@ -0,0 +1,65 @@ +package apikey_migration + +import ( + "errors" + "strings" + "time" + + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/init/session/psession" + "gorm.io/gorm" +) + +const ( + panelAdmin = "panel_admin" + enterpriseUser = "enterprise_user" +) + +func ToEnterprise(tx *gorm.DB, superAdminID string) error { + if strings.TrimSpace(superAdminID) == "" { + return errors.New("missing enterprise super administrator identity") + } + return moveOwner(tx, panelAdmin, psession.SuperAdminSessionUserID, enterpriseUser, superAdminID) +} + +func ToCommunity(tx *gorm.DB, superAdminID string) error { + if tx == nil { + return errors.New("missing Core database") + } + if strings.TrimSpace(superAdminID) == "" { + return errors.New("missing enterprise super administrator identity") + } + if tx.Migrator().HasTable(&model.APIKey{}) { + now := time.Now() + if err := tx.Model(&model.APIKey{}). + Where("owner_type = ? AND owner_id <> ? AND status <> ?", enterpriseUser, superAdminID, "Revoked"). + Updates(map[string]interface{}{"status": "Revoked", "secret_ciphertext": "", "active_name": nil, "allow_app_binding": false, "revoked_at": now, "revision": gorm.Expr("revision + 1")}).Error; err != nil { + return err + } + } + if tx.Migrator().HasTable(&model.LegacyAPIKeyPolicy{}) { + if err := tx.Where("owner_type = ? AND owner_id <> ?", enterpriseUser, superAdminID).Delete(&model.LegacyAPIKeyPolicy{}).Error; err != nil { + return err + } + } + return moveOwner(tx, enterpriseUser, superAdminID, panelAdmin, psession.SuperAdminSessionUserID) +} + +func moveOwner(tx *gorm.DB, fromType, fromID, toType, toID string) error { + if tx == nil { + return errors.New("missing Core database") + } + if tx.Migrator().HasTable(&model.APIKey{}) { + if err := tx.Model(&model.APIKey{}).Where("owner_type = ? AND owner_id = ?", fromType, fromID). + Updates(map[string]interface{}{"owner_type": toType, "owner_id": toID, "revision": gorm.Expr("revision + 1")}).Error; err != nil { + return err + } + } + if tx.Migrator().HasTable(&model.LegacyAPIKeyPolicy{}) { + if err := tx.Model(&model.LegacyAPIKeyPolicy{}).Where("owner_type = ? AND owner_id = ?", fromType, fromID). + Updates(map[string]interface{}{"owner_type": toType, "owner_id": toID, "revision": gorm.Expr("revision + 1")}).Error; err != nil { + return err + } + } + return nil +} diff --git a/core/utils/encrypt/api_key.go b/core/utils/encrypt/api_key.go new file mode 100644 index 000000000..3c262e2e9 --- /dev/null +++ b/core/utils/encrypt/api_key.go @@ -0,0 +1,74 @@ +package encrypt + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/global" +) + +func APIKeyEncryptionKey() (string, error) { + if global.CONF.Base.EncryptKey != "" { + return global.CONF.Base.EncryptKey, nil + } + var value model.Setting + if global.DB == nil { + return "", errors.New("API key encryption key unavailable") + } + if err := global.DB.Where("key = ?", "EncryptKey").First(&value).Error; err != nil { + return "", err + } + if value.Value == "" { + return "", errors.New("API key encryption key unavailable") + } + return value.Value, nil +} + +func apiKeyAEAD(key string) (cipher.AEAD, error) { + if key == "" { + return nil, errors.New("API key encryption key unavailable") + } + derived := sha256.Sum256([]byte("1panel:api-key:v1:" + key)) + block, err := aes.NewCipher(derived[:]) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +func EncryptAPIKey(secret, id, key string) (string, error) { + aead, err := apiKeyAEAD(key) + if err != nil { + return "", err + } + if secret == "" || id == "" { + return "", errors.New("empty API key") + } + nonce := make([]byte, aead.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + return base64.RawStdEncoding.EncodeToString(aead.Seal(nonce, nonce, []byte(secret), []byte("api-key:v1:"+id))), nil +} + +func DecryptAPIKey(value, id, key string) (string, error) { + aead, err := apiKeyAEAD(key) + if err != nil { + return "", err + } + data, err := base64.RawStdEncoding.DecodeString(value) + if err != nil || len(data) < aead.NonceSize()+aead.Overhead() { + return "", errors.New("invalid API key ciphertext") + } + plain, err := aead.Open(nil, data[:aead.NonceSize()], data[aead.NonceSize():], []byte("api-key:v1:"+id)) + if err != nil { + return "", errors.New("invalid API key ciphertext") + } + return string(plain), nil +} diff --git a/core/utils/terminal_session/capabilities.go b/core/utils/terminal_session/capabilities.go new file mode 100644 index 000000000..0beb6df7e --- /dev/null +++ b/core/utils/terminal_session/capabilities.go @@ -0,0 +1,43 @@ +package terminal_session + +import ( + "encoding/json" + "errors" + + "github.com/gin-gonic/gin" +) + +const CapabilityPath = "/api/v2/internal/terminal/capabilities" + +func RequiresLeaseCapability(c *gin.Context) bool { + if c == nil || c.Request == nil || !c.GetBool("API_AUTH") || c.GetString("API_AUTH_KEY_KIND") != "apiKey" { + return false + } + switch c.Request.URL.Path { + case "/api/v2/hosts/terminal/local", "/api/v2/hosts/terminal/ssh", "/api/v2/hosts/terminal/container": + return true + default: + return false + } +} + +func CheckLeaseCapability(fetch func() (interface{}, error)) error { + data, err := fetch() + if err != nil { + return err + } + encoded, err := json.Marshal(data) + if err != nil { + return err + } + var capabilities struct { + APIKeyLeaseVersion int `json:"apiKeyLeaseVersion"` + } + if err := json.Unmarshal(encoded, &capabilities); err != nil { + return err + } + if capabilities.APIKeyLeaseVersion < 1 { + return errors.New("Agent does not support API key terminal authorization leases") + } + return nil +} diff --git a/core/utils/terminal_session/identity.go b/core/utils/terminal_session/identity.go index cc9eda234..facaee495 100644 --- a/core/utils/terminal_session/identity.go +++ b/core/utils/terminal_session/identity.go @@ -3,6 +3,8 @@ package terminal_session import ( "crypto/sha256" "encoding/hex" + "strconv" + "time" "github.com/1Panel-dev/1Panel/core/constant" "github.com/1Panel-dev/1Panel/core/init/session/psession" @@ -10,13 +12,15 @@ import ( ) const ( - HeaderUserID = "X-Panel-User-ID" - HeaderAuthSessionID = "X-Panel-Auth-Session-ID" + HeaderUserID = "X-Panel-User-ID" + HeaderAuthSessionID = "X-Panel-Auth-Session-ID" + HeaderAuthLeaseUntil = "X-Panel-Auth-Lease-Until" ) type Identity struct { - UserID string - AuthSessionID string + UserID string + AuthSessionID string + AuthLeaseUntil time.Time } func FromContext(c *gin.Context) (Identity, bool) { @@ -32,7 +36,16 @@ func FromContext(c *gin.Context) (Identity, bool) { return Identity{}, false } if c.GetBool("API_AUTH") { - return Identity{UserID: user.ID, AuthSessionID: APIAuthSessionID(user.ID)}, true + identity := Identity{UserID: user.ID, AuthSessionID: APIAuthSessionID(user.ID), AuthLeaseUntil: time.Now().Add(90 * time.Second)} + if keyID := c.GetString("API_AUTH_KEY_ID"); keyID != "" && c.GetString("API_AUTH_KEY_KIND") == "apiKey" { + identity.AuthSessionID = APIKeyAuthSessionID(keyID) + if value, exists := c.Get("API_AUTH_KEY_EXPIRES_AT"); exists { + if expiresAt, ok := value.(time.Time); ok && !expiresAt.IsZero() && expiresAt.Before(identity.AuthLeaseUntil) { + identity.AuthLeaseUntil = expiresAt + } + } + } + return identity, true } sessionID, err := c.Cookie(constant.SessionName) if err != nil || sessionID == "" { @@ -45,6 +58,10 @@ func APIAuthSessionID(userID string) string { return "api:" + userID } +func APIKeyAuthSessionID(keyID string) string { + return "api-key:" + keyID +} + func HashAuthSessionID(sessionID string) string { sum := sha256.Sum256([]byte(sessionID)) return hex.EncodeToString(sum[:]) @@ -53,9 +70,14 @@ func HashAuthSessionID(sessionID string) string { func ClearForwardedHeaders(c *gin.Context) { c.Request.Header.Del(HeaderUserID) c.Request.Header.Del(HeaderAuthSessionID) + c.Request.Header.Del(HeaderAuthLeaseUntil) } func SetForwardedHeaders(c *gin.Context, identity Identity) { c.Request.Header.Set(HeaderUserID, identity.UserID) c.Request.Header.Set(HeaderAuthSessionID, identity.AuthSessionID) + c.Request.Header.Del(HeaderAuthLeaseUntil) + if !identity.AuthLeaseUntil.IsZero() { + c.Request.Header.Set(HeaderAuthLeaseUntil, strconv.FormatInt(identity.AuthLeaseUntil.UnixMilli(), 10)) + } } diff --git a/core/utils/terminal_session/revoke.go b/core/utils/terminal_session/revoke.go new file mode 100644 index 000000000..d416cbb48 --- /dev/null +++ b/core/utils/terminal_session/revoke.go @@ -0,0 +1,146 @@ +package terminal_session + +import ( + "errors" + "fmt" + "sync" + "time" + + "github.com/1Panel-dev/1Panel/core/global" +) + +type RevokeFunc func(scope, userID, authSessionID string) error + +type resumableRevocation struct { + err error + retry RevokeFunc +} + +func (e resumableRevocation) Error() string { return e.err.Error() } +func (e resumableRevocation) Unwrap() error { return e.err } +func (e resumableRevocation) RetryRevokeFunc() RevokeFunc { return e.retry } + +func PendingRevocation(err error, retry RevokeFunc) error { + if err == nil { + return nil + } + return resumableRevocation{err: err, retry: retry} +} + +func nextRevokeAttempt(err error, fallback RevokeFunc) RevokeFunc { + var resumable interface{ RetryRevokeFunc() RevokeFunc } + if errors.As(err, &resumable) && resumable.RetryRevokeFunc() != nil { + return resumable.RetryRevokeFunc() + } + return fallback +} + +type revokeTarget struct{ scope, userID, authSessionID string } +type revokeRetry struct { + target revokeTarget + revoke RevokeFunc + next time.Time + deadline time.Time + attempt int + version uint64 +} + +type revocationQueue struct { + mu sync.Mutex + pending map[revokeTarget]revokeRetry + limit int + version uint64 +} + +var terminalRevocations = revocationQueue{pending: make(map[revokeTarget]revokeRetry), limit: 256} +var startRevocationWorker sync.Once + +func RevokeWithRetry(scope, userID, authSessionID string, revoke RevokeFunc) error { + if revoke == nil || (scope != "all" && userID == "") || (scope == "auth_session" && authSessionID == "") || + (scope != "all" && scope != "user" && scope != "auth_session") { + return errors.New("invalid terminal revocation") + } + target := revokeTarget{scope, userID, authSessionID} + terminalRevocations.mu.Lock() + previous, hadPrevious := terminalRevocations.pending[target] + terminalRevocations.mu.Unlock() + err := revoke(scope, userID, authSessionID) + if err == nil { + if hadPrevious { + terminalRevocations.complete(previous, nil, time.Now()) + } + return nil + } + if !terminalRevocations.enqueue(target, nextRevokeAttempt(err, revoke), time.Now()) { + err = errors.Join(err, errors.New("terminal revocation retry capacity exceeded")) + } else { + startRevocationWorker.Do(func() { go runRevocationRetries() }) + } + logRevokeFailure(target, err) + return fmt.Errorf("terminal closure is pending: %w", err) +} + +func (q *revocationQueue) enqueue(target revokeTarget, revoke RevokeFunc, now time.Time) bool { + q.mu.Lock() + defer q.mu.Unlock() + if _, exists := q.pending[target]; !exists && len(q.pending) >= q.limit { + return false + } + q.version++ + q.pending[target] = revokeRetry{target: target, revoke: revoke, next: now.Add(5 * time.Second), deadline: now.Add(2 * time.Minute), version: q.version} + return true +} + +func (q *revocationQueue) due(now time.Time) []revokeRetry { + q.mu.Lock() + defer q.mu.Unlock() + var jobs []revokeRetry + for _, item := range q.pending { + if !now.Before(item.next) { + jobs = append(jobs, item) + } + } + return jobs +} + +func (q *revocationQueue) complete(item revokeRetry, err error, now time.Time) { + q.mu.Lock() + defer q.mu.Unlock() + current, exists := q.pending[item.target] + if !exists || current.version != item.version { + return + } + if err == nil || !now.Before(item.deadline) { + delete(q.pending, item.target) + return + } + item.attempt++ + item.revoke = nextRevokeAttempt(err, item.revoke) + item.next = now.Add(time.Duration(min(item.attempt+1, 6)) * 5 * time.Second) + q.pending[item.target] = item +} + +func runRevocationRetries() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for now := range ticker.C { + for _, item := range terminalRevocations.due(now) { + if time.Now().After(item.deadline) { + terminalRevocations.complete(item, errors.New("revocation retry deadline reached"), time.Now()) + logRevokeFailure(item.target, errors.New("revocation retries exhausted; Agent authorization lease must expire")) + continue + } + err := item.revoke(item.target.scope, item.target.userID, item.target.authSessionID) + terminalRevocations.complete(item, err, time.Now()) + if err != nil { + logRevokeFailure(item.target, err) + } + } + } +} + +func logRevokeFailure(target revokeTarget, err error) { + if global.LOG != nil { + global.LOG.Warnf("terminal revocation scope=%s user=%s session=%s: %v", target.scope, target.userID, target.authSessionID, err) + } +} diff --git a/core/utils/xpack/helper/auth_helper.go b/core/utils/xpack/helper/auth_helper.go index fce77ccb2..dc54259f1 100644 --- a/core/utils/xpack/helper/auth_helper.go +++ b/core/utils/xpack/helper/auth_helper.go @@ -19,7 +19,7 @@ import ( "github.com/gin-gonic/gin" ) -type authHelper struct{} +type authHelper struct{ auth.PanelAPIKeyOwnerProvider } func NewIAuthProvider() providers.AuthProvider { return &authHelper{} @@ -73,7 +73,7 @@ func (a *authHelper) CoreAPIAuthMiddleware() gin.HandlerFunc { c.Set(psession.GinContextSessionUserKey, psession.SessionUser{ ID: psession.SuperAdminSessionUserID, Name: name, Role: "ADMIN", }) - }) + }, a) } func (a *authHelper) CoreRBACMiddlewares() []gin.HandlerFunc { return nil } @@ -93,7 +93,7 @@ func (a *authHelper) GenerateApiKey(_ *gin.Context) (string, error) { return "", err } userID := psession.SuperAdminSessionUserID - if err := a.RevokeTerminalSessions("auth_session", userID, terminalsession.APIAuthSessionID(userID)); err != nil { + if err := terminalsession.RevokeWithRetry("auth_session", userID, terminalsession.APIAuthSessionID(userID), a.RevokeTerminalSessions); err != nil { global.LOG.Warnf("revoke API terminal sessions after API key generation failed, err: %v", err) } return apiKey, nil @@ -103,14 +103,18 @@ func (a *authHelper) UpdateApiConfig(c *gin.Context, req baseDto.ApiInterfaceCon return err } userID := psession.SuperAdminSessionUserID - if err := a.RevokeTerminalSessions("auth_session", userID, terminalsession.APIAuthSessionID(userID)); err != nil { + if err := terminalsession.RevokeWithRetry("auth_session", userID, terminalsession.APIAuthSessionID(userID), a.RevokeTerminalSessions); err != nil { global.LOG.Warnf("revoke API terminal sessions after API config update failed, err: %v", err) } return nil } -func (a *authHelper) GetCurrentUserInfo(_ *gin.Context) (*baseDto.CurrentUserInfo, error) { - return auth.GetCurrentUserInfo() +func (a *authHelper) GetCurrentUserInfo(c *gin.Context) (*baseDto.CurrentUserInfo, error) { + info, err := auth.GetCurrentUserInfo() + if info != nil && c != nil && (c.GetBool("API_AUTH") || auth.HasAPICredentials(c)) { + info.ApiKey = "" + } + return info, err } func (a *authHelper) ShouldCheckPasswordExpiration(_ *gin.Context) (bool, error) { return true, nil @@ -127,7 +131,7 @@ func (a *authHelper) UpdateCurrentUserInfo(c *gin.Context, req baseDto.CurrentUs return err } if identity.UserID != "" { - if err := a.RevokeTerminalSessions("user", identity.UserID, ""); err != nil { + if err := terminalsession.RevokeWithRetry("user", identity.UserID, "", a.RevokeTerminalSessions); err != nil { global.LOG.Warnf("revoke terminal sessions after user update failed, err: %v", err) } } @@ -139,7 +143,7 @@ func (a *authHelper) HandlePasswordExpired(c *gin.Context, old, new string) erro return err } if identity.UserID != "" { - if err := a.RevokeTerminalSessions("user", identity.UserID, ""); err != nil { + if err := terminalsession.RevokeWithRetry("user", identity.UserID, "", a.RevokeTerminalSessions); err != nil { global.LOG.Warnf("revoke terminal sessions after password change failed, err: %v", err) } } diff --git a/frontend/src/api/interface/api-key.ts b/frontend/src/api/interface/api-key.ts new file mode 100644 index 000000000..83f0b068d --- /dev/null +++ b/frontend/src/api/interface/api-key.ts @@ -0,0 +1,35 @@ +export namespace APIKey { + export type Status = 'Enable' | 'Disable' | 'Revoked' | 'Expired'; + export interface Editable { + name: string; + description: string; + ipWhiteList: string; + apiTrustedProxies: string; + apiKeyValidityTime: number; + expiresAt: string | null; + allowAppBinding: boolean; + } + export interface Item extends Editable { + id: string; + kind: 'legacy' | 'apiKey'; + keyHint: string; + status: Status; + revision: number; + createdAt: string | null; + } + export interface Search { + items: Item[]; + total: number; + used: number; + limit: number; + } + export interface Created { + item: Item; + apiKey: string; + alreadyCreated?: boolean; + } + export interface Reference { + id: string; + revision: number; + } +} diff --git a/frontend/src/api/interface/log.ts b/frontend/src/api/interface/log.ts index 6b566af2d..ec8614e27 100644 --- a/frontend/src/api/interface/log.ts +++ b/frontend/src/api/interface/log.ts @@ -6,6 +6,9 @@ export namespace Log { id: number; source: string; user: string; + apiKeyID?: string; + apiKeyName?: string; + authMethod?: string; node: string; ip: string; path: string; diff --git a/frontend/src/api/modules/api-key.ts b/frontend/src/api/modules/api-key.ts new file mode 100644 index 000000000..04d91830f --- /dev/null +++ b/frontend/src/api/modules/api-key.ts @@ -0,0 +1,20 @@ +import http from '@/api'; +import type { APIKey } from '@/api/interface/api-key'; + +const prefix = '/core/auth/api/keys'; + +export const searchAPIKeys = (params: { page: number; pageSize: number; excludeRevoked?: boolean }) => { + return http.post(`${prefix}/search`, params); +}; +export const createAPIKey = (params: APIKey.Editable & { requestID: string }) => { + return http.post(`${prefix}/create`, params); +}; +export const updateAPIKey = (params: APIKey.Editable & APIKey.Reference) => { + return http.post<{ terminalClosePending: boolean }>(`${prefix}/update`, params); +}; +export const setAPIKeyStatus = (params: APIKey.Reference & { status: 'Enable' | 'Disable' }) => { + return http.post<{ terminalClosePending: boolean }>(`${prefix}/status`, params); +}; +export const revokeAPIKey = (params: APIKey.Reference) => { + return http.post<{ terminalClosePending: boolean }>(`${prefix}/revoke`, params); +}; diff --git a/frontend/src/components/api-key-management/editor.vue b/frontend/src/components/api-key-management/editor.vue new file mode 100644 index 000000000..1314d342a --- /dev/null +++ b/frontend/src/components/api-key-management/editor.vue @@ -0,0 +1,353 @@ + + + diff --git a/frontend/src/components/api-key-management/summary.vue b/frontend/src/components/api-key-management/summary.vue new file mode 100644 index 000000000..41aa9f01d --- /dev/null +++ b/frontend/src/components/api-key-management/summary.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index fc99b678d..73c37866b 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -1,6 +1,62 @@ import { getFuLocaleMessage } from '@/lang/fu'; const message = { + apiKeyManagement: { + title: 'Panel API', + manage: 'Manage API Keys', + personalEntry: 'Manage your keys, access conditions, and APP binding in Settings.', + create: 'Create API Key', + legacy: 'Legacy Key', + identifier: 'Key identifier', + details: 'API Key details', + quota: '{0} / {1} new keys (disabled and expired keys count; the legacy key is excluded)', + statusEnable: 'Enabled', + statusDisable: 'Disabled', + statusRevoked: 'Revoked', + statusExpired: 'Expired', + specifiedIPs: 'Specified IPs', + anyIP: 'Any IP', + ipHelp: 'Enter one IP or CIDR per line. Select Any IP explicitly to allow all sources.', + signatureWindow: 'Signature validity window', + expiresAt: 'Key expiration', + never: 'Never expires', + futureExpiry: 'Select an expiration time in the future.', + allowAppBinding: 'Allow APP QR binding', + bindingException: + 'Allow this key to be delivered again to a phone through a one-time QR code generated in your signed-in panel session.', + bindingDisableHelp: + 'Turning off QR binding blocks future delivery. Existing connections remain valid until the key is disabled, revoked, or expires.', + saveSecret: 'Copy and save this secret now. After closing, ordinary key management cannot display it again.', + saved: 'I have saved the secret', + alreadyCreated: + 'This key was already created. Its secret will not be shown again. If the original response was lost, revoke and recreate it, or use QR binding if allowed.', + uncertain: + 'The request result is uncertain. Retry with the same details to avoid duplicate keys, or close and check the list before creating another.', + legacyHelp: 'The legacy key keeps existing connections compatible. Its switch and reset affect only that key.', + showLegacy: 'Show legacy secret', + revoke: 'Revoke', + revokeConfirm: + 'Revoke “{0}”? New requests will be rejected and its terminal connections will be closed. This cannot be undone; running background jobs are not cancelled.', + disableConfirm: + 'Disable “{0}”? New requests will be rejected and its terminal connections will be closed. Running background jobs are not cancelled.', + enableConfirm: 'Enable “{0}” with its current access conditions?', + bindingKey: 'Key for APP binding', + selectKey: 'Select your key', + noBindableKey: + 'No key is available for QR binding. Create one or enable QR binding for an existing active key.', + appKeyExpired: 'The selected API Key has expired. Extend its expiration date or select another key.', + appSecurityHelp: + 'The mobile app accesses the panel using the selected API Key and has the same permissions as the account that owns the key. Keep your bound devices secure.', + mobileIPHelp: + 'The phone’s outgoing IP must match the key whitelist. Switching Wi-Fi or mobile networks may block access; the whitelist is never expanded automatically.', + qrUnavailable: 'This QR code is unavailable. Refresh to generate a new one.', + showQR: 'Show QR code', + hideQR: 'Hide QR code', + bindingNotAllowed: 'QR binding is off', + sameKeyDevices: + 'Multiple devices may share this key and its revocation. Use a separate key for each device if you need independent control.', + closePending: 'The key change was saved. Closing remote terminal connections is pending and will be retried.', + }, commons: { true: 'True', false: 'False', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 1cd9df4b1..55a8805a1 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -1,6 +1,62 @@ import { getFuLocaleMessage } from '@/lang/fu'; const message = { + apiKeyManagement: { + title: 'API del panel', + manage: 'Gestionar claves API', + personalEntry: 'Gestiona tus claves, condiciones de acceso y vinculación de la APP en Ajustes.', + create: 'Crear clave API', + legacy: 'Clave anterior', + identifier: 'Identificador de clave', + details: 'Detalles de la clave API', + quota: '{0} / {1} claves nuevas (incluye desactivadas y caducadas; excluye la clave anterior)', + statusEnable: 'Activada', + statusDisable: 'Desactivada', + statusRevoked: 'Revocada', + statusExpired: 'Caducada', + specifiedIPs: 'IP específicas', + anyIP: 'Cualquier IP', + ipHelp: 'Introduce una IP o CIDR por línea. Selecciona Cualquier IP explícitamente para permitir todos los orígenes.', + signatureWindow: 'Ventana de validez de firma', + expiresAt: 'Caducidad de la clave', + never: 'Nunca caduca', + futureExpiry: 'Selecciona una fecha de caducidad futura.', + allowAppBinding: 'Permitir vinculación de APP por QR', + bindingException: + 'Permite volver a entregar esta clave al teléfono mediante un QR de un solo uso generado desde tu sesión del panel.', + bindingDisableHelp: + 'Desactivar la vinculación impide nuevas entregas. Las conexiones existentes siguen siendo válidas hasta desactivar, revocar o caducar la clave.', + saveSecret: 'Copia y guarda la clave ahora. Tras cerrar, la gestión normal no podrá mostrarla de nuevo.', + saved: 'He guardado la clave', + alreadyCreated: + 'La clave ya se creó y no se mostrará de nuevo. Si se perdió la respuesta original, revócala y crea otra, o usa QR si está permitido.', + uncertain: + 'El resultado es incierto. Reintenta con los mismos datos para evitar duplicados, o cierra y revisa la lista antes de crear otra.', + legacyHelp: + 'La clave anterior mantiene la compatibilidad con las conexiones existentes. Su interruptor y restablecimiento solo afectan a esa clave.', + showLegacy: 'Mostrar clave anterior', + revoke: 'Revocar', + revokeConfirm: + '¿Revocar «{0}»? Se rechazarán nuevas solicitudes y se cerrarán sus terminales. Es irreversible; las tareas en curso no se cancelan.', + disableConfirm: + '¿Desactivar «{0}»? Se rechazarán nuevas solicitudes y se cerrarán sus terminales. Las tareas en curso no se cancelan.', + enableConfirm: '¿Activar «{0}» con sus condiciones de acceso actuales?', + bindingKey: 'Clave para vincular la APP', + selectKey: 'Selecciona tu clave', + noBindableKey: 'No hay claves disponibles para QR. Crea una o permite QR en una clave activa.', + appKeyExpired: 'La clave API seleccionada ha caducado. Amplía su fecha de caducidad o selecciona otra clave.', + appSecurityHelp: + 'La aplicación móvil utiliza la clave API seleccionada para acceder al panel con los mismos permisos que la cuenta propietaria de la clave. Protege los dispositivos vinculados.', + mobileIPHelp: + 'La IP de salida del teléfono debe coincidir con la lista permitida. Cambiar de red puede bloquear el acceso; la lista no se amplía automáticamente.', + qrUnavailable: 'Este QR no está disponible. Actualiza para generar otro.', + showQR: 'Mostrar QR', + hideQR: 'Ocultar QR', + bindingNotAllowed: 'Vinculación QR desactivada', + sameKeyDevices: + 'Varios dispositivos pueden compartir la clave y su revocación. Usa una clave distinta por dispositivo para controlarlos por separado.', + closePending: 'El cambio se guardó. El cierre de terminales remotos está pendiente y se reintentará.', + }, commons: { true: 'Verdadero', false: 'Falso', diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index e5dee8b8a..6b55248fa 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -1,6 +1,62 @@ import { getFuLocaleMessage } from '@/lang/fu'; const message = { + apiKeyManagement: { + title: 'API پنل', + manage: 'مدیریت کلیدهای API', + personalEntry: 'کلیدها، شرایط دسترسی و اتصال برنامه را در تنظیمات مدیریت کنید.', + create: 'ایجاد کلید API', + legacy: 'کلید قدیمی', + identifier: 'شناسه کلید', + details: 'جزئیات کلید API', + quota: 'کلیدهای جدید: {0} / {1} (کلیدهای غیرفعال و منقضی حساب می‌شوند؛ کلید قدیمی مستثنا است)', + statusEnable: 'فعال', + statusDisable: 'غیرفعال', + statusRevoked: 'لغوشده', + statusExpired: 'منقضی', + specifiedIPs: 'IPهای مشخص', + anyIP: 'هر IP', + ipHelp: 'در هر خط یک IP یا CIDR وارد کنید. برای اجازه به همه مبدأها، گزینه هر IP را صریحاً انتخاب کنید.', + signatureWindow: 'بازه اعتبار امضای درخواست', + expiresAt: 'زمان انقضای کلید', + never: 'بدون انقضا', + futureExpiry: 'زمان انقضایی در آینده انتخاب کنید.', + allowAppBinding: 'اجازه اتصال برنامه با QR', + bindingException: + 'اجازه می‌دهد این کلید دوباره از طریق QR یک‌بارمصرف ساخته‌شده در نشست واردشده پنل به تلفن تحویل شود.', + bindingDisableHelp: + 'خاموش کردن اتصال QR فقط تحویل‌های بعدی را مسدود می‌کند. اتصال‌های موجود تا غیرفعال‌سازی، لغو یا انقضای کلید معتبر می‌مانند.', + saveSecret: 'اکنون کلید را کپی و ذخیره کنید. پس از بستن، مدیریت عادی نمی‌تواند کلید کامل را دوباره نشان دهد.', + saved: 'کلید را ذخیره کردم', + alreadyCreated: + 'این کلید قبلاً ایجاد شده و دوباره نمایش داده نمی‌شود. اگر پاسخ اصلی گم شده است، کلید را لغو و دوباره ایجاد کنید یا در صورت اجازه از QR استفاده کنید.', + uncertain: + 'نتیجه درخواست نامشخص است. برای جلوگیری از تکرار، با همان اطلاعات دوباره تلاش کنید یا ببندید و پیش از ایجاد کلید دیگر فهرست را بررسی کنید.', + legacyHelp: + 'کلید قدیمی سازگاری اتصال‌های موجود را حفظ می‌کند. تغییر وضعیت و بازنشانی فقط بر همان کلید اثر دارد.', + showLegacy: 'نمایش کلید قدیمی', + revoke: 'لغو', + revokeConfirm: + '«{0}» لغو شود؟ درخواست‌های جدید رد و پایانه‌های مربوط بسته می‌شوند. بازگشت‌پذیر نیست؛ کارهای پس‌زمینه در حال اجرا لغو نمی‌شوند.', + disableConfirm: + '«{0}» غیرفعال شود؟ درخواست‌های جدید رد و پایانه‌های مربوط بسته می‌شوند. کارهای پس‌زمینه در حال اجرا لغو نمی‌شوند.', + enableConfirm: '«{0}» با شرایط دسترسی فعلی فعال شود؟', + bindingKey: 'کلید اتصال برنامه', + selectKey: 'کلید خود را انتخاب کنید', + noBindableKey: 'کلیدی برای اتصال QR موجود نیست. کلیدی ایجاد کنید یا اتصال QR را برای کلیدی فعال مجاز کنید.', + appKeyExpired: 'کلید API انتخاب‌شده منقضی شده است. تاریخ انقضا را تمدید کنید یا کلید دیگری انتخاب کنید.', + appSecurityHelp: + 'برنامه تلفن همراه با استفاده از کلید API انتخاب‌شده و با همان مجوزهای حساب مالک کلید به پنل دسترسی پیدا می‌کند. از دستگاه‌های متصل‌شده به‌خوبی محافظت کنید.', + mobileIPHelp: + 'IP خروجی تلفن باید با فهرست مجاز مطابقت داشته باشد. تغییر شبکه ممکن است دسترسی را مسدود کند؛ فهرست خودکار گسترش نمی‌یابد.', + qrUnavailable: 'این QR در دسترس نیست. برای ساخت کد جدید تازه‌سازی کنید.', + showQR: 'نمایش QR', + hideQR: 'پنهان کردن QR', + bindingNotAllowed: 'اتصال QR خاموش است', + sameKeyDevices: + 'چند دستگاه می‌توانند کلید و لغو مشترک داشته باشند. برای کنترل مستقل، برای هر دستگاه کلید جداگانه بسازید.', + closePending: 'تغییر کلید ذخیره شد. بستن پایانه‌های راه دور در انتظار است و دوباره تلاش خواهد شد.', + }, commons: { true: 'درست', false: 'نادرست', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index f1f9410e3..510a60e68 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -1,6 +1,60 @@ import { getFuLocaleMessage } from '@/lang/fu'; const message = { + apiKeyManagement: { + title: 'パネル API', + manage: 'API Key を管理', + personalEntry: '設定で自分のキー、アクセス条件、APP の QR 連携を管理します。', + create: 'API Key を作成', + legacy: '旧版キー', + identifier: 'キー識別子', + details: 'API Key の詳細', + quota: '新規キー {0} / {1}(無効・期限切れも含む。旧版キーは除く)', + statusEnable: '有効', + statusDisable: '無効', + statusRevoked: '失効済み', + statusExpired: '期限切れ', + specifiedIPs: '指定 IP', + anyIP: 'すべての IP', + ipHelp: '1 行に 1 つの IP または CIDR を入力します。すべての接続元を許可する場合は明示的に選択してください。', + signatureWindow: 'リクエスト署名の有効期間', + expiresAt: 'キーの有効期限', + never: '無期限', + futureExpiry: '現在より後の有効期限を選択してください。', + allowAppBinding: 'APP の QR 連携を許可', + bindingException: + 'ログイン中のパネルで生成した使い捨て QR コードを通じて、このキーを再び携帯端末に渡すことを許可します。', + bindingDisableHelp: + 'オフにすると今後の QR 配布のみ停止します。既存の接続はキーの無効化、失効、期限切れまで利用できます。', + saveSecret: '今すぐキーをコピーして保存してください。閉じると通常のキー管理から完全なキーを再表示できません。', + saved: 'キーを保存しました', + alreadyCreated: + 'このキーは作成済みで、再表示できません。最初の応答を受け取れなかった場合は失効させて再作成するか、許可されていれば QR を使用してください。', + uncertain: 'リクエスト結果が不明です。重複を避けるため同じ設定で再試行するか、閉じて一覧を確認してください。', + legacyHelp: '旧版キーは既存接続との互換性を保ちます。切り替えとリセットはこのキーだけに影響します。', + showLegacy: '旧版キーを表示', + revoke: '失効させる', + revokeConfirm: + '「{0}」を失効させますか?新しいリクエストを拒否し、対応するターミナルを閉じます。元に戻せません。実行中のバックグラウンド処理は中止されません。', + disableConfirm: + '「{0}」を無効にしますか?新しいリクエストを拒否し、対応するターミナルを閉じます。実行中のバックグラウンド処理は中止されません。', + enableConfirm: '現在のアクセス条件で「{0}」を有効にしますか?', + bindingKey: 'APP 連携用キー', + selectKey: '自分のキーを選択', + noBindableKey: 'QR 連携できるキーがありません。新規作成するか、有効なキーの QR 連携を許可してください。', + appKeyExpired: '選択した API Key の有効期限が切れています。有効期限を延長するか、別のキーを選択してください。', + appSecurityHelp: + 'モバイルアプリは、選択した API Key を使用して、そのキーを所有するアカウントと同じ権限でパネルにアクセスします。連携済みの端末は適切に管理してください。', + mobileIPHelp: + '携帯端末の送信元 IP が許可リストに一致する必要があります。ネットワークの切り替えで接続できなくなる場合があります。自動で許可範囲は広がりません。', + qrUnavailable: 'この QR は利用できません。更新して再生成してください。', + showQR: 'QR を表示', + hideQR: 'QR を非表示', + bindingNotAllowed: 'QR 連携は無効', + sameKeyDevices: + '複数端末で同じキーを共有すると同時に失効します。個別に管理する場合は端末ごとにキーを作成してください。', + closePending: 'キーの変更を保存しました。一部のリモートターミナルの終了は保留中で、再試行されます。', + }, commons: { true: 'はい', false: 'いいえ', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 9263305da..6de4b7d49 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -1,6 +1,59 @@ import { getFuLocaleMessage } from '@/lang/fu'; const message = { + apiKeyManagement: { + title: '패널 API', + manage: 'API Key 관리', + personalEntry: '설정에서 내 키, 접근 조건 및 APP QR 연결을 관리합니다.', + create: 'API Key 만들기', + legacy: '기존 키', + identifier: '키 식별자', + details: 'API Key 상세 정보', + quota: '새 키 {0} / {1} (비활성 및 만료 키 포함, 기존 키 제외)', + statusEnable: '활성', + statusDisable: '비활성', + statusRevoked: '폐기됨', + statusExpired: '만료됨', + specifiedIPs: '지정 IP', + anyIP: '모든 IP', + ipHelp: '한 줄에 IP 또는 CIDR 하나를 입력하세요. 모든 출처를 허용하려면 모든 IP를 명시적으로 선택하세요.', + signatureWindow: '요청 서명 유효 시간', + expiresAt: '키 만료 시간', + never: '만료 없음', + futureExpiry: '현재 이후의 만료 시간을 선택하세요.', + allowAppBinding: 'APP QR 연결 허용', + bindingException: '로그인한 패널 세션에서 만든 일회용 QR 코드를 통해 이 키를 휴대폰에 다시 전달할 수 있습니다.', + bindingDisableHelp: + 'QR 연결을 끄면 이후 전달만 차단됩니다. 기존 연결은 키를 비활성화하거나 폐기하거나 만료될 때까지 유효합니다.', + saveSecret: '지금 키를 복사하여 저장하세요. 닫으면 일반 키 관리에서 전체 키를 다시 표시할 수 없습니다.', + saved: '키를 저장했습니다', + alreadyCreated: + '이 키는 이미 생성되어 다시 표시할 수 없습니다. 원래 응답을 잃었다면 폐기 후 다시 만들거나 허용된 QR 연결을 사용하세요.', + uncertain: + '요청 결과를 확인할 수 없습니다. 중복 생성을 피하려면 같은 설정으로 재시도하거나 닫고 목록을 확인하세요.', + legacyHelp: '기존 키는 기존 연결의 호환성을 유지합니다. 스위치와 재설정은 이 키에만 적용됩니다.', + showLegacy: '기존 키 표시', + revoke: '폐기', + revokeConfirm: + '“{0}” 키를 폐기할까요? 새 요청이 거부되고 해당 터미널이 닫힙니다. 되돌릴 수 없으며 실행 중인 백그라운드 작업은 취소되지 않습니다.', + disableConfirm: + '“{0}” 키를 비활성화할까요? 새 요청이 거부되고 해당 터미널이 닫힙니다. 실행 중인 백그라운드 작업은 취소되지 않습니다.', + enableConfirm: '현재 접근 조건으로 “{0}” 키를 활성화할까요?', + bindingKey: 'APP 연결에 사용할 키', + selectKey: '내 키 선택', + noBindableKey: 'QR 연결 가능한 키가 없습니다. 키를 만들거나 유효한 키의 QR 연결을 허용하세요.', + appKeyExpired: '선택한 API Key가 만료되었습니다. 만료일을 연장하거나 다른 키를 선택하세요.', + appSecurityHelp: + '모바일 앱은 선택한 API Key를 사용하여 해당 키 소유 계정과 동일한 권한으로 패널에 접근합니다. 연결된 기기를 안전하게 관리하세요.', + mobileIPHelp: + '휴대폰의 외부 IP는 허용 목록과 일치해야 합니다. 네트워크 전환으로 접근이 차단될 수 있으며 허용 목록은 자동으로 확대되지 않습니다.', + qrUnavailable: '이 QR 코드를 사용할 수 없습니다. 새로 고침하여 다시 만드세요.', + showQR: 'QR 코드 표시', + hideQR: 'QR 코드 숨기기', + bindingNotAllowed: 'QR 연결 꺼짐', + sameKeyDevices: '여러 기기가 같은 키를 공유하면 함께 폐기됩니다. 개별 제어가 필요하면 기기별 키를 만드세요.', + closePending: '키 변경을 저장했습니다. 일부 원격 터미널 종료가 대기 중이며 재시도됩니다.', + }, commons: { true: '참', false: '거짓', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index e93a93874..f2e34bd58 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -1,6 +1,56 @@ import { getFuLocaleMessage } from '@/lang/fu'; const message = { + apiKeyManagement: { + title: 'API ພາເນລ', + manage: 'ຈັດການ API Key', + personalEntry: 'ຈັດການກະແຈ, ເງື່ອນໄຂເຂົ້າໃຊ້ ແລະ ການຜູກ APP ໃນການຕັ້ງຄ່າ.', + create: 'ສ້າງ API Key', + legacy: 'ກະແຈເກົ່າ', + identifier: 'ລະຫັດກະແຈ', + details: 'ລາຍລະອຽດ API Key', + quota: 'ກະແຈໃໝ່ {0} / {1} (ນັບກະແຈປິດໃຊ້ ແລະ ໝົດອາຍຸ; ບໍ່ນັບກະແຈເກົ່າ)', + statusEnable: 'ເປີດໃຊ້', + statusDisable: 'ປິດໃຊ້', + statusRevoked: 'ຖອນແລ້ວ', + statusExpired: 'ໝົດອາຍຸ', + specifiedIPs: 'IP ທີ່ລະບຸ', + anyIP: 'ທຸກ IP', + ipHelp: 'ໃສ່ IP ຫຼື CIDR ໜຶ່ງລາຍການຕໍ່ແຖວ. ເລືອກ ທຸກ IP ເພື່ອອະນຸຍາດທຸກແຫຼ່ງ.', + signatureWindow: 'ໄລຍະລາຍເຊັນຄຳຮ້ອງຖືກຕ້ອງ', + expiresAt: 'ເວລາກະແຈໝົດອາຍຸ', + never: 'ບໍ່ໝົດອາຍຸ', + futureExpiry: 'ເລືອກເວລາໝົດອາຍຸໃນອະນາຄົດ.', + allowAppBinding: 'ອະນຸຍາດຜູກ APP ຜ່ານ QR', + bindingException: 'ອະນຸຍາດສົ່ງກະແຈນີ້ໃຫ້ໂທລະສັບອີກຄັ້ງ ຜ່ານ QR ໃຊ້ຄັ້ງດຽວທີ່ສ້າງໃນເຊດຊັນແຜງທີ່ເຂົ້າລະບົບ.', + bindingDisableHelp: + 'ປິດການຜູກ QR ຈະຢຸດສະເພາະການສົ່ງຄັ້ງໃໝ່. ການເຊື່ອມຕໍ່ເກົ່າຍັງໃຊ້ໄດ້ຈົນກະແຈຖືກປິດ, ຖອນ ຫຼື ໝົດອາຍຸ.', + saveSecret: 'ຄັດລອກ ແລະ ບັນທຶກກະແຈດຽວນີ້. ຫຼັງປິດແລ້ວ ການຈັດການປົກກະຕິຈະບໍ່ສະແດງກະແຈເຕັມອີກ.', + saved: 'ຂ້ອຍບັນທຶກກະແຈແລ້ວ', + alreadyCreated: 'ກະແຈນີ້ສ້າງແລ້ວ ແລະ ບໍ່ສະແດງອີກ. ຖ້າຄຳຕອບເກົ່າຫາຍ ໃຫ້ຖອນແລ້ວສ້າງໃໝ່ ຫຼື ໃຊ້ QR ຖ້າອະນຸຍາດ.', + uncertain: 'ຍັງບໍ່ແນ່ໃຈຜົນຄຳຮ້ອງ. ລອງໃໝ່ດ້ວຍຂໍ້ມູນເດີມເພື່ອບໍ່ສ້າງຊ້ຳ ຫຼື ປິດແລ້ວກວດລາຍການກ່ອນສ້າງອີກ.', + legacyHelp: 'ກະແຈເກົ່າຮັກສາການເຊື່ອມຕໍ່ເກົ່າ. ສະວິດ ແລະ ການຣີເຊັດມີຜົນສະເພາະກະແຈນັ້ນ.', + showLegacy: 'ສະແດງກະແຈເກົ່າ', + revoke: 'ຖອນ', + revokeConfirm: + 'ຖອນ “{0}” ຫຼືບໍ່? ຄຳຮ້ອງໃໝ່ຈະຖືກປະຕິເສດ ແລະ ປິດເທີມິນອນທີ່ກ່ຽວຂ້ອງ. ບໍ່ສາມາດກູ້ຄືນ; ວຽກເບື້ອງຫຼັງທີ່ແລ່ນຢູ່ບໍ່ຖືກຍົກເລີກ.', + disableConfirm: + 'ປິດໃຊ້ “{0}” ຫຼືບໍ່? ຄຳຮ້ອງໃໝ່ຈະຖືກປະຕິເສດ ແລະ ປິດເທີມິນອນ. ວຽກເບື້ອງຫຼັງທີ່ແລ່ນຢູ່ບໍ່ຖືກຍົກເລີກ.', + enableConfirm: 'ເປີດໃຊ້ “{0}” ດ້ວຍເງື່ອນໄຂເຂົ້າໃຊ້ປັດຈຸບັນຫຼືບໍ່?', + bindingKey: 'ກະແຈສຳລັບຜູກ APP', + selectKey: 'ເລືອກກະແຈຂອງທ່ານ', + noBindableKey: 'ບໍ່ມີກະແຈສຳລັບຜູກ QR. ສ້າງໃໝ່ ຫຼື ອະນຸຍາດ QR ໃຫ້ກະແຈທີ່ໃຊ້ງານໄດ້.', + appKeyExpired: 'API Key ທີ່ເລືອກໝົດອາຍຸແລ້ວ. ກະລຸນາຂະຫຍາຍວັນໝົດອາຍຸ ຫຼື ເລືອກກະແຈອື່ນ.', + appSecurityHelp: + 'ແອັບມືຖືໃຊ້ API Key ທີ່ເລືອກເພື່ອເຂົ້າເຖິງພາເນລ ໂດຍມີສິດຄືກັນກັບບັນຊີເຈົ້າຂອງ Key. ກະລຸນາຮັກສາອຸປະກອນທີ່ຜູກແລ້ວໃຫ້ປອດໄພ.', + mobileIPHelp: 'IP ຂາອອກຂອງໂທລະສັບຕ້ອງກົງກັບລາຍການອະນຸຍາດ. ປ່ຽນເຄືອຂ່າຍອາດຖືກປະຕິເສດ; ລາຍການບໍ່ຂະຫຍາຍເອງ.', + qrUnavailable: 'QR ນີ້ໃຊ້ບໍ່ໄດ້. ໂຫຼດໃໝ່ເພື່ອສ້າງອີກ.', + showQR: 'ສະແດງ QR', + hideQR: 'ເຊື່ອງ QR', + bindingNotAllowed: 'ປິດການຜູກ QR', + sameKeyDevices: 'ຫຼາຍອຸປະກອນສາມາດໃຊ້ກະແຈດຽວ ແລະ ຖືກຖອນພ້ອມກັນ. ໃຊ້ກະແຈຕ່າງກັນເພື່ອຄວບຄຸມແຍກ.', + closePending: 'ບັນທຶກການປ່ຽນກະແຈແລ້ວ. ການປິດເທີມິນອນທາງໄກຍັງລໍຖ້າ ແລະ ຈະລອງໃໝ່.', + }, commons: { true: 'ແມ່ນ', false: 'ບໍ່ແມ່ນ', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 22f4e1edc..4cee166c2 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -1,6 +1,63 @@ import { getFuLocaleMessage } from '@/lang/fu'; const message = { + apiKeyManagement: { + title: 'API Panel', + manage: 'Urus kunci API', + personalEntry: 'Urus kunci, syarat akses dan pautan APP anda dalam Tetapan.', + create: 'Cipta kunci API', + legacy: 'Kunci lama', + identifier: 'Pengecam kunci', + details: 'Butiran kunci API', + quota: '{0} / {1} kunci baharu (termasuk kunci dinyahdayakan dan tamat tempoh; tidak termasuk kunci lama)', + statusEnable: 'Didayakan', + statusDisable: 'Dinyahdayakan', + statusRevoked: 'Dibatalkan', + statusExpired: 'Tamat tempoh', + specifiedIPs: 'IP tertentu', + anyIP: 'Sebarang IP', + ipHelp: 'Masukkan satu IP atau CIDR setiap baris. Pilih Sebarang IP secara nyata untuk membenarkan semua sumber.', + signatureWindow: 'Tempoh sah tandatangan permintaan', + expiresAt: 'Tarikh luput kunci', + never: 'Tidak tamat tempoh', + futureExpiry: 'Pilih masa luput pada masa hadapan.', + allowAppBinding: 'Benarkan pautan APP melalui QR', + bindingException: + 'Benarkan kunci ini dihantar semula ke telefon melalui kod QR sekali guna yang dijana dalam sesi panel anda yang telah log masuk.', + bindingDisableHelp: + 'Mematikan pautan QR menyekat penghantaran baharu sahaja. Sambungan sedia ada kekal sah sehingga kunci dinyahdayakan, dibatalkan atau tamat tempoh.', + saveSecret: + 'Salin dan simpan kunci sekarang. Selepas ditutup, pengurusan biasa tidak dapat memaparkan kunci penuh lagi.', + saved: 'Saya telah menyimpan kunci', + alreadyCreated: + 'Kunci ini telah dicipta dan tidak akan dipaparkan semula. Jika respons asal hilang, batalkan dan cipta semula atau gunakan QR jika dibenarkan.', + uncertain: + 'Hasil permintaan belum pasti. Cuba semula dengan butiran sama untuk mengelakkan pendua, atau tutup dan semak senarai sebelum mencipta lagi.', + legacyHelp: + 'Kunci lama mengekalkan keserasian sambungan sedia ada. Suis dan tetapan semulanya hanya mempengaruhi kunci itu.', + showLegacy: 'Tunjukkan kunci lama', + revoke: 'Batalkan', + revokeConfirm: + 'Batalkan “{0}”? Permintaan baharu akan ditolak dan terminalnya ditutup. Tidak boleh diundur; tugas latar yang sedang berjalan tidak dibatalkan.', + disableConfirm: + 'Nyahdayakan “{0}”? Permintaan baharu akan ditolak dan terminalnya ditutup. Tugas latar yang sedang berjalan tidak dibatalkan.', + enableConfirm: 'Dayakan “{0}” dengan syarat akses semasa?', + bindingKey: 'Kunci untuk pautan APP', + selectKey: 'Pilih kunci anda', + noBindableKey: 'Tiada kunci tersedia untuk pautan QR. Cipta kunci atau benarkan QR pada kunci yang aktif.', + appKeyExpired: 'Kunci API yang dipilih telah tamat tempoh. Lanjutkan tarikh luputnya atau pilih kunci lain.', + appSecurityHelp: + 'Aplikasi mudah alih menggunakan kunci API yang dipilih untuk mengakses panel dengan kebenaran yang sama seperti akaun pemilik kunci tersebut. Pastikan peranti yang dipautkan disimpan dengan selamat.', + mobileIPHelp: + 'IP keluar telefon mesti sepadan dengan senarai dibenarkan. Menukar rangkaian boleh menyekat akses; senarai tidak diperluas secara automatik.', + qrUnavailable: 'Kod QR ini tidak tersedia. Segar semula untuk menjana kod baharu.', + showQR: 'Tunjukkan kod QR', + hideQR: 'Sembunyikan kod QR', + bindingNotAllowed: 'Pautan QR dimatikan', + sameKeyDevices: + 'Beberapa peranti boleh berkongsi kunci dan pembatalannya. Gunakan kunci berasingan bagi setiap peranti untuk kawalan bebas.', + closePending: 'Perubahan kunci disimpan. Penutupan terminal jauh masih menunggu dan akan dicuba semula.', + }, commons: { true: 'Benar', false: 'Palsu', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index 3a5aaeae1..1389eef3c 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -1,6 +1,62 @@ import { getFuLocaleMessage } from '@/lang/fu'; const message = { + apiKeyManagement: { + title: 'API do painel', + manage: 'Gerenciar chaves API', + personalEntry: 'Gerencie suas chaves, condições de acesso e vínculo do APP nas Configurações.', + create: 'Criar chave API', + legacy: 'Chave legada', + identifier: 'Identificador da chave', + details: 'Detalhes da chave API', + quota: '{0} / {1} novas chaves (inclui desativadas e expiradas; exclui a chave legada)', + statusEnable: 'Ativada', + statusDisable: 'Desativada', + statusRevoked: 'Revogada', + statusExpired: 'Expirada', + specifiedIPs: 'IPs específicos', + anyIP: 'Qualquer IP', + ipHelp: 'Informe um IP ou CIDR por linha. Selecione Qualquer IP explicitamente para permitir todas as origens.', + signatureWindow: 'Janela de validade da assinatura', + expiresAt: 'Expiração da chave', + never: 'Nunca expira', + futureExpiry: 'Selecione uma data de expiração futura.', + allowAppBinding: 'Permitir vínculo do APP por QR', + bindingException: + 'Permite entregar esta chave novamente ao celular por um QR de uso único gerado na sua sessão autenticada do painel.', + bindingDisableHelp: + 'Desativar o vínculo impede novas entregas. Conexões existentes continuam válidas até a chave ser desativada, revogada ou expirar.', + saveSecret: 'Copie e salve a chave agora. Após fechar, o gerenciamento normal não poderá exibi-la novamente.', + saved: 'Salvei a chave', + alreadyCreated: + 'Esta chave já foi criada e não será exibida novamente. Se a resposta original foi perdida, revogue e recrie a chave ou use o QR, se permitido.', + uncertain: + 'O resultado é incerto. Tente novamente com os mesmos dados para evitar duplicações ou feche e verifique a lista antes de criar outra.', + legacyHelp: + 'A chave legada mantém conexões existentes compatíveis. Ativar, desativar ou redefinir afeta somente essa chave.', + showLegacy: 'Mostrar chave legada', + revoke: 'Revogar', + revokeConfirm: + 'Revogar “{0}”? Novas solicitações serão rejeitadas e seus terminais serão fechados. Não é possível desfazer; tarefas em andamento não serão canceladas.', + disableConfirm: + 'Desativar “{0}”? Novas solicitações serão rejeitadas e seus terminais serão fechados. Tarefas em andamento não serão canceladas.', + enableConfirm: 'Ativar “{0}” com as condições de acesso atuais?', + bindingKey: 'Chave para vínculo do APP', + selectKey: 'Selecione sua chave', + noBindableKey: 'Nenhuma chave disponível para QR. Crie uma ou permita o vínculo em uma chave ativa.', + appKeyExpired: 'A chave API selecionada expirou. Estenda sua validade ou selecione outra chave.', + appSecurityHelp: + 'O aplicativo móvel usa a chave API selecionada para acessar o painel com as mesmas permissões da conta proprietária da chave. Mantenha os dispositivos vinculados seguros.', + mobileIPHelp: + 'O IP de saída do celular deve corresponder à lista permitida. Trocar de rede pode bloquear o acesso; a lista não é ampliada automaticamente.', + qrUnavailable: 'Este QR está indisponível. Atualize para gerar outro.', + showQR: 'Mostrar QR', + hideQR: 'Ocultar QR', + bindingNotAllowed: 'Vínculo por QR desativado', + sameKeyDevices: + 'Vários dispositivos podem compartilhar a chave e sua revogação. Use uma chave por dispositivo para controle independente.', + closePending: 'A alteração foi salva. O fechamento de terminais remotos está pendente e será repetido.', + }, commons: { true: 'Verdadeiro', false: 'falso', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index beafc89ae..f03333757 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -1,6 +1,63 @@ import { getFuLocaleMessage } from '@/lang/fu'; const message = { + apiKeyManagement: { + title: 'API панели', + manage: 'Управление API-ключами', + personalEntry: 'Управляйте своими ключами, условиями доступа и привязкой приложения в настройках.', + create: 'Создать API-ключ', + legacy: 'Прежний ключ', + identifier: 'Идентификатор ключа', + details: 'Сведения об API-ключе', + quota: 'Новых ключей: {0} / {1} (отключённые и истёкшие учитываются; прежний ключ исключён)', + statusEnable: 'Включён', + statusDisable: 'Отключён', + statusRevoked: 'Отозван', + statusExpired: 'Истёк', + specifiedIPs: 'Указанные IP', + anyIP: 'Любой IP', + ipHelp: 'Введите по одному IP или CIDR в строке. Для разрешения всех источников явно выберите «Любой IP».', + signatureWindow: 'Срок действия подписи запроса', + expiresAt: 'Срок действия ключа', + never: 'Бессрочно', + futureExpiry: 'Выберите время окончания в будущем.', + allowAppBinding: 'Разрешить привязку приложения по QR', + bindingException: + 'Разрешает повторно передавать ключ телефону через одноразовый QR-код, созданный в вашей авторизованной сессии панели.', + bindingDisableHelp: + 'Отключение привязки блокирует только будущую передачу. Существующие подключения действуют до отключения, отзыва или истечения ключа.', + saveSecret: + 'Скопируйте и сохраните ключ сейчас. После закрытия обычное управление не сможет показать полный ключ снова.', + saved: 'Ключ сохранён', + alreadyCreated: + 'Ключ уже создан и не будет показан повторно. Если исходный ответ потерян, отзовите и создайте ключ заново либо используйте разрешённую привязку по QR.', + uncertain: + 'Результат запроса неизвестен. Повторите с теми же данными, чтобы избежать дублей, или закройте и проверьте список.', + legacyHelp: + 'Прежний ключ сохраняет совместимость подключений. Его переключение и сброс влияют только на этот ключ.', + showLegacy: 'Показать прежний ключ', + revoke: 'Отозвать', + revokeConfirm: + 'Отозвать «{0}»? Новые запросы будут отклонены, терминалы закрыты. Это необратимо; запущенные фоновые задачи не отменяются.', + disableConfirm: + 'Отключить «{0}»? Новые запросы будут отклонены, терминалы закрыты. Запущенные фоновые задачи не отменяются.', + enableConfirm: 'Включить «{0}» с текущими условиями доступа?', + bindingKey: 'Ключ для привязки приложения', + selectKey: 'Выберите свой ключ', + noBindableKey: 'Нет ключей для QR-привязки. Создайте ключ или разрешите привязку для действующего ключа.', + appKeyExpired: 'Срок действия выбранного API-ключа истёк. Продлите его или выберите другой ключ.', + appSecurityHelp: + 'Мобильное приложение использует выбранный API-ключ для доступа к панели с теми же правами, что и учётная запись владельца ключа. Обеспечьте безопасность привязанных устройств.', + mobileIPHelp: + 'Внешний IP телефона должен быть в разрешённом списке. Смена сети может заблокировать доступ; список автоматически не расширяется.', + qrUnavailable: 'QR-код недоступен. Обновите, чтобы создать новый.', + showQR: 'Показать QR-код', + hideQR: 'Скрыть QR-код', + bindingNotAllowed: 'QR-привязка отключена', + sameKeyDevices: + 'Несколько устройств могут использовать общий ключ и отзыв. Для независимого управления создайте отдельный ключ для каждого устройства.', + closePending: 'Изменение ключа сохранено. Закрытие удалённых терминалов ожидается и будет повторено.', + }, commons: { true: 'Да', false: 'Нет', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index c13384832..de9073f00 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -1,6 +1,64 @@ import { getFuLocaleMessage } from '@/lang/fu'; const message = { + apiKeyManagement: { + title: 'Panel API', + manage: 'API anahtarlarını yönet', + personalEntry: 'Ayarlar bölümünde anahtarlarınızı, erişim koşullarını ve uygulama QR bağlantısını yönetin.', + create: 'API anahtarı oluştur', + legacy: 'Eski anahtar', + identifier: 'Anahtar kimliği', + details: 'API anahtarı ayrıntıları', + quota: '{0} / {1} yeni anahtar (devre dışı ve süresi dolmuş anahtarlar dahil, eski anahtar hariç)', + statusEnable: 'Etkin', + statusDisable: 'Devre dışı', + statusRevoked: 'İptal edildi', + statusExpired: 'Süresi doldu', + specifiedIPs: 'Belirtilen IP’ler', + anyIP: 'Herhangi bir IP', + ipHelp: 'Her satıra bir IP veya CIDR girin. Tüm kaynaklara izin vermek için açıkça Herhangi bir IP seçeneğini seçin.', + signatureWindow: 'İstek imzasının geçerlilik süresi', + expiresAt: 'Anahtarın sona erme zamanı', + never: 'Süresiz', + futureExpiry: 'Gelecekte bir sona erme zamanı seçin.', + allowAppBinding: 'Uygulama QR bağlantısına izin ver', + bindingException: + 'Oturum açılmış panelinizde oluşturulan tek kullanımlık QR koduyla bu anahtarın telefona yeniden iletilmesine izin verir.', + bindingDisableHelp: + 'QR bağlantısını kapatmak gelecekteki iletimi engeller. Mevcut bağlantılar anahtar devre dışı bırakılana, iptal edilene veya süresi dolana kadar geçerlidir.', + saveSecret: + 'Anahtarı şimdi kopyalayıp kaydedin. Kapattıktan sonra normal anahtar yönetimi tam anahtarı yeniden gösteremez.', + saved: 'Anahtarı kaydettim', + alreadyCreated: + 'Bu anahtar zaten oluşturuldu ve yeniden gösterilmez. İlk yanıt kaybolduysa iptal edip yeniden oluşturun veya izin veriliyorsa QR bağlantısını kullanın.', + uncertain: + 'İstek sonucu belirsiz. Tekrarları önlemek için aynı bilgilerle yeniden deneyin veya kapatıp yeni anahtar oluşturmadan listeyi kontrol edin.', + legacyHelp: + 'Eski anahtar mevcut bağlantıların uyumluluğunu korur. Anahtarı açma, kapatma ve sıfırlama yalnızca onu etkiler.', + showLegacy: 'Eski anahtarı göster', + revoke: 'İptal et', + revokeConfirm: + '“{0}” iptal edilsin mi? Yeni istekler reddedilir ve ilgili terminaller kapatılır. Geri alınamaz; çalışan arka plan işleri iptal edilmez.', + disableConfirm: + '“{0}” devre dışı bırakılsın mı? Yeni istekler reddedilir ve ilgili terminaller kapatılır. Çalışan arka plan işleri iptal edilmez.', + enableConfirm: '“{0}” mevcut erişim koşullarıyla etkinleştirilsin mi?', + bindingKey: 'Uygulama bağlantısı için anahtar', + selectKey: 'Anahtarınızı seçin', + noBindableKey: + 'QR bağlantısı için uygun anahtar yok. Yeni anahtar oluşturun veya etkin bir anahtarda QR bağlantısını açın.', + appKeyExpired: 'Seçilen API anahtarının süresi doldu. Geçerlilik süresini uzatın veya başka bir anahtar seçin.', + appSecurityHelp: + 'Mobil uygulama, seçilen API anahtarını kullanarak panele anahtarın sahibi olan hesapla aynı yetkilerle erişir. Bağlı cihazlarınızı güvende tutun.', + mobileIPHelp: + 'Telefonun çıkış IP’si izin listesiyle eşleşmelidir. Ağ değiştirmek erişimi engelleyebilir; liste otomatik genişletilmez.', + qrUnavailable: 'Bu QR kodu kullanılamıyor. Yenisini oluşturmak için yenileyin.', + showQR: 'QR kodunu göster', + hideQR: 'QR kodunu gizle', + bindingNotAllowed: 'QR bağlantısı kapalı', + sameKeyDevices: + 'Birden fazla cihaz aynı anahtarı ve iptal durumunu paylaşabilir. Bağımsız yönetim için her cihaza ayrı anahtar kullanın.', + closePending: 'Anahtar değişikliği kaydedildi. Uzak terminallerin kapatılması bekleniyor ve yeniden denenecek.', + }, commons: { true: 'Doğru', false: 'Yanlış', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 964ba6996..11923c1c2 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -1,6 +1,55 @@ import { getFuLocaleMessage } from '@/lang/fu'; const message = { + apiKeyManagement: { + title: '面板 API', + manage: '管理 API Key', + personalEntry: '前往設定管理自己的金鑰、存取條件與 APP 掃碼綁定。', + create: '建立 API Key', + legacy: '舊版 Key', + identifier: '金鑰識別', + details: 'API Key 詳情', + quota: '新 Key {0} / {1}(停用、到期的仍計數,舊版 Key 不計入)', + statusEnable: '已啟用', + statusDisable: '已停用', + statusRevoked: '已撤銷', + statusExpired: '已到期', + specifiedIPs: '指定 IP', + anyIP: '所有 IP', + ipHelp: '每行填寫一個 IP 或 CIDR。允許所有來源時,請明確選擇「所有 IP」。', + signatureWindow: '請求簽章有效期間', + expiresAt: '金鑰到期時間', + never: '永不到期', + futureExpiry: '請選擇晚於目前時間的到期時間。', + allowAppBinding: '允許 APP 掃碼綁定', + bindingException: '允許在目前帳號的面板登入工作階段中產生一次性 QR Code,再次向手機交付這把金鑰。', + bindingDisableHelp: '關閉掃碼綁定後,已綁定裝置仍可使用,直到金鑰停用、撤銷或到期。', + saveSecret: '請立即複製並儲存金鑰。關閉後,一般金鑰管理無法再次顯示完整金鑰。', + saved: '我已儲存金鑰', + alreadyCreated: + '這把金鑰已建立,無法再次顯示明文。若原回應遺失,請撤銷後重新建立;允許掃碼綁定時,也可透過掃碼交付。', + uncertain: '請求結果尚不確定。請保持原設定重試,避免重複建立;或關閉並檢查清單後再建立其他金鑰。', + legacyHelp: '舊版 Key 用於相容已有連線,其開關與重設只影響這把 Key。', + showLegacy: '檢視舊版金鑰', + revoke: '撤銷', + revokeConfirm: + '確認撤銷「{0}」?新請求將被拒絕,並關閉對應終端連線。撤銷無法復原;已開始的背景工作不會自動取消。', + disableConfirm: '確認停用「{0}」?新請求將被拒絕,並關閉對應終端連線。已開始的背景工作不會自動取消。', + enableConfirm: '確認依目前存取條件啟用「{0}」?', + bindingKey: '用於 APP 綁定的 Key', + selectKey: '選擇自己的 Key', + noBindableKey: '暫無可掃碼綁定的 Key。請先建立,或為已有且有效的 Key 開啟掃碼綁定。', + appKeyExpired: '所選 API Key 已到期,請調整有效期限或選擇其他 Key。', + appSecurityHelp: '行動 App 使用所選 API Key 存取面板,權限與該 Key 所屬帳號一致。請妥善保管已綁定的裝置。', + mobileIPHelp: '手機出口 IP 必須符合白名單。切換 Wi-Fi 或行動網路可能導致存取被拒絕,系統不會自動擴大白名單。', + qrUnavailable: 'QR Code 已無法使用,請重新整理後再掃描。', + showQR: '顯示 QR Code', + hideQR: '隱藏 QR Code', + bindingNotAllowed: '未允許掃碼綁定', + sameKeyDevices: + '多台裝置可共用一個 Key,停用或撤銷該 Key 會同時影響這些裝置。如需個別管理,請為每台裝置建立獨立的 Key。', + closePending: '金鑰變更已儲存,部分遠端終端連線尚未關閉,系統將重試。', + }, commons: { true: '是', false: '否', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index b25d03d0c..619bd8dcf 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -1,6 +1,55 @@ import { getFuLocaleMessage } from '@/lang/fu'; const message = { + apiKeyManagement: { + title: '面板 API', + manage: '管理 API Key', + personalEntry: '前往设置管理自己的密钥、访问条件和 APP 扫码绑定。', + create: '创建 API Key', + legacy: '旧版 Key', + identifier: '密钥标识', + details: 'API Key 详情', + quota: '新 Key {0} / {1}(停用、到期的仍计数,旧版 Key 不计入)', + statusEnable: '已启用', + statusDisable: '已停用', + statusRevoked: '已撤销', + statusExpired: '已到期', + specifiedIPs: '指定 IP', + anyIP: '所有 IP', + ipHelp: '每行填写一个 IP 或 CIDR。允许所有来源时,请明确选择“所有 IP”。', + signatureWindow: '请求签名有效窗口', + expiresAt: '密钥到期时间', + never: '永不过期', + futureExpiry: '请选择晚于当前时间的到期时间。', + allowAppBinding: '允许 APP 扫码绑定', + bindingException: '允许在当前账号的面板登录会话中生成一次性二维码,再次向手机交付这把密钥。', + bindingDisableHelp: '关闭扫码绑定后,已绑定设备仍可使用,直到密钥停用、撤销或到期。', + saveSecret: '请立即复制并保存密钥。关闭后,普通密钥管理无法再次显示完整密钥。', + saved: '我已保存密钥', + alreadyCreated: + '这把密钥已创建,无法再次展示明文。若原响应丢失,请撤销后重新创建;允许扫码绑定时,也可通过扫码交付。', + uncertain: '请求结果尚不确定。请保持原配置重试,避免重复创建;或关闭并检查列表后再创建其他密钥。', + legacyHelp: '旧版 Key 用于兼容已有连接,其开关和重置只影响这把 Key。', + showLegacy: '查看旧版密钥', + revoke: '撤销', + revokeConfirm: + '确认撤销“{0}”?新的请求将被拒绝,并关闭对应终端连接。撤销不可恢复;已经开始的后台任务不会自动取消。', + disableConfirm: '确认停用“{0}”?新的请求将被拒绝,并关闭对应终端连接。已经开始的后台任务不会自动取消。', + enableConfirm: '确认按当前访问条件启用“{0}”?', + bindingKey: '用于 APP 绑定的 Key', + selectKey: '选择自己的 Key', + noBindableKey: '暂无可扫码绑定的 Key。请先创建,或为已有且有效的 Key 开启扫码绑定。', + appKeyExpired: '所选 API Key 已到期,请调整有效期或选择其他 Key。', + appSecurityHelp: '移动端 App 使用所选 API Key 访问面板,权限与该 Key 所属账号一致。请妥善保管已绑定的设备。', + mobileIPHelp: '手机出口 IP 必须符合白名单。切换 Wi-Fi 或移动网络可能导致访问被拒绝,系统不会自动扩大白名单。', + qrUnavailable: '二维码已不可用,请刷新后重新扫码。', + showQR: '显示二维码', + hideQR: '隐藏二维码', + bindingNotAllowed: '未允许扫码绑定', + sameKeyDevices: + '多台设备可共用一个 Key,停用或撤销该 Key 会同时影响这些设备。如需单独管理,请为每台设备创建独立的 Key。', + closePending: '密钥变更已保存,部分远端终端连接尚未关闭,系统将重试。', + }, commons: { true: '是', false: '否', diff --git a/frontend/src/layout/components/Sidebar/components/user-info/index.vue b/frontend/src/layout/components/Sidebar/components/user-info/index.vue index 5b9e54b53..6f0bdb2b7 100644 --- a/frontend/src/layout/components/Sidebar/components/user-info/index.vue +++ b/frontend/src/layout/components/Sidebar/components/user-info/index.vue @@ -138,23 +138,10 @@ -
- - - {{ $t('commons.button.view') }} - -
- {{ $t('setting.apiInterfaceHelper') }} + + {{ $t('apiKeyManagement.manage') }} + + {{ $t('apiKeyManagement.personalEntry') }} @@ -334,55 +321,6 @@ - - - - - - - - - {{ $t('commons.button.reset') }} - - - {{ $t('setting.apiKeyHelper') }} - - - - {{ $t('setting.ipWhiteListHelper') }} - - - - {{ $t('setting.apiTrustedProxiesHelper') }} - - - - - - - {{ $t('setting.apiKeyValidityTimeHelper') }} - - - - - diff --git a/frontend/src/views/setting/index.vue b/frontend/src/views/setting/index.vue index c2f835bbb..8a14d221b 100644 --- a/frontend/src/views/setting/index.vue +++ b/frontend/src/views/setting/index.vue @@ -27,6 +27,10 @@ const buttons = computed(() => { }, ] : []), + { + label: i18n.global.t('apiKeyManagement.title'), + path: '/settings/apikeys', + }, ...(globalStore.hasPermission('alert_view') ? [ { @@ -61,7 +65,7 @@ const buttons = computed(() => { path: isEnterprise.value ? '/enterprise/license' : '/settings/license', }, ]), - ...(isFxplay.value + ...(isFxplay.value || !isAdmin.value ? [] : [ {