feat(settings): add reserved hosts field and surface NAT errors

Add the reserved_hosts setting (input + i18n across all locales) so operators
behind a reverse proxy can declare public dashboard hostnames members must not
register as NAT domains (GHSA-x6fg-52vr-hj4w).

Show the backend error message on NAT create/update failure instead of a
generic "unexpected error", so a reserved-host rejection is explained.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
naiba
2026-05-31 02:04:47 +00:00
co-authored by cloudcode
parent f07c557029
commit 964684f1bf
12 changed files with 40 additions and 2 deletions
+3 -2
View File
@@ -82,10 +82,11 @@ export const NATCard: React.FC<NATCardProps> = ({ data, mutate }) => {
} else { } else {
await createNAT(values) await createNAT(values)
} }
} catch (e) { } catch (e: unknown) {
console.error(e) console.error(e)
toast(t("Error"), { toast(t("Error"), {
description: t("Results.UnExpectedError"), description:
e instanceof Error && e.message ? e.message : t("Results.UnExpectedError"),
}) })
return return
} }
+2
View File
@@ -148,6 +148,8 @@
"Loading": "Laden", "Loading": "Laden",
"Services": "Services", "Services": "Services",
"DashboardOriginalHost": "Agent Verbindungsadresse [Domainname/IP:port]", "DashboardOriginalHost": "Agent Verbindungsadresse [Domainname/IP:port]",
"ReservedHosts": "Reservierte Hosts (kommagetrennt; öffentliche/Reverse-Proxy-Domains, die Mitglieder nicht als NAT-Domain nutzen dürfen)",
"ReservedHostsHint": "Hinter einem Reverse-Proxy sieht das Dashboard seine eigene öffentliche Domain nicht und ist daher nicht automatisch geschützt. Tragen Sie hier jeden öffentlichen Eingangs-Hostnamen ein, sonst könnte ein Mitglied ihn als NAT-Domain registrieren und das Dashboard-Routing übernehmen.",
"NavigateTo": "Navigieren", "NavigateTo": "Navigieren",
"Offline": "Offline", "Offline": "Offline",
"Interval": "Intervall", "Interval": "Intervall",
+2
View File
@@ -142,6 +142,8 @@
"WAF": "Web Application Firewall", "WAF": "Web Application Firewall",
"SiteName": "Site Name", "SiteName": "Site Name",
"DashboardOriginalHost": "Agent connecting address [domain name/IP:port]", "DashboardOriginalHost": "Agent connecting address [domain name/IP:port]",
"ReservedHosts": "Reserved hosts (comma-separated; public/reverse-proxy domains members cannot use as NAT domains)",
"ReservedHostsHint": "Behind a reverse proxy the dashboard cannot see its own public domain, so it is not auto-protected. List every public entry hostname here, or a member could register it as a NAT domain and hijack dashboard routing.",
"ConfigTLS": "Use TLS to connect Agent", "ConfigTLS": "Use TLS to connect Agent",
"LoginFailed": "Login Failed", "LoginFailed": "Login Failed",
"BruteForceAttackingToken": "Brute Force Attacking Token", "BruteForceAttackingToken": "Brute Force Attacking Token",
+2
View File
@@ -158,6 +158,8 @@
"RequestType": "Tipo de solicitud", "RequestType": "Tipo de solicitud",
"RequestBody": "Cuerpo de la solicitud", "RequestBody": "Cuerpo de la solicitud",
"DashboardOriginalHost": "Dirección de conexión del Agent [nombre de dominio/IP:puerto]", "DashboardOriginalHost": "Dirección de conexión del Agent [nombre de dominio/IP:puerto]",
"ReservedHosts": "Hosts reservados (separados por comas; dominios públicos/proxy inverso que los miembros no pueden usar como dominio NAT)",
"ReservedHostsHint": "Detrás de un proxy inverso, el panel no ve su propio dominio público, por lo que no se protege automáticamente. Indique aquí todos los nombres de host de entrada públicos; de lo contrario, un miembro podría registrarlo como dominio NAT y secuestrar el enrutamiento del panel.",
"ConfigTLS": "Usar TLS para conectar el Agent", "ConfigTLS": "Usar TLS para conectar el Agent",
"CustomCodes": "Códigos personalizados (Style y Script)", "CustomCodes": "Códigos personalizados (Style y Script)",
"CustomCodesDashboard": "Códigos personalizados para el Dashboard", "CustomCodesDashboard": "Códigos personalizados para el Dashboard",
+2
View File
@@ -139,6 +139,8 @@
"WAF": "Firewall Aplikasi Web", "WAF": "Firewall Aplikasi Web",
"SiteName": "Nama Situs", "SiteName": "Nama Situs",
"DashboardOriginalHost": "Alamat koneksi Agen [nama domain/IP:port]", "DashboardOriginalHost": "Alamat koneksi Agen [nama domain/IP:port]",
"ReservedHosts": "Host khusus (dipisahkan koma; domain publik/reverse-proxy yang tidak boleh dipakai anggota sebagai domain NAT)",
"ReservedHostsHint": "Di belakang reverse proxy, dasbor tidak melihat domain publiknya sendiri sehingga tidak terlindungi otomatis. Cantumkan setiap host entri publik di sini, atau anggota bisa mendaftarkannya sebagai domain NAT dan membajak perutean dasbor.",
"ConfigTLS": "Gunakan TLS untuk menghubungkan Agen", "ConfigTLS": "Gunakan TLS untuk menghubungkan Agen",
"LoginFailed": "Gagal Masuk", "LoginFailed": "Gagal Masuk",
"BruteForceAttackingToken": "Token Serangan Brute Force", "BruteForceAttackingToken": "Token Serangan Brute Force",
+2
View File
@@ -142,6 +142,8 @@
"CustomCodes": "Codice personalizzato (stili e script)", "CustomCodes": "Codice personalizzato (stili e script)",
"CustomCodesDashboard": "Codice personalizzato per dashboard", "CustomCodesDashboard": "Codice personalizzato per dashboard",
"DashboardOriginalHost": "Indirizzo di ancoraggio dell'agente [nome dominio/IP:porta]", "DashboardOriginalHost": "Indirizzo di ancoraggio dell'agente [nome dominio/IP:porta]",
"ReservedHosts": "Host riservati (separati da virgola; domini pubblici/reverse-proxy che i membri non possono usare come dominio NAT)",
"ReservedHostsHint": "Dietro un reverse proxy il pannello non vede il proprio dominio pubblico, quindi non è protetto automaticamente. Elenca qui ogni hostname di ingresso pubblico, altrimenti un membro potrebbe registrarlo come dominio NAT e dirottare il routing del pannello.",
"ConfigTLS": "Usa TLS per connettere Agent", "ConfigTLS": "Usa TLS per connettere Agent",
"CustomPublicDNSNameserversforDDNS": "Server dei nomi DNS pubblici personalizzati per DDNS", "CustomPublicDNSNameserversforDDNS": "Server dei nomi DNS pubblici personalizzati per DDNS",
"WebRealIPHeader": "Intestazione della richiesta IP reale del frontend", "WebRealIPHeader": "Intestazione della richiesta IP reale del frontend",
+2
View File
@@ -42,6 +42,8 @@
"Credential": "Учетные данные", "Credential": "Учетные данные",
"CommunityThemeDescription": "Эта тема предоставлена сообществом, используйте на свой страх и риск", "CommunityThemeDescription": "Эта тема предоставлена сообществом, используйте на свой страх и риск",
"DashboardOriginalHost": "Адрес подключения агента [домен/IP:порт]", "DashboardOriginalHost": "Адрес подключения агента [домен/IP:порт]",
"ReservedHosts": "Зарезервированные хосты (через запятую; публичные/реверс-прокси домены, которые участники не могут использовать как NAT-домен)",
"ReservedHostsHint": "За реверс-прокси панель не видит собственный публичный домен и не защищается автоматически. Укажите здесь все публичные входные хосты, иначе участник сможет зарегистрировать такой домен как NAT-домен и перехватить маршрутизацию панели.",
"Error": "Ошибка", "Error": "Ошибка",
"TriggerMode": "Режим срабатывания", "TriggerMode": "Режим срабатывания",
"Once": "Один раз", "Once": "Один раз",
+2
View File
@@ -135,6 +135,8 @@
"WAF": "வலை பயன்பாடு ஃபயர்வால்", "WAF": "வலை பயன்பாடு ஃபயர்வால்",
"SiteName": "தளத்தின் பெயர்", "SiteName": "தளத்தின் பெயர்",
"DashboardOriginalHost": "முகவரியை இணைக்கும் முகவரி [டொமைன் பெயர்/ஐபி: போர்ட்]", "DashboardOriginalHost": "முகவரியை இணைக்கும் முகவரி [டொமைன் பெயர்/ஐபி: போர்ட்]",
"ReservedHosts": "ஒதுக்கப்பட்ட ஹோஸ்ட்கள் (கமாவால் பிரிக்கப்பட்டது; உறுப்பினர்கள் NAT டொமைனாகப் பயன்படுத்த முடியாத பொது/ரிவர்ஸ்-ப்ராக்ஸி டொமைன்கள்)",
"ReservedHostsHint": "ரிவர்ஸ்-ப்ராக்ஸிக்குப் பின்னால் டாஷ்போர்டு அதன் சொந்த பொது டொமைனைப் பார்க்க முடியாது, எனவே அது தானாகப் பாதுகாக்கப்படாது. அனைத்து பொது நுழைவு ஹோஸ்ட்களையும் இங்கே பட்டியலிடுங்கள்; இல்லையெனில் ஒரு உறுப்பினர் அதை NAT டொமைனாகப் பதிவுசெய்து டாஷ்போர்டு வழித்தடத்தைக் கடத்தலாம்.",
"ConfigTLS": "முகவரை இணைக்க TLS ஐப் பயன்படுத்தவும்", "ConfigTLS": "முகவரை இணைக்க TLS ஐப் பயன்படுத்தவும்",
"LoginFailed": "உள்நுழைவு தோல்வியடைந்தது", "LoginFailed": "உள்நுழைவு தோல்வியடைந்தது",
"BruteForceAttackingToken": "மிருகத்தனமான ஆற்றல் டோக்கனைத் தாக்குகிறது", "BruteForceAttackingToken": "மிருகத்தனமான ஆற்றல் டோக்கனைத் தாக்குகிறது",
+2
View File
@@ -155,6 +155,8 @@
"CustomCodes": "自定义代码(样式和脚本)", "CustomCodes": "自定义代码(样式和脚本)",
"CustomCodesDashboard": "仪表板的自定义代码", "CustomCodesDashboard": "仪表板的自定义代码",
"DashboardOriginalHost": "Agent对接地址【域名/IP:端口】", "DashboardOriginalHost": "Agent对接地址【域名/IP:端口】",
"ReservedHosts": "保留 Host(逗号分隔;反代/公网域名,成员不可注册为 NAT 域名)",
"ReservedHostsHint": "反代部署下面板看不到自身的对外域名,无法自动保护。请在此列出所有公网入口域名,否则成员可将其注册为 NAT 域名抢占面板路由。",
"ConfigTLS": "Agent 使用 TLS 连接", "ConfigTLS": "Agent 使用 TLS 连接",
"CustomPublicDNSNameserversforDDNS": "DDNS 的自定义公共 DNS 名称服务器", "CustomPublicDNSNameserversforDDNS": "DDNS 的自定义公共 DNS 名称服务器",
"WebRealIPHeader": "前端真实IP请求头", "WebRealIPHeader": "前端真实IP请求头",
+2
View File
@@ -147,6 +147,8 @@
"CustomCodes": "自訂程式碼(樣式和腳本)", "CustomCodes": "自訂程式碼(樣式和腳本)",
"CustomCodesDashboard": "儀表板的自訂程式碼", "CustomCodesDashboard": "儀表板的自訂程式碼",
"DashboardOriginalHost": "Agent對接位址【網域名稱/IP:連接埠】", "DashboardOriginalHost": "Agent對接位址【網域名稱/IP:連接埠】",
"ReservedHosts": "保留 Host(逗號分隔;反代/公網網域,成員不可註冊為 NAT 網域)",
"ReservedHostsHint": "反代部署下面板看不到自身的對外網域,無法自動保護。請在此列出所有公網入口網域,否則成員可將其註冊為 NAT 網域搶佔面板路由。",
"ConfigTLS": "Agent 使用 TLS 連線", "ConfigTLS": "Agent 使用 TLS 連線",
"CustomPublicDNSNameserversforDDNS": "DDNS 的自訂公共 DNS 名稱伺服器", "CustomPublicDNSNameserversforDDNS": "DDNS 的自訂公共 DNS 名稱伺服器",
"WebRealIPHeader": "前端真實IP請求頭", "WebRealIPHeader": "前端真實IP請求頭",
+16
View File
@@ -7,6 +7,7 @@ import { Combobox } from "@/components/ui/combobox"
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -44,6 +45,7 @@ const settingFormSchema = z.object({
language: z.string().min(2), language: z.string().min(2),
user_template: z.string().min(1), user_template: z.string().min(1),
install_host: asOptionalField(z.string()), install_host: asOptionalField(z.string()),
reserved_hosts: asOptionalField(z.string()),
custom_code: asOptionalField(z.string()), custom_code: asOptionalField(z.string()),
custom_code_dashboard: asOptionalField(z.string()), custom_code_dashboard: asOptionalField(z.string()),
web_real_ip_header: asOptionalField(z.string()), web_real_ip_header: asOptionalField(z.string()),
@@ -290,6 +292,20 @@ export default function SettingsPage() {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="reserved_hosts"
render={({ field }) => (
<FormItem>
<FormLabel>{t("ReservedHosts")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>{t("ReservedHostsHint")}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="tls" name="tls"
+3
View File
@@ -722,6 +722,8 @@ export interface ModelSetting {
/** 系统语言,默认 zh_CN */ /** 系统语言,默认 zh_CN */
language: string language: string
oauth2_providers: string[] oauth2_providers: string[]
/** 反代部署下声明对外域名(逗号分隔),防止成员注册冲突 NAT 域名抢占路由 */
reserved_hosts: string
site_name: string site_name: string
/** 用于前端判断生成的安装命令是否启用 TLS */ /** 用于前端判断生成的安装命令是否启用 TLS */
tls: boolean tls: boolean
@@ -742,6 +744,7 @@ export interface ModelSettingForm {
enable_mcp?: boolean enable_mcp?: boolean
ignored_ip_notification?: string ignored_ip_notification?: string
install_host?: string install_host?: string
reserved_hosts?: string
/** IP变更提醒的通知组 */ /** IP变更提醒的通知组 */
ip_change_notification_group_id: number ip_change_notification_group_id: number
/** @minLength 2 */ /** @minLength 2 */