From 8e02d66da3666d3ae824280d2eb0384687be5c8c Mon Sep 17 00:00:00 2001 From: hhftechnologies Date: Thu, 2 Apr 2026 13:44:28 +0530 Subject: [PATCH] Add settings pages, APIs, and read-only mode Introduce a full Settings feature: frontend settings pages, components, hooks, API clients and types to manage Docker hosts, Coolify hosts, authentication, and read-only mode. Backend support added (settings handlers, config manager, Coolify client, registry service and related updates) and get-containers now returns hostErrors and coolifyConfigured so the UI can surface unavailable hosts. UI improvements include a Radix Switch component, a Settings button in the containers toolbar, and a host error banner on the dashboard. Environment, compose and packaging updates add DOCKER_HOSTS, COOLIFY_CONFIGS, READONLY_MODE docs and docker-compose volume; also remove an old auth handler file. --- .env.example | 21 + docker-compose.yml | 7 + frontend/package.json | 1 + frontend/src/components/ui/switch.tsx | 31 ++ .../features/containers/api/get-containers.ts | 12 + .../components/containers-dashboard.tsx | 16 + .../components/containers-toolbar.tsx | 14 +- .../src/features/settings/api/get-settings.ts | 17 + .../settings/api/test-coolify-host.ts | 25 ++ .../features/settings/api/test-docker-host.ts | 24 ++ .../src/features/settings/api/update-auth.ts | 26 ++ .../settings/api/update-coolify-hosts.ts | 22 + .../settings/api/update-docker-hosts.ts | 20 + .../features/settings/api/update-read-only.ts | 20 + .../settings/components/auth-section.tsx | 138 ++++++ .../components/coolify-hosts-section.tsx | 354 ++++++++++++++++ .../components/docker-hosts-section.tsx | 386 +++++++++++++++++ .../settings/components/env-badge.tsx | 9 + .../settings/components/read-only-section.tsx | 59 +++ .../settings/components/settings-page.tsx | 48 +++ .../features/settings/hooks/use-settings.ts | 80 ++++ frontend/src/features/settings/types.ts | 48 +++ frontend/src/routeTree.gen.ts | 21 + frontend/src/routes/settings.tsx | 7 + home/cmd/server/main.go | 59 ++- home/internal/api/auth_handlers.go | 73 ---- home/internal/api/handlers.go | 81 ++-- home/internal/api/image_handlers.go | 29 +- home/internal/api/middleware/readonly.go | 9 +- home/internal/api/network_handlers.go | 24 +- home/internal/api/router.go | 167 +++++--- home/internal/api/settings_handlers.go | 401 ++++++++++++++++++ home/internal/api/stats_ws.go | 4 +- home/internal/api/terminal.go | 6 +- home/internal/auth/middleware.go | 86 ++-- home/internal/auth/service.go | 35 ++ home/internal/config/config.go | 91 +++- home/internal/config/manager.go | 394 +++++++++++++++++ home/internal/coolify/client.go | 225 ++++++++++ home/internal/docker/client.go | 9 + home/internal/docker/container.go | 19 +- home/internal/services/registry.go | 94 ++++ 42 files changed, 2968 insertions(+), 244 deletions(-) create mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/features/settings/api/get-settings.ts create mode 100644 frontend/src/features/settings/api/test-coolify-host.ts create mode 100644 frontend/src/features/settings/api/test-docker-host.ts create mode 100644 frontend/src/features/settings/api/update-auth.ts create mode 100644 frontend/src/features/settings/api/update-coolify-hosts.ts create mode 100644 frontend/src/features/settings/api/update-docker-hosts.ts create mode 100644 frontend/src/features/settings/api/update-read-only.ts create mode 100644 frontend/src/features/settings/components/auth-section.tsx create mode 100644 frontend/src/features/settings/components/coolify-hosts-section.tsx create mode 100644 frontend/src/features/settings/components/docker-hosts-section.tsx create mode 100644 frontend/src/features/settings/components/env-badge.tsx create mode 100644 frontend/src/features/settings/components/read-only-section.tsx create mode 100644 frontend/src/features/settings/components/settings-page.tsx create mode 100644 frontend/src/features/settings/hooks/use-settings.ts create mode 100644 frontend/src/features/settings/types.ts create mode 100644 frontend/src/routes/settings.tsx delete mode 100644 home/internal/api/auth_handlers.go create mode 100644 home/internal/api/settings_handlers.go create mode 100644 home/internal/config/manager.go create mode 100644 home/internal/coolify/client.go create mode 100644 home/internal/services/registry.go diff --git a/.env.example b/.env.example index 7ece9072..554c1092 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,24 @@ FRONTEND_PORT=2345 # Docker host (uncomment to override default) DOCKER_HOST=unix:///var/run/docker.sock + +# Multiple Docker hosts (comma-separated, format: name|host) +# Can also be configured via the Settings page at runtime. +# DOCKER_HOSTS=local|unix:///var/run/docker.sock,remote|ssh://root@10.0.0.1 + +# ============================================================================= +# Coolify Integration (Optional) +# ============================================================================= + +# Persist environment variable changes across Coolify redeployments. +# Format: hostName|apiURL|apiToken (comma-separated for multiple hosts) +# Can also be configured via the Settings page at runtime. +# COOLIFY_CONFIGS=prod|https://coolify.example.com|your-api-token + +# ============================================================================= +# Read-Only Mode (Optional) +# ============================================================================= + +# When true, disables all container actions (start/stop/restart/remove). +# Can also be toggled via the Settings page at runtime. +READONLY_MODE=false diff --git a/docker-compose.yml b/docker-compose.yml index 2ca94561..10d78339 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,14 @@ services: - ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD_SALT=${ADMIN_PASSWORD_SALT} - DOCKER_HOST=${DOCKER_HOST:-unix:///var/run/docker.sock} + - DOCKER_HOSTS=${DOCKER_HOSTS:-} + - COOLIFY_CONFIGS=${COOLIFY_CONFIGS:-} + - READONLY_MODE=${READONLY_MODE:-false} volumes: - /var/run/docker.sock:/var/run/docker.sock - /root/.ssh:/root/.ssh:ro - /proc:/host/proc:ro + - vps-monitor-data:/data + +volumes: + vps-monitor-data: diff --git a/frontend/package.json b/frontend/package.json index 5591aa09..b54722e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.0.6", diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 00000000..67a9a074 --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitive from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Switch } diff --git a/frontend/src/features/containers/api/get-containers.ts b/frontend/src/features/containers/api/get-containers.ts index ff27e418..11085bab 100644 --- a/frontend/src/features/containers/api/get-containers.ts +++ b/frontend/src/features/containers/api/get-containers.ts @@ -5,10 +5,17 @@ import type { ContainerInfo, DockerHost } from "../types"; const CONTAINERS_ENDPOINT = `${API_BASE_URL}/api/v1/containers`; +export interface HostError { + host: string; + message: string; +} + export interface GetContainersResponse { containers: ContainerInfo[]; readOnly: boolean; hosts: DockerHost[]; + hostErrors?: HostError[]; + coolifyConfigured?: boolean; } export async function getContainers(): Promise { @@ -28,6 +35,9 @@ export async function getContainers(): Promise { const containers = (data as { containers?: unknown }).containers; const readOnly = (data as { readOnly?: boolean }).readOnly ?? false; const hosts = (data as { hosts?: unknown }).hosts; + const hostErrors = (data as { hostErrors?: unknown }).hostErrors; + const coolifyConfigured = + (data as { coolifyConfigured?: boolean }).coolifyConfigured ?? false; if (!Array.isArray(containers)) { throw new Error("Unexpected response format"); @@ -41,5 +51,7 @@ export async function getContainers(): Promise { containers: containers as ContainerInfo[], readOnly, hosts: hosts as DockerHost[], + hostErrors: (Array.isArray(hostErrors) ? hostErrors : []) as HostError[], + coolifyConfigured, }; } diff --git a/frontend/src/features/containers/components/containers-dashboard.tsx b/frontend/src/features/containers/components/containers-dashboard.tsx index 5279205a..59beac04 100644 --- a/frontend/src/features/containers/components/containers-dashboard.tsx +++ b/frontend/src/features/containers/components/containers-dashboard.tsx @@ -55,6 +55,7 @@ export function ContainersDashboard() { const containers = data?.containers ?? []; const isReadOnly = data?.readOnly ?? false; const hosts = data?.hosts ?? []; + const hostErrors = data?.hostErrors ?? []; const hostInfo = useMemo( () => ({ @@ -390,6 +391,21 @@ export function ContainersDashboard() { systemUsage={systemUsage} /> + {hostErrors.length > 0 && ( +
+

+ Some Docker hosts are unavailable +

+
    + {hostErrors.map((he) => ( +
  • + {he.host}: {he.message} +
  • + ))} +
+
+ )} +
+ + + + + Settings + + {isAuthEnabled && ( diff --git a/frontend/src/features/settings/api/get-settings.ts b/frontend/src/features/settings/api/get-settings.ts new file mode 100644 index 00000000..4f68a59e --- /dev/null +++ b/frontend/src/features/settings/api/get-settings.ts @@ -0,0 +1,17 @@ +import { authenticatedFetch } from "@/lib/api-client"; +import { API_BASE_URL } from "@/types/api"; + +import type { SettingsResponse } from "../types"; + +const SETTINGS_ENDPOINT = `${API_BASE_URL}/api/v1/settings`; + +export async function getSettings(): Promise { + const response = await authenticatedFetch(SETTINGS_ENDPOINT); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Request failed with status ${response.status}`); + } + + return (await response.json()) as SettingsResponse; +} diff --git a/frontend/src/features/settings/api/test-coolify-host.ts b/frontend/src/features/settings/api/test-coolify-host.ts new file mode 100644 index 00000000..868113b4 --- /dev/null +++ b/frontend/src/features/settings/api/test-coolify-host.ts @@ -0,0 +1,25 @@ +import { authenticatedFetch } from "@/lib/api-client"; +import { API_BASE_URL } from "@/types/api"; + +import type { TestConnectionResult } from "../types"; + +const ENDPOINT = `${API_BASE_URL}/api/v1/settings/test/coolify-host`; + +export async function testCoolifyHost( + hostName: string, + apiURL: string, + apiToken: string, +): Promise { + const response = await authenticatedFetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hostName, apiURL, apiToken }), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Failed to test Coolify host"); + } + + return (await response.json()) as TestConnectionResult; +} diff --git a/frontend/src/features/settings/api/test-docker-host.ts b/frontend/src/features/settings/api/test-docker-host.ts new file mode 100644 index 00000000..d92efefe --- /dev/null +++ b/frontend/src/features/settings/api/test-docker-host.ts @@ -0,0 +1,24 @@ +import { authenticatedFetch } from "@/lib/api-client"; +import { API_BASE_URL } from "@/types/api"; + +import type { TestConnectionResult } from "../types"; + +const ENDPOINT = `${API_BASE_URL}/api/v1/settings/test/docker-host`; + +export async function testDockerHost( + name: string, + host: string, +): Promise { + const response = await authenticatedFetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, host }), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Failed to test Docker host"); + } + + return (await response.json()) as TestConnectionResult; +} diff --git a/frontend/src/features/settings/api/update-auth.ts b/frontend/src/features/settings/api/update-auth.ts new file mode 100644 index 00000000..c3864341 --- /dev/null +++ b/frontend/src/features/settings/api/update-auth.ts @@ -0,0 +1,26 @@ +import { authenticatedFetch } from "@/lib/api-client"; +import { API_BASE_URL } from "@/types/api"; + +const ENDPOINT = `${API_BASE_URL}/api/v1/settings/auth`; + +export interface UpdateAuthPayload { + enabled: boolean; + adminUsername: string; + newPassword?: string; +} + +export async function updateAuth(payload: UpdateAuthPayload): Promise { + const response = await authenticatedFetch(ENDPOINT, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Failed to update auth settings"); + } + + const data = (await response.json()) as { message?: string }; + return data.message ?? "Auth settings updated"; +} diff --git a/frontend/src/features/settings/api/update-coolify-hosts.ts b/frontend/src/features/settings/api/update-coolify-hosts.ts new file mode 100644 index 00000000..05656b52 --- /dev/null +++ b/frontend/src/features/settings/api/update-coolify-hosts.ts @@ -0,0 +1,22 @@ +import { authenticatedFetch } from "@/lib/api-client"; +import { API_BASE_URL } from "@/types/api"; + +const ENDPOINT = `${API_BASE_URL}/api/v1/settings/coolify-hosts`; + +export async function updateCoolifyHosts( + hosts: { hostName: string; apiURL: string; apiToken: string }[], +): Promise { + const response = await authenticatedFetch(ENDPOINT, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hosts }), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Failed to update Coolify hosts"); + } + + const data = (await response.json()) as { message?: string }; + return data.message ?? "Coolify hosts updated"; +} diff --git a/frontend/src/features/settings/api/update-docker-hosts.ts b/frontend/src/features/settings/api/update-docker-hosts.ts new file mode 100644 index 00000000..5e968926 --- /dev/null +++ b/frontend/src/features/settings/api/update-docker-hosts.ts @@ -0,0 +1,20 @@ +import { authenticatedFetch } from "@/lib/api-client"; +import { API_BASE_URL } from "@/types/api"; + +const ENDPOINT = `${API_BASE_URL}/api/v1/settings/docker-hosts`; + +export async function updateDockerHosts(hosts: { name: string; host: string }[]): Promise { + const response = await authenticatedFetch(ENDPOINT, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hosts }), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Failed to update Docker hosts"); + } + + const data = (await response.json()) as { message?: string }; + return data.message ?? "Docker hosts updated"; +} diff --git a/frontend/src/features/settings/api/update-read-only.ts b/frontend/src/features/settings/api/update-read-only.ts new file mode 100644 index 00000000..dd56bf55 --- /dev/null +++ b/frontend/src/features/settings/api/update-read-only.ts @@ -0,0 +1,20 @@ +import { authenticatedFetch } from "@/lib/api-client"; +import { API_BASE_URL } from "@/types/api"; + +const ENDPOINT = `${API_BASE_URL}/api/v1/settings/read-only`; + +export async function updateReadOnly(value: boolean): Promise { + const response = await authenticatedFetch(ENDPOINT, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Failed to update read-only mode"); + } + + const data = (await response.json()) as { message?: string }; + return data.message ?? "Read-only mode updated"; +} diff --git a/frontend/src/features/settings/components/auth-section.tsx b/frontend/src/features/settings/components/auth-section.tsx new file mode 100644 index 00000000..e15d7e67 --- /dev/null +++ b/frontend/src/features/settings/components/auth-section.tsx @@ -0,0 +1,138 @@ +import { useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; + +import { useUpdateAuth } from "../hooks/use-settings"; +import type { AuthConfig } from "../types"; +import { EnvBadge } from "./env-badge"; + +interface AuthSectionProps { + config: AuthConfig; +} + +export function AuthSection({ config }: AuthSectionProps) { + const isEnv = config.source === "env"; + const [enabled, setEnabled] = useState(config.enabled); + const [username, setUsername] = useState(config.adminUsername ?? ""); + const [password, setPassword] = useState(""); + + const mutation = useUpdateAuth(); + + const hasChanges = + enabled !== config.enabled || + username !== (config.adminUsername ?? "") || + password.length > 0; + + function handleSave() { + if (enabled && !username.trim()) { + toast.error("Username is required when enabling auth"); + return; + } + if (enabled && !config.enabled && !password) { + toast.error("Password is required when first enabling auth"); + return; + } + + mutation.mutate( + { + enabled, + adminUsername: username, + ...(password ? { newPassword: password } : {}), + }, + { + onSuccess: (msg) => { + toast.success(msg); + setPassword(""); + }, + onError: (err) => toast.error(err.message), + }, + ); + } + + return ( + + +
+ Authentication + {isEnv && } +
+ + Protect the dashboard with username and password authentication. + +
+ +
+ { + setEnabled(checked); + if (!checked) setPassword(""); + }} + disabled={isEnv} + /> + +
+ + {enabled && ( +
+
+ + setUsername(e.target.value)} + disabled={isEnv} + placeholder="admin" + /> +
+
+ + setPassword(e.target.value)} + disabled={isEnv} + placeholder={config.enabled ? "Leave blank to keep current" : "Enter password"} + /> +
+
+ )} + + {hasChanges && !isEnv && ( + + )} +
+
+ ); +} diff --git a/frontend/src/features/settings/components/coolify-hosts-section.tsx b/frontend/src/features/settings/components/coolify-hosts-section.tsx new file mode 100644 index 00000000..3230f8c1 --- /dev/null +++ b/frontend/src/features/settings/components/coolify-hosts-section.tsx @@ -0,0 +1,354 @@ +import { useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { + useTestCoolifyHost, + useUpdateCoolifyHosts, +} from "../hooks/use-settings"; +import type { CoolifyHostsConfig } from "../types"; +import { EnvBadge } from "./env-badge"; + +interface CoolifyHostsSectionProps { + config: CoolifyHostsConfig; +} + +interface EditingHost { + hostName: string; + apiURL: string; + apiToken: string; +} + +const EMPTY_HOST: EditingHost = { hostName: "", apiURL: "", apiToken: "" }; + +export function CoolifyHostsSection({ config }: CoolifyHostsSectionProps) { + const envHosts = config.hosts.filter((h) => h.source === "env"); + const [fileHosts, setFileHosts] = useState( + config.hosts + .filter((h) => h.source !== "env") + .map(({ hostName, apiURL, apiToken }) => ({ hostName, apiURL, apiToken })), + ); + const [editingIndex, setEditingIndex] = useState(null); + const [editingHost, setEditingHost] = useState(EMPTY_HOST); + const [isAdding, setIsAdding] = useState(false); + const [newHost, setNewHost] = useState(EMPTY_HOST); + const [testResults, setTestResults] = useState< + Record + >({}); + const [testingKey, setTestingKey] = useState(null); + + const updateMutation = useUpdateCoolifyHosts(); + const testMutation = useTestCoolifyHost(); + + const originalFileHosts = useMemo( + () => config.hosts.filter((h) => h.source !== "env").map(({ hostName, apiURL, apiToken }) => ({ hostName, apiURL, apiToken })), + [config.hosts], + ); + const hasChanges = fileHosts.length !== originalFileHosts.length || + fileHosts.some((h, i) => h.hostName !== originalFileHosts[i]?.hostName || h.apiURL !== originalFileHosts[i]?.apiURL || h.apiToken !== originalFileHosts[i]?.apiToken); + + function handleSave() { + updateMutation.mutate(fileHosts, { + onSuccess: (msg) => toast.success(msg), + onError: (err) => toast.error(err.message), + }); + } + + function handleRemove(index: number) { + setFileHosts((prev) => prev.filter((_, i) => i !== index)); + setTestResults({}); + } + + function handleStartEdit(index: number) { + setEditingIndex(index); + setEditingHost({ ...fileHosts[index] }); + } + + function handleCancelEdit() { + setEditingIndex(null); + setEditingHost(EMPTY_HOST); + } + + function handleSaveEdit() { + if (editingIndex === null) return; + if (!editingHost.hostName.trim() || !editingHost.apiURL.trim() || !editingHost.apiToken.trim()) { + toast.error("Host name, API URL, and API token are all required"); + return; + } + const trimmedName = editingHost.hostName.trim(); + if (envHosts.some((h) => h.hostName === trimmedName)) { + toast.error(`Host name "${trimmedName}" is defined via environment variable`); + return; + } + if (fileHosts.some((h, i) => i !== editingIndex && h.hostName === trimmedName)) { + toast.error(`Host name "${trimmedName}" already exists`); + return; + } + const next = [...fileHosts]; + next[editingIndex] = { ...editingHost }; + setFileHosts(next); + setEditingIndex(null); + setEditingHost(EMPTY_HOST); + setTestResults({}); + } + + function handleAddHost() { + if (!newHost.hostName.trim() || !newHost.apiURL.trim() || !newHost.apiToken.trim()) { + toast.error("Host name, API URL, and API token are all required"); + return; + } + const trimmedName = newHost.hostName.trim(); + if (envHosts.some((h) => h.hostName === trimmedName)) { + toast.error(`Host name "${trimmedName}" is already defined via environment variable`); + return; + } + if (fileHosts.some((h) => h.hostName === trimmedName)) { + toast.error(`Host name "${trimmedName}" already exists`); + return; + } + setFileHosts([...fileHosts, { hostName: trimmedName, apiURL: newHost.apiURL.trim(), apiToken: newHost.apiToken.trim() }]); + setNewHost(EMPTY_HOST); + setIsAdding(false); + setTestResults({}); + } + + function handleTest(key: string, h: { hostName: string; apiURL: string; apiToken: string }) { + setTestingKey(key); + testMutation.mutate( + { hostName: h.hostName, apiURL: h.apiURL, apiToken: h.apiToken }, + { + onSuccess: (result) => { + setTestResults((prev) => ({ + ...prev, + [key]: { success: result.success, message: result.message }, + })); + setTestingKey(null); + }, + onError: (err) => { + setTestResults((prev) => ({ + ...prev, + [key]: { success: false, message: err.message }, + })); + setTestingKey(null); + }, + }, + ); + } + + function renderRow( + h: { hostName: string; apiURL: string; apiToken: string }, + isEnvRow: boolean, + fileIndex?: number, + ) { + const testKey = `${isEnvRow ? "env" : "file"}-${h.hostName}`; + const isEditing = !isEnvRow && editingIndex === fileIndex; + + if (isEditing && fileIndex !== undefined) { + return ( + + + setEditingHost((prev) => ({ ...prev, hostName: e.target.value }))} + className="h-8" + /> + + + setEditingHost((prev) => ({ ...prev, apiURL: e.target.value }))} + className="h-8" + placeholder="https://coolify.example.com" + /> + + + setEditingHost((prev) => ({ ...prev, apiToken: e.target.value }))} + className="h-8" + placeholder="Leave masked to keep existing" + type="password" + /> + + + +
+ + +
+
+
+ ); + } + + return ( + + +
+ {h.hostName} + {isEnvRow && } +
+
+ {h.apiURL} + {h.apiToken} + + {testResults[testKey] && ( + + {testResults[testKey].message} + + )} + + +
+ + {!isEnvRow && fileIndex !== undefined && ( + <> + + + + )} +
+
+
+ ); + } + + const allHosts = [ + ...envHosts.map((h) => ({ ...h, isEnv: true as const })), + ...fileHosts.map((h, i) => ({ ...h, source: "file" as const, isEnv: false as const, fileIndex: i })), + ]; + + return ( + + + Coolify Hosts + + Connect to Coolify instances to persist environment variable changes across redeployments. + + + + {allHosts.length > 0 && ( + + + + Name + API URL + Token + Status + Actions + + + + {allHosts.map((h) => + renderRow( + { hostName: h.hostName, apiURL: h.apiURL, apiToken: h.apiToken }, + h.isEnv, + h.isEnv ? undefined : h.fileIndex, + ), + )} + +
+ )} + + {allHosts.length === 0 && !isAdding && ( +

No Coolify hosts configured.

+ )} + + {isAdding && ( +
+
+ + setNewHost((prev) => ({ ...prev, hostName: e.target.value }))} + placeholder="production" + className="h-8" + /> +
+
+ + setNewHost((prev) => ({ ...prev, apiURL: e.target.value }))} + placeholder="https://coolify.example.com" + className="h-8" + /> +
+
+ + setNewHost((prev) => ({ ...prev, apiToken: e.target.value }))} + placeholder="Token" + type="password" + className="h-8" + /> +
+
+ + +
+
+ )} + +
+ {!isAdding && ( + + )} + {hasChanges && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/features/settings/components/docker-hosts-section.tsx b/frontend/src/features/settings/components/docker-hosts-section.tsx new file mode 100644 index 00000000..3e0feefa --- /dev/null +++ b/frontend/src/features/settings/components/docker-hosts-section.tsx @@ -0,0 +1,386 @@ +import { useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { useTestDockerHost, useUpdateDockerHosts } from "../hooks/use-settings"; +import type { DockerHostsConfig } from "../types"; +import { EnvBadge } from "./env-badge"; + +interface DockerHostsSectionProps { + config: DockerHostsConfig; +} + +interface EditingHost { + name: string; + host: string; +} + +export function DockerHostsSection({ config }: DockerHostsSectionProps) { + const envHosts = config.hosts.filter((h) => h.source === "env"); + const [fileHosts, setFileHosts] = useState( + config.hosts.filter((h) => h.source !== "env").map(({ name, host }) => ({ name, host })), + ); + const [editingIndex, setEditingIndex] = useState(null); + const [editingHost, setEditingHost] = useState({ + name: "", + host: "", + }); + const [isAdding, setIsAdding] = useState(false); + const [newHost, setNewHost] = useState({ + name: "", + host: "", + }); + const [testResults, setTestResults] = useState< + Record + >({}); + const [testingKey, setTestingKey] = useState(null); + + const updateMutation = useUpdateDockerHosts(); + const testMutation = useTestDockerHost(); + + const originalFileHosts = useMemo( + () => config.hosts.filter((h) => h.source !== "env").map(({ name, host }) => ({ name, host })), + [config.hosts], + ); + const hasChanges = fileHosts.length !== originalFileHosts.length || + fileHosts.some((h, i) => h.name !== originalFileHosts[i]?.name || h.host !== originalFileHosts[i]?.host); + + function handleSave() { + updateMutation.mutate( + fileHosts.map(({ name, host }) => ({ name, host })), + { + onSuccess: (msg) => toast.success(msg), + onError: (err) => toast.error(err.message), + }, + ); + } + + function handleRemove(index: number) { + setFileHosts((prev) => prev.filter((_, i) => i !== index)); + setTestResults({}); + } + + function handleStartEdit(index: number) { + setEditingIndex(index); + setEditingHost({ ...fileHosts[index] }); + } + + function handleCancelEdit() { + setEditingIndex(null); + setEditingHost({ name: "", host: "" }); + } + + function handleSaveEdit() { + if (editingIndex === null) return; + if (!editingHost.name.trim() || !editingHost.host.trim()) { + toast.error("Name and host are required"); + return; + } + const trimmedName = editingHost.name.trim(); + if (envHosts.some((h) => h.name === trimmedName)) { + toast.error(`Host name "${trimmedName}" is defined via environment variable`); + return; + } + if (fileHosts.some((h, i) => i !== editingIndex && h.name === trimmedName)) { + toast.error(`Host name "${trimmedName}" already exists`); + return; + } + const next = [...fileHosts]; + next[editingIndex] = { ...editingHost }; + setFileHosts(next); + setEditingIndex(null); + setEditingHost({ name: "", host: "" }); + setTestResults({}); + } + + function handleAddHost() { + if (!newHost.name.trim() || !newHost.host.trim()) { + toast.error("Name and host are required"); + return; + } + const trimmedName = newHost.name.trim(); + if (envHosts.some((h) => h.name === trimmedName)) { + toast.error(`Host name "${trimmedName}" is already defined via environment variable`); + return; + } + if (fileHosts.some((h) => h.name === trimmedName)) { + toast.error(`Host name "${trimmedName}" already exists`); + return; + } + setFileHosts([...fileHosts, { name: trimmedName, host: newHost.host.trim() }]); + setNewHost({ name: "", host: "" }); + setIsAdding(false); + setTestResults({}); + } + + function handleTest(key: string, hostUrl: string) { + setTestingKey(key); + setTestResults((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + testMutation.mutate( + { name: key, host: hostUrl }, + { + onSuccess: (result) => { + setTestResults((prev) => ({ + ...prev, + [key]: { + success: result.success, + message: result.success + ? `Connected (Docker ${result.dockerVersion ?? ""})` + : result.message, + }, + })); + setTestingKey(null); + }, + onError: (err) => { + setTestResults((prev) => ({ + ...prev, + [key]: { success: false, message: err.message }, + })); + setTestingKey(null); + }, + }, + ); + } + + function renderRow( + h: { name: string; host: string }, + isEnvRow: boolean, + fileIndex?: number, + ) { + const testKey = `${isEnvRow ? "env" : "file"}-${h.name}`; + const isEditing = !isEnvRow && editingIndex === fileIndex; + + if (isEditing && fileIndex !== undefined) { + return ( + + + + setEditingHost((prev) => ({ ...prev, name: e.target.value })) + } + className="h-8" + /> + + + + setEditingHost((prev) => ({ ...prev, host: e.target.value })) + } + className="h-8" + placeholder="unix:///var/run/docker.sock" + /> + + + +
+ + +
+
+
+ ); + } + + return ( + + +
+ {h.name} + {isEnvRow && } +
+
+ + {h.host} + + + {testResults[testKey] && ( + + {testResults[testKey].message} + + )} + + +
+ + {!isEnvRow && fileIndex !== undefined && ( + <> + + + + )} +
+
+
+ ); + } + + const allHosts = [ + ...envHosts.map((h) => ({ ...h, isEnv: true as const })), + ...fileHosts.map((h, i) => ({ ...h, source: "file" as const, isEnv: false as const, fileIndex: i })), + ]; + + return ( + + + Docker Hosts + + Configure which Docker daemon sockets or remote hosts to connect to. + + + + {allHosts.length > 0 && ( + + + + Name + Host + Status + Actions + + + + {allHosts.map((h) => + renderRow( + { name: h.name, host: h.host }, + h.isEnv, + h.isEnv ? undefined : h.fileIndex, + ), + )} + +
+ )} + + {allHosts.length === 0 && !isAdding && ( +

+ No Docker hosts configured. +

+ )} + + {isAdding && ( +
+
+ + + setNewHost((prev) => ({ ...prev, name: e.target.value })) + } + placeholder="my-server" + className="h-8" + /> +
+
+ + + setNewHost((prev) => ({ ...prev, host: e.target.value })) + } + placeholder="ssh://root@10.0.0.1" + className="h-8" + /> +
+
+ + +
+
+ )} + +
+ {!isAdding && ( + + )} + {hasChanges && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/features/settings/components/env-badge.tsx b/frontend/src/features/settings/components/env-badge.tsx new file mode 100644 index 00000000..a02ac95e --- /dev/null +++ b/frontend/src/features/settings/components/env-badge.tsx @@ -0,0 +1,9 @@ +import { Badge } from "@/components/ui/badge"; + +export function EnvBadge() { + return ( + + Set via environment variable + + ); +} diff --git a/frontend/src/features/settings/components/read-only-section.tsx b/frontend/src/features/settings/components/read-only-section.tsx new file mode 100644 index 00000000..87b9c257 --- /dev/null +++ b/frontend/src/features/settings/components/read-only-section.tsx @@ -0,0 +1,59 @@ +import { toast } from "sonner"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +import { useUpdateReadOnly } from "../hooks/use-settings"; +import type { ReadOnlyConfig } from "../types"; +import { EnvBadge } from "./env-badge"; + +interface ReadOnlySectionProps { + config: ReadOnlyConfig; +} + +export function ReadOnlySection({ config }: ReadOnlySectionProps) { + const isEnv = config.source === "env"; + const mutation = useUpdateReadOnly(); + + function handleToggle(checked: boolean) { + mutation.mutate(checked, { + onSuccess: (msg) => toast.success(msg), + onError: (err) => toast.error(err.message), + }); + } + + return ( + + +
+ Read-Only Mode + {isEnv && } +
+ + When enabled, container actions (start, stop, restart, remove) are + disabled. Log viewing is unaffected. + +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/settings/components/settings-page.tsx b/frontend/src/features/settings/components/settings-page.tsx new file mode 100644 index 00000000..27d1e7b8 --- /dev/null +++ b/frontend/src/features/settings/components/settings-page.tsx @@ -0,0 +1,48 @@ +import { Spinner } from "@/components/ui/spinner"; + +import { useSettings } from "../hooks/use-settings"; +import { AuthSection } from "./auth-section"; +import { CoolifyHostsSection } from "./coolify-hosts-section"; +import { DockerHostsSection } from "./docker-hosts-section"; +import { ReadOnlySection } from "./read-only-section"; + +export function SettingsPage() { + const { data, isLoading, error } = useSettings(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

+ Failed to load settings: {error.message} +

+
+ ); + } + + if (!data) return null; + + return ( +
+
+

Settings

+

+ Manage VPS Monitor configuration. Sections marked as set via environment + variable can only be changed by updating the environment and + restarting. +

+
+ + + + +
+ ); +} diff --git a/frontend/src/features/settings/hooks/use-settings.ts b/frontend/src/features/settings/hooks/use-settings.ts new file mode 100644 index 00000000..bc0aef8e --- /dev/null +++ b/frontend/src/features/settings/hooks/use-settings.ts @@ -0,0 +1,80 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { getSettings } from "../api/get-settings"; +import { testCoolifyHost } from "../api/test-coolify-host"; +import { testDockerHost } from "../api/test-docker-host"; +import { updateAuth, type UpdateAuthPayload } from "../api/update-auth"; +import { updateCoolifyHosts } from "../api/update-coolify-hosts"; +import { updateDockerHosts } from "../api/update-docker-hosts"; +import { updateReadOnly } from "../api/update-read-only"; +const SETTINGS_KEY = ["settings"] as const; + +export function useSettings() { + return useQuery({ + queryKey: SETTINGS_KEY, + queryFn: getSettings, + staleTime: 30_000, + }); +} + +export function useUpdateDockerHosts() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (hosts: { name: string; host: string }[]) => updateDockerHosts(hosts), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: SETTINGS_KEY }); + }, + }); +} + +export function useUpdateCoolifyHosts() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (hosts: { hostName: string; apiURL: string; apiToken: string }[]) => + updateCoolifyHosts(hosts), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: SETTINGS_KEY }); + }, + }); +} + +export function useUpdateReadOnly() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (value: boolean) => updateReadOnly(value), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: SETTINGS_KEY }); + }, + }); +} + +export function useUpdateAuth() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (payload: UpdateAuthPayload) => updateAuth(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: SETTINGS_KEY }); + }, + }); +} + +export function useTestDockerHost() { + return useMutation({ + mutationFn: ({ name, host }: { name: string; host: string }) => + testDockerHost(name, host), + }); +} + +export function useTestCoolifyHost() { + return useMutation({ + mutationFn: ({ + hostName, + apiURL, + apiToken, + }: { + hostName: string; + apiURL: string; + apiToken: string; + }) => testCoolifyHost(hostName, apiURL, apiToken), + }); +} diff --git a/frontend/src/features/settings/types.ts b/frontend/src/features/settings/types.ts new file mode 100644 index 00000000..6f3efe8d --- /dev/null +++ b/frontend/src/features/settings/types.ts @@ -0,0 +1,48 @@ +export type ConfigSource = "file" | "env" | "default" | "mixed"; + +export interface DockerHost { + name: string; + host: string; + source: ConfigSource; +} + +export interface CoolifyHost { + hostName: string; + apiURL: string; + apiToken: string; + source: ConfigSource; +} + +export interface DockerHostsConfig { + source: ConfigSource; + hosts: DockerHost[]; +} + +export interface CoolifyHostsConfig { + source: ConfigSource; + hosts: CoolifyHost[]; +} + +export interface ReadOnlyConfig { + source: ConfigSource; + value: boolean; +} + +export interface AuthConfig { + source: ConfigSource; + enabled: boolean; + adminUsername?: string; +} + +export interface SettingsResponse { + dockerHosts: DockerHostsConfig; + coolifyHosts: CoolifyHostsConfig; + readOnly: ReadOnlyConfig; + auth: AuthConfig; +} + +export interface TestConnectionResult { + success: boolean; + message: string; + dockerVersion?: string; +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index cebf0e03..4fdd0241 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SettingsRouteImport } from './routes/settings' import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' import { Route as StatsIndexRouteImport } from './routes/stats/index' @@ -18,6 +19,11 @@ import { Route as AlertsIndexRouteImport } from './routes/alerts/index' import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query' import { Route as ContainersContainerIdLogsRouteImport } from './routes/containers/$containerId/logs' +const SettingsRoute = SettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => rootRouteImport, +} as any) const LoginRoute = LoginRouteImport.update({ id: '/login', path: '/login', @@ -63,6 +69,7 @@ const ContainersContainerIdLogsRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute + '/settings': typeof SettingsRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute '/alerts': typeof AlertsIndexRoute '/images': typeof ImagesIndexRoute @@ -73,6 +80,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute + '/settings': typeof SettingsRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute '/alerts': typeof AlertsIndexRoute '/images': typeof ImagesIndexRoute @@ -84,6 +92,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/login': typeof LoginRoute + '/settings': typeof SettingsRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute '/alerts/': typeof AlertsIndexRoute '/images/': typeof ImagesIndexRoute @@ -96,6 +105,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/login' + | '/settings' | '/demo/tanstack-query' | '/alerts' | '/images' @@ -106,6 +116,7 @@ export interface FileRouteTypes { to: | '/' | '/login' + | '/settings' | '/demo/tanstack-query' | '/alerts' | '/images' @@ -116,6 +127,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/login' + | '/settings' | '/demo/tanstack-query' | '/alerts/' | '/images/' @@ -127,6 +139,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute LoginRoute: typeof LoginRoute + SettingsRoute: typeof SettingsRoute DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute AlertsIndexRoute: typeof AlertsIndexRoute ImagesIndexRoute: typeof ImagesIndexRoute @@ -137,6 +150,13 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/settings': { + id: '/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof SettingsRouteImport + parentRoute: typeof rootRouteImport + } '/login': { id: '/login' path: '/login' @@ -199,6 +219,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LoginRoute: LoginRoute, + SettingsRoute: SettingsRoute, DemoTanstackQueryRoute: DemoTanstackQueryRoute, AlertsIndexRoute: AlertsIndexRoute, ImagesIndexRoute: ImagesIndexRoute, diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx new file mode 100644 index 00000000..9888b8d2 --- /dev/null +++ b/frontend/src/routes/settings.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SettingsPage } from "@/features/settings/components/settings-page"; + +export const Route = createFileRoute("/settings")({ + component: SettingsPage, +}); diff --git a/home/cmd/server/main.go b/home/cmd/server/main.go index 66de8a7c..42b34f34 100644 --- a/home/cmd/server/main.go +++ b/home/cmd/server/main.go @@ -1,33 +1,42 @@ package main import ( - "fmt" "log" "net/http" + "time" "github.com/hhftechnology/vps-monitor/internal/alerts" "github.com/hhftechnology/vps-monitor/internal/api" "github.com/hhftechnology/vps-monitor/internal/auth" "github.com/hhftechnology/vps-monitor/internal/config" + "github.com/hhftechnology/vps-monitor/internal/coolify" "github.com/hhftechnology/vps-monitor/internal/docker" + "github.com/hhftechnology/vps-monitor/internal/services" "github.com/hhftechnology/vps-monitor/internal/system" ) func main() { system.Init() - cfg := config.NewConfig() - fmt.Println("Config", cfg) + manager := config.NewManager() + cfg := manager.Config() multiHostClient, err := docker.NewMultiHostClient(cfg.DockerHosts) if err != nil { - panic(err) + log.Fatalf("Failed to create Docker client: %v", err) } + // Auth: env-based first, then file-based fallback. authService, err := auth.NewService() if err != nil { log.Fatalf("Failed to initialize auth service: %v\nPlease ensure ALL auth environment variables are set: JWT_SECRET, ADMIN_USERNAME, and ADMIN_PASSWORD.", err) } + if authService == nil { + fc := manager.FileConfigSnapshot() + if fc.Auth != nil && fc.Auth.Enabled { + authService = auth.NewServiceFromFileConfig(fc.Auth) + } + } if authService == nil { log.Println("Authentication is DISABLED - no auth environment variables detected") @@ -43,7 +52,15 @@ func main() { log.Println("Read-only mode is DISABLED - all operations are allowed") } - // Initialize alert monitor if enabled + // Coolify client + coolifyClient := coolify.NewMultiClient(cfg.CoolifyHosts) + if coolifyClient != nil { + log.Printf("Coolify integration is ENABLED (%d host configs)", len(cfg.CoolifyHosts)) + } else { + log.Println("Coolify integration is DISABLED") + } + + // Alert monitor (vps-monitor specific) var alertMonitor *alerts.Monitor if cfg.Alerts.Enabled { alertMonitor = alerts.NewMonitor(multiHostClient, &cfg.Alerts) @@ -60,10 +77,40 @@ func main() { log.Println(" To enable alerts, set: ALERTS_ENABLED=true") } + registry := services.NewRegistry(multiHostClient, coolifyClient, authService, cfg, alertMonitor) + + // Hot-reload callback + manager.OnChange(func(newCfg *config.Config) { + registry.UpdateConfig(newCfg) + + // Recreate Docker clients + newDocker, err := docker.NewMultiHostClient(newCfg.DockerHosts) + if err != nil { + log.Printf("Warning: failed to recreate Docker clients after config change: %v", err) + } else { + old := registry.SwapDocker(newDocker) + go func() { + time.Sleep(30 * time.Second) + old.Close() + }() + } + + // Recreate Coolify clients + registry.SwapCoolify(coolify.NewMultiClient(newCfg.CoolifyHosts)) + + // Recreate auth service from file config (env-based auth is immutable) + fc := manager.FileConfigSnapshot() + if manager.Sources().Auth == config.SourceFile && fc.Auth != nil { + registry.SwapAuth(auth.NewServiceFromFileConfig(fc.Auth)) + } + + log.Println("Configuration reloaded successfully") + }) + routerOpts := &api.RouterOptions{ AlertMonitor: alertMonitor, } - apiRouter := api.NewRouter(multiHostClient, authService, cfg, routerOpts) + apiRouter := api.NewRouter(registry, manager, routerOpts) log.Println("Server starting on :6789") if err := http.ListenAndServe(":6789", apiRouter); err != nil { diff --git a/home/internal/api/auth_handlers.go b/home/internal/api/auth_handlers.go deleted file mode 100644 index 80b9047b..00000000 --- a/home/internal/api/auth_handlers.go +++ /dev/null @@ -1,73 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - - "github.com/hhftechnology/vps-monitor/internal/auth" - "github.com/hhftechnology/vps-monitor/internal/models" -) - -type AuthHandlers struct { - authService *auth.Service -} - -func NewAuthHandlers(authService *auth.Service) *AuthHandlers { - return &AuthHandlers{ - authService: authService, - } -} - -func (ah *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { - var loginReq models.LoginRequest - - if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if loginReq.Username == "" || loginReq.Password == "" { - http.Error(w, "Username and password are required", http.StatusBadRequest) - return - } - - if err := ah.authService.ValidateCredentials(loginReq.Username, loginReq.Password); err != nil { - http.Error(w, "Invalid username or password", http.StatusUnauthorized) - return - } - - token, err := ah.authService.GenerateToken(loginReq.Username) - if err != nil { - http.Error(w, "Failed to generate token", http.StatusInternalServerError) - return - } - - response := models.LoginResponse{ - Token: token, - User: models.User{ - Username: loginReq.Username, - Role: "admin", - }, - } - - WriteJsonResponse(w, http.StatusOK, response) -} - -// GetMe returns the current authenticated user's information -func (ah *AuthHandlers) GetMe(w http.ResponseWriter, r *http.Request) { - userValue := r.Context().Value(auth.UserContextKey) - if userValue == nil { - http.Error(w, "User not found in context", http.StatusUnauthorized) - return - } - - user, ok := userValue.(models.User) - if !ok { - http.Error(w, "Invalid user data in context", http.StatusInternalServerError) - return - } - - WriteJsonResponse(w, http.StatusOK, map[string]interface{}{ - "user": user, - }) -} diff --git a/home/internal/api/handlers.go b/home/internal/api/handlers.go index 9531e5cd..26713c36 100644 --- a/home/internal/api/handlers.go +++ b/home/internal/api/handlers.go @@ -4,12 +4,14 @@ import ( "context" "encoding/json" "fmt" + "log" "net/http" "regexp" "strconv" "time" "github.com/go-chi/chi/v5" + "github.com/hhftechnology/vps-monitor/internal/coolify" "github.com/hhftechnology/vps-monitor/internal/models" "github.com/hhftechnology/vps-monitor/internal/system" ) @@ -26,38 +28,45 @@ func (ar *APIRouter) GetSystemStats(w http.ResponseWriter, r *http.Request) { } // Override hostname if configured - if ar.config.Hostname != "" { - stats.HostInfo.Hostname = ar.config.Hostname + cfg := ar.registry.Config() + if cfg.Hostname != "" { + stats.HostInfo.Hostname = cfg.Hostname } WriteJsonResponse(w, http.StatusOK, stats) } func (ar *APIRouter) GetContainers(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - containersMap, hostErrors, err := ar.docker.ListContainersAllHosts(ctx) + containersMap, hostErrors, err := ar.registry.Docker().ListContainersAllHosts(ctx) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if len(hostErrors) > 0 { - http.Error(w, fmt.Sprintf("Error listing containers on some hosts: %v", hostErrors), http.StatusInternalServerError) - return - } - // Flatten the map for easier frontend consumption allContainers := []models.ContainerInfo{} for _, containers := range containersMap { allContainers = append(allContainers, containers...) } + // Build host errors list for the frontend (graceful partial results) + hostErrorMessages := make([]map[string]string, 0, len(hostErrors)) + for _, he := range hostErrors { + hostErrorMessages = append(hostErrorMessages, map[string]string{ + "host": he.HostName, + "message": he.Err.Error(), + }) + } + WriteJsonResponse(w, http.StatusOK, map[string]any{ - "containers": allContainers, - "hosts": ar.docker.GetHosts(), - "readOnly": ar.config.ReadOnly, + "containers": allContainers, + "hosts": ar.registry.Docker().GetHosts(), + "readOnly": ar.registry.Config().ReadOnly, + "hostErrors": hostErrorMessages, + "coolifyConfigured": ar.registry.Coolify() != nil, }) } @@ -70,15 +79,12 @@ func (ar *APIRouter) GetContainer(w http.ResponseWriter, r *http.Request) { return } - inspect, err := ar.docker.GetContainer(r.Context(), host, id) + inspect, err := ar.registry.Docker().GetContainer(r.Context(), host, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - // Transform InspectResponse into a flat structure matching the frontend ContainerDetailData interface. - // The raw InspectResponse has "State" as an object (e.g. {Status:"running", Running:true}), - // but the frontend expects "state" as a simple string like the list endpoint returns. name := inspect.Name if len(name) > 0 && name[0] == '/' { name = name[1:] @@ -130,7 +136,7 @@ func (ar *APIRouter) StartContainer(w http.ResponseWriter, r *http.Request) { return } - err := ar.docker.StartContainer(r.Context(), host, id) + err := ar.registry.Docker().StartContainer(r.Context(), host, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -149,7 +155,7 @@ func (ar *APIRouter) StopContainer(w http.ResponseWriter, r *http.Request) { return } - err := ar.docker.StopContainer(r.Context(), host, id) + err := ar.registry.Docker().StopContainer(r.Context(), host, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -168,7 +174,7 @@ func (ar *APIRouter) RestartContainer(w http.ResponseWriter, r *http.Request) { return } - err := ar.docker.RestartContainer(r.Context(), host, id) + err := ar.registry.Docker().RestartContainer(r.Context(), host, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -187,7 +193,7 @@ func (ar *APIRouter) RemoveContainer(w http.ResponseWriter, r *http.Request) { return } - err := ar.docker.RemoveContainer(r.Context(), host, id) + err := ar.registry.Docker().RemoveContainer(r.Context(), host, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -206,7 +212,6 @@ func (ar *APIRouter) GetContainerLogsParsed(w http.ResponseWriter, r *http.Reque return } - // Parse query parameters for log options options := parseLogOptions(r) if options.Follow { @@ -214,7 +219,7 @@ func (ar *APIRouter) GetContainerLogsParsed(w http.ResponseWriter, r *http.Reque return } - logs, err := ar.docker.GetContainerLogsParsed(host, id, options) + logs, err := ar.registry.Docker().GetContainerLogsParsed(host, id, options) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -227,7 +232,7 @@ func (ar *APIRouter) GetContainerLogsParsed(w http.ResponseWriter, r *http.Reque } func (ar *APIRouter) streamParsedLogs(w http.ResponseWriter, host, id string, options models.LogOptions) { - stream, err := ar.docker.StreamContainerLogsParsed(host, id, options) + stream, err := ar.registry.Docker().StreamContainerLogsParsed(host, id, options) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -308,7 +313,7 @@ func (ar *APIRouter) GetEnvVariables(w http.ResponseWriter, r *http.Request) { return } - envVariables, err := ar.docker.GetEnvVariables(r.Context(), host, id) + envVariables, err := ar.registry.Docker().GetEnvVariables(r.Context(), host, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -334,7 +339,6 @@ func (ar *APIRouter) UpdateEnvVariables(w http.ResponseWriter, r *http.Request) return } - // Validate environment variable keys (using pre-compiled regex) for key := range envVariables.Env { if !envKeyRegex.MatchString(key) { http.Error(w, fmt.Sprintf("invalid environment variable key: %s", key), http.StatusBadRequest) @@ -342,14 +346,35 @@ func (ar *APIRouter) UpdateEnvVariables(w http.ResponseWriter, r *http.Request) } } - newContainerID, err := ar.docker.SetEnvVariables(r.Context(), host, id, envVariables.Env) + newContainerID, labels, err := ar.registry.Docker().SetEnvVariables(r.Context(), host, id, envVariables.Env) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - WriteJsonResponse(w, http.StatusOK, map[string]any{ + response := map[string]any{ "message": "Environment variables updated", "new_container_id": newContainerID, - }) + } + + // Best-effort sync to Coolify API + coolifyMulti := ar.registry.Coolify() + if coolifyMulti != nil { + coolifyClient := coolifyMulti.GetClient(host) + coolifyResource := coolify.ExtractResourceInfo(labels) + if coolifyClient != nil && coolifyResource != nil { + syncCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if syncErr := coolifyClient.SyncEnvVars(syncCtx, coolifyResource, envVariables.Env); syncErr != nil { + log.Printf("Warning: failed to sync env vars to Coolify for host %s: %v", host, syncErr) + response["coolify_synced"] = false + response["coolify_error"] = syncErr.Error() + } else { + response["coolify_synced"] = true + } + } + } + + WriteJsonResponse(w, http.StatusOK, response) } diff --git a/home/internal/api/image_handlers.go b/home/internal/api/image_handlers.go index 62001b32..ceb02671 100644 --- a/home/internal/api/image_handlers.go +++ b/home/internal/api/image_handlers.go @@ -19,27 +19,32 @@ func (ar *APIRouter) GetImages(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() - imagesMap, hostErrors, err := ar.docker.ListImagesAllHosts(ctx) + imagesMap, hostErrors, err := ar.registry.Docker().ListImagesAllHosts(ctx) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if len(hostErrors) > 0 { - http.Error(w, fmt.Sprintf("Error listing images on some hosts: %v", hostErrors), http.StatusInternalServerError) - return - } - // Flatten the map for easier frontend consumption allImages := []models.ImageInfo{} for _, images := range imagesMap { allImages = append(allImages, images...) } + // Build host errors list (graceful partial results) + hostErrorMessages := make([]map[string]string, 0, len(hostErrors)) + for _, he := range hostErrors { + hostErrorMessages = append(hostErrorMessages, map[string]string{ + "host": he.HostName, + "message": he.Err.Error(), + }) + } + WriteJsonResponse(w, http.StatusOK, map[string]any{ - "images": allImages, - "hosts": ar.docker.GetHosts(), - "readOnly": ar.config.ReadOnly, + "images": allImages, + "hosts": ar.registry.Docker().GetHosts(), + "readOnly": ar.registry.Config().ReadOnly, + "hostErrors": hostErrorMessages, }) } @@ -60,7 +65,7 @@ func (ar *APIRouter) GetImage(w http.ResponseWriter, r *http.Request) { return } - image, err := ar.docker.GetImage(r.Context(), host, id) + image, err := ar.registry.Docker().GetImage(r.Context(), host, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -91,7 +96,7 @@ func (ar *APIRouter) RemoveImage(w http.ResponseWriter, r *http.Request) { force, _ := strconv.ParseBool(forceStr) - result, err := ar.docker.RemoveImage(r.Context(), host, id, force) + result, err := ar.registry.Docker().RemoveImage(r.Context(), host, id, force) if err != nil { http.Error(w, fmt.Sprintf("Failed to remove image: %v", err), http.StatusInternalServerError) return @@ -118,7 +123,7 @@ func (ar *APIRouter) PullImage(w http.ResponseWriter, r *http.Request) { return } - reader, err := ar.docker.PullImage(r.Context(), host, imageName) + reader, err := ar.registry.Docker().PullImage(r.Context(), host, imageName) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/home/internal/api/middleware/readonly.go b/home/internal/api/middleware/readonly.go index f8d216bb..4eceff12 100644 --- a/home/internal/api/middleware/readonly.go +++ b/home/internal/api/middleware/readonly.go @@ -3,15 +3,14 @@ package middleware import ( "encoding/json" "net/http" - - "github.com/hhftechnology/vps-monitor/internal/config" ) -// ReadOnly creates a middleware that blocks mutating requests when in read-only mode -func ReadOnly(cfg *config.Config) func(http.Handler) http.Handler { +// ReadOnly creates a middleware that blocks mutating requests when in read-only mode. +// The isReadOnly function is evaluated per request, supporting hot-reload. +func ReadOnly(isReadOnly func() bool) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if cfg.ReadOnly { + if isReadOnly() { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) diff --git a/home/internal/api/network_handlers.go b/home/internal/api/network_handlers.go index e125a1e6..3a7a3063 100644 --- a/home/internal/api/network_handlers.go +++ b/home/internal/api/network_handlers.go @@ -2,7 +2,6 @@ package api import ( "context" - "fmt" "net/http" "time" @@ -15,26 +14,31 @@ func (ar *APIRouter) GetNetworks(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() - networksMap, hostErrors, err := ar.docker.ListNetworksAllHosts(ctx) + networksMap, hostErrors, err := ar.registry.Docker().ListNetworksAllHosts(ctx) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if len(hostErrors) > 0 { - http.Error(w, fmt.Sprintf("Error listing networks on some hosts: %v", hostErrors), http.StatusInternalServerError) - return - } - // Flatten the map for easier frontend consumption allNetworks := []models.NetworkInfo{} for _, networks := range networksMap { allNetworks = append(allNetworks, networks...) } + // Build host errors list (graceful partial results) + hostErrorMessages := make([]map[string]string, 0, len(hostErrors)) + for _, he := range hostErrors { + hostErrorMessages = append(hostErrorMessages, map[string]string{ + "host": he.HostName, + "message": he.Err.Error(), + }) + } + WriteJsonResponse(w, http.StatusOK, map[string]any{ - "networks": allNetworks, - "hosts": ar.docker.GetHosts(), + "networks": allNetworks, + "hosts": ar.registry.Docker().GetHosts(), + "hostErrors": hostErrorMessages, }) } @@ -48,7 +52,7 @@ func (ar *APIRouter) GetNetwork(w http.ResponseWriter, r *http.Request) { return } - network, err := ar.docker.GetNetworkDetails(r.Context(), host, id) + network, err := ar.registry.Docker().GetNetworkDetails(r.Context(), host, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/home/internal/api/router.go b/home/internal/api/router.go index 3d0d4ee7..419f74f0 100644 --- a/home/internal/api/router.go +++ b/home/internal/api/router.go @@ -13,8 +13,8 @@ import ( "github.com/hhftechnology/vps-monitor/internal/api/middleware" "github.com/hhftechnology/vps-monitor/internal/auth" "github.com/hhftechnology/vps-monitor/internal/config" - "github.com/hhftechnology/vps-monitor/internal/docker" "github.com/hhftechnology/vps-monitor/internal/models" + "github.com/hhftechnology/vps-monitor/internal/services" "github.com/hhftechnology/vps-monitor/internal/static" ) @@ -27,10 +27,8 @@ var jsonBufferPool = sync.Pool{ type APIRouter struct { router *chi.Mux - docker *docker.MultiHostClient - authService *auth.Service - config *config.Config - alertMonitor *alerts.Monitor + registry *services.Registry + manager *config.Manager alertHandlers *AlertHandlers } @@ -39,33 +37,31 @@ type RouterOptions struct { AlertMonitor *alerts.Monitor } -func NewRouter(docker *docker.MultiHostClient, authService *auth.Service, config *config.Config, opts *RouterOptions) *chi.Mux { +func NewRouter(registry *services.Registry, manager *config.Manager, opts *RouterOptions) *chi.Mux { + cfg := registry.Config() + r := &APIRouter{ - router: chi.NewRouter(), - docker: docker, - authService: authService, - config: config, + router: chi.NewRouter(), + registry: registry, + manager: manager, } - // Set up alert handlers if monitor is provided + // Set up alert handlers if opts != nil && opts.AlertMonitor != nil { - r.alertMonitor = opts.AlertMonitor r.alertHandlers = NewAlertHandlers(opts.AlertMonitor, &models.AlertConfigResponse{ - Enabled: config.Alerts.Enabled, - CPUThreshold: config.Alerts.CPUThreshold, - MemoryThreshold: config.Alerts.MemoryThreshold, - CheckInterval: config.Alerts.CheckInterval.String(), - WebhookEnabled: config.Alerts.WebhookURL != "", + Enabled: cfg.Alerts.Enabled, + CPUThreshold: cfg.Alerts.CPUThreshold, + MemoryThreshold: cfg.Alerts.MemoryThreshold, + CheckInterval: cfg.Alerts.CheckInterval.String(), + WebhookEnabled: cfg.Alerts.WebhookURL != "", }) } else { - // Create handlers with nil monitor (alerts disabled) but preserve the - // configured/default thresholds so clients can still display accurate config. r.alertHandlers = NewAlertHandlers(nil, &models.AlertConfigResponse{ - Enabled: config.Alerts.Enabled, - CPUThreshold: config.Alerts.CPUThreshold, - MemoryThreshold: config.Alerts.MemoryThreshold, - CheckInterval: config.Alerts.CheckInterval.String(), - WebhookEnabled: config.Alerts.WebhookURL != "", + Enabled: cfg.Alerts.Enabled, + CPUThreshold: cfg.Alerts.CPUThreshold, + MemoryThreshold: cfg.Alerts.MemoryThreshold, + CheckInterval: cfg.Alerts.CheckInterval.String(), + WebhookEnabled: cfg.Alerts.WebhookURL != "", }) } @@ -99,37 +95,29 @@ func (ar *APIRouter) Routes() *chi.Mux { // API routes ar.router.Route("/api/v1", func(r chi.Router) { - // System stats - publicly available for now + // System stats - publicly available r.Get("/system/stats", ar.GetSystemStats) - if ar.authService != nil { - authHandlers := NewAuthHandlers(ar.authService) - r.Post("/auth/login", authHandlers.Login) + // Auth login - always registered, dynamic behavior + r.Post("/auth/login", ar.handleLogin) - r.Group(func(protected chi.Router) { - protected.Use(auth.Middleware(ar.authService)) + // Settings endpoints (protected by dynamic auth) + ar.registerSettingsRoutes(r) - protected.Get("/auth/me", authHandlers.GetMe) - // protected.Get("/system/stats", ar.GetSystemStats) // Moved to public - protected.Post("/devices/register", ar.RegisterDevice) - ar.registerContainerRoutes(protected) - ar.registerImageRoutes(protected) - ar.registerNetworkRoutes(protected) - ar.registerAlertRoutes(protected) - }) - return - } + // All other routes go through dynamic auth middleware + r.Group(func(protected chi.Router) { + protected.Use(auth.DynamicMiddleware(ar.registry.Auth)) - // r.Get("/system/stats", ar.GetSystemStats) // Already registered above - r.Post("/devices/register", ar.RegisterDevice) - ar.registerContainerRoutes(r) - ar.registerImageRoutes(r) - ar.registerNetworkRoutes(r) - ar.registerAlertRoutes(r) + protected.Get("/auth/me", ar.handleGetMe) + protected.Post("/devices/register", ar.RegisterDevice) + ar.registerContainerRoutes(protected) + ar.registerImageRoutes(protected) + ar.registerNetworkRoutes(protected) + ar.registerAlertRoutes(protected) + }) }) // Serve embedded frontend static files - // This handles all non-API routes and serves the React SPA staticFS, err := static.GetFileSystem() if err != nil { log.Printf("Warning: Could not load embedded frontend files: %v", err) @@ -149,12 +137,14 @@ func (ar *APIRouter) registerContainerRoutes(r chi.Router) { r.Get("/", ar.GetContainer) r.Get("/logs/parsed", ar.GetContainerLogsParsed) r.Get("/env", ar.GetEnvVariables) - r.Get("/stats", ar.HandleContainerStats) // WebSocket for streaming stats - r.Get("/stats/once", ar.GetContainerStatsOnce) // Single snapshot + r.Get("/stats", ar.HandleContainerStats) + r.Get("/stats/once", ar.GetContainerStatsOnce) // Mutating routes (blocked in read-only mode) r.Group(func(mutating chi.Router) { - mutating.Use(middleware.ReadOnly(ar.config)) + mutating.Use(middleware.ReadOnly(func() bool { + return ar.registry.Config().ReadOnly + })) mutating.Post("/start", ar.StartContainer) mutating.Post("/stop", ar.StopContainer) mutating.Post("/restart", ar.RestartContainer) @@ -172,14 +162,18 @@ func (ar *APIRouter) registerImageRoutes(r chi.Router) { // Mutating routes (blocked in read-only mode) r.Group(func(mutating chi.Router) { - mutating.Use(middleware.ReadOnly(ar.config)) + mutating.Use(middleware.ReadOnly(func() bool { + return ar.registry.Config().ReadOnly + })) mutating.Delete("/", ar.RemoveImage) }) }) // Image pull (mutating) r.Group(func(mutating chi.Router) { - mutating.Use(middleware.ReadOnly(ar.config)) + mutating.Use(middleware.ReadOnly(func() bool { + return ar.registry.Config().ReadOnly + })) mutating.Post("/images/pull", ar.PullImage) }) } @@ -195,3 +189,72 @@ func (ar *APIRouter) registerAlertRoutes(r chi.Router) { r.Post("/alerts/{id}/acknowledge", ar.alertHandlers.AcknowledgeAlert) r.Post("/alerts/acknowledge-all", ar.alertHandlers.AcknowledgeAllAlerts) } + +func (ar *APIRouter) registerSettingsRoutes(r chi.Router) { + r.Route("/settings", func(r chi.Router) { + r.Use(auth.DynamicMiddleware(ar.registry.Auth)) + + r.Get("/", ar.GetSettings) + r.Put("/docker-hosts", ar.UpdateDockerHosts) + r.Put("/coolify-hosts", ar.UpdateCoolifyHosts) + r.Put("/read-only", ar.UpdateReadOnly) + r.Put("/auth", ar.UpdateAuth) + r.Post("/test/docker-host", ar.TestDockerHost) + r.Post("/test/coolify-host", ar.TestCoolifyHost) + }) +} + +// handleLogin delegates to the dynamic auth service. +func (ar *APIRouter) handleLogin(w http.ResponseWriter, r *http.Request) { + svc := ar.registry.Auth() + if svc == nil { + http.Error(w, "Authentication is not enabled", http.StatusNotFound) + return + } + + var loginReq struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if loginReq.Username == "" || loginReq.Password == "" { + http.Error(w, "Username and password are required", http.StatusBadRequest) + return + } + + if err := svc.ValidateCredentials(loginReq.Username, loginReq.Password); err != nil { + http.Error(w, "Invalid username or password", http.StatusUnauthorized) + return + } + + token, err := svc.GenerateToken(loginReq.Username) + if err != nil { + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + WriteJsonResponse(w, http.StatusOK, map[string]any{ + "token": token, + "user": map[string]string{ + "username": loginReq.Username, + "role": "admin", + }, + }) +} + +// handleGetMe returns the current authenticated user's information. +func (ar *APIRouter) handleGetMe(w http.ResponseWriter, r *http.Request) { + userValue := r.Context().Value(auth.UserContextKey) + if userValue == nil { + http.Error(w, "User not found in context", http.StatusUnauthorized) + return + } + + WriteJsonResponse(w, http.StatusOK, map[string]any{ + "user": userValue, + }) +} diff --git a/home/internal/api/settings_handlers.go b/home/internal/api/settings_handlers.go new file mode 100644 index 00000000..bb099893 --- /dev/null +++ b/home/internal/api/settings_handlers.go @@ -0,0 +1,401 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "regexp" + "strings" + "time" + + "github.com/hhftechnology/vps-monitor/internal/auth" + "github.com/hhftechnology/vps-monitor/internal/config" + "github.com/hhftechnology/vps-monitor/internal/coolify" + "github.com/hhftechnology/vps-monitor/internal/docker" +) + +const secretMask = "••••••••" + +var hostNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) + +// GetSettings returns the current configuration with source tracking and masked secrets. +func (ar *APIRouter) GetSettings(w http.ResponseWriter, r *http.Request) { + sources := ar.manager.Sources() + cfg := ar.registry.Config() + fc := ar.manager.FileConfigSnapshot() + + // Docker hosts with per-entry source + envDockerNames := ar.manager.EnvDockerHostNames() + dockerHosts := make([]map[string]any, 0, len(cfg.DockerHosts)) + for _, h := range cfg.DockerHosts { + source := config.SourceFile + if envDockerNames[h.Name] { + source = config.SourceEnv + } + dockerHosts = append(dockerHosts, map[string]any{ + "name": h.Name, + "host": h.Host, + "source": source, + }) + } + + // Coolify hosts with per-entry source (mask tokens) + envCoolifyNames := ar.manager.EnvCoolifyHostNames() + coolifyHosts := make([]map[string]any, 0, len(cfg.CoolifyHosts)) + for _, ch := range cfg.CoolifyHosts { + source := config.SourceFile + if envCoolifyNames[ch.HostName] { + source = config.SourceEnv + } + coolifyHosts = append(coolifyHosts, map[string]any{ + "hostName": ch.HostName, + "apiURL": ch.APIURL, + "apiToken": secretMask, + "source": source, + }) + } + + // Auth + authResp := map[string]any{ + "source": sources.Auth, + "enabled": false, + } + if sources.Auth == config.SourceEnv { + svc := ar.registry.Auth() + authResp["enabled"] = svc != nil + } else if fc.Auth != nil { + authResp["enabled"] = fc.Auth.Enabled + authResp["adminUsername"] = fc.Auth.AdminUsername + } + + WriteJsonResponse(w, http.StatusOK, map[string]any{ + "dockerHosts": map[string]any{ + "source": sources.DockerHosts, + "hosts": dockerHosts, + }, + "coolifyHosts": map[string]any{ + "source": sources.CoolifyHosts, + "hosts": coolifyHosts, + }, + "readOnly": map[string]any{ + "source": sources.ReadOnly, + "value": cfg.ReadOnly, + }, + "auth": authResp, + }) +} + +// UpdateDockerHosts handles PUT /api/v1/settings/docker-hosts. +func (ar *APIRouter) UpdateDockerHosts(w http.ResponseWriter, r *http.Request) { + var req struct { + Hosts []config.DockerHost `json:"hosts"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if len(req.Hosts) == 0 && len(ar.manager.EnvDockerHostNames()) == 0 { + http.Error(w, "at least one Docker host is required", http.StatusBadRequest) + return + } + + seen := make(map[string]bool) + for _, h := range req.Hosts { + if !hostNameRegex.MatchString(h.Name) { + http.Error(w, fmt.Sprintf("invalid host name: %q", h.Name), http.StatusBadRequest) + return + } + if !isValidHostScheme(h.Host) { + http.Error(w, fmt.Sprintf("invalid host URL: %q (must start with unix://, ssh://, or tcp://)", h.Host), http.StatusBadRequest) + return + } + if seen[h.Name] { + http.Error(w, fmt.Sprintf("duplicate host name: %q", h.Name), http.StatusBadRequest) + return + } + seen[h.Name] = true + } + + if err := ar.manager.UpdateDockerHosts(req.Hosts); err != nil { + http.Error(w, err.Error(), settingsErrorStatus(err)) + return + } + + WriteJsonResponse(w, http.StatusOK, map[string]any{"message": "Docker hosts updated"}) +} + +// UpdateCoolifyHosts handles PUT /api/v1/settings/coolify-hosts. +func (ar *APIRouter) UpdateCoolifyHosts(w http.ResponseWriter, r *http.Request) { + var req struct { + Hosts []struct { + HostName string `json:"hostName"` + APIURL string `json:"apiURL"` + APIToken string `json:"apiToken"` + } `json:"hosts"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + existing := ar.manager.FileConfigSnapshot() + existingMap := make(map[string]string) + for _, ch := range existing.CoolifyHosts { + existingMap[ch.HostName] = ch.APIToken + } + + hosts := make([]config.CoolifyHostConfig, 0, len(req.Hosts)) + seen := make(map[string]bool) + for _, h := range req.Hosts { + if h.HostName == "" || h.APIURL == "" || h.APIToken == "" { + http.Error(w, "hostName, apiURL, and apiToken are required for each entry", http.StatusBadRequest) + return + } + if !hostNameRegex.MatchString(h.HostName) { + http.Error(w, fmt.Sprintf("invalid host name: %q", h.HostName), http.StatusBadRequest) + return + } + if !isValidCoolifyURL(h.APIURL) { + http.Error(w, fmt.Sprintf("invalid API URL: %q (must start with http:// or https://)", h.APIURL), http.StatusBadRequest) + return + } + if seen[h.HostName] { + http.Error(w, fmt.Sprintf("duplicate host name: %q", h.HostName), http.StatusBadRequest) + return + } + seen[h.HostName] = true + + token := h.APIToken + if token == secretMask { + if stored, ok := existingMap[h.HostName]; ok { + token = stored + } else { + http.Error(w, fmt.Sprintf("no existing token for host %q; provide the actual token", h.HostName), http.StatusBadRequest) + return + } + } + + hosts = append(hosts, config.CoolifyHostConfig{ + HostName: h.HostName, + APIURL: strings.TrimRight(h.APIURL, "/"), + APIToken: token, + }) + } + + if err := ar.manager.UpdateCoolifyHosts(hosts); err != nil { + http.Error(w, err.Error(), settingsErrorStatus(err)) + return + } + + WriteJsonResponse(w, http.StatusOK, map[string]any{"message": "Coolify hosts updated"}) +} + +// UpdateReadOnly handles PUT /api/v1/settings/read-only. +func (ar *APIRouter) UpdateReadOnly(w http.ResponseWriter, r *http.Request) { + var req struct { + Value bool `json:"value"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if err := ar.manager.UpdateReadOnly(req.Value); err != nil { + http.Error(w, err.Error(), settingsErrorStatus(err)) + return + } + + WriteJsonResponse(w, http.StatusOK, map[string]any{"message": "Read-only mode updated"}) +} + +// UpdateAuth handles PUT /api/v1/settings/auth. +func (ar *APIRouter) UpdateAuth(w http.ResponseWriter, r *http.Request) { + var req struct { + Enabled bool `json:"enabled"` + AdminUsername string `json:"adminUsername"` + NewPassword string `json:"newPassword,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Enabled && req.AdminUsername == "" { + http.Error(w, "adminUsername is required when enabling auth", http.StatusBadRequest) + return + } + + err := ar.manager.UpdateAuth(func(authCfg *config.FileAuthConfig) (*config.FileAuthConfig, error) { + authCfg.Enabled = req.Enabled + + if req.Enabled { + authCfg.AdminUsername = req.AdminUsername + + if authCfg.JWTSecret == "" { + secret, err := auth.GenerateRandomHex(32) + if err != nil { + return nil, fmt.Errorf("failed to generate JWT secret") + } + authCfg.JWTSecret = secret + } + + if req.NewPassword != "" { + salt, err := auth.GenerateRandomHex(32) + if err != nil { + return nil, fmt.Errorf("failed to generate salt") + } + authCfg.AdminPasswordSalt = salt + authCfg.AdminPasswordHash = auth.HashPasswordSHA256(req.NewPassword, salt) + } + + if authCfg.AdminPasswordHash == "" { + return nil, fmt.Errorf("newPassword is required when first enabling auth") + } + } + + return authCfg, nil + }) + + if err != nil { + http.Error(w, err.Error(), settingsErrorStatus(err)) + return + } + + WriteJsonResponse(w, http.StatusOK, map[string]any{"message": "Auth settings updated"}) +} + +// TestDockerHost handles POST /api/v1/settings/test/docker-host. +func (ar *APIRouter) TestDockerHost(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Host string `json:"host"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Host == "" { + http.Error(w, "host is required", http.StatusBadRequest) + return + } + + if !isValidHostScheme(req.Host) { + http.Error(w, "invalid host URL (must start with unix://, ssh://, or tcp://)", http.StatusBadRequest) + return + } + + tempClient, err := docker.NewMultiHostClient([]config.DockerHost{ + {Name: "test", Host: req.Host}, + }) + if err != nil { + WriteJsonResponse(w, http.StatusOK, map[string]any{ + "success": false, + "message": fmt.Sprintf("Failed to create client: %v", err), + }) + return + } + defer tempClient.Close() + + cl, err := tempClient.GetClient("test") + if err != nil { + WriteJsonResponse(w, http.StatusOK, map[string]any{ + "success": false, + "message": fmt.Sprintf("Internal error: %v", err), + }) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + ping, err := cl.Ping(ctx) + if err != nil { + WriteJsonResponse(w, http.StatusOK, map[string]any{ + "success": false, + "message": fmt.Sprintf("Connection failed: %v", err), + }) + return + } + + WriteJsonResponse(w, http.StatusOK, map[string]any{ + "success": true, + "message": "Connection successful", + "dockerVersion": ping.APIVersion, + }) +} + +// TestCoolifyHost handles POST /api/v1/settings/test/coolify-host. +func (ar *APIRouter) TestCoolifyHost(w http.ResponseWriter, r *http.Request) { + var req struct { + HostName string `json:"hostName"` + APIURL string `json:"apiURL"` + APIToken string `json:"apiToken"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.APIURL == "" || req.APIToken == "" { + http.Error(w, "apiURL and apiToken are required", http.StatusBadRequest) + return + } + + if !isValidCoolifyURL(req.APIURL) { + http.Error(w, "invalid API URL (must start with http:// or https://)", http.StatusBadRequest) + return + } + + token := req.APIToken + if token == secretMask { + fc := ar.manager.FileConfigSnapshot() + for _, ch := range fc.CoolifyHosts { + if ch.HostName == req.HostName { + token = ch.APIToken + break + } + } + if token == secretMask { + http.Error(w, "no stored token found; provide the actual token", http.StatusBadRequest) + return + } + } + + client := coolify.NewSingleClient(strings.TrimRight(req.APIURL, "/"), token) + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if err := client.TestConnection(ctx); err != nil { + log.Printf("Coolify test connection failed: %v", err) + WriteJsonResponse(w, http.StatusOK, map[string]any{ + "success": false, + "message": fmt.Sprintf("Connection failed: %v", err), + }) + return + } + + WriteJsonResponse(w, http.StatusOK, map[string]any{ + "success": true, + "message": "Connection successful", + }) +} + +func settingsErrorStatus(err error) int { + if strings.Contains(err.Error(), "environment variable") { + return http.StatusConflict + } + return http.StatusInternalServerError +} + +func isValidHostScheme(host string) bool { + return strings.HasPrefix(host, "unix://") || + strings.HasPrefix(host, "ssh://") || + strings.HasPrefix(host, "tcp://") +} + +func isValidCoolifyURL(raw string) bool { + return strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "http://") +} diff --git a/home/internal/api/stats_ws.go b/home/internal/api/stats_ws.go index 006f87ae..c464a7dd 100644 --- a/home/internal/api/stats_ws.go +++ b/home/internal/api/stats_ws.go @@ -37,7 +37,7 @@ func (ar *APIRouter) HandleContainerStats(w http.ResponseWriter, r *http.Request ctx := r.Context() // Start streaming stats from Docker - statsCh, errCh := ar.docker.StreamContainerStats(ctx, host, id) + statsCh, errCh := ar.registry.Docker().StreamContainerStats(ctx, host, id) // Handle WebSocket close from client done := make(chan struct{}) @@ -111,7 +111,7 @@ func (ar *APIRouter) GetContainerStatsOnce(w http.ResponseWriter, r *http.Reques return } - stats, err := ar.docker.GetContainerStatsOnce(r.Context(), host, id) + stats, err := ar.registry.Docker().GetContainerStatsOnce(r.Context(), host, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/home/internal/api/terminal.go b/home/internal/api/terminal.go index cca09b15..023db2a1 100644 --- a/home/internal/api/terminal.go +++ b/home/internal/api/terminal.go @@ -104,12 +104,12 @@ func (ar *APIRouter) HandleTerminal(w http.ResponseWriter, r *http.Request) { } func (ar *APIRouter) startExecSession(ctx context.Context, host, containerID string) (string, *types.HijackedResponse, error) { - execID, err := ar.docker.CreateExec(ctx, host, containerID) + execID, err := ar.registry.Docker().CreateExec(ctx, host, containerID) if err != nil { return "", nil, fmt.Errorf("create exec failed: %w", err) } - resp, err := ar.docker.AttachExec(ctx, host, execID) + resp, err := ar.registry.Docker().AttachExec(ctx, host, execID) if err != nil { return "", nil, fmt.Errorf("attach exec failed: %w", err) } @@ -165,7 +165,7 @@ func (ar *APIRouter) forwardClientInput( if messageType == websocket.TextMessage { var msg ResizeMessage if err := json.Unmarshal(data, &msg); err == nil && msg.Type == "resize" { - if err := ar.docker.ResizeExec(ctx, host, execID, msg.Rows, msg.Cols); err != nil { + if err := ar.registry.Docker().ResizeExec(ctx, host, execID, msg.Rows, msg.Cols); err != nil { log.Printf("failed to resize terminal: %v", err) } continue diff --git a/home/internal/auth/middleware.go b/home/internal/auth/middleware.go index fb3aa8a1..1f27a968 100644 --- a/home/internal/auth/middleware.go +++ b/home/internal/auth/middleware.go @@ -11,44 +11,62 @@ type contextKey string const UserContextKey contextKey = "user" -// Middleware creates an authentication middleware -func Middleware(authService *Service) func(http.Handler) http.Handler { +// DynamicMiddleware resolves the auth service per request, supporting hot-reload. +// If the service function returns nil, auth is disabled and the request passes through. +func DynamicMiddleware(getService func() *Service) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - var tokenString string - - if authHeader != "" { - // Check if the header starts with "Bearer " - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || parts[0] != "Bearer" { - http.Error(w, "Invalid authorization header format", http.StatusUnauthorized) - return - } - tokenString = parts[1] - } else { - // Try to get token from query parameter (useful for WebSockets) - tokenString = r.URL.Query().Get("token") - if tokenString == "" { - http.Error(w, "Authorization header or token query parameter required", http.StatusUnauthorized) - return - } - } - - claims, err := authService.VerifyToken(tokenString) - if err != nil { - if errors.Is(err, ErrTokenExpired) { - http.Error(w, "Token has expired", http.StatusUnauthorized) - return - } - http.Error(w, "Invalid token", http.StatusUnauthorized) + svc := getService() + if svc == nil { + next.ServeHTTP(w, r) return } - - user := GetUserFromClaims(claims) - ctx := context.WithValue(r.Context(), UserContextKey, user) - - next.ServeHTTP(w, r.WithContext(ctx)) + validateAndServe(svc, next, w, r) }) } } + +// Middleware creates an authentication middleware with a fixed auth service. +func Middleware(authService *Service) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + validateAndServe(authService, next, w, r) + }) + } +} + +// validateAndServe extracts JWT, validates it, adds user to context, and calls next. +func validateAndServe(svc *Service, next http.Handler, w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + var tokenString string + + if authHeader != "" { + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + http.Error(w, "Invalid authorization header format", http.StatusUnauthorized) + return + } + tokenString = parts[1] + } else { + tokenString = r.URL.Query().Get("token") + if tokenString == "" { + http.Error(w, "Authorization header or token query parameter required", http.StatusUnauthorized) + return + } + } + + claims, err := svc.VerifyToken(tokenString) + if err != nil { + if errors.Is(err, ErrTokenExpired) { + http.Error(w, "Token has expired", http.StatusUnauthorized) + return + } + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + user := GetUserFromClaims(claims) + ctx := context.WithValue(r.Context(), UserContextKey, user) + + next.ServeHTTP(w, r.WithContext(ctx)) +} diff --git a/home/internal/auth/service.go b/home/internal/auth/service.go index d12cd114..5f5ae063 100644 --- a/home/internal/auth/service.go +++ b/home/internal/auth/service.go @@ -1,6 +1,7 @@ package auth import ( + "crypto/rand" "crypto/sha256" "encoding/hex" "errors" @@ -8,6 +9,7 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + "github.com/hhftechnology/vps-monitor/internal/config" "github.com/hhftechnology/vps-monitor/internal/models" "golang.org/x/crypto/bcrypt" ) @@ -134,6 +136,39 @@ func GetUserFromClaims(claims *Claims) models.User { } } +// NewServiceFromFileConfig creates an auth service from file-based config. +// Returns nil if the config is nil, disabled, or incomplete. +func NewServiceFromFileConfig(cfg *config.FileAuthConfig) *Service { + if cfg == nil || !cfg.Enabled { + return nil + } + if cfg.JWTSecret == "" || cfg.AdminUsername == "" || cfg.AdminPasswordHash == "" { + return nil + } + return &Service{ + jwtSecret: []byte(cfg.JWTSecret), + adminUsername: cfg.AdminUsername, + adminPasswordHash: cfg.AdminPasswordHash, + sha256Salt: cfg.AdminPasswordSalt, + tokenExpiration: 7 * 24 * time.Hour, + } +} + +// HashPasswordSHA256 computes SHA256(password + salt) and returns the hex-encoded hash. +func HashPasswordSHA256(password, salt string) string { + h := sha256.Sum256([]byte(password + salt)) + return hex.EncodeToString(h[:]) +} + +// GenerateRandomHex generates a cryptographically random hex string of the specified byte length. +func GenerateRandomHex(byteLen int) (string, error) { + b := make([]byte, byteLen) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + // HashPassword generates a bcrypt hash from a plain password func HashPassword(password string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) diff --git a/home/internal/config/config.go b/home/internal/config/config.go index c102bb27..d9038b69 100644 --- a/home/internal/config/config.go +++ b/home/internal/config/config.go @@ -9,8 +9,24 @@ import ( ) type DockerHost struct { - Name string - Host string + Name string `json:"name"` + Host string `json:"host"` +} + +// CoolifyHostConfig maps a Docker host name to a Coolify API instance. +type CoolifyHostConfig struct { + HostName string `json:"hostName"` + APIURL string `json:"apiURL"` + APIToken string `json:"apiToken"` +} + +// FileAuthConfig represents auth settings stored in the config file. +type FileAuthConfig struct { + Enabled bool `json:"enabled"` + JWTSecret string `json:"jwtSecret,omitempty"` + AdminUsername string `json:"adminUsername,omitempty"` + AdminPasswordHash string `json:"adminPasswordHash,omitempty"` + AdminPasswordSalt string `json:"adminPasswordSalt,omitempty"` } // AlertConfig holds configuration for the alerting system @@ -23,16 +39,18 @@ type AlertConfig struct { } type Config struct { - ReadOnly bool - Hostname string // Optional override for displayed hostname - DockerHosts []DockerHost - Alerts AlertConfig + ReadOnly bool + Hostname string // Optional override for displayed hostname + DockerHosts []DockerHost + CoolifyHosts []CoolifyHostConfig + Alerts AlertConfig } func NewConfig() *Config { isReadOnlyMode := os.Getenv("READONLY_MODE") == "true" hostname := os.Getenv("HOSTNAME_OVERRIDE") // Custom display hostname dockerHosts := parseDockerHosts() + coolifyHosts := parseCoolifyHostConfigs() alertConfig := parseAlertConfig() // if we don't have any docker hosts, we should default back to @@ -41,11 +59,23 @@ func NewConfig() *Config { dockerHosts = []DockerHost{{Name: "local", Host: "unix:///var/run/docker.sock"}} } + // Warn if Coolify hosts reference unknown Docker hosts + dockerHostNames := make(map[string]bool, len(dockerHosts)) + for _, dh := range dockerHosts { + dockerHostNames[dh.Name] = true + } + for _, ch := range coolifyHosts { + if !dockerHostNames[ch.HostName] { + log.Printf("Warning: COOLIFY_CONFIGS references unknown Docker host %q", ch.HostName) + } + } + return &Config{ - ReadOnly: isReadOnlyMode, - Hostname: hostname, - DockerHosts: dockerHosts, - Alerts: alertConfig, + ReadOnly: isReadOnlyMode, + Hostname: hostname, + DockerHosts: dockerHosts, + CoolifyHosts: coolifyHosts, + Alerts: alertConfig, } } @@ -79,6 +109,47 @@ func parseAlertConfig() AlertConfig { return config } +func parseCoolifyHostConfigs() []CoolifyHostConfig { + // Format: COOLIFY_CONFIGS=hostA|https://coolify-a.com|tokenA,hostB|https://coolify-b.com|tokenB + raw := os.Getenv("COOLIFY_CONFIGS") + if raw == "" { + return nil + } + + var configs []CoolifyHostConfig + seen := make(map[string]bool) + + for entry := range strings.SplitSeq(raw, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + parts := strings.SplitN(entry, "|", 3) + if len(parts) != 3 { + log.Fatalf("Invalid COOLIFY_CONFIGS format: %s (expected: hostName|apiURL|apiToken)", entry) + } + hostName := strings.TrimSpace(parts[0]) + apiURL := strings.TrimRight(strings.TrimSpace(parts[1]), "/") + apiToken := strings.TrimSpace(parts[2]) + + if hostName == "" || apiURL == "" || apiToken == "" { + log.Fatalf("Invalid COOLIFY_CONFIGS format: %s (all fields must be non-empty)", entry) + } + if seen[hostName] { + log.Fatalf("Duplicate Coolify host name in COOLIFY_CONFIGS: %s", hostName) + } + seen[hostName] = true + + configs = append(configs, CoolifyHostConfig{ + HostName: hostName, + APIURL: apiURL, + APIToken: apiToken, + }) + } + + return configs +} + func parseDockerHosts() []DockerHost { // Format: DOCKER_HOSTS=local=unix:///var/run/docker.sock,remote=ssh://root@X.X.X.X dockerHosts := os.Getenv("DOCKER_HOSTS") diff --git a/home/internal/config/manager.go b/home/internal/config/manager.go new file mode 100644 index 00000000..0d54d545 --- /dev/null +++ b/home/internal/config/manager.go @@ -0,0 +1,394 @@ +package config + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sync" +) + +// FileConfig represents the JSON config file structure. +type FileConfig struct { + DockerHosts []DockerHost `json:"dockerHosts,omitempty"` + CoolifyHosts []CoolifyHostConfig `json:"coolifyHosts,omitempty"` + ReadOnly *bool `json:"readOnly,omitempty"` + Auth *FileAuthConfig `json:"auth,omitempty"` +} + +// Source indicates where a config value came from. +type Source string + +const ( + SourceEnv Source = "env" + SourceFile Source = "file" + SourceDefault Source = "default" + SourceMixed Source = "mixed" +) + +// EnvSnapshot captures which env vars are set at startup. +type EnvSnapshot struct { + DockerHostsSet bool + CoolifySet bool + ReadOnlySet bool + AuthSet bool +} + +// Manager handles loading, merging, and persisting configuration. +type Manager struct { + mu sync.RWMutex + filePath string + envSnapshot EnvSnapshot + envConfig *Config // config derived purely from env vars + fileConfig FileConfig // config from file + merged *Config // final merged config + sources ConfigSources // per-category source tracking + onChange []func(*Config) // callbacks when config changes + generation uint64 // incremented on each merge to detect stale callbacks +} + +// ConfigSources tracks the source of each config category. +type ConfigSources struct { + DockerHosts Source `json:"dockerHosts"` + CoolifyHosts Source `json:"coolifyHosts"` + ReadOnly Source `json:"readOnly"` + Auth Source `json:"auth"` +} + +// NewManager creates a config manager that loads from env vars and an optional file. +func NewManager() *Manager { + filePath := os.Getenv("CONFIG_PATH") + if filePath == "" { + filePath = "/data/config.json" + } + + envSnapshot := EnvSnapshot{ + DockerHostsSet: os.Getenv("DOCKER_HOSTS") != "", + CoolifySet: os.Getenv("COOLIFY_CONFIGS") != "", + ReadOnlySet: os.Getenv("READONLY_MODE") != "", + AuthSet: os.Getenv("JWT_SECRET") != "" || + os.Getenv("ADMIN_USERNAME") != "" || + os.Getenv("ADMIN_PASSWORD") != "", + } + + // Load env-based config using existing parsers. + envConfig := NewConfig() + + m := &Manager{ + filePath: filePath, + envSnapshot: envSnapshot, + envConfig: envConfig, + } + + // Load file config (if it exists). + m.fileConfig = m.loadFile() + + // Merge and compute sources. + m.merged, m.sources = m.merge() + + return m +} + +// Config returns the current merged config (thread-safe). +func (m *Manager) Config() *Config { + m.mu.RLock() + defer m.mu.RUnlock() + return m.merged +} + +// Sources returns the source tracking for each category. +func (m *Manager) Sources() ConfigSources { + m.mu.RLock() + defer m.mu.RUnlock() + return m.sources +} + +// FileConfigSnapshot returns the current file config (for reading stored secrets). +func (m *Manager) FileConfigSnapshot() FileConfig { + m.mu.RLock() + defer m.mu.RUnlock() + return m.fileConfig +} + +// OnChange registers a callback invoked after any config update. +func (m *Manager) OnChange(fn func(*Config)) { + m.mu.Lock() + defer m.mu.Unlock() + m.onChange = append(m.onChange, fn) +} + +// EnvDockerHostNames returns the set of Docker host names defined via env vars. +func (m *Manager) EnvDockerHostNames() map[string]bool { + m.mu.RLock() + defer m.mu.RUnlock() + names := make(map[string]bool) + if m.envSnapshot.DockerHostsSet { + for _, h := range m.envConfig.DockerHosts { + names[h.Name] = true + } + } + return names +} + +// EnvCoolifyHostNames returns the set of Coolify host names defined via env vars. +func (m *Manager) EnvCoolifyHostNames() map[string]bool { + m.mu.RLock() + defer m.mu.RUnlock() + names := make(map[string]bool) + if m.envSnapshot.CoolifySet { + for _, h := range m.envConfig.CoolifyHosts { + names[h.HostName] = true + } + } + return names +} + +// UpdateDockerHosts updates the file-defined Docker hosts. +func (m *Manager) UpdateDockerHosts(hosts []DockerHost) error { + m.mu.Lock() + + if m.envSnapshot.DockerHostsSet { + envNames := make(map[string]bool) + for _, h := range m.envConfig.DockerHosts { + envNames[h.Name] = true + } + for _, h := range hosts { + if envNames[h.Name] { + m.mu.Unlock() + return fmt.Errorf("host %q is defined via environment variable and cannot be managed from the UI", h.Name) + } + } + } + + oldDockerHosts := m.fileConfig.DockerHosts + m.fileConfig.DockerHosts = hosts + if err := m.persist(); err != nil { + m.fileConfig.DockerHosts = oldDockerHosts + m.mu.Unlock() + return err + } + m.remerge() + return nil +} + +// UpdateCoolifyHosts updates the file-defined Coolify hosts. +func (m *Manager) UpdateCoolifyHosts(hosts []CoolifyHostConfig) error { + m.mu.Lock() + + if m.envSnapshot.CoolifySet { + envNames := make(map[string]bool) + for _, h := range m.envConfig.CoolifyHosts { + envNames[h.HostName] = true + } + for _, h := range hosts { + if envNames[h.HostName] { + m.mu.Unlock() + return fmt.Errorf("coolify host %q is defined via environment variable and cannot be managed from the UI", h.HostName) + } + } + } + + oldCoolifyHosts := m.fileConfig.CoolifyHosts + m.fileConfig.CoolifyHosts = hosts + if err := m.persist(); err != nil { + m.fileConfig.CoolifyHosts = oldCoolifyHosts + m.mu.Unlock() + return err + } + m.remerge() + return nil +} + +// UpdateReadOnly updates the read-only setting in the file config. +func (m *Manager) UpdateReadOnly(readOnly bool) error { + m.mu.Lock() + + if m.envSnapshot.ReadOnlySet { + m.mu.Unlock() + return fmt.Errorf("read-only mode is configured via environment variable and cannot be changed from the UI") + } + + oldReadOnly := m.fileConfig.ReadOnly + m.fileConfig.ReadOnly = &readOnly + if err := m.persist(); err != nil { + m.fileConfig.ReadOnly = oldReadOnly + m.mu.Unlock() + return err + } + m.remerge() + return nil +} + +// UpdateAuth applies a mutation function to the current file auth config atomically. +func (m *Manager) UpdateAuth(mutate func(current *FileAuthConfig) (*FileAuthConfig, error)) error { + m.mu.Lock() + + if m.envSnapshot.AuthSet { + m.mu.Unlock() + return fmt.Errorf("auth is configured via environment variables and cannot be changed from the UI") + } + + oldAuth := m.fileConfig.Auth + current := &FileAuthConfig{} + if oldAuth != nil { + *current = *oldAuth + } + + updated, err := mutate(current) + if err != nil { + m.mu.Unlock() + return err + } + + m.fileConfig.Auth = updated + if err := m.persist(); err != nil { + m.fileConfig.Auth = oldAuth + m.mu.Unlock() + return err + } + m.remerge() + return nil +} + +// merge produces the merged config and source tracking. Must be called with lock held. +func (m *Manager) merge() (*Config, ConfigSources) { + cfg := &Config{} + sources := ConfigSources{} + + // Preserve vps-monitor specific fields from env config + cfg.Hostname = m.envConfig.Hostname + cfg.Alerts = m.envConfig.Alerts + + // Docker hosts: env hosts + file hosts combined. Env hosts win on name collision. + envDockerNames := make(map[string]bool) + if m.envSnapshot.DockerHostsSet { + for _, h := range m.envConfig.DockerHosts { + cfg.DockerHosts = append(cfg.DockerHosts, h) + envDockerNames[h.Name] = true + } + } + for _, h := range m.fileConfig.DockerHosts { + if !envDockerNames[h.Name] { + cfg.DockerHosts = append(cfg.DockerHosts, h) + } + } + if len(cfg.DockerHosts) == 0 { + cfg.DockerHosts = []DockerHost{{Name: "local", Host: "unix:///var/run/docker.sock"}} + sources.DockerHosts = SourceDefault + } else if m.envSnapshot.DockerHostsSet && len(m.fileConfig.DockerHosts) > 0 { + sources.DockerHosts = SourceMixed + } else if m.envSnapshot.DockerHostsSet { + sources.DockerHosts = SourceEnv + } else { + sources.DockerHosts = SourceFile + } + + // Coolify hosts: same merge strategy. + envCoolifyNames := make(map[string]bool) + if m.envSnapshot.CoolifySet { + for _, h := range m.envConfig.CoolifyHosts { + cfg.CoolifyHosts = append(cfg.CoolifyHosts, h) + envCoolifyNames[h.HostName] = true + } + } + for _, h := range m.fileConfig.CoolifyHosts { + if !envCoolifyNames[h.HostName] { + cfg.CoolifyHosts = append(cfg.CoolifyHosts, h) + } + } + if len(cfg.CoolifyHosts) == 0 { + sources.CoolifyHosts = SourceDefault + } else if m.envSnapshot.CoolifySet && len(m.fileConfig.CoolifyHosts) > 0 { + sources.CoolifyHosts = SourceMixed + } else if m.envSnapshot.CoolifySet { + sources.CoolifyHosts = SourceEnv + } else { + sources.CoolifyHosts = SourceFile + } + + // Read-only + if m.envSnapshot.ReadOnlySet { + cfg.ReadOnly = m.envConfig.ReadOnly + sources.ReadOnly = SourceEnv + } else if m.fileConfig.ReadOnly != nil { + cfg.ReadOnly = *m.fileConfig.ReadOnly + sources.ReadOnly = SourceFile + } else { + cfg.ReadOnly = false + sources.ReadOnly = SourceDefault + } + + // Auth + if m.envSnapshot.AuthSet { + sources.Auth = SourceEnv + } else if m.fileConfig.Auth != nil { + sources.Auth = SourceFile + } else { + sources.Auth = SourceDefault + } + + return cfg, sources +} + +// remerge re-merges config then unlocks the mutex before firing callbacks. +// Callers must hold the write lock. The lock is released by this function. +func (m *Manager) remerge() { + m.generation++ + gen := m.generation + m.merged, m.sources = m.merge() + cfg := m.merged + cbs := make([]func(*Config), len(m.onChange)) + copy(cbs, m.onChange) + m.mu.Unlock() + for _, fn := range cbs { + m.mu.RLock() + stale := m.generation != gen + m.mu.RUnlock() + if stale { + return + } + fn(cfg) + } +} + +// loadFile reads the config file from disk. +func (m *Manager) loadFile() FileConfig { + data, err := os.ReadFile(m.filePath) + if err != nil { + if os.IsNotExist(err) { + return FileConfig{} + } + log.Fatalf("Failed to read config file %s: %v", m.filePath, err) + } + + var fc FileConfig + if err := json.Unmarshal(data, &fc); err != nil { + log.Fatalf("Failed to parse config file %s: %v\nIf the file is corrupted, delete it and restart.", m.filePath, err) + } + return fc +} + +// persist writes the file config to disk atomically. Must be called with lock held. +func (m *Manager) persist() error { + data, err := json.MarshalIndent(m.fileConfig, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + dir := filepath.Dir(m.filePath) + if err := os.MkdirAll(dir, 0750); err != nil { + return fmt.Errorf("failed to create config directory %s: %w", dir, err) + } + + tmp := m.filePath + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + os.Remove(tmp) + return fmt.Errorf("failed to write temp config file: %w", err) + } + if err := os.Rename(tmp, m.filePath); err != nil { + os.Remove(tmp) + return fmt.Errorf("failed to rename config file: %w", err) + } + return nil +} diff --git a/home/internal/coolify/client.go b/home/internal/coolify/client.go new file mode 100644 index 00000000..fb52b00a --- /dev/null +++ b/home/internal/coolify/client.go @@ -0,0 +1,225 @@ +package coolify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/hhftechnology/vps-monitor/internal/config" +) + +type ResourceType string + +const ( + ResourceTypeApplication ResourceType = "application" + ResourceTypeService ResourceType = "service" + ResourceTypeDatabase ResourceType = "database" +) + +type ResourceInfo struct { + Type ResourceType + UUID string +} + +type Client struct { + apiURL string + apiToken string + httpClient *http.Client +} + +func newClient(apiURL, apiToken string) *Client { + return &Client{ + apiURL: apiURL, + apiToken: apiToken, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +// MultiClient routes Coolify API calls to the correct per-host client. +type MultiClient struct { + clients map[string]*Client +} + +// NewMultiClient creates a MultiClient from per-host configs. +// Returns nil if no configs are provided. +func NewMultiClient(hostConfigs []config.CoolifyHostConfig) *MultiClient { + if len(hostConfigs) == 0 { + return nil + } + + clients := make(map[string]*Client, len(hostConfigs)) + for _, hc := range hostConfigs { + clients[hc.HostName] = newClient(hc.APIURL, hc.APIToken) + } + + return &MultiClient{clients: clients} +} + +// GetClient returns the Coolify client for the given Docker host name. +// Returns nil if no config exists for that host. +func (mc *MultiClient) GetClient(hostName string) *Client { + if mc == nil { + return nil + } + return mc.clients[hostName] +} + +// TestConnection checks if the Coolify API is reachable by calling /api/v1/version. +func (c *Client) TestConnection(ctx context.Context) error { + if c == nil { + return fmt.Errorf("coolify client is nil") + } + url := fmt.Sprintf("%s/api/v1/version", c.apiURL) + _, err := c.doRequest(ctx, "GET", url, nil) + if err != nil { + return fmt.Errorf("coolify API unreachable: %w", err) + } + return nil +} + +// NewSingleClient creates a single Coolify client for testing connections. +func NewSingleClient(apiURL, apiToken string) *Client { + return newClient(apiURL, apiToken) +} + +// ExtractResourceInfo checks container labels for Coolify management info. +// Returns nil if the container is not managed by Coolify. +func ExtractResourceInfo(labels map[string]string) *ResourceInfo { + if labels["coolify.managed"] != "true" { + return nil + } + + uuid := labels["com.docker.compose.project"] + if uuid == "" { + return nil + } + + switch labels["coolify.type"] { + case "application": + return &ResourceInfo{Type: ResourceTypeApplication, UUID: uuid} + case "service": + return &ResourceInfo{Type: ResourceTypeService, UUID: uuid} + case "database": + return &ResourceInfo{Type: ResourceTypeDatabase, UUID: uuid} + default: + return &ResourceInfo{Type: ResourceTypeApplication, UUID: uuid} + } +} + +type envVarEntry struct { + Key string `json:"key"` + Value string `json:"value"` + IsPreview bool `json:"is_preview"` +} + +type bulkEnvPayload struct { + Data []envVarEntry `json:"data"` +} + +type coolifyEnvVar struct { + UUID string `json:"uuid"` + Key string `json:"key"` +} + +// SyncEnvVars syncs environment variables to Coolify's API so they persist +// across redeployments. It upserts new/changed vars and deletes removed ones. +func (c *Client) SyncEnvVars(ctx context.Context, resource *ResourceInfo, envVars map[string]string) error { + if c == nil || resource == nil { + return nil + } + + if resource.Type == ResourceTypeDatabase { + return fmt.Errorf("coolify: syncing env vars for database resources is not supported") + } + + // 1. Fetch current env vars from Coolify to find deletions + existing, err := c.listEnvVars(ctx, resource) + if err != nil { + return fmt.Errorf("coolify: failed to fetch existing env vars: %w", err) + } + + // 2. Delete env vars that no longer exist + for _, ev := range existing { + if _, exists := envVars[ev.Key]; !exists { + if delErr := c.deleteEnvVar(ctx, resource, ev.UUID); delErr != nil { + log.Printf("Warning: failed to delete Coolify env var %s (%s): %v", ev.Key, ev.UUID, delErr) + } + } + } + + // 3. Bulk upsert the current env vars + entries := make([]envVarEntry, 0, len(envVars)) + for key, value := range envVars { + entries = append(entries, envVarEntry{Key: key, Value: value, IsPreview: false}) + } + + body, err := json.Marshal(bulkEnvPayload{Data: entries}) + if err != nil { + return fmt.Errorf("coolify: failed to marshal env vars: %w", err) + } + + url := fmt.Sprintf("%s/api/v1/%ss/%s/envs/bulk", c.apiURL, resource.Type, resource.UUID) + if _, err := c.doRequest(ctx, http.MethodPatch, url, body); err != nil { + return fmt.Errorf("coolify: bulk update failed: %w", err) + } + + return nil +} + +func (c *Client) listEnvVars(ctx context.Context, resource *ResourceInfo) ([]coolifyEnvVar, error) { + url := fmt.Sprintf("%s/api/v1/%ss/%s/envs", c.apiURL, resource.Type, resource.UUID) + + respBody, err := c.doRequest(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + var envVars []coolifyEnvVar + if err := json.Unmarshal(respBody, &envVars); err != nil { + return nil, err + } + return envVars, nil +} + +func (c *Client) deleteEnvVar(ctx context.Context, resource *ResourceInfo, envUUID string) error { + url := fmt.Sprintf("%s/api/v1/%ss/%s/envs/%s", c.apiURL, resource.Type, resource.UUID, envUUID) + _, err := c.doRequest(ctx, http.MethodDelete, url, nil) + return err +} + +func (c *Client) doRequest(ctx context.Context, method, url string, body []byte) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.apiToken) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + return respBody, nil +} diff --git a/home/internal/docker/client.go b/home/internal/docker/client.go index a002fd20..2f684a87 100644 --- a/home/internal/docker/client.go +++ b/home/internal/docker/client.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" "sync" + "time" "github.com/docker/cli/cli/connhelper" "github.com/docker/docker/api/types/container" @@ -38,6 +39,7 @@ func NewMultiHostClient(hosts []config.DockerHost) (*MultiHostClient, error) { Transport: &http.Transport{ DialContext: helper.Dialer, }, + Timeout: 10 * time.Second, } apiClient, err = client.NewClientWithOpts( @@ -156,3 +158,10 @@ func (c *MultiHostClient) GetClient(hostName string) (*client.Client, error) { func (c *MultiHostClient) GetHosts() []config.DockerHost { return c.hosts } + +// Close closes all underlying Docker API clients. +func (c *MultiHostClient) Close() { + for _, cl := range c.clients { + cl.Close() + } +} diff --git a/home/internal/docker/container.go b/home/internal/docker/container.go index 284b14e1..5617f234 100644 --- a/home/internal/docker/container.go +++ b/home/internal/docker/container.go @@ -74,17 +74,20 @@ func (c *MultiHostClient) GetEnvVariables(ctx context.Context, hostName, id stri return envMap, nil } -func (c *MultiHostClient) SetEnvVariables(ctx context.Context, hostName, id string, envVariables map[string]string) (string, error) { +func (c *MultiHostClient) SetEnvVariables(ctx context.Context, hostName, id string, envVariables map[string]string) (string, map[string]string, error) { apiClient, err := c.GetClient(hostName) if err != nil { - return "", err + return "", nil, err } inspect, err := apiClient.ContainerInspect(ctx, id) if err != nil { - return "", err + return "", nil, err } + // Store labels before modifying the container (needed for Coolify sync) + labels := inspect.Config.Labels + envMap := make(map[string]string) // First, load all existing env vars from the container config for _, env := range inspect.Config.Env { @@ -114,12 +117,12 @@ func (c *MultiHostClient) SetEnvVariables(ctx context.Context, hostName, id stri err = apiClient.ContainerStop(ctx, id, container.StopOptions{}) if err != nil { - return "", err + return "", nil, err } err = apiClient.ContainerRemove(ctx, id, container.RemoveOptions{}) if err != nil { - return "", err + return "", nil, err } newConfig := inspect.Config @@ -136,13 +139,13 @@ func (c *MultiHostClient) SetEnvVariables(ctx context.Context, hostName, id stri containerName, ) if err != nil { - return "", err + return "", nil, err } err = apiClient.ContainerStart(ctx, resp.ID, container.StartOptions{}) if err != nil { - return "", err + return "", nil, err } - return resp.ID, nil + return resp.ID, labels, nil } diff --git a/home/internal/services/registry.go b/home/internal/services/registry.go new file mode 100644 index 00000000..9645db33 --- /dev/null +++ b/home/internal/services/registry.go @@ -0,0 +1,94 @@ +package services + +import ( + "sync" + + "github.com/hhftechnology/vps-monitor/internal/alerts" + "github.com/hhftechnology/vps-monitor/internal/auth" + "github.com/hhftechnology/vps-monitor/internal/config" + "github.com/hhftechnology/vps-monitor/internal/coolify" + "github.com/hhftechnology/vps-monitor/internal/docker" +) + +// Registry holds all runtime services behind a RWMutex, allowing hot-swap. +type Registry struct { + mu sync.RWMutex + docker *docker.MultiHostClient + coolify *coolify.MultiClient + auth *auth.Service + config *config.Config + alerts *alerts.Monitor +} + +// NewRegistry creates a registry with the initial set of services. +func NewRegistry( + dockerClient *docker.MultiHostClient, + coolifyClient *coolify.MultiClient, + authService *auth.Service, + cfg *config.Config, + alertMonitor *alerts.Monitor, +) *Registry { + return &Registry{ + docker: dockerClient, + coolify: coolifyClient, + auth: authService, + config: cfg, + alerts: alertMonitor, + } +} + +func (r *Registry) Docker() *docker.MultiHostClient { + r.mu.RLock() + defer r.mu.RUnlock() + return r.docker +} + +func (r *Registry) Coolify() *coolify.MultiClient { + r.mu.RLock() + defer r.mu.RUnlock() + return r.coolify +} + +func (r *Registry) Auth() *auth.Service { + r.mu.RLock() + defer r.mu.RUnlock() + return r.auth +} + +func (r *Registry) Config() *config.Config { + r.mu.RLock() + defer r.mu.RUnlock() + return r.config +} + +func (r *Registry) Alerts() *alerts.Monitor { + r.mu.RLock() + defer r.mu.RUnlock() + return r.alerts +} + +func (r *Registry) SwapDocker(newClient *docker.MultiHostClient) *docker.MultiHostClient { + r.mu.Lock() + defer r.mu.Unlock() + old := r.docker + r.docker = newClient + return old +} + +func (r *Registry) SwapCoolify(newClient *coolify.MultiClient) { + r.mu.Lock() + defer r.mu.Unlock() + r.coolify = newClient +} + +func (r *Registry) SwapAuth(newService *auth.Service) { + r.mu.Lock() + defer r.mu.Unlock() + r.auth = newService +} + +func (r *Registry) UpdateConfig(cfg *config.Config) { + r.mu.Lock() + defer r.mu.Unlock() + r.config = cfg +}