Skip to content

Commit f0b7dfb

Browse files
committed
Add WARP import precheck
1 parent cd57dca commit f0b7dfb

2 files changed

Lines changed: 184 additions & 2 deletions

File tree

webui/src/components/pages/ToolsPage.vue

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,18 @@ import NetworkSnapshotPanel from "./NetworkSnapshotPanel.vue";
1717
import WarpRouteRulesPanel from "./WarpRouteRulesPanel.vue";
1818
import { summarizeBackupPayload } from "./backupPayloadSummary";
1919
import type { PendingToolAction } from "./toolActions";
20+
import { formatWarpImportSummaryReport, summarizeWarpImport, warpImportTone } from "./warpImportSummary";
2021
2122
const { state, runShell, runCli, refreshDns, refreshMcp, refreshTopology, refreshSysroute, refreshWarp, shellQuote } = useMagicNet();
2223
const { isRunning, withAction } = useActionLock();
2324
const toolsRefreshing = ref(false);
2425
const pendingToolAction = ref<PendingToolAction | null>(null);
2526
const backupSummaryCopied = ref(false);
27+
const warpSummaryCopied = ref(false);
2628
const backupPayloadSummary = computed(() => summarizeBackupPayload(state.backup.payload.trim()));
29+
const warpImportSummary = computed(() => summarizeWarpImport(state.warp.importText));
2730
watch(() => state.backup.payload, () => { backupSummaryCopied.value = false; });
31+
watch(() => state.warp.importText, () => { warpSummaryCopied.value = false; });
2832
2933
async function refreshTools(): Promise<void> {
3034
toolsRefreshing.value = true;
@@ -166,15 +170,24 @@ function importWarp(): void {
166170
state.output = "请先粘贴 WARP/WireGuard 配置。";
167171
return;
168172
}
173+
if (!warpImportSummary.value.looksImportable) {
174+
state.output = warpImportSummary.value.message;
175+
return;
176+
}
169177
requestToolAction({
170178
key: "warp-import",
171179
title: "导入并启用 WARP",
172-
detail: "会写入 WireGuard/WARP 配置,并让 MagicNet 应用新的 WARP 出站。",
180+
detail: `会写入 WireGuard/WARP 配置,并让 MagicNet 应用新的 WARP 出站。${warpImportSummary.value.status === "warning" ? ` ${warpImportSummary.value.message}` : ""}`,
173181
command: "warp import-file <config-file>",
174182
run: () => runImportWarp(payload),
175183
});
176184
}
177185
186+
async function copyWarpSummary(): Promise<void> {
187+
warpSummaryCopied.value = await copyText(formatWarpImportSummaryReport(warpImportSummary.value));
188+
state.output = warpSummaryCopied.value ? "WARP 导入摘要已复制。" : "剪贴板不可用,WARP 导入摘要未复制。";
189+
}
190+
178191
async function runSetWarpEnabled(enabled: boolean): Promise<void> {
179192
await withAction(enabled ? "warp-enable" : "warp-disable", async () => {
180193
const text = await runCli(enabled ? "warp enable" : "warp disable", enabled ? "启用 WARP" : "禁用 WARP");
@@ -254,8 +267,16 @@ async function writeBackupPayloadFile(payload: string): Promise<string> {
254267
<h3 class="inline-flex items-center gap-2 text-base font-semibold"><Network :size="17" /> WARP 出站</h3>
255268
<p class="text-sm leading-6 text-zinc-400">导入自己的 WARP/WireGuard 配置后,MagicNet 会生成 sing-box wireguard endpoint;可全局在 sing-box 选择器里选择 warp,也可给指定域名添加 warp 路由。</p>
256269
<Textarea v-model="state.warp.importText" class="min-h-32 font-mono text-xs" spellcheck="false" placeholder="[Interface]&#10;PrivateKey = ...&#10;Address = ...&#10;&#10;[Peer]&#10;PublicKey = ...&#10;Endpoint = ...:2408" />
270+
<div class="rounded-md border p-3 text-sm leading-6" :class="warpImportTone(warpImportSummary.status)">
271+
<p class="font-medium">{{ warpImportSummary.status === 'ok' ? '可导入' : warpImportSummary.status === 'warning' ? '可导入但需确认' : warpImportSummary.status === 'error' ? '不能导入' : '等待配置' }}</p>
272+
<p class="mt-1 text-xs opacity-80">{{ warpImportSummary.message }}</p>
273+
<p class="mt-2 text-xs opacity-80">
274+
Interface {{ warpImportSummary.hasInterface ? '存在' : '缺失' }} · Peer {{ warpImportSummary.hasPeer ? '存在' : '缺失' }} · Address {{ warpImportSummary.hasAddress ? '存在' : '缺失' }} · Endpoint {{ warpImportSummary.hasEndpoint ? '存在' : '缺失' }}
275+
</p>
276+
</div>
257277
<div class="grid gap-2 sm:grid-cols-2">
258-
<Button :loading="isRunning('warp-import')" @click="importWarp"><Upload :size="16" />导入并启用</Button>
278+
<Button :disabled="!warpImportSummary.looksImportable" :loading="isRunning('warp-import')" @click="importWarp"><Upload :size="16" />导入并启用</Button>
279+
<Button variant="outline" :disabled="!state.warp.importText.trim()" @click="copyWarpSummary"><Copy :size="16" />{{ warpSummaryCopied ? '已复制摘要' : '复制导入摘要' }}</Button>
259280
<Button variant="secondary" :disabled="!state.warp.configured" :loading="isRunning('warp-test')" @click="testWarp"><RadioTower :size="16" />测试 WARP</Button>
260281
<Button variant="outline" :disabled="!state.warp.configured || state.warp.enabled" :loading="isRunning('warp-enable')" @click="setWarpEnabled(true)"><Power :size="16" />启用</Button>
261282
<Button variant="outline" :disabled="!state.warp.enabled" :loading="isRunning('warp-disable')" @click="setWarpEnabled(false)"><PowerOff :size="16" />禁用</Button>
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
export type WarpImportSummary = {
2+
status: "idle" | "ok" | "warning" | "error";
3+
message: string;
4+
lines: number;
5+
hasInterface: boolean;
6+
hasPeer: boolean;
7+
hasPrivateKey: boolean;
8+
hasPublicKey: boolean;
9+
hasAddress: boolean;
10+
hasEndpoint: boolean;
11+
allowedIps: number;
12+
dnsServers: number;
13+
mtu: string;
14+
keepalive: string;
15+
endpointHost: string;
16+
endpointPort: string;
17+
looksImportable: boolean;
18+
};
19+
20+
type SectionName = "interface" | "peer" | "";
21+
22+
export function summarizeWarpImport(text: string): WarpImportSummary {
23+
const trimmed = text.trim();
24+
if (!trimmed) return summary("idle", "等待粘贴 WireGuard/WARP 配置。", 0, {}, {});
25+
const parsed = parseWireGuard(trimmed);
26+
const endpoint = splitEndpoint(parsed.peer.endpoint || "");
27+
const missing = [
28+
parsed.hasInterface ? "" : "[Interface]",
29+
parsed.hasPeer ? "" : "[Peer]",
30+
parsed.interface.privatekey ? "" : "PrivateKey",
31+
parsed.interface.address ? "" : "Address",
32+
parsed.peer.publickey ? "" : "PublicKey",
33+
parsed.peer.endpoint ? "" : "Endpoint"
34+
].filter(Boolean);
35+
if (missing.length) {
36+
return summary("error", `缺少 ${missing.join(", ")},CLI 会拒绝导入。`, parsed.lines, parsed.interface, parsed.peer, parsed.hasInterface, parsed.hasPeer);
37+
}
38+
if (!endpoint.host || !endpoint.port) {
39+
return summary("error", "Endpoint 需要包含 host:port。", parsed.lines, parsed.interface, parsed.peer, parsed.hasInterface, parsed.hasPeer);
40+
}
41+
if (!validPort(endpoint.port)) {
42+
return summary("error", "Endpoint 端口必须是 0-65535 的数字。", parsed.lines, parsed.interface, parsed.peer, parsed.hasInterface, parsed.hasPeer);
43+
}
44+
if (!parsed.peer.allowedips) {
45+
return summary("warning", "AllowedIPs 未填写,CLI 会使用 0.0.0.0/0 和 ::/0 默认值。", parsed.lines, parsed.interface, parsed.peer, parsed.hasInterface, parsed.hasPeer);
46+
}
47+
return summary("ok", "配置字段齐全,可交给 CLI 导入。", parsed.lines, parsed.interface, parsed.peer, parsed.hasInterface, parsed.hasPeer);
48+
}
49+
50+
export function formatWarpImportSummaryReport(summary: WarpImportSummary): string {
51+
return [
52+
"MagicNet WARP import summary",
53+
"privacy_note=private/public keys and raw endpoint are omitted",
54+
`status=${summary.status}`,
55+
`message=${summary.message}`,
56+
`lines=${summary.lines}`,
57+
`has_interface=${summary.hasInterface ? 1 : 0}`,
58+
`has_peer=${summary.hasPeer ? 1 : 0}`,
59+
`has_private_key=${summary.hasPrivateKey ? 1 : 0}`,
60+
`has_public_key=${summary.hasPublicKey ? 1 : 0}`,
61+
`has_address=${summary.hasAddress ? 1 : 0}`,
62+
`has_endpoint=${summary.hasEndpoint ? 1 : 0}`,
63+
`endpoint_host_present=${summary.endpointHost ? 1 : 0}`,
64+
`endpoint_port=${summary.endpointPort || "none"}`,
65+
`allowed_ips=${summary.allowedIps}`,
66+
`dns_servers=${summary.dnsServers}`,
67+
`mtu=${summary.mtu || "default"}`,
68+
`keepalive=${summary.keepalive || "default"}`
69+
].join("\n");
70+
}
71+
72+
export function warpImportTone(status: WarpImportSummary["status"]): string {
73+
if (status === "error") return "border-red-400/30 bg-red-400/10 text-red-100";
74+
if (status === "warning") return "border-amber-400/30 bg-amber-400/10 text-amber-100";
75+
if (status === "ok") return "border-emerald-400/25 bg-emerald-400/10 text-emerald-100";
76+
return "border-zinc-800 bg-zinc-950 text-zinc-400";
77+
}
78+
79+
function parseWireGuard(text: string): {
80+
lines: number;
81+
hasInterface: boolean;
82+
hasPeer: boolean;
83+
interface: Record<string, string>;
84+
peer: Record<string, string>;
85+
} {
86+
let section: SectionName = "";
87+
const iface: Record<string, string> = {};
88+
const peer: Record<string, string> = {};
89+
let hasInterface = false;
90+
let hasPeer = false;
91+
const lines = text.split(/\r?\n/);
92+
lines.forEach((raw) => {
93+
const line = raw.split("#", 1)[0].split(";", 1)[0].trim();
94+
if (!line) return;
95+
if (line.startsWith("[") && line.endsWith("]")) {
96+
const sectionName = line.slice(1, -1).trim();
97+
section = sectionName === "Interface" ? "interface" : sectionName === "Peer" ? "peer" : "";
98+
hasInterface ||= section === "interface";
99+
hasPeer ||= section === "peer";
100+
return;
101+
}
102+
const [key, ...rest] = line.split("=");
103+
const value = rest.join("=").trim();
104+
const normalizedKey = key?.trim().toLowerCase();
105+
if (!normalizedKey) return;
106+
if (section === "interface") iface[normalizedKey] = value;
107+
if (section === "peer") peer[normalizedKey] = value;
108+
});
109+
return { lines: lines.filter((line) => line.trim()).length, hasInterface, hasPeer, interface: iface, peer };
110+
}
111+
112+
function summary(
113+
status: WarpImportSummary["status"],
114+
message: string,
115+
lines: number,
116+
iface: Record<string, string>,
117+
peer: Record<string, string>,
118+
hasInterface = false,
119+
hasPeer = false
120+
): WarpImportSummary {
121+
const endpoint = splitEndpoint(peer.endpoint || "");
122+
return {
123+
status,
124+
message,
125+
lines,
126+
hasInterface,
127+
hasPeer,
128+
hasPrivateKey: Boolean(iface.privatekey),
129+
hasPublicKey: Boolean(peer.publickey),
130+
hasAddress: Boolean(iface.address),
131+
hasEndpoint: Boolean(peer.endpoint),
132+
allowedIps: splitCsv(peer.allowedips).length,
133+
dnsServers: splitCsv(iface.dns).length,
134+
mtu: iface.mtu || "",
135+
keepalive: peer.persistentkeepalive || "",
136+
endpointHost: endpoint.host,
137+
endpointPort: endpoint.port,
138+
looksImportable: status === "ok" || status === "warning"
139+
};
140+
}
141+
142+
function splitEndpoint(endpoint: string): { host: string; port: string } {
143+
if (endpoint.startsWith("[")) {
144+
const end = endpoint.indexOf("]:");
145+
if (end < 0) return { host: "", port: "" };
146+
return { host: endpoint.slice(1, end), port: endpoint.slice(end + 2) };
147+
}
148+
const index = endpoint.lastIndexOf(":");
149+
if (index <= 0 || index >= endpoint.length - 1) return { host: "", port: "" };
150+
return { host: endpoint.slice(0, index), port: endpoint.slice(index + 1) };
151+
}
152+
153+
function validPort(port: string): boolean {
154+
if (!/^\d+$/.test(port)) return false;
155+
const parsed = Number(port);
156+
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 65535;
157+
}
158+
159+
function splitCsv(value: string): string[] {
160+
return value.split(",").map((item) => item.trim()).filter(Boolean);
161+
}

0 commit comments

Comments
 (0)