feat(hosts): bulk-add multiple hosts to multiple inbounds (#5677)

* feat(hosts): bulk-add multiple hosts to multiple inbounds

Allow users to select multiple inbound IDs and enter multiple host
addresses (with optional per-host port override) in a single form
submission.

- Add BulkAddHostReq entity and POST /panel/api/hosts/bulk/add endpoint
- Add AddHostsBulk service with GORM transaction safety
- Add parseHostAndPort helper (IPv4, bracketed/bracketless IPv6, port)
- Update HostFormModal to multi-select inbounds and tag-input hosts
- Wire bulkCreate mutation in HostsPage with existing-host suggestions
- Register endpoint in api-docs/endpoints.ts and regenerate OpenAPI/Zod

* feat(hosts): group override records by group_id and support group editing

* fix: import Popover in HostList

* fix: use messageApi in HostFormModal

* fix(hosts): resolve 4 bugs found in host-group code review

- fix(schema): allow empty hosts array in BulkAddHostSchema so users can
  save a host without an address (inherits inbound endpoint). The old
  .min(1) was never enforced at runtime since the schema is only used for
  type inference, but the type was incorrect.

- fix(service): validate new inbound IDs in UpdateHostGroup before deleting
  old rows, matching the same check already present in AddHostGroup. Prevents
  orphaned host rows when an invalid inbound ID is supplied on edit.

- fix(service): replace full-table scan in GetHostsByInbound with two
  targeted queries (DISTINCT group_id WHERE inbound_id=?, then
  WHERE group_id IN ?) to avoid loading every host in the DB.

- fix(mutations): remove unused createMut / create export from
  useHostMutations. The /hosts/add endpoint is identical to /hosts/bulk/add;
  only bulkCreate is used by the UI.

* fix(hosts): address code review feedback (optimize bulk inserts, add validation tests, and remove comments)

* fix(fmt): apply gofumpt formatting to model.go and db.go

The previous merge commit incorrectly applied gofmt (tab-aligned) to
these files. The repository's golangci config requires gofumpt+goimports
which produces space-aligned struct fields. This commit restores the
correct gofumpt formatting that matches upstream/main.

* chore(frontend): regenerate API schemas and update lockfile

* fix

* refactor(hosts): dedupe host-group service and tidy frontend

AddHostGroup and UpdateHostGroup shared an identical ~35-field
model.Host construction and hand-rolled transaction boilerplate
(tx.Begin plus a committed flag plus a deferred recover/rollback).
Extract buildHostRows, validateInboundsExist and formatHostAddr, and
run every mutation through db.Transaction. groupHosts collapses its
duplicated address/port formatting and create/append fork into one
path using slices.Contains. Behavior-preserving: host.go drops ~90
lines with the existing service/controller tests green.

Frontend: drop the Partial union and two as-casts in HostsPage.onSave
(the modal always passes a full BulkAddHostValues), and remove the
movable index map in HostList in favor of the table render index arg.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
AmirRnz
2026-07-09 01:05:20 +03:30
committed by GitHub
parent f431e9cc03
commit 42690e1b8c
33 changed files with 1842 additions and 1108 deletions
File diff suppressed because it is too large Load Diff
+19 -19
View File
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys'; import { keys } from '@/api/queryKeys';
import type { HostFormValues } from '@/schemas/api/host'; import type { BulkAddHostValues } from '@/schemas/api/host';
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } }; const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
@@ -10,51 +10,51 @@ export function useHostMutations() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.hosts.root() }); const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.hosts.root() });
const createMut = useMutation({ const bulkCreateMut = useMutation({
mutationFn: (payload: Partial<HostFormValues>) => HttpUtil.post('/panel/api/hosts/add', payload), mutationFn: (payload: BulkAddHostValues) => HttpUtil.post('/panel/api/hosts/bulk/add', payload, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); }, onSuccess: (msg) => { if (msg?.success) invalidate(); },
}); });
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: Partial<HostFormValues> }) => mutationFn: ({ groupId, payload }: { groupId: string; payload: BulkAddHostValues }) =>
HttpUtil.post(`/panel/api/hosts/update/${id}`, payload), HttpUtil.post(`/panel/api/hosts/update/${groupId}`, payload, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); }, onSuccess: (msg) => { if (msg?.success) invalidate(); },
}); });
const removeMut = useMutation({ const removeMut = useMutation({
mutationFn: (id: number) => HttpUtil.post(`/panel/api/hosts/del/${id}`), mutationFn: (groupId: string) => HttpUtil.post(`/panel/api/hosts/del/${groupId}`),
onSuccess: (msg) => { if (msg?.success) invalidate(); }, onSuccess: (msg) => { if (msg?.success) invalidate(); },
}); });
const setEnableMut = useMutation({ const setEnableMut = useMutation({
mutationFn: ({ id, enable }: { id: number; enable: boolean }) => mutationFn: ({ groupId, enable }: { groupId: string; enable: boolean }) =>
HttpUtil.post(`/panel/api/hosts/setEnable/${id}`, { enable }), HttpUtil.post(`/panel/api/hosts/setEnable/${groupId}`, { enable }),
onSuccess: (msg) => { if (msg?.success) invalidate(); }, onSuccess: (msg) => { if (msg?.success) invalidate(); },
}); });
const reorderMut = useMutation({ const reorderMut = useMutation({
mutationFn: (ids: number[]) => HttpUtil.post('/panel/api/hosts/reorder', { ids }, JSON_HEADERS), mutationFn: (groupIds: string[]) => HttpUtil.post('/panel/api/hosts/reorder', { ids: groupIds }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); }, onSuccess: (msg) => { if (msg?.success) invalidate(); },
}); });
const bulkEnableMut = useMutation({ const bulkEnableMut = useMutation({
mutationFn: ({ ids, enable }: { ids: number[]; enable: boolean }) => mutationFn: ({ groupIds, enable }: { groupIds: string[]; enable: boolean }) =>
HttpUtil.post('/panel/api/hosts/bulk/setEnable', { ids, enable }, JSON_HEADERS), HttpUtil.post('/panel/api/hosts/bulk/setEnable', { ids: groupIds, enable }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); }, onSuccess: (msg) => { if (msg?.success) invalidate(); },
}); });
const bulkDelMut = useMutation({ const bulkDelMut = useMutation({
mutationFn: (ids: number[]) => HttpUtil.post('/panel/api/hosts/bulk/del', { ids }, JSON_HEADERS), mutationFn: (groupIds: string[]) => HttpUtil.post('/panel/api/hosts/bulk/del', { ids: groupIds }, JSON_HEADERS),
onSuccess: (msg) => { if (msg?.success) invalidate(); }, onSuccess: (msg) => { if (msg?.success) invalidate(); },
}); });
return { return {
create: (payload: Partial<HostFormValues>) => createMut.mutateAsync(payload), bulkCreate: (payload: BulkAddHostValues) => bulkCreateMut.mutateAsync(payload),
update: (id: number, payload: Partial<HostFormValues>) => updateMut.mutateAsync({ id, payload }), update: (groupId: string, payload: BulkAddHostValues) => updateMut.mutateAsync({ groupId, payload }),
remove: (id: number) => removeMut.mutateAsync(id), remove: (groupId: string) => removeMut.mutateAsync(groupId),
setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }), setEnable: (groupId: string, enable: boolean) => setEnableMut.mutateAsync({ groupId, enable }),
reorder: (ids: number[]) => reorderMut.mutateAsync(ids), reorder: (groupIds: string[]) => reorderMut.mutateAsync(groupIds),
bulkSetEnable: (ids: number[], enable: boolean) => bulkEnableMut.mutateAsync({ ids, enable }), bulkSetEnable: (groupIds: string[], enable: boolean) => bulkEnableMut.mutateAsync({ groupIds, enable }),
bulkDel: (ids: number[]) => bulkDelMut.mutateAsync(ids), bulkDel: (groupIds: string[]) => bulkDelMut.mutateAsync(groupIds),
}; };
} }
+48
View File
@@ -314,6 +314,7 @@ export const EXAMPLES: Record<string, unknown> = {
], ],
"finalMask": "", "finalMask": "",
"fingerprint": "", "fingerprint": "",
"groupId": "",
"hostHeader": "", "hostHeader": "",
"id": 1, "id": 1,
"inboundId": 1, "inboundId": 1,
@@ -346,6 +347,53 @@ export const EXAMPLES: Record<string, unknown> = {
"verifyPeerCertByName": "", "verifyPeerCertByName": "",
"vlessRoute": "443" "vlessRoute": "443"
}, },
"HostGroup": {
"allowInsecure": false,
"alpn": [
""
],
"echConfigList": "",
"excludeFromSubTypes": [
""
],
"finalMask": "",
"fingerprint": "",
"groupId": "",
"hostHeader": "",
"hosts": [
""
],
"inboundIds": [
0
],
"isDisabled": false,
"isHidden": false,
"keepSniBlank": false,
"mihomoIpVersion": "dual",
"mihomoX25519": false,
"muxParams": "",
"nodeGuids": [
""
],
"overrideSniFromAddress": false,
"path": "",
"pinnedPeerCertSha256": [
""
],
"port": 0,
"remark": "",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sockoptParams": "",
"sortOrder": 0,
"tags": [
""
],
"verifyPeerCertByName": "",
"vlessRoute": ""
},
"Inbound": { "Inbound": {
"clientStats": [ "clientStats": [
{ {
+174 -159
View File
@@ -1,23 +1,18 @@
// Code generated by tools/openapigen. DO NOT EDIT. // Code generated by tools/openapigen. DO NOT EDIT.
export const SCHEMAS: Record<string, unknown> = { export const SCHEMAS: Record<string, unknown> = {
"AllSetting": { "AllSetting": {
"description": "AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.",
"properties": { "properties": {
"datepicker": { "datepicker": {
"description": "Date picker format",
"type": "string" "type": "string"
}, },
"expireDiff": { "expireDiff": {
"description": "Expiration warning threshold in days",
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"externalTrafficInformEnable": { "externalTrafficInformEnable": {
"description": "Enable external traffic reporting",
"type": "boolean" "type": "boolean"
}, },
"externalTrafficInformURI": { "externalTrafficInformURI": {
"description": "URI for external traffic reporting",
"type": "string" "type": "string"
}, },
"ldapAutoCreate": { "ldapAutoCreate": {
@@ -45,11 +40,9 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "integer" "type": "integer"
}, },
"ldapEnable": { "ldapEnable": {
"description": "LDAP settings",
"type": "boolean" "type": "boolean"
}, },
"ldapFlagField": { "ldapFlagField": {
"description": "Generic flag configuration",
"type": "string" "type": "string"
}, },
"ldapHost": { "ldapHost": {
@@ -82,7 +75,6 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "boolean" "type": "boolean"
}, },
"ldapUserAttr": { "ldapUserAttr": {
"description": "e.g., mail or uid",
"type": "string" "type": "string"
}, },
"ldapUserFilter": { "ldapUserFilter": {
@@ -92,299 +84,231 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string" "type": "string"
}, },
"pageSize": { "pageSize": {
"description": "UI settings\nNumber of items per page in lists (0 disables pagination)",
"maximum": 1000, "maximum": 1000,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"panelOutbound": { "panelOutbound": {
"description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
"type": "string" "type": "string"
}, },
"remarkTemplate": { "remarkTemplate": {
"description": "Subscription remark template ({{VAR}} tokens) rendered per client",
"type": "string" "type": "string"
}, },
"restartXrayOnClientDisable": { "restartXrayOnClientDisable": {
"description": "Restart Xray when clients are auto-disabled by expiry/traffic limit",
"type": "boolean" "type": "boolean"
}, },
"sessionMaxAge": { "sessionMaxAge": {
"description": "Session maximum age in minutes (cap at one year)",
"maximum": 525600, "maximum": 525600,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"smtpCpu": { "smtpCpu": {
"description": "CPU threshold for email notifications",
"maximum": 100, "maximum": 100,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"smtpEnable": { "smtpEnable": {
"description": "Email (SMTP) notification settings\nEnable email notifications",
"type": "boolean" "type": "boolean"
}, },
"smtpEnabledEvents": { "smtpEnabledEvents": {
"description": "Comma-separated event types to send via email",
"type": "string" "type": "string"
}, },
"smtpEncryptionType": { "smtpEncryptionType": {
"description": "SMTP encryption: none, starttls, tls",
"type": "string" "type": "string"
}, },
"smtpHost": { "smtpHost": {
"description": "SMTP server host",
"type": "string" "type": "string"
}, },
"smtpMemory": { "smtpMemory": {
"description": "Memory threshold for email notifications",
"maximum": 100, "maximum": 100,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"smtpPassword": { "smtpPassword": {
"description": "SMTP password",
"type": "string" "type": "string"
}, },
"smtpPort": { "smtpPort": {
"description": "SMTP server port",
"maximum": 65535, "maximum": 65535,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"smtpTo": { "smtpTo": {
"description": "Comma-separated recipient emails",
"type": "string" "type": "string"
}, },
"smtpUsername": { "smtpUsername": {
"description": "SMTP username",
"type": "string" "type": "string"
}, },
"subAnnounce": { "subAnnounce": {
"description": "Subscription announce",
"type": "string" "type": "string"
}, },
"subCertFile": { "subCertFile": {
"description": "SSL certificate file for subscription server",
"type": "string" "type": "string"
}, },
"subClashEnable": { "subClashEnable": {
"description": "Enable Clash/Mihomo subscription endpoint",
"type": "boolean" "type": "boolean"
}, },
"subClashEnableRouting": { "subClashEnableRouting": {
"description": "Enable global routing rules for Clash/Mihomo",
"type": "boolean" "type": "boolean"
}, },
"subClashPath": { "subClashPath": {
"description": "Path for Clash/Mihomo subscription endpoint",
"type": "string" "type": "string"
}, },
"subClashRules": { "subClashRules": {
"description": "Clash/Mihomo global routing rules",
"type": "string" "type": "string"
}, },
"subClashURI": { "subClashURI": {
"description": "Clash/Mihomo subscription server URI",
"type": "string" "type": "string"
}, },
"subDomain": { "subDomain": {
"description": "Domain for subscription server validation",
"type": "string" "type": "string"
}, },
"subEnable": { "subEnable": {
"description": "Subscription server settings\nEnable subscription server",
"type": "boolean" "type": "boolean"
}, },
"subEnableRouting": { "subEnableRouting": {
"description": "Enable routing for subscription",
"type": "boolean" "type": "boolean"
}, },
"subEncrypt": { "subEncrypt": {
"description": "Encrypt subscription responses",
"type": "boolean" "type": "boolean"
}, },
"subHideSettings": { "subHideSettings": {
"description": "Hide server settings in happ subscription (Only for Happ)",
"type": "boolean" "type": "boolean"
}, },
"subIncyEnableRouting": { "subIncyEnableRouting": {
"description": "Enable routing injection for the Incy client",
"type": "boolean" "type": "boolean"
}, },
"subIncyRoutingRules": { "subIncyRoutingRules": {
"description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
"type": "string" "type": "string"
}, },
"subJsonEnable": { "subJsonEnable": {
"description": "Enable JSON subscription endpoint",
"type": "boolean" "type": "boolean"
}, },
"subJsonFinalMask": { "subJsonFinalMask": {
"description": "JSON subscription global finalmask (tcp/udp masks + quicParams)",
"type": "string" "type": "string"
}, },
"subJsonMux": { "subJsonMux": {
"description": "JSON subscription mux configuration",
"type": "string" "type": "string"
}, },
"subJsonPath": { "subJsonPath": {
"description": "Path for JSON subscription endpoint",
"type": "string" "type": "string"
}, },
"subJsonRules": { "subJsonRules": {
"type": "string" "type": "string"
}, },
"subJsonURI": { "subJsonURI": {
"description": "JSON subscription server URI",
"type": "string" "type": "string"
}, },
"subKeyFile": { "subKeyFile": {
"description": "SSL private key file for subscription server",
"type": "string" "type": "string"
}, },
"subListen": { "subListen": {
"description": "Subscription server listen IP",
"type": "string" "type": "string"
}, },
"subPath": { "subPath": {
"description": "Base path for subscription URLs",
"type": "string" "type": "string"
}, },
"subPort": { "subPort": {
"description": "Subscription server port",
"maximum": 65535, "maximum": 65535,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileUrl": { "subProfileUrl": {
"description": "Subscription profile URL",
"type": "string" "type": "string"
}, },
"subRoutingRules": { "subRoutingRules": {
"description": "Subscription global routing rules (Only for Happ)",
"type": "string" "type": "string"
}, },
"subSupportUrl": { "subSupportUrl": {
"description": "Subscription support URL",
"type": "string" "type": "string"
}, },
"subThemeDir": { "subThemeDir": {
"description": "Absolute path to a folder containing a custom subscription page template",
"type": "string" "type": "string"
}, },
"subTitle": { "subTitle": {
"description": "Subscription title",
"type": "string" "type": "string"
}, },
"subURI": { "subURI": {
"description": "Subscription server URI",
"type": "string" "type": "string"
}, },
"subUpdates": { "subUpdates": {
"description": "Subscription update interval in minutes",
"maximum": 525600, "maximum": 525600,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"tgBotAPIServer": { "tgBotAPIServer": {
"description": "Custom API server for Telegram bot",
"type": "string" "type": "string"
}, },
"tgBotBackup": { "tgBotBackup": {
"description": "Enable database backup via Telegram",
"type": "boolean" "type": "boolean"
}, },
"tgBotChatId": { "tgBotChatId": {
"description": "Telegram chat ID for notifications",
"type": "string" "type": "string"
}, },
"tgBotEnable": { "tgBotEnable": {
"description": "Telegram bot settings\nEnable Telegram bot notifications",
"type": "boolean" "type": "boolean"
}, },
"tgBotProxy": { "tgBotProxy": {
"description": "Proxy URL for Telegram bot",
"type": "string" "type": "string"
}, },
"tgBotToken": { "tgBotToken": {
"description": "Telegram bot token",
"type": "string" "type": "string"
}, },
"tgCpu": { "tgCpu": {
"description": "CPU usage threshold for alerts (percent)",
"maximum": 100, "maximum": 100,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"tgEnabledEvents": { "tgEnabledEvents": {
"description": "Comma-separated event types to send via Telegram",
"type": "string" "type": "string"
}, },
"tgLang": { "tgLang": {
"description": "Telegram bot language",
"type": "string" "type": "string"
}, },
"tgMemory": { "tgMemory": {
"description": "Memory usage threshold for alerts (percent)",
"maximum": 100, "maximum": 100,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"tgRunTime": { "tgRunTime": {
"description": "Cron schedule for Telegram notifications",
"type": "string" "type": "string"
}, },
"timeLocation": { "timeLocation": {
"description": "Security settings\nTime zone location",
"type": "string" "type": "string"
}, },
"trafficDiff": { "trafficDiff": {
"description": "Traffic warning threshold percentage",
"maximum": 100, "maximum": 100,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"trustedProxyCIDRs": { "trustedProxyCIDRs": {
"description": "Trusted reverse proxy IPs/CIDRs for forwarded headers",
"type": "string" "type": "string"
}, },
"twoFactorEnable": { "twoFactorEnable": {
"description": "Enable two-factor authentication",
"type": "boolean" "type": "boolean"
}, },
"twoFactorToken": { "twoFactorToken": {
"description": "Two-factor authentication token",
"type": "string" "type": "string"
}, },
"warpUpdateInterval": { "warpUpdateInterval": {
"description": "WARP",
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"webBasePath": { "webBasePath": {
"description": "Base path for web panel URLs",
"type": "string" "type": "string"
}, },
"webCertFile": { "webCertFile": {
"description": "Path to SSL certificate file for web server",
"type": "string" "type": "string"
}, },
"webDomain": { "webDomain": {
"description": "Web server domain for domain validation",
"type": "string" "type": "string"
}, },
"webKeyFile": { "webKeyFile": {
"description": "Path to SSL private key file for web server",
"type": "string" "type": "string"
}, },
"webListen": { "webListen": {
"description": "Web server settings\nWeb server listen IP address",
"type": "string" "type": "string"
}, },
"webPort": { "webPort": {
"description": "Web server port number",
"maximum": 65535, "maximum": 65535,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
@@ -489,23 +413,18 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "object" "type": "object"
}, },
"AllSettingView": { "AllSettingView": {
"description": "AllSettingView is the browser-safe settings read model. Secret values\nare redacted from the embedded write model and represented by presence\nflags so the UI can show configured/not configured state.",
"properties": { "properties": {
"datepicker": { "datepicker": {
"description": "Date picker format",
"type": "string" "type": "string"
}, },
"expireDiff": { "expireDiff": {
"description": "Expiration warning threshold in days",
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"externalTrafficInformEnable": { "externalTrafficInformEnable": {
"description": "Enable external traffic reporting",
"type": "boolean" "type": "boolean"
}, },
"externalTrafficInformURI": { "externalTrafficInformURI": {
"description": "URI for external traffic reporting",
"type": "string" "type": "string"
}, },
"hasApiToken": { "hasApiToken": {
@@ -554,11 +473,9 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "integer" "type": "integer"
}, },
"ldapEnable": { "ldapEnable": {
"description": "LDAP settings",
"type": "boolean" "type": "boolean"
}, },
"ldapFlagField": { "ldapFlagField": {
"description": "Generic flag configuration",
"type": "string" "type": "string"
}, },
"ldapHost": { "ldapHost": {
@@ -591,7 +508,6 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "boolean" "type": "boolean"
}, },
"ldapUserAttr": { "ldapUserAttr": {
"description": "e.g., mail or uid",
"type": "string" "type": "string"
}, },
"ldapUserFilter": { "ldapUserFilter": {
@@ -601,299 +517,231 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string" "type": "string"
}, },
"pageSize": { "pageSize": {
"description": "UI settings\nNumber of items per page in lists (0 disables pagination)",
"maximum": 1000, "maximum": 1000,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"panelOutbound": { "panelOutbound": {
"description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
"type": "string" "type": "string"
}, },
"remarkTemplate": { "remarkTemplate": {
"description": "Subscription remark template ({{VAR}} tokens) rendered per client",
"type": "string" "type": "string"
}, },
"restartXrayOnClientDisable": { "restartXrayOnClientDisable": {
"description": "Restart Xray when clients are auto-disabled by expiry/traffic limit",
"type": "boolean" "type": "boolean"
}, },
"sessionMaxAge": { "sessionMaxAge": {
"description": "Session maximum age in minutes (cap at one year)",
"maximum": 525600, "maximum": 525600,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"smtpCpu": { "smtpCpu": {
"description": "CPU threshold for email notifications",
"maximum": 100, "maximum": 100,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"smtpEnable": { "smtpEnable": {
"description": "Email (SMTP) notification settings\nEnable email notifications",
"type": "boolean" "type": "boolean"
}, },
"smtpEnabledEvents": { "smtpEnabledEvents": {
"description": "Comma-separated event types to send via email",
"type": "string" "type": "string"
}, },
"smtpEncryptionType": { "smtpEncryptionType": {
"description": "SMTP encryption: none, starttls, tls",
"type": "string" "type": "string"
}, },
"smtpHost": { "smtpHost": {
"description": "SMTP server host",
"type": "string" "type": "string"
}, },
"smtpMemory": { "smtpMemory": {
"description": "Memory threshold for email notifications",
"maximum": 100, "maximum": 100,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"smtpPassword": { "smtpPassword": {
"description": "SMTP password",
"type": "string" "type": "string"
}, },
"smtpPort": { "smtpPort": {
"description": "SMTP server port",
"maximum": 65535, "maximum": 65535,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"smtpTo": { "smtpTo": {
"description": "Comma-separated recipient emails",
"type": "string" "type": "string"
}, },
"smtpUsername": { "smtpUsername": {
"description": "SMTP username",
"type": "string" "type": "string"
}, },
"subAnnounce": { "subAnnounce": {
"description": "Subscription announce",
"type": "string" "type": "string"
}, },
"subCertFile": { "subCertFile": {
"description": "SSL certificate file for subscription server",
"type": "string" "type": "string"
}, },
"subClashEnable": { "subClashEnable": {
"description": "Enable Clash/Mihomo subscription endpoint",
"type": "boolean" "type": "boolean"
}, },
"subClashEnableRouting": { "subClashEnableRouting": {
"description": "Enable global routing rules for Clash/Mihomo",
"type": "boolean" "type": "boolean"
}, },
"subClashPath": { "subClashPath": {
"description": "Path for Clash/Mihomo subscription endpoint",
"type": "string" "type": "string"
}, },
"subClashRules": { "subClashRules": {
"description": "Clash/Mihomo global routing rules",
"type": "string" "type": "string"
}, },
"subClashURI": { "subClashURI": {
"description": "Clash/Mihomo subscription server URI",
"type": "string" "type": "string"
}, },
"subDomain": { "subDomain": {
"description": "Domain for subscription server validation",
"type": "string" "type": "string"
}, },
"subEnable": { "subEnable": {
"description": "Subscription server settings\nEnable subscription server",
"type": "boolean" "type": "boolean"
}, },
"subEnableRouting": { "subEnableRouting": {
"description": "Enable routing for subscription",
"type": "boolean" "type": "boolean"
}, },
"subEncrypt": { "subEncrypt": {
"description": "Encrypt subscription responses",
"type": "boolean" "type": "boolean"
}, },
"subHideSettings": { "subHideSettings": {
"description": "Hide server settings in happ subscription (Only for Happ)",
"type": "boolean" "type": "boolean"
}, },
"subIncyEnableRouting": { "subIncyEnableRouting": {
"description": "Enable routing injection for the Incy client",
"type": "boolean" "type": "boolean"
}, },
"subIncyRoutingRules": { "subIncyRoutingRules": {
"description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
"type": "string" "type": "string"
}, },
"subJsonEnable": { "subJsonEnable": {
"description": "Enable JSON subscription endpoint",
"type": "boolean" "type": "boolean"
}, },
"subJsonFinalMask": { "subJsonFinalMask": {
"description": "JSON subscription global finalmask (tcp/udp masks + quicParams)",
"type": "string" "type": "string"
}, },
"subJsonMux": { "subJsonMux": {
"description": "JSON subscription mux configuration",
"type": "string" "type": "string"
}, },
"subJsonPath": { "subJsonPath": {
"description": "Path for JSON subscription endpoint",
"type": "string" "type": "string"
}, },
"subJsonRules": { "subJsonRules": {
"type": "string" "type": "string"
}, },
"subJsonURI": { "subJsonURI": {
"description": "JSON subscription server URI",
"type": "string" "type": "string"
}, },
"subKeyFile": { "subKeyFile": {
"description": "SSL private key file for subscription server",
"type": "string" "type": "string"
}, },
"subListen": { "subListen": {
"description": "Subscription server listen IP",
"type": "string" "type": "string"
}, },
"subPath": { "subPath": {
"description": "Base path for subscription URLs",
"type": "string" "type": "string"
}, },
"subPort": { "subPort": {
"description": "Subscription server port",
"maximum": 65535, "maximum": 65535,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"subProfileUrl": { "subProfileUrl": {
"description": "Subscription profile URL",
"type": "string" "type": "string"
}, },
"subRoutingRules": { "subRoutingRules": {
"description": "Subscription global routing rules (Only for Happ)",
"type": "string" "type": "string"
}, },
"subSupportUrl": { "subSupportUrl": {
"description": "Subscription support URL",
"type": "string" "type": "string"
}, },
"subThemeDir": { "subThemeDir": {
"description": "Absolute path to a folder containing a custom subscription page template",
"type": "string" "type": "string"
}, },
"subTitle": { "subTitle": {
"description": "Subscription title",
"type": "string" "type": "string"
}, },
"subURI": { "subURI": {
"description": "Subscription server URI",
"type": "string" "type": "string"
}, },
"subUpdates": { "subUpdates": {
"description": "Subscription update interval in minutes",
"maximum": 525600, "maximum": 525600,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"tgBotAPIServer": { "tgBotAPIServer": {
"description": "Custom API server for Telegram bot",
"type": "string" "type": "string"
}, },
"tgBotBackup": { "tgBotBackup": {
"description": "Enable database backup via Telegram",
"type": "boolean" "type": "boolean"
}, },
"tgBotChatId": { "tgBotChatId": {
"description": "Telegram chat ID for notifications",
"type": "string" "type": "string"
}, },
"tgBotEnable": { "tgBotEnable": {
"description": "Telegram bot settings\nEnable Telegram bot notifications",
"type": "boolean" "type": "boolean"
}, },
"tgBotProxy": { "tgBotProxy": {
"description": "Proxy URL for Telegram bot",
"type": "string" "type": "string"
}, },
"tgBotToken": { "tgBotToken": {
"description": "Telegram bot token",
"type": "string" "type": "string"
}, },
"tgCpu": { "tgCpu": {
"description": "CPU usage threshold for alerts (percent)",
"maximum": 100, "maximum": 100,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"tgEnabledEvents": { "tgEnabledEvents": {
"description": "Comma-separated event types to send via Telegram",
"type": "string" "type": "string"
}, },
"tgLang": { "tgLang": {
"description": "Telegram bot language",
"type": "string" "type": "string"
}, },
"tgMemory": { "tgMemory": {
"description": "Memory usage threshold for alerts (percent)",
"maximum": 100, "maximum": 100,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"tgRunTime": { "tgRunTime": {
"description": "Cron schedule for Telegram notifications",
"type": "string" "type": "string"
}, },
"timeLocation": { "timeLocation": {
"description": "Security settings\nTime zone location",
"type": "string" "type": "string"
}, },
"trafficDiff": { "trafficDiff": {
"description": "Traffic warning threshold percentage",
"maximum": 100, "maximum": 100,
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"trustedProxyCIDRs": { "trustedProxyCIDRs": {
"description": "Trusted reverse proxy IPs/CIDRs for forwarded headers",
"type": "string" "type": "string"
}, },
"twoFactorEnable": { "twoFactorEnable": {
"description": "Enable two-factor authentication",
"type": "boolean" "type": "boolean"
}, },
"twoFactorToken": { "twoFactorToken": {
"description": "Two-factor authentication token",
"type": "string" "type": "string"
}, },
"warpUpdateInterval": { "warpUpdateInterval": {
"description": "WARP",
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"webBasePath": { "webBasePath": {
"description": "Base path for web panel URLs",
"type": "string" "type": "string"
}, },
"webCertFile": { "webCertFile": {
"description": "Path to SSL certificate file for web server",
"type": "string" "type": "string"
}, },
"webDomain": { "webDomain": {
"description": "Web server domain for domain validation",
"type": "string" "type": "string"
}, },
"webKeyFile": { "webKeyFile": {
"description": "Path to SSL private key file for web server",
"type": "string" "type": "string"
}, },
"webListen": { "webListen": {
"description": "Web server settings\nWeb server listen IP address",
"type": "string" "type": "string"
}, },
"webPort": { "webPort": {
"description": "Web server port number",
"maximum": 65535, "maximum": 65535,
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
@@ -1427,7 +1275,6 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "object" "type": "object"
}, },
"Host": { "Host": {
"description": "Host is an override endpoint attached to an inbound: at subscription time each\nenabled host renders one share link/proxy with its own address/port/TLS/etc.,\nsuperseding the legacy externalProxy array. Free-JSON fields are stored as\ntext and parsed in the sub layer; slice fields use the json serializer.",
"properties": { "properties": {
"address": { "address": {
"example": "cdn.example.com", "example": "cdn.example.com",
@@ -1461,6 +1308,9 @@ export const SCHEMAS: Record<string, unknown> = {
"fingerprint": { "fingerprint": {
"type": "string" "type": "string"
}, },
"groupId": {
"type": "string"
},
"hostHeader": { "hostHeader": {
"type": "string" "type": "string"
}, },
@@ -1575,6 +1425,7 @@ export const SCHEMAS: Record<string, unknown> = {
"excludeFromSubTypes", "excludeFromSubTypes",
"finalMask", "finalMask",
"fingerprint", "fingerprint",
"groupId",
"hostHeader", "hostHeader",
"id", "id",
"inboundId", "inboundId",
@@ -1602,6 +1453,175 @@ export const SCHEMAS: Record<string, unknown> = {
], ],
"type": "object" "type": "object"
}, },
"HostGroup": {
"properties": {
"allowInsecure": {
"type": "boolean"
},
"alpn": {
"items": {
"type": "string"
},
"type": "array"
},
"echConfigList": {
"type": "string"
},
"excludeFromSubTypes": {
"items": {
"type": "string"
},
"type": "array"
},
"finalMask": {
"type": "string"
},
"fingerprint": {
"type": "string"
},
"groupId": {
"type": "string"
},
"hostHeader": {
"type": "string"
},
"hosts": {
"items": {
"type": "string"
},
"type": "array"
},
"inboundIds": {
"items": {
"type": "integer"
},
"type": "array"
},
"isDisabled": {
"type": "boolean"
},
"isHidden": {
"type": "boolean"
},
"keepSniBlank": {
"type": "boolean"
},
"mihomoIpVersion": {
"enum": [
"dual",
"ipv4",
"ipv6",
"ipv4-prefer",
"ipv6-prefer"
],
"type": "string"
},
"mihomoX25519": {
"type": "boolean"
},
"muxParams": {
"type": "string"
},
"nodeGuids": {
"items": {
"type": "string"
},
"type": "array"
},
"overrideSniFromAddress": {
"type": "boolean"
},
"path": {
"type": "string"
},
"pinnedPeerCertSha256": {
"items": {
"type": "string"
},
"type": "array"
},
"port": {
"maximum": 65535,
"minimum": 0,
"type": "integer"
},
"remark": {
"maxLength": 256,
"type": "string"
},
"security": {
"enum": [
"same",
"tls",
"none",
"reality"
],
"type": "string"
},
"serverDescription": {
"maxLength": 64,
"type": "string"
},
"shuffleHost": {
"type": "boolean"
},
"sni": {
"type": "string"
},
"sockoptParams": {
"type": "string"
},
"sortOrder": {
"type": "integer"
},
"tags": {
"items": {
"type": "string"
},
"type": "array"
},
"verifyPeerCertByName": {
"type": "string"
},
"vlessRoute": {
"type": "string"
}
},
"required": [
"allowInsecure",
"alpn",
"echConfigList",
"excludeFromSubTypes",
"finalMask",
"fingerprint",
"groupId",
"hostHeader",
"hosts",
"inboundIds",
"isDisabled",
"isHidden",
"keepSniBlank",
"mihomoIpVersion",
"mihomoX25519",
"muxParams",
"nodeGuids",
"overrideSniFromAddress",
"path",
"pinnedPeerCertSha256",
"port",
"remark",
"security",
"serverDescription",
"shuffleHost",
"sni",
"sockoptParams",
"sortOrder",
"tags",
"verifyPeerCertByName",
"vlessRoute"
],
"type": "object"
},
"Inbound": { "Inbound": {
"description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.", "description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.",
"properties": { "properties": {
@@ -1889,17 +1909,12 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "object" "type": "object"
}, },
"Msg": { "Msg": {
"description": "Msg represents a standard API response message with success status, message text, and optional data object.",
"properties": { "properties": {
"msg": { "msg": {
"description": "Response message text",
"type": "string" "type": "string"
}, },
"obj": { "obj": {},
"description": "Optional data object"
},
"success": { "success": {
"description": "Indicates if the operation was successful",
"type": "boolean" "type": "boolean"
} }
}, },
+35
View File
@@ -325,6 +325,7 @@ export interface Host {
excludeFromSubTypes: string[]; excludeFromSubTypes: string[];
finalMask: string; finalMask: string;
fingerprint: string; fingerprint: string;
groupId: string;
hostHeader: string; hostHeader: string;
id: number; id: number;
inboundId: number; inboundId: number;
@@ -352,6 +353,40 @@ export interface Host {
vlessRoute: string; vlessRoute: string;
} }
export interface HostGroup {
allowInsecure: boolean;
alpn: string[];
echConfigList: string;
excludeFromSubTypes: string[];
finalMask: string;
fingerprint: string;
groupId: string;
hostHeader: string;
hosts: string[];
inboundIds: number[];
isDisabled: boolean;
isHidden: boolean;
keepSniBlank: boolean;
mihomoIpVersion: string;
mihomoX25519: boolean;
muxParams: string;
nodeGuids: string[];
overrideSniFromAddress: boolean;
path: string;
pinnedPeerCertSha256: string[];
port: number;
remark: string;
security: string;
serverDescription: string;
shuffleHost: boolean;
sni: string;
sockoptParams: string;
sortOrder: number;
tags: string[];
verifyPeerCertByName: string;
vlessRoute: string;
}
export interface Inbound { export interface Inbound {
clientStats: ClientTraffic[]; clientStats: ClientTraffic[];
down: number; down: number;
+36
View File
@@ -348,6 +348,7 @@ export const HostSchema = z.object({
excludeFromSubTypes: z.array(z.string()), excludeFromSubTypes: z.array(z.string()),
finalMask: z.string(), finalMask: z.string(),
fingerprint: z.string(), fingerprint: z.string(),
groupId: z.string(),
hostHeader: z.string(), hostHeader: z.string(),
id: z.number().int(), id: z.number().int(),
inboundId: z.number().int(), inboundId: z.number().int(),
@@ -376,6 +377,41 @@ export const HostSchema = z.object({
}); });
export type Host = z.infer<typeof HostSchema>; export type Host = z.infer<typeof HostSchema>;
export const HostGroupSchema = z.object({
allowInsecure: z.boolean(),
alpn: z.array(z.string()),
echConfigList: z.string(),
excludeFromSubTypes: z.array(z.string()),
finalMask: z.string(),
fingerprint: z.string(),
groupId: z.string(),
hostHeader: z.string(),
hosts: z.array(z.string()),
inboundIds: z.array(z.number().int()),
isDisabled: z.boolean(),
isHidden: z.boolean(),
keepSniBlank: z.boolean(),
mihomoIpVersion: z.enum(['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer']),
mihomoX25519: z.boolean(),
muxParams: z.string(),
nodeGuids: z.array(z.string()),
overrideSniFromAddress: z.boolean(),
path: z.string(),
pinnedPeerCertSha256: z.array(z.string()),
port: z.number().int().min(0).max(65535),
remark: z.string().max(256),
security: z.enum(['same', 'tls', 'none', 'reality']),
serverDescription: z.string().max(64),
shuffleHost: z.boolean(),
sni: z.string(),
sockoptParams: z.string(),
sortOrder: z.number().int(),
tags: z.array(z.string()),
verifyPeerCertByName: z.string(),
vlessRoute: z.string(),
});
export type HostGroup = z.infer<typeof HostGroupSchema>;
export const InboundSchema = z.object({ export const InboundSchema = z.object({
clientStats: z.array(z.lazy(() => ClientTrafficSchema)), clientStats: z.array(z.lazy(() => ClientTrafficSchema)),
down: z.number().int(), down: z.number().int(),
+35 -25
View File
@@ -1029,26 +1029,26 @@ export const sections: readonly Section[] = [
method: 'GET', method: 'GET',
path: '/panel/api/hosts/list', path: '/panel/api/hosts/list',
summary: 'List every host across all inbounds, grouped by inbound then ordered by sort order.', summary: 'List every host across all inbounds, grouped by inbound then ordered by sort order.',
responseSchema: 'Host', responseSchema: 'HostGroup',
responseSchemaArray: true, responseSchemaArray: true,
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/hosts/get/:id', path: '/panel/api/hosts/get/:groupId',
summary: 'Fetch a single host by ID.', summary: 'Fetch a single host group by Group ID.',
params: [ params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' }, { name: 'groupId', in: 'path', type: 'string', desc: 'Host Group ID.' },
], ],
responseSchema: 'Host', responseSchema: 'HostGroup',
}, },
{ {
method: 'GET', method: 'GET',
path: '/panel/api/hosts/byInbound/:inboundId', path: '/panel/api/hosts/byInbound/:inboundId',
summary: "Fetch one inbound's hosts, ordered by sort order then id.", summary: "Fetch one inbound's hosts, grouped by host group.",
params: [ params: [
{ name: 'inboundId', in: 'path', type: 'number', desc: 'Inbound ID.' }, { name: 'inboundId', in: 'path', type: 'number', desc: 'Inbound ID.' },
], ],
responseSchema: 'Host', responseSchema: 'HostGroup',
responseSchemaArray: true, responseSchemaArray: true,
}, },
{ {
@@ -1060,54 +1060,64 @@ export const sections: readonly Section[] = [
{ {
method: 'POST', method: 'POST',
path: '/panel/api/hosts/add', path: '/panel/api/hosts/add',
summary: 'Create a host on an inbound. inboundId and remark are required; security defaults to "same" (inherit the inbound).', summary: 'Create a host group on inbounds.',
body: '{\n "inboundId": 1,\n "remark": "cdn-front",\n "address": "cdn.example.com",\n "port": 8443,\n "security": "same",\n "sni": "",\n "tags": ["CDN"]\n}', body: '{\n "inboundIds": [1],\n "remark": "cdn-front",\n "hosts": ["cdn.example.com"],\n "port": 8443,\n "security": "same",\n "tags": ["CDN"]\n}',
responseSchema: 'Host', responseSchema: 'Host',
responseSchemaArray: true,
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/hosts/update/:id', path: '/panel/api/hosts/update/:groupId',
summary: 'Replace a hosts content. The inbound and sort order are immutable here (use /reorder for ordering).', summary: 'Replace a host groups content.',
params: [ params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' }, { name: 'groupId', in: 'path', type: 'string', desc: 'Host Group ID.' },
], ],
body: '{\n "inboundId": 1,\n "remark": "cdn-front",\n "address": "cdn.example.com",\n "port": 8443,\n "security": "same",\n "sni": "",\n "tags": ["CDN"]\n}', body: '{\n "inboundIds": [1],\n "remark": "cdn-front",\n "hosts": ["cdn.example.com"],\n "port": 8443,\n "security": "same",\n "tags": ["CDN"]\n}',
responseSchema: 'Host', responseSchema: 'Host',
responseSchemaArray: true,
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/hosts/del/:id', path: '/panel/api/hosts/del/:groupId',
summary: 'Delete a host.', summary: 'Delete a host group.',
params: [ params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' }, { name: 'groupId', in: 'path', type: 'string', desc: 'Host Group ID.' },
], ],
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/hosts/setEnable/:id', path: '/panel/api/hosts/setEnable/:groupId',
summary: 'Enable or disable a single host (disabled hosts are skipped in subscriptions).', summary: 'Enable or disable a host group.',
params: [ params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Host ID.' }, { name: 'groupId', in: 'path', type: 'string', desc: 'Host Group ID.' },
], ],
body: '{\n "enable": true\n}', body: '{\n "enable": true\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/hosts/reorder', path: '/panel/api/hosts/reorder',
summary: 'Set host sort order by the position of each id in the array.', summary: 'Set host group sort order by the position of each groupId in the array.',
body: '{\n "ids": [3, 1, 2]\n}', body: '{\n "ids": ["abc-123", "def-456"]\n}',
},
{
method: 'POST',
path: '/panel/api/hosts/bulk/add',
summary: 'Add a host group to inbounds (same as /add).',
body: '{\n "inboundIds": [1, 2],\n "hosts": ["cdn.example.com", "cdn2.example.com:443"],\n "remark": "Cloudflare CDN",\n "port": 0,\n "security": "same",\n "isDisabled": false\n}',
responseSchema: 'Host',
responseSchemaArray: true,
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/hosts/bulk/setEnable', path: '/panel/api/hosts/bulk/setEnable',
summary: 'Enable or disable many hosts in one call.', summary: 'Enable or disable many host groups in one call.',
body: '{\n "ids": [1, 2, 3],\n "enable": false\n}', body: '{\n "ids": ["abc-123", "def-456"],\n "enable": false\n}',
}, },
{ {
method: 'POST', method: 'POST',
path: '/panel/api/hosts/bulk/del', path: '/panel/api/hosts/bulk/del',
summary: 'Delete many hosts in one call.', summary: 'Delete many host groups in one call.',
body: '{\n "ids": [1, 2, 3]\n}', body: '{\n "ids": ["abc-123", "def-456"]\n}',
}, },
], ],
}, },
+67 -35
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd'; import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd';
import { import {
@@ -14,7 +14,7 @@ import {
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import type { HostRecord } from '@/api/queries/useHostsQuery'; import type { HostRecord } from '@/api/queries/useHostsQuery';
import { HostFormSchema, type HostFormValues } from '@/schemas/api/host'; import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
import type { InboundOption } from '@/schemas/client'; import type { InboundOption } from '@/schemas/client';
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives'; import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { FormField, rhfZodValidate } from '@/components/form/rhf';
@@ -23,18 +23,17 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel'; import { catTabLabel } from '@/pages/settings/catTabLabel';
import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms'; import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
/* type FormShape = Omit<BulkAddHostValues, 'isDisabled'> & {
* inboundId is optional in the form so a new host starts unselected (the Select enable: boolean;
* shows its placeholder instead of 0); the required rule enforces it on submit. };
*/
type FormShape = Omit<HostFormValues, 'isDisabled' | 'inboundId'> & { enable: boolean; inboundId?: number };
interface HostFormModalProps { interface HostFormModalProps {
open: boolean; open: boolean;
mode: 'add' | 'edit'; mode: 'add' | 'edit';
host: HostRecord | null; host: HostRecord | null;
inboundOptions: InboundOption[]; inboundOptions: InboundOption[];
save: (payload: Partial<HostFormValues>) => Promise<{ success?: boolean; msg?: string } | undefined>; existingHosts: HostRecord[];
save: (payload: BulkAddHostValues) => Promise<{ success?: boolean; msg?: string } | undefined>;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
@@ -42,21 +41,21 @@ const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
function defaultsFor(host: HostRecord | null): FormShape { function defaultsFor(host: HostRecord | null): FormShape {
return { return {
inboundId: host?.inboundId, inboundIds: host?.inboundIds ?? [],
hosts: (host?.hosts || []).filter((h) => h && h.trim() !== ''),
sortOrder: host?.sortOrder ?? 0, sortOrder: host?.sortOrder ?? 0,
remark: host?.remark ?? '', remark: host?.remark ?? '',
serverDescription: host?.serverDescription ?? '', serverDescription: host?.serverDescription ?? '',
enable: host ? !host.isDisabled : true, enable: host ? !host.isDisabled : true,
isHidden: host?.isHidden ?? false, isHidden: host?.isHidden ?? false,
tags: host?.tags ?? [], tags: host?.tags ?? [],
address: host?.address ?? '',
port: host?.port ?? 0, port: host?.port ?? 0,
security: (host?.security as HostFormValues['security']) ?? 'same', security: (host?.security as BulkAddHostValues['security']) ?? 'same',
sni: host?.sni ?? '', sni: host?.sni ?? '',
hostHeader: host?.hostHeader ?? '', hostHeader: host?.hostHeader ?? '',
path: host?.path ?? '', path: host?.path ?? '',
alpn: (host?.alpn as HostFormValues['alpn']) ?? [], alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
fingerprint: host?.fingerprint as HostFormValues['fingerprint'], fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
overrideSniFromAddress: host?.overrideSniFromAddress ?? false, overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
keepSniBlank: host?.keepSniBlank ?? false, keepSniBlank: host?.keepSniBlank ?? false,
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [], pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
@@ -67,31 +66,30 @@ function defaultsFor(host: HostRecord | null): FormShape {
sockoptParams: asString(host?.sockoptParams), sockoptParams: asString(host?.sockoptParams),
finalMask: host?.finalMask ?? '', finalMask: host?.finalMask ?? '',
vlessRoute: host?.vlessRoute ?? '', vlessRoute: host?.vlessRoute ?? '',
excludeFromSubTypes: (host?.excludeFromSubTypes as HostFormValues['excludeFromSubTypes']) ?? [], excludeFromSubTypes: (host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [],
nodeGuids: host?.nodeGuids ?? [], nodeGuids: host?.nodeGuids ?? [],
mihomoIpVersion: host?.mihomoIpVersion as HostFormValues['mihomoIpVersion'], mihomoIpVersion: host?.mihomoIpVersion as BulkAddHostValues['mihomoIpVersion'],
mihomoX25519: host?.mihomoX25519 ?? false, mihomoX25519: host?.mihomoX25519 ?? false,
shuffleHost: host?.shuffleHost ?? false, shuffleHost: host?.shuffleHost ?? false,
}; };
} }
export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) { export default function HostFormModal({ open, mode, host, inboundOptions, existingHosts, save, onOpenChange }: HostFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const methods = useForm<FormShape>({ defaultValues: defaultsFor(host) }); const methods = useForm<FormShape>({ defaultValues: defaultsFor(host) });
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
/*
* Drive conditional field visibility off the selected security, like the
* legacy externalProxy form: same/none inherit fully and hide every TLS/cert
* field; reality shows only the reality-relevant subset (its keys are
* inherited from the inbound); tls shows the full TLS override set.
*/
const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string; const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
const showTls = security === 'tls' || security === 'reality'; const showTls = security === 'tls' || security === 'reality';
const showTlsExtras = security === 'tls'; const showTlsExtras = security === 'tls';
useEffect(() => { useEffect(() => {
if (open) methods.reset(defaultsFor(host)); if (open) {
methods.reset(defaultsFor(host));
setLoading(false);
}
}, [open, host, methods]); }, [open, host, methods]);
const { nodes } = useNodesQuery(); const { nodes } = useNodesQuery();
@@ -114,15 +112,42 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []); const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []); const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
const hostOptions = useMemo(() => {
const addresses = new Set<string>();
for (const h of existingHosts || []) {
if (h.hosts) {
for (const addr of h.hosts) {
if (addr && addr.trim() !== '') {
addresses.add(addr);
}
}
}
}
return Array.from(addresses).map((addr) => ({ value: addr, label: addr }));
}, [existingHosts]);
const onFinish = async (values: FormShape) => { const onFinish = async (values: FormShape) => {
if (loading) return;
const { enable, ...rest } = values; const { enable, ...rest } = values;
const payload: Partial<HostFormValues> = { ...rest, isDisabled: !enable }; const isDisabled = !enable;
const res = await save(payload); const payload: BulkAddHostValues = {
if (res?.success) { ...rest,
message.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update')); hosts: (rest.hosts || []).filter((h) => h && h.trim() !== ''),
onOpenChange(false); isDisabled,
} else if (res?.msg) { };
message.error(res.msg); setLoading(true);
try {
const res = await save(payload);
if (res?.success) {
messageApi.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'));
onOpenChange(false);
} else if (res?.msg) {
messageApi.error(res.msg);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
} }
}; };
@@ -132,12 +157,14 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')} title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
onOk={methods.handleSubmit(onFinish)} onOk={methods.handleSubmit(onFinish)}
onCancel={() => onOpenChange(false)} onCancel={() => onOpenChange(false)}
confirmLoading={loading}
okText={t('save')} okText={t('save')}
cancelText={t('cancel')} cancelText={t('cancel')}
destroyOnHidden destroyOnHidden
width={isMobile ? '95vw' : 760} width={isMobile ? '95vw' : 760}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }} styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
> >
{messageContextHolder}
<FormProvider {...methods}> <FormProvider {...methods}>
<Form <Form
colon={false} colon={false}
@@ -154,23 +181,28 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile), label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
children: ( children: (
<> <>
<FormField name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={{ validate: rhfZodValidate(HostFormSchema.shape.remark) }}> <FormField name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.remark) }}>
<Input maxLength={256} /> <Input maxLength={256} />
</FormField> </FormField>
<FormField name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}> <FormField name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
<Input maxLength={64} /> <Input maxLength={64} />
</FormField> </FormField>
<FormField name="inboundId" label={t('pages.hosts.fields.inbound')} rules={{ validate: rhfZodValidate(HostFormSchema.shape.inboundId) }}> <FormField name="inboundIds" label={t('pages.hosts.fields.inbound')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.inboundIds) }}>
<Select <Select
mode="multiple"
options={inboundSelectOptions} options={inboundSelectOptions}
showSearch showSearch
optionFilterProp="label" optionFilterProp="label"
disabled={mode === 'edit'}
placeholder={t('pages.hosts.selectInbound')} placeholder={t('pages.hosts.selectInbound')}
/> />
</FormField> </FormField>
<FormField name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}> <FormField name="hosts" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.hosts) }}>
<Input placeholder="cdn.example.com" /> <Select
mode="tags"
options={hostOptions}
tokenSeparators={[',', ';', ' ']}
placeholder="cdn.example.com, cdn2.example.com:443"
/>
</FormField> </FormField>
<FormField name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}> <FormField name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
<InputNumber min={0} max={65535} /> <InputNumber min={0} max={65535} />
+92 -37
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Card, Space, Switch, Table, Tag, Tooltip } from 'antd'; import { Button, Card, Popover, Space, Switch, Table, Tag, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
ArrowDownOutlined, ArrowDownOutlined,
@@ -20,8 +20,8 @@ interface HostListProps {
inboundOptions: InboundOption[]; inboundOptions: InboundOption[];
loading?: boolean; loading?: boolean;
isMobile?: boolean; isMobile?: boolean;
selectedIds: number[]; selectedGroupIds: string[];
onSelectionChange: (ids: number[]) => void; onSelectionChange: (groupIds: string[]) => void;
onAdd: () => void; onAdd: () => void;
onEdit: (host: HostRecord) => void; onEdit: (host: HostRecord) => void;
onDelete: (host: HostRecord) => void; onDelete: (host: HostRecord) => void;
@@ -31,56 +31,50 @@ interface HostListProps {
onBulkDelete: () => void; onBulkDelete: () => void;
} }
// Sorted by inbound then sort_order then id — the same order the subscription const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
// renderer uses, so the list mirrors the emitted link order. vless: 'blue',
function sortHosts(hosts: HostRecord[]): HostRecord[] { vmess: 'geekblue',
trojan: 'volcano',
shadowsocks: 'magenta',
hysteria: 'cyan',
hysteria2: 'green',
wireguard: 'gold',
http: 'purple',
mixed: 'lime',
tunnel: 'orange',
};
export function sortHosts(hosts: HostRecord[]): HostRecord[] {
return [...hosts].sort((a, b) => { return [...hosts].sort((a, b) => {
if (a.inboundId !== b.inboundId) return a.inboundId - b.inboundId;
const sa = a.sortOrder ?? 0; const sa = a.sortOrder ?? 0;
const sb = b.sortOrder ?? 0; const sb = b.sortOrder ?? 0;
if (sa !== sb) return sa - sb; if (sa !== sb) return sa - sb;
return a.id - b.id; return (a.remark || '').localeCompare(b.remark || '');
}); });
} }
export default function HostList(props: HostListProps) { export default function HostList(props: HostListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
hosts, inboundOptions, loading, isMobile, selectedIds, onSelectionChange, hosts, inboundOptions, loading, isMobile, selectedGroupIds, onSelectionChange,
onAdd, onEdit, onDelete, onToggleEnable, onMove, onBulkEnable, onBulkDelete, onAdd, onEdit, onDelete, onToggleEnable, onMove, onBulkEnable, onBulkDelete,
} = props; } = props;
const inboundLabel = useMemo(() => { const inboundsMap = useMemo(() => {
const map = new Map<number, string>(); const map = new Map<number, InboundOption>();
for (const ib of inboundOptions) map.set(ib.id, ib.remark || ib.tag || `#${ib.id}`); for (const ib of inboundOptions) map.set(ib.id, ib);
return map; return map;
}, [inboundOptions]); }, [inboundOptions]);
const sorted = useMemo(() => sortHosts(hosts), [hosts]); const sorted = useMemo(() => sortHosts(hosts), [hosts]);
// Move is bounded to neighbours within the same inbound (sort_order is per-inbound).
const movable = useMemo(() => {
const byInbound = new Map<number, number>();
const idxInGroup = new Map<number, number>();
const counters = new Map<number, number>();
for (const h of sorted) byInbound.set(h.inboundId, (byInbound.get(h.inboundId) ?? 0) + 1);
for (const h of sorted) {
const c = counters.get(h.inboundId) ?? 0;
idxInGroup.set(h.id, c);
counters.set(h.inboundId, c + 1);
}
return { byInbound, idxInGroup };
}, [sorted]);
// Column order requested: Actions, Enable, then the rest.
const columns: ColumnsType<HostRecord> = [ const columns: ColumnsType<HostRecord> = [
{ {
title: t('pages.hosts.fields.actions'), title: t('pages.hosts.fields.actions'),
key: 'actions', key: 'actions',
width: 168, width: 168,
render: (_, h) => { render: (_, h, idx) => {
const idx = movable.idxInGroup.get(h.id) ?? 0; const count = sorted.length;
const count = movable.byInbound.get(h.inboundId) ?? 1;
return ( return (
<Space size={2}> <Space size={2}>
<Tooltip title={t('pages.hosts.moveUp')}> <Tooltip title={t('pages.hosts.moveUp')}>
@@ -121,12 +115,73 @@ export default function HostList(props: HostListProps) {
{ {
title: t('pages.hosts.fields.endpoint'), title: t('pages.hosts.fields.endpoint'),
key: 'endpoint', key: 'endpoint',
render: (_, h) => <span className="host-endpoint">{`${h.address || '—'}${h.port ? `:${h.port}` : ''}`}</span>, render: (_, h) => {
const addrs = h.hosts?.filter(a => a.trim() !== '') || [];
if (addrs.length === 0) return <Tag color="orange">{t('pages.hosts.fields.inheritAddress') || 'inherits'}</Tag>;
const visible = addrs.slice(0, 1);
const overflow = addrs.slice(1);
return (
<>
{visible.map((addr) => <Tag key={addr}>{addr}</Tag>)}
{overflow.length > 0 && (
<Popover
trigger="click"
placement="bottomRight"
content={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
{overflow.map((addr) => <Tag key={addr}>{addr}</Tag>)}
</div>
}
>
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
+{overflow.length}
</Tag>
</Popover>
)}
</>
);
},
}, },
{ {
title: t('pages.hosts.fields.inbound'), title: t('pages.hosts.fields.inbound'),
key: 'inbound', key: 'inbound',
render: (_, h) => inboundLabel.get(h.inboundId) ?? `#${h.inboundId}`, render: (_, h) => {
const ids = h.inboundIds || [];
if (ids.length === 0) return <span className="host-muted"></span>;
const visible = ids.slice(0, 1);
const overflow = ids.slice(1);
const chip = (id: number) => {
const ib = inboundsMap.get(id);
const label = ib ? (ib.remark || ib.tag || `#${id}`) : `#${id}`;
const proto = (ib?.protocol || '').toLowerCase();
const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
return (
<Tooltip key={id} title={label}>
<Tag color={color} style={{ margin: 2 }}>{label}</Tag>
</Tooltip>
);
};
return (
<>
{visible.map(chip)}
{overflow.length > 0 && (
<Popover
trigger="click"
placement="bottomRight"
content={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
{overflow.map(chip)}
</div>
}
>
<Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
+{overflow.length}
</Tag>
</Popover>
)}
</>
);
},
}, },
{ {
title: t('pages.hosts.fields.security'), title: t('pages.hosts.fields.security'),
@@ -145,7 +200,7 @@ export default function HostList(props: HostListProps) {
const toolbar = ( const toolbar = (
<div className="card-toolbar"> <div className="card-toolbar">
{selectedIds.length === 0 ? ( {selectedGroupIds.length === 0 ? (
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}> <Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
{!isMobile && t('pages.hosts.addHost')} {!isMobile && t('pages.hosts.addHost')}
</Button> </Button>
@@ -157,7 +212,7 @@ export default function HostList(props: HostListProps) {
onClose={() => onSelectionChange([])} onClose={() => onSelectionChange([])}
style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }} style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
> >
{t('pages.hosts.selectedCount', { count: selectedIds.length })} {t('pages.hosts.selectedCount', { count: selectedGroupIds.length })}
</Tag> </Tag>
<Button onClick={() => onBulkEnable(true)}>{t('pages.hosts.bulkEnable')}</Button> <Button onClick={() => onBulkEnable(true)}>{t('pages.hosts.bulkEnable')}</Button>
<Button onClick={() => onBulkEnable(false)}>{t('pages.hosts.bulkDisable')}</Button> <Button onClick={() => onBulkEnable(false)}>{t('pages.hosts.bulkDisable')}</Button>
@@ -170,7 +225,7 @@ export default function HostList(props: HostListProps) {
return ( return (
<Card size="small" hoverable title={toolbar} className="hosts-card"> <Card size="small" hoverable title={toolbar} className="hosts-card">
<Table<HostRecord> <Table<HostRecord>
rowKey="id" rowKey="groupId"
size="small" size="small"
loading={loading} loading={loading}
columns={columns} columns={columns}
@@ -178,8 +233,8 @@ export default function HostList(props: HostListProps) {
pagination={false} pagination={false}
scroll={{ x: 'max-content' }} scroll={{ x: 'max-content' }}
rowSelection={{ rowSelection={{
selectedRowKeys: selectedIds, selectedRowKeys: selectedGroupIds,
onChange: (keys) => onSelectionChange(keys as number[]), onChange: (keys) => onSelectionChange(keys as string[]),
}} }}
locale={{ locale={{
emptyText: ( emptyText: (
+29 -40
View File
@@ -10,22 +10,10 @@ import { useHostMutations } from '@/api/queries/useHostMutations';
import { useInboundOptions } from '@/api/queries/useInboundOptions'; import { useInboundOptions } from '@/api/queries/useInboundOptions';
import AppSidebar from '@/layouts/AppSidebar'; import AppSidebar from '@/layouts/AppSidebar';
import { setMessageInstance } from '@/utils/messageBus'; import { setMessageInstance } from '@/utils/messageBus';
import type { HostFormValues } from '@/schemas/api/host'; import type { BulkAddHostValues } from '@/schemas/api/host';
import HostList from './HostList'; import HostList, { sortHosts } from './HostList';
import HostFormModal from './HostFormModal'; import HostFormModal from './HostFormModal';
// Hosts for one inbound in render order — used to compute a reorder payload.
function inboundHostsInOrder(hosts: HostRecord[], inboundId: number): HostRecord[] {
return hosts
.filter((h) => h.inboundId === inboundId)
.sort((a, b) => {
const sa = a.sortOrder ?? 0;
const sb = b.sortOrder ?? 0;
if (sa !== sb) return sa - sb;
return a.id - b.id;
});
}
export default function HostsPage() { export default function HostsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
@@ -35,13 +23,13 @@ export default function HostsPage() {
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { hosts, loading, fetched, fetchError, refetch } = useHostsQuery(); const { hosts, loading, fetched, fetchError, refetch } = useHostsQuery();
const { create, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = useHostMutations(); const { bulkCreate, update, remove, setEnable, reorder, bulkSetEnable, bulkDel } = useHostMutations();
const { data: inboundOptions = [] } = useInboundOptions(); const { data: inboundOptions = [] } = useInboundOptions();
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add'); const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [formHost, setFormHost] = useState<HostRecord | null>(null); const [formHost, setFormHost] = useState<HostRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedGroupIds, setSelectedGroupIds] = useState<string[]>([]);
const onAdd = useCallback(() => { const onAdd = useCallback(() => {
setFormMode('add'); setFormMode('add');
@@ -55,12 +43,12 @@ export default function HostsPage() {
setFormOpen(true); setFormOpen(true);
}, []); }, []);
const onSave = useCallback(async (payload: Partial<HostFormValues>) => { const onSave = useCallback(async (payload: BulkAddHostValues) => {
if (formMode === 'edit' && formHost?.id) { if (formMode === 'edit' && formHost?.groupId) {
return update(formHost.id, payload); return update(formHost.groupId, payload);
} }
return create(payload); return bulkCreate(payload);
}, [formMode, formHost, update, create]); }, [formMode, formHost, update, bulkCreate]);
const onDelete = useCallback((host: HostRecord) => { const onDelete = useCallback((host: HostRecord) => {
modal.confirm({ modal.confirm({
@@ -69,48 +57,48 @@ export default function HostsPage() {
okType: 'danger', okType: 'danger',
cancelText: t('cancel'), cancelText: t('cancel'),
onOk: async () => { onOk: async () => {
const msg = await remove(host.id); const msg = await remove(host.groupId);
if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete')); if (msg?.success) messageApi.success(t('pages.hosts.toasts.delete'));
}, },
}); });
}, [modal, t, remove, messageApi]); }, [modal, t, remove, messageApi]);
const onToggleEnable = useCallback(async (host: HostRecord, next: boolean) => { const onToggleEnable = useCallback(async (host: HostRecord, next: boolean) => {
await setEnable(host.id, next); await setEnable(host.groupId, next);
}, [setEnable]); }, [setEnable]);
const onMove = useCallback(async (host: HostRecord, dir: 'up' | 'down') => { const onMove = useCallback(async (host: HostRecord, dir: 'up' | 'down') => {
const group = inboundHostsInOrder(hosts, host.inboundId); const sorted = sortHosts(hosts);
const idx = group.findIndex((h) => h.id === host.id); const idx = sorted.findIndex((h) => h.groupId === host.groupId);
const swapWith = dir === 'up' ? idx - 1 : idx + 1; const swapWith = dir === 'up' ? idx - 1 : idx + 1;
if (idx < 0 || swapWith < 0 || swapWith >= group.length) return; if (idx < 0 || swapWith < 0 || swapWith >= sorted.length) return;
const ids = group.map((h) => h.id); const groupIds = sorted.map((h) => h.groupId);
[ids[idx], ids[swapWith]] = [ids[swapWith], ids[idx]]; [groupIds[idx], groupIds[swapWith]] = [groupIds[swapWith], groupIds[idx]];
await reorder(ids); await reorder(groupIds);
}, [hosts, reorder]); }, [hosts, reorder]);
const onBulkEnable = useCallback(async (enable: boolean) => { const onBulkEnable = useCallback(async (enable: boolean) => {
if (selectedIds.length === 0) return; if (selectedGroupIds.length === 0) return;
const msg = await bulkSetEnable(selectedIds, enable); const msg = await bulkSetEnable(selectedGroupIds, enable);
if (msg?.success) setSelectedIds([]); if (msg?.success) setSelectedGroupIds([]);
}, [selectedIds, bulkSetEnable]); }, [selectedGroupIds, bulkSetEnable]);
const onBulkDelete = useCallback(() => { const onBulkDelete = useCallback(() => {
if (selectedIds.length === 0) return; if (selectedGroupIds.length === 0) return;
modal.confirm({ modal.confirm({
title: t('pages.hosts.bulkDeleteConfirm', { count: selectedIds.length }), title: t('pages.hosts.bulkDeleteConfirm', { count: selectedGroupIds.length }),
okText: t('delete'), okText: t('delete'),
okType: 'danger', okType: 'danger',
cancelText: t('cancel'), cancelText: t('cancel'),
onOk: async () => { onOk: async () => {
const msg = await bulkDel(selectedIds); const msg = await bulkDel(selectedGroupIds);
if (msg?.success) { if (msg?.success) {
messageApi.success(t('pages.hosts.toasts.delete')); messageApi.success(t('pages.hosts.toasts.delete'));
setSelectedIds([]); setSelectedGroupIds([]);
} }
}, },
}); });
}, [selectedIds, modal, t, bulkDel, messageApi]); }, [selectedGroupIds, modal, t, bulkDel, messageApi]);
const summary = useMemo(() => { const summary = useMemo(() => {
const total = hosts.length; const total = hosts.length;
@@ -179,8 +167,8 @@ export default function HostsPage() {
inboundOptions={inboundOptions} inboundOptions={inboundOptions}
loading={loading} loading={loading}
isMobile={isMobile} isMobile={isMobile}
selectedIds={selectedIds} selectedGroupIds={selectedGroupIds}
onSelectionChange={setSelectedIds} onSelectionChange={setSelectedGroupIds}
onAdd={onAdd} onAdd={onAdd}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
@@ -201,6 +189,7 @@ export default function HostsPage() {
mode={formMode} mode={formMode}
host={formHost} host={formHost}
inboundOptions={inboundOptions} inboundOptions={inboundOptions}
existingHosts={hosts}
save={onSave} save={onSave}
onOpenChange={setFormOpen} onOpenChange={setFormOpen}
/> />
+9 -21
View File
@@ -2,29 +2,18 @@ import { z } from 'zod';
import { AlpnSchema, UtlsFingerprintSchema } from '@/schemas/protocols/security/tls'; import { AlpnSchema, UtlsFingerprintSchema } from '@/schemas/protocols/security/tls';
// A Host is a per-inbound override endpoint: at subscription time each enabled
// host renders one extra share link/proxy with its own address/port/TLS, etc.,
// superseding the legacy externalProxy array. The form schema mirrors the field
// logic of schemas/protocols/stream/external-proxy.ts and reuses the shared
// ALPN / uTLS primitives.
export const HostSecuritySchema = z.enum(['same', 'tls', 'none', 'reality']); export const HostSecuritySchema = z.enum(['same', 'tls', 'none', 'reality']);
export type HostSecurity = z.infer<typeof HostSecuritySchema>; export type HostSecurity = z.infer<typeof HostSecuritySchema>;
export const MihomoIpVersionSchema = z.enum(['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer']); export const MihomoIpVersionSchema = z.enum(['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer']);
export const SubTypeSchema = z.enum(['raw', 'json', 'clash']); export const SubTypeSchema = z.enum(['raw', 'json', 'clash']);
// Tags are short uppercase identifiers (≤10 tags, each ≤36 chars). Enforced on
// the frontend; the backend stores them verbatim.
const HostTagSchema = z.string().regex(/^[A-Z0-9_:]+$/, 'pages.hosts.toasts.badTag').max(36); const HostTagSchema = z.string().regex(/^[A-Z0-9_:]+$/, 'pages.hosts.toasts.badTag').max(36);
// HostFormValues is what the form edits and POSTs.
export const HostFormSchema = z.object({ export const HostFormSchema = z.object({
id: z.number().optional(), id: z.number().optional(),
inboundId: z.number().int().positive(), inboundId: z.number().int().positive(),
sortOrder: z.number().int().default(0), sortOrder: z.number().int().default(0),
// Remark may contain {{VAR}} template tokens expanded per client at
// subscription time, so the stored template gets a generous cap.
remark: z.string().trim().min(1).max(256), remark: z.string().trim().min(1).max(256),
serverDescription: z.string().max(64).default(''), serverDescription: z.string().max(64).default(''),
isDisabled: z.boolean().default(false), isDisabled: z.boolean().default(false),
@@ -46,8 +35,6 @@ export const HostFormSchema = z.object({
overrideSniFromAddress: z.boolean().default(false), overrideSniFromAddress: z.boolean().default(false),
keepSniBlank: z.boolean().default(false), keepSniBlank: z.boolean().default(false),
pinnedPeerCertSha256: z.array(z.string()).default([]), pinnedPeerCertSha256: z.array(z.string()).default([]),
// Comma-separated cert names (xray `vcn`). Legacy rows stored a boolean here;
// coerce any stray bool to '' so old data loads cleanly.
verifyPeerCertByName: z.preprocess( verifyPeerCertByName: z.preprocess(
(v) => (typeof v === 'boolean' ? '' : v), (v) => (typeof v === 'boolean' ? '' : v),
z.string().default(''), z.string().default(''),
@@ -58,7 +45,6 @@ export const HostFormSchema = z.object({
muxParams: z.string().default(''), muxParams: z.string().default(''),
sockoptParams: z.string().default(''), sockoptParams: z.string().default(''),
finalMask: z.string().default(''), finalMask: z.string().default(''),
// Single value 0-65535 baked into the subscription UUID's 3rd group. Empty = none.
vlessRoute: z vlessRoute: z
.string() .string()
.trim() .trim()
@@ -69,8 +55,6 @@ export const HostFormSchema = z.object({
excludeFromSubTypes: z.array(SubTypeSchema).default([]), excludeFromSubTypes: z.array(SubTypeSchema).default([]),
// Visual-only assignment of nodes that resolve from this host (stored, not yet
// wired into routing).
nodeGuids: z.array(z.string()).default([]), nodeGuids: z.array(z.string()).default([]),
mihomoIpVersion: z.preprocess( mihomoIpVersion: z.preprocess(
@@ -82,18 +66,16 @@ export const HostFormSchema = z.object({
}); });
export type HostFormValues = z.infer<typeof HostFormSchema>; export type HostFormValues = z.infer<typeof HostFormSchema>;
// HostRecord is the loose list/read projection from /panel/api/hosts. Slice and
// free-JSON fields tolerate the backend serializing nil as null.
export const HostRecordSchema = z.object({ export const HostRecordSchema = z.object({
id: z.number(), groupId: z.string(),
inboundId: z.number(), inboundIds: z.array(z.number()),
hosts: z.array(z.string()),
sortOrder: z.number().optional(), sortOrder: z.number().optional(),
remark: z.string().optional(), remark: z.string().optional(),
serverDescription: z.string().optional(), serverDescription: z.string().optional(),
isDisabled: z.boolean().optional(), isDisabled: z.boolean().optional(),
isHidden: z.boolean().optional(), isHidden: z.boolean().optional(),
tags: z.array(z.string()).nullish(), tags: z.array(z.string()).nullish(),
address: z.string().optional(),
port: z.number().optional(), port: z.number().optional(),
security: z.string().optional(), security: z.string().optional(),
sni: z.string().optional(), sni: z.string().optional(),
@@ -123,3 +105,9 @@ export const HostRecordSchema = z.object({
export type HostRecord = z.infer<typeof HostRecordSchema>; export type HostRecord = z.infer<typeof HostRecordSchema>;
export const HostListSchema = z.array(HostRecordSchema); export const HostListSchema = z.array(HostRecordSchema);
export const BulkAddHostSchema = HostFormSchema.omit({ inboundId: true, address: true }).extend({
inboundIds: z.array(z.number().int().positive()).min(1),
hosts: z.array(z.string()).default([]),
});
export type BulkAddHostValues = z.infer<typeof BulkAddHostSchema>;
+42 -95
View File
@@ -1,5 +1,3 @@
// Package database provides database initialization, migration, and management utilities
// for the 3x-ui panel using GORM with SQLite or PostgreSQL.
package database package database
import ( import (
@@ -39,7 +37,6 @@ const (
DialectPostgres = "postgres" DialectPostgres = "postgres"
) )
// IsPostgres reports whether the active connection is a PostgreSQL backend.
func IsPostgres() bool { func IsPostgres() bool {
if db == nil { if db == nil {
return config.GetDBKind() == "postgres" return config.GetDBKind() == "postgres"
@@ -47,7 +44,6 @@ func IsPostgres() bool {
return db.Name() == "postgres" return db.Name() == "postgres"
} }
// Dialect returns the active GORM dialect name, or "" if the DB is not open.
func Dialect() string { func Dialect() string {
if db == nil { if db == nil {
return "" return ""
@@ -131,8 +127,6 @@ func initModels() error {
return nil return nil
} }
// postgresModelSettled skips AutoMigrate when table, columns, and indexes all exist:
// its catalog-filtered column probe misdetects on some setups and re-ADDs columns forever (#5665).
func postgresModelSettled(mdl any) bool { func postgresModelSettled(mdl any) bool {
migrator := db.Migrator() migrator := db.Migrator()
if !migrator.HasTable(mdl) { if !migrator.HasTable(mdl) {
@@ -166,21 +160,12 @@ func dropLegacyForeignKeys() error {
return nil return nil
} }
// migrateHostVerifyPeerCertByNameColumn converts hosts.verify_peer_cert_by_name
// from its original boolean shape to the comma-separated string xray-core's
// verifyPeerCertByName (vcn) actually expects. The legacy boolean was dead
// (never emitted into links), so its value carries no meaning and is discarded.
// Idempotent by construction (no HistoryOfSeeders row — writing one here would
// flip the fresh-DB detection in runSeeders). Runs right after AutoMigrate,
// before anything reads or writes Host rows (critical on Postgres, where the
// column stays boolean-typed until the ALTER below).
func migrateHostVerifyPeerCertByNameColumn() error { func migrateHostVerifyPeerCertByNameColumn() error {
if !db.Migrator().HasColumn(&model.Host{}, "verify_peer_cert_by_name") { if !db.Migrator().HasColumn(&model.Host{}, "verify_peer_cert_by_name") {
return nil return nil
} }
if IsPostgres() { if IsPostgres() {
// Only convert a still-boolean column; once it is text this is a no-op,
// so a user-set name is never wiped on a later restart.
var dataType string var dataType string
if err := db.Raw( if err := db.Raw(
`SELECT data_type FROM information_schema.columns WHERE table_name = 'hosts' AND column_name = 'verify_peer_cert_by_name'`, `SELECT data_type FROM information_schema.columns WHERE table_name = 'hosts' AND column_name = 'verify_peer_cert_by_name'`,
@@ -195,15 +180,10 @@ func migrateHostVerifyPeerCertByNameColumn() error {
} }
return db.Exec(`ALTER TABLE hosts ALTER COLUMN verify_peer_cert_by_name TYPE text USING ''`).Error return db.Exec(`ALTER TABLE hosts ALTER COLUMN verify_peer_cert_by_name TYPE text USING ''`).Error
} }
// SQLite keeps the original numeric-affinity column; blank any legacy
// integer/null value so it doesn't read back as "0"/"1". After conversion
// every value is text, so re-running touches nothing.
return db.Exec(`UPDATE hosts SET verify_peer_cert_by_name = '' WHERE verify_peer_cert_by_name IS NULL OR typeof(verify_peer_cert_by_name) <> 'text'`).Error return db.Exec(`UPDATE hosts SET verify_peer_cert_by_name = '' WHERE verify_peer_cert_by_name IS NULL OR typeof(verify_peer_cert_by_name) <> 'text'`).Error
} }
// seedHostsFromExternalProxy is a one-time, self-gated migration that creates a
// Host row for every legacy externalProxy entry on every inbound. Additive: the
// externalProxy arrays are left intact in StreamSettings.
func seedHostsFromExternalProxy() error { func seedHostsFromExternalProxy() error {
var history []string var history []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil { if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
@@ -228,13 +208,6 @@ func seedHostsFromExternalProxy() error {
}) })
} }
// seedWireguardPeersToClients is a one-time, self-gated migration that converts
// legacy single-config WireGuard inbounds into the multi-client model: each
// settings.peers[] entry becomes a managed client in the clients table attached
// to the inbound, and the inbound settings are rewritten so peers becomes a
// clients[] array (GetXrayConfig re-projects clients back to peers for xray).
// Idempotent: gated on the history row and skipped per-inbound once it already
// has client links.
func seedWireguardPeersToClients() error { func seedWireguardPeersToClients() error {
var history []string var history []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil { if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
@@ -349,8 +322,6 @@ func seedWireguardPeersToClients() error {
}) })
} }
// wireguardPeerEmail derives a stable, unique client email for a migrated peer
// from the inbound remark plus the peer's comment (or its 1-based index).
func wireguardPeerEmail(remark string, peer map[string]any, index int, used map[string]struct{}) string { func wireguardPeerEmail(remark string, peer map[string]any, index int, used map[string]struct{}) string {
base := strings.TrimSpace(remark) base := strings.TrimSpace(remark)
if base == "" { if base == "" {
@@ -564,10 +535,6 @@ func CreateHostsFromExternalProxy(tx *gorm.DB, inboundId int, streamSettings str
return created, nil return created, nil
} }
// externalProxyEntryToHost maps one legacy externalProxy entry onto a Host.
// forceTls (same|tls|none) maps straight to Security; an unknown value falls back
// to "same" (inherit). An empty remark gets a stable generated label so the row
// stays valid/editable, and the remark is capped at the model's 256-char limit.
func externalProxyEntryToHost(inboundId, index int, ep map[string]any) *model.Host { func externalProxyEntryToHost(inboundId, index int, ep map[string]any) *model.Host {
security, _ := ep["forceTls"].(string) security, _ := ep["forceTls"].(string)
switch security { switch security {
@@ -794,8 +761,7 @@ func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
return false return false
} }
errMsg := strings.ToLower(err.Error()) errMsg := strings.ToLower(err.Error())
// SQLite: "duplicate column name: foo"
// Postgres: `pq: column "foo" of relation "bar" already exists` / `sqlstate 42701`
const sqlitePrefix = "duplicate column name:" const sqlitePrefix = "duplicate column name:"
if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok { if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok {
col := strings.TrimSpace(after) col := strings.TrimSpace(after)
@@ -803,7 +769,6 @@ func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col) return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
} }
if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") { if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") {
// Best effort: extract the column name between the first pair of double quotes.
if _, after, ok := strings.Cut(errMsg, "column \""); ok { if _, after, ok := strings.Cut(errMsg, "column \""); ok {
rest := after rest := after
if e := strings.Index(rest, "\""); e > 0 { if e := strings.Index(rest, "\""); e > 0 {
@@ -815,7 +780,6 @@ func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
return false return false
} }
// initUser creates a default admin user if the users table is empty.
func initUser() error { func initUser() error {
empty, err := isTableEmpty("users") empty, err := isTableEmpty("users")
if err != nil { if err != nil {
@@ -838,7 +802,6 @@ func initUser() error {
return nil return nil
} }
// runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running.
func runSeeders(isUsersEmpty bool) error { func runSeeders(isUsersEmpty bool) error {
empty, err := isTableEmpty("history_of_seeders") empty, err := isTableEmpty("history_of_seeders")
if err != nil { if err != nil {
@@ -940,22 +903,22 @@ func runSeeders(isUsersEmpty bool) error {
} }
} }
// Self-gated on the "HostsFromExternalProxy" row, so it is safe to call
// unconditionally here.
if err := seedHostsFromExternalProxy(); err != nil { if err := seedHostsFromExternalProxy(); err != nil {
return err return err
} }
// Self-gated on the "ResetIpLimitNoFail2ban" row.
if err := resetIpLimitsWithoutFail2ban(); err != nil { if err := resetIpLimitsWithoutFail2ban(); err != nil {
return err return err
} }
// Self-gated on the "WireguardPeersToClients" row.
if err := seedWireguardPeersToClients(); err != nil { if err := seedWireguardPeersToClients(); err != nil {
return err return err
} }
if err := seedHostGroupIds(); err != nil {
return err
}
// Self-gated on the "MtprotoSecretsToClients" row. // Self-gated on the "MtprotoSecretsToClients" row.
if err := seedMtprotoSecretsToClients(); err != nil { if err := seedMtprotoSecretsToClients(); err != nil {
return err return err
@@ -974,11 +937,38 @@ func runSeeders(isUsersEmpty bool) error {
return normalizeSettingPaths() return normalizeSettingPaths()
} }
// resetIpLimitsWithoutFail2ban zeroes every client's IP limit on hosts where func seedHostGroupIds() error {
// fail2ban can't enforce it (not installed, or the integration disabled). The var history []string
// limit silently does nothing there yet kept logging a repeated warning, so a if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
// stale value is just misleading — the panel also disables the field on these return err
// hosts. One-time, self-gated on the seeder row. }
if slices.Contains(history, "HostGroupIds") {
return nil
}
var hosts []*model.Host
if err := db.Where("group_id = '' OR group_id IS NULL").Find(&hosts).Error; err != nil {
return err
}
if len(hosts) > 0 {
err := db.Transaction(func(tx *gorm.DB) error {
for _, h := range hosts {
h.GroupId = random.NumLower(16)
if err := tx.Model(h).Update("group_id", h.GroupId).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
}
return db.Create(&model.HistoryOfSeeders{SeederName: "HostGroupIds"}).Error
}
func resetIpLimitsWithoutFail2ban() error { func resetIpLimitsWithoutFail2ban() error {
var history []string var history []string
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil { if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
@@ -1050,10 +1040,6 @@ func resetIpLimitsWithoutFail2ban() error {
}) })
} }
// fail2banCanEnforce reports whether per-client IP limits can actually be
// enforced on this host: the integration must be enabled (XUI_ENABLE_FAIL2BAN)
// and fail2ban-client must be present. Mirrors the service-layer check, kept
// local to avoid an import cycle.
func fail2banCanEnforce() bool { func fail2banCanEnforce() bool {
if v, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN"); ok && v != "true" { if v, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN"); ok && v != "true" {
return false return false
@@ -1064,8 +1050,6 @@ func fail2banCanEnforce() bool {
return exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run() == nil return exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run() == nil
} }
// clearLegacyProxySettings drops the deprecated panelProxy/tgBotProxy rows so a
// stale tgBotProxy no longer masks the panelOutbound egress fallback.
func clearLegacyProxySettings() error { func clearLegacyProxySettings() error {
return db.Transaction(func(tx *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("key IN ?", []string{"panelProxy", "tgBotProxy"}). if err := tx.Where("key IN ?", []string{"panelProxy", "tgBotProxy"}).
@@ -1076,12 +1060,6 @@ func clearLegacyProxySettings() error {
}) })
} }
// normalizeSettingPaths repairs URI-path settings persisted before the
// leading/trailing-slash rules existed (or restored from an old backup),
// mirroring entity.AllSetting.CheckValid. CheckValid self-heals these on save,
// but the frontend rejects the whole Settings form on the bad stored value
// before a save can ever reach it (#5726), so the stored rows themselves must
// be fixed. Idempotent; runs on every start.
func normalizeSettingPaths() error { func normalizeSettingPaths() error {
pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"} pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
var rows []model.Setting var rows []model.Setting
@@ -1345,10 +1323,6 @@ func isLegacyPrivateOnlyFinalRules(v any) bool {
return true return true
} }
// normalizeClientJSONFields coerces loosely-typed numeric fields in a raw
// settings.clients entry so json.Unmarshal into model.Client doesn't fail
// when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings
// drop the key so the field falls back to its zero value.
func normalizeClientJSONFields(obj map[string]any) { func normalizeClientJSONFields(obj map[string]any) {
normalizeInt := func(key string) { normalizeInt := func(key string) {
raw, exists := obj[key] raw, exists := obj[key]
@@ -1462,10 +1436,6 @@ func seedClientsFromInboundJSON() error {
}) })
} }
// seedApiTokens copies the legacy `apiToken` setting into the new
// api_tokens table as a row named "default" so existing central panels
// keep working after the upgrade. Idempotent — records itself in
// history_of_seeders and only runs when api_tokens is empty.
func seedApiTokens() error { func seedApiTokens() error {
empty, err := isTableEmpty("api_tokens") empty, err := isTableEmpty("api_tokens")
if err != nil { if err != nil {
@@ -1489,10 +1459,6 @@ func seedApiTokens() error {
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
} }
// hashExistingApiTokens replaces any plaintext token stored before tokens were
// hashed at rest with its SHA-256 digest. Callers keep their plaintext copy
// (used on remote nodes), so existing tokens keep authenticating; the panel
// just can no longer reveal them. Idempotent — already-hashed rows are skipped.
func hashExistingApiTokens() error { func hashExistingApiTokens() error {
var rows []*model.ApiToken var rows []*model.ApiToken
if err := db.Find(&rows).Error; err != nil { if err := db.Find(&rows).Error; err != nil {
@@ -1511,15 +1477,12 @@ func hashExistingApiTokens() error {
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensHash"}).Error return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensHash"}).Error
} }
// isTableEmpty returns true if the named table contains zero rows.
func isTableEmpty(tableName string) (bool, error) { func isTableEmpty(tableName string) (bool, error) {
var count int64 var count int64
err := db.Table(tableName).Count(&count).Error err := db.Table(tableName).Count(&count).Error
return count == 0, err return count == 0, err
} }
// InitDB sets up the database connection, migrates models, and runs seeders.
// When XUI_DB_TYPE=postgres, dbPath is ignored and XUI_DB_DSN is used instead.
func InitDB(dbPath string) error { func InitDB(dbPath string) error {
var gormLogger logger.Interface var gormLogger logger.Interface
if config.IsDebug() { if config.IsDebug() {
@@ -1553,8 +1516,7 @@ func InitDB(dbPath string) error {
if err = os.MkdirAll(dir, 0o755); err != nil { if err = os.MkdirAll(dir, 0o755); err != nil {
return err return err
} }
// Keep journal_mode=DELETE so the DB stays a single file (no -wal/-shm
// sidecars). synchronous defaults to FULL for durability but is tunable.
sync := sqliteSynchronous() sync := sqliteSynchronous()
dsn := dbPath + "?_journal_mode=DELETE&_busy_timeout=10000&_synchronous=" + sync + "&_txlock=immediate" dsn := dbPath + "?_journal_mode=DELETE&_busy_timeout=10000&_synchronous=" + sync + "&_txlock=immediate"
db, err = gorm.Open(sqlite.Open(dsn), c) db, err = gorm.Open(sqlite.Open(dsn), c)
@@ -1565,9 +1527,7 @@ func InitDB(dbPath string) error {
if err != nil { if err != nil {
return err return err
} }
// Re-assert the DSN pragmas plus scan-friendly ones for large datasets.
// cache_size/mmap_size/temp_store create no extra files, so the single-file
// guarantee holds; they just cut disk I/O on the 50k-row hot paths.
pragmas := []string{ pragmas := []string{
"PRAGMA journal_mode=DELETE", "PRAGMA journal_mode=DELETE",
"PRAGMA busy_timeout=10000", "PRAGMA busy_timeout=10000",
@@ -1616,17 +1576,12 @@ func InitDB(dbPath string) error {
return runSeeders(isUsersEmpty) return runSeeders(isUsersEmpty)
} }
// normalizeApiTokenCreatedAtSeconds repairs rows written while ApiToken used
// autoCreateTime:milli. The threshold separates modern Unix milliseconds from
// Unix seconds and makes this safe to run on every startup.
func normalizeApiTokenCreatedAtSeconds() error { func normalizeApiTokenCreatedAtSeconds() error {
return db.Model(&model.ApiToken{}). return db.Model(&model.ApiToken{}).
Where("created_at >= ?", model.ApiTokenUnixMillisecondsThreshold). Where("created_at >= ?", model.ApiTokenUnixMillisecondsThreshold).
UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error
} }
// sqliteSynchronous returns the SQLite synchronous mode, defaulting to FULL.
// Whitelisted because the value is interpolated directly into a PRAGMA string.
func sqliteSynchronous() string { func sqliteSynchronous() string {
switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) { switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
case "OFF": case "OFF":
@@ -1652,7 +1607,6 @@ func envInt(key string, def int) int {
return n return n
} }
// CloseDB closes the database connection if it exists.
func CloseDB() error { func CloseDB() error {
if db != nil { if db != nil {
sqlDB, err := db.DB() sqlDB, err := db.DB()
@@ -1664,7 +1618,6 @@ func CloseDB() error {
return nil return nil
} }
// GetDB returns the global GORM database instance.
func GetDB() *gorm.DB { func GetDB() *gorm.DB {
return db return db
} }
@@ -1673,7 +1626,6 @@ func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound) return errors.Is(err, gorm.ErrRecordNotFound)
} }
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
func IsSQLiteDB(file io.ReaderAt) (bool, error) { func IsSQLiteDB(file io.ReaderAt) (bool, error) {
signature := []byte("SQLite format 3\x00") signature := []byte("SQLite format 3\x00")
buf := make([]byte, len(signature)) buf := make([]byte, len(signature))
@@ -1684,8 +1636,6 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
return bytes.Equal(buf, signature), nil return bytes.Equal(buf, signature), nil
} }
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
// No-op on PostgreSQL (WAL there is managed by the server).
func Checkpoint() error { func Checkpoint() error {
if IsPostgres() { if IsPostgres() {
return nil return nil
@@ -1693,11 +1643,8 @@ func Checkpoint() error {
return db.Exec("PRAGMA wal_checkpoint;").Error return db.Exec("PRAGMA wal_checkpoint;").Error
} }
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
// and runs a PRAGMA integrity_check to ensure the file is structurally sound.
// It does not mutate global state or run migrations.
func ValidateSQLiteDB(dbPath string) error { func ValidateSQLiteDB(dbPath string) error {
if _, err := os.Stat(dbPath); err != nil { // file must exist if _, err := os.Stat(dbPath); err != nil {
return err return err
} }
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard}) gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
+1 -2
View File
@@ -9,10 +9,9 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
) )
// hostColumns is the set of columns initModels must create for the hosts table.
func hostColumns() []string { func hostColumns() []string {
return []string{ return []string{
"id", "inbound_id", "sort_order", "remark", "server_description", "is_disabled", "is_hidden", "tags", "id", "group_id", "inbound_id", "sort_order", "remark", "server_description", "is_disabled", "is_hidden", "tags",
"address", "port", "address", "port",
"security", "sni", "host_header", "path", "alpn", "fingerprint", "security", "sni", "host_header", "path", "alpn", "fingerprint",
"override_sni_from_address", "keep_sni_blank", "pinned_peer_cert_sha256", "override_sni_from_address", "keep_sni_blank", "pinned_peer_cert_sha256",
+1 -4
View File
@@ -834,12 +834,9 @@ type InboundFallback struct {
func (InboundFallback) TableName() string { return "inbound_fallbacks" } func (InboundFallback) TableName() string { return "inbound_fallbacks" }
// Host is an override endpoint attached to an inbound: at subscription time each
// enabled host renders one share link/proxy with its own address/port/TLS/etc.,
// superseding the legacy externalProxy array. Free-JSON fields are stored as
// text and parsed in the sub layer; slice fields use the json serializer.
type Host struct { type Host struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"` Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
GroupId string `json:"groupId" form:"groupId" gorm:"column:group_id;index"`
InboundId int `json:"inboundId" form:"inboundId" gorm:"index;not null;column:inbound_id" validate:"required" example:"1"` InboundId int `json:"inboundId" form:"inboundId" gorm:"index;not null;column:inbound_id" validate:"required" example:"1"`
SortOrder int `json:"sortOrder" form:"sortOrder" gorm:"default:0;column:sort_order"` SortOrder int `json:"sortOrder" form:"sortOrder" gorm:"default:0;column:sort_order"`
Remark string `json:"remark" form:"remark" validate:"required,max=256" example:"cdn-front"` Remark string `json:"remark" form:"remark" validate:"required,max=256" example:"cdn-front"`
+24 -41
View File
@@ -3,15 +3,13 @@ package controller
import ( import (
"strconv" "strconv"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// HostController exposes CRUD + ordering for Host override endpoints under
// /panel/api/hosts. Thin HTTP layer over HostService; mirrors NodeController.
type HostController struct { type HostController struct {
hostService service.HostService hostService service.HostService
} }
@@ -24,15 +22,16 @@ func NewHostController(g *gin.RouterGroup) *HostController {
func (a *HostController) initRouter(g *gin.RouterGroup) { func (a *HostController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list) g.GET("/list", a.list)
g.GET("/get/:id", a.get) g.GET("/get/:groupId", a.get)
g.GET("/byInbound/:inboundId", a.byInbound) g.GET("/byInbound/:inboundId", a.byInbound)
g.GET("/tags", a.tags) g.GET("/tags", a.tags)
g.POST("/add", a.add) g.POST("/add", a.add)
g.POST("/update/:id", a.update) g.POST("/update/:groupId", a.update)
g.POST("/del/:id", a.del) g.POST("/del/:groupId", a.del)
g.POST("/setEnable/:id", a.setEnable) g.POST("/setEnable/:groupId", a.setEnable)
g.POST("/reorder", a.reorder) g.POST("/reorder", a.reorder)
g.POST("/bulk/add", a.add)
g.POST("/bulk/setEnable", a.bulkSetEnable) g.POST("/bulk/setEnable", a.bulkSetEnable)
g.POST("/bulk/del", a.bulkDel) g.POST("/bulk/del", a.bulkDel)
} }
@@ -47,12 +46,8 @@ func (a *HostController) list(c *gin.Context) {
} }
func (a *HostController) get(c *gin.Context) { func (a *HostController) get(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) groupId := c.Param("groupId")
if err != nil { h, err := a.hostService.GetHostGroup(groupId)
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
h, err := a.hostService.GetHost(id)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.obtain"), err) jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.obtain"), err)
return return
@@ -84,11 +79,11 @@ func (a *HostController) tags(c *gin.Context) {
} }
func (a *HostController) add(c *gin.Context) { func (a *HostController) add(c *gin.Context) {
h, ok := middleware.BindAndValidate[model.Host](c) req, ok := middleware.BindJSONAndValidate[entity.HostGroup](c)
if !ok { if !ok {
return return
} }
created, err := a.hostService.AddHost(h) created, err := a.hostService.AddHostGroup(req)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.add"), err) jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.add"), err)
return return
@@ -97,16 +92,12 @@ func (a *HostController) add(c *gin.Context) {
} }
func (a *HostController) update(c *gin.Context) { func (a *HostController) update(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) groupId := c.Param("groupId")
if err != nil { req, ok := middleware.BindJSONAndValidate[entity.HostGroup](c)
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
h, ok := middleware.BindAndValidate[model.Host](c)
if !ok { if !ok {
return return
} }
updated, err := a.hostService.UpdateHost(id, h) updated, err := a.hostService.UpdateHostGroup(groupId, req)
if err != nil { if err != nil {
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err) jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
return return
@@ -115,12 +106,8 @@ func (a *HostController) update(c *gin.Context) {
} }
func (a *HostController) del(c *gin.Context) { func (a *HostController) del(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) groupId := c.Param("groupId")
if err != nil { if err := a.hostService.DeleteHostGroup(groupId); err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
if err := a.hostService.DeleteHost(id); err != nil {
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err) jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
return return
} }
@@ -128,11 +115,7 @@ func (a *HostController) del(c *gin.Context) {
} }
func (a *HostController) setEnable(c *gin.Context) { func (a *HostController) setEnable(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id")) groupId := c.Param("groupId")
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
body := struct { body := struct {
Enable bool `json:"enable" form:"enable"` Enable bool `json:"enable" form:"enable"`
}{} }{}
@@ -140,7 +123,7 @@ func (a *HostController) setEnable(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err) jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
return return
} }
if err := a.hostService.SetHostEnable(id, body.Enable); err != nil { if err := a.hostService.SetHostGroupEnable(groupId, body.Enable); err != nil {
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err) jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
return return
} }
@@ -149,13 +132,13 @@ func (a *HostController) setEnable(c *gin.Context) {
func (a *HostController) reorder(c *gin.Context) { func (a *HostController) reorder(c *gin.Context) {
var req struct { var req struct {
Ids []int `json:"ids" form:"ids"` Ids []string `json:"ids" form:"ids"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err) jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
return return
} }
if err := a.hostService.ReorderHosts(req.Ids); err != nil { if err := a.hostService.ReorderHostGroups(req.Ids); err != nil {
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err) jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
return return
} }
@@ -164,14 +147,14 @@ func (a *HostController) reorder(c *gin.Context) {
func (a *HostController) bulkSetEnable(c *gin.Context) { func (a *HostController) bulkSetEnable(c *gin.Context) {
var req struct { var req struct {
Ids []int `json:"ids" form:"ids"` Ids []string `json:"ids" form:"ids"`
Enable bool `json:"enable" form:"enable"` Enable bool `json:"enable" form:"enable"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err) jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
return return
} }
if err := a.hostService.SetHostsEnable(req.Ids, req.Enable); err != nil { if err := a.hostService.SetHostsGroupEnable(req.Ids, req.Enable); err != nil {
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err) jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
return return
} }
@@ -180,13 +163,13 @@ func (a *HostController) bulkSetEnable(c *gin.Context) {
func (a *HostController) bulkDel(c *gin.Context) { func (a *HostController) bulkDel(c *gin.Context) {
var req struct { var req struct {
Ids []int `json:"ids" form:"ids"` Ids []string `json:"ids" form:"ids"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err) jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
return return
} }
if err := a.hostService.DeleteHosts(req.Ids); err != nil { if err := a.hostService.DeleteHostsGroup(req.Ids); err != nil {
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err) jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
return return
} }
+55 -33
View File
@@ -6,7 +6,6 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"strconv"
"testing" "testing"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
@@ -17,12 +16,11 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger" xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
) )
func newHostTestDB(t *testing.T) { func newHostTestDB(t *testing.T) {
t.Helper() t.Helper()
// I18nWeb logs a warning when the localizer is absent (as in tests); the
// logger must be initialised so that warning does not nil-panic.
xuilogger.InitLogger(logging.ERROR) xuilogger.InitLogger(logging.ERROR)
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
dbDir := t.TempDir() dbDir := t.TempDir()
@@ -62,8 +60,6 @@ func doHostReq(t *testing.T, engine *gin.Engine, method, path string, body any)
return env return env
} }
// TestHostController_AddListGetDelete exercises the CRUD round-trip and asserts
// the {success,msg,obj} envelope convention through the registered routes.
func TestHostController_AddListGetDelete(t *testing.T) { func TestHostController_AddListGetDelete(t *testing.T) {
newHostTestDB(t) newHostTestDB(t)
engine := gin.New() engine := gin.New()
@@ -74,53 +70,83 @@ func TestHostController_AddListGetDelete(t *testing.T) {
t.Fatalf("seed inbound: %v", err) t.Fatalf("seed inbound: %v", err)
} }
// add
add := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/add", map[string]any{ add := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/add", map[string]any{
"inboundId": ib.Id, "remark": "h1", "address": "h1.example.com", "port": 8443, "inboundIds": []int{ib.Id}, "remark": "h1", "hosts": []string{"h1.example.com"}, "port": 8443,
}) })
if !add.Success { if !add.Success {
t.Fatalf("add not successful: %s", add.Msg) t.Fatalf("add not successful: %s", add.Msg)
} }
var created model.Host var created []*model.Host
if err := json.Unmarshal(add.Obj, &created); err != nil { if err := json.Unmarshal(add.Obj, &created); err != nil {
t.Fatalf("decode created host: %v", err) t.Fatalf("decode created hosts: %v", err)
} }
if created.Id == 0 || created.Remark != "h1" { if len(created) != 1 || created[0].GroupId == "" || created[0].Remark != "h1" {
t.Fatalf("created host = %+v", created) t.Fatalf("created hosts = %+v", created)
} }
groupId := created[0].GroupId
// list
list := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil) list := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil)
var hosts []model.Host var groups []entity.HostGroup
if err := json.Unmarshal(list.Obj, &hosts); err != nil { if err := json.Unmarshal(list.Obj, &groups); err != nil {
t.Fatalf("decode list: %v", err) t.Fatalf("decode list: %v", err)
} }
if len(hosts) != 1 || hosts[0].Id != created.Id { if len(groups) != 1 || groups[0].GroupId != groupId {
t.Fatalf("list = %+v, want one host id=%d", hosts, created.Id) t.Fatalf("list = %+v, want one group groupId=%s", groups, groupId)
} }
// get get := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+groupId, nil)
get := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+itoa(created.Id), nil)
if !get.Success { if !get.Success {
t.Fatalf("get not successful: %s", get.Msg) t.Fatalf("get not successful: %s", get.Msg)
} }
// del update := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/update/"+groupId, map[string]any{
del := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/del/"+itoa(created.Id), nil) "inboundIds": []int{ib.Id}, "remark": "h1-updated", "hosts": []string{"h1.example.com"}, "port": 8443,
if !del.Success { })
t.Fatalf("del not successful: %s", del.Msg) if !update.Success {
t.Fatalf("update not successful: %s", update.Msg)
} }
get2 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+groupId, nil)
var group2 entity.HostGroup
_ = json.Unmarshal(get2.Obj, &group2)
if group2.Remark != "h1-updated" {
t.Fatalf("update did not change remark: %s", group2.Remark)
}
setEn := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/bulk/setEnable", map[string]any{
"ids": []string{groupId}, "enable": false,
})
if !setEn.Success {
t.Fatalf("bulk/setEnable not successful: %s", setEn.Msg)
}
get3 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+groupId, nil)
var group3 entity.HostGroup
_ = json.Unmarshal(get3.Obj, &group3)
if !group3.IsDisabled {
t.Fatalf("bulk/setEnable did not disable host group")
}
add2 := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/bulk/add", map[string]any{
"inboundIds": []int{ib.Id}, "remark": "h2", "hosts": []string{"h2.example.com"}, "port": 8443,
})
var created2 []*model.Host
_ = json.Unmarshal(add2.Obj, &created2)
groupId2 := created2[0].GroupId
bulkDel := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/bulk/del", map[string]any{
"ids": []string{groupId, groupId2},
})
if !bulkDel.Success {
t.Fatalf("bulk/del not successful: %s", bulkDel.Msg)
}
list2 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil) list2 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil)
var hosts2 []model.Host var groups2 []entity.HostGroup
_ = json.Unmarshal(list2.Obj, &hosts2) _ = json.Unmarshal(list2.Obj, &groups2)
if len(hosts2) != 0 { if len(groups2) != 0 {
t.Fatalf("after delete, list = %+v, want empty", hosts2) t.Fatalf("after delete, list = %+v, want empty", groups2)
} }
} }
// TestHostController_AuthInherited mirrors production wiring: the hosts group is
// nested under the api group guarded by checkAPIAuth, so an unauthenticated XHR
// to a hosts route is rejected (401) — the auth is inherited, not re-declared.
func TestHostController_AuthInherited(t *testing.T) { func TestHostController_AuthInherited(t *testing.T) {
newHostTestDB(t) newHostTestDB(t)
engine := gin.New() engine := gin.New()
@@ -140,7 +166,3 @@ func TestHostController_AuthInherited(t *testing.T) {
t.Fatalf("unauthenticated hosts/list = %d, want 401 (auth inherited)", w.Code) t.Fatalf("unauthenticated hosts/list = %d, want 401 (auth inherited)", w.Code)
} }
} }
func itoa(i int) string {
return strconv.Itoa(i)
}
+119 -101
View File
@@ -1,4 +1,3 @@
// Package entity defines data structures and entities used by the web layer of the 3x-ui panel.
package entity package entity
import ( import (
@@ -11,100 +10,91 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/util/common"
) )
// Msg represents a standard API response message with success status, message text, and optional data object.
type Msg struct { type Msg struct {
Success bool `json:"success"` // Indicates if the operation was successful Success bool `json:"success"`
Msg string `json:"msg"` // Response message text Msg string `json:"msg"`
Obj any `json:"obj"` // Optional data object Obj any `json:"obj"`
} }
// AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
type AllSetting struct { type AllSetting struct {
// Web server settings WebListen string `json:"webListen" form:"webListen"`
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address WebDomain string `json:"webDomain" form:"webDomain"`
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation WebPort int `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"`
WebPort int `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"` // Web server port number WebCertFile string `json:"webCertFile" form:"webCertFile"`
WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server WebKeyFile string `json:"webKeyFile" form:"webKeyFile"`
WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server WebBasePath string `json:"webBasePath" form:"webBasePath"`
WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=1,lte=525600"`
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=1,lte=525600"` // Session maximum age in minutes (cap at one year) TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers PanelOutbound string `json:"panelOutbound" form:"panelOutbound"`
PanelOutbound string `json:"panelOutbound" form:"panelOutbound"` // Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)
// UI settings PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"` // Number of items per page in lists (0 disables pagination) ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
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"`
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"` // Traffic warning threshold percentage RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"` // Subscription remark template ({{VAR}} tokens) rendered per client Datepicker string `json:"datepicker" form:"datepicker"`
Datepicker string `json:"datepicker" form:"datepicker"` // Date picker format
// Telegram bot settings TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"`
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` // Enable Telegram bot notifications TgBotToken string `json:"tgBotToken" form:"tgBotToken"`
TgBotToken string `json:"tgBotToken" form:"tgBotToken"` // Telegram bot token TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"`
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"` // Proxy URL for Telegram bot TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"`
TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"` // Custom API server for Telegram bot TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"`
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"` // Telegram chat ID for notifications TgRunTime string `json:"tgRunTime" form:"tgRunTime"`
TgRunTime string `json:"tgRunTime" form:"tgRunTime"` // Cron schedule for Telegram notifications TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"`
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` // Enable database backup via Telegram TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"`
TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent) TgMemory int `json:"tgMemory" form:"tgMemory" validate:"gte=0,lte=100"`
TgMemory int `json:"tgMemory" form:"tgMemory" validate:"gte=0,lte=100"` // Memory usage threshold for alerts (percent) TgLang string `json:"tgLang" form:"tgLang"`
TgLang string `json:"tgLang" form:"tgLang"` // Telegram bot language TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`
TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"` // Comma-separated event types to send via Telegram
// Email (SMTP) notification settings SmtpEnable bool `json:"smtpEnable" form:"smtpEnable"`
SmtpEnable bool `json:"smtpEnable" form:"smtpEnable"` // Enable email notifications SmtpHost string `json:"smtpHost" form:"smtpHost"`
SmtpHost string `json:"smtpHost" form:"smtpHost"` // SMTP server host SmtpPort int `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"`
SmtpPort int `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"` // SMTP server port SmtpUsername string `json:"smtpUsername" form:"smtpUsername"`
SmtpUsername string `json:"smtpUsername" form:"smtpUsername"` // SMTP username SmtpPassword string `json:"smtpPassword" form:"smtpPassword"`
SmtpPassword string `json:"smtpPassword" form:"smtpPassword"` // SMTP password SmtpTo string `json:"smtpTo" form:"smtpTo"`
SmtpTo string `json:"smtpTo" form:"smtpTo"` // Comma-separated recipient emails SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"`
SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"` // SMTP encryption: none, starttls, tls SmtpEnabledEvents string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"`
SmtpEnabledEvents string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"` // Comma-separated event types to send via email SmtpCpu int `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`
SmtpCpu int `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"` // CPU threshold for email notifications SmtpMemory int `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"`
SmtpMemory int `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"` // Memory threshold for email notifications
// Security settings TimeLocation string `json:"timeLocation" form:"timeLocation"`
TimeLocation string `json:"timeLocation" form:"timeLocation"` // Time zone location TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"`
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"` // Enable two-factor authentication TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"`
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"` // Two-factor authentication token
// Subscription server settings SubEnable bool `json:"subEnable" form:"subEnable"`
SubEnable bool `json:"subEnable" form:"subEnable"` // Enable subscription server SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"` // Enable JSON subscription endpoint SubTitle string `json:"subTitle" form:"subTitle"`
SubTitle string `json:"subTitle" form:"subTitle"` // Subscription title SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"` // Subscription support URL SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"` // Subscription profile URL SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` // Subscription announce SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` // Enable routing for subscription SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"`
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` // Subscription global routing rules (Only for Happ) SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"`
SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"` // Enable routing injection for the Incy client SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"`
SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"` // Incy routing deep-link injected into the subscription body (Only for Incy) SubListen string `json:"subListen" form:"subListen"`
SubListen string `json:"subListen" form:"subListen"` // Subscription server listen IP SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"` // Subscription server port SubPath string `json:"subPath" form:"subPath"`
SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs SubDomain string `json:"subDomain" form:"subDomain"`
SubDomain string `json:"subDomain" form:"subDomain"` // Domain for subscription server validation SubCertFile string `json:"subCertFile" form:"subCertFile"`
SubCertFile string `json:"subCertFile" form:"subCertFile"` // SSL certificate file for subscription server SubKeyFile string `json:"subKeyFile" form:"subKeyFile"`
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"` // SSL private key file for subscription server SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"`
SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"` // Subscription update interval in minutes ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"`
ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"` // Enable external traffic reporting ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"`
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"` // URI for external traffic reporting RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` // Restart Xray when clients are auto-disabled by expiry/traffic limit SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"`
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` // Encrypt subscription responses SubURI string `json:"subURI" form:"subURI"`
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI SubJsonPath string `json:"subJsonPath" form:"subJsonPath"`
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint SubJsonURI string `json:"subJsonURI" form:"subJsonURI"`
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"`
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint SubClashPath string `json:"subClashPath" form:"subClashPath"`
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint SubClashURI string `json:"subClashURI" form:"subClashURI"`
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"`
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo SubClashRules string `json:"subClashRules" form:"subClashRules"`
SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams) SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"` // Absolute path to a folder containing a custom subscription page template SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"` // Hide server settings in happ subscription (Only for Happ) SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"`
// LDAP settings
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"` LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
LdapHost string `json:"ldapHost" form:"ldapHost"` LdapHost string `json:"ldapHost" form:"ldapHost"`
LdapPort int `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"` LdapPort int `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"`
@@ -114,28 +104,22 @@ type AllSetting struct {
LdapPassword string `json:"ldapPassword" form:"ldapPassword"` LdapPassword string `json:"ldapPassword" form:"ldapPassword"`
LdapBaseDN string `json:"ldapBaseDN" form:"ldapBaseDN"` LdapBaseDN string `json:"ldapBaseDN" form:"ldapBaseDN"`
LdapUserFilter string `json:"ldapUserFilter" form:"ldapUserFilter"` LdapUserFilter string `json:"ldapUserFilter" form:"ldapUserFilter"`
LdapUserAttr string `json:"ldapUserAttr" form:"ldapUserAttr"` // e.g., mail or uid LdapUserAttr string `json:"ldapUserAttr" form:"ldapUserAttr"`
LdapVlessField string `json:"ldapVlessField" form:"ldapVlessField"` LdapVlessField string `json:"ldapVlessField" form:"ldapVlessField"`
LdapSyncCron string `json:"ldapSyncCron" form:"ldapSyncCron"` LdapSyncCron string `json:"ldapSyncCron" form:"ldapSyncCron"`
// Generic flag configuration LdapFlagField string `json:"ldapFlagField" form:"ldapFlagField"`
LdapFlagField string `json:"ldapFlagField" form:"ldapFlagField"` LdapTruthyValues string `json:"ldapTruthyValues" form:"ldapTruthyValues"`
LdapTruthyValues string `json:"ldapTruthyValues" form:"ldapTruthyValues"` LdapInvertFlag bool `json:"ldapInvertFlag" form:"ldapInvertFlag"`
LdapInvertFlag bool `json:"ldapInvertFlag" form:"ldapInvertFlag"` LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"`
LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"` LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"`
LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"` LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"`
LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"` LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"`
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"` LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"` LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
// JSON subscription routing rules
// WARP
WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"` WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
} }
// AllSettingView is the browser-safe settings read model. Secret values
// are redacted from the embedded write model and represented by presence
// flags so the UI can show configured/not configured state.
type AllSettingView struct { type AllSettingView struct {
AllSetting AllSetting
@@ -148,7 +132,6 @@ type AllSettingView struct {
HasSmtpPassword bool `json:"hasSmtpPassword"` HasSmtpPassword bool `json:"hasSmtpPassword"`
} }
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
func pathHasForbiddenChar(s string) bool { func pathHasForbiddenChar(s string) bool {
for _, r := range s { for _, r := range s {
if r == '\\' || r == ' ' || r < 0x20 || r == 0x7f { if r == '\\' || r == ' ' || r < 0x20 || r == 0x7f {
@@ -260,3 +243,38 @@ func (s *AllSetting) CheckValid() error {
return nil return nil
} }
type HostGroup struct {
GroupId string `json:"groupId"`
InboundIds []int `json:"inboundIds" validate:"required,min=1"`
Hosts []string `json:"hosts" validate:"omitempty"`
SortOrder int `json:"sortOrder"`
Remark string `json:"remark" validate:"required,max=256"`
ServerDescription string `json:"serverDescription" validate:"omitempty,max=64"`
IsDisabled bool `json:"isDisabled"`
IsHidden bool `json:"isHidden"`
Tags []string `json:"tags"`
Port int `json:"port" validate:"gte=0,lte=65535"`
Security string `json:"security" validate:"omitempty,oneof=same tls none reality"`
Sni string `json:"sni"`
HostHeader string `json:"hostHeader"`
Path string `json:"path"`
Alpn []string `json:"alpn"`
Fingerprint string `json:"fingerprint"`
OverrideSniFromAddress bool `json:"overrideSniFromAddress"`
KeepSniBlank bool `json:"keepSniBlank"`
PinnedPeerCertSha256 []string `json:"pinnedPeerCertSha256"`
VerifyPeerCertByName string `json:"verifyPeerCertByName"`
AllowInsecure bool `json:"allowInsecure"`
EchConfigList string `json:"echConfigList"`
MuxParams string `json:"muxParams"`
SockoptParams string `json:"sockoptParams"`
FinalMask string `json:"finalMask"`
VlessRoute string `json:"vlessRoute"`
ExcludeFromSubTypes []string `json:"excludeFromSubTypes"`
NodeGuids []string `json:"nodeGuids"`
MihomoIpVersion string `json:"mihomoIpVersion" validate:"omitempty,oneof=dual ipv4 ipv6 ipv4-prefer ipv6-prefer"`
MihomoX25519 bool `json:"mihomoX25519"`
ShuffleHost bool `json:"shuffleHost"`
}
+300 -88
View File
@@ -1,115 +1,301 @@
package service package service
import ( import (
"slices"
"sort" "sort"
"strconv"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"gorm.io/gorm"
) )
// HostService manages Host rows (override endpoints attached to an inbound).
// Mirrors the empty-struct + database.GetDB() shape of ClientService.
type HostService struct{} type HostService struct{}
// GetHosts returns every host, grouped by inbound then ordered by sort_order. func formatHostAddr(addr string, port int) string {
func (s *HostService) GetHosts() ([]*model.Host, error) { if port <= 0 {
var hosts []*model.Host return addr
err := database.GetDB().Order("inbound_id asc, sort_order asc, id asc").Find(&hosts).Error }
return hosts, err if strings.Contains(addr, ":") {
return "[" + addr + "]:" + strconv.Itoa(port)
}
return addr + ":" + strconv.Itoa(port)
} }
// GetHostsByInbound returns one inbound's hosts ordered by sort_order then id. func newHostGroup(h *model.Host, groupId string) *entity.HostGroup {
func (s *HostService) GetHostsByInbound(inboundId int) ([]*model.Host, error) { return &entity.HostGroup{
var hosts []*model.Host GroupId: groupId,
err := database.GetDB().Where("inbound_id = ?", inboundId).Order("sort_order asc, id asc").Find(&hosts).Error InboundIds: []int{},
return hosts, err Hosts: []string{},
SortOrder: h.SortOrder,
Remark: h.Remark,
ServerDescription: h.ServerDescription,
IsDisabled: h.IsDisabled,
IsHidden: h.IsHidden,
Tags: h.Tags,
Port: h.Port,
Security: h.Security,
Sni: h.Sni,
HostHeader: h.HostHeader,
Path: h.Path,
Alpn: h.Alpn,
Fingerprint: h.Fingerprint,
OverrideSniFromAddress: h.OverrideSniFromAddress,
KeepSniBlank: h.KeepSniBlank,
PinnedPeerCertSha256: h.PinnedPeerCertSha256,
VerifyPeerCertByName: h.VerifyPeerCertByName,
AllowInsecure: h.AllowInsecure,
EchConfigList: h.EchConfigList,
MuxParams: h.MuxParams,
SockoptParams: h.SockoptParams,
FinalMask: h.FinalMask,
VlessRoute: h.VlessRoute,
ExcludeFromSubTypes: h.ExcludeFromSubTypes,
NodeGuids: h.NodeGuids,
MihomoIpVersion: h.MihomoIpVersion,
MihomoX25519: h.MihomoX25519,
ShuffleHost: h.ShuffleHost,
}
} }
func (s *HostService) GetHost(id int) (*model.Host, error) { func groupHosts(hosts []*model.Host) []*entity.HostGroup {
host := &model.Host{} groupsMap := make(map[string]*entity.HostGroup)
if err := database.GetDB().First(host, id).Error; err != nil { var orderedGroupIds []string
return nil, err
}
return host, nil
}
// AddHost creates a host after confirming its inbound exists (no hard FK). for _, h := range hosts {
func (s *HostService) AddHost(host *model.Host) (*model.Host, error) { gId := h.GroupId
db := database.GetDB() if gId == "" {
var count int64 gId = "fallback_" + strconv.Itoa(h.Id)
if err := db.Model(&model.Inbound{}).Where("id = ?", host.InboundId).Count(&count).Error; err != nil { }
return nil, err
}
if count == 0 {
return nil, common.NewError("inbound not found")
}
host.Id = 0
if err := db.Create(host).Error; err != nil {
return nil, err
}
return host, nil
}
// UpdateHost overwrites a host's content. InboundId and SortOrder are immutable g, exists := groupsMap[gId]
// here — the inbound is fixed at creation and ordering is owned by ReorderHosts. if !exists {
func (s *HostService) UpdateHost(id int, host *model.Host) (*model.Host, error) { g = newHostGroup(h, gId)
db := database.GetDB() groupsMap[gId] = g
existing := &model.Host{} orderedGroupIds = append(orderedGroupIds, gId)
if err := db.First(existing, id).Error; err != nil { }
return nil, err
}
host.Id = id
host.InboundId = existing.InboundId
host.SortOrder = existing.SortOrder
host.CreatedAt = existing.CreatedAt
if err := db.Save(host).Error; err != nil {
return nil, err
}
return s.GetHost(id)
}
func (s *HostService) DeleteHost(id int) error { if !slices.Contains(g.InboundIds, h.InboundId) {
return database.GetDB().Delete(&model.Host{}, id).Error g.InboundIds = append(g.InboundIds, h.InboundId)
} }
hostStr := formatHostAddr(h.Address, h.Port)
func (s *HostService) SetHostEnable(id int, enable bool) error { if !slices.Contains(g.Hosts, hostStr) {
return database.GetDB().Model(&model.Host{}).Where("id = ?", id).Update("is_disabled", !enable).Error g.Hosts = append(g.Hosts, hostStr)
} }
if h.SortOrder < g.SortOrder {
func (s *HostService) SetHostsEnable(ids []int, enable bool) error { g.SortOrder = h.SortOrder
if len(ids) == 0 {
return nil
}
return database.GetDB().Model(&model.Host{}).Where("id IN ?", ids).Update("is_disabled", !enable).Error
}
func (s *HostService) DeleteHosts(ids []int) error {
if len(ids) == 0 {
return nil
}
return database.GetDB().Where("id IN ?", ids).Delete(&model.Host{}).Error
}
// ReorderHosts assigns sort_order by the position of each id in ids, in a single
// transaction (driver-safe on SQLite and Postgres).
func (s *HostService) ReorderHosts(ids []int) error {
if len(ids) == 0 {
return nil
}
tx := database.GetDB().Begin()
for i, id := range ids {
if err := tx.Model(&model.Host{}).Where("id = ?", id).Update("sort_order", i).Error; err != nil {
tx.Rollback()
return err
} }
} }
return tx.Commit().Error
res := make([]*entity.HostGroup, 0, len(orderedGroupIds))
for _, gId := range orderedGroupIds {
res = append(res, groupsMap[gId])
}
sort.SliceStable(res, func(i, j int) bool {
if res[i].SortOrder != res[j].SortOrder {
return res[i].SortOrder < res[j].SortOrder
}
return res[i].Remark < res[j].Remark
})
return res
}
func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
hostsToProcess := req.Hosts
if len(hostsToProcess) == 0 {
hostsToProcess = []string{""}
}
var rows []*model.Host
for _, hostStr := range hostsToProcess {
addr, port := parseHostAndPort(hostStr, req.Port)
for _, inboundId := range req.InboundIds {
rows = append(rows, &model.Host{
GroupId: groupId,
InboundId: inboundId,
SortOrder: req.SortOrder,
Remark: req.Remark,
ServerDescription: req.ServerDescription,
IsDisabled: req.IsDisabled,
IsHidden: req.IsHidden,
Tags: req.Tags,
Address: addr,
Port: port,
Security: req.Security,
Sni: req.Sni,
HostHeader: req.HostHeader,
Path: req.Path,
Alpn: req.Alpn,
Fingerprint: req.Fingerprint,
OverrideSniFromAddress: req.OverrideSniFromAddress,
KeepSniBlank: req.KeepSniBlank,
PinnedPeerCertSha256: req.PinnedPeerCertSha256,
VerifyPeerCertByName: req.VerifyPeerCertByName,
AllowInsecure: req.AllowInsecure,
EchConfigList: req.EchConfigList,
MuxParams: req.MuxParams,
SockoptParams: req.SockoptParams,
FinalMask: req.FinalMask,
VlessRoute: req.VlessRoute,
ExcludeFromSubTypes: req.ExcludeFromSubTypes,
NodeGuids: req.NodeGuids,
MihomoIpVersion: req.MihomoIpVersion,
MihomoX25519: req.MihomoX25519,
ShuffleHost: req.ShuffleHost,
})
}
}
return rows
}
func validateInboundsExist(tx *gorm.DB, inboundIds []int) error {
for _, inboundId := range inboundIds {
var count int64
if err := tx.Model(&model.Inbound{}).Where("id = ?", inboundId).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return common.NewError("inbound not found")
}
}
return nil
}
func (s *HostService) GetHosts() ([]*entity.HostGroup, error) {
var hosts []*model.Host
err := database.GetDB().Order("inbound_id asc, sort_order asc, id asc").Find(&hosts).Error
if err != nil {
return nil, err
}
return groupHosts(hosts), nil
}
func (s *HostService) GetHostsByInbound(inboundId int) ([]*entity.HostGroup, error) {
var groupIds []string
if err := database.GetDB().Model(&model.Host{}).Where("inbound_id = ?", inboundId).Distinct().Pluck("group_id", &groupIds).Error; err != nil {
return nil, err
}
if len(groupIds) == 0 {
return nil, nil
}
var hosts []*model.Host
if err := database.GetDB().Where("group_id IN ?", groupIds).Order("sort_order asc, id asc").Find(&hosts).Error; err != nil {
return nil, err
}
return groupHosts(hosts), nil
}
func (s *HostService) GetHostGroup(groupId string) (*entity.HostGroup, error) {
var hosts []*model.Host
err := database.GetDB().Where("group_id = ?", groupId).Order("sort_order asc, id asc").Find(&hosts).Error
if err != nil {
return nil, err
}
if len(hosts) == 0 {
return nil, common.NewError("host group not found")
}
grouped := groupHosts(hosts)
if len(grouped) == 0 {
return nil, common.NewError("host group not found")
}
return grouped[0], nil
}
func (s *HostService) AddHostGroup(req *entity.HostGroup) ([]*model.Host, error) {
groupId := req.GroupId
if groupId == "" {
groupId = random.NumLower(16)
}
created := buildHostRows(groupId, req)
err := database.GetDB().Transaction(func(tx *gorm.DB) error {
if err := validateInboundsExist(tx, req.InboundIds); err != nil {
return err
}
if len(created) > 0 {
return tx.Create(&created).Error
}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}
func (s *HostService) UpdateHostGroup(groupId string, req *entity.HostGroup) ([]*model.Host, error) {
created := buildHostRows(groupId, req)
err := database.GetDB().Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&model.Host{}).Where("group_id = ?", groupId).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return common.NewError("host group not found")
}
if err := validateInboundsExist(tx, req.InboundIds); err != nil {
return err
}
if err := tx.Where("group_id = ?", groupId).Delete(&model.Host{}).Error; err != nil {
return err
}
if len(created) > 0 {
return tx.Create(&created).Error
}
return nil
})
if err != nil {
return nil, err
}
return created, nil
}
func (s *HostService) DeleteHostGroup(groupId string) error {
return database.GetDB().Where("group_id = ?", groupId).Delete(&model.Host{}).Error
}
func (s *HostService) SetHostGroupEnable(groupId string, enable bool) error {
return database.GetDB().Model(&model.Host{}).Where("group_id = ?", groupId).Update("is_disabled", !enable).Error
}
func (s *HostService) SetHostsGroupEnable(groupIds []string, enable bool) error {
if len(groupIds) == 0 {
return nil
}
return database.GetDB().Model(&model.Host{}).Where("group_id IN ?", groupIds).Update("is_disabled", !enable).Error
}
func (s *HostService) DeleteHostsGroup(groupIds []string) error {
if len(groupIds) == 0 {
return nil
}
return database.GetDB().Where("group_id IN ?", groupIds).Delete(&model.Host{}).Error
}
func (s *HostService) ReorderHostGroups(groupIds []string) error {
if len(groupIds) == 0 {
return nil
}
return database.GetDB().Transaction(func(tx *gorm.DB) error {
for i, groupId := range groupIds {
if err := tx.Model(&model.Host{}).Where("group_id = ?", groupId).Update("sort_order", i).Error; err != nil {
return err
}
}
return nil
})
} }
// GetAllTags returns the distinct, sorted set of tags across all hosts.
func (s *HostService) GetAllTags() ([]string, error) { func (s *HostService) GetAllTags() ([]string, error) {
hosts, err := s.GetHosts() var hosts []*model.Host
err := database.GetDB().Find(&hosts).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -128,3 +314,29 @@ func (s *HostService) GetAllTags() ([]string, error) {
sort.Strings(out) sort.Strings(out)
return out, nil return out, nil
} }
func parseHostAndPort(hostStr string, defaultPort int) (string, int) {
hostStr = strings.TrimSpace(hostStr)
if hostStr == "" {
return "", defaultPort
}
if strings.Count(hostStr, ":") > 1 && !strings.Contains(hostStr, "[") {
return hostStr, defaultPort
}
lastColon := strings.LastIndex(hostStr, ":")
if lastColon != -1 && lastColon < len(hostStr)-1 {
pStr := hostStr[lastColon+1:]
if p, err := strconv.Atoi(pStr); err == nil && p >= 0 && p <= 65535 {
addr := hostStr[:lastColon]
if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") {
addr = addr[1 : len(addr)-1]
}
return addr, p
}
}
addr := hostStr
if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") {
addr = addr[1 : len(addr)-1]
}
return addr, defaultPort
}
+243 -55
View File
@@ -5,25 +5,28 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
) )
func mkHost(t *testing.T, svc *HostService, inboundId int, remark string, order int) *model.Host { func mkHost(t *testing.T, svc *HostService, inboundId int, remark string, order int) *entity.HostGroup {
t.Helper() t.Helper()
h, err := svc.AddHost(&model.Host{ created, err := svc.AddHostGroup(&entity.HostGroup{
InboundId: inboundId, InboundIds: []int{inboundId},
Remark: remark, Remark: remark,
SortOrder: order, SortOrder: order,
Address: remark + ".example.com", Hosts: []string{remark + ".example.com"},
Port: 8443, Port: 8443,
}) })
if err != nil { if err != nil {
t.Fatalf("AddHost %s: %v", remark, err) t.Fatalf("AddHostGroup %s: %v", remark, err)
} }
return h g, err := svc.GetHostGroup(created[0].GroupId)
if err != nil {
t.Fatalf("GetHostGroup %s: %v", remark, err)
}
return g
} }
// TestAddHost_GetHostsByInbound: create persists; query returns by inbound,
// ordered by sort_order then id.
func TestAddHost_GetHostsByInbound(t *testing.T) { func TestAddHost_GetHostsByInbound(t *testing.T) {
setupBulkDB(t) setupBulkDB(t)
svc := &HostService{} svc := &HostService{}
@@ -38,24 +41,22 @@ func TestAddHost_GetHostsByInbound(t *testing.T) {
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got)) t.Fatalf("len = %d, want 2", len(got))
} }
if got[0].Id != h2.Id || got[1].Id != h1.Id { if got[0].GroupId != h2.GroupId || got[1].GroupId != h1.GroupId {
t.Fatalf("order = [%d,%d], want [%d,%d] (sort_order asc)", got[0].Id, got[1].Id, h2.Id, h1.Id) t.Fatalf("order = [%s,%s], want [%s,%s] (sort_order asc)", got[0].GroupId, got[1].GroupId, h2.GroupId, h1.GroupId)
} }
if got[0].Address != "a.example.com" { if got[0].Hosts[0] != "a.example.com:8443" {
t.Fatalf("address not persisted: %q", got[0].Address) t.Fatalf("address not persisted: %q", got[0].Hosts[0])
} }
} }
// TestAddHost_RejectsUnknownInbound: a host whose inbound does not exist is refused.
func TestAddHost_RejectsUnknownInbound(t *testing.T) { func TestAddHost_RejectsUnknownInbound(t *testing.T) {
setupBulkDB(t) setupBulkDB(t)
svc := &HostService{} svc := &HostService{}
if _, err := svc.AddHost(&model.Host{InboundId: 99999, Remark: "x"}); err == nil { if _, err := svc.AddHostGroup(&entity.HostGroup{InboundIds: []int{99999}, Remark: "x", Hosts: []string{"test.com"}}); err == nil {
t.Fatalf("expected error adding host to unknown inbound") t.Fatalf("expected error adding host to unknown inbound")
} }
} }
// TestReorderHosts: reorder updates sort_order and re-query reflects new order.
func TestReorderHosts(t *testing.T) { func TestReorderHosts(t *testing.T) {
setupBulkDB(t) setupBulkDB(t)
svc := &HostService{} svc := &HostService{}
@@ -64,22 +65,21 @@ func TestReorderHosts(t *testing.T) {
h2 := mkHost(t, svc, ib.Id, "h2", 0) h2 := mkHost(t, svc, ib.Id, "h2", 0)
h3 := mkHost(t, svc, ib.Id, "h3", 0) h3 := mkHost(t, svc, ib.Id, "h3", 0)
want := []int{h3.Id, h1.Id, h2.Id} want := []string{h3.GroupId, h1.GroupId, h2.GroupId}
if err := svc.ReorderHosts(want); err != nil { if err := svc.ReorderHostGroups(want); err != nil {
t.Fatalf("ReorderHosts: %v", err) t.Fatalf("ReorderHostGroups: %v", err)
} }
got, _ := svc.GetHostsByInbound(ib.Id) got, _ := svc.GetHostsByInbound(ib.Id)
for i, h := range got { for i, g := range got {
if h.Id != want[i] { if g.GroupId != want[i] {
t.Fatalf("position %d = %d, want %d", i, h.Id, want[i]) t.Fatalf("position %d = %s, want %s", i, g.GroupId, want[i])
} }
if h.SortOrder != i { if g.SortOrder != i {
t.Fatalf("host %d sort_order = %d, want %d", h.Id, h.SortOrder, i) t.Fatalf("host %s sort_order = %d, want %d", g.GroupId, g.SortOrder, i)
} }
} }
} }
// TestSetHostEnableAndBulk: per-row and bulk enable/disable toggles persist.
func TestSetHostEnableAndBulk(t *testing.T) { func TestSetHostEnableAndBulk(t *testing.T) {
setupBulkDB(t) setupBulkDB(t)
svc := &HostService{} svc := &HostService{}
@@ -87,32 +87,31 @@ func TestSetHostEnableAndBulk(t *testing.T) {
h1 := mkHost(t, svc, ib.Id, "h1", 0) h1 := mkHost(t, svc, ib.Id, "h1", 0)
h2 := mkHost(t, svc, ib.Id, "h2", 1) h2 := mkHost(t, svc, ib.Id, "h2", 1)
if err := svc.SetHostEnable(h1.Id, false); err != nil { if err := svc.SetHostGroupEnable(h1.GroupId, false); err != nil {
t.Fatalf("SetHostEnable: %v", err) t.Fatalf("SetHostGroupEnable: %v", err)
} }
if g, _ := svc.GetHost(h1.Id); g == nil || !g.IsDisabled { if g, _ := svc.GetHostGroup(h1.GroupId); g == nil || !g.IsDisabled {
t.Fatalf("h1 should be disabled after SetHostEnable(false)") t.Fatalf("h1 should be disabled after SetHostGroupEnable(false)")
} }
if err := svc.SetHostsEnable([]int{h1.Id, h2.Id}, true); err != nil { if err := svc.SetHostsGroupEnable([]string{h1.GroupId, h2.GroupId}, true); err != nil {
t.Fatalf("SetHostsEnable(true): %v", err) t.Fatalf("SetHostsGroupEnable(true): %v", err)
} }
for _, id := range []int{h1.Id, h2.Id} { for _, gid := range []string{h1.GroupId, h2.GroupId} {
if g, _ := svc.GetHost(id); g == nil || g.IsDisabled { if g, _ := svc.GetHostGroup(gid); g == nil || g.IsDisabled {
t.Fatalf("host %d should be enabled", id) t.Fatalf("host %s should be enabled", gid)
} }
} }
if err := svc.SetHostsEnable([]int{h1.Id, h2.Id}, false); err != nil { if err := svc.SetHostsGroupEnable([]string{h1.GroupId, h2.GroupId}, false); err != nil {
t.Fatalf("SetHostsEnable(false): %v", err) t.Fatalf("SetHostsGroupEnable(false): %v", err)
} }
for _, id := range []int{h1.Id, h2.Id} { for _, gid := range []string{h1.GroupId, h2.GroupId} {
if g, _ := svc.GetHost(id); g == nil || !g.IsDisabled { if g, _ := svc.GetHostGroup(gid); g == nil || !g.IsDisabled {
t.Fatalf("host %d should be disabled", id) t.Fatalf("host %s should be disabled", gid)
} }
} }
} }
// TestDeleteHosts: bulk delete removes exactly the named rows.
func TestDeleteHosts(t *testing.T) { func TestDeleteHosts(t *testing.T) {
setupBulkDB(t) setupBulkDB(t)
svc := &HostService{} svc := &HostService{}
@@ -121,27 +120,24 @@ func TestDeleteHosts(t *testing.T) {
h2 := mkHost(t, svc, ib.Id, "h2", 1) h2 := mkHost(t, svc, ib.Id, "h2", 1)
h3 := mkHost(t, svc, ib.Id, "h3", 2) h3 := mkHost(t, svc, ib.Id, "h3", 2)
if err := svc.DeleteHosts([]int{h1.Id, h3.Id}); err != nil { if err := svc.DeleteHostsGroup([]string{h1.GroupId, h3.GroupId}); err != nil {
t.Fatalf("DeleteHosts: %v", err) t.Fatalf("DeleteHostsGroup: %v", err)
} }
got, _ := svc.GetHostsByInbound(ib.Id) got, _ := svc.GetHostsByInbound(ib.Id)
if len(got) != 1 || got[0].Id != h2.Id { if len(got) != 1 || got[0].GroupId != h2.GroupId {
t.Fatalf("remaining = %v, want only h2 (%d)", got, h2.Id) t.Fatalf("remaining = %v, want only h2 (%s)", got, h2.GroupId)
} }
} }
// TestDeleteInboundCascadesHosts: deleting an inbound deletes its hosts.
func TestDeleteInboundCascadesHosts(t *testing.T) { func TestDeleteInboundCascadesHosts(t *testing.T) {
setupBulkDB(t) setupBulkDB(t)
svc := &HostService{} svc := &HostService{}
inboundSvc := &InboundService{} inboundSvc := &InboundService{}
// Disabled local inbound so DelInbound skips the runtime push.
ib := &model.Inbound{Tag: "casc", Enable: false, Port: 4443, Protocol: model.VLESS, Settings: `{"clients":[]}`} ib := &model.Inbound{Tag: "casc", Enable: false, Port: 4443, Protocol: model.VLESS, Settings: `{"clients":[]}`}
if err := database.GetDB().Create(ib).Error; err != nil { if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err) t.Fatalf("create inbound: %v", err)
} }
mkHost(t, svc, ib.Id, "h1", 0) h1 := mkHost(t, svc, ib.Id, "h1", 0)
mkHost(t, svc, ib.Id, "h2", 1)
if _, err := inboundSvc.DelInbound(ib.Id); err != nil { if _, err := inboundSvc.DelInbound(ib.Id); err != nil {
t.Fatalf("DelInbound: %v", err) t.Fatalf("DelInbound: %v", err)
@@ -150,18 +146,20 @@ func TestDeleteInboundCascadesHosts(t *testing.T) {
if len(got) != 0 { if len(got) != 0 {
t.Fatalf("hosts not cascaded on inbound delete, len = %d", len(got)) t.Fatalf("hosts not cascaded on inbound delete, len = %d", len(got))
} }
if _, err := svc.GetHostGroup(h1.GroupId); err == nil {
t.Fatalf("expected group to be deleted after cascading")
}
} }
// TestGetAllTags: distinct, sorted tags across all hosts.
func TestGetAllTags(t *testing.T) { func TestGetAllTags(t *testing.T) {
setupBulkDB(t) setupBulkDB(t)
svc := &HostService{} svc := &HostService{}
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`) ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
if _, err := svc.AddHost(&model.Host{InboundId: ib.Id, Remark: "h1", Tags: []string{"EU", "CDN"}}); err != nil { if _, err := svc.AddHostGroup(&entity.HostGroup{InboundIds: []int{ib.Id}, Remark: "h1", Hosts: []string{"h1.com"}, Tags: []string{"EU", "CDN"}}); err != nil {
t.Fatalf("AddHost: %v", err) t.Fatalf("AddHostGroup: %v", err)
} }
if _, err := svc.AddHost(&model.Host{InboundId: ib.Id, Remark: "h2", Tags: []string{"CDN", "FAST"}}); err != nil { if _, err := svc.AddHostGroup(&entity.HostGroup{InboundIds: []int{ib.Id}, Remark: "h2", Hosts: []string{"h2.com"}, Tags: []string{"CDN", "FAST"}}); err != nil {
t.Fatalf("AddHost: %v", err) t.Fatalf("AddHostGroup: %v", err)
} }
tags, err := svc.GetAllTags() tags, err := svc.GetAllTags()
if err != nil { if err != nil {
@@ -177,3 +175,193 @@ func TestGetAllTags(t *testing.T) {
} }
} }
} }
func TestAddHostsGroup(t *testing.T) {
setupBulkDB(t)
svc := &HostService{}
ib1 := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
ib2 := mkInbound(t, 80, model.VLESS, `{"clients":[]}`)
req := &entity.HostGroup{
InboundIds: []int{ib1.Id, ib2.Id},
Hosts: []string{"h1.com", "h2.com:443", "[2001:db8::1]:80"},
Remark: "BulkRemark",
Port: 8443,
Security: "same",
}
created, err := svc.AddHostGroup(req)
if err != nil {
t.Fatalf("AddHostGroup: %v", err)
}
if len(created) != 6 {
t.Fatalf("expected 6 created hosts, got %d", len(created))
}
got1, _ := svc.GetHostsByInbound(ib1.Id)
if len(got1) != 1 {
t.Fatalf("expected 1 group for inbound 1, got %d", len(got1))
}
g := got1[0]
if g.Remark != "BulkRemark" {
t.Errorf("expected remark BulkRemark, got %s", g.Remark)
}
var foundH2Port443 bool
var foundIPv6Port80 bool
var foundH1DefaultPort8443 bool
for _, hostStr := range g.Hosts {
if hostStr == "h2.com:443" {
foundH2Port443 = true
}
if hostStr == "[2001:db8::1]:80" {
foundIPv6Port80 = true
}
if hostStr == "h1.com:8443" {
foundH1DefaultPort8443 = true
}
}
if !foundH2Port443 {
t.Error("missing custom port override host h2.com:443")
}
if !foundIPv6Port80 {
t.Error("missing IPv6 host with port override [2001:db8::1]:80")
}
if !foundH1DefaultPort8443 {
t.Error("missing default port fallback host h1.com:8443")
}
}
func TestParseHostAndPort_IPv6EdgeCases(t *testing.T) {
tests := []struct {
input string
defaultPort int
wantAddr string
wantPort int
}{
{"2001:db8::1", 8443, "2001:db8::1", 8443},
{"[2001:db8::1]:80", 8443, "2001:db8::1", 80},
{"h1.com:443", 8443, "h1.com", 443},
{"h1.com", 8443, "h1.com", 8443},
}
for _, tc := range tests {
addr, port := parseHostAndPort(tc.input, tc.defaultPort)
if addr != tc.wantAddr || port != tc.wantPort {
t.Errorf("parseHostAndPort(%q, %d) = (%q, %d); want (%q, %d)",
tc.input, tc.defaultPort, addr, port, tc.wantAddr, tc.wantPort)
}
}
}
func TestParseHostAndPort_AdversarialStressCases(t *testing.T) {
tests := []struct {
input string
defaultPort int
wantAddr string
wantPort int
}{
{"", 8443, "", 8443},
{" ", 8443, "", 8443},
{"h1.com: ", 8443, "h1.com:", 8443},
{"h1.com: -1", 8443, "h1.com: -1", 8443},
{"h1.com:-1", 8443, "h1.com:-1", 8443},
{"h1.com:0", 8443, "h1.com", 0},
{"h1.com:65535", 8443, "h1.com", 65535},
{"h1.com:65536", 8443, "h1.com:65536", 8443},
{"h1.com:80a", 8443, "h1.com:80a", 8443},
{"h1.com:123:456", 8443, "h1.com:123:456", 8443},
{"[2001:db8::1]", 8443, "2001:db8::1", 8443},
{"[2001:db8::1]:80", 8443, "2001:db8::1", 80},
{"2001:db8::1", 8443, "2001:db8::1", 8443},
{"[2001:db8::1]:65536", 8443, "[2001:db8::1]:65536", 8443},
{"[]:80", 8443, "", 80},
{"[:]::80", 8443, "[:]:", 80},
{"h1.com:", 8443, "h1.com:", 8443},
{"h1.com:123:", 8443, "h1.com:123:", 8443},
{" h1.com : 80 ", 8443, "h1.com : 80", 8443},
{" [2001:db8::1]:80 ", 8443, "2001:db8::1", 80},
{"[2001:db8::1]:+80", 8443, "2001:db8::1", 80},
{"[2001:db8::1]:080", 8443, "2001:db8::1", 80},
{"[2001:db8::1]80", 8443, "[2001:db8::1]80", 8443},
{"[::1]", 8443, "::1", 8443},
{"[2001:db8::1", 8443, "[2001:db8:", 1},
{"[2001:db8::1]:-80", 8443, "[2001:db8::1]:-80", 8443},
{"h1.com:443:80", 8443, "h1.com:443:80", 8443},
{"[2001:db8::1]::80", 8443, "[2001:db8::1]:", 80},
}
for _, tc := range tests {
addr, port := parseHostAndPort(tc.input, tc.defaultPort)
if addr != tc.wantAddr || port != tc.wantPort {
t.Errorf("parseHostAndPort(%q, %d) = (%q, %d); want (%q, %d)",
tc.input, tc.defaultPort, addr, port, tc.wantAddr, tc.wantPort)
}
}
}
func TestAddHostGroup_OptionalAddress(t *testing.T) {
setupBulkDB(t)
svc := &HostService{}
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
created, err := svc.AddHostGroup(&entity.HostGroup{
InboundIds: []int{ib.Id},
Remark: "OptionalAddressHost",
Hosts: nil,
Port: 8443,
})
if err != nil {
t.Fatalf("AddHostGroup with nil Hosts failed: %v", err)
}
if len(created) != 1 {
t.Fatalf("expected 1 host created, got %d", len(created))
}
g, err := svc.GetHostGroup(created[0].GroupId)
if err != nil {
t.Fatalf("GetHostGroup failed: %v", err)
}
if len(g.Hosts) != 1 || g.Hosts[0] != ":8443" {
t.Fatalf("expected Hosts list to contain default port fallback ':8443', got %v", g.Hosts)
}
}
func TestUpdateHostGroup_ValidateBeforeDelete(t *testing.T) {
setupBulkDB(t)
svc := &HostService{}
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
h1 := mkHost(t, svc, ib.Id, "h1", 0)
req := &entity.HostGroup{
InboundIds: []int{99999},
Remark: "h1-updated",
Hosts: []string{"h1.com"},
}
if _, err := svc.UpdateHostGroup(h1.GroupId, req); err == nil {
t.Fatalf("expected error updating host group with invalid inbound")
}
got, err := svc.GetHostGroup(h1.GroupId)
if err != nil {
t.Fatalf("original host group should not be deleted: %v", err)
}
if got.Remark != "h1" {
t.Fatalf("original host group remark changed: %s", got.Remark)
}
req.InboundIds = []int{ib.Id}
if _, err := svc.UpdateHostGroup(h1.GroupId, req); err != nil {
t.Fatalf("valid update failed: %v", err)
}
got2, _ := svc.GetHostGroup(h1.GroupId)
if got2.Remark != "h1-updated" {
t.Fatalf("remark not updated: %s", got2.Remark)
}
}
+3 -2
View File
@@ -1894,7 +1894,7 @@
"fields": { "fields": {
"remark": "ملاحظة", "remark": "ملاحظة",
"serverDescription": "الوصف", "serverDescription": "الوصف",
"inbound": "الوارد", "inbound": "الواردات",
"address": "العنوان", "address": "العنوان",
"port": "المنفذ", "port": "المنفذ",
"endpoint": "النهاية", "endpoint": "النهاية",
@@ -1921,7 +1921,8 @@
"tags": "وسوم", "tags": "وسوم",
"nodeGuids": "النودز", "nodeGuids": "النودز",
"excludeFromSubTypes": "استبعاد من الصيغ", "excludeFromSubTypes": "استبعاد من الصيغ",
"verifyPeerCertByName": "التحقق من شهادة النظير بالاسم" "verifyPeerCertByName": "التحقق من شهادة النظير بالاسم",
"inheritAddress": "يرث العنوان"
}, },
"hints": { "hints": {
"address": "اتركه فارغاً ليرث عنوان الوارد نفسه.", "address": "اتركه فارغاً ليرث عنوان الوارد نفسه.",
+6 -5
View File
@@ -1016,7 +1016,7 @@
"fields": { "fields": {
"remark": "Remark", "remark": "Remark",
"serverDescription": "Description", "serverDescription": "Description",
"inbound": "Inbound", "inbound": "Inbounds",
"address": "Address", "address": "Address",
"port": "Port", "port": "Port",
"endpoint": "Endpoint", "endpoint": "Endpoint",
@@ -1043,7 +1043,8 @@
"shuffleHost": "Shuffle host", "shuffleHost": "Shuffle host",
"tags": "Tags", "tags": "Tags",
"nodeGuids": "Nodes", "nodeGuids": "Nodes",
"excludeFromSubTypes": "Exclude from formats" "excludeFromSubTypes": "Exclude from formats",
"inheritAddress": "Inherits"
}, },
"hints": { "hints": {
"address": "Leave blank to inherit the inbound's own address.", "address": "Leave blank to inherit the inbound's own address.",
@@ -1098,9 +1099,9 @@
"toasts": { "toasts": {
"list": "Failed to load hosts", "list": "Failed to load hosts",
"obtain": "Failed to load host", "obtain": "Failed to load host",
"add": "Add host", "add": "Host added successfully",
"update": "Update host", "update": "Host updated successfully",
"delete": "Delete host", "delete": "Host deleted successfully",
"badTag": "Invalid tag", "badTag": "Invalid tag",
"badVlessRoute": "Enter a single number between 0 and 65535" "badVlessRoute": "Enter a single number between 0 and 65535"
} }
+3 -2
View File
@@ -1894,7 +1894,7 @@
"fields": { "fields": {
"remark": "Notas", "remark": "Notas",
"serverDescription": "Descripción", "serverDescription": "Descripción",
"inbound": "Inbound", "inbound": "Inbounds",
"address": "Dirección", "address": "Dirección",
"port": "Puerto", "port": "Puerto",
"endpoint": "Punto final", "endpoint": "Punto final",
@@ -1921,7 +1921,8 @@
"shuffleHost": "Barajar host", "shuffleHost": "Barajar host",
"tags": "Etiquetas", "tags": "Etiquetas",
"nodeGuids": "Nodos", "nodeGuids": "Nodos",
"excludeFromSubTypes": "Excluir de formatos" "excludeFromSubTypes": "Excluir de formatos",
"inheritAddress": "Hereda dirección"
}, },
"hints": { "hints": {
"address": "Déjalo en blanco para heredar la dirección propia del inbound.", "address": "Déjalo en blanco para heredar la dirección propia del inbound.",
+3 -2
View File
@@ -1894,7 +1894,7 @@
"fields": { "fields": {
"remark": "نام", "remark": "نام",
"serverDescription": "توضیحات", "serverDescription": "توضیحات",
"inbound": "اینباند", "inbound": "اینباندها",
"address": "آدرس", "address": "آدرس",
"port": "پورت", "port": "پورت",
"endpoint": "نقطه پایانی", "endpoint": "نقطه پایانی",
@@ -1921,7 +1921,8 @@
"shuffleHost": "درهم‌سازی میزبان", "shuffleHost": "درهم‌سازی میزبان",
"tags": "برچسب‌ها", "tags": "برچسب‌ها",
"nodeGuids": "نودها", "nodeGuids": "نودها",
"excludeFromSubTypes": "حذف از فرمت‌ها" "excludeFromSubTypes": "حذف از فرمت‌ها",
"inheritAddress": "ارث‌بری آدرس"
}, },
"hints": { "hints": {
"address": "برای ارث‌بری آدرس خودِ اینباند خالی بگذارید.", "address": "برای ارث‌بری آدرس خودِ اینباند خالی بگذارید.",
+2 -1
View File
@@ -1921,7 +1921,8 @@
"tags": "Tag", "tags": "Tag",
"nodeGuids": "Node", "nodeGuids": "Node",
"excludeFromSubTypes": "Kecualikan dari format", "excludeFromSubTypes": "Kecualikan dari format",
"verifyPeerCertByName": "Verifikasi sertifikat peer berdasarkan nama" "verifyPeerCertByName": "Verifikasi sertifikat peer berdasarkan nama",
"inheritAddress": "Warisi alamat"
}, },
"hints": { "hints": {
"address": "Biarkan kosong untuk mewarisi alamat inbound itu sendiri.", "address": "Biarkan kosong untuk mewarisi alamat inbound itu sendiri.",
+2 -1
View File
@@ -1921,7 +1921,8 @@
"shuffleHost": "ホストをシャッフル", "shuffleHost": "ホストをシャッフル",
"tags": "タグ", "tags": "タグ",
"nodeGuids": "ノード", "nodeGuids": "ノード",
"excludeFromSubTypes": "形式から除外" "excludeFromSubTypes": "形式から除外",
"inheritAddress": "アドレス継承"
}, },
"hints": { "hints": {
"address": "空欄にするとインバウンド自身のアドレスを継承します。", "address": "空欄にするとインバウンド自身のアドレスを継承します。",
+3 -2
View File
@@ -1894,7 +1894,7 @@
"fields": { "fields": {
"remark": "Observação", "remark": "Observação",
"serverDescription": "Descrição", "serverDescription": "Descrição",
"inbound": "Entrada", "inbound": "Entradas",
"address": "Endereço", "address": "Endereço",
"port": "Porta", "port": "Porta",
"endpoint": "Endpoint", "endpoint": "Endpoint",
@@ -1921,7 +1921,8 @@
"shuffleHost": "Embaralhar host", "shuffleHost": "Embaralhar host",
"tags": "Tags", "tags": "Tags",
"nodeGuids": "Nós", "nodeGuids": "Nós",
"excludeFromSubTypes": "Excluir dos formatos" "excludeFromSubTypes": "Excluir dos formatos",
"inheritAddress": "Herda endereço"
}, },
"hints": { "hints": {
"address": "Deixe em branco para herdar o próprio endereço da entrada.", "address": "Deixe em branco para herdar o próprio endereço da entrada.",
+3 -2
View File
@@ -1894,7 +1894,7 @@
"fields": { "fields": {
"remark": "Примечание", "remark": "Примечание",
"serverDescription": "Описание", "serverDescription": "Описание",
"inbound": "Входящее", "inbound": "Входящие",
"address": "Адрес", "address": "Адрес",
"port": "Порт", "port": "Порт",
"endpoint": "Конечная точка", "endpoint": "Конечная точка",
@@ -1921,7 +1921,8 @@
"shuffleHost": "Перемешивать хост", "shuffleHost": "Перемешивать хост",
"tags": "Теги", "tags": "Теги",
"nodeGuids": "Узлы", "nodeGuids": "Узлы",
"excludeFromSubTypes": "Исключить из форматов" "excludeFromSubTypes": "Исключить из форматов",
"inheritAddress": "Наследует адрес"
}, },
"hints": { "hints": {
"address": "Оставьте пустым, чтобы унаследовать собственный адрес входящего.", "address": "Оставьте пустым, чтобы унаследовать собственный адрес входящего.",
+3 -2
View File
@@ -1894,7 +1894,7 @@
"fields": { "fields": {
"remark": "Açıklama", "remark": "Açıklama",
"serverDescription": "Tanım", "serverDescription": "Tanım",
"inbound": "Gelen Bağlantı", "inbound": "Gelen Bağlantılar",
"address": "Adres", "address": "Adres",
"port": "Port", "port": "Port",
"endpoint": "Uç Nokta", "endpoint": "Uç Nokta",
@@ -1921,7 +1921,8 @@
"tags": "Etiketler", "tags": "Etiketler",
"nodeGuids": "Düğümler", "nodeGuids": "Düğümler",
"excludeFromSubTypes": "Formatlardan hariç tut", "excludeFromSubTypes": "Formatlardan hariç tut",
"verifyPeerCertByName": "Peer sertifikasını ada göre doğrula" "verifyPeerCertByName": "Peer sertifikasını ada göre doğrula",
"inheritAddress": "Adresi devralır"
}, },
"hints": { "hints": {
"address": "Gelen bağlantının kendi adresini devralmak için boş bırakın.", "address": "Gelen bağlantının kendi adresini devralmak için boş bırakın.",
+3 -2
View File
@@ -1894,7 +1894,7 @@
"fields": { "fields": {
"remark": "Примітка", "remark": "Примітка",
"serverDescription": "Опис", "serverDescription": "Опис",
"inbound": "Вхідний", "inbound": "Вхідні",
"address": "Адреса", "address": "Адреса",
"port": "Порт", "port": "Порт",
"endpoint": "Кінцева точка", "endpoint": "Кінцева точка",
@@ -1921,7 +1921,8 @@
"shuffleHost": "Перемішувати host", "shuffleHost": "Перемішувати host",
"tags": "Теги", "tags": "Теги",
"nodeGuids": "Вузли", "nodeGuids": "Вузли",
"excludeFromSubTypes": "Виключити з форматів" "excludeFromSubTypes": "Виключити з форматів",
"inheritAddress": "Успадковує адресу"
}, },
"hints": { "hints": {
"address": "Залиште порожнім, щоб успадкувати власну адресу вхідного.", "address": "Залиште порожнім, щоб успадкувати власну адресу вхідного.",
+3 -2
View File
@@ -1894,7 +1894,7 @@
"fields": { "fields": {
"remark": "Ghi chú", "remark": "Ghi chú",
"serverDescription": "Mô tả", "serverDescription": "Mô tả",
"inbound": "Inbound", "inbound": "Inbounds",
"address": "Địa chỉ", "address": "Địa chỉ",
"port": "Cổng", "port": "Cổng",
"endpoint": "Endpoint", "endpoint": "Endpoint",
@@ -1921,7 +1921,8 @@
"shuffleHost": "Xáo trộn host", "shuffleHost": "Xáo trộn host",
"tags": "Tag", "tags": "Tag",
"nodeGuids": "Nút", "nodeGuids": "Nút",
"excludeFromSubTypes": "Loại trừ khỏi định dạng" "excludeFromSubTypes": "Loại trừ khỏi định dạng",
"inheritAddress": "Kế thừa địa chỉ"
}, },
"hints": { "hints": {
"address": "Để trống để kế thừa địa chỉ của chính inbound.", "address": "Để trống để kế thừa địa chỉ của chính inbound.",
+2 -1
View File
@@ -1921,7 +1921,8 @@
"shuffleHost": "随机打乱 Host", "shuffleHost": "随机打乱 Host",
"tags": "标签", "tags": "标签",
"nodeGuids": "节点", "nodeGuids": "节点",
"excludeFromSubTypes": "从格式中排除" "excludeFromSubTypes": "从格式中排除",
"inheritAddress": "继承地址"
}, },
"hints": { "hints": {
"address": "留空则继承入站自身的地址。", "address": "留空则继承入站自身的地址。",
+2 -1
View File
@@ -1921,7 +1921,8 @@
"shuffleHost": "隨機排序 Host", "shuffleHost": "隨機排序 Host",
"tags": "標籤", "tags": "標籤",
"nodeGuids": "節點", "nodeGuids": "節點",
"excludeFromSubTypes": "從格式中排除" "excludeFromSubTypes": "從格式中排除",
"inheritAddress": "繼承地址"
}, },
"hints": { "hints": {
"address": "留空以繼承入站本身的地址。", "address": "留空以繼承入站本身的地址。",
+1
View File
@@ -65,6 +65,7 @@ func run(root, outDir string) error {
"Msg", "Msg",
"AllSetting", "AllSetting",
"AllSettingView", "AllSettingView",
"HostGroup",
), ),
}, },
{ {