feat: 支持托管中转主机和节点接入线路
This commit is contained in:
@@ -7,6 +7,7 @@ const navItems = [
|
||||
{ href: "/admin/dashboard", label: "仪表盘" },
|
||||
{ href: "/admin/users", label: "用户管理" },
|
||||
{ href: "/admin/nodes", label: "节点管理" },
|
||||
{ href: "/admin/relays", label: "中转线路" },
|
||||
{ href: "/admin/certificates", label: "证书与安装" },
|
||||
{ href: "/admin/subscriptions", label: "订阅配置" },
|
||||
{ href: "/admin/audits", label: "审计日志" },
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireAdminAccess } from "@/lib/session";
|
||||
import {
|
||||
deleteRelay,
|
||||
RelayError,
|
||||
saveRelayHost,
|
||||
saveRelayRoute,
|
||||
} from "@/lib/relays";
|
||||
const value = (form: FormData, key: string) => String(form.get(key) || "");
|
||||
async function run(work: () => Promise<unknown>) {
|
||||
await requireAdminAccess();
|
||||
try {
|
||||
await work();
|
||||
revalidatePath("/admin/relays");
|
||||
revalidatePath("/admin/subscriptions");
|
||||
} catch (e) {
|
||||
return {
|
||||
error: e instanceof RelayError ? e.message : "保存失败,请检查输入。",
|
||||
};
|
||||
}
|
||||
}
|
||||
export async function saveHostAction(form: FormData) {
|
||||
return run(() =>
|
||||
saveRelayHost({
|
||||
id: value(form, "id") || undefined,
|
||||
name: value(form, "name"),
|
||||
server_host: value(form, "server_host"),
|
||||
enabled: form.get("enabled") === "on",
|
||||
}),
|
||||
);
|
||||
}
|
||||
export async function saveRouteAction(form: FormData) {
|
||||
return run(() =>
|
||||
saveRelayRoute({
|
||||
id: value(form, "id") || undefined,
|
||||
name: value(form, "name"),
|
||||
host_id: value(form, "host_id"),
|
||||
node_id: value(form, "node_id"),
|
||||
public_port: value(form, "public_port"),
|
||||
listen_port: value(form, "listen_port"),
|
||||
enabled: form.get("enabled") === "on",
|
||||
}),
|
||||
);
|
||||
}
|
||||
export async function deleteHostAction(form: FormData) {
|
||||
return run(() => deleteRelay("host", value(form, "id")));
|
||||
}
|
||||
export async function deleteRouteAction(form: FormData) {
|
||||
return run(() => deleteRelay("route", value(form, "id")));
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import Link from "next/link";
|
||||
import { ActionForm } from "@/components/action-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SubmitButton } from "@/components/submit-button";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { relayDesired, relayInventory, type RelayRoute } from "@/lib/relays";
|
||||
import { ensureDb } from "@/lib/db";
|
||||
import { listNodes } from "@/lib/store";
|
||||
import { formatLocalDateTime } from "@/lib/datetime";
|
||||
import {
|
||||
saveHostAction,
|
||||
saveRouteAction,
|
||||
deleteHostAction,
|
||||
deleteRouteAction,
|
||||
} from "./actions";
|
||||
export default async function RelaysPage() {
|
||||
const [{ hosts, routes }, nodes, db] = await Promise.all([
|
||||
relayInventory(),
|
||||
listNodes(),
|
||||
ensureDb(),
|
||||
]);
|
||||
const targets = nodes.filter((n) => ["hy2", "anytls"].includes(n.node_type));
|
||||
const now = new Date().getTime();
|
||||
const states = new Map(
|
||||
hosts.map((h) => [
|
||||
h.id,
|
||||
!h.enabled
|
||||
? "已停用"
|
||||
: !h.last_seen_at
|
||||
? "等待安装"
|
||||
: now - Date.parse(h.last_seen_at) > 90000
|
||||
? "离线"
|
||||
: h.error
|
||||
? h.error === "port_conflict"
|
||||
? "端口冲突"
|
||||
: "应用失败"
|
||||
: h.applied_hash !== relayDesired(db, h).hash
|
||||
? "等待应用"
|
||||
: relayDesired(db, h).config.endpoints.length === 0
|
||||
? "待添加线路"
|
||||
: h.service_active
|
||||
? "配置已应用"
|
||||
: "服务未运行",
|
||||
]),
|
||||
);
|
||||
const routeForm = (r?: RelayRoute) => (
|
||||
<ActionForm
|
||||
action={saveRouteAction}
|
||||
className="dense-form-grid provision-form"
|
||||
>
|
||||
{r && <input name="id" type="hidden" value={r.id} />}
|
||||
<label className="field">
|
||||
<span>线路名称</span>
|
||||
<Input
|
||||
name="name"
|
||||
required
|
||||
defaultValue={r?.name}
|
||||
placeholder="JP2 - AnyTLS · 经 HK2"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>中转主机</span>
|
||||
<select
|
||||
aria-label="中转主机"
|
||||
name="host_id"
|
||||
required
|
||||
defaultValue={r?.host_id || ""}
|
||||
>
|
||||
<option value="" disabled>
|
||||
选择入口主机
|
||||
</option>
|
||||
{hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>目标节点</span>
|
||||
<select
|
||||
aria-label="目标节点"
|
||||
name="node_id"
|
||||
required
|
||||
defaultValue={r?.node_id || ""}
|
||||
>
|
||||
<option value="" disabled>
|
||||
选择托管节点
|
||||
</option>
|
||||
{targets.map((n) => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{n.name} · {n.node_type === "hy2" ? "UDP" : "TCP"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>公网端口</span>
|
||||
<Input
|
||||
name="public_port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
required
|
||||
defaultValue={r?.public_port}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>本机监听端口</span>
|
||||
<Input
|
||||
name="listen_port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
required
|
||||
defaultValue={r?.listen_port}
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
defaultChecked={r ? Boolean(r.enabled) : true}
|
||||
/>
|
||||
<span>启用线路</span>
|
||||
</label>
|
||||
<SubmitButton>{r ? "保存线路" : "创建线路"}</SubmitButton>
|
||||
</ActionForm>
|
||||
);
|
||||
return (
|
||||
<main className="page-stack management-page">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<h2>中转线路</h2>
|
||||
<p className="muted">
|
||||
入口主机透传 TCP / UDP,目标节点继续认证与计量,保留直连。
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/subscriptions"
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
订阅排序与分组
|
||||
</Link>
|
||||
</section>
|
||||
<section className="panel-card compact-create-panel">
|
||||
<details open={!hosts.length}>
|
||||
<summary className="compact-create-summary">
|
||||
<h3>新增中转主机</h3>
|
||||
</summary>
|
||||
<ActionForm
|
||||
action={saveHostAction}
|
||||
className="dense-form-grid provision-form"
|
||||
>
|
||||
<label className="field">
|
||||
<span>主机名称</span>
|
||||
<Input name="name" required placeholder="HK2" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>公网域名 / IP</span>
|
||||
<Input
|
||||
name="server_host"
|
||||
required
|
||||
placeholder="hk2.p.xn--876a.net"
|
||||
/>
|
||||
</label>
|
||||
<input type="hidden" name="enabled" value="on" />
|
||||
<SubmitButton>创建主机</SubmitButton>
|
||||
</ActionForm>
|
||||
</details>
|
||||
</section>
|
||||
<section className="panel-card dense-panel">
|
||||
<div className="section-title">
|
||||
<h3>中转主机</h3>
|
||||
<span>{hosts.length} 台</span>
|
||||
</div>
|
||||
<div className="provision-list">
|
||||
{hosts.map((h) => (
|
||||
<details key={h.id} className="group-disclosure">
|
||||
<summary className="group-summary">
|
||||
<strong>{h.name}</strong>
|
||||
<span className="muted break-all">{h.server_host}</span>
|
||||
<span>{states.get(h.id)}</span>
|
||||
</summary>
|
||||
<p className="muted">
|
||||
最近上报:
|
||||
{h.last_seen_at
|
||||
? formatLocalDateTime(h.last_seen_at)
|
||||
: "尚未上报"}
|
||||
。状态代表配置和进程,不代表大陆线路质量。
|
||||
</p>
|
||||
<ActionForm
|
||||
action={saveHostAction}
|
||||
className="dense-form-grid provision-form"
|
||||
>
|
||||
<input type="hidden" name="id" value={h.id} />
|
||||
<label className="field">
|
||||
<span>主机名称</span>
|
||||
<Input name="name" defaultValue={h.name} required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>公网域名 / IP</span>
|
||||
<Input
|
||||
name="server_host"
|
||||
defaultValue={h.server_host}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
defaultChecked={Boolean(h.enabled)}
|
||||
/>
|
||||
<span>启用主机</span>
|
||||
</label>
|
||||
<SubmitButton>保存主机</SubmitButton>
|
||||
</ActionForm>
|
||||
<div className="entity-action-strip">
|
||||
<a
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
href={`/api/admin/relays/${h.id}/install`}
|
||||
>
|
||||
下载安装 SH
|
||||
</a>
|
||||
<ActionForm action={deleteHostAction}>
|
||||
<input type="hidden" name="id" value={h.id} />
|
||||
<SubmitButton
|
||||
variant="destructive"
|
||||
confirm="删除该主机及其全部中转线路;管理程序收到撤销后停止中转。"
|
||||
>
|
||||
删除主机
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
<p className="muted">
|
||||
以 root 在入口主机执行脚本,使用独立 Realm
|
||||
服务。脚本含管理凭据,请妥善保管。仅开放线路所需端口;不需要额外的公网管理端口或证书。
|
||||
</p>
|
||||
</section>
|
||||
<section className="panel-card compact-create-panel">
|
||||
<details open={hosts.length > 0 && !routes.length}>
|
||||
<summary className="compact-create-summary">
|
||||
<h3>新增中转线路</h3>
|
||||
</summary>
|
||||
{routeForm()}
|
||||
</details>
|
||||
</section>
|
||||
<section className="panel-card dense-panel">
|
||||
<div className="section-title">
|
||||
<h3>线路列表</h3>
|
||||
<span>{routes.length} 条</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
端口相同表示无 NAT 端口转换。配置约 15
|
||||
秒同步,应用变更会中断该主机已有中转连接;服务失联超过 90
|
||||
秒停止转发。仅配置已应用的线路进入订阅,SNI 和凭据继承目标节点。UDP
|
||||
业务仍可通过 AnyTLS 的 TCP 隧道转发。
|
||||
</p>
|
||||
{routes.map((r) => {
|
||||
const h = hosts.find((h) => h.id === r.host_id),
|
||||
n = nodes.find((n) => n.id === r.node_id);
|
||||
return (
|
||||
<details key={r.id} className="group-disclosure">
|
||||
<summary className="group-summary">
|
||||
<strong>{r.name}</strong>
|
||||
<span>
|
||||
{n?.node_type === "hy2" ? "UDP" : "TCP"} ·{" "}
|
||||
{r.enabled ? states.get(r.host_id) : "已停用"}
|
||||
</span>
|
||||
</summary>
|
||||
<p className="muted break-all">
|
||||
{h?.server_host}:{r.public_port} → 本机 :{r.listen_port} →{" "}
|
||||
{n?.server_host}:{n?.server_port}
|
||||
{!n?.enabled ? "(目标已停用)" : ""}
|
||||
</p>
|
||||
{routeForm(r)}
|
||||
<ActionForm action={deleteRouteAction}>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<SubmitButton
|
||||
variant="destructive"
|
||||
confirm="删除这条中转线路,保留原目标节点与直连。"
|
||||
>
|
||||
删除线路
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { requireAdminAccess } from "@/lib/session";
|
||||
import { relayInventory } from "@/lib/relays";
|
||||
import { decryptText } from "@/lib/crypto";
|
||||
import { getEnv } from "@/lib/env";
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
await requireAdminAccess();
|
||||
const id = (await context.params).id;
|
||||
const row = (await relayInventory()).hosts.find((h) => h.id === id);
|
||||
if (!row) return new Response(null, { status: 404 });
|
||||
const origin = new URL(getEnv().APP_URL);
|
||||
if (
|
||||
origin.protocol !== "https:" &&
|
||||
!["localhost", "127.0.0.1"].includes(origin.hostname)
|
||||
)
|
||||
return new Response("请配置 HTTPS APP_URL。", { status: 409 });
|
||||
const source = await readFile(
|
||||
path.join(process.cwd(), "lib/templates/relay-agent.py"),
|
||||
"utf8",
|
||||
);
|
||||
const token = decryptText(row.token_encrypted);
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
credentials: { panel: origin.origin, id, token },
|
||||
source,
|
||||
}),
|
||||
).toString("base64");
|
||||
const script = (
|
||||
await readFile(
|
||||
path.join(process.cwd(), "lib/templates/install-relay.sh"),
|
||||
"utf8",
|
||||
)
|
||||
)
|
||||
.replace("__ID__", `'${id}'`)
|
||||
.replace("__PAYLOAD__", `'${payload}'`);
|
||||
return new Response(script, {
|
||||
headers: {
|
||||
"Content-Type": "text/x-shellscript; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="install-relay-${id}.sh"`,
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { syncRelay, RelayError } from "@/lib/relays";
|
||||
export const runtime = "nodejs";
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const headers = { "Cache-Control": "no-store" };
|
||||
try {
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) return new Response(null, { status: 400, headers });
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > 16384) {
|
||||
await reader.cancel();
|
||||
return new Response(null, { status: 413, headers });
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Response.json(
|
||||
await syncRelay(
|
||||
(await context.params).id,
|
||||
request.headers.get("authorization")?.replace(/^Bearer /, "") || "",
|
||||
JSON.parse(Buffer.concat(chunks).toString("utf8")),
|
||||
),
|
||||
{ headers },
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.json(
|
||||
{ error: e instanceof RelayError ? e.message : "无效报告。" },
|
||||
{ status: e instanceof RelayError ? 403 : 400, headers },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user