feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function HostRevokeButton({ hostId }: { hostId: string }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function revoke() {
|
||||
if (!window.confirm("撤销后 daemon 令牌立即失效并释放槽位。保留主机 identity.json 时可通过新的配对码安全恢复。确定继续?")) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/hosts/revoke", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ hostId, reason: "用户从主机列表撤销设备凭据" }),
|
||||
});
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (!response.ok) throw new Error(body.message || "撤销失败");
|
||||
router.refresh();
|
||||
} catch (revokeError) {
|
||||
setError(revokeError instanceof Error ? revokeError.message : "撤销失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button className="button button-secondary" type="button" onClick={revoke} disabled={busy}>
|
||||
{busy ? "正在撤销…" : "撤销主机"}
|
||||
</button>
|
||||
{error && <small className="form-error" role="alert">{error}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function PairingCancelButton({
|
||||
pairingId,
|
||||
onCancelled,
|
||||
}: {
|
||||
pairingId: string;
|
||||
onCancelled?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function cancel() {
|
||||
if (
|
||||
!window.confirm(
|
||||
"取消后这枚配对凭证立即失效并释放占位。若 daemon 正在认领,最终状态以主机列表为准。确定取消?",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/hosts/pairing/cancel", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ pairingId }),
|
||||
});
|
||||
const body = (await response.json()) as { message?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(body.message || "取消配对请求失败");
|
||||
}
|
||||
onCancelled?.();
|
||||
router.refresh();
|
||||
} catch (cancelError) {
|
||||
setError(
|
||||
cancelError instanceof Error ? cancelError.message : "取消配对请求失败",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pairing-cancel-action">
|
||||
<button
|
||||
className="button button-secondary"
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "正在取消…" : "取消配对"}
|
||||
</button>
|
||||
{error && (
|
||||
<small className="form-error" role="alert">
|
||||
{error}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PairingCancelButton } from "../PairingCancelButton";
|
||||
import {
|
||||
buildDaemonRegistrationCommand,
|
||||
daemonStartCommand,
|
||||
type SupportedHostOS,
|
||||
} from "./onboarding";
|
||||
import type { PairingAccessState } from "../../onboarding";
|
||||
|
||||
type PairingResult = { id: string; bootstrapToken: string; expiresAt: string };
|
||||
type PairingView = PairingResult & {
|
||||
requestedName: string;
|
||||
os: SupportedHostOS;
|
||||
};
|
||||
type PairingProgress = {
|
||||
id: string;
|
||||
status: "waiting" | "claimed" | "expired" | "locked" | "cancelled";
|
||||
expiresAt: string;
|
||||
claimedHostId: string | null;
|
||||
claimedAt: string | null;
|
||||
claimAttemptState: "not_seen" | "seen" | "invalid";
|
||||
lastClaimAttemptAt: string | null;
|
||||
};
|
||||
type PairingTerminal = Exclude<PairingProgress["status"], "waiting" | "claimed">;
|
||||
type PairingCompletion = {
|
||||
hostId: string;
|
||||
claimedAt: string;
|
||||
};
|
||||
type CopyTarget = "command" | "token" | "start";
|
||||
const unavailableCopy: Record<Exclude<PairingAccessState, "available">, { title: string; detail: string; href: string; action: string; readiness?: boolean }> = {
|
||||
request_pending: {
|
||||
title: "免费闭测申请正在审核",
|
||||
detail: "处理结果会显示在公测权益页;审核期间不需要重复申请。",
|
||||
href: "/dashboard/billing",
|
||||
action: "查看申请进度",
|
||||
},
|
||||
gated: {
|
||||
title: "公开接入仍由安全门禁阻止",
|
||||
detail: "免费政策已经预设,但 P0 证据尚未齐全。可以申请小范围免费闭测资格。",
|
||||
href: "/dashboard/billing",
|
||||
action: "申请免费闭测",
|
||||
readiness: true,
|
||||
},
|
||||
inactive: {
|
||||
title: "公开公测当前未开放",
|
||||
detail: "该账户没有有效的免费公测或闭测邀请;可以提交闭测申请,不会因此创建订单或要求付款。",
|
||||
href: "/dashboard/billing",
|
||||
action: "申请免费闭测",
|
||||
},
|
||||
full: {
|
||||
title: "当前没有可用主机槽位",
|
||||
detail: "已有主机和等待中的配对请求已经占满当前免费容量,请先取消旧请求或联系管理员调整邀请。",
|
||||
href: "/dashboard/hosts",
|
||||
action: "管理主机和配对",
|
||||
},
|
||||
};
|
||||
const terminalCopy: Record<PairingTerminal, { title: string; detail: string }> = {
|
||||
expired: {
|
||||
title: "这枚配对码已经过期",
|
||||
detail: "十分钟有效期已经结束,原码不能恢复。确认 daemon 已准备好后再生成一枚新码。",
|
||||
},
|
||||
locked: {
|
||||
title: "这枚配对码已经锁定",
|
||||
detail: "错误尝试次数已达到上限,原码不能继续使用。请核对 daemon 和复制步骤后重新生成。",
|
||||
},
|
||||
cancelled: {
|
||||
title: "这枚配对码已经取消",
|
||||
detail: "原码已失效并释放预留容量;需要接入时可以重新生成。",
|
||||
},
|
||||
};
|
||||
|
||||
export function PairingForm({
|
||||
accessState,
|
||||
releaseAvailable,
|
||||
minimumDaemonVersion,
|
||||
connectOrigin,
|
||||
}: {
|
||||
accessState: PairingAccessState;
|
||||
releaseAvailable: boolean;
|
||||
minimumDaemonVersion: string;
|
||||
connectOrigin: string;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [os, setOs] = useState<SupportedHostOS>("windows");
|
||||
const [pairing, setPairing] = useState<PairingView | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState<CopyTarget | null>(null);
|
||||
const [copyError, setCopyError] = useState("");
|
||||
const [closedBetaBuildConfirmed, setClosedBetaBuildConfirmed] = useState(false);
|
||||
const [progressError, setProgressError] = useState("");
|
||||
const [completion, setCompletion] = useState<PairingCompletion | null>(null);
|
||||
const [terminal, setTerminal] = useState<PairingTerminal | null>(null);
|
||||
const [claimAttempt, setClaimAttempt] = useState<Pick<
|
||||
PairingProgress,
|
||||
"claimAttemptState" | "lastClaimAttemptAt"
|
||||
>>({ claimAttemptState: "not_seen", lastClaimAttemptAt: null });
|
||||
const resultHeading = useRef<HTMLHeadingElement>(null);
|
||||
const pairingId = pairing?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (pairing) resultHeading.current?.focus();
|
||||
}, [pairing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pairingId) return;
|
||||
let active = true;
|
||||
let timer: number | undefined;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/hosts/pairing?pairing_id=${encodeURIComponent(pairingId!)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
const body = (await response.json()) as {
|
||||
pairing?: PairingProgress;
|
||||
message?: string;
|
||||
};
|
||||
if (!active) return;
|
||||
if (!response.ok || !body.pairing) {
|
||||
throw new Error(body.message || "暂时无法确认配对状态");
|
||||
}
|
||||
setProgressError("");
|
||||
setClaimAttempt({
|
||||
claimAttemptState: body.pairing.claimAttemptState,
|
||||
lastClaimAttemptAt: body.pairing.lastClaimAttemptAt,
|
||||
});
|
||||
if (body.pairing.status === "claimed" && body.pairing.claimedHostId && body.pairing.claimedAt) {
|
||||
setCompletion({
|
||||
hostId: body.pairing.claimedHostId,
|
||||
claimedAt: body.pairing.claimedAt,
|
||||
});
|
||||
setPairing(null);
|
||||
setCopied(null);
|
||||
setCopyError("");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
body.pairing.status === "expired"
|
||||
|| body.pairing.status === "locked"
|
||||
|| body.pairing.status === "cancelled"
|
||||
) {
|
||||
setTerminal(body.pairing.status);
|
||||
setPairing(null);
|
||||
setCopied(null);
|
||||
setCopyError("");
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(poll, 2_000);
|
||||
} catch (pollError) {
|
||||
if (!active) return;
|
||||
setProgressError(
|
||||
pollError instanceof Error
|
||||
? `${pollError.message};页面会继续重试。`
|
||||
: "暂时无法确认配对状态;页面会继续重试。",
|
||||
);
|
||||
timer = window.setTimeout(poll, 5_000);
|
||||
}
|
||||
}
|
||||
|
||||
timer = window.setTimeout(poll, 1_000);
|
||||
return () => {
|
||||
active = false;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [pairingId]);
|
||||
|
||||
if (accessState !== "available") {
|
||||
const copy = unavailableCopy[accessState];
|
||||
return (
|
||||
<div className="empty-state pairing-access-blocked" role="status">
|
||||
<span className="empty-symbol">×</span>
|
||||
<h2>{copy.title}</h2>
|
||||
<p>{copy.detail}</p>
|
||||
<div className="pairing-result-actions">
|
||||
<a className="button button-primary" href={copy.href}>{copy.action}</a>
|
||||
{copy.readiness && <a className="button button-secondary" href="/readiness">查看公测门禁</a>}
|
||||
<a className="button button-secondary" href="/dashboard">返回总览</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function copyText(value: string, target: CopyTarget) {
|
||||
setCopyError("");
|
||||
try {
|
||||
if (!navigator.clipboard?.writeText) throw new Error("clipboard_unavailable");
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(target);
|
||||
} catch {
|
||||
setCopied(null);
|
||||
setCopyError("浏览器未允许自动复制。请点入对应文本框,使用 Ctrl+C 或系统复制操作手动复制。");
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setTerminal(null);
|
||||
setCompletion(null);
|
||||
if (!releaseAvailable && !closedBetaBuildConfirmed) {
|
||||
setError("公开下载尚未就绪。请先取得并核验兼容的闭测构建,再确认后生成配对码。");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/hosts/pairing", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ requestedName: name, os }),
|
||||
});
|
||||
const body = (await response.json()) as { pairing?: PairingResult; message?: string };
|
||||
if (!response.ok || !body.pairing) throw new Error(body.message || "无法创建配对请求");
|
||||
setPairing({
|
||||
...body.pairing,
|
||||
requestedName: name.trim(),
|
||||
os,
|
||||
});
|
||||
setCopied(null);
|
||||
setCopyError("");
|
||||
setProgressError("");
|
||||
setClaimAttempt({ claimAttemptState: "not_seen", lastClaimAttemptAt: null });
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "无法创建配对请求");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (completion) {
|
||||
return (
|
||||
<div className="empty-state pairing-complete" role="status">
|
||||
<span className="empty-symbol">✓</span>
|
||||
<h2>主机已成功认领</h2>
|
||||
<p>一次性配对码已经从页面状态中清除。控制平面已建立可撤销设备凭据并推进授权 revision;daemon 会继续连接同一个服务地址。</p>
|
||||
<small>认领时间:{new Date(completion.claimedAt).toLocaleString("zh-CN")} · 主机编号 {completion.hostId}</small>
|
||||
<div className="pairing-result-actions">
|
||||
<a className="button button-primary" href="/dashboard/hosts">查看主机与开通状态</a>
|
||||
<button className="button button-secondary" type="button" onClick={() => setCompletion(null)}>再添加一台主机</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (terminal) {
|
||||
const copy = terminalCopy[terminal];
|
||||
return (
|
||||
<div className="empty-state pairing-terminal" role="status">
|
||||
<span className="empty-symbol">×</span>
|
||||
<h2>{copy.title}</h2>
|
||||
<p>{copy.detail}</p>
|
||||
<div className="pairing-result-actions">
|
||||
<button className="button button-primary" type="button" onClick={() => setTerminal(null)}>重新生成配对码</button>
|
||||
<a className="button button-secondary" href="/dashboard/hosts">查看主机和配对</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pairing) {
|
||||
const registrationCommand = buildDaemonRegistrationCommand({
|
||||
os: pairing.os,
|
||||
connectOrigin,
|
||||
hostName: pairing.requestedName,
|
||||
});
|
||||
const startCommand = daemonStartCommand(pairing.os);
|
||||
return (
|
||||
<div className="pairing-result">
|
||||
<span className="eyebrow">PAIRING REQUEST CREATED</span>
|
||||
<h2 ref={resultHeading} tabIndex={-1}>在 {pairing.os === "windows" ? "Windows" : "Linux"} 主机完成注册</h2>
|
||||
<p>命令已按 Cloud 稳定 Connect 服务地址和主机名生成。一次性码不会拼进命令、URL 或浏览器存储。</p>
|
||||
<div className="pairing-progress" role="status" aria-live="polite">
|
||||
<strong>{claimAttempt.claimAttemptState === "seen" ? "Cloud 已收到请求,但尚未认领" : claimAttempt.claimAttemptState === "invalid" ? "认领尝试时间待核实" : "尚未收到注册请求"}</strong>
|
||||
<span>{claimAttempt.claimAttemptState === "seen"
|
||||
? `最近一次尝试:${new Date(claimAttempt.lastClaimAttemptAt!).toLocaleString("zh-CN")}。这只证明 Cloud 收到过针对该配对 ID 的请求,不证明来源一定是你的 daemon。请先查看终端错误,并核对系统类型、配对码、daemon 版本和 identity.json;当前码有效时无需反复生成。`
|
||||
: claimAttempt.claimAttemptState === "invalid"
|
||||
? "Cloud 找到过针对这枚配对请求的记录,但时间异常,不能判断先后;请查看终端错误或提交反馈。"
|
||||
: "请先在目标主机运行注册命令并粘贴一次性码。页面每两秒确认一次,尚未收到请求通常表示命令未运行、地址不可达或请求还没发出。"}</span>
|
||||
</div>
|
||||
{progressError && <p className="form-error" role="alert">{progressError}</p>}
|
||||
<a className="text-link pairing-download-link" href="/download">还没有兼容 daemon?先查看下载与 SHA-256 校验 →</a>
|
||||
|
||||
<ol className="registration-steps">
|
||||
<li>
|
||||
<strong>复制命令,在 daemon 所在目录运行</strong>
|
||||
<label className="pairing-copy-field">
|
||||
<span>{pairing.os === "windows" ? "PowerShell" : "Bash"} 注册命令</span>
|
||||
<textarea
|
||||
readOnly
|
||||
rows={pairing.os === "windows" ? 12 : 10}
|
||||
value={registrationCommand}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
aria-describedby="registration-command-note"
|
||||
/>
|
||||
</label>
|
||||
<button className="button button-secondary" type="button" onClick={() => copyText(registrationCommand, "command")}>{copied === "command" ? "命令已复制" : "复制注册命令"}</button>
|
||||
<small id="registration-command-note">运行后终端会隐藏输入并等待你粘贴一次性码。</small>
|
||||
</li>
|
||||
<li>
|
||||
<strong>终端出现提示后,再复制并粘贴一次性码</strong>
|
||||
<label className="pairing-copy-field">
|
||||
<span>一次性配对码</span>
|
||||
<input readOnly value={pairing.bootstrapToken} onFocus={(event) => event.currentTarget.select()} autoComplete="off" spellCheck={false} />
|
||||
</label>
|
||||
<button className="button button-secondary" type="button" onClick={() => copyText(pairing.bootstrapToken, "token")}>{copied === "token" ? "配对码已复制" : "复制一次性配对码"}</button>
|
||||
<small>有效期至 {new Date(pairing.expiresAt).toLocaleString("zh-CN")};终端输入不会回显。</small>
|
||||
</li>
|
||||
<li>
|
||||
<strong>注册成功后启动 daemon</strong>
|
||||
<label className="pairing-copy-field compact">
|
||||
<span>启动命令</span>
|
||||
<input readOnly value={startCommand} onFocus={(event) => event.currentTarget.select()} />
|
||||
</label>
|
||||
<button className="button button-secondary" type="button" onClick={() => copyText(startCommand, "start")}>{copied === "start" ? "启动命令已复制" : "复制启动命令"}</button>
|
||||
<small>保持 daemon 运行;控制面认领完成后,它会在同一个稳定服务地址等待共享 Relay 就绪,不需要轮询或切换后端地址。</small>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{copyError && <p className="form-error" role="alert">{copyError}</p>}
|
||||
{copied && !copyError && <p className="form-success" role="status">复制成功。配对码使用后请用普通文本覆盖剪贴板。</p>}
|
||||
<div className="prototype-callout"><strong>使用后立即清除</strong><span>注册命令会在结束时清除进程环境中的配对码;认领成功后服务端摘要也会立即失效。如果响应中途丢失,请在主机列表撤销记录后重新配对。不要截图或写入日志。</span></div>
|
||||
<div className="prototype-callout"><strong>恢复原记录需要最新版 daemon</strong><span>保留 identity.json、删除旧 config.json 后重新注册。恢复请求会用原 Ed25519 私钥签名;旧版 daemon 或只有公开指纹的请求会被拒绝。</span></div>
|
||||
<div className="prototype-callout"><strong>当前接入边界</strong><span>控制平面会签发独立、可撤销的设备令牌;席位只决定主机能否加入租户,不会自动授予手机读取这台主机的权限。</span></div>
|
||||
<div className="pairing-result-actions">
|
||||
<a className="button button-secondary" href="/dashboard/hosts">返回主机列表</a>
|
||||
<PairingCancelButton
|
||||
pairingId={pairing.id}
|
||||
onCancelled={() => {
|
||||
setPairing(null);
|
||||
setTerminal("cancelled");
|
||||
setCopied(null);
|
||||
setCopyError("");
|
||||
setProgressError("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="cloud-form pairing-form" onSubmit={submit}>
|
||||
{!releaseAvailable && (
|
||||
<div className="prototype-callout daemon-preflight" role="group" aria-labelledby="daemon-preflight-title">
|
||||
<strong id="daemon-preflight-title">公开 daemon 下载尚未就绪</strong>
|
||||
<span>当前不会把你送去下载不确定的“最新版”。只有已经从管理员处取得兼容构建,并核对版本与 SHA-256 的闭测参与者,才应生成十分钟配对码。</span>
|
||||
<a className="text-link" href="/download">查看最低 v{minimumDaemonVersion} 要求与下载状态 →</a>
|
||||
<label className="switch-row">
|
||||
<input type="checkbox" checked={closedBetaBuildConfirmed} onChange={(event) => setClosedBetaBuildConfirmed(event.target.checked)} />
|
||||
<span><strong>我已有经过核验的兼容闭测构建</strong><small>确认构建版本不低于 v{minimumDaemonVersion},来源和 SHA-256 已由管理员单独提供并核对。</small></span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<label><span>主机名称</span><input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:家里工作站" minLength={2} maxLength={48} required /><small>这是控制台显示名,不会成为任意路径输入。</small></label>
|
||||
<fieldset><legend>操作系统</legend><div className="choice-grid"><label className={os === "windows" ? "selected" : ""}><input type="radio" name="os" value="windows" checked={os === "windows"} onChange={() => setOs("windows")} /><strong>Windows</strong><small>amd64 · 正式支持</small></label><label className={os === "linux" ? "selected" : ""}><input type="radio" name="os" value="linux" checked={os === "linux"} onChange={() => setOs("linux")} /><strong>Linux</strong><small>amd64 / arm64 · 正式支持</small></label></div></fieldset>
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
<button className="button button-primary" type="submit" disabled={loading || (!releaseAvailable && !closedBetaBuildConfirmed)}>{loading ? "正在创建…" : "生成 10 分钟配对码"}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export type SupportedHostOS = "windows" | "linux";
|
||||
|
||||
export function assertSafeConnectOrigin(value: string): string {
|
||||
const origin = value.trim().replace(/\/$/, "");
|
||||
const parsed = new URL(origin);
|
||||
const isLoopback = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
|
||||
if (parsed.origin !== origin || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback))) {
|
||||
throw new Error("Cloud Connect 服务必须使用 HTTPS origin(本机开发地址除外)");
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
export function resolveConnectOrigin(
|
||||
configured: string | undefined,
|
||||
development = process.env.NODE_ENV !== "production",
|
||||
): string {
|
||||
const value = configured?.trim() ?? "";
|
||||
if (value) return assertSafeConnectOrigin(value);
|
||||
if (development) return "http://127.0.0.1:3000";
|
||||
throw new Error("NEKONEST_CLOUD_CONNECT_ORIGIN is required outside development");
|
||||
}
|
||||
|
||||
export function quotePowerShell(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export function quoteBash(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
export function buildDaemonRegistrationCommand(input: {
|
||||
os: SupportedHostOS;
|
||||
connectOrigin: string;
|
||||
hostName: string;
|
||||
}): string {
|
||||
const connectOrigin = assertSafeConnectOrigin(input.connectOrigin);
|
||||
const hostName = input.hostName.trim();
|
||||
if (!hostName) throw new Error("主机名称不能为空");
|
||||
|
||||
if (input.os === "windows") {
|
||||
return [
|
||||
`$env:NEKONEST_SERVER = ${quotePowerShell(connectOrigin)}`,
|
||||
`$env:NEKONEST_TRANSPORT_MODE = 'sealed'`,
|
||||
`$secureToken = Read-Host '粘贴一次性配对码' -AsSecureString`,
|
||||
`$tokenPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)`,
|
||||
"try {",
|
||||
` $env:NEKONEST_BOOTSTRAP_TOKEN = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPtr)`,
|
||||
` if ([string]::IsNullOrWhiteSpace($env:NEKONEST_BOOTSTRAP_TOKEN)) { throw '未读取到配对码' }`,
|
||||
` & '.\\nekonest-daemon.exe' -register -name ${quotePowerShell(hostName)}`,
|
||||
"} finally {",
|
||||
" Remove-Item Env:NEKONEST_BOOTSTRAP_TOKEN -ErrorAction SilentlyContinue",
|
||||
" [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPtr)",
|
||||
"}",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
"(",
|
||||
" read -rsp '粘贴一次性配对码: ' NEKONEST_BOOTSTRAP_TOKEN",
|
||||
" printf '\\n'",
|
||||
` if [ -z "$NEKONEST_BOOTSTRAP_TOKEN" ]; then printf '未读取到配对码\\n' >&2; exit 1; fi`,
|
||||
` export NEKONEST_SERVER=${quoteBash(connectOrigin)}`,
|
||||
" export NEKONEST_TRANSPORT_MODE='sealed'",
|
||||
" export NEKONEST_BOOTSTRAP_TOKEN",
|
||||
` ./nekonest-daemon -register -name ${quoteBash(hostName)}`,
|
||||
")",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function daemonStartCommand(os: SupportedHostOS): string {
|
||||
return os === "windows" ? ".\\nekonest-daemon.exe" : "./nekonest-daemon";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { requireCloudViewer } from "../../../cloud-auth";
|
||||
import { DashboardShell, PageHeading } from "../../../components/Shells";
|
||||
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
|
||||
import { getDaemonReleaseState } from "../../../daemon-release";
|
||||
import { deriveBetaOnboarding } from "../../onboarding";
|
||||
import { PairingForm } from "./PairingForm";
|
||||
import { resolveConnectOrigin } from "./onboarding";
|
||||
import { env } from "cloudflare:workers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function NewHostPage() {
|
||||
const viewer = await requireCloudViewer("/dashboard/hosts/new");
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const [snapshot, daemonRelease] = await Promise.all([
|
||||
getDashboardSnapshot(account),
|
||||
getDaemonReleaseState(),
|
||||
]);
|
||||
const onboarding = deriveBetaOnboarding({
|
||||
entitlement: snapshot.entitlement,
|
||||
hasPendingRequest: snapshot.accessRequests.some((request) => request.status === "requested"),
|
||||
});
|
||||
const connectOrigin = resolveConnectOrigin(
|
||||
env.NEKONEST_CLOUD_CONNECT_ORIGIN,
|
||||
process.env.NODE_ENV !== "production",
|
||||
);
|
||||
return (
|
||||
<DashboardShell viewer={viewer} active="/dashboard/hosts">
|
||||
<div className="cloud-page narrow-cloud-page">
|
||||
<PageHeading eyebrow="ADD OR RECOVER HOST / 添加或恢复主机" title="先创建一次性配对请求。" description="新主机和已撤销主机的安全恢复共用这一步;家里不需要打开入站端口。" actions={<Link className="button button-secondary" href="/download">先下载兼容 daemon</Link>} />
|
||||
<div className="form-layout">
|
||||
<section className="panel form-panel"><PairingForm accessState={onboarding.pairingAccessState} releaseAvailable={daemonRelease.available} minimumDaemonVersion={daemonRelease.minimumVersion} connectOrigin={connectOrigin} /></section>
|
||||
<aside className="form-aside"><span className="eyebrow">接下来会发生</span><ol><li><strong>控制平面生成短时凭证</strong><p>原始代码只显示一次,库中保存 SHA-256 哈希。</p></li><li><strong>daemon 主动认领</strong><p>daemon 提交本机身份公钥,成功后获得独立可撤销令牌。</p></li><li><strong>恢复时证明原私钥</strong><p>若该身份对应已撤销记录,最新版 daemon 必须对本次配对签名;只有公开指纹不够。</p></li><li><strong>原子占用主机槽位</strong><p>认领和槽位分配在同一事务中完成;凭证成功后立即烧毁。</p></li></ol></aside>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Link from "next/link";
|
||||
import { requireCloudViewer } from "../../cloud-auth";
|
||||
import { DashboardShell, PageHeading, StatusPill, formatDate } from "../../components/Shells";
|
||||
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
|
||||
import { HostRevokeButton } from "./HostRevokeButton";
|
||||
import { PairingCancelButton } from "./PairingCancelButton";
|
||||
import { getConnectionCopy } from "../connection-copy";
|
||||
import { deriveControlPlaneContact } from "@/db/device-control-plane";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function lifecycleLabel(value: string) {
|
||||
return value === "active" ? "已启用" : value === "deactivated" ? "已撤销" : value;
|
||||
}
|
||||
|
||||
function slotLabel(value: string) {
|
||||
return value === "active" ? "占用中" : value === "released" ? "已释放" : value;
|
||||
}
|
||||
|
||||
export default async function HostsPage() {
|
||||
const viewer = await requireCloudViewer("/dashboard/hosts");
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const snapshot = await getDashboardSnapshot(account);
|
||||
const connection = getConnectionCopy(snapshot.connection.state);
|
||||
return (
|
||||
<DashboardShell viewer={viewer} active="/dashboard/hosts">
|
||||
<div className="cloud-page">
|
||||
<PageHeading
|
||||
eyebrow="HOSTS / 主机"
|
||||
title="主机与槽位"
|
||||
description="只有启用的持久主机记录占槽位。离线不释放;明确停用或撤销后才释放。"
|
||||
actions={<Link className="button button-primary" href="/dashboard/hosts/new">添加主机</Link>}
|
||||
/>
|
||||
|
||||
<div className="entitlement-bar">
|
||||
<div><span>权益来源</span><strong>{snapshot.entitlement.mode === "public_beta" ? "公开公测" : snapshot.entitlement.mode === "grant" ? "闭测邀请" : "无"}</strong></div>
|
||||
<div><span>启用</span><strong>{snapshot.entitlement.activeSlots}</strong></div>
|
||||
<div><span>配对占位</span><strong>{snapshot.entitlement.reservedSlots}</strong></div>
|
||||
<div><span>可用</span><strong>{snapshot.entitlement.unlimited ? "不按槽位限额" : snapshot.entitlement.availableSlots}</strong></div>
|
||||
</div>
|
||||
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading"><div><span>已认领</span><h2>持久主机记录</h2></div><StatusPill tone={connection.tone}>{connection.label}</StatusPill></div>
|
||||
<p>{connection.detail} {connection.nextStep}</p>
|
||||
<p className="host-contact-note">“控制面签到”只表示设备令牌最近通过了 Cloud 授权;是否在线仍以共享 Relay 的实时连接为准。</p>
|
||||
{snapshot.hosts.length ? (
|
||||
<div className="host-list">
|
||||
{snapshot.hosts.map((host) => {
|
||||
const contact = deriveControlPlaneContact(host.control_plane_last_seen_at);
|
||||
return (
|
||||
<article className="host-row detailed-host" key={host.id}>
|
||||
<span className={`host-icon host-${host.os}`}>{host.os === "windows" ? "W" : "L"}</span>
|
||||
<div><strong>{host.name}</strong><small>{host.id}</small></div>
|
||||
<div><span className="row-label">槽位</span><strong>{slotLabel(host.slot_state)}</strong></div>
|
||||
<div>
|
||||
<span className="row-label">Daemon / 控制面</span>
|
||||
<strong>{host.daemon_version || "版本待上报"}</strong>
|
||||
<small>{host.control_plane_last_seen_at ? formatDate(host.control_plane_last_seen_at, true) : "等待首次 Relay 授权"}</small>
|
||||
</div>
|
||||
<StatusPill tone={host.lifecycle === "active" ? contact.tone : "neutral"}>{host.lifecycle === "active" ? contact.label : lifecycleLabel(host.lifecycle)}</StatusPill>
|
||||
{host.lifecycle === "active" && <HostRevokeButton hostId={host.id} />}
|
||||
{host.lifecycle === "deactivated" && <Link className="button button-secondary" href="/dashboard/hosts/new">重新配对</Link>}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state"><span className="empty-symbol">⌁</span><h3>没有真实主机记录</h3><p>这是正确的空状态。控制平面不会为示意图制造一台“在线”主机。</p><Link className="button button-primary" href="/dashboard/hosts/new">创建第一个配对请求</Link></div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel full-panel recovery-guide">
|
||||
<div className="panel-heading"><div><span>恢复与重装</span><h2>先分清身份文件还在不在</h2></div></div>
|
||||
<div className="recovery-options">
|
||||
<article><StatusPill tone="good">identity.json 还在</StatusPill><h3>恢复原主机记录</h3><p>撤销旧令牌后创建新配对码,保留 identity.json、删除旧 config.json,并使用最新版 daemon 重新注册。daemon 会签名证明仍持有原私钥;Cloud 复用原主机 ID 并签发新令牌。</p></article>
|
||||
<article><StatusPill tone="warn">身份文件已丢失</StatusPill><h3>建立新的主机记录</h3><p>先撤销旧记录,再创建新配对码。新安装会生成新身份和新主机 ID;旧记录保留为已撤销审计历史,不会静默换绑。</p></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading"><div><span>一次性</span><h2>等待认领的配对请求</h2></div></div>
|
||||
{snapshot.pairings.length ? (
|
||||
<div className="pairing-list">
|
||||
{snapshot.pairings.map((pairing) => (
|
||||
<article key={pairing.id}><div><strong>{pairing.requested_name}</strong><small>{pairing.os.toUpperCase()} · {pairing.id}</small></div><StatusPill tone="warn">等待 daemon</StatusPill><span>过期:{formatDate(pairing.expires_at, true)}</span><PairingCancelButton pairingId={pairing.id} /></article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-inline">没有等待中的配对请求。</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user