2026-10-13 19:09:48 +08:00
|
|
|
|
#!/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";
|
2026-10-19 12:57:57 +08:00
|
|
|
|
const VERSION = "1.4.2";
|
2026-10-13 19:09:48 +08:00
|
|
|
|
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); });
|