diff --git a/openflare_server/web/app/(dashboard)/tunnel/page.tsx b/openflare_server/web/app/(dashboard)/tunnel/page.tsx new file mode 100644 index 00000000..963b24b3 --- /dev/null +++ b/openflare_server/web/app/(dashboard)/tunnel/page.tsx @@ -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 ; +} diff --git a/openflare_server/web/features/nodes/components/node-detail-page.tsx b/openflare_server/web/features/nodes/components/node-detail-page.tsx index 3d9008c1..4738042e 100644 --- a/openflare_server/web/features/nodes/components/node-detail-page.tsx +++ b/openflare_server/web/features/nodes/components/node-detail-page.tsx @@ -53,6 +53,8 @@ import { import { buildNodeInstallCommand, buildNodeDockerInstallCommand, + buildRelayInstallCommand, + buildRelayDockerInstallCommand, getApplyLabel, getApplyVariant, getNodeStatusLabel, @@ -557,11 +559,15 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { const normalizedServerUrl = getServerUrl(serverUrl); const nodeInstallCommand = 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 = 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 selectedAgentRelease = diff --git a/openflare_server/web/features/nodes/components/node-editor-modal.tsx b/openflare_server/web/features/nodes/components/node-editor-modal.tsx index d2a6a85a..d87b5ac6 100644 --- a/openflare_server/web/features/nodes/components/node-editor-modal.tsx +++ b/openflare_server/web/features/nodes/components/node-editor-modal.tsx @@ -37,6 +37,7 @@ type RegionOption = { const nodeEditorSchema = z .object({ + type: z.enum(['edge_node', 'tunnel_relay']), name: z .string() .trim() @@ -45,6 +46,9 @@ const nodeEditorSchema = z ip: z.string().trim().max(64, '节点 IP 不能超过 64 个字符'), ip_manual_override: 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_region: z.string(), 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) { return; } @@ -107,10 +122,14 @@ const nodeEditorSchema = z type NodeEditorValues = z.infer; const defaultValues: NodeEditorValues = { + type: 'edge_node', name: '', ip: '', ip_manual_override: false, auto_update_enabled: false, + relay_bind_port: '7000', + relay_client_access_addr: '', + relay_client_proxy_url: '', geo_manual_override: false, geo_region: '', geo_name: '', @@ -192,10 +211,14 @@ function buildFormValues(node?: Partial | null): NodeEditorValues { } return { + type: node.type ?? 'edge_node', name: node.name ?? '', ip: node.ip ?? '', ip_manual_override: node.ip_manual_override ?? 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_region: node.geo_manual_override ? (node.geo_name ?? '') : '', geo_name: node.geo_name ?? '', @@ -211,12 +234,20 @@ function buildFormValues(node?: Partial | null): NodeEditorValues { } 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) { return { - name: values.name.trim(), - ip: values.ip.trim(), - ip_manual_override: values.ip_manual_override, - auto_update_enabled: values.auto_update_enabled, + ...basePayload, geo_manual_override: false, geo_name: '', geo_latitude: null, @@ -225,10 +256,7 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload { } return { - name: values.name.trim(), - ip: values.ip.trim(), - ip_manual_override: values.ip_manual_override, - auto_update_enabled: values.auto_update_enabled, + ...basePayload, geo_manual_override: true, geo_name: values.geo_name.trim(), geo_latitude: @@ -262,6 +290,10 @@ export function NodeEditorModal({ defaultValues, }); + const watchedType = useWatch({ + control: form.control, + name: 'type', + }); const watchedAutoUpdate = useWatch({ control: form.control, name: 'auto_update_enabled', @@ -314,6 +346,16 @@ export function NodeEditorModal({ } >
+ + + + + + + + {watchedType === 'tunnel_relay' && ( +
+

中继配置 (Relay Config)

+ + + + + + + + + + + +
+ )} + - - form.setValue('auto_update_enabled', checked, { - shouldDirty: true, - shouldValidate: true, - }) - } - /> + {watchedType === 'edge_node' && ( + + form.setValue('auto_update_enabled', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> + )}
-

- {node.name} -

+
+

+ {node.name} +

+ {node.type === 'tunnel_relay' ? ( + + Relay + + ) : ( + + Edge + + )} +

IP:{node.ip || 'null'} {node.ip_manual_override ? '(已锁定)' : ''} @@ -328,14 +341,21 @@ export function NodesPage() {

- + {node.type === 'tunnel_relay' ? ( + + ) : ( + + )}
diff --git a/openflare_server/web/features/nodes/types.ts b/openflare_server/web/features/nodes/types.ts index 7396f3d4..96c750bd 100644 --- a/openflare_server/web/features/nodes/types.ts +++ b/openflare_server/web/features/nodes/types.ts @@ -3,9 +3,15 @@ import type { ReleaseChannel } from '@/features/update/types'; export interface NodeItem { id: number; node_id: string; + type: 'edge_node' | 'tunnel_relay'; name: string; ip: string; 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_latitude?: number | null; geo_longitude?: number | null; @@ -40,9 +46,13 @@ export interface NodeBootstrapToken { } export interface NodeMutationPayload { + type: 'edge_node' | 'tunnel_relay'; name: string; ip: string; ip_manual_override: boolean; + relay_bind_port?: number; + relay_client_access_addr?: string; + relay_client_proxy_url?: string; auto_update_enabled: boolean; geo_name: string; geo_latitude?: number | null; diff --git a/openflare_server/web/features/nodes/utils.ts b/openflare_server/web/features/nodes/utils.ts index 6533eb90..b0672969 100644 --- a/openflare_server/web/features/nodes/utils.ts +++ b/openflare_server/web/features/nodes/utils.ts @@ -196,3 +196,50 @@ export function buildNodeDockerInstallCommand( ` ${image}`, ].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'); +} diff --git a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx index 7c4b3731..72522af3 100644 --- a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx +++ b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx @@ -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({ 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 ( @@ -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: '反向代理设置已保存。' }, ); })} > - - - +
+ +
+ + +
+
+ + {form.watch('upstream_type') === 'direct' ? ( + + + + ) : ( +
+ + + + + + + + + + + +
+ )} ; 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({ } /> - - - +
+ +
+ + +
+
+ + {form.watch('upstream_type') === 'direct' ? ( + + + + ) : ( +
+ + + + + + + + + + + +
+ )} ('/tunnels/'); +} + +export function createTunnel(payload: TunnelMutationPayload) { + return apiRequest('/tunnels/', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function updateTunnel(id: number, payload: TunnelMutationPayload) { + return apiRequest(`/tunnels/${id}/update`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function deleteTunnel(id: number) { + return apiRequest(`/tunnels/${id}/delete`, { + method: 'POST', + }); +} + +export function rotateTunnelToken(id: number) { + return apiRequest(`/tunnels/${id}/rotate-token`, { + method: 'POST', + }); +} diff --git a/openflare_server/web/features/tunnels/components/tunnel-deployment-modal.tsx b/openflare_server/web/features/tunnels/components/tunnel-deployment-modal.tsx new file mode 100644 index 00000000..72c0fa8a --- /dev/null +++ b/openflare_server/web/features/tunnels/components/tunnel-deployment-modal.tsx @@ -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 ( + + + 关闭 + +
+ } + > +
+
+

+ 方式一:Docker Run +

+ {dockerRunCmd} +
+
+

+ 方式二:Docker Compose (docker-compose.yml) +

+ {dockerComposeYaml} +
+
+

+ 隧道 Token (仅供高级用法) +

+

+ {tunnel.tunnel_token} +

+
+
+ + ); +} diff --git a/openflare_server/web/features/tunnels/components/tunnel-editor-modal.tsx b/openflare_server/web/features/tunnels/components/tunnel-editor-modal.tsx new file mode 100644 index 00000000..802bf3a5 --- /dev/null +++ b/openflare_server/web/features/tunnels/components/tunnel-editor-modal.tsx @@ -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; + +const defaultValues: TunnelEditorValues = { + name: '', + remark: '', +}; + +function buildFormValues(tunnel?: Partial | 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 | null; + isSubmitting: boolean; + title: string; + description: string; + submitLabel: string; + onClose: () => void; + onSubmit: (payload: TunnelMutationPayload) => void; +}) { + const form = useForm({ + 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 ( + + + 取消 + + + {isSubmitting ? '保存中...' : submitLabel} + + + } + > + + + + + + + + + + + ); +} diff --git a/openflare_server/web/features/tunnels/components/tunnels-page.tsx b/openflare_server/web/features/tunnels/components/tunnels-page.tsx new file mode 100644 index 00000000..e7e453b0 --- /dev/null +++ b/openflare_server/web/features/tunnels/components/tunnels-page.tsx @@ -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(null); + + const [editingTunnel, setEditingTunnel] = useState(null); + const [isEditorOpen, setIsEditorOpen] = useState(false); + + const [deploymentTunnel, setDeploymentTunnel] = useState(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 ( + <> +
+ + 新建 Tunnel + + } + /> + + {feedback ? ( + + ) : null} + + + {tunnelsQuery.isLoading ? ( + + ) : tunnelsQuery.isError ? ( + + ) : tunnels.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {tunnels.map((tunnel) => ( + + + + + + + + ))} + +
名称 / 隧道ID状态版本 / 校验已连接 Relay操作
+
+

+ {tunnel.name} +

+

+ {tunnel.tunnel_id.slice(0, 8)}... +

+ {tunnel.remark ? ( +

+ {tunnel.remark} +

+ ) : null} +
+
+
+ + {tunnel.status === 'online' && isMeaningfulTime(tunnel.last_seen_at) ? ( +

+ 活跃:{formatRelativeTime(tunnel.last_seen_at)} +

+ ) : null} +
+
+
+

+ 版本:{tunnel.current_version || '未知'} +

+

+ {tunnel.current_checksum + ? `签名:${tunnel.current_checksum.slice(0, 12)}...` + : '签名:未知'} +

+
+
+ {tunnel.connected_relays && tunnel.connected_relays.length > 0 ? ( +
+ {tunnel.connected_relays.map((relay, index) => ( +

+ {relay} +

+ ))} +
+ ) : ( +

暂无

+ )} +
+
+
+ + + +
+ +
+
+
+ )} +
+
+ + { + setFeedback(null); + saveMutation.mutate(payload); + }} + /> + + setIsDeploymentOpen(false)} + /> + + ); +} diff --git a/openflare_server/web/features/tunnels/types.ts b/openflare_server/web/features/tunnels/types.ts new file mode 100644 index 00000000..6c265065 --- /dev/null +++ b/openflare_server/web/features/tunnels/types.ts @@ -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; +}