huge update.

fix issues with failing tests (I'm just gonna fucking assume directx isn't installed on the github actions machines).

Added MUCH better RBAC support and control

Fixed an issue when you have over a million clients RBAC would be slow asf

Add settings page with some basic settings and configs you can change.
This commit is contained in:
KingKDot
2026-03-06 20:50:02 -06:00
parent 0b4e059f2b
commit d26668df78
26 changed files with 2092 additions and 50 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "overlord-server",
"version": "1.1.0",
"version": "1.2.1",
"private": true,
"type": "module",
"scripts": {
+10 -1
View File
@@ -2,6 +2,7 @@ import { state } from "./state.js";
import { digestData } from "./utils.js";
const POLL_INTERVAL_MS = 5000;
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
let pollTimer = null;
let render = () => {};
@@ -91,11 +92,19 @@ function updateOsFilter(items) {
}
}
function getConfiguredPollIntervalMs(defaultMs) {
const savedSeconds = Number(localStorage.getItem(PREF_REFRESH_KEY));
if (!Number.isFinite(savedSeconds)) return defaultMs;
const boundedSeconds = Math.min(120, Math.max(3, savedSeconds));
return boundedSeconds * 1000;
}
export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) {
const effectiveInterval = getConfiguredPollIntervalMs(intervalMs);
clearInterval(pollTimer);
pollTimer = setInterval(() => {
loadWithOptions({ force: false });
}, intervalMs);
}, effectiveInterval);
}
export async function sendCommand(clientId, action) {
+12
View File
@@ -23,6 +23,7 @@ if (host) {
"/plugins": "plugins-link",
"/build": "build-link",
"/users": "users-link",
"/user-client-access": "users-link",
"/notifications": "notifications-link",
};
const activeId = activeMap[path];
@@ -58,6 +59,17 @@ if (host) {
});
}
if (refs.accountSettingsBtn && !refs.accountSettingsBtn.dataset.boundSettings) {
refs.accountSettingsBtn.dataset.boundSettings = "true";
refs.accountSettingsBtn.addEventListener("click", () => {
window.location.href = "/settings";
});
}
if (path === "/settings" && refs.accountSettingsBtn) {
refs.accountSettingsBtn.classList.add("ring-1", "ring-sky-500/60", "bg-slate-700");
}
const updateToggle = () => {
const enabled = getNotificationsEnabled();
if (refs.notifyToggle) {
@@ -85,8 +85,12 @@ export function mountNav(host) {
class="hidden min-w-[20px] h-5 px-1 rounded-full bg-rose-500 text-white text-xs flex items-center justify-center"
></span>
</button>
<div
class="inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-100 min-w-0 max-w-full md:max-w-none"
<button
id="account-settings-btn"
class="inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-100 min-w-0 max-w-full md:max-w-none border border-slate-700/70 hover:bg-slate-700 transition-colors"
title="Open settings"
aria-label="Open settings"
type="button"
>
<i class="fa-solid fa-user-shield text-sky-300"></i>
<span id="username-display" class="truncate max-w-[110px] sm:max-w-[180px] md:max-w-none">Loading...</span>
@@ -94,7 +98,7 @@ export function mountNav(host) {
id="role-badge"
class="text-sm px-2 py-0.5 rounded-full bg-slate-700 shrink-0"
></span>
</div>
</button>
<button
id="logout-btn"
class="group inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-900/40 hover:bg-red-800/60 text-red-100 border border-red-700/60 transition-colors"
@@ -116,6 +120,7 @@ export function mountNav(host) {
logoutBtn: document.getElementById("logout-btn"),
notifyToggle: document.getElementById("notify-toggle"),
notifyBadge: document.getElementById("notify-badge"),
accountSettingsBtn: document.getElementById("account-settings-btn"),
usernameDisplay: document.getElementById("username-display"),
roleBadge: document.getElementById("role-badge"),
usersLink: document.getElementById("users-link"),
+384
View File
@@ -0,0 +1,384 @@
import {
getNotificationsEnabled,
setNotificationsEnabled,
} from "./notify-client.js";
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
const usernameEl = document.getElementById("settings-username");
const roleEl = document.getElementById("settings-role");
const messageEl = document.getElementById("settings-message");
const passwordForm = document.getElementById("password-form");
const currentPasswordInput = document.getElementById("current-password");
const newPasswordInput = document.getElementById("new-password");
const confirmPasswordInput = document.getElementById("confirm-password");
const passwordPolicyHint = document.getElementById("password-policy-hint");
const prefsForm = document.getElementById("prefs-form");
const prefNotificationsInput = document.getElementById("pref-notifications");
const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds");
const bansTableBody = document.getElementById("bans-table-body");
const bansPermissionNote = document.getElementById("bans-permission-note");
const refreshBansBtn = document.getElementById("refresh-bans-btn");
const securityForm = document.getElementById("security-form");
const securityPermissionNote = document.getElementById("security-permission-note");
const securitySaveBtn = document.getElementById("security-save-btn");
const securitySessionTtlInput = document.getElementById("security-session-ttl");
const securityLoginMaxAttemptsInput = document.getElementById("security-login-max-attempts");
const securityLoginWindowInput = document.getElementById("security-login-window");
const securityLockoutInput = document.getElementById("security-lockout");
const securityPasswordMinInput = document.getElementById("security-password-min");
const securityRequireUppercaseInput = document.getElementById("security-require-uppercase");
const securityRequireLowercaseInput = document.getElementById("security-require-lowercase");
const securityRequireNumberInput = document.getElementById("security-require-number");
const securityRequireSymbolInput = document.getElementById("security-require-symbol");
let currentUser = null;
let securityConfig = null;
function showMessage(text, type = "ok") {
if (!messageEl) return;
messageEl.textContent = text;
messageEl.classList.remove(
"hidden",
"text-emerald-200",
"border-emerald-700",
"bg-emerald-900/30",
"text-rose-200",
"border-rose-700",
"bg-rose-900/30",
);
if (type === "error") {
messageEl.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30");
} else {
messageEl.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30");
}
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : String(text);
return div.innerHTML;
}
function formatDate(timestamp) {
const ts = Number(timestamp) || 0;
if (!ts) return "-";
return new Date(ts).toLocaleString();
}
function canManageClientBans(role) {
return role === "admin" || role === "operator";
}
function isAdmin(role) {
return role === "admin";
}
function getPasswordRequirementsText() {
const minLength = Number(securityConfig?.passwordMinLength) || 6;
const requirements = [`min ${minLength} chars`];
if (securityConfig?.passwordRequireUppercase) requirements.push("uppercase");
if (securityConfig?.passwordRequireLowercase) requirements.push("lowercase");
if (securityConfig?.passwordRequireNumber) requirements.push("number");
if (securityConfig?.passwordRequireSymbol) requirements.push("symbol");
return requirements.join(", ");
}
function updatePasswordPolicyUi() {
const minLength = Number(securityConfig?.passwordMinLength) || 6;
if (newPasswordInput) {
newPasswordInput.minLength = minLength;
newPasswordInput.placeholder = `New password (${getPasswordRequirementsText()})`;
}
if (confirmPasswordInput) {
confirmPasswordInput.minLength = minLength;
}
if (passwordPolicyHint) {
passwordPolicyHint.textContent = `Policy: ${getPasswordRequirementsText()}`;
}
}
function setSecurityFormDisabled(disabled) {
const controls = [
securitySessionTtlInput,
securityLoginMaxAttemptsInput,
securityLoginWindowInput,
securityLockoutInput,
securityPasswordMinInput,
securityRequireUppercaseInput,
securityRequireLowercaseInput,
securityRequireNumberInput,
securityRequireSymbolInput,
securitySaveBtn,
];
for (const control of controls) {
if (!control) continue;
control.disabled = disabled;
}
}
function applySecurityForm() {
if (!securityConfig) return;
securitySessionTtlInput.value = String(securityConfig.sessionTtlHours || 168);
securityLoginMaxAttemptsInput.value = String(securityConfig.loginMaxAttempts || 5);
securityLoginWindowInput.value = String(securityConfig.loginWindowMinutes || 15);
securityLockoutInput.value = String(securityConfig.loginLockoutMinutes || 30);
securityPasswordMinInput.value = String(securityConfig.passwordMinLength || 6);
securityRequireUppercaseInput.checked = Boolean(securityConfig.passwordRequireUppercase);
securityRequireLowercaseInput.checked = Boolean(securityConfig.passwordRequireLowercase);
securityRequireNumberInput.checked = Boolean(securityConfig.passwordRequireNumber);
securityRequireSymbolInput.checked = Boolean(securityConfig.passwordRequireSymbol);
updatePasswordPolicyUi();
}
async function loadSecurityPolicy() {
if (!currentUser) return;
if (!isAdmin(currentUser.role)) {
securityPermissionNote.classList.remove("hidden");
setSecurityFormDisabled(true);
securityConfig = {
passwordMinLength: 6,
passwordRequireUppercase: false,
passwordRequireLowercase: false,
passwordRequireNumber: false,
passwordRequireSymbol: false,
};
updatePasswordPolicyUi();
return;
}
securityPermissionNote.classList.add("hidden");
const res = await fetch("/api/settings/security", { credentials: "include" });
if (!res.ok) {
showMessage("Failed to load security settings.", "error");
setSecurityFormDisabled(true);
return;
}
const data = await res.json().catch(() => ({}));
securityConfig = data.security || null;
applySecurityForm();
setSecurityFormDisabled(false);
}
function loadPrefs() {
prefNotificationsInput.checked = getNotificationsEnabled();
const refreshSeconds = Number(localStorage.getItem(PREF_REFRESH_KEY) || 8);
prefRefreshSecondsInput.value = String(Math.min(120, Math.max(3, refreshSeconds)));
}
async function loadCurrentUser() {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) {
window.location.href = "/login.html";
return;
}
currentUser = await res.json();
usernameEl.textContent = currentUser.username || "unknown";
roleEl.textContent = currentUser.role || "unknown";
}
async function updatePassword(event) {
event.preventDefault();
if (!currentUser?.userId) return;
const currentPassword = currentPasswordInput.value;
const newPassword = newPasswordInput.value;
const confirmPassword = confirmPasswordInput.value;
const minLength = Number(securityConfig?.passwordMinLength) || 6;
if (newPassword.length < minLength) {
showMessage(`New password must be at least ${minLength} characters.`, "error");
return;
}
if (newPassword !== confirmPassword) {
showMessage("Password confirmation does not match.", "error");
return;
}
const res = await fetch(`/api/users/${currentUser.userId}/password`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword,
newPassword,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || "Failed to update password.", "error");
return;
}
passwordForm.reset();
showMessage("Password updated successfully.");
}
function savePrefs(event) {
event.preventDefault();
const refreshSeconds = Math.min(
120,
Math.max(3, Number(prefRefreshSecondsInput.value || 8)),
);
setNotificationsEnabled(prefNotificationsInput.checked);
localStorage.setItem(PREF_REFRESH_KEY, String(refreshSeconds));
prefRefreshSecondsInput.value = String(refreshSeconds);
showMessage("Preferences saved.");
}
async function saveSecurityPolicy(event) {
event.preventDefault();
if (!isAdmin(currentUser?.role)) {
showMessage("Admin role required.", "error");
return;
}
const payload = {
sessionTtlHours: Number(securitySessionTtlInput.value || 168),
loginMaxAttempts: Number(securityLoginMaxAttemptsInput.value || 5),
loginWindowMinutes: Number(securityLoginWindowInput.value || 15),
loginLockoutMinutes: Number(securityLockoutInput.value || 30),
passwordMinLength: Number(securityPasswordMinInput.value || 6),
passwordRequireUppercase: securityRequireUppercaseInput.checked,
passwordRequireLowercase: securityRequireLowercaseInput.checked,
passwordRequireNumber: securityRequireNumberInput.checked,
passwordRequireSymbol: securityRequireSymbolInput.checked,
};
const res = await fetch("/api/settings/security", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || "Failed to save security policy.", "error");
return;
}
securityConfig = data.security || payload;
applySecurityForm();
showMessage("Security policy updated.");
}
async function loadBannedIps() {
if (!currentUser) return;
if (!canManageClientBans(currentUser.role)) {
bansPermissionNote.classList.remove("hidden");
refreshBansBtn.disabled = true;
bansTableBody.innerHTML = `
<tr>
<td colspan="4" class="px-3 py-6 text-center text-slate-500">No access</td>
</tr>
`;
return;
}
bansPermissionNote.classList.add("hidden");
const res = await fetch("/api/clients/banned-ips", { credentials: "include" });
if (!res.ok) {
bansTableBody.innerHTML = `
<tr>
<td colspan="4" class="px-3 py-6 text-center text-rose-300">Failed to load banned IPs</td>
</tr>
`;
return;
}
const data = await res.json().catch(() => ({ items: [] }));
const items = Array.isArray(data.items) ? data.items : [];
if (items.length === 0) {
bansTableBody.innerHTML = `
<tr>
<td colspan="4" class="px-3 py-6 text-center text-slate-400">No banned IPs</td>
</tr>
`;
return;
}
bansTableBody.innerHTML = items
.map(
(item) => `
<tr>
<td class="px-3 py-2 font-mono text-xs sm:text-sm text-slate-100">${escapeHtml(item.ip)}</td>
<td class="px-3 py-2 text-slate-300">${escapeHtml(item.reason || "Manual ban")}</td>
<td class="px-3 py-2 text-slate-400">${formatDate(item.createdAt)}</td>
<td class="px-3 py-2 text-right">
<button
type="button"
class="unban-btn px-2.5 py-1.5 rounded bg-emerald-700/80 hover:bg-emerald-600 text-white text-xs"
data-ip="${escapeHtml(item.ip)}"
>
<i class="fa-solid fa-unlock mr-1"></i>Unban
</button>
</td>
</tr>
`,
)
.join("");
}
async function handleUnbanClick(event) {
const button = event.target.closest(".unban-btn");
if (!button) return;
const ip = button.dataset.ip;
if (!ip) return;
if (!confirm(`Unban ${ip}?`)) return;
button.disabled = true;
const res = await fetch(`/api/clients/banned-ips?ip=${encodeURIComponent(ip)}`, {
method: "DELETE",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || `Failed to unban ${ip}.`, "error");
button.disabled = false;
return;
}
showMessage(`Unbanned ${ip}.`);
await loadBannedIps();
}
async function init() {
try {
await loadCurrentUser();
loadPrefs();
await loadSecurityPolicy();
await loadBannedIps();
passwordForm.addEventListener("submit", updatePassword);
prefsForm.addEventListener("submit", savePrefs);
securityForm.addEventListener("submit", saveSecurityPolicy);
refreshBansBtn.addEventListener("click", loadBannedIps);
bansTableBody.addEventListener("click", handleUnbanClick);
} catch (error) {
console.error("settings init failed", error);
showMessage("Failed to load settings.", "error");
}
}
init();
@@ -0,0 +1,358 @@
const params = new URLSearchParams(window.location.search);
const targetUserId = Number(params.get("userId") || 0);
const subtitleEl = document.getElementById("target-user-subtitle");
const messageEl = document.getElementById("access-message");
const searchInput = document.getElementById("clients-search");
const refreshBtn = document.getElementById("clients-refresh-btn");
const tableBody = document.getElementById("clients-table-body");
const pageLabel = document.getElementById("clients-page-label");
const prevBtn = document.getElementById("clients-prev-btn");
const nextBtn = document.getElementById("clients-next-btn");
const scopeForm = document.getElementById("scope-form");
const scopeSelect = document.getElementById("scope-select");
const rulesList = document.getElementById("rules-list");
const state = {
me: null,
targetUser: null,
scope: "none",
rules: [],
rulesMap: new Map(),
page: 1,
pageSize: 30,
total: 0,
items: [],
search: "",
};
function showMessage(text, type = "ok") {
messageEl.textContent = text;
messageEl.classList.remove(
"hidden",
"text-emerald-200",
"border-emerald-700",
"bg-emerald-900/30",
"text-rose-200",
"border-rose-700",
"bg-rose-900/30",
);
if (type === "error") {
messageEl.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30");
} else {
messageEl.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30");
}
}
function escapeHtml(value) {
const div = document.createElement("div");
div.textContent = value == null ? "" : String(value);
return div.innerHTML;
}
function getRule(clientId) {
return state.rulesMap.get(clientId) || "";
}
function getEffective(clientId) {
if (state.targetUser?.role === "admin") return true;
const rule = getRule(clientId);
if (state.scope === "none") return false;
if (state.scope === "all") return true;
if (state.scope === "allowlist") return rule === "allow";
if (state.scope === "denylist") return rule !== "deny";
return false;
}
function renderRules() {
if (!state.rules.length) {
rulesList.innerHTML = '<div class="text-sm text-slate-500">No explicit rules.</div>';
return;
}
rulesList.innerHTML = state.rules
.map(
(rule) => `
<div class="flex items-center justify-between gap-2 text-sm rounded-lg bg-slate-900 border border-slate-800 px-2 py-1.5">
<div class="min-w-0 flex-1">
<span class="block font-mono text-xs sm:text-sm text-slate-200 truncate" title="${escapeHtml(rule.clientId)}">${escapeHtml(rule.clientId)}</span>
<span class="ml-2 ${rule.access === "allow" ? "text-emerald-300" : "text-rose-300"}">${rule.access}</span>
</div>
<button
type="button"
class="rule-remove-btn px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300"
data-client-id="${escapeHtml(rule.clientId)}"
>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
`,
)
.join("");
}
function renderClients() {
if (!state.items.length) {
tableBody.innerHTML = `
<tr>
<td colspan="6" class="px-3 py-6 text-center text-slate-400">No matching clients</td>
</tr>
`;
} else {
tableBody.innerHTML = state.items
.map((client) => {
const rule = getRule(client.id);
const effective = getEffective(client.id);
const status = client.online ? "online" : "offline";
return `
<tr>
<td class="px-3 py-2">
<div class="font-mono text-xs sm:text-sm text-slate-100">${escapeHtml(client.id)}</div>
<div class="text-xs text-slate-500">${escapeHtml(client.os || "unknown")} / ${escapeHtml(client.arch || "-")}</div>
</td>
<td class="px-3 py-2 text-slate-300">
<div>${escapeHtml(client.host || "-")}</div>
<div class="text-xs text-slate-500">${escapeHtml(client.user || "-")}</div>
</td>
<td class="px-3 py-2">
<span class="px-2 py-0.5 rounded-full text-xs ${client.online ? "bg-emerald-900/40 text-emerald-300 border border-emerald-800" : "bg-slate-800 text-slate-400 border border-slate-700"}">${status}</span>
</td>
<td class="px-3 py-2 ${rule === "allow" ? "text-emerald-300" : rule === "deny" ? "text-rose-300" : "text-slate-500"}">${rule || "none"}</td>
<td class="px-3 py-2 ${effective ? "text-emerald-300" : "text-rose-300"}">${effective ? "visible" : "hidden"}</td>
<td class="px-3 py-2 text-right">
<div class="inline-flex gap-1">
<button type="button" class="rule-btn px-2 py-1 text-xs rounded bg-emerald-800/70 hover:bg-emerald-700 text-white disabled:opacity-50" data-client-id="${escapeHtml(client.id)}" data-access="allow" ${rule === "allow" ? "disabled" : ""}>Allow</button>
<button type="button" class="rule-btn px-2 py-1 text-xs rounded bg-rose-800/70 hover:bg-rose-700 text-white disabled:opacity-50" data-client-id="${escapeHtml(client.id)}" data-access="deny" ${rule === "deny" ? "disabled" : ""}>Deny</button>
<button type="button" class="rule-clear-btn px-2 py-1 text-xs rounded bg-slate-800 hover:bg-slate-700 text-slate-200 disabled:opacity-50" data-client-id="${escapeHtml(client.id)}" ${!rule ? "disabled" : ""}>Clear</button>
</div>
</td>
</tr>
`;
})
.join("");
}
const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
pageLabel.textContent = `Page ${state.page} / ${totalPages} (${state.total} clients)`;
prevBtn.disabled = state.page <= 1;
nextBtn.disabled = state.page >= totalPages;
}
async function loadCurrentUser() {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) {
window.location.href = "/login.html";
return;
}
state.me = await res.json();
if (state.me.role !== "admin") {
window.location.href = "/";
}
}
async function loadTargetUser() {
const res = await fetch("/api/users", { credentials: "include" });
if (!res.ok) {
throw new Error("Failed to load users");
}
const data = await res.json().catch(() => ({ users: [] }));
state.targetUser = (data.users || []).find((user) => user.id === targetUserId) || null;
if (!state.targetUser) {
throw new Error("Target user not found");
}
subtitleEl.textContent = `Managing visibility for ${state.targetUser.username} (${state.targetUser.role})`;
}
async function loadPolicy() {
const res = await fetch(`/api/users/${targetUserId}/client-access`, { credentials: "include" });
if (!res.ok) {
throw new Error("Failed to load access policy");
}
const data = await res.json().catch(() => ({ scope: "none", rules: [] }));
state.scope = data.scope || "none";
state.rules = Array.isArray(data.rules) ? data.rules : [];
state.rulesMap = new Map(state.rules.map((rule) => [rule.clientId, rule.access]));
scopeSelect.value = state.scope;
renderRules();
}
async function loadClients() {
tableBody.innerHTML = `
<tr>
<td colspan="6" class="px-3 py-6 text-center text-slate-400">Loading clients...</td>
</tr>
`;
const query = new URLSearchParams({
page: String(state.page),
pageSize: String(state.pageSize),
q: state.search,
sort: "last_seen_desc",
status: "all",
os: "all",
});
const res = await fetch(`/api/clients?${query.toString()}`, { credentials: "include" });
if (!res.ok) {
throw new Error("Failed to load clients");
}
const data = await res.json();
state.items = Array.isArray(data.items) ? data.items : [];
state.total = Number(data.total) || 0;
renderClients();
}
async function saveScope(event) {
event.preventDefault();
const nextScope = scopeSelect.value;
const res = await fetch(`/api/users/${targetUserId}/client-access`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ scope: nextScope }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || "Failed to save scope", "error");
return;
}
state.scope = nextScope;
showMessage("Scope updated.");
renderClients();
}
async function setRule(clientId, access) {
const res = await fetch(`/api/users/${targetUserId}/client-access/rules`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ clientId, access }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || "Failed to save rule", "error");
return;
}
await loadPolicy();
renderClients();
showMessage(`Rule saved: ${access} ${clientId}`);
}
async function clearRule(clientId) {
const res = await fetch(
`/api/users/${targetUserId}/client-access/rules?clientId=${encodeURIComponent(clientId)}`,
{
method: "DELETE",
credentials: "include",
},
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || "Failed to remove rule", "error");
return;
}
await loadPolicy();
renderClients();
showMessage(`Rule removed for ${clientId}`);
}
tableBody.addEventListener("click", async (event) => {
const ruleBtn = event.target.closest(".rule-btn");
if (ruleBtn) {
const clientId = ruleBtn.dataset.clientId;
const access = ruleBtn.dataset.access;
if (clientId && (access === "allow" || access === "deny")) {
await setRule(clientId, access);
}
return;
}
const clearBtn = event.target.closest(".rule-clear-btn");
if (clearBtn) {
const clientId = clearBtn.dataset.clientId;
if (clientId) {
await clearRule(clientId);
}
}
});
rulesList.addEventListener("click", async (event) => {
const removeBtn = event.target.closest(".rule-remove-btn");
if (!removeBtn) return;
const clientId = removeBtn.dataset.clientId;
if (clientId) {
await clearRule(clientId);
}
});
scopeForm.addEventListener("submit", saveScope);
searchInput.addEventListener("input", () => {
state.search = searchInput.value.trim();
state.page = 1;
loadClients().catch((error) => {
console.error(error);
showMessage("Failed to load clients", "error");
});
});
refreshBtn.addEventListener("click", () => {
loadClients().catch((error) => {
console.error(error);
showMessage("Failed to refresh clients", "error");
});
});
prevBtn.addEventListener("click", () => {
if (state.page <= 1) return;
state.page -= 1;
loadClients().catch((error) => {
console.error(error);
showMessage("Failed to load previous page", "error");
});
});
nextBtn.addEventListener("click", () => {
const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
if (state.page >= totalPages) return;
state.page += 1;
loadClients().catch((error) => {
console.error(error);
showMessage("Failed to load next page", "error");
});
});
async function init() {
if (!targetUserId || Number.isNaN(targetUserId)) {
window.location.href = "/users";
return;
}
try {
await loadCurrentUser();
await loadTargetUser();
await loadPolicy();
await loadClients();
} catch (error) {
console.error(error);
showMessage("Failed to initialize access manager", "error");
}
}
init();
+18
View File
@@ -183,6 +183,16 @@ function renderUsers() {
>
<i class="fa-solid fa-user-tag"></i>
</button>
<button
class="user-action-btn px-3 py-1.5 text-sm bg-indigo-900/30 hover:bg-indigo-900/50 text-indigo-300 rounded border border-indigo-800 transition-colors"
data-action="client-access"
data-user-id="${user.id}"
data-username="${escapeHtml(user.username)}"
data-role="${escapeHtml(user.role)}"
title="Client Visibility"
>
<i class="fa-solid fa-user-shield"></i>
</button>
<button
class="user-action-btn px-3 py-1.5 text-sm bg-red-900/20 hover:bg-red-900/40 text-red-400 rounded border border-red-800 transition-colors"
data-action="delete"
@@ -242,6 +252,9 @@ function attachActionListeners() {
case "delete":
deleteUser(userId, username);
break;
case "client-access":
configureClientAccess(userId, username, role);
break;
}
};
@@ -411,5 +424,10 @@ window.deleteUser = async function (userId, username) {
}
};
window.configureClientAccess = async function (userId, username, role) {
const params = new URLSearchParams({ userId: String(userId) });
window.location.href = `/user-client-access?${params.toString()}`;
};
getCurrentUser();
loadUsers();
+244
View File
@@ -0,0 +1,244 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings - Overlord</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="stylesheet" href="/assets/tailwind.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="stylesheet" href="/assets/main.css" />
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<header id="top-nav"></header>
<main class="max-w-6xl w-full mx-auto px-4 py-8 space-y-6">
<div>
<h1 class="text-2xl font-bold text-slate-100">Settings</h1>
<p class="text-slate-400 text-sm">
Manage your account preferences and review banned IP addresses.
</p>
</div>
<section class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div>
<h2 class="text-lg font-semibold">Basic Settings</h2>
<p class="text-sm text-slate-400">Update account and local dashboard preferences.</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div class="rounded-lg bg-slate-900 border border-slate-800 px-3 py-2">
<div class="text-slate-400">Username</div>
<div id="settings-username" class="font-medium">-</div>
</div>
<div class="rounded-lg bg-slate-900 border border-slate-800 px-3 py-2">
<div class="text-slate-400">Role</div>
<div id="settings-role" class="font-medium">-</div>
</div>
</div>
<form id="password-form" class="space-y-3 pt-1">
<h3 class="font-medium">Change Password</h3>
<input
id="current-password"
type="password"
autocomplete="current-password"
placeholder="Current password"
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
required
/>
<input
id="new-password"
type="password"
autocomplete="new-password"
minlength="6"
placeholder="New password (min 6 chars)"
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
required
/>
<input
id="confirm-password"
type="password"
autocomplete="new-password"
minlength="6"
placeholder="Confirm new password"
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
required
/>
<p id="password-policy-hint" class="text-xs text-slate-400"></p>
<button
type="submit"
class="px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-700 transition-colors text-white font-medium"
>
<i class="fa-solid fa-key mr-2"></i>Update Password
</button>
</form>
<form id="prefs-form" class="space-y-3 pt-1">
<h3 class="font-medium">Dashboard Preferences</h3>
<label class="flex items-center gap-2">
<input id="pref-notifications" type="checkbox" class="h-4 w-4" />
<span>Enable notification badge updates</span>
</label>
<label class="block">
<span class="text-sm text-slate-300">Client refresh interval (seconds)</span>
<input
id="pref-refresh-seconds"
type="number"
min="3"
max="120"
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</label>
<button
type="submit"
class="px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 transition-colors text-white font-medium"
>
<i class="fa-solid fa-floppy-disk mr-2"></i>Save Preferences
</button>
</form>
<form id="security-form" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
<div>
<h3 class="font-medium">Security Policy</h3>
<p class="text-xs text-slate-400">Admin-only global security controls.</p>
</div>
<div id="security-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
Only admins can update security policy settings.
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="block">
<span class="text-sm text-slate-300">Session TTL (hours)</span>
<input
id="security-session-ttl"
type="number"
min="1"
max="720"
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</label>
<label class="block">
<span class="text-sm text-slate-300">Max login attempts</span>
<input
id="security-login-max-attempts"
type="number"
min="1"
max="50"
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</label>
<label class="block">
<span class="text-sm text-slate-300">Login window (minutes)</span>
<input
id="security-login-window"
type="number"
min="1"
max="1440"
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</label>
<label class="block">
<span class="text-sm text-slate-300">Lockout duration (minutes)</span>
<input
id="security-lockout"
type="number"
min="1"
max="1440"
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</label>
<label class="block sm:col-span-2">
<span class="text-sm text-slate-300">Minimum password length</span>
<input
id="security-password-min"
type="number"
min="6"
max="128"
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</label>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<label class="flex items-center gap-2">
<input id="security-require-uppercase" type="checkbox" class="h-4 w-4" />
<span>Require uppercase letter</span>
</label>
<label class="flex items-center gap-2">
<input id="security-require-lowercase" type="checkbox" class="h-4 w-4" />
<span>Require lowercase letter</span>
</label>
<label class="flex items-center gap-2">
<input id="security-require-number" type="checkbox" class="h-4 w-4" />
<span>Require number</span>
</label>
<label class="flex items-center gap-2">
<input id="security-require-symbol" type="checkbox" class="h-4 w-4" />
<span>Require symbol</span>
</label>
</div>
<button
type="submit"
id="security-save-btn"
class="px-4 py-2 rounded-lg bg-indigo-700 hover:bg-indigo-600 transition-colors text-white font-medium"
>
<i class="fa-solid fa-shield-halved mr-2"></i>Save Security Policy
</button>
</form>
<p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2"></p>
</div>
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold">Banned IPs</h2>
<p class="text-sm text-slate-400">Unban blocked agent source IPs.</p>
</div>
<button
id="refresh-bans-btn"
class="px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm"
type="button"
>
<i class="fa-solid fa-rotate mr-1"></i>Refresh
</button>
</div>
<div id="bans-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
Your role does not allow IP unban actions.
</div>
<div class="overflow-x-auto border border-slate-800 rounded-lg">
<table class="w-full text-sm">
<thead class="bg-slate-800/60 text-slate-300">
<tr>
<th class="text-left px-3 py-2">IP</th>
<th class="text-left px-3 py-2">Reason</th>
<th class="text-left px-3 py-2">Banned At</th>
<th class="text-right px-3 py-2">Action</th>
</tr>
</thead>
<tbody id="bans-table-body" class="divide-y divide-slate-800">
<tr>
<td colspan="4" class="px-3 py-6 text-center text-slate-400">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
<script type="module" src="/assets/nav.js"></script>
<script type="module" src="/assets/settings.js"></script>
</body>
</html>
@@ -0,0 +1,139 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>User Client Access - Overlord</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="stylesheet" href="/assets/tailwind.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="stylesheet" href="/assets/main.css" />
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<header id="top-nav"></header>
<main class="w-full px-4 py-7 space-y-5 min-h-[calc(100vh-72px)]">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">Client Visibility Access</h1>
<p class="text-slate-400 text-sm" id="target-user-subtitle">Loading user...</p>
</div>
<a
href="/users"
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 border border-slate-700 hover:bg-slate-700"
>
<i class="fa-solid fa-arrow-left"></i>
<span>Back to Users</span>
</a>
</div>
<section class="grid grid-cols-1 xl:grid-cols-3 gap-4 min-h-[calc(100vh-170px)]">
<div class="xl:col-span-2 bg-slate-900/60 border border-slate-800 rounded-xl p-4 space-y-4 flex flex-col min-h-[420px]">
<div class="flex flex-wrap items-end gap-3">
<label class="flex-1 min-w-[240px]">
<span class="text-sm text-slate-300">Search clients</span>
<div class="mt-1 flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900 border border-slate-700">
<i class="fa-solid fa-magnifying-glass text-slate-500"></i>
<input
id="clients-search"
type="search"
class="w-full bg-transparent outline-none placeholder:text-slate-500"
placeholder="Search by host, user, id"
/>
</div>
</label>
<button
id="clients-refresh-btn"
type="button"
class="px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700"
>
<i class="fa-solid fa-rotate mr-1"></i>Refresh
</button>
</div>
<div class="overflow-x-auto overflow-y-auto border border-slate-800 rounded-xl flex-1 min-h-0">
<table class="w-full text-sm">
<thead class="bg-slate-800/70 text-slate-300">
<tr>
<th class="text-left px-3 py-2">Client</th>
<th class="text-left px-3 py-2">Host/User</th>
<th class="text-left px-3 py-2">Status</th>
<th class="text-left px-3 py-2">Rule</th>
<th class="text-left px-3 py-2">Effective</th>
<th class="text-right px-3 py-2">Actions</th>
</tr>
</thead>
<tbody id="clients-table-body" class="divide-y divide-slate-800">
<tr>
<td colspan="6" class="px-3 py-6 text-center text-slate-400">Loading clients...</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between gap-3">
<button
id="clients-prev-btn"
type="button"
class="px-3 py-2 rounded-lg bg-slate-800 border border-slate-700 hover:bg-slate-700 disabled:opacity-50"
>
Prev
</button>
<div id="clients-page-label" class="text-sm text-slate-400"></div>
<button
id="clients-next-btn"
type="button"
class="px-3 py-2 rounded-lg bg-slate-800 border border-slate-700 hover:bg-slate-700 disabled:opacity-50"
>
Next
</button>
</div>
</div>
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4 space-y-4 min-h-[420px]">
<div>
<h2 class="text-lg font-semibold">Visibility Policy</h2>
<p class="text-sm text-slate-400">Control what this user is allowed to see.</p>
</div>
<form id="scope-form" class="space-y-3">
<label class="block">
<span class="text-sm text-slate-300">Access Scope</span>
<select
id="scope-select"
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="none">none - No clients</option>
<option value="allowlist">allowlist - Approved only</option>
<option value="denylist">denylist - All except denied</option>
<option value="all">all - All clients</option>
</select>
</label>
<button
type="submit"
class="px-3 py-2 rounded-lg bg-indigo-700 hover:bg-indigo-600 text-white"
>
<i class="fa-solid fa-floppy-disk mr-1"></i>Save Scope
</button>
</form>
<div class="border-t border-slate-800 pt-3">
<h3 class="font-medium text-slate-200">Current Rules</h3>
<p class="text-xs text-slate-400 mb-2">Explicit per-client allow/deny overrides.</p>
<div id="rules-list" class="space-y-1 max-h-[320px] overflow-auto pr-1"></div>
</div>
<p id="access-message" class="hidden text-sm rounded-lg px-3 py-2"></p>
</div>
</section>
</main>
<script type="module" src="/assets/nav.js"></script>
<script type="module" src="/assets/user-client-access.js"></script>
</body>
</html>
+11 -2
View File
@@ -10,7 +10,6 @@ import {
const JWT_ISSUER = "overlord-server";
const JWT_AUDIENCE = "overlord-client";
const JWT_EXPIRATION = "7d";
const tokenBlacklist = new Set<string>();
@@ -25,6 +24,15 @@ function getSecretKey(): Uint8Array {
return new TextEncoder().encode(config.auth.jwtSecret);
}
export function getSessionTtlHours(): number {
const configured = Number(getConfig().security.sessionTtlHours) || 168;
return Math.min(24 * 30, Math.max(1, configured));
}
export function getSessionTtlSeconds(): number {
return getSessionTtlHours() * 60 * 60;
}
export interface JWTPayload {
sub: string;
userId: number;
@@ -42,6 +50,7 @@ export interface AuthenticatedUser {
}
export async function generateToken(user: User): Promise<string> {
const expiration = `${getSessionTtlHours()}h`;
const token = await new SignJWT({
sub: user.username,
userId: user.id,
@@ -51,7 +60,7 @@ export async function generateToken(user: User): Promise<string> {
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setExpirationTime(JWT_EXPIRATION)
.setExpirationTime(expiration)
.sign(getSecretKey());
return token;
+111
View File
@@ -32,6 +32,17 @@ export interface Config {
telegramBotToken: string;
telegramChatId: string;
};
security: {
sessionTtlHours: number;
loginMaxAttempts: number;
loginWindowMinutes: number;
loginLockoutMinutes: number;
passwordMinLength: number;
passwordRequireUppercase: boolean;
passwordRequireLowercase: boolean;
passwordRequireNumber: boolean;
passwordRequireSymbol: boolean;
};
}
const DEFAULT_CONFIG: Config = {
@@ -62,6 +73,17 @@ const DEFAULT_CONFIG: Config = {
telegramBotToken: "",
telegramChatId: "",
},
security: {
sessionTtlHours: 168,
loginMaxAttempts: 5,
loginWindowMinutes: 15,
loginLockoutMinutes: 30,
passwordMinLength: 6,
passwordRequireUppercase: false,
passwordRequireLowercase: false,
passwordRequireNumber: false,
passwordRequireSymbol: false,
},
};
type SaveSecrets = {
@@ -256,6 +278,44 @@ export function loadConfig(): Config {
fileConfig.notifications?.telegramChatId ||
DEFAULT_CONFIG.notifications.telegramChatId,
},
security: {
sessionTtlHours:
Number(process.env.OVERLORD_SESSION_TTL_HOURS) ||
fileConfig.security?.sessionTtlHours ||
DEFAULT_CONFIG.security.sessionTtlHours,
loginMaxAttempts:
Number(process.env.OVERLORD_LOGIN_MAX_ATTEMPTS) ||
fileConfig.security?.loginMaxAttempts ||
DEFAULT_CONFIG.security.loginMaxAttempts,
loginWindowMinutes:
Number(process.env.OVERLORD_LOGIN_WINDOW_MINUTES) ||
fileConfig.security?.loginWindowMinutes ||
DEFAULT_CONFIG.security.loginWindowMinutes,
loginLockoutMinutes:
Number(process.env.OVERLORD_LOGIN_LOCKOUT_MINUTES) ||
fileConfig.security?.loginLockoutMinutes ||
DEFAULT_CONFIG.security.loginLockoutMinutes,
passwordMinLength:
Number(process.env.OVERLORD_PASSWORD_MIN_LENGTH) ||
fileConfig.security?.passwordMinLength ||
DEFAULT_CONFIG.security.passwordMinLength,
passwordRequireUppercase:
String(process.env.OVERLORD_PASSWORD_REQUIRE_UPPERCASE || "").toLowerCase() === "true" ||
fileConfig.security?.passwordRequireUppercase ||
DEFAULT_CONFIG.security.passwordRequireUppercase,
passwordRequireLowercase:
String(process.env.OVERLORD_PASSWORD_REQUIRE_LOWERCASE || "").toLowerCase() === "true" ||
fileConfig.security?.passwordRequireLowercase ||
DEFAULT_CONFIG.security.passwordRequireLowercase,
passwordRequireNumber:
String(process.env.OVERLORD_PASSWORD_REQUIRE_NUMBER || "").toLowerCase() === "true" ||
fileConfig.security?.passwordRequireNumber ||
DEFAULT_CONFIG.security.passwordRequireNumber,
passwordRequireSymbol:
String(process.env.OVERLORD_PASSWORD_REQUIRE_SYMBOL || "").toLowerCase() === "true" ||
fileConfig.security?.passwordRequireSymbol ||
DEFAULT_CONFIG.security.passwordRequireSymbol,
},
};
if (saveChanged) {
@@ -332,3 +392,54 @@ export async function updateNotificationsConfig(
await writeFile(configPath, JSON.stringify(fileConfig, null, 2));
return next;
}
export async function updateSecurityConfig(
updates: Partial<Config["security"]>,
): Promise<Config["security"]> {
const current = getConfig();
const next = {
...current.security,
...updates,
};
const toNumberOr = (value: unknown, fallback: number) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
next.sessionTtlHours = Math.min(24 * 30, Math.max(1, toNumberOr(next.sessionTtlHours, 168)));
next.loginMaxAttempts = Math.min(50, Math.max(1, toNumberOr(next.loginMaxAttempts, 5)));
next.loginWindowMinutes = Math.min(24 * 24, Math.max(1, toNumberOr(next.loginWindowMinutes, 15)));
next.loginLockoutMinutes = Math.min(24 * 24, Math.max(1, toNumberOr(next.loginLockoutMinutes, 30)));
next.passwordMinLength = Math.min(128, Math.max(6, toNumberOr(next.passwordMinLength, 6)));
next.passwordRequireUppercase = Boolean(next.passwordRequireUppercase);
next.passwordRequireLowercase = Boolean(next.passwordRequireLowercase);
next.passwordRequireNumber = Boolean(next.passwordRequireNumber);
next.passwordRequireSymbol = Boolean(next.passwordRequireSymbol);
configCache = {
...current,
security: next,
};
const configPath = resolve(process.cwd(), "config.json");
let fileConfig: any = {};
if (existsSync(configPath)) {
try {
const content = readFileSync(configPath, "utf-8");
fileConfig = JSON.parse(content) || {};
} catch {
fileConfig = {};
}
}
fileConfig.security = next;
try {
await mkdir(resolve(process.cwd()), { recursive: true });
} catch {}
await writeFile(configPath, JSON.stringify(fileConfig, null, 2));
return next;
}
+48 -3
View File
@@ -187,6 +187,26 @@ export function unbanIp(ip: string) {
db.run(`DELETE FROM banned_ips WHERE ip=?`, ip);
}
export type BannedIpEntry = {
ip: string;
reason: string | null;
createdAt: number;
};
export function listBannedIps(): BannedIpEntry[] {
const rows = db
.query<{ ip: string; reason: string | null; createdAt: number }>(
`SELECT ip, reason, created_at as createdAt FROM banned_ips ORDER BY created_at DESC`,
)
.all();
return rows.map((row) => ({
ip: row.ip,
reason: row.reason,
createdAt: Number(row.createdAt) || 0,
}));
}
export function isIpBanned(ip: string): boolean {
const row = db.query<{ ip: string }>(`SELECT ip FROM banned_ips WHERE ip=?`).get(ip);
return !!row?.ip;
@@ -198,7 +218,16 @@ export function markAllClientsOffline() {
}
export function listClients(filters: ListFilters): ListResult {
const { page, pageSize, search, sort, statusFilter, osFilter } = filters;
const {
page,
pageSize,
search,
sort,
statusFilter,
osFilter,
allowedClientIds,
deniedClientIds,
} = filters;
const where: string[] = [];
const params: any[] = [];
@@ -221,6 +250,20 @@ export function listClients(filters: ListFilters): ListResult {
params.push(osFilter);
}
if (Array.isArray(allowedClientIds)) {
if (allowedClientIds.length === 0) {
where.push("1=0");
} else {
where.push(`id IN (${allowedClientIds.map(() => "?").join(",")})`);
params.push(...allowedClientIds);
}
}
if (Array.isArray(deniedClientIds) && deniedClientIds.length > 0) {
where.push(`id NOT IN (${deniedClientIds.map(() => "?").join(",")})`);
params.push(...deniedClientIds);
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const orderBy = (() => {
@@ -242,8 +285,10 @@ export function listClients(filters: ListFilters): ListResult {
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients ${whereSql}`)
.get(...params) ?? { c: 0 };
const onlineRow = db
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients WHERE online=1`)
.get() ?? { c: 0 };
.query<{ c: number }>(
`SELECT COUNT(*) as c FROM clients ${whereSql ? `${whereSql} AND online=1` : "WHERE online=1"}`,
)
.get(...params) ?? { c: 0 };
const offset = (page - 1) * pageSize;
const rows = db
+5 -2
View File
@@ -1,10 +1,12 @@
import { describe, expect, test } from "bun:test";
import { getConfig } from "./config";
import { isRateLimited, recordFailedAttempt, recordSuccessfulAttempt } from "./rateLimit";
describe("rateLimit", () => {
test("locks out after repeated failures", () => {
const maxAttempts = Math.max(1, Number(getConfig().security.loginMaxAttempts) || 5);
const ip = `10.0.0.${Date.now()}`;
for (let i = 0; i < 5; i += 1) {
for (let i = 0; i < maxAttempts; i += 1) {
recordFailedAttempt(ip);
}
@@ -14,8 +16,9 @@ describe("rateLimit", () => {
});
test("successful attempt clears lock state", () => {
const maxAttempts = Math.max(1, Number(getConfig().security.loginMaxAttempts) || 5);
const ip = `10.0.1.${Date.now()}`;
for (let i = 0; i < 5; i += 1) {
for (let i = 0; i < maxAttempts; i += 1) {
recordFailedAttempt(ip);
}
expect(isRateLimited(ip).limited).toBe(true);
+21 -11
View File
@@ -1,4 +1,5 @@
import { logger } from "./logger";
import { getConfig } from "./config";
interface RateLimitEntry {
attempts: number;
@@ -8,15 +9,21 @@ interface RateLimitEntry {
const rateLimitStore = new Map<string, RateLimitEntry>();
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 15 * 60 * 1000;
const LOCKOUT_MS = 30 * 60 * 1000;
const CLEANUP_INTERVAL = 60 * 1000;
function getRateLimitPolicy() {
const security = getConfig().security;
const maxAttempts = Math.min(50, Math.max(1, Number(security.loginMaxAttempts) || 5));
const windowMs = Math.min(24 * 60 * 60 * 1000, Math.max(60 * 1000, (Number(security.loginWindowMinutes) || 15) * 60 * 1000));
const lockoutMs = Math.min(24 * 60 * 60 * 1000, Math.max(60 * 1000, (Number(security.loginLockoutMinutes) || 30) * 60 * 1000));
return { maxAttempts, windowMs, lockoutMs };
}
export function isRateLimited(ip: string): {
limited: boolean;
retryAfter?: number;
} {
const policy = getRateLimitPolicy();
const entry = rateLimitStore.get(ip);
if (!entry) {
@@ -30,14 +37,14 @@ export function isRateLimited(ip: string): {
return { limited: true, retryAfter };
}
if (now - entry.firstAttempt > WINDOW_MS) {
if (now - entry.firstAttempt > policy.windowMs) {
rateLimitStore.delete(ip);
return { limited: false };
}
if (entry.attempts >= MAX_ATTEMPTS) {
entry.lockedUntil = now + LOCKOUT_MS;
const retryAfter = Math.ceil(LOCKOUT_MS / 1000);
if (entry.attempts >= policy.maxAttempts) {
entry.lockedUntil = now + policy.lockoutMs;
const retryAfter = Math.ceil(policy.lockoutMs / 1000);
logger.warn(
`[rate-limit] IP ${ip} locked out for ${retryAfter}s after ${entry.attempts} failed attempts`,
);
@@ -48,6 +55,7 @@ export function isRateLimited(ip: string): {
}
export function recordFailedAttempt(ip: string): void {
const policy = getRateLimitPolicy();
const now = Date.now();
const entry = rateLimitStore.get(ip);
@@ -59,7 +67,7 @@ export function recordFailedAttempt(ip: string): void {
return;
}
if (now - entry.firstAttempt > WINDOW_MS) {
if (now - entry.firstAttempt > policy.windowMs) {
rateLimitStore.set(ip, {
attempts: 1,
firstAttempt: now,
@@ -69,7 +77,7 @@ export function recordFailedAttempt(ip: string): void {
entry.attempts++;
logger.debug(
`[rate-limit] IP ${ip} failed attempt ${entry.attempts}/${MAX_ATTEMPTS}`,
`[rate-limit] IP ${ip} failed attempt ${entry.attempts}/${policy.maxAttempts}`,
);
}
@@ -78,11 +86,12 @@ export function recordSuccessfulAttempt(ip: string): void {
}
function cleanupExpired(): void {
const policy = getRateLimitPolicy();
const now = Date.now();
let cleaned = 0;
for (const [ip, entry] of rateLimitStore.entries()) {
const windowExpired = now - entry.firstAttempt > WINDOW_MS;
const windowExpired = now - entry.firstAttempt > policy.windowMs;
const lockoutExpired = entry.lockedUntil && entry.lockedUntil < now;
if ((windowExpired && !entry.lockedUntil) || lockoutExpired) {
@@ -114,6 +123,7 @@ export function getRateLimitStats(): { total: number; locked: number } {
setInterval(cleanupExpired, CLEANUP_INTERVAL);
const initialPolicy = getRateLimitPolicy();
logger.info(
`[rate-limit] Initialized: ${MAX_ATTEMPTS} attempts per ${WINDOW_MS / 60000} minutes, ${LOCKOUT_MS / 60000} minute lockout`,
`[rate-limit] Initialized: ${initialPolicy.maxAttempts} attempts per ${Math.round(initialPolicy.windowMs / 60000)} minutes, ${Math.round(initialPolicy.lockoutMs / 60000)} minute lockout`,
);
@@ -0,0 +1,60 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { getSessionTtlHours, getSessionTtlSeconds } from "./auth";
import { getConfig, updateSecurityConfig, type Config } from "./config";
import { isRateLimited, recordFailedAttempt, recordSuccessfulAttempt } from "./rateLimit";
let originalSecurity: Config["security"];
beforeAll(() => {
originalSecurity = { ...getConfig().security };
});
afterAll(async () => {
await updateSecurityConfig({ ...originalSecurity });
});
describe("security settings", () => {
test("session TTL helpers reflect configured policy", async () => {
await updateSecurityConfig({ sessionTtlHours: 3 });
expect(getSessionTtlHours()).toBe(3);
expect(getSessionTtlSeconds()).toBe(3 * 60 * 60);
});
test("rate limiting honors configured max attempts", async () => {
await updateSecurityConfig({
loginMaxAttempts: 2,
loginWindowMinutes: 5,
loginLockoutMinutes: 5,
});
const ip = `10.9.0.${Date.now()}`;
recordFailedAttempt(ip);
expect(isRateLimited(ip).limited).toBe(false);
recordFailedAttempt(ip);
const limited = isRateLimited(ip);
expect(limited.limited).toBe(true);
expect(limited.retryAfter).toBeGreaterThan(0);
recordSuccessfulAttempt(ip);
expect(isRateLimited(ip).limited).toBe(false);
});
test("security bounds are clamped", async () => {
const updated = await updateSecurityConfig({
sessionTtlHours: 100000,
loginMaxAttempts: 0,
loginWindowMinutes: 0,
loginLockoutMinutes: 0,
passwordMinLength: 2,
});
expect(updated.sessionTtlHours).toBe(24 * 30);
expect(updated.loginMaxAttempts).toBe(1);
expect(updated.loginWindowMinutes).toBe(1);
expect(updated.loginLockoutMinutes).toBe(1);
expect(updated.passwordMinLength).toBe(6);
});
});
@@ -3,6 +3,7 @@ import {
authenticateUser,
extractTokenFromRequest,
generateToken,
getSessionTtlSeconds,
getUserFromRequest,
revokeToken,
} from "../../auth";
@@ -62,6 +63,7 @@ export async function handleAuthRoutes(
if (user) {
const token = await generateToken(user);
const sessionTtlSeconds = getSessionTtlSeconds();
logger.info(
`[auth] User ${user.username} logged in. must_change_password =`,
@@ -93,7 +95,7 @@ export async function handleAuthRoutes(
{
headers: {
"Content-Type": "application/json",
"Set-Cookie": `overlord_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800`,
"Set-Cookie": `overlord_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${sessionTtlSeconds}`,
},
},
);
@@ -7,12 +7,20 @@ import {
clientExists,
deleteClientRow,
getClientIp,
isIpBanned,
listBannedIps,
listClients,
setOnlineState,
unbanIp,
} from "../../db";
import { metrics } from "../../metrics";
import { encodeMessage } from "../../protocol";
import { requirePermission } from "../../rbac";
import {
canUserAccessClient,
getUserClientAccessScope,
listUserClientRuleIdsByAccess,
} from "../../users";
type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -43,7 +51,8 @@ export async function handleClientRoutes(
}
if (url.pathname === "/api/clients") {
if (!(await authenticateRequest(req))) {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
@@ -52,10 +61,86 @@ export async function handleClientRoutes(
const sort = url.searchParams.get("sort") || "last_seen_desc";
const statusFilter = url.searchParams.get("status") || "all";
const osFilter = url.searchParams.get("os") || "all";
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter });
if (user.role === "admin") {
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter });
return Response.json(result, { headers: deps.CORS_HEADERS });
}
const scope = getUserClientAccessScope(user.userId);
if (scope === "none") {
return Response.json(
{ page, pageSize, total: 0, online: 0, items: [] },
{ headers: deps.CORS_HEADERS },
);
}
const allowedClientIds =
scope === "allowlist"
? listUserClientRuleIdsByAccess(user.userId, "allow")
: undefined;
const deniedClientIds =
scope === "denylist"
? listUserClientRuleIdsByAccess(user.userId, "deny")
: undefined;
const result = listClients({
page,
pageSize,
search,
sort,
statusFilter,
osFilter,
allowedClientIds,
deniedClientIds,
});
return Response.json(result, { headers: deps.CORS_HEADERS });
}
if (url.pathname === "/api/clients/banned-ips") {
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 });
}
if (req.method === "GET") {
return Response.json({ items: listBannedIps() }, { headers: deps.CORS_HEADERS });
}
if (req.method === "DELETE") {
const ipToUnban = (url.searchParams.get("ip") || "").trim();
if (!ipToUnban) {
return Response.json({ error: "Missing ip query parameter" }, { status: 400 });
}
if (!/^[0-9a-fA-F:.]{3,64}$/.test(ipToUnban)) {
return Response.json({ error: "Invalid IP format" }, { status: 400 });
}
if (!isIpBanned(ipToUnban)) {
return Response.json({ error: "IP is not banned" }, { status: 404 });
}
unbanIp(ipToUnban);
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.COMMAND,
details: `Unbanned IP ${ipToUnban}`,
success: true,
});
return Response.json({ ok: true }, { headers: deps.CORS_HEADERS });
}
}
const banMatch = url.pathname.match(/^\/api\/clients\/(.+)\/ban$/);
if (req.method === "POST" && banMatch) {
const user = await authenticateRequest(req);
@@ -68,6 +153,9 @@ export async function handleClientRoutes(
}
const targetId = banMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const target = clientManager.getClient(targetId);
const targetIp = target?.ip || getClientIp(targetId);
if (!targetIp) {
@@ -99,10 +187,14 @@ export async function handleClientRoutes(
const thumbnailMatch = url.pathname.match(/^\/api\/clients\/(.+)\/thumbnail$/);
if (req.method === "POST" && thumbnailMatch) {
if (!(await authenticateRequest(req))) {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const clientId = thumbnailMatch[1];
if (!canUserAccessClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const { generateThumbnail, markThumbnailRequested } = await import("../../thumbnails");
markThumbnailRequested(clientId);
const target = clientManager.getClient(clientId);
@@ -135,6 +227,9 @@ export async function handleClientRoutes(
}
const targetId = clientDeleteMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const target = clientManager.getClient(targetId);
const existsInDb = clientExists(targetId);
if (!target && !existsInDb) {
@@ -181,6 +276,9 @@ export async function handleClientRoutes(
}
const targetId = cmdMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const target = clientManager.getClient(targetId);
const ip = server.requestIP(req)?.address || "unknown";
@@ -1,6 +1,7 @@
import { authenticateRequest } from "../../auth";
import * as clientManager from "../../clientManager";
import { getAuditLogs } from "../../auditLog";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
import { getConfig, updateSecurityConfig } from "../../config";
import { listClients } from "../../db";
import { logger } from "../../logger";
import { metrics } from "../../metrics";
@@ -9,6 +10,7 @@ import { requirePermission } from "../../rbac";
type MiscRouteDeps = {
CORS_HEADERS: Record<string, string>;
SERVER_VERSION: string;
requestIP?: (req: Request) => { address?: string } | null | undefined;
getConsoleSessionCount: () => number;
getRdSessionCount: () => number;
getFileBrowserSessionCount: () => number;
@@ -93,6 +95,53 @@ export async function handleMiscRoutes(
});
}
if (url.pathname === "/api/settings/security") {
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 });
}
if (req.method === "GET") {
return Response.json({ security: getConfig().security }, { headers: deps.CORS_HEADERS });
}
if (req.method === "PUT") {
let body: any = {};
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const updated = await updateSecurityConfig({
sessionTtlHours: Number(body?.sessionTtlHours),
loginMaxAttempts: Number(body?.loginMaxAttempts),
loginWindowMinutes: Number(body?.loginWindowMinutes),
loginLockoutMinutes: Number(body?.loginLockoutMinutes),
passwordMinLength: Number(body?.passwordMinLength),
passwordRequireUppercase: Boolean(body?.passwordRequireUppercase),
passwordRequireLowercase: Boolean(body?.passwordRequireLowercase),
passwordRequireNumber: Boolean(body?.passwordRequireNumber),
passwordRequireSymbol: Boolean(body?.passwordRequireSymbol),
});
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: "Updated security settings",
success: true,
});
return Response.json({ ok: true, security: updated }, { headers: deps.CORS_HEADERS });
}
}
if (req.method === "GET" && url.pathname === "/api/audit-logs") {
const user = await authenticateRequest(req);
if (!user) {
@@ -1,6 +1,6 @@
import { authenticateRequest } from "../../auth";
import { requirePermission } from "../../rbac";
import { getUserById } from "../../users";
import { canUserAccessClient, getUserById, type UserRole } from "../../users";
type PageRouteDeps = {
PUBLIC_ROOT: string;
@@ -37,6 +37,11 @@ export async function handlePageRoutes(
url: URL,
deps: PageRouteDeps,
): Promise<Response | null> {
const canAccessClientPage = (userId: number, role: UserRole, clientId: string): boolean => {
if (!clientId) return false;
return canUserAccessClient(userId, role, clientId);
};
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
const authed = await authenticateRequest(req);
@@ -71,6 +76,12 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = (url.searchParams.get("clientId") || "").trim();
if (!canAccessClientPage(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/remotedesktop.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("remotedesktop.html")) });
@@ -89,6 +100,12 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = (url.searchParams.get("clientId") || "").trim();
if (!canAccessClientPage(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/hvnc.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("hvnc.html")) });
@@ -168,6 +185,42 @@ export async function handlePageRoutes(
}
}
if (req.method === "GET" && url.pathname === "/user-client-access") {
const user = await authenticateRequest(req);
if (!user) {
return serveLoginOrUnauthorized(deps);
}
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
if (maybeChange) return maybeChange;
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/user-client-access.html`);
if (await file.exists()) {
return new Response(file, {
headers: deps.secureHeaders(deps.mimeType("user-client-access.html")),
});
}
}
if (req.method === "GET" && url.pathname === "/settings") {
const user = await authenticateRequest(req);
if (!user) {
return serveLoginOrUnauthorized(deps);
}
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
if (maybeChange) return maybeChange;
const file = Bun.file(`${deps.PUBLIC_ROOT}/settings.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("settings.html")) });
}
}
if (req.method === "GET" && url.pathname === "/build") {
const user = await authenticateRequest(req);
if (!user) {
@@ -210,6 +263,9 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
if (!canAccessClientPage(user.userId, user.role, consolePageMatch[1])) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/console.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("console.html")) });
@@ -226,6 +282,9 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
if (!canAccessClientPage(user.userId, user.role, filesPageMatch[1])) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/filebrowser.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("filebrowser.html")) });
@@ -242,6 +301,9 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
if (!canAccessClientPage(user.userId, user.role, processesPageMatch[1])) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/processes.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("processes.html")) });
@@ -258,6 +320,9 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
if (!canAccessClientPage(user.userId, user.role, keyloggerPageMatch[1])) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/keylogger.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("keylogger.html")) });
@@ -274,6 +339,9 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
if (!canAccessClientPage(user.userId, user.role, proxyPageMatch[1])) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/proxy.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("proxy.html")) });
@@ -1,12 +1,19 @@
import { generateToken, authenticateRequest } from "../../auth";
import { generateToken, authenticateRequest, getSessionTtlSeconds } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { requirePermission } from "../../rbac";
import {
type ClientAccessRuleKind,
type ClientAccessScope,
createUser,
deleteUser,
getUserById,
getUserClientAccessScope,
listUsers,
listUserClientAccessRules,
removeUserClientAccessRule,
setUserClientAccessRule,
setUserClientAccessScope,
updateUserPassword,
updateUserRole,
} from "../../users";
@@ -114,6 +121,7 @@ export async function handleUsersRoutes(
if (user.userId === userId && targetUser) {
const newToken = await generateToken(targetUser);
const sessionTtlSeconds = getSessionTtlSeconds();
return new Response(
JSON.stringify({
success: true,
@@ -122,7 +130,7 @@ export async function handleUsersRoutes(
{
headers: {
"Content-Type": "application/json",
"Set-Cookie": `overlord_token=${newToken}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800`,
"Set-Cookie": `overlord_token=${newToken}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${sessionTtlSeconds}`,
},
},
);
@@ -166,6 +174,118 @@ export async function handleUsersRoutes(
return Response.json({ error: result.error }, { status: 400 });
}
if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/client-access$/)) {
const authedUser = requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
const scope = getUserClientAccessScope(userId);
const rules = listUserClientAccessRules(userId);
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: authedUser.username,
ip,
action: AuditAction.COMMAND,
details: `Viewed client access policy for user: ${targetUser.username}`,
success: true,
});
return Response.json({ scope, rules });
}
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/client-access$/)) {
const authedUser = requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
const body = await req.json();
const scope = body?.scope as ClientAccessScope;
const result = setUserClientAccessScope(userId, scope);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: authedUser.username,
ip,
action: AuditAction.COMMAND,
details: `Updated client access scope for ${targetUser.username} to ${scope}`,
success: true,
});
return Response.json({ success: true, scope });
}
if (req.method === "POST" && url.pathname.match(/^\/api\/users\/\d+\/client-access\/rules$/)) {
const authedUser = requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
const body = await req.json();
const clientId = String(body?.clientId || "").trim();
const access = body?.access as ClientAccessRuleKind;
const result = setUserClientAccessRule(userId, clientId, access);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: authedUser.username,
ip,
action: AuditAction.COMMAND,
details: `Set client access rule for ${targetUser.username}: ${access} ${clientId}`,
success: true,
});
return Response.json({ success: true });
}
if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+\/client-access\/rules$/)) {
const authedUser = requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
const clientId = String(url.searchParams.get("clientId") || "").trim();
if (!clientId) {
return Response.json({ error: "clientId is required" }, { status: 400 });
}
const result = removeUserClientAccessRule(userId, clientId);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: authedUser.username,
ip,
action: AuditAction.COMMAND,
details: `Removed client access rule for ${targetUser.username}: ${clientId}`,
success: true,
});
return Response.json({ success: true });
}
if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+$/)) {
const authedUser = requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
+2
View File
@@ -28,6 +28,8 @@ export type ListFilters = {
sort: string;
statusFilter?: string;
osFilter?: string;
allowedClientIds?: string[];
deniedClientIds?: string[];
};
export type ListItem = ClientInfo & {
@@ -0,0 +1,107 @@
import { describe, expect, test } from "bun:test";
import {
canUserAccessClient,
canViewClients,
createUser,
deleteUser,
getUserByUsername,
getUserClientAccessScope,
listUserClientAccessRules,
setUserClientAccessRule,
setUserClientAccessScope,
updateUserRole,
} from "./users";
const createdUserIds: number[] = [];
async function createTempUser(role: "viewer" | "operator" | "admin") {
const username = `r_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`;
const result = await createUser(username, "Aa1!VeryLongTestPassword_2026", role, "test");
expect(result.success).toBe(true);
const user = getUserByUsername(username);
expect(user).not.toBeNull();
createdUserIds.push(user!.id);
return user!;
}
function cleanupCreatedUsers() {
while (createdUserIds.length > 0) {
const id = createdUserIds.pop();
if (typeof id === "number") {
deleteUser(id);
}
}
}
describe("users client access RBAC", () => {
test("clients:view permission is admin-only by default", () => {
expect(canViewClients("admin")).toBe(true);
expect(canViewClients("operator")).toBe(false);
expect(canViewClients("viewer")).toBe(false);
});
test("new non-admin users default to no client visibility", async () => {
try {
const viewer = await createTempUser("viewer");
expect(getUserClientAccessScope(viewer.id)).toBe("none");
expect(canUserAccessClient(viewer.id, viewer.role, "client-a")).toBe(false);
} finally {
cleanupCreatedUsers();
}
});
test("allowlist scope only allows explicitly approved clients", async () => {
try {
const operator = await createTempUser("operator");
const scopeResult = setUserClientAccessScope(operator.id, "allowlist");
expect(scopeResult.success).toBe(true);
const ruleResult = setUserClientAccessRule(operator.id, "client-allowed", "allow");
expect(ruleResult.success).toBe(true);
const rules = listUserClientAccessRules(operator.id);
expect(rules.some((rule) => rule.clientId === "client-allowed" && rule.access === "allow")).toBe(true);
expect(canUserAccessClient(operator.id, operator.role, "client-allowed")).toBe(true);
expect(canUserAccessClient(operator.id, operator.role, "client-other")).toBe(false);
} finally {
cleanupCreatedUsers();
}
});
test("denylist scope blocks denied clients and allows others", async () => {
try {
const operator = await createTempUser("operator");
const scopeResult = setUserClientAccessScope(operator.id, "denylist");
expect(scopeResult.success).toBe(true);
const ruleResult = setUserClientAccessRule(operator.id, "client-denied", "deny");
expect(ruleResult.success).toBe(true);
expect(canUserAccessClient(operator.id, operator.role, "client-denied")).toBe(false);
expect(canUserAccessClient(operator.id, operator.role, "client-safe")).toBe(true);
} finally {
cleanupCreatedUsers();
}
});
test("role changes reset scope between admin and non-admin", async () => {
try {
const operator = await createTempUser("operator");
expect(getUserClientAccessScope(operator.id)).toBe("none");
const toAdmin = updateUserRole(operator.id, "admin");
expect(toAdmin.success).toBe(true);
expect(getUserClientAccessScope(operator.id)).toBe("all");
const backToOperator = updateUserRole(operator.id, "operator");
expect(backToOperator.success).toBe(true);
expect(getUserClientAccessScope(operator.id)).toBe("none");
} finally {
cleanupCreatedUsers();
}
});
});
+196 -12
View File
@@ -9,6 +9,8 @@ const dbPath = resolve(dataDir, "overlord.db");
const db = new Database(dbPath);
export type UserRole = "admin" | "operator" | "viewer";
export type ClientAccessScope = "none" | "allowlist" | "denylist" | "all";
export type ClientAccessRuleKind = "allow" | "deny";
export interface User {
id: number;
@@ -19,6 +21,7 @@ export interface User {
last_login: number | null;
created_by: string | null;
must_change_password: number;
client_scope: ClientAccessScope;
}
export interface UserInfo {
@@ -28,6 +31,13 @@ export interface UserInfo {
created_at: number;
last_login: number | null;
created_by: string | null;
client_scope: ClientAccessScope;
}
export interface UserClientAccessRule {
userId: number;
clientId: string;
access: ClientAccessRuleKind;
}
db.exec(`
@@ -39,10 +49,26 @@ db.exec(`
created_at INTEGER NOT NULL,
last_login INTEGER,
created_by TEXT,
must_change_password INTEGER DEFAULT 0
must_change_password INTEGER DEFAULT 0,
client_scope TEXT NOT NULL DEFAULT 'none' CHECK(client_scope IN ('none', 'allowlist', 'denylist', 'all'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_client_access_rules (
user_id INTEGER NOT NULL,
client_id TEXT NOT NULL,
access TEXT NOT NULL CHECK(access IN ('allow', 'deny')),
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, client_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_user_client_access_rules_user ON user_client_access_rules(user_id)`,
);
try {
db.exec(
`ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0`,
@@ -54,6 +80,23 @@ try {
}
}
try {
db.exec(
`ALTER TABLE users ADD COLUMN client_scope TEXT NOT NULL DEFAULT 'none' CHECK(client_scope IN ('none', 'allowlist', 'denylist', 'all'))`,
);
logger.info("[users] Added client_scope column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
try {
db.exec(`UPDATE users SET client_scope='all' WHERE role='admin'`);
} catch (err: any) {
logger.error("[users] Failed to normalize admin client_scope:", err);
}
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as {
count: number;
};
@@ -69,8 +112,8 @@ if (userCount.count === 0) {
});
db.prepare(
"INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password) VALUES (?, ?, ?, ?, ?, ?)",
).run(initialUsername, defaultPassword, "admin", Date.now(), "system", 1);
"INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(initialUsername, defaultPassword, "admin", Date.now(), "system", 1, "all");
const createdUser = db
.prepare("SELECT * FROM users WHERE username = ?")
@@ -106,12 +149,146 @@ export function getUserByUsername(username: string): User | null {
export function listUsers(): UserInfo[] {
const users = db
.prepare(
"SELECT id, username, role, created_at, last_login, created_by FROM users ORDER BY created_at DESC",
"SELECT id, username, role, created_at, last_login, created_by, client_scope FROM users ORDER BY created_at DESC",
)
.all() as UserInfo[];
return users;
}
export function getUserClientAccessScope(userId: number): ClientAccessScope {
const row = db
.prepare("SELECT client_scope FROM users WHERE id = ?")
.get(userId) as { client_scope?: ClientAccessScope } | undefined;
return row?.client_scope || "none";
}
export function listUserClientAccessRules(userId: number): UserClientAccessRule[] {
return db
.prepare(
"SELECT user_id as userId, client_id as clientId, access FROM user_client_access_rules WHERE user_id = ? ORDER BY client_id ASC",
)
.all(userId) as UserClientAccessRule[];
}
export function listUserClientRuleIdsByAccess(
userId: number,
access: ClientAccessRuleKind,
): string[] {
return db
.prepare(
"SELECT client_id as clientId FROM user_client_access_rules WHERE user_id = ? AND access = ? ORDER BY client_id ASC",
)
.all(userId, access)
.map((row: { clientId: string }) => row.clientId);
}
export function setUserClientAccessScope(
userId: number,
scope: ClientAccessScope,
): { success: boolean; error?: string } {
if (!["none", "allowlist", "denylist", "all"].includes(scope)) {
return { success: false, error: "Invalid client access scope" };
}
try {
db.prepare("UPDATE users SET client_scope = ? WHERE id = ?").run(scope, userId);
return { success: true };
} catch (err: any) {
logger.error("[users] setUserClientAccessScope error:", err);
return { success: false, error: err.message || "Failed to update client access scope" };
}
}
export function setUserClientAccessRule(
userId: number,
clientId: string,
access: ClientAccessRuleKind,
): { success: boolean; error?: string } {
const normalizedClientId = (clientId || "").trim();
if (!normalizedClientId) {
return { success: false, error: "clientId is required" };
}
if (!["allow", "deny"].includes(access)) {
return { success: false, error: "Invalid client access rule" };
}
try {
db.prepare(
"INSERT OR REPLACE INTO user_client_access_rules (user_id, client_id, access, created_at) VALUES (?, ?, ?, ?)",
).run(userId, normalizedClientId, access, Date.now());
return { success: true };
} catch (err: any) {
logger.error("[users] setUserClientAccessRule error:", err);
return { success: false, error: err.message || "Failed to update client access rule" };
}
}
export function removeUserClientAccessRule(
userId: number,
clientId: string,
): { success: boolean; error?: string } {
try {
db.prepare("DELETE FROM user_client_access_rules WHERE user_id = ? AND client_id = ?").run(
userId,
clientId,
);
return { success: true };
} catch (err: any) {
logger.error("[users] removeUserClientAccessRule error:", err);
return { success: false, error: err.message || "Failed to remove client access rule" };
}
}
export function canUserAccessClient(
userId: number,
role: UserRole,
clientId: string,
): boolean {
if (role === "admin") return true;
const scope = getUserClientAccessScope(userId);
if (scope === "none") return false;
if (scope === "all") return true;
const row = db
.prepare(
"SELECT access FROM user_client_access_rules WHERE user_id = ? AND client_id = ?",
)
.get(userId, clientId) as { access?: ClientAccessRuleKind } | undefined;
if (scope === "allowlist") {
return row?.access === "allow";
}
if (scope === "denylist") {
return row?.access !== "deny";
}
return false;
}
function validatePasswordPolicy(password: string): string | null {
const security = getConfig().security;
const minLength = Math.min(128, Math.max(6, Number(security.passwordMinLength) || 6));
if (!password || password.length < minLength) {
return `Password must be at least ${minLength} characters`;
}
if (security.passwordRequireUppercase && !/[A-Z]/.test(password)) {
return "Password must include at least one uppercase letter";
}
if (security.passwordRequireLowercase && !/[a-z]/.test(password)) {
return "Password must include at least one lowercase letter";
}
if (security.passwordRequireNumber && !/[0-9]/.test(password)) {
return "Password must include at least one number";
}
if (security.passwordRequireSymbol && !/[^A-Za-z0-9]/.test(password)) {
return "Password must include at least one symbol";
}
return null;
}
export async function createUser(
username: string,
password: string,
@@ -133,8 +310,9 @@ export async function createUser(
};
}
if (!password || password.length < 6) {
return { success: false, error: "Password must be at least 6 characters" };
const policyError = validatePasswordPolicy(password);
if (policyError) {
return { success: false, error: policyError };
}
const existing = getUserByUsername(username);
@@ -150,9 +328,9 @@ export async function createUser(
const result = db
.prepare(
"INSERT INTO users (username, password_hash, role, created_at, created_by) VALUES (?, ?, ?, ?, ?)",
"INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(username, passwordHash, role, Date.now(), createdBy);
.run(username, passwordHash, role, Date.now(), createdBy, role === "admin" ? "all" : "none");
return { success: true, userId: result.lastInsertRowid as number };
} catch (err: any) {
@@ -165,8 +343,9 @@ export async function updateUserPassword(
userId: number,
newPassword: string,
): Promise<{ success: boolean; error?: string }> {
if (!newPassword || newPassword.length < 6) {
return { success: false, error: "Password must be at least 6 characters" };
const policyError = validatePasswordPolicy(newPassword);
if (policyError) {
return { success: false, error: policyError };
}
try {
@@ -193,7 +372,12 @@ export function updateUserRole(
newRole: UserRole,
): { success: boolean; error?: string } {
try {
db.prepare("UPDATE users SET role = ? WHERE id = ?").run(newRole, userId);
const nextScope: ClientAccessScope = newRole === "admin" ? "all" : "none";
db.prepare("UPDATE users SET role = ?, client_scope = ? WHERE id = ?").run(
newRole,
nextScope,
userId,
);
return { success: true };
} catch (err: any) {
console.error("[users] Update role error:", err);
@@ -253,7 +437,7 @@ export function canControlClients(role: UserRole): boolean {
}
export function canViewClients(role: UserRole): boolean {
return true;
return role === "admin";
}
export function canViewAuditLogs(role: UserRole): boolean {
+1 -1
View File
@@ -1 +1 @@
export const SERVER_VERSION = "1.1.0";
export const SERVER_VERSION = "1.2.1";