Files
fnos-gitea-sync/fnos-install.js
T

234 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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); });