[新增] 内网穿透隧道前端管理与代理规则绑定支持 (P5)

This commit is contained in:
ryan
2026-06-01 09:20:45 +08:00
parent 9ded8c71da
commit 330771e7c7
14 changed files with 1145 additions and 102 deletions
@@ -23,6 +23,7 @@ import {
getWAFSiteRuleGroups,
replaceWAFSiteRuleGroups,
} from '@/features/waf/api/waf';
import { getTunnels } from '@/features/tunnels/api/tunnels';
import {
buildDomainRowsFromRoute,
DomainListInput,
@@ -156,19 +157,48 @@ const rateLimitSchema = z
const reverseProxySchema = z
.object({
origin_urls_text: z.string().trim().min(1, '请至少填写一个上游地址'),
upstream_type: z.enum(['direct', 'tunnel']),
origin_urls_text: z.string().trim(),
origin_host: z.string(),
tunnel_id: z.string().optional(),
tunnel_target_addr: z.string().trim().optional(),
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
custom_headers_text: z.string(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
})
.superRefine((value, context) => {
const { error } = parseOriginUrls(value.origin_urls_text);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: error,
});
if (value.upstream_type === 'direct') {
if (!value.origin_urls_text.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: '请至少填写一个上游地址',
});
} else {
const { error } = parseOriginUrls(value.origin_urls_text);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: error,
});
}
}
} else {
if (!value.tunnel_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_id'],
message: '请选择内网穿透隧道',
});
}
if (!value.tunnel_target_addr) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_target_addr'],
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
});
}
}
const originHostError = validateOriginHost(value.origin_host);
@@ -518,11 +548,20 @@ function ReverseProxySection({
saving: boolean;
onSave: SaveHandler;
}) {
const tunnelsQuery = useQuery({
queryKey: ['tunnels'],
queryFn: getTunnels,
});
const form = useForm<ReverseProxyValues>({
resolver: zodResolver(reverseProxySchema),
defaultValues: {
upstream_type: route.upstream_type || 'direct',
origin_urls_text: route.upstream_list.join('\n'),
origin_host: route.origin_host || '',
tunnel_id: route.tunnel_id ? String(route.tunnel_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
custom_headers_text: customHeadersToText(route.custom_header_list),
remark: route.remark || '',
},
@@ -530,8 +569,12 @@ function ReverseProxySection({
useEffect(() => {
form.reset({
upstream_type: route.upstream_type || 'direct',
origin_urls_text: route.upstream_list.join('\n'),
origin_host: route.origin_host || '',
tunnel_id: route.tunnel_id ? String(route.tunnel_id) : '',
tunnel_target_addr: route.tunnel_target_addr || '',
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
custom_headers_text: customHeadersToText(route.custom_header_list),
remark: route.remark || '',
});
@@ -539,8 +582,8 @@ function ReverseProxySection({
return (
<ConfigSectionShell
title="反向代理"
description="第一行作为主回源;填写多行时会自动进入多上游负载均衡模式。"
title="回源代理"
description="配置请求回源上游的策略与地址。"
formId="proxy-route-proxy-form"
saving={saving}
>
@@ -548,8 +591,28 @@ function ReverseProxySection({
id="proxy-route-proxy-form"
className="space-y-5"
onSubmit={form.handleSubmit((values) => {
const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]);
let originUrl = '';
let originScheme: 'http' | 'https' = 'http';
let originAddress = '';
let originPort = '';
let originUri = '';
let upstreams: string[] = [];
if (values.upstream_type === 'direct') {
const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]);
originUrl = urls[0];
originScheme = primaryOrigin.scheme;
originAddress = primaryOrigin.address;
originPort = primaryOrigin.port;
originUri = primaryOrigin.uri;
upstreams = urls.slice(1);
} else {
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
originScheme = values.tunnel_target_protocol as 'http' | 'https';
originAddress = values.tunnel_target_addr || '';
}
const { headers } = parseCustomHeadersText(
values.custom_headers_text,
);
@@ -557,34 +620,109 @@ function ReverseProxySection({
onSave(
buildPayloadFromRoute(route, {
origin_id: null,
origin_url: urls[0],
origin_scheme: primaryOrigin.scheme,
origin_address: primaryOrigin.address,
origin_port: primaryOrigin.port,
origin_uri: primaryOrigin.uri,
origin_url: originUrl,
origin_scheme: originScheme,
origin_address: originAddress,
origin_port: originPort,
origin_uri: originUri,
origin_host: values.origin_host.trim(),
upstreams: urls.slice(1),
upstreams: upstreams,
custom_headers: headers,
remark: values.remark.trim(),
upstream_type: values.upstream_type,
tunnel_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
}),
{ message: '反向代理设置已保存。' },
);
})}
>
<ResourceField
label="上游地址"
hint="每行一个完整 URL。多上游模式下不要带 path 或 query。"
error={form.formState.errors.origin_urls_text?.message}
>
<ResourceTextarea
aria-label="上游地址"
className="min-h-40"
placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
{...form.register('origin_urls_text')}
/>
</ResourceField>
<div className="space-y-3">
<label className="text-sm font-medium text-[var(--foreground-primary)] block">回源方式</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
<input
type="radio"
value="direct"
{...form.register('upstream_type')}
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
直连上游
</label>
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
<input
type="radio"
value="tunnel"
{...form.register('upstream_type')}
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
内网穿透 (Tunnel)
</label>
</div>
</div>
{form.watch('upstream_type') === 'direct' ? (
<ResourceField
label="上游地址"
hint="每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。"
error={form.formState.errors.origin_urls_text?.message}
>
<ResourceTextarea
aria-label="上游地址"
className="min-h-40"
placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
{...form.register('origin_urls_text')}
/>
</ResourceField>
) : (
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
<ResourceField
label="选择内网穿透隧道"
hint="将请求转发到该隧道连接的客户端节点。"
error={form.formState.errors.tunnel_id?.message}
>
<select
{...form.register('tunnel_id')}
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="">请选择...</option>
{(tunnelsQuery.data ?? []).map((tunnel) => (
<option key={tunnel.id} value={tunnel.id}>
{tunnel.name} ({tunnel.status === 'online' ? '在线' : '离线'})
</option>
))}
</select>
</ResourceField>
<ResourceField
label="内网服务协议"
hint="转发到内网服务时使用的协议。"
error={form.formState.errors.tunnel_target_protocol?.message}
>
<select
{...form.register('tunnel_target_protocol')}
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</ResourceField>
<ResourceField
label="内网服务地址"
hint="例如: 127.0.0.1:8080 或 192.168.1.10:80"
error={form.formState.errors.tunnel_target_addr?.message}
>
<ResourceInput
placeholder="127.0.0.1:8080"
{...form.register('tunnel_target_addr')}
/>
</ResourceField>
</div>
)}
<ResourceField
label="Origin Host Header"
@@ -20,6 +20,7 @@ import {
parseOriginUrls,
validateDomains,
} from '@/features/proxy-routes/helpers';
import { getTunnels } from '@/features/tunnels/api/tunnels';
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
import {
@@ -39,7 +40,11 @@ const createWebsiteSchema = z
.object({
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
domain_rows: z.array(domainRowSchema).min(1),
origin_urls_text: z.string().trim().min(1, '请至少填写一个上游地址'),
upstream_type: z.enum(['direct', 'tunnel']),
origin_urls_text: z.string().trim(),
tunnel_id: z.string().optional(),
tunnel_target_addr: z.string().trim().optional(),
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
enabled: z.boolean(),
redirect_http: z.boolean(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
@@ -57,13 +62,38 @@ const createWebsiteSchema = z
});
}
const { error } = parseOriginUrls(value.origin_urls_text);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: error,
});
if (value.upstream_type === 'direct') {
if (!value.origin_urls_text.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: '请至少填写一个上游地址',
});
} else {
const { error } = parseOriginUrls(value.origin_urls_text);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: error,
});
}
}
} else {
if (!value.tunnel_id) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_id'],
message: '请选择内网穿透隧道',
});
}
if (!value.tunnel_target_addr) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['tunnel_target_addr'],
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
});
}
}
const selectedCertificateCount = new Set(
@@ -85,7 +115,11 @@ type CreateWebsiteFormValues = z.infer<typeof createWebsiteSchema>;
const defaultValues: CreateWebsiteFormValues = {
site_name: '',
domain_rows: [{ domain: '', certificateId: '' }],
upstream_type: 'direct',
origin_urls_text: '',
tunnel_id: '',
tunnel_target_addr: '',
tunnel_target_protocol: 'http',
enabled: true,
redirect_http: false,
remark: '',
@@ -138,6 +172,11 @@ export function ProxyRouteCreateDrawer({
queryFn: getTlsCertificates,
enabled: open,
});
const tunnelsQuery = useQuery({
queryKey: ['tunnels'],
queryFn: getTunnels,
enabled: open,
});
const combinedDomainSuggestions = useMemo(
() => [
@@ -159,29 +198,40 @@ export function ProxyRouteCreateDrawer({
const selectedCertIDs = normalizeSelectedCertificateIDs(
values.domain_rows,
);
const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]);
let originUrl = '';
const originHost = '';
let upstreams: string[] = [];
if (values.upstream_type === 'direct') {
const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]);
originUrl = buildOriginUrl(
primaryOrigin.scheme,
primaryOrigin.address,
primaryOrigin.port,
primaryOrigin.uri,
);
upstreams = urls.slice(1);
} else {
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
}
return createProxyRoute({
site_name: values.site_name.trim() || domains[0],
domain: domains[0],
domains,
origin_id: null,
origin_url: buildOriginUrl(
primaryOrigin.scheme,
primaryOrigin.address,
primaryOrigin.port,
primaryOrigin.uri,
),
origin_scheme: primaryOrigin.scheme,
origin_address: primaryOrigin.address,
origin_port: primaryOrigin.port,
origin_uri: primaryOrigin.uri,
origin_host: '',
upstreams: urls.slice(1),
origin_url: originUrl,
origin_scheme: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).scheme : 'http',
origin_address: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).address : values.tunnel_target_addr || '',
origin_port: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).port : '80',
origin_uri: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).uri : '',
origin_host: originHost,
upstreams,
enabled: values.enabled,
enable_https: selectedCertIDs.length > 0,
cert_id: selectedCertIDs[0] ?? null,
cert_id: selectedCertIDs.length > 0 ? selectedCertIDs[0] : null,
cert_ids: selectedCertIDs,
domain_cert_ids: domainCertIDs,
redirect_http:
@@ -196,9 +246,11 @@ export function ProxyRouteCreateDrawer({
pow_enabled: false,
pow_config: '{}',
basic_auth_enabled: false,
basic_auth_username: '',
basic_auth_password: '',
remark: values.remark.trim(),
upstream_type: values.upstream_type,
tunnel_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
});
},
onSuccess: (route) => {
@@ -285,19 +337,90 @@ export function ProxyRouteCreateDrawer({
}
/>
<ResourceField
label="上游地址"
hint="每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。"
error={form.formState.errors.origin_urls_text?.message}
>
<ResourceTextarea
aria-label="上游地址"
placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
{...form.register('origin_urls_text')}
/>
</ResourceField>
<div className="space-y-3">
<label className="text-sm font-medium text-[var(--foreground-primary)] block">回源方式</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
<input
type="radio"
value="direct"
{...form.register('upstream_type')}
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
直连上游
</label>
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
<input
type="radio"
value="tunnel"
{...form.register('upstream_type')}
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
内网穿透 (Tunnel)
</label>
</div>
</div>
{form.watch('upstream_type') === 'direct' ? (
<ResourceField
label="上游地址"
hint="每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。"
error={form.formState.errors.origin_urls_text?.message}
>
<ResourceTextarea
aria-label="上游地址"
placeholder={
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
}
{...form.register('origin_urls_text')}
/>
</ResourceField>
) : (
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
<ResourceField
label="选择内网穿透隧道"
hint="将请求转发到该隧道连接的客户端节点。"
error={form.formState.errors.tunnel_id?.message}
>
<select
{...form.register('tunnel_id')}
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="">请选择...</option>
{(tunnelsQuery.data ?? []).map((tunnel) => (
<option key={tunnel.id} value={tunnel.id}>
{tunnel.name} ({tunnel.status === 'online' ? '在线' : '离线'})
</option>
))}
</select>
</ResourceField>
<ResourceField
label="内网服务协议"
hint="转发到内网服务时使用的协议。"
error={form.formState.errors.tunnel_target_protocol?.message}
>
<select
{...form.register('tunnel_target_protocol')}
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</ResourceField>
<ResourceField
label="内网服务地址"
hint="例如: 127.0.0.1:8080 或 192.168.1.10:80"
error={form.formState.errors.tunnel_target_addr?.message}
>
<ResourceInput
placeholder="127.0.0.1:8080"
{...form.register('tunnel_target_addr')}
/>
</ResourceField>
</div>
)}
<ToggleField
label="创建后立即启用"