308 lines
9.1 KiB
TypeScript
308 lines
9.1 KiB
TypeScript
import {
|
|
createHash,
|
|
randomBytes,
|
|
randomUUID,
|
|
timingSafeEqual,
|
|
} from "node:crypto";
|
|
import { isIP } from "node:net";
|
|
import { z } from "zod";
|
|
import type Database from "better-sqlite3";
|
|
import { ensureDb } from "@/lib/db";
|
|
import { encryptText } from "@/lib/crypto";
|
|
import type { Hy2NodeRow } from "@/lib/store";
|
|
|
|
export type RelayHost = {
|
|
id: string;
|
|
name: string;
|
|
server_host: string;
|
|
enabled: number;
|
|
token_hash: string;
|
|
token_encrypted: string;
|
|
last_seen_at: string | null;
|
|
applied_hash: string;
|
|
service_active: number;
|
|
error: string;
|
|
created_at: string;
|
|
};
|
|
export type RelayRoute = {
|
|
id: string;
|
|
host_id: string;
|
|
node_id: number;
|
|
name: string;
|
|
public_port: number;
|
|
listen_port: number;
|
|
enabled: number;
|
|
created_at: string;
|
|
};
|
|
export class RelayError extends Error {}
|
|
const hostname = z
|
|
.string()
|
|
.trim()
|
|
.min(1)
|
|
.max(253)
|
|
.refine(
|
|
(v) =>
|
|
Boolean(isIP(v)) ||
|
|
/^(?=.{1,253}$)[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(v),
|
|
"请输入域名或 IP,不含协议和端口。",
|
|
);
|
|
const hostSchema = z.object({
|
|
id: z.string().uuid().optional(),
|
|
name: z.string().trim().min(1).max(100),
|
|
server_host: hostname,
|
|
enabled: z.boolean(),
|
|
});
|
|
const routeSchema = z.object({
|
|
id: z.string().uuid().optional(),
|
|
host_id: z.string().uuid(),
|
|
node_id: z.coerce.number().int().positive(),
|
|
name: z.string().trim().min(1).max(100),
|
|
public_port: z.coerce.number().int().min(1).max(65535),
|
|
listen_port: z.coerce.number().int().min(1).max(65535),
|
|
enabled: z.boolean(),
|
|
});
|
|
const digest = (value: string) =>
|
|
createHash("sha256").update(value).digest("hex");
|
|
export const relayReportSchema = z
|
|
.object({
|
|
appliedHash: z.string().regex(/^([a-f0-9]{64})?$/),
|
|
active: z.boolean(),
|
|
error: z.enum(["", "port_conflict", "apply_failed", "service_stopped"]),
|
|
})
|
|
.strict();
|
|
export async function relayInventory() {
|
|
const db = await ensureDb();
|
|
return {
|
|
hosts: db
|
|
.prepare("SELECT * FROM relay_hosts ORDER BY created_at DESC")
|
|
.all() as RelayHost[],
|
|
routes: db
|
|
.prepare("SELECT * FROM relay_routes ORDER BY created_at DESC")
|
|
.all() as RelayRoute[],
|
|
};
|
|
}
|
|
export async function saveRelayHost(input: unknown) {
|
|
const parsed = hostSchema.safeParse(input);
|
|
if (!parsed.success)
|
|
throw new RelayError("请填写主机名称和有效的公网域名/IP。");
|
|
const data = parsed.data,
|
|
db = await ensureDb();
|
|
if (data.id) {
|
|
if (
|
|
!db
|
|
.prepare(
|
|
"UPDATE relay_hosts SET name=?,server_host=?,enabled=? WHERE id=?",
|
|
)
|
|
.run(data.name, data.server_host, +data.enabled, data.id).changes
|
|
)
|
|
throw new RelayError("中转主机不存在。");
|
|
return data.id;
|
|
}
|
|
const id = randomUUID(),
|
|
token = randomBytes(32).toString("hex");
|
|
db.prepare(
|
|
"INSERT INTO relay_hosts(id,name,server_host,enabled,token_hash,token_encrypted,created_at) VALUES(?,?,?,?,?,?,?)",
|
|
).run(
|
|
id,
|
|
data.name,
|
|
data.server_host,
|
|
+data.enabled,
|
|
digest(token),
|
|
encryptText(token),
|
|
new Date().toISOString(),
|
|
);
|
|
return id;
|
|
}
|
|
export async function saveRelayRoute(input: unknown) {
|
|
const parsed = routeSchema.safeParse(input);
|
|
if (!parsed.success)
|
|
throw new RelayError("请填写线路名称、主机、目标节点及 1–65535 的端口。");
|
|
const data = parsed.data,
|
|
db = await ensureDb();
|
|
return db
|
|
.transaction(() => {
|
|
const host = db
|
|
.prepare("SELECT * FROM relay_hosts WHERE id=?")
|
|
.get(data.host_id) as RelayHost | undefined;
|
|
const node = db
|
|
.prepare("SELECT * FROM hy2_nodes WHERE id=?")
|
|
.get(data.node_id) as Hy2NodeRow | undefined;
|
|
if (
|
|
!host ||
|
|
!node ||
|
|
!["hy2", "anytls"].includes(node.node_type) ||
|
|
!node.server_host ||
|
|
!node.server_port
|
|
)
|
|
throw new RelayError("请选择托管 HY2 或 AnyTLS 节点作为目标。");
|
|
if (
|
|
data.enabled &&
|
|
db
|
|
.prepare(
|
|
`SELECT r.id FROM relay_routes r JOIN hy2_nodes n ON n.id=r.node_id WHERE r.host_id=? AND r.enabled=1 AND n.node_type=? AND (r.listen_port=? OR r.public_port=?) AND r.id<>?`,
|
|
)
|
|
.get(
|
|
data.host_id,
|
|
node.node_type,
|
|
data.listen_port,
|
|
data.public_port,
|
|
data.id || "",
|
|
)
|
|
)
|
|
throw new RelayError(
|
|
"该主机同协议的监听端口或公网端口已被其他线路使用。",
|
|
);
|
|
if (
|
|
db
|
|
.prepare("SELECT id FROM relay_routes WHERE name=? AND id<>?")
|
|
.get(data.name, data.id || "") ||
|
|
db
|
|
.prepare("SELECT id FROM hy2_nodes WHERE name=? OR client_name=?")
|
|
.get(data.name, data.name)
|
|
)
|
|
throw new RelayError("订阅名称与已有节点或线路重复。");
|
|
if (
|
|
host.server_host.toLowerCase() === node.server_host.toLowerCase() &&
|
|
[data.public_port, data.listen_port].includes(node.server_port)
|
|
)
|
|
throw new RelayError("中转入口不能指向自身。");
|
|
const id = data.id || randomUUID();
|
|
if (data.id) {
|
|
if (
|
|
!db
|
|
.prepare(
|
|
"UPDATE relay_routes SET host_id=?,node_id=?,name=?,public_port=?,listen_port=?,enabled=? WHERE id=?",
|
|
)
|
|
.run(
|
|
data.host_id,
|
|
data.node_id,
|
|
data.name,
|
|
data.public_port,
|
|
data.listen_port,
|
|
+data.enabled,
|
|
id,
|
|
).changes
|
|
)
|
|
throw new RelayError("线路不存在。");
|
|
} else
|
|
db.prepare(
|
|
"INSERT INTO relay_routes(id,host_id,node_id,name,public_port,listen_port,enabled,created_at) VALUES(?,?,?,?,?,?,?,?)",
|
|
).run(
|
|
id,
|
|
data.host_id,
|
|
data.node_id,
|
|
data.name,
|
|
data.public_port,
|
|
data.listen_port,
|
|
+data.enabled,
|
|
new Date().toISOString(),
|
|
);
|
|
return id;
|
|
})
|
|
.immediate();
|
|
}
|
|
export async function deleteRelay(kind: "host" | "route", id: string) {
|
|
const db = await ensureDb();
|
|
db.prepare(
|
|
kind === "host"
|
|
? "DELETE FROM relay_hosts WHERE id=?"
|
|
: "DELETE FROM relay_routes WHERE id=?",
|
|
).run(id);
|
|
}
|
|
const address = (host: string, port: number) =>
|
|
`${isIP(host) === 6 ? `[${host}]` : host}:${port}`;
|
|
export function relayDesired(db: Database.Database, host: RelayHost) {
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT r.*, n.node_type,n.server_host,n.server_port FROM relay_routes r JOIN hy2_nodes n ON n.id=r.node_id WHERE r.host_id=? AND r.enabled=1 AND n.enabled=1 AND n.node_type IN ('hy2','anytls') ORDER BY r.id`,
|
|
)
|
|
.all(host.id) as (RelayRoute &
|
|
Pick<Hy2NodeRow, "node_type" | "server_host" | "server_port">)[];
|
|
const active = host.enabled ? rows : [];
|
|
const config = {
|
|
log: { level: "warn" },
|
|
endpoints: active.map((r) => ({
|
|
listen: `0.0.0.0:${r.listen_port}`,
|
|
remote: address(r.server_host, r.server_port),
|
|
network: {
|
|
no_tcp: r.node_type === "hy2",
|
|
use_udp: r.node_type === "hy2",
|
|
udp_timeout: 300,
|
|
},
|
|
})),
|
|
};
|
|
return {
|
|
config,
|
|
hash: digest(JSON.stringify(config)),
|
|
leaseSeconds: 90,
|
|
intervalSeconds: 15,
|
|
};
|
|
}
|
|
export async function syncRelay(id: string, token: string, input: unknown) {
|
|
const db = await ensureDb();
|
|
const host = db.prepare("SELECT * FROM relay_hosts WHERE id=?").get(id) as
|
|
RelayHost | undefined;
|
|
if (
|
|
!host ||
|
|
!/^[a-f0-9]{64}$/.test(token) ||
|
|
!timingSafeEqual(Buffer.from(digest(token)), Buffer.from(host.token_hash))
|
|
)
|
|
throw new RelayError("凭据无效。");
|
|
const data = relayReportSchema.parse(input);
|
|
db.prepare(
|
|
"UPDATE relay_hosts SET last_seen_at=?,applied_hash=?,service_active=?,error=? WHERE id=?",
|
|
).run(
|
|
new Date().toISOString(),
|
|
data.appliedHash,
|
|
+data.active,
|
|
data.error,
|
|
id,
|
|
);
|
|
return relayDesired(db, host);
|
|
}
|
|
// These are subscription-only views of the existing destination, never polling nodes.
|
|
export function appendRelaySubscriptionNodes(
|
|
db: Database.Database,
|
|
nodes: Hy2NodeRow[],
|
|
) {
|
|
const hosts = db
|
|
.prepare("SELECT * FROM relay_hosts WHERE enabled=1")
|
|
.all() as RelayHost[];
|
|
const routes = db
|
|
.prepare("SELECT * FROM relay_routes WHERE enabled=1 ORDER BY created_at")
|
|
.all() as RelayRoute[];
|
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
const ready = new Map(
|
|
hosts
|
|
.filter(
|
|
(h) =>
|
|
h.service_active &&
|
|
!h.error &&
|
|
h.last_seen_at &&
|
|
Date.now() - Date.parse(h.last_seen_at) < 90000 &&
|
|
h.applied_hash === relayDesired(db, h).hash,
|
|
)
|
|
.map((h) => [h.id, h]),
|
|
);
|
|
return [
|
|
...nodes,
|
|
...routes.flatMap((r) => {
|
|
const host = ready.get(r.host_id),
|
|
node = byId.get(r.node_id);
|
|
if (!host || !node || !["hy2", "anytls"].includes(node.node_type))
|
|
return [];
|
|
return [
|
|
{
|
|
...node,
|
|
slug: `relay/${r.id}`,
|
|
name: r.name,
|
|
client_name: r.name,
|
|
server_host: host.server_host,
|
|
server_port: r.public_port,
|
|
sni: node.sni || node.server_host,
|
|
},
|
|
];
|
|
}),
|
|
];
|
|
}
|