new notification system
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
const DISMISS_KEY = "overlord_cert_banner_dismissed";
|
||||||
|
|
||||||
|
|
||||||
|
export async function showCertBannerIfNeeded(anchor) {
|
||||||
|
if (sessionStorage.getItem(DISMISS_KEY) === "1") return;
|
||||||
|
if (localStorage.getItem(DISMISS_KEY) === "1") return;
|
||||||
|
|
||||||
|
let source;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/cert/info");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
source = data.source;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source !== "self-signed") return;
|
||||||
|
|
||||||
|
if ("serviceWorker" in navigator && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.getRegistration("/");
|
||||||
|
if (reg && reg.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
const os = detectOS();
|
||||||
|
|
||||||
|
const banner = document.createElement("div");
|
||||||
|
banner.id = "cert-trust-banner";
|
||||||
|
banner.className =
|
||||||
|
"mx-4 mt-3 mb-1 flex items-start gap-3 rounded-lg border border-amber-500/30 " +
|
||||||
|
"bg-amber-950/60 px-4 py-3 text-sm text-amber-200 shadow-lg";
|
||||||
|
|
||||||
|
banner.innerHTML = `
|
||||||
|
<span class="mt-0.5 text-lg select-none">⚠️</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-semibold text-amber-100 mb-1">Untrusted self-signed certificate</p>
|
||||||
|
<p class="mb-2 text-amber-200/80 leading-relaxed">
|
||||||
|
This server is using a self-signed TLS certificate. Your browser doesn't
|
||||||
|
trust it, which may block some features (desktop notifications, service workers).
|
||||||
|
Download the certificate and add it to your OS / browser trust store to fix this.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<a href="/api/cert/download"
|
||||||
|
download="overlord-ca.crt"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded bg-amber-600 hover:bg-amber-500
|
||||||
|
px-3 py-1 text-xs font-medium text-white transition-colors">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V3"/></svg>
|
||||||
|
Download Certificate
|
||||||
|
</a>
|
||||||
|
<button id="cert-banner-how" type="button"
|
||||||
|
class="text-xs text-amber-300 underline underline-offset-2 hover:text-amber-100 transition-colors">
|
||||||
|
How to install (${os.label})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="cert-banner-instructions" class="hidden mt-2 rounded bg-slate-900/80
|
||||||
|
border border-slate-700/50 p-3 text-xs text-slate-300 leading-relaxed whitespace-pre-line">${os.instructions}</div>
|
||||||
|
</div>
|
||||||
|
<button id="cert-banner-dismiss" type="button"
|
||||||
|
class="ml-2 mt-0.5 text-amber-400 hover:text-amber-200 transition-colors"
|
||||||
|
aria-label="Dismiss">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
anchor.insertAdjacentElement("afterend", banner);
|
||||||
|
|
||||||
|
document.getElementById("cert-banner-dismiss")?.addEventListener("click", () => {
|
||||||
|
banner.remove();
|
||||||
|
localStorage.setItem(DISMISS_KEY, "1");
|
||||||
|
sessionStorage.setItem(DISMISS_KEY, "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("cert-banner-how")?.addEventListener("click", () => {
|
||||||
|
const el = document.getElementById("cert-banner-instructions");
|
||||||
|
if (el) el.classList.toggle("hidden");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectOS() {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
if (ua.includes("win")) {
|
||||||
|
return {
|
||||||
|
label: "Windows",
|
||||||
|
instructions:
|
||||||
|
`1. Double-click the downloaded <strong>overlord-ca.crt</strong> file.
|
||||||
|
2. Click <strong>Install Certificate...</strong>
|
||||||
|
3. Choose <strong>Current User</strong> or <strong>Local Machine</strong>.
|
||||||
|
4. Select <strong>"Place all certificates in the following store"</strong>.
|
||||||
|
5. Click <strong>Browse</strong> → choose <strong>Trusted Root Certification Authorities</strong>.
|
||||||
|
6. Click <strong>Next</strong> → <strong>Finish</strong>.
|
||||||
|
7. <strong>Restart your browser</strong> for the change to take effect.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ua.includes("mac")) {
|
||||||
|
return {
|
||||||
|
label: "macOS",
|
||||||
|
instructions:
|
||||||
|
`1. Double-click the downloaded <strong>overlord-ca.crt</strong> file — it opens in Keychain Access.
|
||||||
|
2. If prompted, add it to the <strong>login</strong> or <strong>System</strong> keychain.
|
||||||
|
3. Find the certificate in the list, double-click it.
|
||||||
|
4. Expand <strong>Trust</strong> and set <strong>"When using this certificate"</strong> to <strong>Always Trust</strong>.
|
||||||
|
5. Close the dialog (you'll be prompted for your password).
|
||||||
|
6. <strong>Restart your browser</strong>.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: "Linux",
|
||||||
|
instructions:
|
||||||
|
`<strong>Debian / Ubuntu:</strong>
|
||||||
|
sudo cp overlord-ca.crt /usr/local/share/ca-certificates/overlord-ca.crt
|
||||||
|
sudo update-ca-certificates
|
||||||
|
|
||||||
|
<strong>Fedora / RHEL:</strong>
|
||||||
|
sudo cp overlord-ca.crt /etc/pki/ca-trust/source/anchors/overlord-ca.crt
|
||||||
|
sudo update-ca-trust
|
||||||
|
|
||||||
|
Then <strong>restart your browser</strong>.
|
||||||
|
For Chrome/Chromium you may also need: Settings → Privacy → Manage Certificates → Authorities → Import.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,10 +8,12 @@ import {
|
|||||||
import { mountNav } from "./nav/template.js";
|
import { mountNav } from "./nav/template.js";
|
||||||
import { createAdaptiveNavController } from "./nav/layout.js";
|
import { createAdaptiveNavController } from "./nav/layout.js";
|
||||||
import { applyUserRoleUI } from "./nav/role-ui.js";
|
import { applyUserRoleUI } from "./nav/role-ui.js";
|
||||||
|
import { showCertBannerIfNeeded } from "./cert-banner.js";
|
||||||
|
|
||||||
const host = document.getElementById("top-nav");
|
const host = document.getElementById("top-nav");
|
||||||
if (host) {
|
if (host) {
|
||||||
const refs = mountNav(host);
|
const refs = mountNav(host);
|
||||||
|
showCertBannerIfNeeded(host);
|
||||||
const { applyAdaptiveNavLayout } = createAdaptiveNavController(host, refs);
|
const { applyAdaptiveNavLayout } = createAdaptiveNavController(host, refs);
|
||||||
|
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
self.addEventListener("install", () => self.skipWaiting());
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
const targetUrl = (event.notification.data && event.notification.data.url)
|
||||||
|
? event.notification.data.url
|
||||||
|
: "/notifications";
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients
|
||||||
|
.matchAll({ type: "window", includeUncontrolled: true })
|
||||||
|
.then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
try {
|
||||||
|
const clientUrl = new URL(client.url);
|
||||||
|
if (clientUrl.origin === self.location.origin) {
|
||||||
|
if (clientUrl.pathname !== "/notifications") {
|
||||||
|
client.navigate(targetUrl);
|
||||||
|
}
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(targetUrl);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("message", (event) => {
|
||||||
|
const data = event.data;
|
||||||
|
if (!data || data.type !== "show_notification") return;
|
||||||
|
|
||||||
|
const title = String(data.title || "Overlord Notification");
|
||||||
|
const options = {
|
||||||
|
body: String(data.body || ""),
|
||||||
|
icon: data.icon || "/assets/overlord.png",
|
||||||
|
badge: "/assets/overlord.png",
|
||||||
|
tag: data.tag || `overlord-${Date.now()}`,
|
||||||
|
data: { url: "/notifications" },
|
||||||
|
requireInteraction: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
|
});
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
startNotificationClient,
|
startNotificationClient,
|
||||||
subscribeNotifications,
|
subscribeNotifications,
|
||||||
|
subscribeClientEvents,
|
||||||
subscribeReady,
|
subscribeReady,
|
||||||
subscribeStatus,
|
subscribeStatus,
|
||||||
markAllNotificationsRead,
|
markAllNotificationsRead,
|
||||||
|
getClientEventNotificationEnabled,
|
||||||
|
setClientEventNotificationEnabled,
|
||||||
} from "./notify-client.js";
|
} from "./notify-client.js";
|
||||||
|
|
||||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||||
@@ -42,6 +45,11 @@ const resetTelegramTemplateBtn = document.getElementById("reset-telegram-templat
|
|||||||
const previewTelegramTemplateBtn = document.getElementById("preview-telegram-template");
|
const previewTelegramTemplateBtn = document.getElementById("preview-telegram-template");
|
||||||
const telegramTemplatePreview = document.getElementById("telegram-template-preview");
|
const telegramTemplatePreview = document.getElementById("telegram-template-preview");
|
||||||
|
|
||||||
|
const eventNotifOnlineInput = document.getElementById("event-notif-online");
|
||||||
|
const eventNotifOfflineInput = document.getElementById("event-notif-offline");
|
||||||
|
const eventNotifPurgatoryInput = document.getElementById("event-notif-purgatory");
|
||||||
|
const saveEventNotifsBtn = document.getElementById("save-event-notifs");
|
||||||
|
|
||||||
// Preview modal
|
// Preview modal
|
||||||
const panel = document.getElementById("notification-panel");
|
const panel = document.getElementById("notification-panel");
|
||||||
const previewModal = document.getElementById("notification-preview-modal");
|
const previewModal = document.getElementById("notification-preview-modal");
|
||||||
@@ -541,6 +549,35 @@ function connect() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
subscribeNotifications((item) => renderRow(item, true));
|
subscribeNotifications((item) => renderRow(item, true));
|
||||||
|
subscribeClientEvents((item) => renderClientEventRow(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLIENT_EVENT_BADGE = {
|
||||||
|
client_online: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-emerald-900/60 text-emerald-300 border border-emerald-700/50"><i class="fa-solid fa-circle text-xs"></i> online</span>`,
|
||||||
|
client_offline: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-rose-900/60 text-rose-300 border border-rose-700/50"><i class="fa-solid fa-circle text-xs"></i> offline</span>`,
|
||||||
|
client_purgatory: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-amber-900/60 text-amber-300 border border-amber-700/50"><i class="fa-solid fa-hourglass-half text-xs"></i> purgatory</span>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderClientEventRow(item) {
|
||||||
|
if (!listEl) return;
|
||||||
|
const badge = CLIENT_EVENT_BADGE[item.event] ||
|
||||||
|
`<span class="text-xs text-slate-400">${escapeHtml(item.event)}</span>`;
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
row.className = "border-t border-slate-800/60";
|
||||||
|
row.innerHTML = `
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap text-slate-400">${formatTime(item.ts)}</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.clientId || "-")}</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.user || "-")}</td>
|
||||||
|
<td class="py-2 pr-4 max-w-xl truncate italic text-slate-400">client event</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.os || "-")}</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap">-</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap">${badge}</td>
|
||||||
|
<td class="py-2 pr-4"></td>
|
||||||
|
`;
|
||||||
|
listEl.prepend(row);
|
||||||
|
const rows = listEl.querySelectorAll("tr");
|
||||||
|
if (rows.length > MAX_ROWS) rows[rows.length - 1].remove();
|
||||||
|
if (emptyState) emptyState.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Preview modal ─────────────────────────────────────────────────────────────
|
// ── Preview modal ─────────────────────────────────────────────────────────────
|
||||||
@@ -564,11 +601,30 @@ document.addEventListener("visibilitychange", clearIfActive);
|
|||||||
window.addEventListener("focus", clearIfActive);
|
window.addEventListener("focus", clearIfActive);
|
||||||
clearIfActive();
|
clearIfActive();
|
||||||
|
|
||||||
|
// ── Event notification toggles ───────────────────────────────────────────────
|
||||||
|
function loadEventNotifPrefs() {
|
||||||
|
if (eventNotifOnlineInput) eventNotifOnlineInput.checked = getClientEventNotificationEnabled("client_online");
|
||||||
|
if (eventNotifOfflineInput) eventNotifOfflineInput.checked = getClientEventNotificationEnabled("client_offline");
|
||||||
|
if (eventNotifPurgatoryInput) eventNotifPurgatoryInput.checked = getClientEventNotificationEnabled("client_purgatory");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireEventNotifSave() {
|
||||||
|
if (!saveEventNotifsBtn) return;
|
||||||
|
saveEventNotifsBtn.addEventListener("click", () => {
|
||||||
|
if (eventNotifOnlineInput) setClientEventNotificationEnabled("client_online", eventNotifOnlineInput.checked);
|
||||||
|
if (eventNotifOfflineInput) setClientEventNotificationEnabled("client_offline", eventNotifOfflineInput.checked);
|
||||||
|
if (eventNotifPurgatoryInput) setClientEventNotificationEnabled("client_purgatory", eventNotifPurgatoryInput.checked);
|
||||||
|
window.showToast?.("Event notification preferences saved", "success", 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||||
initWebhookTemplateEditor();
|
initWebhookTemplateEditor();
|
||||||
wireKeywordSave();
|
wireKeywordSave();
|
||||||
wireWebhookSave();
|
wireWebhookSave();
|
||||||
wireTelegramSave();
|
wireTelegramSave();
|
||||||
loadMySettings();
|
loadMySettings();
|
||||||
|
loadEventNotifPrefs();
|
||||||
|
wireEventNotifSave();
|
||||||
initRoleUi();
|
initRoleUi();
|
||||||
connect();
|
connect();
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { decodeMsgpack } from "./msgpack-helpers.js";
|
|||||||
|
|
||||||
const STORAGE_KEY = "overlord_notifications_enabled";
|
const STORAGE_KEY = "overlord_notifications_enabled";
|
||||||
const UNREAD_KEY = "overlord_notifications_unread";
|
const UNREAD_KEY = "overlord_notifications_unread";
|
||||||
|
const DESKTOP_NOTIF_KEY = "overlord_desktop_notifications_enabled";
|
||||||
|
const CLIENT_EVENT_KEYS = {
|
||||||
|
client_online: "overlord_desktop_notify_client_online",
|
||||||
|
client_offline: "overlord_desktop_notify_client_offline",
|
||||||
|
client_purgatory: "overlord_desktop_notify_client_purgatory",
|
||||||
|
};
|
||||||
|
|
||||||
let enabled = localStorage.getItem(STORAGE_KEY);
|
let enabled = localStorage.getItem(STORAGE_KEY);
|
||||||
if (enabled === null) {
|
if (enabled === null) {
|
||||||
enabled = "1";
|
enabled = "1";
|
||||||
@@ -10,12 +17,21 @@ if (enabled === null) {
|
|||||||
if (localStorage.getItem(UNREAD_KEY) === null) {
|
if (localStorage.getItem(UNREAD_KEY) === null) {
|
||||||
localStorage.setItem(UNREAD_KEY, "0");
|
localStorage.setItem(UNREAD_KEY, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
localStorage.getItem(DESKTOP_NOTIF_KEY) === null &&
|
||||||
|
typeof Notification !== "undefined" &&
|
||||||
|
Notification.permission === "granted"
|
||||||
|
) {
|
||||||
|
localStorage.setItem(DESKTOP_NOTIF_KEY, "1");
|
||||||
|
}
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let started = false;
|
let started = false;
|
||||||
let readyHandlers = new Set();
|
let readyHandlers = new Set();
|
||||||
let notificationHandlers = new Set();
|
let notificationHandlers = new Set();
|
||||||
let statusHandlers = new Set();
|
let statusHandlers = new Set();
|
||||||
let unreadHandlers = new Set();
|
let unreadHandlers = new Set();
|
||||||
|
let clientEventHandlers = new Set();
|
||||||
let lastHistory = [];
|
let lastHistory = [];
|
||||||
|
|
||||||
function emitStatus(status) {
|
function emitStatus(status) {
|
||||||
@@ -43,6 +59,14 @@ function emitNotification(item) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitClientEvent(item) {
|
||||||
|
for (const handler of clientEventHandlers) {
|
||||||
|
try {
|
||||||
|
handler(item);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function shouldNotify() {
|
function shouldNotify() {
|
||||||
return localStorage.getItem(STORAGE_KEY) === "1";
|
return localStorage.getItem(STORAGE_KEY) === "1";
|
||||||
}
|
}
|
||||||
@@ -65,6 +89,125 @@ function incrementUnread() {
|
|||||||
setUnreadCount(getUnreadCount() + 1);
|
setUnreadCount(getUnreadCount() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDesktopNotificationsEnabled() {
|
||||||
|
const stored = localStorage.getItem(DESKTOP_NOTIF_KEY);
|
||||||
|
if (stored === null) {
|
||||||
|
return typeof Notification !== "undefined" && Notification.permission === "granted";
|
||||||
|
}
|
||||||
|
return stored === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDesktopNotificationsEnabled(value) {
|
||||||
|
localStorage.setItem(DESKTOP_NOTIF_KEY, value ? "1" : "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClientEventNotificationEnabled(eventType) {
|
||||||
|
const key = CLIENT_EVENT_KEYS[eventType];
|
||||||
|
if (!key) return false;
|
||||||
|
const stored = localStorage.getItem(key);
|
||||||
|
return stored === null ? true : stored === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setClientEventNotificationEnabled(eventType, value) {
|
||||||
|
const key = CLIENT_EVENT_KEYS[eventType];
|
||||||
|
if (!key) return;
|
||||||
|
localStorage.setItem(key, value ? "1" : "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestDesktopNotificationPermission() {
|
||||||
|
if (!("Notification" in window)) return "denied";
|
||||||
|
if (Notification.permission !== "default") return Notification.permission;
|
||||||
|
try {
|
||||||
|
return await Notification.requestPermission();
|
||||||
|
} catch {
|
||||||
|
return "denied";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireNotification(title, body, tag, clickUrl) {
|
||||||
|
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then((reg) =>
|
||||||
|
reg.showNotification(title, {
|
||||||
|
body,
|
||||||
|
icon: "/assets/overlord.png",
|
||||||
|
tag,
|
||||||
|
data: { url: clickUrl || "/notifications" },
|
||||||
|
requireInteraction: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => fireNotificationFallback(title, body, tag, clickUrl));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fireNotificationFallback(title, body, tag, clickUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireNotificationFallback(title, body, tag, clickUrl) {
|
||||||
|
try {
|
||||||
|
const n = new Notification(title, {
|
||||||
|
body,
|
||||||
|
icon: "/assets/overlord.png",
|
||||||
|
tag,
|
||||||
|
silent: false,
|
||||||
|
});
|
||||||
|
n.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
n.close();
|
||||||
|
if (clickUrl && window.location.pathname !== clickUrl) {
|
||||||
|
window.location.href = clickUrl;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[notifications] desktop notification failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDesktopNotification(item) {
|
||||||
|
if (!getDesktopNotificationsEnabled()) return;
|
||||||
|
if (!("Notification" in window)) return;
|
||||||
|
if (Notification.permission !== "granted") return;
|
||||||
|
|
||||||
|
const title = item.keyword
|
||||||
|
? `Overlord \u2014 ${item.keyword}`
|
||||||
|
: "Overlord \u2014 Notification";
|
||||||
|
const lines = [item.title];
|
||||||
|
if (item.user) lines.push(`User: ${item.user}`);
|
||||||
|
if (item.host) lines.push(`Host: ${item.host}`);
|
||||||
|
if (item.process) lines.push(`Process: ${item.process}`);
|
||||||
|
const body = lines.filter(Boolean).join("\n");
|
||||||
|
|
||||||
|
fireNotification(title, body, `overlord-${item.id || Date.now()}`, "/notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLIENT_EVENT_LABELS = {
|
||||||
|
client_online: "\u{1F7E2} Client Online",
|
||||||
|
client_offline: "\u{1F534} Client Offline",
|
||||||
|
client_purgatory: "\u{1F7E1} Client Awaiting Approval",
|
||||||
|
};
|
||||||
|
|
||||||
|
function showClientEventDesktopNotification(item) {
|
||||||
|
if (!getDesktopNotificationsEnabled()) return;
|
||||||
|
if (!getClientEventNotificationEnabled(item.event)) return;
|
||||||
|
if (!("Notification" in window)) return;
|
||||||
|
if (Notification.permission !== "granted") return;
|
||||||
|
|
||||||
|
const title = CLIENT_EVENT_LABELS[item.event] || "Overlord \u2014 Client Event";
|
||||||
|
const lines = [];
|
||||||
|
if (item.host) lines.push(`Host: ${item.host}`);
|
||||||
|
if (item.user) lines.push(`User: ${item.user}`);
|
||||||
|
if (item.os) lines.push(`OS: ${item.os}`);
|
||||||
|
if (item.clientId) lines.push(`ID: ${item.clientId}`);
|
||||||
|
const body = lines.join("\n") || item.clientId || "";
|
||||||
|
const dest = item.event === "client_purgatory" ? "/purgatory" : "/";
|
||||||
|
|
||||||
|
fireNotification(
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
`overlord-client-${item.event}-${item.clientId || Date.now()}`,
|
||||||
|
dest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function handleMessage(payload) {
|
function handleMessage(payload) {
|
||||||
if (!payload || typeof payload.type !== "string") return;
|
if (!payload || typeof payload.type !== "string") return;
|
||||||
if (payload.type === "ready") {
|
if (payload.type === "ready") {
|
||||||
@@ -77,6 +220,12 @@ function handleMessage(payload) {
|
|||||||
if (shouldNotify()) {
|
if (shouldNotify()) {
|
||||||
incrementUnread();
|
incrementUnread();
|
||||||
}
|
}
|
||||||
|
showDesktopNotification(payload.item);
|
||||||
|
}
|
||||||
|
if (payload.type === "client_event" && payload.event) {
|
||||||
|
console.log("[notifications] client event", payload.event, payload.clientId);
|
||||||
|
emitClientEvent(payload);
|
||||||
|
showClientEventDesktopNotification(payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +308,26 @@ export async function startNotificationClient() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[notifications] failed to load msgpackr", err);
|
console.warn("[notifications] failed to load msgpackr", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ("serviceWorker" in navigator && window.isSecureContext) {
|
||||||
|
fetch("/assets/notification-sw.js")
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) return;
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register("/assets/notification-sw.js", { scope: "/" })
|
||||||
|
.catch(() => { });
|
||||||
|
})
|
||||||
|
.catch(() => {/* SW script unreachable (e.g. self-signed cert) — skip quietly */});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getDesktopNotificationsEnabled()) {
|
||||||
|
requestDesktopNotificationPermission().then((perm) => {
|
||||||
|
if (perm !== "granted") {
|
||||||
|
setDesktopNotificationsEnabled(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +336,11 @@ export function subscribeNotifications(handler) {
|
|||||||
return () => notificationHandlers.delete(handler);
|
return () => notificationHandlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function subscribeClientEvents(handler) {
|
||||||
|
clientEventHandlers.add(handler);
|
||||||
|
return () => clientEventHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
export function subscribeUnread(handler) {
|
export function subscribeUnread(handler) {
|
||||||
unreadHandlers.add(handler);
|
unreadHandlers.add(handler);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
getNotificationsEnabled,
|
getNotificationsEnabled,
|
||||||
setNotificationsEnabled,
|
setNotificationsEnabled,
|
||||||
|
getDesktopNotificationsEnabled,
|
||||||
|
setDesktopNotificationsEnabled,
|
||||||
|
requestDesktopNotificationPermission,
|
||||||
} from "./notify-client.js";
|
} from "./notify-client.js";
|
||||||
|
|
||||||
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
|
||||||
@@ -17,6 +20,8 @@ const passwordPolicyHint = document.getElementById("password-policy-hint");
|
|||||||
|
|
||||||
const prefsForm = document.getElementById("prefs-form");
|
const prefsForm = document.getElementById("prefs-form");
|
||||||
const prefNotificationsInput = document.getElementById("pref-notifications");
|
const prefNotificationsInput = document.getElementById("pref-notifications");
|
||||||
|
const prefDesktopNotificationsInput = document.getElementById("pref-desktop-notifications");
|
||||||
|
const prefDesktopNotificationsHint = document.getElementById("pref-desktop-notifications-hint");
|
||||||
const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds");
|
const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds");
|
||||||
|
|
||||||
const myTelegramChatIdInput = document.getElementById("my-telegram-chat-id");
|
const myTelegramChatIdInput = document.getElementById("my-telegram-chat-id");
|
||||||
@@ -259,6 +264,9 @@ async function loadTlsSettings() {
|
|||||||
|
|
||||||
function loadPrefs() {
|
function loadPrefs() {
|
||||||
prefNotificationsInput.checked = getNotificationsEnabled();
|
prefNotificationsInput.checked = getNotificationsEnabled();
|
||||||
|
if (prefDesktopNotificationsInput) {
|
||||||
|
prefDesktopNotificationsInput.checked = getDesktopNotificationsEnabled();
|
||||||
|
}
|
||||||
const refreshSeconds = Number(localStorage.getItem(PREF_REFRESH_KEY) || 8);
|
const refreshSeconds = Number(localStorage.getItem(PREF_REFRESH_KEY) || 8);
|
||||||
prefRefreshSecondsInput.value = String(Math.min(120, Math.max(3, refreshSeconds)));
|
prefRefreshSecondsInput.value = String(Math.min(120, Math.max(3, refreshSeconds)));
|
||||||
}
|
}
|
||||||
@@ -325,8 +333,29 @@ function savePrefs(event) {
|
|||||||
localStorage.setItem(PREF_REFRESH_KEY, String(refreshSeconds));
|
localStorage.setItem(PREF_REFRESH_KEY, String(refreshSeconds));
|
||||||
prefRefreshSecondsInput.value = String(refreshSeconds);
|
prefRefreshSecondsInput.value = String(refreshSeconds);
|
||||||
|
|
||||||
|
const wantsDesktop = prefDesktopNotificationsInput
|
||||||
|
? prefDesktopNotificationsInput.checked
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (wantsDesktop) {
|
||||||
|
requestDesktopNotificationPermission().then((perm) => {
|
||||||
|
if (perm === "granted") {
|
||||||
|
setDesktopNotificationsEnabled(true);
|
||||||
|
if (prefDesktopNotificationsHint) prefDesktopNotificationsHint.classList.add("hidden");
|
||||||
|
showMessage("Preferences saved. Desktop notifications enabled.");
|
||||||
|
} else {
|
||||||
|
setDesktopNotificationsEnabled(false);
|
||||||
|
if (prefDesktopNotificationsInput) prefDesktopNotificationsInput.checked = false;
|
||||||
|
if (prefDesktopNotificationsHint) prefDesktopNotificationsHint.classList.remove("hidden");
|
||||||
|
showMessage("Desktop notifications require browser permission — not granted.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setDesktopNotificationsEnabled(false);
|
||||||
|
if (prefDesktopNotificationsHint) prefDesktopNotificationsHint.classList.add("hidden");
|
||||||
showMessage("Preferences saved.");
|
showMessage("Preferences saved.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSecurityPolicy(event) {
|
async function saveSecurityPolicy(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -251,6 +251,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop Event Notifications -->
|
||||||
|
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Desktop Event Notifications</h2>
|
||||||
|
<p class="text-sm text-slate-400">
|
||||||
|
Choose which client events trigger a desktop (OS) notification. Desktop notifications must be enabled in <a href="/settings" class="text-sky-400 hover:underline">Settings</a> first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="save-event-notifs"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-save"></i>
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||||
|
<label class="flex items-center gap-3 rounded-lg bg-slate-950 border border-slate-800 px-4 py-3 cursor-pointer hover:border-slate-600 transition-colors">
|
||||||
|
<input id="event-notif-online" type="checkbox" class="h-4 w-4 accent-emerald-500 shrink-0" />
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-slate-200 font-medium">🟢 Client Online</span>
|
||||||
|
<span class="text-slate-500 text-xs">Fire when a client connects</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 rounded-lg bg-slate-950 border border-slate-800 px-4 py-3 cursor-pointer hover:border-slate-600 transition-colors">
|
||||||
|
<input id="event-notif-offline" type="checkbox" class="h-4 w-4 accent-red-500 shrink-0" />
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-slate-200 font-medium">🔴 Client Offline</span>
|
||||||
|
<span class="text-slate-500 text-xs">Fire when a client disconnects</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 rounded-lg bg-slate-950 border border-slate-800 px-4 py-3 cursor-pointer hover:border-slate-600 transition-colors">
|
||||||
|
<input id="event-notif-purgatory" type="checkbox" class="h-4 w-4 accent-yellow-500 shrink-0" />
|
||||||
|
<span class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-slate-200 font-medium">🟡 Client in Purgatory</span>
|
||||||
|
<span class="text-slate-500 text-xs">Fire when a client awaits approval</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Notification feed -->
|
<!-- Notification feed -->
|
||||||
<div
|
<div
|
||||||
id="notification-panel"
|
id="notification-panel"
|
||||||
|
|||||||
@@ -86,6 +86,14 @@
|
|||||||
<input id="pref-notifications" type="checkbox" class="h-4 w-4" />
|
<input id="pref-notifications" type="checkbox" class="h-4 w-4" />
|
||||||
<span>Enable notification badge updates</span>
|
<span>Enable notification badge updates</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input id="pref-desktop-notifications" type="checkbox" class="h-4 w-4 accent-emerald-500" />
|
||||||
|
<span>Enable desktop (OS) notifications</span>
|
||||||
|
</label>
|
||||||
|
<p id="pref-desktop-notifications-hint" class="hidden text-xs text-amber-400">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation mr-1"></i>
|
||||||
|
Browser permission was denied. Reset notification permissions for this site in your browser settings, then try again.
|
||||||
|
</p>
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="text-sm text-slate-300">Client refresh interval (seconds)</span>
|
<span class="text-sm text-slate-300">Client refresh interval (seconds)</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -358,6 +358,8 @@ async function startServer() {
|
|||||||
getRdSessionCount: sessionManager.getRdSessionCount,
|
getRdSessionCount: sessionManager.getRdSessionCount,
|
||||||
getFileBrowserSessionCount: sessionManager.getFileBrowserSessionCount,
|
getFileBrowserSessionCount: sessionManager.getFileBrowserSessionCount,
|
||||||
getProcessSessionCount: sessionManager.getProcessSessionCount,
|
getProcessSessionCount: sessionManager.getProcessSessionCount,
|
||||||
|
tlsCertPath: tls?.certPathUsed,
|
||||||
|
tlsSource: tls?.source,
|
||||||
},
|
},
|
||||||
assets: {
|
assets: {
|
||||||
PUBLIC_ROOT,
|
PUBLIC_ROOT,
|
||||||
@@ -471,6 +473,7 @@ async function startServer() {
|
|||||||
notifyRemoteDesktopStatus,
|
notifyRemoteDesktopStatus,
|
||||||
handleBuildTagConnection,
|
handleBuildTagConnection,
|
||||||
notifyDashboard: sessionManager.notifyDashboardViewers,
|
notifyDashboard: sessionManager.notifyDashboardViewers,
|
||||||
|
broadcastClientEvent: notificationPluginHandlers.broadcastClientLifecycleEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = Bun.serve<SocketData>({
|
const server = Bun.serve<SocketData>({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const CORS_HEADERS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SECURITY_HEADERS = {
|
export const SECURITY_HEADERS = {
|
||||||
"Content-Security-Policy": "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.googleapis.com; img-src 'self' data: blob: https://cdn.jsdelivr.net https://a.tile.openstreetmap.org https://b.tile.openstreetmap.org https://c.tile.openstreetmap.org https://tile.openstreetmap.org https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.gstatic.com; connect-src 'self' wss: ws: https://cdn.jsdelivr.net",
|
"Content-Security-Policy": "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.googleapis.com; img-src 'self' data: blob: https://cdn.jsdelivr.net https://a.tile.openstreetmap.org https://b.tile.openstreetmap.org https://c.tile.openstreetmap.org https://tile.openstreetmap.org https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.gstatic.com; connect-src 'self' wss: ws: https://cdn.jsdelivr.net",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
"X-Frame-Options": "DENY",
|
"X-Frame-Options": "DENY",
|
||||||
"X-XSS-Protection": "1; mode=block",
|
"X-XSS-Protection": "1; mode=block",
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ export async function handleAssetsRoutes(
|
|||||||
|
|
||||||
const file = Bun.file(resolvedPath);
|
const file = Bun.file(resolvedPath);
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType(url.pathname)) });
|
const headers: Record<string, string> = { ...deps.secureHeaders(deps.mimeType(url.pathname)) };
|
||||||
|
if (relativePath === "notification-sw.js") {
|
||||||
|
headers["Service-Worker-Allowed"] = "/";
|
||||||
|
}
|
||||||
|
return new Response(file, { headers });
|
||||||
}
|
}
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type MiscRouteDeps = {
|
|||||||
getRdSessionCount: () => number;
|
getRdSessionCount: () => number;
|
||||||
getFileBrowserSessionCount: () => number;
|
getFileBrowserSessionCount: () => number;
|
||||||
getProcessSessionCount: () => number;
|
getProcessSessionCount: () => number;
|
||||||
|
tlsCertPath?: string;
|
||||||
|
tlsSource?: "certbot" | "configured" | "self-signed";
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function handleMiscRoutes(
|
export async function handleMiscRoutes(
|
||||||
@@ -388,5 +390,28 @@ export async function handleMiscRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/cert/info") {
|
||||||
|
return Response.json(
|
||||||
|
{ source: deps.tlsSource || "unknown" },
|
||||||
|
{ headers: { "Content-Type": "application/json", ...deps.CORS_HEADERS } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/cert/download" && deps.tlsCertPath) {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(deps.tlsCertPath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
return new Response(file, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-pem-file",
|
||||||
|
"Content-Disposition": 'attachment; filename="overlord-ca.crt"',
|
||||||
|
...deps.CORS_HEADERS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return new Response("Certificate not available", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ type WsLifecycleDeps = {
|
|||||||
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
|
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
|
||||||
handleBuildTagConnection: (clientId: string, buildTag: string) => void;
|
handleBuildTagConnection: (clientId: string, buildTag: string) => void;
|
||||||
notifyDashboard: () => void;
|
notifyDashboard: () => void;
|
||||||
|
broadcastClientEvent: (
|
||||||
|
event: "client_online" | "client_offline" | "client_purgatory",
|
||||||
|
info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string },
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ENROLLMENT_TIMEOUT_MS = 30_000;
|
const ENROLLMENT_TIMEOUT_MS = 30_000;
|
||||||
@@ -296,6 +300,14 @@ export async function handleWebSocketMessage(
|
|||||||
logger.info(`[purgatory] client ${resolvedId} is pending approval`);
|
logger.info(`[purgatory] client ${resolvedId} is pending approval`);
|
||||||
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
|
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
|
||||||
deps.notifyDashboard();
|
deps.notifyDashboard();
|
||||||
|
deps.broadcastClientEvent("client_purgatory", {
|
||||||
|
id: resolvedId,
|
||||||
|
host: (payload as any).host || undefined,
|
||||||
|
user: (payload as any).user || undefined,
|
||||||
|
os: (payload as any).os || undefined,
|
||||||
|
ip: ip || undefined,
|
||||||
|
country,
|
||||||
|
});
|
||||||
try { ws.close(4001, "pending"); } catch {}
|
try { ws.close(4001, "pending"); } catch {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -349,8 +361,14 @@ export async function handleWebSocketMessage(
|
|||||||
clientManager.addClient(infoObj.id, infoObj);
|
clientManager.addClient(infoObj.id, infoObj);
|
||||||
|
|
||||||
deps.dispatchAutoScriptsForConnection(infoObj, ws);
|
deps.dispatchAutoScriptsForConnection(infoObj, ws);
|
||||||
deps.notifyDashboard();
|
deps.notifyDashboard(); deps.broadcastClientEvent("client_online", {
|
||||||
|
id: infoObj.id,
|
||||||
|
host: infoObj.host,
|
||||||
|
user: infoObj.user,
|
||||||
|
os: infoObj.os,
|
||||||
|
ip: infoObj.ip,
|
||||||
|
country: infoObj.country,
|
||||||
|
});
|
||||||
if (infoObj.role === "client") {
|
if (infoObj.role === "client") {
|
||||||
deps.notifyRemoteDesktopStatus(resolvedId, "online");
|
deps.notifyRemoteDesktopStatus(resolvedId, "online");
|
||||||
metrics.recordConnection();
|
metrics.recordConnection();
|
||||||
@@ -642,6 +660,17 @@ export function handleWebSocketClose(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === "client" && currentClient) {
|
||||||
|
deps.broadcastClientEvent("client_offline", {
|
||||||
|
id: clientId,
|
||||||
|
host: currentClient.host,
|
||||||
|
user: currentClient.user,
|
||||||
|
os: currentClient.os,
|
||||||
|
ip: currentClient.ip,
|
||||||
|
country: currentClient.country,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
clientManager.deleteClient(clientId);
|
clientManager.deleteClient(clientId);
|
||||||
stopAllProxiesForClient(clientId);
|
stopAllProxiesForClient(clientId);
|
||||||
clearClientSyncState(clientId);
|
clearClientSyncState(clientId);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { certificatesExist, generateSelfSignedCert, getLocalIPs, isOpenSSLAvailable } from "../certGenerator";
|
import { certificatesExist, generateSelfSignedCert, getLocalIPs, isOpenSSLAvailable } from "../certGenerator";
|
||||||
import { logger } from "../logger";
|
import { logger } from "../logger";
|
||||||
|
import { X509Certificate } from "crypto";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
type TlsBootstrapParams = {
|
type TlsBootstrapParams = {
|
||||||
@@ -119,6 +120,15 @@ export async function prepareTlsOptions(params: TlsBootstrapParams): Promise<Tls
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (source === "configured" && tlsOptions.cert) {
|
||||||
|
try {
|
||||||
|
const x509 = new X509Certificate(tlsOptions.cert);
|
||||||
|
if (x509.issuer === x509.subject) {
|
||||||
|
source = "self-signed";
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tlsOptions,
|
tlsOptions,
|
||||||
certPathUsed: params.certPath,
|
certPathUsed: params.certPath,
|
||||||
|
|||||||
@@ -304,6 +304,27 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
broadcastClientLifecycleEvent(
|
||||||
|
event: "client_online" | "client_offline" | "client_purgatory",
|
||||||
|
info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string },
|
||||||
|
) {
|
||||||
|
const item = { type: "client_event", event, clientId: info.id, host: info.host, user: info.user, os: info.os, ip: info.ip, country: info.country, ts: Date.now() };
|
||||||
|
for (const session of sessionManager.getAllNotificationSessions().values()) {
|
||||||
|
const sRole = session.userRole ?? session.viewer.data.userRole ?? "";
|
||||||
|
const sUserId = session.userId ?? session.viewer.data.userId;
|
||||||
|
if (event === "client_purgatory") {
|
||||||
|
if (sRole !== "admin" && sRole !== "operator") continue;
|
||||||
|
} else {
|
||||||
|
if (sRole !== "admin") {
|
||||||
|
if (sUserId === undefined || !deps.canUserAccessClient(sUserId, sRole, info.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
safeSendViewer(session.viewer, item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
markPluginLoaded,
|
markPluginLoaded,
|
||||||
|
|
||||||
isPluginLoaded(clientId: string, pluginId: string): boolean {
|
isPluginLoaded(clientId: string, pluginId: string): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user