diff --git a/common/mac.go b/common/mac.go
new file mode 100644
index 000000000..1e1055e02
--- /dev/null
+++ b/common/mac.go
@@ -0,0 +1,16 @@
+package common
+
+import (
+ "fmt"
+ "net"
+ "strings"
+)
+
+// NormalizeMacAddress validates an EUI-48 address and returns its canonical form.
+func NormalizeMacAddress(value string) (string, error) {
+ hardwareAddress, err := net.ParseMAC(strings.TrimSpace(value))
+ if err != nil || len(hardwareAddress) != 6 {
+ return "", fmt.Errorf("invalid MAC address: %q", value)
+ }
+ return strings.ToLower(hardwareAddress.String()), nil
+}
diff --git a/common/mac_test.go b/common/mac_test.go
new file mode 100644
index 000000000..86e5d7feb
--- /dev/null
+++ b/common/mac_test.go
@@ -0,0 +1,36 @@
+package common
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNormalizeMacAddress(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ valid bool
+ }{
+ {name: "colon separated", input: "94:b6:09:f6:4f:41", expected: "94:b6:09:f6:4f:41", valid: true},
+ {name: "hyphen separated uppercase", input: "94-B6-09-F6-4F-41", expected: "94:b6:09:f6:4f:41", valid: true},
+ {name: "surrounding whitespace", input: " 94:b6:09:f6:4f:41 ", expected: "94:b6:09:f6:4f:41", valid: true},
+ {name: "missing octet", input: "94:b6:09:f6:4f", valid: false},
+ {name: "eui64 is not a client MAC", input: "94:b6:09:f6:4f:41:00:01", valid: false},
+ {name: "empty", input: "", valid: false},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ actual, err := NormalizeMacAddress(test.input)
+ if !test.valid {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, test.expected, actual)
+ })
+ }
+}
diff --git a/controller/token.go b/controller/token.go
index 836e9b295..e65ae0ea0 100644
--- a/controller/token.go
+++ b/controller/token.go
@@ -175,6 +175,10 @@ func AddToken(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return
}
+ if err := token.NormalizeMacLimits(); err != nil {
+ common.ApiError(c, err)
+ return
+ }
// 非无限额度时,检查额度值是否超出有效范围
if !token.UnlimitedQuota {
if token.RemainQuota < 0 {
@@ -219,6 +223,8 @@ func AddToken(c *gin.Context) {
ModelLimitsEnabled: token.ModelLimitsEnabled,
ModelLimits: token.ModelLimits,
AllowIps: token.AllowIps,
+ MacCheckEnabled: token.MacCheckEnabled,
+ AllowMacs: token.AllowMacs,
Group: token.Group,
CrossGroupRetry: token.CrossGroupRetry,
}
@@ -260,6 +266,10 @@ func UpdateToken(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgTokenNameTooLong)
return
}
+ if err := token.NormalizeMacLimits(); err != nil {
+ common.ApiError(c, err)
+ return
+ }
if !token.UnlimitedQuota {
if token.RemainQuota < 0 {
common.ApiErrorI18n(c, i18n.MsgTokenQuotaNegative)
@@ -297,6 +307,8 @@ func UpdateToken(c *gin.Context) {
cleanToken.ModelLimitsEnabled = token.ModelLimitsEnabled
cleanToken.ModelLimits = token.ModelLimits
cleanToken.AllowIps = token.AllowIps
+ cleanToken.MacCheckEnabled = token.MacCheckEnabled
+ cleanToken.AllowMacs = token.AllowMacs
cleanToken.Group = token.Group
cleanToken.CrossGroupRetry = token.CrossGroupRetry
}
diff --git a/controller/token_test.go b/controller/token_test.go
index b9b661524..1d566eaa0 100644
--- a/controller/token_test.go
+++ b/controller/token_test.go
@@ -311,6 +311,11 @@ func runTokenMigrationCompatibilityTest(t *testing.T, db *gorm.DB, dialect strin
}
migrateTokenControllerTestDB(t, db)
+ for _, column := range []string{"mac_check_enabled", "allow_macs"} {
+ if !db.Migrator().HasColumn(&model.Token{}, column) {
+ t.Fatalf("expected migrated token schema to contain %s", column)
+ }
+ }
if got := getTokenKeyColumnType(t, db, dialect); got != "varchar(128)" {
t.Fatalf("expected migrated key column type varchar(128), got %q", got)
@@ -504,6 +509,33 @@ func TestGetTokenMasksKeyInResponse(t *testing.T) {
}
}
+func TestAddTokenPersistsMacValidation(t *testing.T) {
+ db := setupTokenControllerTestDB(t)
+ body := map[string]any{
+ "name": "mac-restricted-token",
+ "expired_time": -1,
+ "unlimited_quota": true,
+ "model_limits_enabled": false,
+ "model_limits": "",
+ "mac_check_enabled": true,
+ "allow_macs": "94-B6-09-F6-4F-41",
+ "group": "default",
+ "cross_group_retry": false,
+ }
+
+ ctx, recorder := newAuthenticatedContext(t, http.MethodPost, "/api/token/", body, 1)
+ AddToken(ctx)
+
+ response := decodeAPIResponse(t, recorder)
+ require.True(t, response.Success, "expected success response, got message: %s", response.Message)
+
+ var created model.Token
+ require.NoError(t, db.First(&created, "user_id = ? AND name = ?", 1, "mac-restricted-token").Error)
+ require.True(t, created.MacCheckEnabled)
+ require.NotNil(t, created.AllowMacs)
+ require.Equal(t, "94:b6:09:f6:4f:41", *created.AllowMacs)
+}
+
func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "editable-token", "yzab1234cdef5678")
@@ -516,6 +548,8 @@ func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
"unlimited_quota": true,
"model_limits_enabled": false,
"model_limits": "",
+ "mac_check_enabled": true,
+ "allow_macs": "94-B6-09-F6-4F-41\n00:11:22:33:44:55",
"group": "default",
"cross_group_retry": false,
}
@@ -538,6 +572,12 @@ func TestUpdateTokenMasksKeyInResponse(t *testing.T) {
if strings.Contains(recorder.Body.String(), token.Key) {
t.Fatalf("update response leaked raw token key: %s", recorder.Body.String())
}
+
+ var updated model.Token
+ require.NoError(t, db.First(&updated, token.Id).Error)
+ require.True(t, updated.MacCheckEnabled)
+ require.NotNil(t, updated.AllowMacs)
+ require.Equal(t, "94:b6:09:f6:4f:41\n00:11:22:33:44:55", *updated.AllowMacs)
}
func TestGetTokenKeyRequiresOwnershipAndReturnsFullKey(t *testing.T) {
diff --git a/middleware/auth.go b/middleware/auth.go
index 86abddc79..4981c6c05 100644
--- a/middleware/auth.go
+++ b/middleware/auth.go
@@ -402,6 +402,21 @@ func TokenAuth() func(c *gin.Context) {
logger.LogDebug(c, "Client IP %s passed the token IP restrictions check", clientIp)
}
+ if token.MacCheckEnabled {
+ clientMac := c.Request.Header.Get("X-Client-Mac")
+ allowed, err := tokenAllowsMac(token, clientMac)
+ if err != nil {
+ common.SysLog(fmt.Sprintf("TokenAuth invalid MAC whitelist configuration for token %d: %v", token.Id, err))
+ abortWithOpenAiMessage(c, http.StatusInternalServerError, "MAC address whitelist configuration is invalid")
+ return
+ }
+ if !allowed {
+ abortWithOpenAiMessage(c, http.StatusForbidden, "Your MAC address is not allowed by this token", types.ErrorCodeAccessDenied)
+ return
+ }
+ logger.LogDebug(c, "Client MAC %s passed the token MAC restrictions check", clientMac)
+ }
+
userCache, err := model.GetUserCache(token.UserId)
if err != nil {
common.SysLog(fmt.Sprintf("TokenAuth GetUserCache error for user %d: %v", token.UserId, err))
@@ -444,6 +459,30 @@ func TokenAuth() func(c *gin.Context) {
}
}
+func tokenAllowsMac(token *model.Token, clientMac string) (bool, error) {
+ if token == nil {
+ return false, fmt.Errorf("token is nil")
+ }
+ if !token.MacCheckEnabled {
+ return true, nil
+ }
+
+ normalizedClientMac, err := common.NormalizeMacAddress(clientMac)
+ if err != nil {
+ return false, nil
+ }
+ for _, allowedMac := range token.GetMacLimits() {
+ normalizedAllowedMac, err := common.NormalizeMacAddress(allowedMac)
+ if err != nil {
+ return false, err
+ }
+ if normalizedClientMac == normalizedAllowedMac {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error {
if token == nil {
return fmt.Errorf("token is nil")
diff --git a/middleware/auth_mac_test.go b/middleware/auth_mac_test.go
new file mode 100644
index 000000000..6f883204e
--- /dev/null
+++ b/middleware/auth_mac_test.go
@@ -0,0 +1,41 @@
+package middleware
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/model"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTokenAllowsMac(t *testing.T) {
+ allowedMacs := "94:b6:09:f6:4f:41\n00:11:22:33:44:55"
+ invalidMacs := "not-a-mac"
+
+ tests := []struct {
+ name string
+ token *model.Token
+ clientMac string
+ allowed bool
+ wantError bool
+ }{
+ {name: "disabled token skips validation", token: &model.Token{}, allowed: true},
+ {name: "enabled token accepts listed MAC", token: &model.Token{MacCheckEnabled: true, AllowMacs: &allowedMacs}, clientMac: "94-B6-09-F6-4F-41", allowed: true},
+ {name: "enabled token rejects missing header", token: &model.Token{MacCheckEnabled: true, AllowMacs: &allowedMacs}},
+ {name: "enabled token rejects malformed header", token: &model.Token{MacCheckEnabled: true, AllowMacs: &allowedMacs}, clientMac: "invalid"},
+ {name: "enabled token rejects unlisted MAC", token: &model.Token{MacCheckEnabled: true, AllowMacs: &allowedMacs}, clientMac: "aa:bb:cc:dd:ee:ff"},
+ {name: "invalid configured MAC fails closed", token: &model.Token{MacCheckEnabled: true, AllowMacs: &invalidMacs}, clientMac: "94:b6:09:f6:4f:41", wantError: true},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ allowed, err := tokenAllowsMac(test.token, test.clientMac)
+ if test.wantError {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ assert.Equal(t, test.allowed, allowed)
+ })
+ }
+}
diff --git a/model/token.go b/model/token.go
index 92f0b61e7..1c2930d59 100644
--- a/model/token.go
+++ b/model/token.go
@@ -25,6 +25,8 @@ type Token struct {
ModelLimitsEnabled bool `json:"model_limits_enabled"`
ModelLimits string `json:"model_limits" gorm:"type:text"`
AllowIps *string `json:"allow_ips" gorm:"default:''"`
+ MacCheckEnabled bool `json:"mac_check_enabled"`
+ AllowMacs *string `json:"allow_macs" gorm:"type:text"`
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota
Group string `json:"group" gorm:"default:''"`
CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效
@@ -78,6 +80,54 @@ func (token *Token) GetIpLimits() []string {
return ipLimits
}
+func (token *Token) GetMacLimits() []string {
+ macLimits := make([]string, 0)
+ if token.AllowMacs == nil {
+ return macLimits
+ }
+ cleanMacs := strings.ReplaceAll(*token.AllowMacs, " ", "")
+ if cleanMacs == "" {
+ return macLimits
+ }
+ for _, mac := range strings.Split(cleanMacs, "\n") {
+ mac = strings.TrimSpace(strings.ReplaceAll(mac, ",", ""))
+ if mac != "" {
+ macLimits = append(macLimits, mac)
+ }
+ }
+ return macLimits
+}
+
+func (token *Token) NormalizeMacLimits() error {
+ macLimits := token.GetMacLimits()
+ if len(macLimits) == 0 {
+ empty := ""
+ token.AllowMacs = &empty
+ if token.MacCheckEnabled {
+ return errors.New("MAC address whitelist is required when MAC validation is enabled")
+ }
+ return nil
+ }
+
+ normalizedMacs := make([]string, 0, len(macLimits))
+ seen := make(map[string]struct{}, len(macLimits))
+ for _, mac := range macLimits {
+ normalizedMac, err := common.NormalizeMacAddress(mac)
+ if err != nil {
+ return err
+ }
+ if _, exists := seen[normalizedMac]; exists {
+ continue
+ }
+ seen[normalizedMac] = struct{}{}
+ normalizedMacs = append(normalizedMacs, normalizedMac)
+ }
+
+ normalized := strings.Join(normalizedMacs, "\n")
+ token.AllowMacs = &normalized
+ return nil
+}
+
func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) {
var tokens []*Token
var err error
@@ -321,7 +371,7 @@ func (token *Token) Update() (err error) {
}
}()
err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota",
- "model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry").Updates(token).Error
+ "model_limits_enabled", "model_limits", "allow_ips", "mac_check_enabled", "allow_macs", "group", "cross_group_retry").Updates(token).Error
return err
}
diff --git a/model/token_mac_test.go b/model/token_mac_test.go
new file mode 100644
index 000000000..c656f0664
--- /dev/null
+++ b/model/token_mac_test.go
@@ -0,0 +1,40 @@
+package model
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNormalizeMacLimits(t *testing.T) {
+ t.Run("enabled token requires whitelist", func(t *testing.T) {
+ token := Token{MacCheckEnabled: true}
+
+ require.Error(t, token.NormalizeMacLimits())
+ require.NotNil(t, token.AllowMacs)
+ assert.Empty(t, *token.AllowMacs)
+ })
+
+ t.Run("normalizes and deduplicates addresses", func(t *testing.T) {
+ allowMacs := "94-B6-09-F6-4F-41\n94:b6:09:f6:4f:41\n00:11:22:33:44:55"
+ token := Token{MacCheckEnabled: true, AllowMacs: &allowMacs}
+
+ require.NoError(t, token.NormalizeMacLimits())
+ require.NotNil(t, token.AllowMacs)
+ assert.Equal(t, "94:b6:09:f6:4f:41\n00:11:22:33:44:55", *token.AllowMacs)
+ })
+
+ t.Run("rejects invalid address", func(t *testing.T) {
+ allowMacs := "not-a-mac"
+ token := Token{AllowMacs: &allowMacs}
+
+ require.Error(t, token.NormalizeMacLimits())
+ })
+
+ t.Run("disabled token accepts empty whitelist", func(t *testing.T) {
+ token := Token{}
+
+ require.NoError(t, token.NormalizeMacLimits())
+ })
+}
diff --git a/web/classic/src/components/table/tokens/modals/EditTokenModal.jsx b/web/classic/src/components/table/tokens/modals/EditTokenModal.jsx
index 9112658bd..400c8973b 100644
--- a/web/classic/src/components/table/tokens/modals/EditTokenModal.jsx
+++ b/web/classic/src/components/table/tokens/modals/EditTokenModal.jsx
@@ -79,6 +79,8 @@ const EditTokenModal = (props) => {
model_limits_enabled: false,
model_limits: [],
allow_ips: '',
+ mac_check_enabled: false,
+ allow_macs: '',
group: '',
cross_group_retry: false,
tokenCount: 1,
@@ -552,7 +554,10 @@ const EditTokenModal = (props) => {
? `▾ ${t('收起原生额度输入')}`
: `▸ ${t('使用原生额度输入')}`}
-
+
{
style={{ width: '100%' }}
/>
+
+
+
+ {values.mac_check_enabled && (
+
+
+
+ )}
diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json
index 363926b25..8b2df21a4 100644
--- a/web/classic/src/i18n/locales/en.json
+++ b/web/classic/src/i18n/locales/en.json
@@ -3830,6 +3830,10 @@
"默认折叠侧边栏": "Default collapse sidebar",
"默认测试模型": "Default Test Model",
"默认用户消息": "Default User Message",
- "默认补全倍率": "Default completion ratio"
+ "默认补全倍率": "Default completion ratio",
+ "MAC Address Validation": "MAC Address Validation",
+ "MAC Address Whitelist": "MAC Address Whitelist",
+ "One MAC address per line": "One MAC address per line",
+ "Require X-Client-Mac to match this API key's whitelist": "Require X-Client-Mac to match this API key's whitelist"
}
}
diff --git a/web/classic/src/i18n/locales/fr.json b/web/classic/src/i18n/locales/fr.json
index 420206ff7..1b913ff7b 100644
--- a/web/classic/src/i18n/locales/fr.json
+++ b/web/classic/src/i18n/locales/fr.json
@@ -3684,6 +3684,10 @@
"默认折叠侧边栏": "Réduire la barre latérale par défaut",
"默认测试模型": "Modèle de test par défaut",
"默认用户消息": "Bonjour",
- "默认补全倍率": "Taux de complétion par défaut"
+ "默认补全倍率": "Taux de complétion par défaut",
+ "MAC Address Validation": "Validation de l'adresse MAC",
+ "MAC Address Whitelist": "Liste blanche d'adresses MAC",
+ "One MAC address per line": "Une adresse MAC par ligne",
+ "Require X-Client-Mac to match this API key's whitelist": "Exiger que X-Client-Mac corresponde à la liste blanche de cette clé API"
}
}
diff --git a/web/classic/src/i18n/locales/ja.json b/web/classic/src/i18n/locales/ja.json
index d071b81de..d3fdc84ff 100644
--- a/web/classic/src/i18n/locales/ja.json
+++ b/web/classic/src/i18n/locales/ja.json
@@ -3653,6 +3653,10 @@
"默认折叠侧边栏": "サイドバーをデフォルトで折りたたむ",
"默认测试模型": "デフォルトテストモデル",
"默认用户消息": "こんにちは",
- "默认补全倍率": "デフォルト補完倍率"
+ "默认补全倍率": "デフォルト補完倍率",
+ "MAC Address Validation": "MACアドレス検証",
+ "MAC Address Whitelist": "MACアドレス許可リスト",
+ "One MAC address per line": "1行に1つのMACアドレス",
+ "Require X-Client-Mac to match this API key's whitelist": "X-Client-Mac がこの API キーの許可リストと一致することを要求します"
}
}
diff --git a/web/classic/src/i18n/locales/ru.json b/web/classic/src/i18n/locales/ru.json
index 63660aa14..d48b93411 100644
--- a/web/classic/src/i18n/locales/ru.json
+++ b/web/classic/src/i18n/locales/ru.json
@@ -3704,6 +3704,10 @@
"默认折叠侧边栏": "Сворачивать боковую панель по умолчанию",
"默认测试模型": "Модель для тестирования по умолчанию",
"默认用户消息": "Здравствуйте",
- "默认补全倍率": "Коэффициент завершения по умолчанию"
+ "默认补全倍率": "Коэффициент завершения по умолчанию",
+ "MAC Address Validation": "Проверка MAC-адреса",
+ "MAC Address Whitelist": "Белый список MAC-адресов",
+ "One MAC address per line": "Один MAC-адрес на строку",
+ "Require X-Client-Mac to match this API key's whitelist": "Требовать соответствия X-Client-Mac белому списку этого API-ключа"
}
}
diff --git a/web/classic/src/i18n/locales/vi.json b/web/classic/src/i18n/locales/vi.json
index 6a8f09ea6..202c6332c 100644
--- a/web/classic/src/i18n/locales/vi.json
+++ b/web/classic/src/i18n/locales/vi.json
@@ -4218,6 +4218,10 @@
"默认折叠侧边栏": "Mặc định thu gọn thanh bên",
"默认测试模型": "Mô hình kiểm tra mặc định",
"默认用户消息": "Xin chào",
- "默认补全倍率": "Tỷ lệ hoàn thành mặc định"
+ "默认补全倍率": "Tỷ lệ hoàn thành mặc định",
+ "MAC Address Validation": "Xác thực địa chỉ MAC",
+ "MAC Address Whitelist": "Danh sách cho phép địa chỉ MAC",
+ "One MAC address per line": "Mỗi dòng một địa chỉ MAC",
+ "Require X-Client-Mac to match this API key's whitelist": "Yêu cầu X-Client-Mac khớp với danh sách cho phép của khóa API này"
}
}
diff --git a/web/classic/src/i18n/locales/zh-CN.json b/web/classic/src/i18n/locales/zh-CN.json
index 906b8503f..2ca69567e 100644
--- a/web/classic/src/i18n/locales/zh-CN.json
+++ b/web/classic/src/i18n/locales/zh-CN.json
@@ -3813,6 +3813,10 @@
"默认折叠侧边栏": "默认折叠侧边栏",
"默认测试模型": "默认测试模型",
"默认用户消息": "你好",
- "默认补全倍率": "默认补全倍率"
+ "默认补全倍率": "默认补全倍率",
+ "MAC Address Validation": "MAC 地址校验",
+ "MAC Address Whitelist": "MAC 地址白名单",
+ "One MAC address per line": "每行填写一个 MAC 地址",
+ "Require X-Client-Mac to match this API key's whitelist": "要求 X-Client-Mac 与此 API 密钥的白名单匹配"
}
}
diff --git a/web/classic/src/i18n/locales/zh-TW.json b/web/classic/src/i18n/locales/zh-TW.json
index d6f20609c..cf4e2961a 100644
--- a/web/classic/src/i18n/locales/zh-TW.json
+++ b/web/classic/src/i18n/locales/zh-TW.json
@@ -3677,6 +3677,10 @@
"默认折叠侧边栏": "預設摺疊側邊欄",
"默认测试模型": "預設測試模型",
"默认用户消息": "你好",
- "默认补全倍率": "預設補全倍率"
+ "默认补全倍率": "預設補全倍率",
+ "MAC Address Validation": "MAC 位址驗證",
+ "MAC Address Whitelist": "MAC 位址白名單",
+ "One MAC address per line": "每行填寫一個 MAC 位址",
+ "Require X-Client-Mac to match this API key's whitelist": "要求 X-Client-Mac 與此 API 金鑰的白名單相符"
}
}
diff --git a/web/classic/src/i18n/locales/zh.json b/web/classic/src/i18n/locales/zh.json
index e8d0c219d..9ee692afd 100644
--- a/web/classic/src/i18n/locales/zh.json
+++ b/web/classic/src/i18n/locales/zh.json
@@ -2630,6 +2630,10 @@
"并确认自行承担部署": "并确认自行承担部署",
"运营和收费行为产生的法律责任": "运营和收费行为产生的法律责任",
",": ",",
- "、": "、"
+ "、": "、",
+ "MAC Address Validation": "MAC 地址校验",
+ "MAC Address Whitelist": "MAC 地址白名单",
+ "One MAC address per line": "每行填写一个 MAC 地址",
+ "Require X-Client-Mac to match this API key's whitelist": "要求 X-Client-Mac 与此 API 密钥的白名单匹配"
}
}
diff --git a/web/default/src/features/keys/components/api-keys-cells.tsx b/web/default/src/features/keys/components/api-keys-cells.tsx
index d72aca893..7c5919610 100644
--- a/web/default/src/features/keys/components/api-keys-cells.tsx
+++ b/web/default/src/features/keys/components/api-keys-cells.tsx
@@ -220,3 +220,45 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
)
}
+
+export function MacRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
+ const { t } = useTranslation()
+ const allowMacs = apiKey.allow_macs?.trim()
+
+ if (!apiKey.mac_check_enabled || !allowMacs) {
+ return (
+
+ )
+ }
+
+ const macs = allowMacs
+ .split('\n')
+ .map((mac) => mac.trim())
+ .filter(Boolean)
+
+ return (
+
+ }>
+
+
+
+
+ {macs.map((mac) => (
+
+ {mac}
+
+ ))}
+
+
+
+ )
+}
diff --git a/web/default/src/features/keys/components/api-keys-columns.tsx b/web/default/src/features/keys/components/api-keys-columns.tsx
index a3584a12d..ad7a8212c 100644
--- a/web/default/src/features/keys/components/api-keys-columns.tsx
+++ b/web/default/src/features/keys/components/api-keys-columns.tsx
@@ -43,6 +43,7 @@ import {
ApiKeyCell,
ModelLimitsCell,
IpRestrictionsCell,
+ MacRestrictionsCell,
} from './api-keys-cells'
import { DataTableRowActions } from './data-table-row-actions'
@@ -259,6 +260,15 @@ export function useApiKeysColumns(now: number): ColumnDef
[] {
size: 160,
meta: { mobileHidden: true },
},
+ {
+ id: 'allow_macs',
+ accessorKey: 'allow_macs',
+ header: t('MAC Restriction'),
+ cell: ({ row }) => ,
+ enableSorting: false,
+ size: 160,
+ meta: { mobileHidden: true },
+ },
{
accessorKey: 'created_time',
header: t('Created'),
diff --git a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx
index 9fa64e6f3..b6bb5e871 100644
--- a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx
+++ b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx
@@ -249,6 +249,7 @@ export function ApiKeysMutateDrawer({
: t('Enter quota in {{currency}}', { currency: currencyLabel })
const selectedGroup = form.watch('group')
const unlimitedQuota = form.watch('unlimited_quota')
+ const macCheckEnabled = form.watch('mac_check_enabled')
return (
)}
/>
+
+ (
+
+
+
+ {t('MAC Address Validation')}
+
+
+ {t(
+ "Require X-Client-Mac to match this API key's whitelist"
+ )}
+
+
+
+
+
+
+ )}
+ />
+
+ {macCheckEnabled && (
+ (
+
+ {t('MAC Address Whitelist')}
+
+
+
+
+
+ )}
+ />
+ )}
diff --git a/web/default/src/features/keys/lib/api-key-form.ts b/web/default/src/features/keys/lib/api-key-form.ts
index 827ce3264..d03fd5994 100644
--- a/web/default/src/features/keys/lib/api-key-form.ts
+++ b/web/default/src/features/keys/lib/api-key-form.ts
@@ -22,7 +22,7 @@ import { z } from 'zod'
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
import { DEFAULT_GROUP } from '../constants'
-import { type ApiKeyFormData, type ApiKey } from '../types'
+import type { ApiKey, ApiKeyFormData } from '../types'
// ============================================================================
// Form Schema
@@ -37,18 +37,17 @@ export function getApiKeyFormSchema(t: TFunction) {
unlimited_quota: z.boolean(),
model_limits: z.array(z.string()),
allow_ips: z.string().optional(),
+ mac_check_enabled: z.boolean(),
+ allow_macs: z.string().optional(),
group: z.string().optional(),
cross_group_retry: z.boolean().optional(),
tokenCount: z.number().min(1).optional(),
})
.superRefine((data, ctx) => {
- if (data.unlimited_quota) {
- return
- }
-
if (
- data.remain_quota_dollars === undefined ||
- data.remain_quota_dollars < 0
+ !data.unlimited_quota &&
+ (data.remain_quota_dollars === undefined ||
+ data.remain_quota_dollars < 0)
) {
ctx.addIssue({
code: 'custom',
@@ -56,6 +55,31 @@ export function getApiKeyFormSchema(t: TFunction) {
message: t('Quota must be zero or greater'),
})
}
+
+ const macAddresses = (data.allow_macs || '')
+ .split('\n')
+ .map((mac) => mac.trim())
+ .filter(Boolean)
+ if (data.mac_check_enabled && macAddresses.length === 0) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['allow_macs'],
+ message: t(
+ 'MAC validation requires at least one allowed MAC address'
+ ),
+ })
+ }
+
+ const validMacPattern =
+ /^([0-9a-f]{2})([:-])(?:[0-9a-f]{2}\2){4}[0-9a-f]{2}$/i
+ const invalidMac = macAddresses.find((mac) => !validMacPattern.test(mac))
+ if (invalidMac) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['allow_macs'],
+ message: t('Enter a valid MAC address on each line'),
+ })
+ }
})
}
@@ -72,6 +96,8 @@ export const API_KEY_FORM_DEFAULT_VALUES: ApiKeyFormValues = {
unlimited_quota: true,
model_limits: [],
allow_ips: '',
+ mac_check_enabled: false,
+ allow_macs: '',
group: DEFAULT_GROUP,
cross_group_retry: true,
tokenCount: 1,
@@ -109,6 +135,8 @@ export function transformFormDataToPayload(
model_limits_enabled: data.model_limits.length > 0,
model_limits: data.model_limits.join(','),
allow_ips: data.allow_ips || '',
+ mac_check_enabled: data.mac_check_enabled,
+ allow_macs: data.allow_macs || '',
group: data.group || '',
cross_group_retry: data.group === 'auto' ? !!data.cross_group_retry : false,
}
@@ -134,6 +162,8 @@ export function transformApiKeyToFormDefaults(
? apiKey.model_limits.split(',').filter(Boolean)
: [],
allow_ips: apiKey.allow_ips || '',
+ mac_check_enabled: apiKey.mac_check_enabled || false,
+ allow_macs: apiKey.allow_macs || '',
group: apiKey.group || DEFAULT_GROUP,
cross_group_retry: !!apiKey.cross_group_retry,
tokenCount: 1,
diff --git a/web/default/src/features/keys/types.ts b/web/default/src/features/keys/types.ts
index 1583e6497..ff4b2ea30 100644
--- a/web/default/src/features/keys/types.ts
+++ b/web/default/src/features/keys/types.ts
@@ -45,6 +45,8 @@ export const apiKeySchema = z.object({
model_limits_enabled: z.boolean(),
model_limits: z.string().nullish().default(''),
allow_ips: z.string().nullish().default(''),
+ mac_check_enabled: z.boolean().optional().default(false),
+ allow_macs: z.string().nullish().default(''),
})
export type ApiKey = z.infer
@@ -90,6 +92,8 @@ export interface ApiKeyFormData {
model_limits_enabled: boolean
model_limits: string
allow_ips: string
+ mac_check_enabled: boolean
+ allow_macs: string
group: string
cross_group_retry: boolean
}
diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json
index 9eba821e3..3fcd34b4f 100644
--- a/web/default/src/i18n/locales/en.json
+++ b/web/default/src/i18n/locales/en.json
@@ -5178,6 +5178,14 @@
"Zero retention": "Zero retention",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V4",
- "Zoom": "Zoom"
+ "Zoom": "Zoom",
+ "MAC Address Validation": "MAC Address Validation",
+ "MAC Address Whitelist": "MAC Address Whitelist",
+ "MAC validation requires at least one allowed MAC address": "MAC validation requires at least one allowed MAC address",
+ "One MAC address per line": "One MAC address per line",
+ "Enter a valid MAC address on each line": "Enter a valid MAC address on each line",
+ "Require X-Client-Mac to match this API key's whitelist": "Require X-Client-Mac to match this API key's whitelist",
+ "MAC Restriction": "MAC Restriction",
+ "{{count}} MAC(s)": "{{count}} MAC(s)"
}
}
diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json
index f4014a48f..8a019097a 100644
--- a/web/default/src/i18n/locales/fr.json
+++ b/web/default/src/i18n/locales/fr.json
@@ -5178,6 +5178,14 @@
"Zero retention": "Aucune rétention",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V4",
- "Zoom": "Zoom"
+ "Zoom": "Zoom",
+ "MAC Address Validation": "Validation de l'adresse MAC",
+ "MAC Address Whitelist": "Liste blanche d'adresses MAC",
+ "MAC validation requires at least one allowed MAC address": "La validation MAC nécessite au moins une adresse MAC autorisée",
+ "One MAC address per line": "Une adresse MAC par ligne",
+ "Enter a valid MAC address on each line": "Saisissez une adresse MAC valide sur chaque ligne",
+ "Require X-Client-Mac to match this API key's whitelist": "Exiger que X-Client-Mac corresponde à la liste blanche de cette clé API",
+ "MAC Restriction": "Restriction MAC",
+ "{{count}} MAC(s)": "{{count}} adresse(s) MAC"
}
}
diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json
index 527f49b9f..227176fa2 100644
--- a/web/default/src/i18n/locales/ja.json
+++ b/web/default/src/i18n/locales/ja.json
@@ -5178,6 +5178,14 @@
"Zero retention": "データ保持なし",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V 4",
- "Zoom": "ズーム"
+ "Zoom": "ズーム",
+ "MAC Address Validation": "MACアドレス検証",
+ "MAC Address Whitelist": "MACアドレス許可リスト",
+ "MAC validation requires at least one allowed MAC address": "MAC検証を有効にするには、許可するMACアドレスを1つ以上指定してください",
+ "One MAC address per line": "1行に1つのMACアドレス",
+ "Enter a valid MAC address on each line": "各行に有効なMACアドレスを入力してください",
+ "Require X-Client-Mac to match this API key's whitelist": "X-Client-Mac がこの API キーの許可リストと一致することを要求します",
+ "MAC Restriction": "MAC 制限",
+ "{{count}} MAC(s)": "{{count}} 個の MAC"
}
}
diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json
index 0c424ad55..f47aa7430 100644
--- a/web/default/src/i18n/locales/ru.json
+++ b/web/default/src/i18n/locales/ru.json
@@ -5178,6 +5178,14 @@
"Zero retention": "Без хранения данных",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V4",
- "Zoom": "Zoom"
+ "Zoom": "Zoom",
+ "MAC Address Validation": "Проверка MAC-адреса",
+ "MAC Address Whitelist": "Белый список MAC-адресов",
+ "MAC validation requires at least one allowed MAC address": "Для проверки MAC требуется хотя бы один разрешенный MAC-адрес",
+ "One MAC address per line": "Один MAC-адрес на строку",
+ "Enter a valid MAC address on each line": "Укажите допустимый MAC-адрес в каждой строке",
+ "Require X-Client-Mac to match this API key's whitelist": "Требовать соответствия X-Client-Mac белому списку этого API-ключа",
+ "MAC Restriction": "Ограничение MAC",
+ "{{count}} MAC(s)": "MAC-адресов: {{count}}"
}
}
diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json
index 17fc695a8..f944977a3 100644
--- a/web/default/src/i18n/locales/vi.json
+++ b/web/default/src/i18n/locales/vi.json
@@ -5178,6 +5178,14 @@
"Zero retention": "Không lưu dữ liệu",
"Zhipu": "Zhipu",
"Zhipu V4": "Zhipu V4",
- "Zoom": "Zoom"
+ "Zoom": "Zoom",
+ "MAC Address Validation": "Xác thực địa chỉ MAC",
+ "MAC Address Whitelist": "Danh sách cho phép địa chỉ MAC",
+ "MAC validation requires at least one allowed MAC address": "Xác thực MAC yêu cầu ít nhất một địa chỉ MAC được phép",
+ "One MAC address per line": "Mỗi dòng một địa chỉ MAC",
+ "Enter a valid MAC address on each line": "Nhập một địa chỉ MAC hợp lệ trên mỗi dòng",
+ "Require X-Client-Mac to match this API key's whitelist": "Yêu cầu X-Client-Mac khớp với danh sách cho phép của khóa API này",
+ "MAC Restriction": "Giới hạn MAC",
+ "{{count}} MAC(s)": "{{count}} địa chỉ MAC"
}
}
diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json
index 279e4e6e7..a739d5f27 100644
--- a/web/default/src/i18n/locales/zh-TW.json
+++ b/web/default/src/i18n/locales/zh-TW.json
@@ -5178,6 +5178,14 @@
"Zero retention": "零數據保留",
"Zhipu": "智譜",
"Zhipu V4": "智譜 V4",
- "Zoom": "縮放"
+ "Zoom": "縮放",
+ "MAC Address Validation": "MAC 位址驗證",
+ "MAC Address Whitelist": "MAC 位址白名單",
+ "MAC validation requires at least one allowed MAC address": "啟用 MAC 驗證時至少需要填寫一個允許的 MAC 位址",
+ "One MAC address per line": "每行填寫一個 MAC 位址",
+ "Enter a valid MAC address on each line": "請確保每行都是有效的 MAC 位址",
+ "Require X-Client-Mac to match this API key's whitelist": "要求 X-Client-Mac 與此 API 金鑰的白名單相符",
+ "MAC Restriction": "MAC 限制",
+ "{{count}} MAC(s)": "{{count}} 個 MAC"
}
}
diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json
index 45368da4d..44aac7091 100644
--- a/web/default/src/i18n/locales/zh.json
+++ b/web/default/src/i18n/locales/zh.json
@@ -5178,6 +5178,14 @@
"Zero retention": "零数据保留",
"Zhipu": "智谱",
"Zhipu V4": "智谱 V4",
- "Zoom": "缩放"
+ "Zoom": "缩放",
+ "MAC Address Validation": "MAC 地址校验",
+ "MAC Address Whitelist": "MAC 地址白名单",
+ "MAC validation requires at least one allowed MAC address": "启用 MAC 校验时至少需要填写一个允许的 MAC 地址",
+ "One MAC address per line": "每行填写一个 MAC 地址",
+ "Enter a valid MAC address on each line": "请确保每行都是有效的 MAC 地址",
+ "Require X-Client-Mac to match this API key's whitelist": "要求 X-Client-Mac 与此 API 密钥的白名单匹配",
+ "MAC Restriction": "MAC 限制",
+ "{{count}} MAC(s)": "{{count}} 个 MAC"
}
}