Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions backend/internal/agent-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import errs from "../lib/error.js";
import agentModel from "../models/agent.js";

const tokenCache = new Map();

function publicAgent(agent) {
return `${agent.name || agent.id} (${agent.url})`;
}

function trimBaseUrl(url) {
return String(url || "").replace(/\/$/, "");
}

async function parsePayload(response) {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
return await response.json();
}
return await response.text();
}

async function request(agent, path, options = {}) {
const url = `${trimBaseUrl(agent.url)}${path}`;
const response = await fetch(url, options);
const payload = await parsePayload(response);
if (!response.ok) {
const message = payload?.error?.message || payload?.message || payload || `HTTP ${response.status}`;
const err = new errs.ValidationError(`Agent ${publicAgent(agent)} request failed: ${message}`);
err.status = response.status;
throw err;
}
return { response, payload };
}

async function getToken(agent, force = false) {
const cached = tokenCache.get(agent.id || agent.url);
if (!force && cached?.token && new Date(cached.expires).getTime() > Date.now() + 60000) {
return cached.token;
}

const { payload } = await request(agent, "/api/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
identity: agent.identity,
secret: agent.secret,
}),
});

if (payload?.requires_2fa) {
throw new errs.ValidationError(`Agent ${publicAgent(agent)} requires 2FA; use a non-2FA service account`);
}
if (!payload?.token) {
throw new errs.ValidationError(`Agent ${publicAgent(agent)} did not return a token`);
}
tokenCache.set(agent.id || agent.url, payload);
return payload.token;
}

function buildForwardPath(req) {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(req.query || {})) {
if (["agent_id", "agent", "node"].includes(key)) {
continue;
}
if (Array.isArray(value)) {
value.forEach((item) => {
query.append(key, item);
});
} else if (typeof value !== "undefined" && value !== null) {
query.append(key, value);
}
}
const qs = query.toString();
return `/api${req.baseUrl}${req.path}${qs ? `?${qs}` : ""}`;
}

const internalAgentClient = {
findRequestedAgentId: (req) => req.query.agent_id || req.query.agent || req.query.node,

shouldForward: (req) => {
const agentId = internalAgentClient.findRequestedAgentId(req);
return agentId && agentId !== "local" && agentId !== "0";
},

getAgent: async (id) => {
const agent = await agentModel.query().where("id", Number.parseInt(id, 10)).andWhere("is_deleted", 0).first();
if (!agent?.id || !agent.enabled) {
throw new errs.ItemNotFoundError(`agent ${id}`);
}
return agent;
},

health: async (agent) => {
const { payload } = await request(agent, "/api", { method: "GET" });
await getToken(agent, true);
return {
ok: true,
version: payload.version,
setup: payload.setup,
checked_on: new Date().toISOString(),
};
},

forward: async (req, res) => {
const agent = await internalAgentClient.getAgent(internalAgentClient.findRequestedAgentId(req));
let token = await getToken(agent);
const headers = {
Authorization: `Bearer ${token}`,
};
let body;
if (!["GET", "HEAD"].includes(req.method)) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(req.body || {});
}
const path = buildForwardPath(req);
let response = await fetch(`${trimBaseUrl(agent.url)}${path}`, {
method: req.method,
headers,
body,
});
if (response.status === 401) {
token = await getToken(agent, true);
response = await fetch(`${trimBaseUrl(agent.url)}${path}`, {
method: req.method,
headers: { ...headers, Authorization: `Bearer ${token}` },
body,
});
}
const payload = await parsePayload(response);
res.status(response.status);
if (typeof payload === "string") {
res.send(payload);
} else {
res.send(payload);
}
},
};

export default internalAgentClient;
107 changes: 107 additions & 0 deletions backend/internal/agent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import errs from "../lib/error.js";
import utils from "../lib/utils.js";
import agentModel from "../models/agent.js";
import internalAgentClient from "./agent-client.js";

const omissions = () => ["is_deleted", "secret"];

function normalizeUrl(url) {
try {
const parsed = new URL(url);
parsed.pathname = parsed.pathname.replace(/\/$/, "");
parsed.search = "";
parsed.hash = "";
return parsed.toString().replace(/\/$/, "");
} catch {
throw new errs.ValidationError("Invalid agent URL");
}
}

const internalAgent = {
getAll: async (access) => {
await access.can("users:list");
return agentModel
.query()
.where("is_deleted", 0)
.orderBy("name", "ASC")
.then(utils.omitRows(omissions()));
},

get: async (access, data) => {
await access.can("users:list");
const row = await agentModel.query().where("id", data.id).andWhere("is_deleted", 0).first();
if (!row?.id) {
throw new errs.ItemNotFoundError(data.id);
}
return utils.omitRow(omissions())(row);
},

create: async (access, data) => {
await access.can("users:list");
const row = await agentModel.query().insertAndFetch({
name: data.name,
url: normalizeUrl(data.url),
identity: data.identity,
secret: data.secret,
enabled: typeof data.enabled === "undefined" ? true : data.enabled,
meta: {},
});
return utils.omitRow(omissions())(row);
},

update: async (access, data) => {
await access.can("users:list");
const existing = await agentModel.query().where("id", data.id).andWhere("is_deleted", 0).first();
if (!existing?.id) {
throw new errs.ItemNotFoundError(data.id);
}
const patch = {};
["name", "identity", "enabled"].forEach((key) => {
if (typeof data[key] !== "undefined") {
patch[key] = data[key];
}
});
if (typeof data.url !== "undefined") {
patch.url = normalizeUrl(data.url);
}
if (typeof data.secret === "string" && data.secret.length) {
patch.secret = data.secret;
}
await agentModel.query().where("id", data.id).patch(patch);
return internalAgent.get(access, { id: data.id });
},

delete: async (access, data) => {
await access.can("users:list");
const existing = await agentModel.query().where("id", data.id).andWhere("is_deleted", 0).first();
if (!existing?.id) {
throw new errs.ItemNotFoundError(data.id);
}
await agentModel.query().where("id", data.id).patch({ is_deleted: 1 });
return true;
},

test: async (access, data) => {
await access.can("users:list");
let agent;
if (data.id) {
agent = await agentModel.query().where("id", data.id).andWhere("is_deleted", 0).first();
} else {
agent = {
url: normalizeUrl(data.url),
identity: data.identity,
secret: data.secret,
};
}
if (!agent) {
throw new errs.ItemNotFoundError(data.id);
}
const result = await internalAgentClient.health(agent);
if (data.id) {
await agentModel.query().where("id", data.id).patch({ meta: { last_test: result } });
}
return result;
},
};

export default internalAgent;
18 changes: 18 additions & 0 deletions backend/lib/express/agent-forward.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import internalAgentClient from "../../internal/agent-client.js";
import { debug, express as logger } from "../../logger.js";

export default function () {
return async (req, res, next) => {
if (!internalAgentClient.shouldForward(req)) {
next();
return;
}
try {
await res.locals.access.can("users:list");
await internalAgentClient.forward(req, res);
} catch (err) {
debug(logger, `${req.method.toUpperCase()} ${req.originalUrl}: ${err}`);
next(err);
}
};
}
33 changes: 33 additions & 0 deletions backend/migrations/20260520160000_agent_table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { migrate as logger } from "../logger.js";

const migrateName = "agent-table";

const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);
return knex.schema.hasTable("agent").then((exists) => {
if (exists) {
logger.info(`[${migrateName}] agent Table already exists`);
return;
}
return knex.schema.createTable("agent", (table) => {
table.increments().primary();
table.dateTime("created_on").notNull();
table.dateTime("modified_on").notNull();
table.integer("is_deleted").notNull().unsigned().defaultTo(0);
table.integer("enabled").notNull().unsigned().defaultTo(1);
table.string("name").notNull();
table.string("url").notNull();
table.string("identity").notNull();
table.text("secret").notNull();
table.json("meta").notNull();
table.unique("url");
});
});
};

const down = (knex) => {
logger.warn(`[${migrateName}] Migrating Down...`);
return knex.schema.dropTableIfExists("agent");
};

export { up, down };
49 changes: 49 additions & 0 deletions backend/models/agent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Model } from "objection";
import db from "../db.js";
import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js";
import now from "./now_helper.js";

Model.knex(db());

const boolFields = ["is_deleted", "enabled"];

class Agent extends Model {
$beforeInsert() {
this.created_on = now();
this.modified_on = now();
if (typeof this.enabled === "undefined") {
this.enabled = true;
}
if (typeof this.meta === "undefined") {
this.meta = {};
}
}

$beforeUpdate() {
this.modified_on = now();
}

$parseDatabaseJson(json) {
const thisJson = super.$parseDatabaseJson(json);
return convertIntFieldsToBool(thisJson, boolFields);
}

$formatDatabaseJson(json) {
const thisJson = convertBoolFieldsToInt(json, boolFields);
return super.$formatDatabaseJson(thisJson);
}

static get name() {
return "Agent";
}

static get tableName() {
return "agent";
}

static get jsonAttributes() {
return ["meta"];
}
}

export default Agent;
Loading