234 lines
9.0 KiB
JavaScript
234 lines
9.0 KiB
JavaScript
#!/usr/bin/env node
|
||
/* GiteaHubSync .fpk installer for fnOS via the app-center web API.
|
||
* 用法:
|
||
* FNOS_HOST=192.168.31.10 FNOS_USER=admin FNOS_PASS=*** \
|
||
* node fnos-install.js [path/to/GiteaHubSync.fpk]
|
||
* 流程(已在 192.168.31.10 随 AgentCheckin 验证):
|
||
* WebSocket 登录 -> ticket -> ost cookie -> 上传 fpk -> 轮询 -> 安装 -> 轮询
|
||
*/
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
|
||
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 = "GiteaHubSync";
|
||
// 版本号从 fpk 源码 manifest 读取(可用 FNOS_VERSION 覆盖),避免升级后忘记改这里
|
||
const VERSION = process.env.FNOS_VERSION ||
|
||
(/version\s*=\s*(\S+)/.exec(
|
||
fs.readFileSync(path.join(__dirname, "GiteaHubSync", "manifest"), "utf8")) || [])[1];
|
||
if (!VERSION) {
|
||
console.error("无法从 GiteaHubSync/manifest 读取版本号");
|
||
process.exit(1);
|
||
}
|
||
const FPK = process.argv[2] ||
|
||
path.join(__dirname, "GiteaHubSync", "GiteaHubSync.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);
|
||
}
|
||
|
||
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 });
|
||
await rpc("util.crypto.getRSAPub", {}, { raw: true });
|
||
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;
|
||
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;
|
||
}
|
||
|
||
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]}`;
|
||
}
|
||
|
||
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(` 桌面/启动台会出现「Gitea→GitHub 同步」图标`);
|
||
log(` 控制面板: http://${HOST}:8899`);
|
||
}
|
||
|
||
main().catch((e) => { console.error("FAILED:", e.message); process.exit(1); });
|