fix: harden hwlab web probe script login
This commit is contained in:
@@ -22,7 +22,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动
|
||||
|
||||
G14/D601 v03 的 bootstrap admin password 是 HWLAB runtime Secret 生命周期的一部分,必须收敛到 `config/hwlab-node-lanes.yaml` 的 `bootstrapAdmin` 声明与受控 `hwlab nodes secret status|ensure --node <node> --lane v03 --name hwlab-v03-bootstrap-admin` CLI。明文只能存在于 Git 忽略、owner-only 的 `.state/secrets/...` sourceRef 文件;CLI 在本地把明文转换为 HWLAB 兼容 password hash,只向运行面同步 `password-hash`,并在输出中只披露 sourceRef、sourceKey、target Secret/key、presence、byte count、fingerprint、mutation 与后续命令。`secret ensure --force` 只用于明确需要按 YAML sourceRef 重灌 bootstrap admin hash 并重启 Cloud API 的受控恢复场景,默认 ensure 不做强制重灌;不要把人工生成 hash、手工写 k8s Secret 或原生 `kubectl rollout` 沉淀为长期入口。
|
||||
|
||||
`hwlab nodes web-probe run --node <node> --lane <lane> [--url <public-origin>]` 是 HWLAB Cloud Web DOM probe 的受控指挥入口。它从 `config/hwlab-node-lanes.yaml` 解析目标 workspace、public URL 和 bootstrap admin sourceRef,在 UniDesk 指挥侧读取 owner-only 明文后只通过一次性 stdin/env 注入目标 workspace 的 `scripts/web-live-dom-probe.mjs`;stdout 只披露 sourceRef、sourceKey、presence、fingerprint、注入方式、DOM 摘要和 artifact hash,不打印密码。缺少 sourceRef 或 source 文件时应结构化返回 `web_login_secret_missing`,不能回退历史默认密码或要求把 secret 复制到 D601/G14 目标 host。需要自定义 Playwright route/intercept、in-flight DOM 读取或专用截图时,使用 `hwlab nodes web-probe script --node <node> --lane <lane> <<'JS' ... JS`,由 CLI 负责同一 sourceRef 凭据解析、`/auth/login` 建立 `hwlab_session`、已认证 `browser/context/page/baseUrl` 注入和 artifact path/hash 摘要;自定义脚本不得自行读取或打印 Web 登录凭据。
|
||||
`hwlab nodes web-probe run --node <node> --lane <lane> [--url <public-origin>]` 是 HWLAB Cloud Web DOM probe 的受控指挥入口。它从 `config/hwlab-node-lanes.yaml` 解析目标 workspace、public URL 和 bootstrap admin sourceRef,在 UniDesk 指挥侧读取 owner-only 明文后只通过一次性 stdin/env 注入目标 workspace 的 `scripts/web-live-dom-probe.mjs`;stdout 只披露 sourceRef、sourceKey、presence、fingerprint、注入方式、DOM 摘要和 artifact hash,不打印密码。缺少 sourceRef 或 source 文件时应结构化返回 `web_login_secret_missing`,不能回退历史默认密码或要求把 secret 复制到 D601/G14 目标 host。需要自定义 Playwright route/intercept、in-flight DOM 读取或专用截图时,使用 `hwlab nodes web-probe script --node <node> --lane <lane> <<'JS' ... JS`,由 CLI 负责同一 sourceRef 凭据解析、`/auth/login` 建立 `hwlab_session`、已认证 `browser/context/page/baseUrl` 注入和 artifact path/hash 摘要;自定义脚本不得自行读取或打印 Web 登录凭据。`web-probe script` 托管登录先对同源 `/auth/login` 做短重试;仍未拿到 `hwlab_session` 时自动回到当前 Cloud Web 登录表单,以浏览器方式提交同一凭据。`probe.auth` 只输出 method、status、attempts、fallback 和 redacted errorSummary,不打印密码、cookie 或可复制 session 值。
|
||||
|
||||
`hwlab nodes control-plane infra plan|status|apply --node D601 --lane v03` 是 D601 HWLAB v03 节点本地 CI/CD 与 git-mirror 前置控制面的 YAML 驱动入口,配置真相源是 `config/hwlab-node-control-plane.yaml`。`plan` 只读展示 YAML target 和将渲染的 control-plane 对象;`status` 只读观察 D601 Tekton、CI namespace、git-mirror、Argo、node-local registry 和 tools image readiness;`apply --dry-run` 只输出 manifest 摘要;`apply --confirm` 只收敛 D601 control-plane bootstrap 对象,不触发 HWLAB runtime rollout,不创建 PK01 DB,也不修改 Caddy/FRP。tools image 的 node-local registry 地址只能作为输出 artifact;输入 base image 必须由 YAML 声明为公开 registry 来源,缺少 output image 时应在 `status.next.blockers` 中体现,而不是把现有 node-local image 当成输入基础镜像。
|
||||
|
||||
|
||||
+231
-11
@@ -3815,39 +3815,259 @@ try {
|
||||
}
|
||||
|
||||
async function authenticate(browserContext) {
|
||||
const apiAuth = await authenticateWithApiRetries(browserContext);
|
||||
if (apiAuth.ok) return apiAuth;
|
||||
return authenticateWithFormFallback(browserContext, apiAuth);
|
||||
}
|
||||
|
||||
async function authenticateWithApiRetries(browserContext) {
|
||||
const loginUrl = new URL("/auth/login", baseUrl).toString();
|
||||
const response = await browserContext.request.post(loginUrl, {
|
||||
data: { username, password },
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
const attempts = [];
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
const response = await browserContext.request.post(loginUrl, {
|
||||
data: { username, password },
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
timeout: Math.min(timeoutMs, 12000),
|
||||
});
|
||||
const summary = await responseSummary(response);
|
||||
const cookieState = await readAuthCookieState(browserContext);
|
||||
const item = {
|
||||
attempt,
|
||||
method: "api",
|
||||
...summary,
|
||||
cookiePresent: cookieState.cookiePresent,
|
||||
cookieNames: cookieState.cookieNames,
|
||||
};
|
||||
attempts.push(item);
|
||||
if (response.ok() && cookieState.cookiePresent) {
|
||||
return {
|
||||
ok: true,
|
||||
method: "api",
|
||||
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
||||
status: response.status(),
|
||||
statusText: response.statusText(),
|
||||
cookiePresent: true,
|
||||
cookieNames: cookieState.cookieNames,
|
||||
attempts,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
if (response.status() < 500 && response.status() !== 429) break;
|
||||
} catch (error) {
|
||||
attempts.push({
|
||||
attempt,
|
||||
method: "api",
|
||||
status: 0,
|
||||
statusText: "request-error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
cookiePresent: false,
|
||||
});
|
||||
}
|
||||
if (attempt < 3) await sleep(300 * attempt);
|
||||
}
|
||||
const cookieState = await readAuthCookieState(browserContext);
|
||||
const last = attempts[attempts.length - 1] ?? null;
|
||||
return {
|
||||
ok: false,
|
||||
method: "api",
|
||||
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
||||
status: typeof last?.status === "number" ? last.status : 0,
|
||||
statusText: typeof last?.statusText === "string" ? last.statusText : "api-login-failed",
|
||||
cookiePresent: cookieState.cookiePresent,
|
||||
cookieNames: cookieState.cookieNames,
|
||||
attempts,
|
||||
errorSummary: last,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function authenticateWithFormFallback(browserContext, apiAuth) {
|
||||
const loginPage = await browserContext.newPage();
|
||||
const fallback = {
|
||||
method: "form",
|
||||
startPath: "/workbench",
|
||||
surface: null,
|
||||
finalPath: null,
|
||||
response: null,
|
||||
outcome: null,
|
||||
error: null,
|
||||
};
|
||||
try {
|
||||
await loginPage.goto(new URL("/workbench", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
||||
const surface = await waitForAuthSurface(loginPage);
|
||||
fallback.surface = surface;
|
||||
if (surface === "workspace") {
|
||||
const cookieState = await readAuthCookieState(browserContext);
|
||||
fallback.finalPath = new URL(loginPage.url()).pathname;
|
||||
return {
|
||||
ok: cookieState.cookiePresent,
|
||||
method: "form-fallback",
|
||||
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
||||
status: cookieState.cookiePresent ? 200 : 0,
|
||||
statusText: cookieState.cookiePresent ? "already-authenticated" : "workspace-without-session-cookie",
|
||||
cookiePresent: cookieState.cookiePresent,
|
||||
cookieNames: cookieState.cookieNames,
|
||||
attempts: apiAuth.attempts,
|
||||
apiErrorSummary: apiAuth.errorSummary,
|
||||
fallback,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
if (surface !== "login-card" && surface !== "legacy-id") {
|
||||
throw new Error("login form not visible");
|
||||
}
|
||||
|
||||
const usernameInput = loginPage.locator('#login-username, input[autocomplete="username"], .login-card input').first();
|
||||
const passwordInput = loginPage.locator('#login-password, input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]').first();
|
||||
const submitButton = loginPage.locator('#login-submit, .login-card button[type="submit"], button[type="submit"]').first();
|
||||
await usernameInput.fill(username);
|
||||
await passwordInput.fill(password);
|
||||
await loginPage.waitForFunction((expected) => {
|
||||
const user = document.querySelector('#login-username, input[autocomplete="username"], .login-card input');
|
||||
const pass = document.querySelector('#login-password, input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]');
|
||||
const submit = document.querySelector('#login-submit, .login-card button[type="submit"], button[type="submit"]');
|
||||
return Boolean(user && pass && user.value === expected.user && pass.value.length === expected.passwordLength && !submit?.disabled);
|
||||
}, { user: username, passwordLength: password.length }, { timeout: Math.min(timeoutMs, 5000) }).catch(() => null);
|
||||
|
||||
const loginResponsePromise = loginPage.waitForResponse((response) => responsePathMatches(response.url(), "/auth/login"), { timeout: Math.min(timeoutMs, 15000) }).catch(() => null);
|
||||
await Promise.allSettled([
|
||||
loginPage.waitForFunction(() => document.querySelector("#workspace") || document.querySelector("#command-input"), null, { timeout: Math.min(timeoutMs, 15000) }),
|
||||
submitButton.click(),
|
||||
]);
|
||||
const loginResponse = await loginResponsePromise;
|
||||
if (loginResponse) fallback.response = await responseSummary(loginResponse);
|
||||
fallback.outcome = await formLoginOutcome(loginPage);
|
||||
fallback.finalPath = new URL(loginPage.url()).pathname;
|
||||
assertNoCredentialLeak(loginPage.url());
|
||||
|
||||
const cookieState = await readAuthCookieState(browserContext);
|
||||
return {
|
||||
ok: cookieState.cookiePresent,
|
||||
method: "form-fallback",
|
||||
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
||||
status: fallback.response?.status ?? (cookieState.cookiePresent ? 200 : 0),
|
||||
statusText: fallback.response?.statusText ?? (cookieState.cookiePresent ? "OK" : "form-login-no-cookie"),
|
||||
cookiePresent: cookieState.cookiePresent,
|
||||
cookieNames: cookieState.cookieNames,
|
||||
attempts: apiAuth.attempts,
|
||||
apiErrorSummary: apiAuth.errorSummary,
|
||||
fallback,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
} catch (error) {
|
||||
fallback.error = error instanceof Error ? error.message : String(error);
|
||||
fallback.finalPath = loginPage ? new URL(loginPage.url()).pathname : null;
|
||||
const cookieState = await readAuthCookieState(browserContext).catch(() => ({ cookiePresent: false, cookieNames: [] }));
|
||||
return {
|
||||
ok: false,
|
||||
method: "form-fallback",
|
||||
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
||||
status: fallback.response?.status ?? 0,
|
||||
statusText: fallback.response?.statusText ?? "form-login-failed",
|
||||
cookiePresent: cookieState.cookiePresent,
|
||||
cookieNames: cookieState.cookieNames,
|
||||
attempts: apiAuth.attempts,
|
||||
apiErrorSummary: apiAuth.errorSummary,
|
||||
fallback,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
} finally {
|
||||
await loginPage.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAuthSurface(targetPage) {
|
||||
const handle = await targetPage.waitForFunction(() => {
|
||||
if (document.querySelector("#workspace") || document.querySelector("#command-input")) return "workspace";
|
||||
if (document.querySelector("#login-username")) return "legacy-id";
|
||||
if (document.querySelector('input[autocomplete="username"]') && document.querySelector('input[autocomplete="current-password"]')) return "login-card";
|
||||
if (document.querySelector(".login-card input")) return "login-card";
|
||||
return "";
|
||||
}, null, { timeout: Math.min(timeoutMs, 12000) }).catch(() => null);
|
||||
if (!handle) return null;
|
||||
return handle.jsonValue().catch(() => null);
|
||||
}
|
||||
|
||||
async function formLoginOutcome(targetPage) {
|
||||
const handle = await targetPage.waitForFunction(() => {
|
||||
if (document.querySelector("#workspace") || document.querySelector("#command-input")) return { kind: "workspace" };
|
||||
const errorText = document.querySelector(".form-error, .login-error, [role='alert'], .error, .text-red-500, .text-destructive")?.textContent?.trim();
|
||||
if (errorText) return { kind: "error", errorText };
|
||||
return null;
|
||||
}, null, { timeout: Math.min(timeoutMs, 5000) }).catch(() => null);
|
||||
if (!handle) return null;
|
||||
return handle.jsonValue().catch(() => null);
|
||||
}
|
||||
|
||||
async function responseSummary(response) {
|
||||
const status = response.status();
|
||||
let bodyPreview = null;
|
||||
if (status >= 400) {
|
||||
try {
|
||||
bodyPreview = redactString(await response.text()).replace(/\s+/gu, " ").slice(0, 500);
|
||||
} catch {
|
||||
bodyPreview = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
status,
|
||||
statusText: response.statusText(),
|
||||
bodyPreview,
|
||||
};
|
||||
}
|
||||
|
||||
async function readAuthCookieState(browserContext) {
|
||||
const cookies = await browserContext.cookies(baseUrl);
|
||||
cookieSecrets = cookies.map((cookie) => cookie.value).filter(Boolean);
|
||||
const cookieNames = cookies.map((cookie) => cookie.name).sort();
|
||||
const cookiePresent = cookieNames.includes("hwlab_session");
|
||||
return {
|
||||
ok: response.ok() && cookiePresent,
|
||||
loginUrl: new URL("/auth/login", baseUrl).pathname,
|
||||
status: response.status(),
|
||||
statusText: response.statusText(),
|
||||
cookiePresent,
|
||||
cookiePresent: cookieNames.includes("hwlab_session"),
|
||||
cookieNames,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function responsePathMatches(rawUrl, path) {
|
||||
try {
|
||||
return new URL(rawUrl).pathname === path;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoCredentialLeak(value) {
|
||||
const finalUrl = new URL(value);
|
||||
if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) {
|
||||
throw new Error("login credentials leaked into URL query");
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function publicAuth(value) {
|
||||
return {
|
||||
ok: value.ok,
|
||||
method: value.method ?? null,
|
||||
loginPath: value.loginUrl,
|
||||
status: value.status,
|
||||
statusText: value.statusText,
|
||||
cookiePresent: value.cookiePresent,
|
||||
cookieNames: value.cookieNames,
|
||||
attempts: value.attempts ?? null,
|
||||
fallback: value.fallback ?? null,
|
||||
errorSummary: cloneSummary(value.errorSummary ?? value.apiErrorSummary ?? null),
|
||||
username,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneSummary(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value ?? null;
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(raw) {
|
||||
if (!raw) throw new Error("missing HWLAB_WEB_BASE_URL");
|
||||
const parsed = new URL(raw);
|
||||
|
||||
Reference in New Issue
Block a user