feat(security): add IP banning system with admin controls
- Add banned_ips database table with IP, timestamp, reason, and banned_by fields - Implement IP ban checking in deviceTrack to block banned connections - Add admin API endpoints: /api/admin/ban/:ip, /api/admin/unban/:ip, /api/admin/disconnect/:ip, /api/admin/refresh/:ip - Add GET /api/banned endpoint to retrieve banned IPs list - Return 403 error page for banned IP connections
This commit is contained in:
+131
-2
@@ -116,6 +116,15 @@ db.run(`
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS banned_ips (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
banned_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
reason TEXT,
|
||||||
|
banned_by TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
// Migrations for upgrading from older schema
|
// Migrations for upgrading from older schema
|
||||||
try {
|
try {
|
||||||
db.run("ALTER TABLE views ADD COLUMN cache_ttl_sec INTEGER NOT NULL DEFAULT 60");
|
db.run("ALTER TABLE views ADD COLUMN cache_ttl_sec INTEGER NOT NULL DEFAULT 60");
|
||||||
@@ -144,6 +153,30 @@ function rowToView(row: ViewRow): View {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed a default view on first run (only when table is empty)
|
||||||
|
const viewCount = (db.query("SELECT COUNT(*) as n FROM views").get() as { n: number }).n;
|
||||||
|
if (viewCount === 0) {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||||
|
[
|
||||||
|
crypto.randomUUID(),
|
||||||
|
"Demo View",
|
||||||
|
JSON.stringify([
|
||||||
|
{ url: "https://www.google.com", durationSec: 30, forceDarkMode: false, bakeStyles: false },
|
||||||
|
{ url: "https://duck.ai", durationSec: 30, forceDarkMode: false, bakeStyles: false },
|
||||||
|
{ url: "https://www.msn.com", durationSec: 30, forceDarkMode: false, bakeStyles: false },
|
||||||
|
]),
|
||||||
|
"ssr",
|
||||||
|
0,
|
||||||
|
60,
|
||||||
|
1920,
|
||||||
|
1080,
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function dbLoadAll(): View[] {
|
function dbLoadAll(): View[] {
|
||||||
return (db.query("SELECT * FROM views ORDER BY created_at DESC").all() as ViewRow[]).map(
|
return (db.query("SELECT * FROM views ORDER BY created_at DESC").all() as ViewRow[]).map(
|
||||||
rowToView,
|
rowToView,
|
||||||
@@ -193,6 +226,30 @@ function dbDelete(id: string): boolean {
|
|||||||
return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0;
|
return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dbBanIP(ip: string, reason?: string): void {
|
||||||
|
db.run(
|
||||||
|
"INSERT OR REPLACE INTO banned_ips (ip, banned_at, reason, banned_by) VALUES (?, ?, ?, ?)",
|
||||||
|
[ip, Math.floor(Date.now() / 1000), reason ?? "Manual ban", "admin"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbUnbanIP(ip: string): boolean {
|
||||||
|
return db.run("DELETE FROM banned_ips WHERE ip = ?", [ip]).changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbIsBanned(ip: string): boolean {
|
||||||
|
return db.query("SELECT ip FROM banned_ips WHERE ip = ?").get(ip) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbGetBannedIPs(): Array<{ ip: string; banned_at: number; reason: string; banned_by: string }> {
|
||||||
|
return db.query("SELECT * FROM banned_ips ORDER BY banned_at DESC").all() as Array<{
|
||||||
|
ip: string;
|
||||||
|
banned_at: number;
|
||||||
|
reason: string;
|
||||||
|
banned_by: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// IN-MEMORY VIEW CACHE
|
// IN-MEMORY VIEW CACHE
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -219,7 +276,13 @@ const devices = new Map<string, DeviceEntry>();
|
|||||||
const warmedUpViews = new Set<string>();
|
const warmedUpViews = new Set<string>();
|
||||||
const DEVICE_TTL_MS = 120_000;
|
const DEVICE_TTL_MS = 120_000;
|
||||||
|
|
||||||
function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): void {
|
function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): boolean {
|
||||||
|
// Check if IP is banned
|
||||||
|
if (dbIsBanned(ip)) {
|
||||||
|
console.warn(`[security] Blocked connection from banned IP: ${ip}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const existing = devices.get(ip);
|
const existing = devices.get(ip);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.viewId = viewId;
|
existing.viewId = viewId;
|
||||||
@@ -238,6 +301,7 @@ function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: st
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function devicesPrune(): void {
|
function devicesPrune(): void {
|
||||||
@@ -1231,6 +1295,11 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
|||||||
return json(getDebugData());
|
return json(getDebugData());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/banned
|
||||||
|
if (path === "/api/banned" && method === "GET") {
|
||||||
|
return json(dbGetBannedIPs());
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/views
|
// POST /api/views
|
||||||
if (path === "/api/views" && method === "POST") {
|
if (path === "/api/views" && method === "POST") {
|
||||||
try {
|
try {
|
||||||
@@ -1330,6 +1399,60 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/disconnect/:ip
|
||||||
|
const disconnectMatch = path.match(/^\/api\/admin\/disconnect\/(.+)$/);
|
||||||
|
if (disconnectMatch && method === "POST") {
|
||||||
|
const ip = decodeURIComponent(disconnectMatch[1]);
|
||||||
|
devices.delete(ip);
|
||||||
|
console.log(`[admin] Disconnected device: ${ip}`);
|
||||||
|
return json({ ok: true, message: `Device ${ip} disconnected` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/ban/:ip
|
||||||
|
const banMatch = path.match(/^\/api\/admin\/ban\/(.+)$/);
|
||||||
|
if (banMatch && method === "POST") {
|
||||||
|
const ip = decodeURIComponent(banMatch[1]);
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(await req.text());
|
||||||
|
const reason = body.reason || "Banned by administrator";
|
||||||
|
const bannedDevice = devices.get(ip);
|
||||||
|
dbBanIP(ip, reason);
|
||||||
|
devices.delete(ip);
|
||||||
|
if (bannedDevice) cacheInvalidate(bannedDevice.viewId);
|
||||||
|
console.log(`[admin] Banned IP: ${ip}, reason: ${reason}`);
|
||||||
|
return json({ ok: true, message: `IP ${ip} banned` });
|
||||||
|
} catch {
|
||||||
|
return json({ error: "Invalid JSON body" }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/unban/:ip
|
||||||
|
const unbanMatch = path.match(/^\/api\/admin\/unban\/(.+)$/);
|
||||||
|
if (unbanMatch && method === "POST") {
|
||||||
|
const ip = decodeURIComponent(unbanMatch[1]);
|
||||||
|
const success = dbUnbanIP(ip);
|
||||||
|
if (success) {
|
||||||
|
console.log(`[admin] Unbanned IP: ${ip}`);
|
||||||
|
return json({ ok: true, message: `IP ${ip} unbanned` });
|
||||||
|
} else {
|
||||||
|
return json({ error: "IP not found in ban list" }, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/admin/refresh/:ip
|
||||||
|
const refreshMatch = path.match(/^\/api\/admin\/refresh\/(.+)$/);
|
||||||
|
if (refreshMatch && method === "POST") {
|
||||||
|
const ip = decodeURIComponent(refreshMatch[1]);
|
||||||
|
const device = devices.get(ip);
|
||||||
|
if (device) {
|
||||||
|
cacheInvalidate(device.viewId);
|
||||||
|
console.log(`[admin] Forced cache refresh for view: ${device.viewId}`);
|
||||||
|
return json({ ok: true, message: `Cache refreshed for view ${device.viewId}` });
|
||||||
|
} else {
|
||||||
|
return json({ error: "Device not found" }, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return json({ error: "Not found" }, 404);
|
return json({ error: "Not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1441,12 +1564,18 @@ const server = Bun.serve<{ interval: number }>({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceTrack(
|
const tracked = deviceTrack(
|
||||||
ip,
|
ip,
|
||||||
view.id,
|
view.id,
|
||||||
view.name,
|
view.name,
|
||||||
req.headers.get("User-Agent") ?? "unknown",
|
req.headers.get("User-Agent") ?? "unknown",
|
||||||
);
|
);
|
||||||
|
if (!tracked) {
|
||||||
|
return new Response(errorPage("Access denied"), {
|
||||||
|
status: 403,
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
return serveView(view);
|
return serveView(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user