mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 08:36:37 +08:00
[新增] 内网穿透隧道前端管理与代理规则绑定支持 (P5)
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { TunnelsPage } from '@/features/tunnels/components/tunnels-page';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: '内网穿透 - OpenFlare',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <TunnelsPage />;
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildNodeInstallCommand,
|
buildNodeInstallCommand,
|
||||||
buildNodeDockerInstallCommand,
|
buildNodeDockerInstallCommand,
|
||||||
|
buildRelayInstallCommand,
|
||||||
|
buildRelayDockerInstallCommand,
|
||||||
getApplyLabel,
|
getApplyLabel,
|
||||||
getApplyVariant,
|
getApplyVariant,
|
||||||
getNodeStatusLabel,
|
getNodeStatusLabel,
|
||||||
@@ -557,11 +559,15 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
|||||||
const normalizedServerUrl = getServerUrl(serverUrl);
|
const normalizedServerUrl = getServerUrl(serverUrl);
|
||||||
const nodeInstallCommand =
|
const nodeInstallCommand =
|
||||||
normalizedServerUrl && node.agent_token
|
normalizedServerUrl && node.agent_token
|
||||||
? buildNodeInstallCommand(normalizedServerUrl, node.agent_token)
|
? node.type === 'tunnel_relay'
|
||||||
|
? buildRelayInstallCommand(normalizedServerUrl, node.agent_token)
|
||||||
|
: buildNodeInstallCommand(normalizedServerUrl, node.agent_token)
|
||||||
: '';
|
: '';
|
||||||
const nodeDockerInstallCommand =
|
const nodeDockerInstallCommand =
|
||||||
normalizedServerUrl && node.agent_token
|
normalizedServerUrl && node.agent_token
|
||||||
? buildNodeDockerInstallCommand(normalizedServerUrl, node.agent_token)
|
? node.type === 'tunnel_relay'
|
||||||
|
? buildRelayDockerInstallCommand(normalizedServerUrl, node.agent_token)
|
||||||
|
: buildNodeDockerInstallCommand(normalizedServerUrl, node.agent_token)
|
||||||
: '';
|
: '';
|
||||||
const updateMode = getUpdateMode(node);
|
const updateMode = getUpdateMode(node);
|
||||||
const selectedAgentRelease =
|
const selectedAgentRelease =
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type RegionOption = {
|
|||||||
|
|
||||||
const nodeEditorSchema = z
|
const nodeEditorSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
type: z.enum(['edge_node', 'tunnel_relay']),
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -45,6 +46,9 @@ const nodeEditorSchema = z
|
|||||||
ip: z.string().trim().max(64, '节点 IP 不能超过 64 个字符'),
|
ip: z.string().trim().max(64, '节点 IP 不能超过 64 个字符'),
|
||||||
ip_manual_override: z.boolean(),
|
ip_manual_override: z.boolean(),
|
||||||
auto_update_enabled: z.boolean(),
|
auto_update_enabled: z.boolean(),
|
||||||
|
relay_bind_port: z.string().trim(),
|
||||||
|
relay_client_access_addr: z.string().trim(),
|
||||||
|
relay_client_proxy_url: z.string().trim(),
|
||||||
geo_manual_override: z.boolean(),
|
geo_manual_override: z.boolean(),
|
||||||
geo_region: z.string(),
|
geo_region: z.string(),
|
||||||
geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'),
|
geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'),
|
||||||
@@ -60,6 +64,17 @@ const nodeEditorSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.type === 'tunnel_relay') {
|
||||||
|
const port = Number(values.relay_bind_port);
|
||||||
|
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['relay_bind_port'],
|
||||||
|
message: '请填写正确的端口号 (1-65535)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!values.geo_manual_override) {
|
if (!values.geo_manual_override) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -107,10 +122,14 @@ const nodeEditorSchema = z
|
|||||||
type NodeEditorValues = z.infer<typeof nodeEditorSchema>;
|
type NodeEditorValues = z.infer<typeof nodeEditorSchema>;
|
||||||
|
|
||||||
const defaultValues: NodeEditorValues = {
|
const defaultValues: NodeEditorValues = {
|
||||||
|
type: 'edge_node',
|
||||||
name: '',
|
name: '',
|
||||||
ip: '',
|
ip: '',
|
||||||
ip_manual_override: false,
|
ip_manual_override: false,
|
||||||
auto_update_enabled: false,
|
auto_update_enabled: false,
|
||||||
|
relay_bind_port: '7000',
|
||||||
|
relay_client_access_addr: '',
|
||||||
|
relay_client_proxy_url: '',
|
||||||
geo_manual_override: false,
|
geo_manual_override: false,
|
||||||
geo_region: '',
|
geo_region: '',
|
||||||
geo_name: '',
|
geo_name: '',
|
||||||
@@ -192,10 +211,14 @@ function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
type: node.type ?? 'edge_node',
|
||||||
name: node.name ?? '',
|
name: node.name ?? '',
|
||||||
ip: node.ip ?? '',
|
ip: node.ip ?? '',
|
||||||
ip_manual_override: node.ip_manual_override ?? false,
|
ip_manual_override: node.ip_manual_override ?? false,
|
||||||
auto_update_enabled: node.auto_update_enabled ?? false,
|
auto_update_enabled: node.auto_update_enabled ?? false,
|
||||||
|
relay_bind_port: String(node.relay_bind_port ?? 7000),
|
||||||
|
relay_client_access_addr: node.relay_client_access_addr ?? '',
|
||||||
|
relay_client_proxy_url: node.relay_client_proxy_url ?? '',
|
||||||
geo_manual_override: node.geo_manual_override ?? false,
|
geo_manual_override: node.geo_manual_override ?? false,
|
||||||
geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '',
|
geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '',
|
||||||
geo_name: node.geo_name ?? '',
|
geo_name: node.geo_name ?? '',
|
||||||
@@ -211,12 +234,20 @@ function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toPayload(values: NodeEditorValues): NodeMutationPayload {
|
function toPayload(values: NodeEditorValues): NodeMutationPayload {
|
||||||
|
const basePayload = {
|
||||||
|
type: values.type,
|
||||||
|
name: values.name.trim(),
|
||||||
|
ip: values.ip.trim(),
|
||||||
|
ip_manual_override: values.ip_manual_override,
|
||||||
|
auto_update_enabled: values.auto_update_enabled,
|
||||||
|
relay_bind_port: values.type === 'tunnel_relay' ? Number(values.relay_bind_port) : undefined,
|
||||||
|
relay_client_access_addr: values.type === 'tunnel_relay' ? values.relay_client_access_addr.trim() : undefined,
|
||||||
|
relay_client_proxy_url: values.type === 'tunnel_relay' ? values.relay_client_proxy_url.trim() : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
if (!values.geo_manual_override) {
|
if (!values.geo_manual_override) {
|
||||||
return {
|
return {
|
||||||
name: values.name.trim(),
|
...basePayload,
|
||||||
ip: values.ip.trim(),
|
|
||||||
ip_manual_override: values.ip_manual_override,
|
|
||||||
auto_update_enabled: values.auto_update_enabled,
|
|
||||||
geo_manual_override: false,
|
geo_manual_override: false,
|
||||||
geo_name: '',
|
geo_name: '',
|
||||||
geo_latitude: null,
|
geo_latitude: null,
|
||||||
@@ -225,10 +256,7 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: values.name.trim(),
|
...basePayload,
|
||||||
ip: values.ip.trim(),
|
|
||||||
ip_manual_override: values.ip_manual_override,
|
|
||||||
auto_update_enabled: values.auto_update_enabled,
|
|
||||||
geo_manual_override: true,
|
geo_manual_override: true,
|
||||||
geo_name: values.geo_name.trim(),
|
geo_name: values.geo_name.trim(),
|
||||||
geo_latitude:
|
geo_latitude:
|
||||||
@@ -262,6 +290,10 @@ export function NodeEditorModal({
|
|||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchedType = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: 'type',
|
||||||
|
});
|
||||||
const watchedAutoUpdate = useWatch({
|
const watchedAutoUpdate = useWatch({
|
||||||
control: form.control,
|
control: form.control,
|
||||||
name: 'auto_update_enabled',
|
name: 'auto_update_enabled',
|
||||||
@@ -314,6 +346,16 @@ export function NodeEditorModal({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form id="node-editor-form" className="space-y-5" onSubmit={handleSubmit}>
|
<form id="node-editor-form" className="space-y-5" onSubmit={handleSubmit}>
|
||||||
|
<ResourceField
|
||||||
|
label="节点类型"
|
||||||
|
hint="边缘节点用于常规的网站代理和缓存;隧道中继节点用于内网穿透的流量中转服务器。"
|
||||||
|
>
|
||||||
|
<ResourceSelect {...form.register('type')} disabled={!!node}>
|
||||||
|
<option value="edge_node">边缘节点 (Edge Node)</option>
|
||||||
|
<option value="tunnel_relay">隧道中继节点 (Tunnel Relay)</option>
|
||||||
|
</ResourceSelect>
|
||||||
|
</ResourceField>
|
||||||
|
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="节点名"
|
label="节点名"
|
||||||
hint="示例:shanghai-edge-1"
|
hint="示例:shanghai-edge-1"
|
||||||
@@ -325,6 +367,35 @@ export function NodeEditorModal({
|
|||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
|
{watchedType === 'tunnel_relay' && (
|
||||||
|
<div className="space-y-5 rounded-lg border border-border p-4 bg-muted/50">
|
||||||
|
<h4 className="text-sm font-medium">中继配置 (Relay Config)</h4>
|
||||||
|
<ResourceField
|
||||||
|
label="中继绑定端口 (Bind Port)"
|
||||||
|
hint="中继服务端在此端口监听并接受 Flared 客户端连接。"
|
||||||
|
error={form.formState.errors.relay_bind_port?.message}
|
||||||
|
>
|
||||||
|
<ResourceInput placeholder="7000" {...form.register('relay_bind_port')} />
|
||||||
|
</ResourceField>
|
||||||
|
|
||||||
|
<ResourceField
|
||||||
|
label="客户端接入地址 (Client Access Addr)"
|
||||||
|
hint="可选,如果不填默认使用节点的 IP + 绑定端口。"
|
||||||
|
error={form.formState.errors.relay_client_access_addr?.message}
|
||||||
|
>
|
||||||
|
<ResourceInput placeholder="例如: relay.example.com:7000" {...form.register('relay_client_access_addr')} />
|
||||||
|
</ResourceField>
|
||||||
|
|
||||||
|
<ResourceField
|
||||||
|
label="代理地址 (Proxy URL)"
|
||||||
|
hint="可选,下发给客户端。当客户端连接中继需要经过 HTTP 代理时使用。"
|
||||||
|
error={form.formState.errors.relay_client_proxy_url?.message}
|
||||||
|
>
|
||||||
|
<ResourceInput placeholder="例如: http://10.0.0.1:3128" {...form.register('relay_client_proxy_url')} />
|
||||||
|
</ResourceField>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="节点 IP"
|
label="节点 IP"
|
||||||
hint={
|
hint={
|
||||||
@@ -363,17 +434,19 @@ export function NodeEditorModal({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToggleField
|
{watchedType === 'edge_node' && (
|
||||||
label="启用自动更新"
|
<ToggleField
|
||||||
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
|
label="启用自动更新"
|
||||||
checked={watchedAutoUpdate}
|
description="开启后 Agent 心跳返回会提示节点自动执行自更新。"
|
||||||
onChange={(checked) =>
|
checked={watchedAutoUpdate}
|
||||||
form.setValue('auto_update_enabled', checked, {
|
onChange={(checked) =>
|
||||||
shouldDirty: true,
|
form.setValue('auto_update_enabled', checked, {
|
||||||
shouldValidate: true,
|
shouldDirty: true,
|
||||||
})
|
shouldValidate: true,
|
||||||
}
|
})
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ToggleField
|
<ToggleField
|
||||||
label="手动指定地图地区"
|
label="手动指定地图地区"
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
getNodeStatusVariant,
|
getNodeStatusVariant,
|
||||||
getOpenrestyStatusLabel,
|
getOpenrestyStatusLabel,
|
||||||
getOpenrestyStatusVariant,
|
getOpenrestyStatusVariant,
|
||||||
|
getRelayStatusLabel,
|
||||||
|
getRelayStatusVariant,
|
||||||
isMeaningfulTime,
|
isMeaningfulTime,
|
||||||
isWSConnectedLastSeen,
|
isWSConnectedLastSeen,
|
||||||
} from '@/features/nodes/utils';
|
} from '@/features/nodes/utils';
|
||||||
@@ -304,9 +306,20 @@ export function NodesPage() {
|
|||||||
<tr key={node.id} className="align-top">
|
<tr key={node.id} className="align-top">
|
||||||
<td className="px-3 py-4">
|
<td className="px-3 py-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium text-[var(--foreground-primary)]">
|
<div className="flex items-center gap-2">
|
||||||
{node.name}
|
<p className="font-medium text-[var(--foreground-primary)]">
|
||||||
</p>
|
{node.name}
|
||||||
|
</p>
|
||||||
|
{node.type === 'tunnel_relay' ? (
|
||||||
|
<span className="rounded-full border border-blue-500/20 bg-blue-50 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||||
|
Relay
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-full border border-gray-500/20 bg-gray-50 px-2 py-0.5 text-xs text-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
|
||||||
|
Edge
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
IP:{node.ip || 'null'}
|
IP:{node.ip || 'null'}
|
||||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||||
@@ -328,14 +341,21 @@ export function NodesPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-4">
|
<td className="px-3 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<StatusBadge
|
{node.type === 'tunnel_relay' ? (
|
||||||
label={getOpenrestyStatusLabel(
|
<StatusBadge
|
||||||
node.openresty_status,
|
label={getRelayStatusLabel(node.relay_status)}
|
||||||
)}
|
variant={getRelayStatusVariant(node.relay_status)}
|
||||||
variant={getOpenrestyStatusVariant(
|
/>
|
||||||
node.openresty_status,
|
) : (
|
||||||
)}
|
<StatusBadge
|
||||||
/>
|
label={getOpenrestyStatusLabel(
|
||||||
|
node.openresty_status,
|
||||||
|
)}
|
||||||
|
variant={getOpenrestyStatusVariant(
|
||||||
|
node.openresty_status,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ import type { ReleaseChannel } from '@/features/update/types';
|
|||||||
export interface NodeItem {
|
export interface NodeItem {
|
||||||
id: number;
|
id: number;
|
||||||
node_id: string;
|
node_id: string;
|
||||||
|
type: 'edge_node' | 'tunnel_relay';
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
ip_manual_override: boolean;
|
ip_manual_override: boolean;
|
||||||
|
relay_bind_port: number;
|
||||||
|
relay_client_access_addr: string;
|
||||||
|
relay_client_proxy_url: string;
|
||||||
|
relay_auth_token: string;
|
||||||
|
relay_status: string;
|
||||||
geo_name: string;
|
geo_name: string;
|
||||||
geo_latitude?: number | null;
|
geo_latitude?: number | null;
|
||||||
geo_longitude?: number | null;
|
geo_longitude?: number | null;
|
||||||
@@ -40,9 +46,13 @@ export interface NodeBootstrapToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeMutationPayload {
|
export interface NodeMutationPayload {
|
||||||
|
type: 'edge_node' | 'tunnel_relay';
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
ip_manual_override: boolean;
|
ip_manual_override: boolean;
|
||||||
|
relay_bind_port?: number;
|
||||||
|
relay_client_access_addr?: string;
|
||||||
|
relay_client_proxy_url?: string;
|
||||||
auto_update_enabled: boolean;
|
auto_update_enabled: boolean;
|
||||||
geo_name: string;
|
geo_name: string;
|
||||||
geo_latitude?: number | null;
|
geo_latitude?: number | null;
|
||||||
|
|||||||
@@ -196,3 +196,50 @@ export function buildNodeDockerInstallCommand(
|
|||||||
` ${image}`,
|
` ${image}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRelayStatusVariant(status: string | null | undefined) {
|
||||||
|
if (status === 'healthy') {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
if (status === 'unhealthy') {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRelayStatusLabel(status: string | null | undefined) {
|
||||||
|
if (status === 'healthy') {
|
||||||
|
return '健康';
|
||||||
|
}
|
||||||
|
if (status === 'unhealthy') {
|
||||||
|
return '异常';
|
||||||
|
}
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
|
||||||
|
const relayInstallerScriptUrl =
|
||||||
|
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-relay.sh';
|
||||||
|
|
||||||
|
export function buildRelayInstallCommand(serverUrl: string, discoveryToken: string) {
|
||||||
|
return [
|
||||||
|
`curl -fsSL ${relayInstallerScriptUrl} | bash -s -- \\`,
|
||||||
|
` --server-url ${serverUrl} \\`,
|
||||||
|
` --discovery-token ${discoveryToken}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRelayDockerInstallCommand(
|
||||||
|
serverUrl: string,
|
||||||
|
discoveryToken: string,
|
||||||
|
) {
|
||||||
|
const image = 'ghcr.io/rain-kl/openflare-relay:latest';
|
||||||
|
|
||||||
|
return [
|
||||||
|
`docker pull ${image}`,
|
||||||
|
`docker rm -f openflare-relay 2>/dev/null || true`,
|
||||||
|
`docker run -d --name openflare-relay --net host --restart unless-stopped \\`,
|
||||||
|
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
|
||||||
|
` -e OPENFLARE_DISCOVERY_TOKEN=${discoveryToken} \\`,
|
||||||
|
` ${image}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|||||||
+170
-32
@@ -23,6 +23,7 @@ import {
|
|||||||
getWAFSiteRuleGroups,
|
getWAFSiteRuleGroups,
|
||||||
replaceWAFSiteRuleGroups,
|
replaceWAFSiteRuleGroups,
|
||||||
} from '@/features/waf/api/waf';
|
} from '@/features/waf/api/waf';
|
||||||
|
import { getTunnels } from '@/features/tunnels/api/tunnels';
|
||||||
import {
|
import {
|
||||||
buildDomainRowsFromRoute,
|
buildDomainRowsFromRoute,
|
||||||
DomainListInput,
|
DomainListInput,
|
||||||
@@ -156,19 +157,48 @@ const rateLimitSchema = z
|
|||||||
|
|
||||||
const reverseProxySchema = z
|
const reverseProxySchema = z
|
||||||
.object({
|
.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(),
|
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(),
|
custom_headers_text: z.string(),
|
||||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
const { error } = parseOriginUrls(value.origin_urls_text);
|
if (value.upstream_type === 'direct') {
|
||||||
if (error) {
|
if (!value.origin_urls_text.trim()) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['origin_urls_text'],
|
path: ['origin_urls_text'],
|
||||||
message: error,
|
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);
|
const originHostError = validateOriginHost(value.origin_host);
|
||||||
@@ -518,11 +548,20 @@ function ReverseProxySection({
|
|||||||
saving: boolean;
|
saving: boolean;
|
||||||
onSave: SaveHandler;
|
onSave: SaveHandler;
|
||||||
}) {
|
}) {
|
||||||
|
const tunnelsQuery = useQuery({
|
||||||
|
queryKey: ['tunnels'],
|
||||||
|
queryFn: getTunnels,
|
||||||
|
});
|
||||||
|
|
||||||
const form = useForm<ReverseProxyValues>({
|
const form = useForm<ReverseProxyValues>({
|
||||||
resolver: zodResolver(reverseProxySchema),
|
resolver: zodResolver(reverseProxySchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
upstream_type: route.upstream_type || 'direct',
|
||||||
origin_urls_text: route.upstream_list.join('\n'),
|
origin_urls_text: route.upstream_list.join('\n'),
|
||||||
origin_host: route.origin_host || '',
|
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),
|
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||||
remark: route.remark || '',
|
remark: route.remark || '',
|
||||||
},
|
},
|
||||||
@@ -530,8 +569,12 @@ function ReverseProxySection({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
form.reset({
|
||||||
|
upstream_type: route.upstream_type || 'direct',
|
||||||
origin_urls_text: route.upstream_list.join('\n'),
|
origin_urls_text: route.upstream_list.join('\n'),
|
||||||
origin_host: route.origin_host || '',
|
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),
|
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||||
remark: route.remark || '',
|
remark: route.remark || '',
|
||||||
});
|
});
|
||||||
@@ -539,8 +582,8 @@ function ReverseProxySection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigSectionShell
|
<ConfigSectionShell
|
||||||
title="反向代理"
|
title="回源代理"
|
||||||
description="第一行作为主回源;填写多行时会自动进入多上游负载均衡模式。"
|
description="配置请求回源上游的策略与地址。"
|
||||||
formId="proxy-route-proxy-form"
|
formId="proxy-route-proxy-form"
|
||||||
saving={saving}
|
saving={saving}
|
||||||
>
|
>
|
||||||
@@ -548,8 +591,28 @@ function ReverseProxySection({
|
|||||||
id="proxy-route-proxy-form"
|
id="proxy-route-proxy-form"
|
||||||
className="space-y-5"
|
className="space-y-5"
|
||||||
onSubmit={form.handleSubmit((values) => {
|
onSubmit={form.handleSubmit((values) => {
|
||||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
let originUrl = '';
|
||||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
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(
|
const { headers } = parseCustomHeadersText(
|
||||||
values.custom_headers_text,
|
values.custom_headers_text,
|
||||||
);
|
);
|
||||||
@@ -557,34 +620,109 @@ function ReverseProxySection({
|
|||||||
onSave(
|
onSave(
|
||||||
buildPayloadFromRoute(route, {
|
buildPayloadFromRoute(route, {
|
||||||
origin_id: null,
|
origin_id: null,
|
||||||
origin_url: urls[0],
|
origin_url: originUrl,
|
||||||
origin_scheme: primaryOrigin.scheme,
|
origin_scheme: originScheme,
|
||||||
origin_address: primaryOrigin.address,
|
origin_address: originAddress,
|
||||||
origin_port: primaryOrigin.port,
|
origin_port: originPort,
|
||||||
origin_uri: primaryOrigin.uri,
|
origin_uri: originUri,
|
||||||
origin_host: values.origin_host.trim(),
|
origin_host: values.origin_host.trim(),
|
||||||
upstreams: urls.slice(1),
|
upstreams: upstreams,
|
||||||
custom_headers: headers,
|
custom_headers: headers,
|
||||||
remark: values.remark.trim(),
|
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: '反向代理设置已保存。' },
|
{ message: '反向代理设置已保存。' },
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ResourceField
|
<div className="space-y-3">
|
||||||
label="上游地址"
|
<label className="text-sm font-medium text-[var(--foreground-primary)] block">回源方式</label>
|
||||||
hint="每行一个完整 URL。多上游模式下不要带 path 或 query。"
|
<div className="flex gap-4">
|
||||||
error={form.formState.errors.origin_urls_text?.message}
|
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
|
||||||
>
|
<input
|
||||||
<ResourceTextarea
|
type="radio"
|
||||||
aria-label="上游地址"
|
value="direct"
|
||||||
className="min-h-40"
|
{...form.register('upstream_type')}
|
||||||
placeholder={
|
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||||
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
|
/>
|
||||||
}
|
直连上游
|
||||||
{...form.register('origin_urls_text')}
|
</label>
|
||||||
/>
|
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
|
||||||
</ResourceField>
|
<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
|
<ResourceField
|
||||||
label="Origin Host Header"
|
label="Origin Host Header"
|
||||||
|
|||||||
+161
-38
@@ -20,6 +20,7 @@ import {
|
|||||||
parseOriginUrls,
|
parseOriginUrls,
|
||||||
validateDomains,
|
validateDomains,
|
||||||
} from '@/features/proxy-routes/helpers';
|
} from '@/features/proxy-routes/helpers';
|
||||||
|
import { getTunnels } from '@/features/tunnels/api/tunnels';
|
||||||
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
||||||
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
|
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +40,11 @@ const createWebsiteSchema = z
|
|||||||
.object({
|
.object({
|
||||||
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
|
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
|
||||||
domain_rows: z.array(domainRowSchema).min(1),
|
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(),
|
enabled: z.boolean(),
|
||||||
redirect_http: z.boolean(),
|
redirect_http: z.boolean(),
|
||||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||||
@@ -57,13 +62,38 @@ const createWebsiteSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = parseOriginUrls(value.origin_urls_text);
|
if (value.upstream_type === 'direct') {
|
||||||
if (error) {
|
if (!value.origin_urls_text.trim()) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['origin_urls_text'],
|
path: ['origin_urls_text'],
|
||||||
message: error,
|
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(
|
const selectedCertificateCount = new Set(
|
||||||
@@ -85,7 +115,11 @@ type CreateWebsiteFormValues = z.infer<typeof createWebsiteSchema>;
|
|||||||
const defaultValues: CreateWebsiteFormValues = {
|
const defaultValues: CreateWebsiteFormValues = {
|
||||||
site_name: '',
|
site_name: '',
|
||||||
domain_rows: [{ domain: '', certificateId: '' }],
|
domain_rows: [{ domain: '', certificateId: '' }],
|
||||||
|
upstream_type: 'direct',
|
||||||
origin_urls_text: '',
|
origin_urls_text: '',
|
||||||
|
tunnel_id: '',
|
||||||
|
tunnel_target_addr: '',
|
||||||
|
tunnel_target_protocol: 'http',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
redirect_http: false,
|
redirect_http: false,
|
||||||
remark: '',
|
remark: '',
|
||||||
@@ -138,6 +172,11 @@ export function ProxyRouteCreateDrawer({
|
|||||||
queryFn: getTlsCertificates,
|
queryFn: getTlsCertificates,
|
||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
|
const tunnelsQuery = useQuery({
|
||||||
|
queryKey: ['tunnels'],
|
||||||
|
queryFn: getTunnels,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
const combinedDomainSuggestions = useMemo(
|
const combinedDomainSuggestions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -159,29 +198,40 @@ export function ProxyRouteCreateDrawer({
|
|||||||
const selectedCertIDs = normalizeSelectedCertificateIDs(
|
const selectedCertIDs = normalizeSelectedCertificateIDs(
|
||||||
values.domain_rows,
|
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({
|
return createProxyRoute({
|
||||||
site_name: values.site_name.trim() || domains[0],
|
site_name: values.site_name.trim() || domains[0],
|
||||||
domain: domains[0],
|
domain: domains[0],
|
||||||
domains,
|
domains,
|
||||||
origin_id: null,
|
origin_id: null,
|
||||||
origin_url: buildOriginUrl(
|
origin_url: originUrl,
|
||||||
primaryOrigin.scheme,
|
origin_scheme: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).scheme : 'http',
|
||||||
primaryOrigin.address,
|
origin_address: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).address : values.tunnel_target_addr || '',
|
||||||
primaryOrigin.port,
|
origin_port: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).port : '80',
|
||||||
primaryOrigin.uri,
|
origin_uri: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).uri : '',
|
||||||
),
|
origin_host: originHost,
|
||||||
origin_scheme: primaryOrigin.scheme,
|
upstreams,
|
||||||
origin_address: primaryOrigin.address,
|
|
||||||
origin_port: primaryOrigin.port,
|
|
||||||
origin_uri: primaryOrigin.uri,
|
|
||||||
origin_host: '',
|
|
||||||
upstreams: urls.slice(1),
|
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
enable_https: selectedCertIDs.length > 0,
|
enable_https: selectedCertIDs.length > 0,
|
||||||
cert_id: selectedCertIDs[0] ?? null,
|
cert_id: selectedCertIDs.length > 0 ? selectedCertIDs[0] : null,
|
||||||
cert_ids: selectedCertIDs,
|
cert_ids: selectedCertIDs,
|
||||||
domain_cert_ids: domainCertIDs,
|
domain_cert_ids: domainCertIDs,
|
||||||
redirect_http:
|
redirect_http:
|
||||||
@@ -196,9 +246,11 @@ export function ProxyRouteCreateDrawer({
|
|||||||
pow_enabled: false,
|
pow_enabled: false,
|
||||||
pow_config: '{}',
|
pow_config: '{}',
|
||||||
basic_auth_enabled: false,
|
basic_auth_enabled: false,
|
||||||
basic_auth_username: '',
|
|
||||||
basic_auth_password: '',
|
|
||||||
remark: values.remark.trim(),
|
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) => {
|
onSuccess: (route) => {
|
||||||
@@ -285,19 +337,90 @@ export function ProxyRouteCreateDrawer({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResourceField
|
<div className="space-y-3">
|
||||||
label="上游地址"
|
<label className="text-sm font-medium text-[var(--foreground-primary)] block">回源方式</label>
|
||||||
hint="每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。"
|
<div className="flex gap-4">
|
||||||
error={form.formState.errors.origin_urls_text?.message}
|
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
|
||||||
>
|
<input
|
||||||
<ResourceTextarea
|
type="radio"
|
||||||
aria-label="上游地址"
|
value="direct"
|
||||||
placeholder={
|
{...form.register('upstream_type')}
|
||||||
'https://origin-a.internal:443\nhttps://origin-b.internal:443'
|
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||||
}
|
/>
|
||||||
{...form.register('origin_urls_text')}
|
直连上游
|
||||||
/>
|
</label>
|
||||||
</ResourceField>
|
<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
|
<ToggleField
|
||||||
label="创建后立即启用"
|
label="创建后立即启用"
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ export interface ProxyRouteItem {
|
|||||||
basic_auth_username: string;
|
basic_auth_username: string;
|
||||||
basic_auth_password: string;
|
basic_auth_password: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
|
upstream_type: 'direct' | 'tunnel';
|
||||||
|
tunnel_id?: number | null;
|
||||||
|
tunnel_target_addr?: string;
|
||||||
|
tunnel_target_protocol?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -88,6 +92,10 @@ export interface ProxyRouteMutationPayload {
|
|||||||
basic_auth_username?: string;
|
basic_auth_username?: string;
|
||||||
basic_auth_password?: string;
|
basic_auth_password?: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
|
upstream_type?: 'direct' | 'tunnel';
|
||||||
|
tunnel_id?: number | null;
|
||||||
|
tunnel_target_addr?: string;
|
||||||
|
tunnel_target_protocol?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TlsCertificateItem {
|
export interface TlsCertificateItem {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { apiRequest } from '@/lib/api/client';
|
||||||
|
|
||||||
|
import type { TunnelItem, TunnelMutationPayload } from '@/features/tunnels/types';
|
||||||
|
|
||||||
|
export function getTunnels() {
|
||||||
|
return apiRequest<TunnelItem[]>('/tunnels/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTunnel(payload: TunnelMutationPayload) {
|
||||||
|
return apiRequest<TunnelItem>('/tunnels/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTunnel(id: number, payload: TunnelMutationPayload) {
|
||||||
|
return apiRequest<TunnelItem>(`/tunnels/${id}/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTunnel(id: number) {
|
||||||
|
return apiRequest<void>(`/tunnels/${id}/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotateTunnelToken(id: number) {
|
||||||
|
return apiRequest<TunnelItem>(`/tunnels/${id}/rotate-token`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AppModal } from '@/components/ui/app-modal';
|
||||||
|
import type { TunnelItem } from '@/features/tunnels/types';
|
||||||
|
import {
|
||||||
|
CodeBlock,
|
||||||
|
PrimaryButton,
|
||||||
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getServerUrl } from '@/features/nodes/utils';
|
||||||
|
|
||||||
|
export function TunnelDeploymentModal({
|
||||||
|
isOpen,
|
||||||
|
tunnel,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
tunnel?: TunnelItem | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [serverUrl, setServerUrl] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && !serverUrl) {
|
||||||
|
setServerUrl(window.location.origin);
|
||||||
|
}
|
||||||
|
}, [serverUrl]);
|
||||||
|
|
||||||
|
if (!tunnel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedServerUrl = getServerUrl(serverUrl);
|
||||||
|
const image = 'ghcr.io/rain-kl/openflare-flared:latest';
|
||||||
|
|
||||||
|
const dockerRunCmd = [
|
||||||
|
`docker pull ${image}`,
|
||||||
|
`docker rm -f openflare-flared 2>/dev/null || true`,
|
||||||
|
`docker run -d --name openflare-flared --net host --restart unless-stopped \\`,
|
||||||
|
` -v /var/run/docker.sock:/var/run/docker.sock \\`,
|
||||||
|
` -v openflare_flared_data:/app/data \\`,
|
||||||
|
` -e OPENFLARE_SERVER_URL=${normalizedServerUrl} \\`,
|
||||||
|
` -e OPENFLARE_TUNNEL_TOKEN=${tunnel.tunnel_token} \\`,
|
||||||
|
` ${image}`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const dockerComposeYaml = `version: '3.8'
|
||||||
|
services:
|
||||||
|
openflare-flared:
|
||||||
|
image: ${image}
|
||||||
|
container_name: openflare-flared
|
||||||
|
network_mode: "host"
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- openflare_flared_data:/app/data
|
||||||
|
environment:
|
||||||
|
- OPENFLARE_SERVER_URL=${normalizedServerUrl}
|
||||||
|
- OPENFLARE_TUNNEL_TOKEN=${tunnel.tunnel_token}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
openflare_flared_data:
|
||||||
|
`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="隧道客户端部署"
|
||||||
|
description="请在需要穿透的内网服务器上部署 Flared 客户端。"
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<PrimaryButton type="button" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
方式一:Docker Run
|
||||||
|
</p>
|
||||||
|
<CodeBlock className="whitespace-pre-wrap">{dockerRunCmd}</CodeBlock>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
方式二:Docker Compose (docker-compose.yml)
|
||||||
|
</p>
|
||||||
|
<CodeBlock className="whitespace-pre-wrap">{dockerComposeYaml}</CodeBlock>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
隧道 Token (仅供高级用法)
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-all text-xs text-[var(--foreground-secondary)]">
|
||||||
|
{tunnel.tunnel_token}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppModal } from '@/components/ui/app-modal';
|
||||||
|
import type { TunnelItem, TunnelMutationPayload } from '@/features/tunnels/types';
|
||||||
|
import {
|
||||||
|
PrimaryButton,
|
||||||
|
ResourceField,
|
||||||
|
ResourceInput,
|
||||||
|
SecondaryButton,
|
||||||
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
|
||||||
|
const tunnelEditorSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, '请输入隧道名称')
|
||||||
|
.max(128, '隧道名称不能超过 128 个字符'),
|
||||||
|
remark: z.string().trim().max(255, '备注不能超过 255 个字符').optional().default(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TunnelEditorValues = z.infer<typeof tunnelEditorSchema>;
|
||||||
|
|
||||||
|
const defaultValues: TunnelEditorValues = {
|
||||||
|
name: '',
|
||||||
|
remark: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildFormValues(tunnel?: Partial<TunnelItem> | null): TunnelEditorValues {
|
||||||
|
if (!tunnel) {
|
||||||
|
return defaultValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: tunnel.name ?? '',
|
||||||
|
remark: tunnel.remark ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunnelEditorModal({
|
||||||
|
isOpen,
|
||||||
|
tunnel,
|
||||||
|
isSubmitting,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
submitLabel,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
tunnel?: Partial<TunnelItem> | null;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
submitLabel: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (payload: TunnelMutationPayload) => void;
|
||||||
|
}) {
|
||||||
|
const form = useForm<TunnelEditorValues>({
|
||||||
|
resolver: zodResolver(tunnelEditorSchema),
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.reset(buildFormValues(tunnel));
|
||||||
|
}, [form, isOpen, tunnel]);
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit((values) => {
|
||||||
|
onSubmit({
|
||||||
|
name: values.name.trim(),
|
||||||
|
remark: values.remark.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
footer={
|
||||||
|
<div className="flex flex-wrap justify-end gap-3">
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
type="submit"
|
||||||
|
form="tunnel-editor-form"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? '保存中...' : submitLabel}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="tunnel-editor-form" className="space-y-5" onSubmit={handleSubmit}>
|
||||||
|
<ResourceField
|
||||||
|
label="隧道名称"
|
||||||
|
hint="示例:home-nas-tunnel"
|
||||||
|
error={form.formState.errors.name?.message}
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
placeholder="home-nas-tunnel"
|
||||||
|
{...form.register('name')}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
|
||||||
|
<ResourceField
|
||||||
|
label="备注 (可选)"
|
||||||
|
hint="用于描述该隧道的用途。"
|
||||||
|
error={form.formState.errors.remark?.message}
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
placeholder="家庭内网穿透"
|
||||||
|
{...form.register('remark')}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
</form>
|
||||||
|
</AppModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
|
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||||
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTunnel,
|
||||||
|
deleteTunnel,
|
||||||
|
getTunnels,
|
||||||
|
updateTunnel,
|
||||||
|
rotateTunnelToken,
|
||||||
|
} from '@/features/tunnels/api/tunnels';
|
||||||
|
import { TunnelEditorModal } from '@/features/tunnels/components/tunnel-editor-modal';
|
||||||
|
import { TunnelDeploymentModal } from '@/features/tunnels/components/tunnel-deployment-modal';
|
||||||
|
import type { TunnelItem, TunnelMutationPayload } from '@/features/tunnels/types';
|
||||||
|
import { PrimaryButton } from '@/features/shared/components/resource-primitives';
|
||||||
|
import { formatRelativeTime } from '@/lib/utils/date';
|
||||||
|
import { isMeaningfulTime } from '@/features/nodes/utils';
|
||||||
|
|
||||||
|
const tunnelsQueryKey = ['tunnels'];
|
||||||
|
|
||||||
|
type FeedbackState = {
|
||||||
|
tone: 'info' | 'success' | 'danger';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunnelsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||||
|
|
||||||
|
const [editingTunnel, setEditingTunnel] = useState<TunnelItem | null>(null);
|
||||||
|
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||||
|
|
||||||
|
const [deploymentTunnel, setDeploymentTunnel] = useState<TunnelItem | null>(null);
|
||||||
|
const [isDeploymentOpen, setIsDeploymentOpen] = useState(false);
|
||||||
|
|
||||||
|
const tunnelsQuery = useQuery({
|
||||||
|
queryKey: tunnelsQueryKey,
|
||||||
|
queryFn: getTunnels,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (payload: TunnelMutationPayload) => {
|
||||||
|
return editingTunnel
|
||||||
|
? updateTunnel(editingTunnel.id, payload)
|
||||||
|
: createTunnel(payload);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
setFeedback({
|
||||||
|
tone: 'success',
|
||||||
|
message: editingTunnel ? '隧道已更新。' : '隧道已创建。',
|
||||||
|
});
|
||||||
|
setEditingTunnel(null);
|
||||||
|
setIsEditorOpen(false);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: tunnelsQueryKey });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: deleteTunnel,
|
||||||
|
onSuccess: async () => {
|
||||||
|
setFeedback({ tone: 'success', message: '隧道已删除。' });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: tunnelsQueryKey });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotateTokenMutation = useMutation({
|
||||||
|
mutationFn: rotateTunnelToken,
|
||||||
|
onSuccess: async () => {
|
||||||
|
setFeedback({ tone: 'success', message: '隧道 Token 已轮转,客户端需要重新部署连接。' });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: tunnelsQueryKey });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tunnels = useMemo(() => tunnelsQuery.data ?? [], [tunnelsQuery.data]);
|
||||||
|
|
||||||
|
const handleResetEditor = () => {
|
||||||
|
setFeedback(null);
|
||||||
|
setEditingTunnel(null);
|
||||||
|
setIsEditorOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setFeedback(null);
|
||||||
|
setEditingTunnel(null);
|
||||||
|
setIsEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (tunnel: TunnelItem) => {
|
||||||
|
setFeedback(null);
|
||||||
|
setEditingTunnel(tunnel);
|
||||||
|
setIsEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeployment = (tunnel: TunnelItem) => {
|
||||||
|
setFeedback(null);
|
||||||
|
setDeploymentTunnel(tunnel);
|
||||||
|
setIsDeploymentOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (tunnelId: number, tunnelName: string) => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`确认删除隧道“${tunnelName}”吗?删除后该隧道的连接将立即中断。`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFeedback(null);
|
||||||
|
deleteMutation.mutate(tunnelId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRotateToken = (tunnelId: number, tunnelName: string) => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`确认重置隧道“${tunnelName}”的 Token 吗?已连接的客户端将被断开,并且需要使用新的 Token 重新部署。`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFeedback(null);
|
||||||
|
rotateTokenMutation.mutate(tunnelId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="内网穿透隧道"
|
||||||
|
description="管理你的内网穿透隧道。"
|
||||||
|
action={
|
||||||
|
<PrimaryButton type="button" onClick={handleCreate}>
|
||||||
|
新建 Tunnel
|
||||||
|
</PrimaryButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{feedback ? (
|
||||||
|
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AppCard
|
||||||
|
title="隧道列表"
|
||||||
|
description="列表每 5 秒自动刷新一次。"
|
||||||
|
>
|
||||||
|
{tunnelsQuery.isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : tunnelsQuery.isError ? (
|
||||||
|
<ErrorState
|
||||||
|
title="隧道列表加载失败"
|
||||||
|
description={getErrorMessage(tunnelsQuery.error)}
|
||||||
|
/>
|
||||||
|
) : tunnels.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="当前还没有隧道"
|
||||||
|
description="点击右上角的“新建 Tunnel”按钮创建你的第一个隧道。"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[var(--foreground-secondary)]">
|
||||||
|
<th className="px-3 py-3 font-medium">名称 / 隧道ID</th>
|
||||||
|
<th className="px-3 py-3 font-medium">状态</th>
|
||||||
|
<th className="px-3 py-3 font-medium">版本 / 校验</th>
|
||||||
|
<th className="px-3 py-3 font-medium">已连接 Relay</th>
|
||||||
|
<th className="px-3 py-3 font-medium text-right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[var(--border-default)]">
|
||||||
|
{tunnels.map((tunnel) => (
|
||||||
|
<tr key={tunnel.id} className="align-top">
|
||||||
|
<td className="px-3 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium text-[var(--foreground-primary)]">
|
||||||
|
{tunnel.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
{tunnel.tunnel_id.slice(0, 8)}...
|
||||||
|
</p>
|
||||||
|
{tunnel.remark ? (
|
||||||
|
<p className="text-xs text-[var(--foreground-muted)] line-clamp-1 max-w-xs">
|
||||||
|
{tunnel.remark}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<StatusBadge
|
||||||
|
label={tunnel.status === 'online' ? '在线' : '离线'}
|
||||||
|
variant={tunnel.status === 'online' ? 'success' : 'danger'}
|
||||||
|
/>
|
||||||
|
{tunnel.status === 'online' && isMeaningfulTime(tunnel.last_seen_at) ? (
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
活跃:{formatRelativeTime(tunnel.last_seen_at)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-4">
|
||||||
|
<div className="space-y-1 text-[var(--foreground-secondary)]">
|
||||||
|
<p>
|
||||||
|
版本:{tunnel.current_version || '未知'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" title={tunnel.current_checksum}>
|
||||||
|
{tunnel.current_checksum
|
||||||
|
? `签名:${tunnel.current_checksum.slice(0, 12)}...`
|
||||||
|
: '签名:未知'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||||
|
{tunnel.connected_relays && tunnel.connected_relays.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{tunnel.connected_relays.map((relay, index) => (
|
||||||
|
<p key={index} className="text-xs">
|
||||||
|
{relay}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs">暂无</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-4 text-right">
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeployment(tunnel)}
|
||||||
|
className="text-sm font-medium text-blue-600 transition hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
部署命令
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEdit(tunnel)}
|
||||||
|
className="text-sm font-medium text-[var(--foreground-primary)] transition hover:text-blue-600 dark:hover:text-blue-500"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(tunnel.id, tunnel.name)}
|
||||||
|
className="text-sm font-medium text-red-600 transition hover:text-red-700 dark:text-red-500 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRotateToken(tunnel.id, tunnel.name)}
|
||||||
|
className="text-xs font-medium text-amber-600 transition hover:text-amber-700 dark:text-amber-500 dark:hover:text-amber-400"
|
||||||
|
>
|
||||||
|
重置 Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TunnelEditorModal
|
||||||
|
isOpen={isEditorOpen}
|
||||||
|
tunnel={editingTunnel}
|
||||||
|
isSubmitting={saveMutation.isPending}
|
||||||
|
title={editingTunnel ? '编辑隧道' : '新建隧道'}
|
||||||
|
description={
|
||||||
|
editingTunnel
|
||||||
|
? '修改此隧道的名称和备注。'
|
||||||
|
: '创建一个内网穿透隧道,稍后你将获得用于客户端连接的 Token 和部署命令。'
|
||||||
|
}
|
||||||
|
submitLabel={editingTunnel ? '保存修改' : '确认创建'}
|
||||||
|
onClose={handleResetEditor}
|
||||||
|
onSubmit={(payload) => {
|
||||||
|
setFeedback(null);
|
||||||
|
saveMutation.mutate(payload);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TunnelDeploymentModal
|
||||||
|
isOpen={isDeploymentOpen}
|
||||||
|
tunnel={deploymentTunnel}
|
||||||
|
onClose={() => setIsDeploymentOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export interface TunnelItem {
|
||||||
|
id: number;
|
||||||
|
tunnel_id: string;
|
||||||
|
name: string;
|
||||||
|
tunnel_token: string;
|
||||||
|
status: 'online' | 'offline';
|
||||||
|
client_version: string;
|
||||||
|
frp_version: string;
|
||||||
|
last_seen_at?: string | null;
|
||||||
|
last_error: string;
|
||||||
|
current_version: string;
|
||||||
|
current_checksum: string;
|
||||||
|
connected_relays: string[];
|
||||||
|
remark: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TunnelMutationPayload {
|
||||||
|
name: string;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user