3 Achegas 9cf35234a5 ... 31845fa8f6

Autor SHA1 Mensaxe Data
  MHSanaei 31845fa8f6 refactor(frontend): tighten HttpUtil generics from any to unknown hai 12 horas
  MHSanaei 7bd281d26d feat(codegen): Go-first tool emitting Zod schemas and TS types hai 12 horas
  MHSanaei 7fda988fb2 feat(backend): gate request bodies with go-playground/validator hai 12 horas

+ 8 - 8
database/model/model.go

@@ -53,14 +53,14 @@ type Inbound struct {
 	Remark               string               `json:"remark" form:"remark"`                                                                            // Human-readable remark
 	Enable               bool                 `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"`                           // Whether the inbound is enabled
 	ExpiryTime           int64                `json:"expiryTime" form:"expiryTime"`                                                                    // Expiration timestamp
-	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule
+	TrafficReset         string               `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
 	LastTrafficResetTime int64                `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"`                               // Last traffic reset timestamp
 	ClientStats          []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`                        // Client traffic statistics
 
 	// Xray configuration fields
 	Listen         string   `json:"listen" form:"listen"`
-	Port           int      `json:"port" form:"port"`
-	Protocol       Protocol `json:"protocol" form:"protocol"`
+	Port           int      `json:"port" form:"port" validate:"gte=1,lte=65535"`
+	Protocol       Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria hysteria2 http mixed tunnel"`
 	Settings       string   `json:"settings" form:"settings"`
 	StreamSettings string   `json:"streamSettings" form:"streamSettings"`
 	Tag            string   `json:"tag" form:"tag" gorm:"unique"`
@@ -247,13 +247,13 @@ type Setting struct {
 // status fields below.
 type Node struct {
 	Id                  int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
-	Name                string `json:"name" form:"name" gorm:"uniqueIndex"`
+	Name                string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"`
 	Remark              string `json:"remark" form:"remark"`
-	Scheme              string `json:"scheme" form:"scheme"`
-	Address             string `json:"address" form:"address"`
-	Port                int    `json:"port" form:"port"`
+	Scheme              string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
+	Address             string `json:"address" form:"address" validate:"required"`
+	Port                int    `json:"port" form:"port" validate:"gte=1,lte=65535"`
 	BasePath            string `json:"basePath" form:"basePath"`
-	ApiToken            string `json:"apiToken" form:"apiToken"`
+	ApiToken            string `json:"apiToken" form:"apiToken" validate:"required"`
 	Enable              bool   `json:"enable" form:"enable" gorm:"default:true"`
 	AllowPrivateAddress bool   `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
 

+ 2 - 1
frontend/package.json

@@ -14,7 +14,8 @@
     "preview": "vite preview",
     "lint": "eslint src",
     "typecheck": "tsc --noEmit",
-    "gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs"
+    "gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
+    "gen:zod": "cd .. && go run ./tools/openapigen"
   },
   "dependencies": {
     "@ant-design/icons": "^6.2.3",

+ 359 - 0
frontend/src/generated/types.ts

@@ -0,0 +1,359 @@
+// Code generated by tools/openapigen. DO NOT EDIT.
+export type Protocol = string;
+
+export interface AllSetting {
+  datepicker: string;
+  expireDiff: number;
+  externalTrafficInformEnable: boolean;
+  externalTrafficInformURI: string;
+  ldapAutoCreate: boolean;
+  ldapAutoDelete: boolean;
+  ldapBaseDN: string;
+  ldapBindDN: string;
+  ldapDefaultExpiryDays: number;
+  ldapDefaultLimitIP: number;
+  ldapDefaultTotalGB: number;
+  ldapEnable: boolean;
+  ldapFlagField: string;
+  ldapHost: string;
+  ldapInboundTags: string;
+  ldapInvertFlag: boolean;
+  ldapPassword: string;
+  ldapPort: number;
+  ldapSyncCron: string;
+  ldapTruthyValues: string;
+  ldapUseTLS: boolean;
+  ldapUserAttr: string;
+  ldapUserFilter: string;
+  ldapVlessField: string;
+  pageSize: number;
+  remarkModel: string;
+  restartXrayOnClientDisable: boolean;
+  sessionMaxAge: number;
+  subAnnounce: string;
+  subCertFile: string;
+  subClashEnable: boolean;
+  subClashPath: string;
+  subClashURI: string;
+  subDomain: string;
+  subEmailInRemark: boolean;
+  subEnable: boolean;
+  subEnableRouting: boolean;
+  subEncrypt: boolean;
+  subJsonEnable: boolean;
+  subJsonFragment: string;
+  subJsonMux: string;
+  subJsonNoises: string;
+  subJsonPath: string;
+  subJsonRules: string;
+  subJsonURI: string;
+  subKeyFile: string;
+  subListen: string;
+  subPath: string;
+  subPort: number;
+  subProfileUrl: string;
+  subRoutingRules: string;
+  subShowInfo: boolean;
+  subSupportUrl: string;
+  subTitle: string;
+  subURI: string;
+  subUpdates: number;
+  tgBotAPIServer: string;
+  tgBotBackup: boolean;
+  tgBotChatId: string;
+  tgBotEnable: boolean;
+  tgBotLoginNotify: boolean;
+  tgBotProxy: string;
+  tgBotToken: string;
+  tgCpu: number;
+  tgLang: string;
+  tgRunTime: string;
+  timeLocation: string;
+  trafficDiff: number;
+  trustedProxyCIDRs: string;
+  twoFactorEnable: boolean;
+  twoFactorToken: string;
+  webBasePath: string;
+  webCertFile: string;
+  webDomain: string;
+  webKeyFile: string;
+  webListen: string;
+  webPort: number;
+}
+
+export interface AllSettingView {
+  datepicker: string;
+  expireDiff: number;
+  externalTrafficInformEnable: boolean;
+  externalTrafficInformURI: string;
+  hasApiToken: boolean;
+  hasLdapPassword: boolean;
+  hasNordSecret: boolean;
+  hasTgBotToken: boolean;
+  hasTwoFactorToken: boolean;
+  hasWarpSecret: boolean;
+  ldapAutoCreate: boolean;
+  ldapAutoDelete: boolean;
+  ldapBaseDN: string;
+  ldapBindDN: string;
+  ldapDefaultExpiryDays: number;
+  ldapDefaultLimitIP: number;
+  ldapDefaultTotalGB: number;
+  ldapEnable: boolean;
+  ldapFlagField: string;
+  ldapHost: string;
+  ldapInboundTags: string;
+  ldapInvertFlag: boolean;
+  ldapPassword: string;
+  ldapPort: number;
+  ldapSyncCron: string;
+  ldapTruthyValues: string;
+  ldapUseTLS: boolean;
+  ldapUserAttr: string;
+  ldapUserFilter: string;
+  ldapVlessField: string;
+  pageSize: number;
+  remarkModel: string;
+  restartXrayOnClientDisable: boolean;
+  sessionMaxAge: number;
+  subAnnounce: string;
+  subCertFile: string;
+  subClashEnable: boolean;
+  subClashPath: string;
+  subClashURI: string;
+  subDomain: string;
+  subEmailInRemark: boolean;
+  subEnable: boolean;
+  subEnableRouting: boolean;
+  subEncrypt: boolean;
+  subJsonEnable: boolean;
+  subJsonFragment: string;
+  subJsonMux: string;
+  subJsonNoises: string;
+  subJsonPath: string;
+  subJsonRules: string;
+  subJsonURI: string;
+  subKeyFile: string;
+  subListen: string;
+  subPath: string;
+  subPort: number;
+  subProfileUrl: string;
+  subRoutingRules: string;
+  subShowInfo: boolean;
+  subSupportUrl: string;
+  subTitle: string;
+  subURI: string;
+  subUpdates: number;
+  tgBotAPIServer: string;
+  tgBotBackup: boolean;
+  tgBotChatId: string;
+  tgBotEnable: boolean;
+  tgBotLoginNotify: boolean;
+  tgBotProxy: string;
+  tgBotToken: string;
+  tgCpu: number;
+  tgLang: string;
+  tgRunTime: string;
+  timeLocation: string;
+  trafficDiff: number;
+  trustedProxyCIDRs: string;
+  twoFactorEnable: boolean;
+  twoFactorToken: string;
+  webBasePath: string;
+  webCertFile: string;
+  webDomain: string;
+  webKeyFile: string;
+  webListen: string;
+  webPort: number;
+}
+
+export interface ApiToken {
+  createdAt: number;
+  enabled: boolean;
+  id: number;
+  name: string;
+  token: string;
+}
+
+export interface Client {
+  auth?: string;
+  comment: string;
+  created_at?: number;
+  email: string;
+  enable: boolean;
+  expiryTime: number;
+  flow?: string;
+  id?: string;
+  limitIp: number;
+  password?: string;
+  reset: number;
+  reverse?: ClientReverse | null;
+  security: string;
+  subId: string;
+  tgId: number;
+  totalGB: number;
+  updated_at?: number;
+}
+
+export interface ClientInbound {
+  clientId: number;
+  createdAt: number;
+  flowOverride: string;
+  inboundId: number;
+}
+
+export interface ClientRecord {
+  auth: string;
+  comment: string;
+  createdAt: number;
+  email: string;
+  enable: boolean;
+  expiryTime: number;
+  flow: string;
+  id: number;
+  limitIp: number;
+  password: string;
+  reset: number;
+  reverse: unknown;
+  security: string;
+  subId: string;
+  tgId: number;
+  totalGB: number;
+  updatedAt: number;
+  uuid: string;
+}
+
+export interface ClientReverse {
+  tag: string;
+}
+
+export interface ClientTraffic {
+  down: number;
+  email: string;
+  enable: boolean;
+  expiryTime: number;
+  id: number;
+  inboundId: number;
+  lastOnline: number;
+  reset: number;
+  subId: string;
+  total: number;
+  up: number;
+  uuid: string;
+}
+
+export interface CustomGeoResource {
+  alias: string;
+  createdAt: number;
+  id: number;
+  lastModified: string;
+  lastUpdatedAt: number;
+  localPath: string;
+  type: string;
+  updatedAt: number;
+  url: string;
+}
+
+export interface FallbackParentInfo {
+  masterId: number;
+  path?: string;
+}
+
+export interface HistoryOfSeeders {
+  id: number;
+  seederName: string;
+}
+
+export interface Inbound {
+  clientStats: ClientTraffic[];
+  down: number;
+  enable: boolean;
+  expiryTime: number;
+  fallbackParent?: FallbackParentInfo | null;
+  id: number;
+  lastTrafficResetTime: number;
+  listen: string;
+  nodeId?: number | null;
+  port: number;
+  protocol: Protocol;
+  remark: string;
+  settings: unknown;
+  sniffing: unknown;
+  streamSettings: unknown;
+  tag: string;
+  total: number;
+  trafficReset: string;
+  up: number;
+}
+
+export interface InboundClientIps {
+  clientEmail: string;
+  id: number;
+  ips: unknown;
+}
+
+export interface InboundFallback {
+  alpn: string;
+  childId: number;
+  id: number;
+  masterId: number;
+  name: string;
+  path: string;
+  sortOrder: number;
+  xver: number;
+}
+
+export interface Msg {
+  msg: string;
+  obj: unknown;
+  success: boolean;
+}
+
+export interface Node {
+  address: string;
+  allowPrivateAddress: boolean;
+  apiToken: string;
+  basePath: string;
+  clientCount: number;
+  cpuPct: number;
+  createdAt: number;
+  depletedCount: number;
+  enable: boolean;
+  id: number;
+  inboundCount: number;
+  lastError: string;
+  lastHeartbeat: number;
+  latencyMs: number;
+  memPct: number;
+  name: string;
+  onlineCount: number;
+  panelVersion: string;
+  port: number;
+  remark: string;
+  scheme: string;
+  status: string;
+  updatedAt: number;
+  uptimeSecs: number;
+  xrayVersion: string;
+}
+
+export interface OutboundTraffics {
+  down: number;
+  id: number;
+  tag: string;
+  total: number;
+  up: number;
+}
+
+export interface Setting {
+  id: number;
+  key: string;
+  value: string;
+}
+
+export interface User {
+  id: number;
+  password: string;
+  username: string;
+}
+

+ 380 - 0
frontend/src/generated/zod.ts

@@ -0,0 +1,380 @@
+// Code generated by tools/openapigen. DO NOT EDIT.
+import { z } from 'zod';
+export const ProtocolSchema = z.string();
+export type Protocol = z.infer<typeof ProtocolSchema>;
+
+export const AllSettingSchema = z.object({
+  datepicker: z.string(),
+  expireDiff: z.number().int().min(0),
+  externalTrafficInformEnable: z.boolean(),
+  externalTrafficInformURI: z.string(),
+  ldapAutoCreate: z.boolean(),
+  ldapAutoDelete: z.boolean(),
+  ldapBaseDN: z.string(),
+  ldapBindDN: z.string(),
+  ldapDefaultExpiryDays: z.number().int().min(0),
+  ldapDefaultLimitIP: z.number().int().min(0),
+  ldapDefaultTotalGB: z.number().int().min(0),
+  ldapEnable: z.boolean(),
+  ldapFlagField: z.string(),
+  ldapHost: z.string(),
+  ldapInboundTags: z.string(),
+  ldapInvertFlag: z.boolean(),
+  ldapPassword: z.string(),
+  ldapPort: z.number().int().min(0).max(65535),
+  ldapSyncCron: z.string(),
+  ldapTruthyValues: z.string(),
+  ldapUseTLS: z.boolean(),
+  ldapUserAttr: z.string(),
+  ldapUserFilter: z.string(),
+  ldapVlessField: z.string(),
+  pageSize: z.number().int().min(1).max(1000),
+  remarkModel: z.string(),
+  restartXrayOnClientDisable: z.boolean(),
+  sessionMaxAge: z.number().int().min(0).max(525600),
+  subAnnounce: z.string(),
+  subCertFile: z.string(),
+  subClashEnable: z.boolean(),
+  subClashPath: z.string(),
+  subClashURI: z.string(),
+  subDomain: z.string(),
+  subEmailInRemark: z.boolean(),
+  subEnable: z.boolean(),
+  subEnableRouting: z.boolean(),
+  subEncrypt: z.boolean(),
+  subJsonEnable: z.boolean(),
+  subJsonFragment: z.string(),
+  subJsonMux: z.string(),
+  subJsonNoises: z.string(),
+  subJsonPath: z.string(),
+  subJsonRules: z.string(),
+  subJsonURI: z.string(),
+  subKeyFile: z.string(),
+  subListen: z.string(),
+  subPath: z.string(),
+  subPort: z.number().int().min(1).max(65535),
+  subProfileUrl: z.string(),
+  subRoutingRules: z.string(),
+  subShowInfo: z.boolean(),
+  subSupportUrl: z.string(),
+  subTitle: z.string(),
+  subURI: z.string(),
+  subUpdates: z.number().int().min(0).max(525600),
+  tgBotAPIServer: z.string(),
+  tgBotBackup: z.boolean(),
+  tgBotChatId: z.string(),
+  tgBotEnable: z.boolean(),
+  tgBotLoginNotify: z.boolean(),
+  tgBotProxy: z.string(),
+  tgBotToken: z.string(),
+  tgCpu: z.number().int().min(0).max(100),
+  tgLang: z.string(),
+  tgRunTime: z.string(),
+  timeLocation: z.string(),
+  trafficDiff: z.number().int().min(0).max(100),
+  trustedProxyCIDRs: z.string(),
+  twoFactorEnable: z.boolean(),
+  twoFactorToken: z.string(),
+  webBasePath: z.string(),
+  webCertFile: z.string(),
+  webDomain: z.string(),
+  webKeyFile: z.string(),
+  webListen: z.string(),
+  webPort: z.number().int().min(1).max(65535),
+});
+export type AllSetting = z.infer<typeof AllSettingSchema>;
+
+export const AllSettingViewSchema = z.object({
+  datepicker: z.string(),
+  expireDiff: z.number().int().min(0),
+  externalTrafficInformEnable: z.boolean(),
+  externalTrafficInformURI: z.string(),
+  hasApiToken: z.boolean(),
+  hasLdapPassword: z.boolean(),
+  hasNordSecret: z.boolean(),
+  hasTgBotToken: z.boolean(),
+  hasTwoFactorToken: z.boolean(),
+  hasWarpSecret: z.boolean(),
+  ldapAutoCreate: z.boolean(),
+  ldapAutoDelete: z.boolean(),
+  ldapBaseDN: z.string(),
+  ldapBindDN: z.string(),
+  ldapDefaultExpiryDays: z.number().int().min(0),
+  ldapDefaultLimitIP: z.number().int().min(0),
+  ldapDefaultTotalGB: z.number().int().min(0),
+  ldapEnable: z.boolean(),
+  ldapFlagField: z.string(),
+  ldapHost: z.string(),
+  ldapInboundTags: z.string(),
+  ldapInvertFlag: z.boolean(),
+  ldapPassword: z.string(),
+  ldapPort: z.number().int().min(0).max(65535),
+  ldapSyncCron: z.string(),
+  ldapTruthyValues: z.string(),
+  ldapUseTLS: z.boolean(),
+  ldapUserAttr: z.string(),
+  ldapUserFilter: z.string(),
+  ldapVlessField: z.string(),
+  pageSize: z.number().int().min(1).max(1000),
+  remarkModel: z.string(),
+  restartXrayOnClientDisable: z.boolean(),
+  sessionMaxAge: z.number().int().min(0).max(525600),
+  subAnnounce: z.string(),
+  subCertFile: z.string(),
+  subClashEnable: z.boolean(),
+  subClashPath: z.string(),
+  subClashURI: z.string(),
+  subDomain: z.string(),
+  subEmailInRemark: z.boolean(),
+  subEnable: z.boolean(),
+  subEnableRouting: z.boolean(),
+  subEncrypt: z.boolean(),
+  subJsonEnable: z.boolean(),
+  subJsonFragment: z.string(),
+  subJsonMux: z.string(),
+  subJsonNoises: z.string(),
+  subJsonPath: z.string(),
+  subJsonRules: z.string(),
+  subJsonURI: z.string(),
+  subKeyFile: z.string(),
+  subListen: z.string(),
+  subPath: z.string(),
+  subPort: z.number().int().min(1).max(65535),
+  subProfileUrl: z.string(),
+  subRoutingRules: z.string(),
+  subShowInfo: z.boolean(),
+  subSupportUrl: z.string(),
+  subTitle: z.string(),
+  subURI: z.string(),
+  subUpdates: z.number().int().min(0).max(525600),
+  tgBotAPIServer: z.string(),
+  tgBotBackup: z.boolean(),
+  tgBotChatId: z.string(),
+  tgBotEnable: z.boolean(),
+  tgBotLoginNotify: z.boolean(),
+  tgBotProxy: z.string(),
+  tgBotToken: z.string(),
+  tgCpu: z.number().int().min(0).max(100),
+  tgLang: z.string(),
+  tgRunTime: z.string(),
+  timeLocation: z.string(),
+  trafficDiff: z.number().int().min(0).max(100),
+  trustedProxyCIDRs: z.string(),
+  twoFactorEnable: z.boolean(),
+  twoFactorToken: z.string(),
+  webBasePath: z.string(),
+  webCertFile: z.string(),
+  webDomain: z.string(),
+  webKeyFile: z.string(),
+  webListen: z.string(),
+  webPort: z.number().int().min(1).max(65535),
+});
+export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
+
+export const ApiTokenSchema = z.object({
+  createdAt: z.number().int(),
+  enabled: z.boolean(),
+  id: z.number().int(),
+  name: z.string(),
+  token: z.string(),
+});
+export type ApiToken = z.infer<typeof ApiTokenSchema>;
+
+export const ClientSchema = z.object({
+  auth: z.string().optional(),
+  comment: z.string(),
+  created_at: z.number().int().optional(),
+  email: z.string(),
+  enable: z.boolean(),
+  expiryTime: z.number().int(),
+  flow: z.string().optional(),
+  id: z.string().optional(),
+  limitIp: z.number().int(),
+  password: z.string().optional(),
+  reset: z.number().int(),
+  reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
+  security: z.string(),
+  subId: z.string(),
+  tgId: z.number().int(),
+  totalGB: z.number().int(),
+  updated_at: z.number().int().optional(),
+});
+export type Client = z.infer<typeof ClientSchema>;
+
+export const ClientInboundSchema = z.object({
+  clientId: z.number().int(),
+  createdAt: z.number().int(),
+  flowOverride: z.string(),
+  inboundId: z.number().int(),
+});
+export type ClientInbound = z.infer<typeof ClientInboundSchema>;
+
+export const ClientRecordSchema = z.object({
+  auth: z.string(),
+  comment: z.string(),
+  createdAt: z.number().int(),
+  email: z.string(),
+  enable: z.boolean(),
+  expiryTime: z.number().int(),
+  flow: z.string(),
+  id: z.number().int(),
+  limitIp: z.number().int(),
+  password: z.string(),
+  reset: z.number().int(),
+  reverse: z.unknown(),
+  security: z.string(),
+  subId: z.string(),
+  tgId: z.number().int(),
+  totalGB: z.number().int(),
+  updatedAt: z.number().int(),
+  uuid: z.string(),
+});
+export type ClientRecord = z.infer<typeof ClientRecordSchema>;
+
+export const ClientReverseSchema = z.object({
+  tag: z.string(),
+});
+export type ClientReverse = z.infer<typeof ClientReverseSchema>;
+
+export const ClientTrafficSchema = z.object({
+  down: z.number().int(),
+  email: z.string(),
+  enable: z.boolean(),
+  expiryTime: z.number().int(),
+  id: z.number().int(),
+  inboundId: z.number().int(),
+  lastOnline: z.number().int(),
+  reset: z.number().int(),
+  subId: z.string(),
+  total: z.number().int(),
+  up: z.number().int(),
+  uuid: z.string(),
+});
+export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
+
+export const CustomGeoResourceSchema = z.object({
+  alias: z.string(),
+  createdAt: z.number().int(),
+  id: z.number().int(),
+  lastModified: z.string(),
+  lastUpdatedAt: z.number().int(),
+  localPath: z.string(),
+  type: z.string(),
+  updatedAt: z.number().int(),
+  url: z.string(),
+});
+export type CustomGeoResource = z.infer<typeof CustomGeoResourceSchema>;
+
+export const FallbackParentInfoSchema = z.object({
+  masterId: z.number().int(),
+  path: z.string().optional(),
+});
+export type FallbackParentInfo = z.infer<typeof FallbackParentInfoSchema>;
+
+export const HistoryOfSeedersSchema = z.object({
+  id: z.number().int(),
+  seederName: z.string(),
+});
+export type HistoryOfSeeders = z.infer<typeof HistoryOfSeedersSchema>;
+
+export const InboundSchema = z.object({
+  clientStats: z.array(z.lazy(() => ClientTrafficSchema)),
+  down: z.number().int(),
+  enable: z.boolean(),
+  expiryTime: z.number().int(),
+  fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
+  id: z.number().int(),
+  lastTrafficResetTime: z.number().int(),
+  listen: z.string(),
+  nodeId: z.number().int().nullable().optional(),
+  port: z.number().int().min(1).max(65535),
+  protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'hysteria2', 'http', 'mixed', 'tunnel']),
+  remark: z.string(),
+  settings: z.unknown(),
+  sniffing: z.unknown(),
+  streamSettings: z.unknown(),
+  tag: z.string(),
+  total: z.number().int(),
+  trafficReset: z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']),
+  up: z.number().int(),
+});
+export type Inbound = z.infer<typeof InboundSchema>;
+
+export const InboundClientIpsSchema = z.object({
+  clientEmail: z.string(),
+  id: z.number().int(),
+  ips: z.unknown(),
+});
+export type InboundClientIps = z.infer<typeof InboundClientIpsSchema>;
+
+export const InboundFallbackSchema = z.object({
+  alpn: z.string(),
+  childId: z.number().int(),
+  id: z.number().int(),
+  masterId: z.number().int(),
+  name: z.string(),
+  path: z.string(),
+  sortOrder: z.number().int(),
+  xver: z.number().int(),
+});
+export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
+
+export const MsgSchema = z.object({
+  msg: z.string(),
+  obj: z.unknown(),
+  success: z.boolean(),
+});
+export type Msg = z.infer<typeof MsgSchema>;
+
+export const NodeSchema = z.object({
+  address: z.string(),
+  allowPrivateAddress: z.boolean(),
+  apiToken: z.string(),
+  basePath: z.string(),
+  clientCount: z.number().int(),
+  cpuPct: z.number(),
+  createdAt: z.number().int(),
+  depletedCount: z.number().int(),
+  enable: z.boolean(),
+  id: z.number().int(),
+  inboundCount: z.number().int(),
+  lastError: z.string(),
+  lastHeartbeat: z.number().int(),
+  latencyMs: z.number().int(),
+  memPct: z.number(),
+  name: z.string(),
+  onlineCount: z.number().int(),
+  panelVersion: z.string(),
+  port: z.number().int().min(1).max(65535),
+  remark: z.string(),
+  scheme: z.enum(['http', 'https']),
+  status: z.string(),
+  updatedAt: z.number().int(),
+  uptimeSecs: z.number().int(),
+  xrayVersion: z.string(),
+});
+export type Node = z.infer<typeof NodeSchema>;
+
+export const OutboundTrafficsSchema = z.object({
+  down: z.number().int(),
+  id: z.number().int(),
+  tag: z.string(),
+  total: z.number().int(),
+  up: z.number().int(),
+});
+export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
+
+export const SettingSchema = z.object({
+  id: z.number().int(),
+  key: z.string(),
+  value: z.string(),
+});
+export type Setting = z.infer<typeof SettingSchema>;
+
+export const UserSchema = z.object({
+  id: z.number().int(),
+  password: z.string(),
+  username: z.string(),
+});
+export type User = z.infer<typeof UserSchema>;
+

+ 1 - 1
frontend/src/pages/index/CustomGeoSection.tsx

@@ -116,7 +116,7 @@ export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
   async function updateAll() {
     setUpdatingAll(true);
     try {
-      const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
+      const msg = await HttpUtil.post<{ succeeded?: unknown[]; failed?: unknown[] }>('/panel/api/custom-geo/update-all');
       const ok = msg?.obj?.succeeded?.length || 0;
       const failed = msg?.obj?.failed?.length || 0;
       if (msg?.success || ok > 0) {

+ 2 - 2
frontend/src/pages/index/IndexPage.tsx

@@ -86,10 +86,10 @@ export default function IndexPage() {
   const [loadingTip, setLoadingTip] = useState(t('loading'));
 
   useEffect(() => {
-    HttpUtil.post('/panel/setting/defaultSettings').then((msg) => {
+    HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/setting/defaultSettings').then((msg) => {
       if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
     });
-    HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => {
+    HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
       if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
     });
   }, []);

+ 1 - 1
frontend/src/pages/index/LogModal.tsx

@@ -69,7 +69,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
   const refresh = useCallback(async () => {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post(`/panel/api/server/logs/${rows}`, {
+      const msg = await HttpUtil.post<string[]>(`/panel/api/server/logs/${rows}`, {
         level,
         syslog,
       });

+ 1 - 1
frontend/src/pages/index/VersionModal.tsx

@@ -39,7 +39,7 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
   const fetchVersions = useCallback(async () => {
     setLoading(true);
     try {
-      const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
+      const msg = await HttpUtil.get<string[]>('/panel/api/server/getXrayVersion');
       if (msg?.success) setVersions(msg.obj || []);
     } finally {
       setLoading(false);

+ 1 - 1
frontend/src/pages/index/XrayLogModal.tsx

@@ -62,7 +62,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
   const refresh = useCallback(async () => {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post(`/panel/api/server/xraylogs/${rows}`, {
+      const msg = await HttpUtil.post<XrayLogEntry[]>(`/panel/api/server/xraylogs/${rows}`, {
         filter,
         showDirect,
         showBlocked,

+ 10 - 9
frontend/src/pages/index/XrayMetricsModal.tsx

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Modal, Select, Tabs, Tag } from 'antd';
 
-import { HttpUtil, SizeFormatter } from '@/utils';
+import { HttpUtil, Msg, SizeFormatter } from '@/utils';
 import Sparkline from '@/components/Sparkline';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import './XrayMetricsModal.css';
@@ -90,7 +90,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
 
   const activeObsTag = obsTags.find((tg) => tg.tag === obsActiveTag) || null;
 
-  const applyHistory = useCallback((msg: { success?: boolean; obj?: { t: number; v: number }[] }, currentBucket: number) => {
+  const applyHistory = useCallback((msg: Msg<{ t: number; v: number }[]> | null | undefined, currentBucket: number) => {
     if (msg?.success && Array.isArray(msg.obj)) {
       const vals: number[] = [];
       const labs: string[] = [];
@@ -112,7 +112,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
 
   const fetchState = useCallback(async () => {
     try {
-      const msg = await HttpUtil.get('/panel/api/server/xrayMetricsState');
+      const msg = await HttpUtil.get<XrayState>('/panel/api/server/xrayMetricsState');
       if (msg?.success && msg.obj) setState(msg.obj);
     } catch (e) {
       console.error('Failed to fetch xray metrics state', e);
@@ -121,12 +121,13 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
 
   const fetchObservatory = useCallback(async () => {
     try {
-      const msg = await HttpUtil.get('/panel/api/server/xrayObservatory');
+      const msg = await HttpUtil.get<ObservatoryTag[]>('/panel/api/server/xrayObservatory');
       if (msg?.success && Array.isArray(msg.obj)) {
-        setObsTags(msg.obj);
+        const tags = msg.obj;
+        setObsTags(tags);
         setObsActiveTag((prev) => {
-          if (msg.obj.find((tg: ObservatoryTag) => tg.tag === prev)) return prev;
-          return msg.obj[0]?.tag || '';
+          if (tags.find((tg) => tg.tag === prev)) return prev;
+          return tags[0]?.tag || '';
         });
       } else {
         setObsTags([]);
@@ -141,7 +142,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
     if (!activeMetric) return;
     try {
       const url = `/panel/api/server/xrayMetricsHistory/${activeMetric.key}/${bucket}`;
-      const msg = await HttpUtil.get(url);
+      const msg = await HttpUtil.get<{ t: number; v: number }[]>(url);
       applyHistory(msg, bucket);
     } catch (e) {
       console.error('Failed to fetch xray metrics bucket', e);
@@ -158,7 +159,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
     }
     try {
       const url = `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(obsActiveTag)}/${bucket}`;
-      const msg = await HttpUtil.get(url);
+      const msg = await HttpUtil.get<{ t: number; v: number }[]>(url);
       applyHistory(msg, bucket);
     } catch (e) {
       console.error('Failed to fetch observatory bucket', e);

+ 9 - 9
frontend/src/pages/xray/NordModal.tsx

@@ -86,14 +86,14 @@ export default function NordModal({
   }, [filteredServers]);
 
   const fetchCountries = useCallback(async () => {
-    const msg = await HttpUtil.post('/panel/xray/nord/countries');
-    if (msg?.success) setCountries(JSON.parse(msg.obj));
+    const msg = await HttpUtil.post<string>('/panel/xray/nord/countries');
+    if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj));
   }, []);
 
   const fetchData = useCallback(async () => {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/xray/nord/data');
+      const msg = await HttpUtil.post<string>('/panel/xray/nord/data');
       if (msg?.success) {
         const next = msg.obj ? JSON.parse(msg.obj) : null;
         setNordData(next);
@@ -111,8 +111,8 @@ export default function NordModal({
   async function login() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/xray/nord/reg', { token });
-      if (msg?.success) {
+      const msg = await HttpUtil.post<string>('/panel/xray/nord/reg', { token });
+      if (msg?.success && msg.obj) {
         setNordData(JSON.parse(msg.obj));
         await fetchCountries();
       }
@@ -124,8 +124,8 @@ export default function NordModal({
   async function saveKey() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: manualKey });
-      if (msg?.success) {
+      const msg = await HttpUtil.post<string>('/panel/xray/nord/setKey', { key: manualKey });
+      if (msg?.success && msg.obj) {
         setNordData(JSON.parse(msg.obj));
         await fetchCountries();
       }
@@ -164,8 +164,8 @@ export default function NordModal({
     setServerId(null);
     setCityId(null);
     try {
-      const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: newCountryId });
-      if (!msg?.success) return;
+      const msg = await HttpUtil.post<string>('/panel/xray/nord/servers', { countryId: newCountryId });
+      if (!msg?.success || !msg.obj) return;
       const data = JSON.parse(msg.obj);
       const locations = data.locations || [];
       const locToCity: Record<number, City> = {};

+ 7 - 7
frontend/src/pages/xray/WarpModal.tsx

@@ -108,7 +108,7 @@ export default function WarpModal({
   const fetchData = useCallback(async () => {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/xray/warp/data');
+      const msg = await HttpUtil.post<string>('/panel/xray/warp/data');
       if (msg?.success) {
         const raw = msg.obj;
         setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
@@ -130,8 +130,8 @@ export default function WarpModal({
     setLoading(true);
     try {
       const keys = Wireguard.generateKeypair();
-      const msg = await HttpUtil.post('/panel/xray/warp/reg', keys);
-      if (msg?.success) {
+      const msg = await HttpUtil.post<string>('/panel/xray/warp/reg', keys);
+      if (msg?.success && msg.obj) {
         const resp = JSON.parse(msg.obj);
         setWarpData(resp.data);
         setWarpConfig(resp.config);
@@ -145,8 +145,8 @@ export default function WarpModal({
   async function getConfig() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/xray/warp/config');
-      if (msg?.success) {
+      const msg = await HttpUtil.post<string>('/panel/xray/warp/config');
+      if (msg?.success && msg.obj) {
         const parsed = JSON.parse(msg.obj);
         setWarpConfig(parsed);
         collectConfig(warpData, parsed);
@@ -161,8 +161,8 @@ export default function WarpModal({
     setLoading(true);
     setLicenseError('');
     try {
-      const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus });
-      if (msg?.success) {
+      const msg = await HttpUtil.post<string>('/panel/xray/warp/license', { license: warpPlus });
+      if (msg?.success && msg.obj) {
         setWarpData(JSON.parse(msg.obj));
         setWarpConfig(null);
         setWarpPlus('');

+ 4 - 8
frontend/src/utils/index.ts

@@ -4,8 +4,7 @@ import { getMessage } from './messageBus';
 
 type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
 
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export class Msg<T = any> {
+export class Msg<T = unknown> {
   success: boolean;
   msg: string;
   obj: T | null;
@@ -50,8 +49,7 @@ export class HttpUtil {
     return typeof data === 'object' ? (data as Msg) : new Msg(false, 'unknown data:', data);
   }
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  static async get<T = any>(url: string, params?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
+  static async get<T = unknown>(url: string, params?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
     const { silent, ...axiosOpts } = options;
     try {
       const resp = await axios.get(url, { params, ...axiosOpts });
@@ -67,8 +65,7 @@ export class HttpUtil {
     }
   }
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  static async post<T = any>(url: string, data?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
+  static async post<T = unknown>(url: string, data?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
     const { silent, ...axiosOpts } = options;
     try {
       const resp = await axios.post(url, data, axiosOpts);
@@ -84,8 +81,7 @@ export class HttpUtil {
     }
   }
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  static async postWithModal<T = any>(url: string, data?: unknown, modal?: HttpModal | null): Promise<Msg<T>> {
+  static async postWithModal<T = unknown>(url: string, data?: unknown, modal?: HttpModal | null): Promise<Msg<T>> {
     if (modal) {
       modal.loading(true);
     }

+ 1 - 1
go.mod

@@ -7,6 +7,7 @@ require (
 	github.com/gin-contrib/sessions v1.1.0
 	github.com/gin-gonic/gin v1.12.0
 	github.com/go-ldap/ldap/v3 v3.4.13
+	github.com/go-playground/validator/v10 v10.30.2
 	github.com/goccy/go-json v0.10.6
 	github.com/goccy/go-yaml v1.19.2
 	github.com/google/uuid v1.6.0
@@ -48,7 +49,6 @@ require (
 	github.com/go-ole/go-ole v1.3.0 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
-	github.com/go-playground/validator/v10 v10.30.2 // indirect
 	github.com/google/btree v1.1.3 // indirect
 	github.com/gorilla/context v1.1.2 // indirect
 	github.com/gorilla/securecookie v1.1.2 // indirect

+ 72 - 0
tools/openapigen/emit_types.go

@@ -0,0 +1,72 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"sort"
+)
+
+func emitTypes(w io.Writer, schemas []Schema, aliases []Alias) error {
+	if _, err := fmt.Fprintln(w, typesHeader); err != nil {
+		return err
+	}
+	for _, a := range sortAliases(aliases) {
+		if _, err := fmt.Fprintf(w, "export type %s = %s;\n", a.Name, tsTypeExpr(a.Underlying)); err != nil {
+			return err
+		}
+	}
+	if len(aliases) > 0 {
+		if _, err := fmt.Fprintln(w); err != nil {
+			return err
+		}
+	}
+	for _, s := range sortSchemas(schemas) {
+		if _, err := fmt.Fprintf(w, "export interface %s {\n", s.Name); err != nil {
+			return err
+		}
+		fields := append([]Field(nil), s.Fields...)
+		sort.SliceStable(fields, func(i, j int) bool { return fields[i].JSONName < fields[j].JSONName })
+		for _, f := range fields {
+			optional := ""
+			if f.Optional {
+				optional = "?"
+			}
+			line := fmt.Sprintf("  %s%s: %s;\n", quoteIfNeeded(f.JSONName), optional, tsTypeExpr(f.Type))
+			if _, err := fmt.Fprint(w, line); err != nil {
+				return err
+			}
+		}
+		if _, err := fmt.Fprintln(w, "}"); err != nil {
+			return err
+		}
+		if _, err := fmt.Fprintln(w); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func tsTypeExpr(t TypeRef) string {
+	switch t.Kind {
+	case KindString:
+		return "string"
+	case KindBool:
+		return "boolean"
+	case KindInt, KindNumber:
+		return "number"
+	case KindAny, KindUnknown, KindRaw:
+		return "unknown"
+	case KindArray:
+		return tsTypeExpr(*t.Element) + "[]"
+	case KindMap:
+		return "Record<" + tsTypeExpr(*t.Key) + ", " + tsTypeExpr(*t.Value) + ">"
+	case KindRef:
+		if t.Name == "nullable" {
+			return tsTypeExpr(*t.Inner) + " | null"
+		}
+		return t.Name
+	}
+	return "unknown"
+}
+
+const typesHeader = `// Code generated by tools/openapigen. DO NOT EDIT.`

+ 156 - 0
tools/openapigen/emit_zod.go

@@ -0,0 +1,156 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"sort"
+	"strings"
+)
+
+func emitZod(w io.Writer, schemas []Schema, aliases []Alias) error {
+	if _, err := fmt.Fprintln(w, zodHeader); err != nil {
+		return err
+	}
+
+	for _, a := range sortAliases(aliases) {
+		if _, err := fmt.Fprintf(w, "export const %sSchema = %s;\n", a.Name, zodTypeExpr(a.Underlying)); err != nil {
+			return err
+		}
+		if _, err := fmt.Fprintf(w, "export type %s = z.infer<typeof %sSchema>;\n\n", a.Name, a.Name); err != nil {
+			return err
+		}
+	}
+
+	for _, s := range sortSchemas(schemas) {
+		if _, err := fmt.Fprintf(w, "export const %sSchema = z.object({\n", s.Name); err != nil {
+			return err
+		}
+		fields := append([]Field(nil), s.Fields...)
+		sort.SliceStable(fields, func(i, j int) bool { return fields[i].JSONName < fields[j].JSONName })
+		for _, f := range fields {
+			line := fmt.Sprintf("  %s: %s,\n", quoteIfNeeded(f.JSONName), zodExpr(f))
+			if _, err := fmt.Fprint(w, line); err != nil {
+				return err
+			}
+		}
+		if _, err := fmt.Fprintln(w, "});"); err != nil {
+			return err
+		}
+		if _, err := fmt.Fprintf(w, "export type %s = z.infer<typeof %sSchema>;\n\n", s.Name, s.Name); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func zodExpr(f Field) string {
+	expr := zodTypeExpr(f.Type)
+	expr = applyZodValidations(expr, f.Type, f.Validate)
+	if f.Optional {
+		expr += ".optional()"
+	}
+	return expr
+}
+
+func zodTypeExpr(t TypeRef) string {
+	switch t.Kind {
+	case KindString:
+		return "z.string()"
+	case KindBool:
+		return "z.boolean()"
+	case KindInt:
+		return "z.number().int()"
+	case KindNumber:
+		return "z.number()"
+	case KindAny, KindUnknown:
+		return "z.unknown()"
+	case KindRaw:
+		return "z.unknown()"
+	case KindArray:
+		return "z.array(" + zodTypeExpr(*t.Element) + ")"
+	case KindMap:
+		return "z.record(" + zodTypeExpr(*t.Key) + ", " + zodTypeExpr(*t.Value) + ")"
+	case KindRef:
+		if t.Name == "nullable" {
+			return zodTypeExpr(*t.Inner) + ".nullable()"
+		}
+		return "z.lazy(() => " + t.Name + "Schema)"
+	}
+	return "z.unknown()"
+}
+
+func applyZodValidations(expr string, t TypeRef, rules []ValidateRule) string {
+	for _, r := range rules {
+		switch r.Name {
+		case "required":
+			continue
+		case "omitempty":
+			continue
+		case "gte":
+			if t.Kind == KindInt || t.Kind == KindNumber {
+				expr += fmt.Sprintf(".min(%s)", r.Param)
+			}
+		case "lte":
+			if t.Kind == KindInt || t.Kind == KindNumber {
+				expr += fmt.Sprintf(".max(%s)", r.Param)
+			}
+		case "gt":
+			if t.Kind == KindInt || t.Kind == KindNumber {
+				expr += fmt.Sprintf(".gt(%s)", r.Param)
+			}
+		case "lt":
+			if t.Kind == KindInt || t.Kind == KindNumber {
+				expr += fmt.Sprintf(".lt(%s)", r.Param)
+			}
+		case "min":
+			if t.Kind == KindString {
+				expr += fmt.Sprintf(".min(%s)", r.Param)
+			} else if t.Kind == KindInt || t.Kind == KindNumber {
+				expr += fmt.Sprintf(".min(%s)", r.Param)
+			}
+		case "max":
+			if t.Kind == KindString {
+				expr += fmt.Sprintf(".max(%s)", r.Param)
+			} else if t.Kind == KindInt || t.Kind == KindNumber {
+				expr += fmt.Sprintf(".max(%s)", r.Param)
+			}
+		case "url":
+			expr += ".url()"
+		case "email":
+			expr += ".email()"
+		case "oneof":
+			values := strings.Fields(r.Param)
+			quoted := make([]string, 0, len(values))
+			for _, v := range values {
+				quoted = append(quoted, fmt.Sprintf("'%s'", v))
+			}
+			expr = fmt.Sprintf("z.enum([%s])", strings.Join(quoted, ", "))
+		}
+	}
+	return expr
+}
+
+func quoteIfNeeded(name string) string {
+	if name == "" {
+		return "''"
+	}
+	for i, r := range name {
+		if r >= 'a' && r <= 'z' {
+			continue
+		}
+		if r >= 'A' && r <= 'Z' {
+			continue
+		}
+		if r == '_' || r == '$' {
+			continue
+		}
+		if i > 0 && r >= '0' && r <= '9' {
+			continue
+		}
+		return "'" + name + "'"
+	}
+	return name
+}
+
+const zodHeader = `// Code generated by tools/openapigen. DO NOT EDIT.
+import { z } from 'zod';`

+ 115 - 0
tools/openapigen/main.go

@@ -0,0 +1,115 @@
+package main
+
+import (
+	"bytes"
+	"flag"
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+func main() {
+	root := flag.String("root", ".", "repository root containing database/model and web/entity")
+	outDir := flag.String("out", "frontend/src/generated", "output directory relative to root")
+	flag.Parse()
+
+	if err := run(*root, *outDir); err != nil {
+		fmt.Fprintln(os.Stderr, "openapigen:", err)
+		os.Exit(1)
+	}
+}
+
+func run(root, outDir string) error {
+	requests := []packageRequest{
+		{
+			Path: resolveRel(root, "database/model"),
+			StructAllow: setOf(
+				"User",
+				"Inbound",
+				"FallbackParentInfo",
+				"OutboundTraffics",
+				"InboundClientIps",
+				"ApiToken",
+				"HistoryOfSeeders",
+				"Setting",
+				"Node",
+				"CustomGeoResource",
+				"ClientReverse",
+				"Client",
+				"ClientRecord",
+				"ClientInbound",
+				"InboundFallback",
+			),
+			AliasAllow: setOf("Protocol"),
+			Overrides: map[string][]walkOverride{
+				"Inbound": {
+					{Field: "Settings", Kind: KindAny},
+					{Field: "StreamSettings", Kind: KindAny},
+					{Field: "Sniffing", Kind: KindAny},
+				},
+				"ClientRecord": {
+					{Field: "Reverse", Kind: KindAny},
+				},
+				"InboundClientIps": {
+					{Field: "Ips", Kind: KindAny},
+				},
+			},
+		},
+		{
+			Path: resolveRel(root, "web/entity"),
+			StructAllow: setOf(
+				"Msg",
+				"AllSetting",
+				"AllSettingView",
+			),
+		},
+		{
+			Path: resolveRel(root, "xray"),
+			StructAllow: setOf(
+				"ClientTraffic",
+			),
+		},
+	}
+
+	schemas, aliases, err := walkPackages(requests)
+	if err != nil {
+		return err
+	}
+	schemas = flattenEmbedded(schemas)
+
+	if len(schemas) == 0 {
+		return fmt.Errorf("no schemas produced; nothing to write")
+	}
+
+	target := filepath.Join(root, outDir)
+	if err := os.MkdirAll(target, 0o755); err != nil {
+		return err
+	}
+
+	zodBuf := &bytes.Buffer{}
+	if err := emitZod(zodBuf, schemas, aliases); err != nil {
+		return err
+	}
+	typesBuf := &bytes.Buffer{}
+	if err := emitTypes(typesBuf, schemas, aliases); err != nil {
+		return err
+	}
+
+	if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
+		return err
+	}
+	if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil {
+		return err
+	}
+
+	fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
+	return nil
+}
+
+func setOf(names ...string) map[string]bool {
+	m := make(map[string]bool, len(names))
+	for _, n := range names {
+		m[n] = true
+	}
+	return m
+}

+ 172 - 0
tools/openapigen/schema.go

@@ -0,0 +1,172 @@
+package main
+
+import (
+	"reflect"
+	"sort"
+	"strings"
+)
+
+type Schema struct {
+	Name    string
+	Package string
+	Fields  []Field
+	Doc     string
+}
+
+type Alias struct {
+	Name       string
+	Package    string
+	Underlying TypeRef
+}
+
+type Field struct {
+	JSONName string
+	GoName   string
+	Type     TypeRef
+	Optional bool
+	Skip     bool
+	Validate []ValidateRule
+	Doc      string
+}
+
+type TypeRef struct {
+	Kind    TypeKind
+	Name    string
+	Element *TypeRef
+	Key     *TypeRef
+	Value   *TypeRef
+	Inner   *TypeRef
+}
+
+type TypeKind string
+
+const (
+	KindString  TypeKind = "string"
+	KindNumber  TypeKind = "number"
+	KindInt     TypeKind = "int"
+	KindBool    TypeKind = "boolean"
+	KindArray   TypeKind = "array"
+	KindMap     TypeKind = "map"
+	KindObject  TypeKind = "object"
+	KindRef     TypeKind = "ref"
+	KindUnknown TypeKind = "unknown"
+	KindAny     TypeKind = "any"
+	KindRaw     TypeKind = "raw"
+)
+
+type ValidateRule struct {
+	Name  string
+	Param string
+}
+
+func parseStructTag(raw string) (json string, validate string, gormHasDash bool) {
+	tag := reflect.StructTag(strings.Trim(raw, "`"))
+	json = tag.Get("json")
+	validate = tag.Get("validate")
+	if g := tag.Get("gorm"); g != "" {
+		for _, part := range strings.Split(g, ";") {
+			if strings.TrimSpace(part) == "-" {
+				gormHasDash = true
+			}
+		}
+	}
+	return
+}
+
+func parseJSONTag(tag string) (name string, omit bool, omitempty bool) {
+	if tag == "" {
+		return "", false, false
+	}
+	parts := strings.Split(tag, ",")
+	name = parts[0]
+	if name == "-" {
+		return "", true, false
+	}
+	for _, p := range parts[1:] {
+		if p == "omitempty" {
+			omitempty = true
+		}
+	}
+	return
+}
+
+func parseValidateTag(tag string) []ValidateRule {
+	if tag == "" {
+		return nil
+	}
+	var rules []ValidateRule
+	for _, part := range strings.Split(tag, ",") {
+		part = strings.TrimSpace(part)
+		if part == "" {
+			continue
+		}
+		eq := strings.IndexByte(part, '=')
+		if eq < 0 {
+			rules = append(rules, ValidateRule{Name: part})
+			continue
+		}
+		rules = append(rules, ValidateRule{Name: part[:eq], Param: part[eq+1:]})
+	}
+	return rules
+}
+
+func (s Schema) HasValidationOn(field string) bool {
+	for _, f := range s.Fields {
+		if f.JSONName == field {
+			return len(f.Validate) > 0
+		}
+	}
+	return false
+}
+
+func sortSchemas(in []Schema) []Schema {
+	out := make([]Schema, len(in))
+	copy(out, in)
+	sort.Slice(out, func(i, j int) bool {
+		return out[i].Name < out[j].Name
+	})
+	return out
+}
+
+func sortAliases(in []Alias) []Alias {
+	out := make([]Alias, len(in))
+	copy(out, in)
+	sort.Slice(out, func(i, j int) bool {
+		return out[i].Name < out[j].Name
+	})
+	return out
+}
+
+func flattenEmbedded(schemas []Schema) []Schema {
+	byName := make(map[string]Schema, len(schemas))
+	for _, s := range schemas {
+		byName[s.Name] = s
+	}
+	out := make([]Schema, 0, len(schemas))
+	for _, s := range schemas {
+		var resolved []Field
+		seen := make(map[string]bool, len(s.Fields))
+		for _, f := range s.Fields {
+			if f.Type.Kind == KindRef && f.Type.Name != "nullable" {
+				if embedded, ok := byName[f.Type.Name]; ok && f.GoName == f.Type.Name {
+					for _, ef := range embedded.Fields {
+						if seen[ef.JSONName] {
+							continue
+						}
+						seen[ef.JSONName] = true
+						resolved = append(resolved, ef)
+					}
+					continue
+				}
+			}
+			if seen[f.JSONName] {
+				continue
+			}
+			seen[f.JSONName] = true
+			resolved = append(resolved, f)
+		}
+		s.Fields = resolved
+		out = append(out, s)
+	}
+	return out
+}

+ 244 - 0
tools/openapigen/walker.go

@@ -0,0 +1,244 @@
+package main
+
+import (
+	"fmt"
+	"go/ast"
+	"go/parser"
+	"go/token"
+	"io/fs"
+	"path/filepath"
+	"strings"
+)
+
+type walkOverride struct {
+	Field string
+	Kind  TypeKind
+}
+
+type packageRequest struct {
+	Path        string
+	StructAllow map[string]bool
+	AliasAllow  map[string]bool
+	Overrides   map[string][]walkOverride
+}
+
+func walkPackages(requests []packageRequest) ([]Schema, []Alias, error) {
+	fset := token.NewFileSet()
+	var schemas []Schema
+	var aliases []Alias
+	for _, req := range requests {
+		dir := req.Path
+		pkgs, err := parser.ParseDir(fset, dir, func(fi fs.FileInfo) bool {
+			return !strings.HasSuffix(fi.Name(), "_test.go")
+		}, parser.ParseComments)
+		if err != nil {
+			return nil, nil, fmt.Errorf("parse %s: %w", dir, err)
+		}
+		for _, pkg := range pkgs {
+			for _, file := range pkg.Files {
+				for _, decl := range file.Decls {
+					gen, ok := decl.(*ast.GenDecl)
+					if !ok || gen.Tok != token.TYPE {
+						continue
+					}
+					for _, spec := range gen.Specs {
+						ts, ok := spec.(*ast.TypeSpec)
+						if !ok {
+							continue
+						}
+						if strct, ok := ts.Type.(*ast.StructType); ok {
+							if req.StructAllow != nil && !req.StructAllow[ts.Name.Name] {
+								continue
+							}
+							s := Schema{
+								Name:    ts.Name.Name,
+								Package: pkg.Name,
+								Doc:     collectDoc(gen.Doc, ts.Doc),
+							}
+							overrides := req.Overrides[ts.Name.Name]
+							for _, fld := range strct.Fields.List {
+								for _, f := range buildFields(fld, overrides) {
+									s.Fields = append(s.Fields, f)
+								}
+							}
+							schemas = append(schemas, s)
+							continue
+						}
+						if req.AliasAllow != nil && !req.AliasAllow[ts.Name.Name] {
+							continue
+						}
+						aliases = append(aliases, Alias{
+							Name:       ts.Name.Name,
+							Package:    pkg.Name,
+							Underlying: exprToType(ts.Type),
+						})
+					}
+				}
+			}
+		}
+	}
+	return schemas, aliases, nil
+}
+
+func collectDoc(group ...*ast.CommentGroup) string {
+	var b strings.Builder
+	for _, g := range group {
+		if g == nil {
+			continue
+		}
+		for _, c := range g.List {
+			line := strings.TrimPrefix(c.Text, "// ")
+			line = strings.TrimPrefix(line, "//")
+			b.WriteString(strings.TrimSpace(line))
+			b.WriteByte('\n')
+		}
+	}
+	return strings.TrimSpace(b.String())
+}
+
+func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
+	var fields []Field
+	tag := ""
+	if fld.Tag != nil {
+		tag = fld.Tag.Value
+	}
+	jsonTag, validateTag, gormDash := parseStructTag(tag)
+	if gormDash && jsonTag == "" {
+		return nil
+	}
+	jsonName, omit, omitempty := parseJSONTag(jsonTag)
+	if omit {
+		return nil
+	}
+	validate := parseValidateTag(validateTag)
+	doc := collectDoc(fld.Doc, fld.Comment)
+
+	for _, n := range fld.Names {
+		fname := jsonName
+		if fname == "" {
+			fname = lowerFirst(n.Name)
+		}
+		t := exprToType(fld.Type)
+		for _, o := range overrides {
+			if o.Field == n.Name || o.Field == jsonName {
+				t = TypeRef{Kind: o.Kind}
+				break
+			}
+		}
+		fields = append(fields, Field{
+			JSONName: fname,
+			GoName:   n.Name,
+			Type:     t,
+			Optional: omitempty || isPointer(fld.Type),
+			Validate: validate,
+			Doc:      doc,
+		})
+	}
+
+	if len(fld.Names) == 0 {
+		fname := jsonName
+		if fname == "" {
+			fname = lowerFirst(exprIdentName(fld.Type))
+		}
+		t := exprToType(fld.Type)
+		for _, o := range overrides {
+			if o.Field == exprIdentName(fld.Type) || o.Field == jsonName {
+				t = TypeRef{Kind: o.Kind}
+				break
+			}
+		}
+		fields = append(fields, Field{
+			JSONName: fname,
+			GoName:   exprIdentName(fld.Type),
+			Type:     t,
+			Optional: omitempty || isPointer(fld.Type),
+			Validate: validate,
+			Doc:      doc,
+		})
+	}
+
+	return fields
+}
+
+func exprToType(expr ast.Expr) TypeRef {
+	switch e := expr.(type) {
+	case *ast.Ident:
+		return identType(e.Name)
+	case *ast.StarExpr:
+		inner := exprToType(e.X)
+		return TypeRef{Kind: KindRef, Name: "nullable", Inner: &inner}
+	case *ast.ArrayType:
+		elem := exprToType(e.Elt)
+		return TypeRef{Kind: KindArray, Element: &elem}
+	case *ast.MapType:
+		k := exprToType(e.Key)
+		v := exprToType(e.Value)
+		return TypeRef{Kind: KindMap, Key: &k, Value: &v}
+	case *ast.SelectorExpr:
+		pkg := exprIdentName(e.X)
+		name := e.Sel.Name
+		if pkg == "json" && name == "RawMessage" {
+			return TypeRef{Kind: KindAny}
+		}
+		if pkg == "time" && name == "Time" {
+			return TypeRef{Kind: KindString, Name: "datetime"}
+		}
+		return TypeRef{Kind: KindRef, Name: name}
+	case *ast.InterfaceType:
+		return TypeRef{Kind: KindAny}
+	default:
+		return TypeRef{Kind: KindUnknown}
+	}
+}
+
+func identType(name string) TypeRef {
+	switch name {
+	case "string":
+		return TypeRef{Kind: KindString}
+	case "bool":
+		return TypeRef{Kind: KindBool}
+	case "int", "int8", "int16", "int32", "int64",
+		"uint", "uint8", "uint16", "uint32", "uint64":
+		return TypeRef{Kind: KindInt}
+	case "float32", "float64":
+		return TypeRef{Kind: KindNumber}
+	case "byte", "rune":
+		return TypeRef{Kind: KindInt}
+	case "any":
+		return TypeRef{Kind: KindAny}
+	default:
+		return TypeRef{Kind: KindRef, Name: name}
+	}
+}
+
+func isPointer(expr ast.Expr) bool {
+	_, ok := expr.(*ast.StarExpr)
+	return ok
+}
+
+func exprIdentName(expr ast.Expr) string {
+	switch e := expr.(type) {
+	case *ast.Ident:
+		return e.Name
+	case *ast.SelectorExpr:
+		return e.Sel.Name
+	case *ast.StarExpr:
+		return exprIdentName(e.X)
+	default:
+		return ""
+	}
+}
+
+func lowerFirst(s string) string {
+	if s == "" {
+		return s
+	}
+	return strings.ToLower(s[:1]) + s[1:]
+}
+
+func resolveRel(base, rel string) string {
+	if filepath.IsAbs(rel) {
+		return rel
+	}
+	return filepath.Clean(filepath.Join(base, rel))
+}

+ 4 - 7
web/controller/inbound.go

@@ -8,6 +8,7 @@ import (
 	"strings"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
 	"github.com/mhsanaei/3x-ui/v3/web/session"
 	"github.com/mhsanaei/3x-ui/v3/web/websocket"
@@ -129,10 +130,8 @@ func (a *InboundController) getInbound(c *gin.Context) {
 
 // addInbound creates a new inbound configuration.
 func (a *InboundController) addInbound(c *gin.Context) {
-	inbound := &model.Inbound{}
-	err := c.ShouldBind(inbound)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), err)
+	inbound, ok := middleware.BindAndValidate[model.Inbound](c)
+	if !ok {
 		return
 	}
 	user := session.GetLoginUser(c)
@@ -200,9 +199,7 @@ func (a *InboundController) updateInbound(c *gin.Context) {
 	inbound := &model.Inbound{
 		Id: id,
 	}
-	err = c.ShouldBind(inbound)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
+	if !middleware.BindAndValidateInto(c, inbound) {
 		return
 	}
 	// Same NodeID=0 → nil normalisation as addInbound. UpdateInbound

+ 5 - 6
web/controller/node.go

@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
 
 	"github.com/gin-gonic/gin"
@@ -61,9 +62,8 @@ func (a *NodeController) get(c *gin.Context) {
 }
 
 func (a *NodeController) add(c *gin.Context) {
-	n := &model.Node{}
-	if err := c.ShouldBind(n); err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
+	n, ok := middleware.BindAndValidate[model.Node](c)
+	if !ok {
 		return
 	}
 	if err := a.nodeService.Create(n); err != nil {
@@ -79,9 +79,8 @@ func (a *NodeController) update(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "get"), err)
 		return
 	}
-	n := &model.Node{}
-	if err := c.ShouldBind(n); err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
+	n, ok := middleware.BindAndValidate[model.Node](c)
+	if !ok {
 		return
 	}
 	if err := a.nodeService.Update(id, n); err != nil {

+ 4 - 5
web/controller/setting.go

@@ -7,6 +7,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
 	"github.com/mhsanaei/3x-ui/v3/web/session"
 
@@ -74,14 +75,12 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) {
 
 // updateSetting updates all settings with the provided data.
 func (a *SettingController) updateSetting(c *gin.Context) {
-	allSetting := &entity.AllSetting{}
-	err := c.ShouldBind(allSetting)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
+	allSetting, ok := middleware.BindAndValidate[entity.AllSetting](c)
+	if !ok {
 		return
 	}
 	oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
-	err = a.settingService.UpdateAllSetting(allSetting)
+	err := a.settingService.UpdateAllSetting(allSetting)
 	if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
 		if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
 			err = bumpErr

+ 22 - 22
web/entity/entity.go

@@ -21,21 +21,21 @@ type Msg struct {
 // AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
 type AllSetting struct {
 	// Web server settings
-	WebListen         string `json:"webListen" form:"webListen"`                 // Web server listen IP address
-	WebDomain         string `json:"webDomain" form:"webDomain"`                 // Web server domain for domain validation
-	WebPort           int    `json:"webPort" form:"webPort"`                     // Web server port number
-	WebCertFile       string `json:"webCertFile" form:"webCertFile"`             // Path to SSL certificate file for web server
-	WebKeyFile        string `json:"webKeyFile" form:"webKeyFile"`               // Path to SSL private key file for web server
-	WebBasePath       string `json:"webBasePath" form:"webBasePath"`             // Base path for web panel URLs
-	SessionMaxAge     int    `json:"sessionMaxAge" form:"sessionMaxAge"`         // Session maximum age in minutes
-	TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers
+	WebListen         string `json:"webListen" form:"webListen"`                                       // Web server listen IP address
+	WebDomain         string `json:"webDomain" form:"webDomain"`                                       // Web server domain for domain validation
+	WebPort           int    `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"`                // Web server port number
+	WebCertFile       string `json:"webCertFile" form:"webCertFile"`                                   // Path to SSL certificate file for web server
+	WebKeyFile        string `json:"webKeyFile" form:"webKeyFile"`                                     // Path to SSL private key file for web server
+	WebBasePath       string `json:"webBasePath" form:"webBasePath"`                                   // Base path for web panel URLs
+	SessionMaxAge     int    `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=0,lte=525600"`   // Session maximum age in minutes (cap at one year)
+	TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`                       // Trusted reverse proxy IPs/CIDRs for forwarded headers
 
 	// UI settings
-	PageSize    int    `json:"pageSize" form:"pageSize"`       // Number of items per page in lists
-	ExpireDiff  int    `json:"expireDiff" form:"expireDiff"`   // Expiration warning threshold in days
-	TrafficDiff int    `json:"trafficDiff" form:"trafficDiff"` // Traffic warning threshold percentage
-	RemarkModel string `json:"remarkModel" form:"remarkModel"` // Remark model pattern for inbounds
-	Datepicker  string `json:"datepicker" form:"datepicker"`   // Date picker format
+	PageSize    int    `json:"pageSize" form:"pageSize" validate:"gte=1,lte=1000"`     // Number of items per page in lists
+	ExpireDiff  int    `json:"expireDiff" form:"expireDiff" validate:"gte=0"`          // Expiration warning threshold in days
+	TrafficDiff int    `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`// Traffic warning threshold percentage
+	RemarkModel string `json:"remarkModel" form:"remarkModel"`                         // Remark model pattern for inbounds
+	Datepicker  string `json:"datepicker" form:"datepicker"`                           // Date picker format
 
 	// Telegram bot settings
 	TgBotEnable      bool   `json:"tgBotEnable" form:"tgBotEnable"`           // Enable Telegram bot notifications
@@ -45,9 +45,9 @@ type AllSetting struct {
 	TgBotChatId      string `json:"tgBotChatId" form:"tgBotChatId"`           // Telegram chat ID for notifications
 	TgRunTime        string `json:"tgRunTime" form:"tgRunTime"`               // Cron schedule for Telegram notifications
 	TgBotBackup      bool   `json:"tgBotBackup" form:"tgBotBackup"`           // Enable database backup via Telegram
-	TgBotLoginNotify bool   `json:"tgBotLoginNotify" form:"tgBotLoginNotify"` // Send login notifications
-	TgCpu            int    `json:"tgCpu" form:"tgCpu"`                       // CPU usage threshold for alerts
-	TgLang           string `json:"tgLang" form:"tgLang"`                     // Telegram bot language
+	TgBotLoginNotify bool   `json:"tgBotLoginNotify" form:"tgBotLoginNotify"`             // Send login notifications
+	TgCpu            int    `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"`          // CPU usage threshold for alerts (percent)
+	TgLang           string `json:"tgLang" form:"tgLang"`                                 // Telegram bot language
 
 	// Security settings
 	TimeLocation    string `json:"timeLocation" form:"timeLocation"`       // Time zone location
@@ -64,12 +64,12 @@ type AllSetting struct {
 	SubEnableRouting            bool   `json:"subEnableRouting" form:"subEnableRouting"`                       // Enable routing for subscription
 	SubRoutingRules             string `json:"subRoutingRules" form:"subRoutingRules"`                         // Subscription global routing rules (Only for Happ)
 	SubListen                   string `json:"subListen" form:"subListen"`                                     // Subscription server listen IP
-	SubPort                     int    `json:"subPort" form:"subPort"`                                         // Subscription server port
+	SubPort                     int    `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`              // Subscription server port
 	SubPath                     string `json:"subPath" form:"subPath"`                                         // Base path for subscription URLs
 	SubDomain                   string `json:"subDomain" form:"subDomain"`                                     // Domain for subscription server validation
 	SubCertFile                 string `json:"subCertFile" form:"subCertFile"`                                 // SSL certificate file for subscription server
 	SubKeyFile                  string `json:"subKeyFile" form:"subKeyFile"`                                   // SSL private key file for subscription server
-	SubUpdates                  int    `json:"subUpdates" form:"subUpdates"`                                   // Subscription update interval in minutes
+	SubUpdates                  int    `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"`       // Subscription update interval in minutes
 	ExternalTrafficInformEnable bool   `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"` // Enable external traffic reporting
 	ExternalTrafficInformURI    string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"`       // URI for external traffic reporting
 	RestartXrayOnClientDisable  bool   `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`   // Restart Xray when clients are auto-disabled by expiry/traffic limit
@@ -90,7 +90,7 @@ type AllSetting struct {
 	// LDAP settings
 	LdapEnable     bool   `json:"ldapEnable" form:"ldapEnable"`
 	LdapHost       string `json:"ldapHost" form:"ldapHost"`
-	LdapPort       int    `json:"ldapPort" form:"ldapPort"`
+	LdapPort       int    `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"`
 	LdapUseTLS     bool   `json:"ldapUseTLS" form:"ldapUseTLS"`
 	LdapBindDN     string `json:"ldapBindDN" form:"ldapBindDN"`
 	LdapPassword   string `json:"ldapPassword" form:"ldapPassword"`
@@ -106,9 +106,9 @@ type AllSetting struct {
 	LdapInboundTags       string `json:"ldapInboundTags" form:"ldapInboundTags"`
 	LdapAutoCreate        bool   `json:"ldapAutoCreate" form:"ldapAutoCreate"`
 	LdapAutoDelete        bool   `json:"ldapAutoDelete" form:"ldapAutoDelete"`
-	LdapDefaultTotalGB    int    `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB"`
-	LdapDefaultExpiryDays int    `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"`
-	LdapDefaultLimitIP    int    `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"`
+	LdapDefaultTotalGB    int    `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"`
+	LdapDefaultExpiryDays int    `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
+	LdapDefaultLimitIP    int    `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
 	// JSON subscription routing rules
 }
 

+ 111 - 0
web/middleware/validate.go

@@ -0,0 +1,111 @@
+package middleware
+
+import (
+	"errors"
+	"net/http"
+	"reflect"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+	"github.com/gin-gonic/gin/binding"
+	"github.com/go-playground/validator/v10"
+
+	"github.com/mhsanaei/3x-ui/v3/web/entity"
+)
+
+var validate = validator.New(validator.WithRequiredStructEnabled())
+
+func BindAndValidate[T any](c *gin.Context) (*T, bool) {
+	var dst T
+	if err := c.ShouldBind(&dst); err != nil {
+		writeBindFailure(c, err)
+		return nil, false
+	}
+	if err := validate.Struct(&dst); err != nil {
+		writeBindFailure(c, err)
+		return nil, false
+	}
+	return &dst, true
+}
+
+func BindAndValidateInto(c *gin.Context, dst any) bool {
+	if err := c.ShouldBind(dst); err != nil {
+		writeBindFailure(c, err)
+		return false
+	}
+	if err := validate.Struct(dst); err != nil {
+		writeBindFailure(c, err)
+		return false
+	}
+	return true
+}
+
+func BindJSONAndValidate[T any](c *gin.Context) (*T, bool) {
+	var dst T
+	if err := c.ShouldBindWith(&dst, binding.JSON); err != nil {
+		writeBindFailure(c, err)
+		return nil, false
+	}
+	if err := validate.Struct(&dst); err != nil {
+		writeBindFailure(c, err)
+		return nil, false
+	}
+	return &dst, true
+}
+
+func BindJSONAndValidateInto(c *gin.Context, dst any) bool {
+	if err := c.ShouldBindWith(dst, binding.JSON); err != nil {
+		writeBindFailure(c, err)
+		return false
+	}
+	if err := validate.Struct(dst); err != nil {
+		writeBindFailure(c, err)
+		return false
+	}
+	return true
+}
+
+type FieldIssue struct {
+	Field   string `json:"field"`
+	Rule    string `json:"rule"`
+	Param   string `json:"param,omitempty"`
+	Message string `json:"message"`
+}
+
+type ValidationPayload struct {
+	Issues  []FieldIssue `json:"issues"`
+	Message string       `json:"message"`
+}
+
+func writeBindFailure(c *gin.Context, err error) {
+	payload := ValidationPayload{Issues: []FieldIssue{}, Message: err.Error()}
+
+	var ve validator.ValidationErrors
+	if errors.As(err, &ve) {
+		payload.Issues = make([]FieldIssue, 0, len(ve))
+		for _, fe := range ve {
+			payload.Issues = append(payload.Issues, FieldIssue{
+				Field:   fe.Field(),
+				Rule:    fe.Tag(),
+				Param:   fe.Param(),
+				Message: fe.Error(),
+			})
+		}
+	}
+
+	c.AbortWithStatusJSON(http.StatusOK, entity.Msg{
+		Success: false,
+		Msg:     "request body failed validation",
+		Obj:     payload,
+	})
+}
+
+func init() {
+	validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
+		name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
+		if name == "-" || name == "" {
+			return fld.Name
+		}
+		return name
+	})
+}

+ 207 - 0
web/middleware/validate_test.go

@@ -0,0 +1,207 @@
+package middleware
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/web/entity"
+)
+
+type sampleBody struct {
+	Port     int    `json:"port" form:"port" validate:"gte=1,lte=65535"`
+	Protocol string `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan"`
+	Tag      string `json:"tag" form:"tag"`
+}
+
+func newRouter(handler gin.HandlerFunc) *gin.Engine {
+	gin.SetMode(gin.TestMode)
+	r := gin.New()
+	r.POST("/submit", handler)
+	return r
+}
+
+func decodeMsg(t *testing.T, body string) entity.Msg {
+	t.Helper()
+	var msg entity.Msg
+	if err := json.Unmarshal([]byte(body), &msg); err != nil {
+		t.Fatalf("decode msg: %v (body=%q)", err, body)
+	}
+	return msg
+}
+
+func TestBindAndValidate_ValidPayloadPassesThrough(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		got, ok := BindAndValidate[sampleBody](c)
+		if !ok {
+			t.Fatalf("expected ok=true, got false (body should be valid)")
+		}
+		if got.Port != 443 || got.Protocol != "vless" || got.Tag != "inbound-443" {
+			t.Fatalf("decoded payload mismatch: %+v", got)
+		}
+		c.JSON(http.StatusOK, entity.Msg{Success: true, Msg: "ok"})
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":443,"protocol":"vless","tag":"inbound-443"}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String())
+	}
+	if msg := decodeMsg(t, rec.Body.String()); !msg.Success {
+		t.Fatalf("expected Success=true; got %+v", msg)
+	}
+}
+
+func TestBindAndValidate_PortOutOfRangeIsRejected(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		if _, ok := BindAndValidate[sampleBody](c); ok {
+			t.Fatal("expected ok=false on invalid port; got true")
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":70000,"protocol":"vless"}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	msg := decodeMsg(t, rec.Body.String())
+	if msg.Success {
+		t.Fatalf("expected Success=false; got %+v", msg)
+	}
+	payload, err := payloadFromObj(msg.Obj)
+	if err != nil {
+		t.Fatalf("payload extraction: %v", err)
+	}
+	found := false
+	for _, issue := range payload.Issues {
+		if issue.Field == "port" && issue.Rule == "lte" {
+			found = true
+			break
+		}
+	}
+	if !found {
+		t.Fatalf("expected an Issue for field=port rule=lte; got %+v", payload.Issues)
+	}
+}
+
+func TestBindAndValidate_ProtocolEnumIsRejected(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		if _, ok := BindAndValidate[sampleBody](c); ok {
+			t.Fatal("expected ok=false on invalid protocol; got true")
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":443,"protocol":"unknown"}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	msg := decodeMsg(t, rec.Body.String())
+	payload, err := payloadFromObj(msg.Obj)
+	if err != nil {
+		t.Fatalf("payload extraction: %v", err)
+	}
+	found := false
+	for _, issue := range payload.Issues {
+		if issue.Field == "protocol" && issue.Rule == "oneof" {
+			found = true
+		}
+	}
+	if !found {
+		t.Fatalf("expected an Issue for field=protocol rule=oneof; got %+v", payload.Issues)
+	}
+}
+
+func TestBindAndValidate_MalformedJSONReturnsMessageButNoIssues(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		if _, ok := BindAndValidate[sampleBody](c); ok {
+			t.Fatal("expected ok=false on malformed JSON; got true")
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	msg := decodeMsg(t, rec.Body.String())
+	if msg.Success {
+		t.Fatal("expected Success=false on malformed JSON")
+	}
+	payload, err := payloadFromObj(msg.Obj)
+	if err != nil {
+		t.Fatalf("payload extraction: %v", err)
+	}
+	if len(payload.Issues) != 0 {
+		t.Fatalf("expected empty Issues for parse error; got %+v", payload.Issues)
+	}
+	if payload.Message == "" {
+		t.Fatal("expected non-empty Message describing the parse error")
+	}
+}
+
+func TestBindAndValidateInto_PreservesPrePopulatedFields(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		dst := &sampleBody{Tag: "preset"}
+		if !BindAndValidateInto(c, dst) {
+			t.Fatal("expected ok=true; got false")
+		}
+		if dst.Tag != "inbound-443" {
+			t.Fatalf("expected payload Tag to overwrite preset; got %q", dst.Tag)
+		}
+		if dst.Port != 443 {
+			t.Fatalf("expected Port=443; got %d", dst.Port)
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader(`{"port":443,"protocol":"trojan","tag":"inbound-443"}`))
+	req.Header.Set("Content-Type", "application/json")
+	r.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+	}
+}
+
+func TestBindJSONAndValidate_RejectsFormEncodedBody(t *testing.T) {
+	r := newRouter(func(c *gin.Context) {
+		if _, ok := BindJSONAndValidate[sampleBody](c); ok {
+			t.Fatal("expected ok=false for form-encoded request to a JSON-only endpoint")
+		}
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "/submit",
+		strings.NewReader("port=443&protocol=vless"))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	r.ServeHTTP(rec, req)
+
+	if msg := decodeMsg(t, rec.Body.String()); msg.Success {
+		t.Fatalf("expected Success=false; got %+v", msg)
+	}
+}
+
+func payloadFromObj(obj any) (ValidationPayload, error) {
+	raw, err := json.Marshal(obj)
+	if err != nil {
+		return ValidationPayload{}, err
+	}
+	var payload ValidationPayload
+	if err := json.Unmarshal(raw, &payload); err != nil {
+		return ValidationPayload{}, err
+	}
+	return payload, nil
+}