Files

251 lines
10 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/* AgentCheckin .fpk installer for fnOS via the app-center web API.
*
* Full flow (validated against fnOS 1.x app center):
* 1. WebSocket wss://<host>:5667/websocket?type=main
* 2. util.getSI + util.crypto.getRSAPub (plain frames are accepted)
* 3. user.login (username/password) -> ticket
* 4. POST /app/ticket {ticket} -> ost cookie
* 5. POST /app-center/v1/download/upload (FormData: taskID/file/volumeID)
* 6. GET /app-center/v1/download/status (poll until status=2)
* 7. POST /app-center/v1/install/task (packageType=local)
* 8. POST /app-center/v1/install/status (poll until status=2)
*
* Usage:
* FNOS_HOST=192.168.31.10 FNOS_USER=admin FNOS_PASS=*** \
* node fnos-install.js [path/to/AgentCheckin.fpk]
*/
const fs = require("fs");
const path = require("path");
// fnOS uses a self-signed certificate on the LAN; accept it for this installer.
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
const HOST = process.env.FNOS_HOST || "192.168.31.10";
const PORT = process.env.FNOS_PORT || "5667";
const BASE = `https://${HOST}:${PORT}`;
const USER = process.env.FNOS_USER;
const PASS = process.env.FNOS_PASS;
const APPNAME = "AgentCheckin";
const VERSION = "1.4.2";
const FPK = process.argv[2] ||
path.join(__dirname, "AgentCheckin", "AgentCheckin.fpk");
const log = (...a) => console.log(...a);
if (!USER || !PASS) {
console.error("请设置环境变量 FNOS_USER 和 FNOS_PASS(NAS 管理员账号密码)");
process.exit(1);
}
if (!fs.existsSync(FPK)) {
console.error("找不到 fpk 文件:", FPK);
process.exit(1);
}
/* ------------------------------------------------------------------ */
/* 1. WebSocket login -> ticket */
/* ------------------------------------------------------------------ */
async function wsLogin() {
const ws = new WebSocket(`wss://${HOST}:${PORT}/websocket?type=main`);
let reqId = 1000;
const pending = new Map();
const send = (obj) => ws.send(JSON.stringify(obj));
const rpc = (req, data, opts = {}) => new Promise((resolve, reject) => {
const id = String(reqId++);
const frame = Object.assign({ req, reqid: id }, data || {});
pending.set(id, { resolve, reject });
send(opts.raw ? frame : { data: frame, config: {} });
setTimeout(() => {
if (pending.has(id)) { pending.delete(id); reject(new Error("timeout " + req)); }
}, opts.timeout || 15000);
});
await new Promise((res, rej) => {
ws.onopen = res;
ws.onerror = (e) => rej(new Error("ws error: " + (e.message || e.type)));
});
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
const p = pending.get(msg.reqid);
if (p) { pending.delete(msg.reqid); p.resolve(msg); }
};
await rpc("util.getSI", {}, { raw: true });
const pubResp = await rpc("util.crypto.getRSAPub", {}, { raw: true });
if (!pubResp.pub && !(pubResp.data && pubResp.data.pub)) {
throw new Error("getRSAPub 响应异常: " + JSON.stringify(pubResp).slice(0, 200));
}
// user.login must be sent as a raw frame with its own reqid (validated flow)
const loginId = String(reqId++);
const loginResp = await new Promise((resolve, reject) => {
pending.set(loginId, { resolve, reject });
send({
req: "user.login", reqid: loginId,
user: USER, password: PASS, stay: 0,
deviceType: "Browser", deviceName: "Linux-Chrome", ver: 2,
did: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 15)}`,
});
setTimeout(() => {
if (pending.has(loginId)) { pending.delete(loginId); reject(new Error("login timeout")); }
}, 20000);
});
ws.close();
const d = loginResp.data || loginResp;
// the login frame carries both `secret` and `ticket`; /app/ticket needs `ticket`
const ticket = d.ticket || d.secret || d.token;
if (!ticket) {
if (loginResp.errno === 131072 || loginResp.result === "fail") {
throw new Error("用户名或密码错误(请检查 FNOS_USER / FNOS_PASS)");
}
throw new Error("登录失败: " + JSON.stringify(loginResp).slice(0, 300));
}
return ticket;
}
/* ------------------------------------------------------------------ */
/* 2. ticket -> ost cookie */
/* ------------------------------------------------------------------ */
async function ticketToCookie(ticket) {
const r = await fetch(`${BASE}/app/ticket`, {
method: "POST",
headers: { "Content-Type": "application/json", Origin: BASE },
body: JSON.stringify({ ticket }),
});
const setCookie = r.headers.getSetCookie
? r.headers.getSetCookie().join("; ")
: (r.headers.get("set-cookie") || "");
const m = /ost=([^;]+)/.exec(setCookie);
if (!m) throw new Error("未能从 /app/ticket 获取 ost cookie: " + setCookie.slice(0, 200));
return `ost=${m[1]}`;
}
/* ------------------------------------------------------------------ */
/* 3-8. upload + install */
/* ------------------------------------------------------------------ */
async function uploadFpk(cookie) {
const taskId = `app-upload-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
const buf = fs.readFileSync(FPK);
const fd = new FormData();
fd.append("taskID", taskId);
fd.append("file", new Blob([buf]), path.basename(FPK));
fd.append("volumeID", "1");
const r = await fetch(`${BASE}/app-center/v1/download/upload`, {
method: "POST",
headers: { Cookie: cookie, Origin: BASE },
body: fd,
});
const j = await r.json().catch(() => ({}));
if (j.code !== 0 && j.code !== 200) throw new Error("上传被拒绝: " + JSON.stringify(j).slice(0, 300));
log("upload accepted, taskId =", taskId);
return taskId;
}
async function poll(cookie, url, opts, isDone, isFail, label, timeoutMs = 300000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const r = await fetch(url, {
...opts,
headers: { Cookie: cookie, "Content-Type": "application/json", Origin: BASE },
});
const j = await r.json().catch(() => ({}));
const st = j.data && j.data.status;
log(` [${label}] status=${st}`);
if (isDone(j)) return j;
if (isFail(j)) throw new Error(`${label} 失败: ` + JSON.stringify(j).slice(0, 400));
await new Promise((res) => setTimeout(res, 2000));
}
throw new Error(`${label} 超时`);
}
async function uninstallIfInstalled(cookie) {
const r = await fetch(`${BASE}/app-center/v1/app/installed?language=zh-CN`,
{ headers: { Cookie: cookie, Origin: BASE } });
const j = await r.json().catch(() => ({}));
const apps = (j.data || {}).list || [];
const existing = apps.find((a) => a.appName === APPNAME);
if (!existing) {
log(`== ${APPNAME} not installed yet, skipping uninstall ==`);
return;
}
log(`== ${APPNAME} v${existing.version} already installed -> uninstalling first (data in @appdata is kept) ==`);
const ur = await fetch(`${BASE}/app-center/v1/uninstall/start`, {
method: "POST",
headers: { Cookie: cookie, "Content-Type": "application/json", Origin: BASE },
body: JSON.stringify({ appName: APPNAME, language: "zh-CN", customParameters: [] }),
});
const uj = await ur.json().catch(() => ({}));
if (uj.code !== 0) throw new Error("卸载启动失败: " + JSON.stringify(uj).slice(0, 300));
const start = Date.now();
while (Date.now() - start < 120000) {
await new Promise((res) => setTimeout(res, 2500));
const r2 = await fetch(`${BASE}/app-center/v1/app/installed?language=zh-CN`,
{ headers: { Cookie: cookie, Origin: BASE } });
const j2 = await r2.json().catch(() => ({}));
const still = ((j2.data || {}).list || []).find((a) => a.appName === APPNAME);
if (!still) { log(" uninstall done (data preserved)"); return; }
}
throw new Error("卸载超时,应用仍在列表中");
}
async function main() {
log("== 1. login (%s:%s)", HOST, PORT);
const ticket = await wsLogin();
log(" ticket ok");
const cookie = await ticketToCookie(ticket);
log(" ost cookie ok");
await uninstallIfInstalled(cookie);
log("== 2. upload fpk:", FPK, `(${(fs.statSync(FPK).size / 1024).toFixed(1)} KB)`);
const taskId = await uploadFpk(cookie);
await poll(cookie,
`${BASE}/app-center/v1/download/status?downloadTaskId=${taskId}&language=zh-CN`,
{ method: "GET" },
(j) => j.data && j.data.status === 2,
(j) => j.data && (j.data.status === 3 || j.data.status === 4),
"upload", 300000);
log(" upload done");
log("== 3. query install info (registers the package) ==");
const infoResp = await fetch(
`${BASE}/app-center/v1/install/info?appName=${APPNAME}&version=${VERSION}&packageType=local&language=zh-CN`,
{ headers: { Cookie: cookie, Origin: BASE } });
const info = await infoResp.json().catch(() => ({}));
log(" install info code:", info.code,
"| deps:", JSON.stringify(((info.data || {}).relation || {}).dependencyApps || []));
log("== 4. create install task");
const r = await fetch(`${BASE}/app-center/v1/install/task`, {
method: "POST",
headers: { Cookie: cookie, "Content-Type": "application/json", Origin: BASE },
body: JSON.stringify({
appName: APPNAME, version: VERSION, packageType: "local",
systemParameters: { agreedToProtocol: true, installVolumeID: 1, dataVolumeId: 1, immediateStart: true },
customParameters: [], language: "zh-CN",
}),
});
const j = await r.json().catch(() => ({}));
const installTaskId = j.data && j.data.taskId;
if (!installTaskId) throw new Error("创建安装任务失败: " + JSON.stringify(j).slice(0, 300));
log(" install task =", installTaskId);
log("== 5. poll install status");
await poll(cookie, `${BASE}/app-center/v1/install/status`, {
method: "POST",
body: JSON.stringify({ taskId: installTaskId, language: "zh-CN" }),
},
(jj) => jj.data && jj.data.status === 2,
(jj) => jj.data && (jj.data.status === 3 || jj.data.status === 4),
"install", 600000);
log("");
log(`✅ ${APPNAME} 安装完成!`);
log(` 桌面/启动台会出现「Agent 签到助手」图标`);
log(` 或访问 https://${HOST}:5667/cgi/ThirdParty/AgentCheckin/index.cgi/`);
}
main().catch((e) => { console.error("FAILED:", e.message); process.exit(1); });