292 lines
11 KiB
JavaScript
292 lines
11 KiB
JavaScript
import http from "node:http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import express from "express";
|
|
import { WebSocketServer, WebSocket } from "ws";
|
|
import { AuthService } from "./auth.js";
|
|
import { buildLocalMulticaCallbackUrl, buildManagedMulticaLoginUrl } from "./multica-oauth.js";
|
|
import { SettingsStore } from "./settings-store.js";
|
|
|
|
const PORT = Number(process.env.CONSOLE_PORT || 4173);
|
|
const RUNNER_URL = process.env.RUNNER_URL || "http://ai-tools:4174";
|
|
const RUNNER_TOKEN = process.env.RUNNER_TOKEN || "";
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "";
|
|
const CONFIG_ENCRYPTION_KEY = process.env.CONFIG_ENCRYPTION_KEY || "";
|
|
const DATA_DIRECTORY = process.env.CONSOLE_DATA_DIRECTORY || "/data";
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
if (RUNNER_TOKEN.length < 32) throw new Error("RUNNER_TOKEN must contain at least 32 characters");
|
|
if (ADMIN_PASSWORD.length < 12) throw new Error("ADMIN_PASSWORD must contain at least 12 characters");
|
|
if (CONFIG_ENCRYPTION_KEY.length < 32) throw new Error("CONFIG_ENCRYPTION_KEY must contain at least 32 characters");
|
|
|
|
const auth = new AuthService(ADMIN_PASSWORD, { secureCookie: process.env.COOKIE_SECURE !== "false" });
|
|
const settings = new SettingsStore(path.join(DATA_DIRECTORY, "settings.enc.json"), CONFIG_ENCRYPTION_KEY);
|
|
await settings.init();
|
|
|
|
async function runnerFetch(route, options = {}) {
|
|
const response = await fetch(`${RUNNER_URL}${route}`, {
|
|
...options,
|
|
headers: {
|
|
Authorization: `Bearer ${RUNNER_TOKEN}`,
|
|
...(options.body ? { "Content-Type": "application/json" } : {}),
|
|
...(options.headers || {}),
|
|
},
|
|
});
|
|
const contentType = response.headers.get("content-type") || "";
|
|
const body = contentType.includes("application/json") ? await response.json() : await response.text();
|
|
if (!response.ok) {
|
|
const error = new Error(body?.error || `Runner request failed (${response.status})`);
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function requireSameSite(req, res, next) {
|
|
if (!["GET", "HEAD", "OPTIONS"].includes(req.method) && req.headers["sec-fetch-site"] === "cross-site") {
|
|
return res.status(403).json({ error: "Cross-site request blocked" });
|
|
}
|
|
next();
|
|
}
|
|
|
|
const app = express();
|
|
app.set("trust proxy", 1);
|
|
app.disable("x-powered-by");
|
|
app.use((_req, res, next) => {
|
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
res.setHeader("X-Frame-Options", "DENY");
|
|
res.setHeader("Referrer-Policy", "no-referrer");
|
|
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
res.setHeader("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' wss:; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'");
|
|
next();
|
|
});
|
|
app.use(express.json({ limit: "128kb" }));
|
|
app.use(requireSameSite);
|
|
|
|
app.get("/health", (_req, res) => res.json({ ok: true, service: "agentdock-console" }));
|
|
app.get("/api/auth/status", (req, res) => res.json({ authenticated: auth.isAuthenticated(req) }));
|
|
app.post("/api/auth/login", (req, res) => {
|
|
const result = auth.login(req, res, req.body?.password);
|
|
res.status(result.status || 200).json(result);
|
|
});
|
|
app.post("/api/auth/logout", (req, res) => {
|
|
auth.logout(req, res);
|
|
res.status(204).end();
|
|
});
|
|
|
|
app.use("/api", auth.middleware());
|
|
|
|
app.get("/api/bootstrap", async (_req, res) => {
|
|
try {
|
|
const [tools, projects, sessions, integrations] = await Promise.all([
|
|
runnerFetch("/tools"),
|
|
runnerFetch("/projects"),
|
|
runnerFetch("/sessions"),
|
|
runnerFetch("/integrations"),
|
|
]);
|
|
res.json({ ...tools, ...projects, ...sessions, ...integrations, settings: settings.publicView() });
|
|
} catch (error) {
|
|
res.status(error.status || 502).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/integrations", async (req, res) => {
|
|
try {
|
|
res.json(await runnerFetch(`/integrations${req.query.force === "1" ? "?force=1" : ""}`));
|
|
} catch (error) {
|
|
res.status(error.status || 502).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/sessions/:id/output", async (req, res) => {
|
|
try {
|
|
res.type("text/plain").send(await runnerFetch(`/sessions/${encodeURIComponent(req.params.id)}/output`));
|
|
} catch (error) {
|
|
res.status(error.status || 502).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/sessions/:id/diff", async (req, res) => {
|
|
try {
|
|
res.type("text/plain").send(await runnerFetch(`/sessions/${encodeURIComponent(req.params.id)}/diff`));
|
|
} catch (error) {
|
|
res.status(error.status || 502).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post("/api/sessions", async (req, res) => {
|
|
try {
|
|
const body = {
|
|
tool: req.body?.tool,
|
|
purpose: req.body?.purpose,
|
|
project: req.body?.project,
|
|
profile: req.body?.profile,
|
|
model: req.body?.model,
|
|
initialInput: req.body?.initialInput,
|
|
serverUrl: req.body?.serverUrl,
|
|
appUrl: req.body?.appUrl,
|
|
cols: req.body?.cols,
|
|
rows: req.body?.rows,
|
|
environment: settings.environmentForTool(req.body?.tool),
|
|
};
|
|
res.status(201).json(await runnerFetch("/sessions", { method: "POST", body: JSON.stringify(body) }));
|
|
} catch (error) {
|
|
res.status(error.status || 502).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
for (const action of ["input", "resize", "stop"]) {
|
|
app.post(`/api/sessions/:id/${action}`, async (req, res) => {
|
|
try {
|
|
await runnerFetch(`/sessions/${encodeURIComponent(req.params.id)}/${action}`, {
|
|
method: "POST",
|
|
body: JSON.stringify(req.body || {}),
|
|
});
|
|
res.status(204).end();
|
|
} catch (error) {
|
|
res.status(error.status || 502).json({ error: error.message });
|
|
}
|
|
});
|
|
}
|
|
|
|
app.put("/api/settings/secrets/:name", async (req, res) => {
|
|
try {
|
|
const value = typeof req.body?.value === "string" ? req.body.value.trim().slice(0, 4096) : "";
|
|
await settings.setSecret(req.params.name, value);
|
|
res.json({ settings: settings.publicView() });
|
|
} catch (error) {
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.put("/api/settings/preferences", async (req, res) => {
|
|
try {
|
|
await settings.setPreferences(req.body || {});
|
|
res.json({ settings: settings.publicView() });
|
|
} catch (error) {
|
|
res.status(400).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post("/api/integrations/cc-switch/:app/switch", async (req, res) => {
|
|
try {
|
|
res.json(await runnerFetch(`/integrations/cc-switch/${encodeURIComponent(req.params.app)}/switch`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ providerId: req.body?.providerId }),
|
|
}));
|
|
} catch (error) {
|
|
res.status(error.status || 502).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post("/api/integrations/cc-switch/:app/import-live", async (req, res) => {
|
|
try {
|
|
res.json(await runnerFetch(`/integrations/cc-switch/${encodeURIComponent(req.params.app)}/import-live`, {
|
|
method: "POST",
|
|
body: "{}",
|
|
}));
|
|
} catch (error) {
|
|
res.status(error.status || 502).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.post("/api/integrations/multica/callback", async (req, res) => {
|
|
try {
|
|
res.json(await runnerFetch("/integrations/multica/callback", {
|
|
method: "POST",
|
|
body: JSON.stringify({ callbackUrl: req.body?.callbackUrl }),
|
|
}));
|
|
} catch (error) {
|
|
res.status(error.status || 502).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
async function multicaSession(id) {
|
|
const [{ sessions }, output] = await Promise.all([
|
|
runnerFetch("/sessions"),
|
|
runnerFetch(`/sessions/${encodeURIComponent(id)}/output`),
|
|
]);
|
|
const session = sessions.find((item) => item.id === id);
|
|
if (!session || session.tool !== "multica" || session.purpose !== "authorization") {
|
|
const error = new Error("Multica 初始化会话不存在");
|
|
error.status = 404;
|
|
throw error;
|
|
}
|
|
if (session.status !== "running") {
|
|
const error = new Error("Multica 初始化会话已过期,请重新开始");
|
|
error.status = 409;
|
|
throw error;
|
|
}
|
|
return { session, output };
|
|
}
|
|
|
|
app.get("/api/integrations/multica/auth-url/:sessionId", async (req, res) => {
|
|
try {
|
|
const { output } = await multicaSession(req.params.sessionId);
|
|
const publicOrigin = `${req.protocol}://${req.get("host")}`;
|
|
res.json({ authUrl: buildManagedMulticaLoginUrl(output, publicOrigin, req.params.sessionId) });
|
|
} catch (error) {
|
|
res.status(error.status || 409).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
app.get("/api/integrations/multica/oauth/callback/:sessionId", async (req, res) => {
|
|
try {
|
|
const { output } = await multicaSession(req.params.sessionId);
|
|
const callbackUrl = buildLocalMulticaCallbackUrl(output, req.query);
|
|
await runnerFetch("/integrations/multica/callback", {
|
|
method: "POST",
|
|
body: JSON.stringify({ callbackUrl }),
|
|
});
|
|
res.type("html").send("<!doctype html><html lang=zh-CN><meta charset=utf-8><meta http-equiv=refresh content='2;url=/'><title>Multica 验证成功</title><body><main><h1>Multica 验证成功</h1><p>验证结果已送回容器,页面即将返回 AgentDock。</p><p><a href='/'>立即返回</a></p></main></body></html>");
|
|
} catch (error) {
|
|
const message = String(error.message).replace(/[&<>"']/g, "");
|
|
res.status(error.status || 400).type("html").send(`<!doctype html><html lang=zh-CN><meta charset=utf-8><title>Multica 验证失败</title><body><main><h1>Multica 验证失败</h1><p>${message}</p><p><a href='/'>返回 AgentDock 重新验证</a></p></main></body></html>`);
|
|
}
|
|
});
|
|
|
|
const dist = path.resolve(__dirname, "../dist");
|
|
app.use(express.static(dist, { index: false, maxAge: "1h" }));
|
|
app.use((_req, res) => res.sendFile(path.join(dist, "index.html")));
|
|
|
|
const server = http.createServer(app);
|
|
const browserSockets = new WebSocketServer({ noServer: true });
|
|
|
|
server.on("upgrade", (request, socket, head) => {
|
|
const url = new URL(request.url, "http://console.internal");
|
|
const match = url.pathname.match(/^\/api\/sessions\/([a-f0-9-]+)\/ws$/);
|
|
if (!match || !auth.isAuthenticated(request)) {
|
|
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
|
return socket.destroy();
|
|
}
|
|
browserSockets.handleUpgrade(request, socket, head, (browser) => {
|
|
const runnerWsUrl = new URL(RUNNER_URL.replace(/^http/, "ws"));
|
|
runnerWsUrl.pathname = `/ws/sessions/${match[1]}`;
|
|
runnerWsUrl.searchParams.set("token", RUNNER_TOKEN);
|
|
const upstream = new WebSocket(runnerWsUrl);
|
|
const heartbeat = setInterval(() => {
|
|
if (browser.readyState === WebSocket.OPEN) browser.ping();
|
|
if (upstream.readyState === WebSocket.OPEN) upstream.ping();
|
|
}, 25_000);
|
|
heartbeat.unref();
|
|
upstream.on("open", () => {
|
|
browser.on("message", (message, isBinary) => {
|
|
if (upstream.readyState === WebSocket.OPEN) upstream.send(message, { binary: isBinary });
|
|
});
|
|
upstream.on("message", (message, isBinary) => {
|
|
if (browser.readyState === WebSocket.OPEN) browser.send(message, { binary: isBinary });
|
|
});
|
|
});
|
|
upstream.on("close", () => browser.close());
|
|
upstream.on("error", () => browser.close(1011, "Runner connection failed"));
|
|
browser.on("close", () => upstream.close());
|
|
const clearHeartbeat = () => clearInterval(heartbeat);
|
|
upstream.once("close", clearHeartbeat);
|
|
browser.once("close", clearHeartbeat);
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, "0.0.0.0", () => {
|
|
console.log(`agentdock console listening on ${PORT}`);
|
|
});
|