From 638fbdb22a2e2d5e6f7ae9e7e54c313b50165dc9 Mon Sep 17 00:00:00 2001 From: tm4rtin17 Date: Fri, 8 May 2026 15:28:10 -0700 Subject: [PATCH 01/25] logs: docker-logs fallback + clean 503 + Tailscale banner fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PublicBindBanner: treat 100.64.0.0/10 (Tailscale CGNAT) as private so the destructive "public-looking address" warning no longer fires when reaching ControlRoom over Tailscale. - internal/logs: add Available() (cached exec.LookPath); /api/logs/journal and /ws/logs/journal now return 503 with a clear operator message when journalctl is missing instead of bubbling up an exec error as 500. - main: warn at boot when journalctl is not in PATH, matching the existing systemd-unavailable warning. - web/Logs: source toggle (Journal / Containers). Containers mode reuses /api/containers + the existing /ws/containers/:id/logs WebSocket — no new backend route — with picker, client-side substring filter, pause, and 1k-line cap. Journal-mode errors now surface the real backend message instead of "Could not fetch logs." - deploy/docker-compose: add group_add: ["${DOCKER_GID:-999}"] so the nonroot uid 65532 can read /var/run/docker.sock; refresh the header comment to match the new logs behavior. --- cmd/controlroom/main.go | 4 + deploy/docker-compose.yml | 14 +- internal/api/logs/logs.go | 12 + internal/logs/journal.go | 14 ++ web/src/components/PublicBindBanner.tsx | 5 + web/src/routes/Logs.tsx | 300 +++++++++++++++++++++--- 6 files changed, 316 insertions(+), 33 deletions(-) diff --git a/cmd/controlroom/main.go b/cmd/controlroom/main.go index 070f885..013e244 100644 --- a/cmd/controlroom/main.go +++ b/cmd/controlroom/main.go @@ -33,6 +33,7 @@ import ( "github.com/tm4rtin17/controlroom/internal/config" "github.com/tm4rtin17/controlroom/internal/docker" "github.com/tm4rtin17/controlroom/internal/jobs" + "github.com/tm4rtin17/controlroom/internal/logs" "github.com/tm4rtin17/controlroom/internal/store" "github.com/tm4rtin17/controlroom/internal/systemd" ) @@ -90,6 +91,9 @@ func run() error { if sysdErr != nil { logger.Warn().Err(sysdErr).Msg("systemd unavailable; /api/services disabled") } + if !logs.Available() { + logger.Warn().Msg("journalctl not in PATH; /api/logs/journal will return 503") + } if sysd != nil { defer func() { _ = sysd.Close() }() } diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 02eaaa4..422c797 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,10 +1,13 @@ # ControlRoom — minimal compose file. # # Notes for container deployment: -# - systemd dbus and journalctl are not available inside the container, -# so /api/services and /api/logs return 503. Use the bare-metal install -# (deploy/install.sh) if you need those. -# - Mount the Docker socket read-only to manage other containers. +# - systemd dbus and journalctl are not available inside the container, so +# /api/services and /api/logs/journal return 503. The Logs page falls +# back to streaming docker container logs; install bare metal +# (deploy/install.sh) if you also need journal logs. +# - The Docker socket is mounted read-only. The container runs as nonroot +# (uid 65532), so we add the host's docker group via group_add — set +# DOCKER_GID to your host's docker group GID (`getent group docker`). # - For Let's Encrypt: set CR_TLS_MODE=acme + CR_ACME_HOST + CR_ACME_EMAIL, # and expose port 80 below for the HTTP-01 challenge. @@ -13,6 +16,9 @@ services: image: ghcr.io/tm4rtin17/controlroom:latest container_name: controlroom restart: unless-stopped + # Grant access to /var/run/docker.sock for the nonroot user. + group_add: + - "${DOCKER_GID:-999}" ports: - "8443:8443" # Uncomment when CR_TLS_MODE=acme to serve HTTP-01 challenges: diff --git a/internal/api/logs/logs.go b/internal/api/logs/logs.go index 116ccf3..9789127 100644 --- a/internal/api/logs/logs.go +++ b/internal/api/logs/logs.go @@ -62,6 +62,9 @@ type queryResp struct { } func (d Deps) queryHandler(c *fiber.Ctx) error { + if !logs.Available() { + return fiber.NewError(http.StatusServiceUnavailable, journalUnavailableMsg) + } entries, err := logs.Query(c.Context(), filterFromQuery(c)) if err != nil { return fiber.NewError(http.StatusInternalServerError, "journal query: "+err.Error()) @@ -69,6 +72,10 @@ func (d Deps) queryHandler(c *fiber.Ctx) error { return c.JSON(queryResp{Entries: entries}) } +// journalUnavailableMsg is shown to the operator when journalctl can't be +// invoked (e.g. distroless container). The SPA surfaces it verbatim. +const journalUnavailableMsg = "journal logs unavailable on this host (journalctl not found) — switch to Containers or run ControlRoom on bare metal" + // ---- live tail ---- // queryFrom is a tiny shim because we can't reuse fiber.Ctx here; we read @@ -90,6 +97,11 @@ func queryFromConn(c *websocket.Conn) logs.Filter { func (d Deps) tailWS(c *websocket.Conn) { defer func() { _ = c.Close() }() + if !logs.Available() { + _ = c.WriteJSON(fiber.Map{"type": "error", "err": journalUnavailableMsg}) + return + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/internal/logs/journal.go b/internal/logs/journal.go index 0dc8101..d3a3130 100644 --- a/internal/logs/journal.go +++ b/internal/logs/journal.go @@ -15,6 +15,7 @@ import ( "os/exec" "regexp" "strconv" + "sync" "syscall" "time" ) @@ -40,6 +41,19 @@ type Filter struct { N int // last-N entries (0 → no limit on tail mode; 200 default for static) } +// Available reports whether `journalctl` is present in PATH. The result is +// cached for the lifetime of the process; we don't expect journalctl to come +// or go after boot. +// +// In container deployments (distroless image) this is false, and the API +// surface should return 503 rather than 500 with a confusing exec error. +func Available() bool { return availableOnce() } + +var availableOnce = sync.OnceValue(func() bool { + _, err := exec.LookPath("journalctl") + return err == nil +}) + // validUnit blocks shell injection through the unit name without locking us // down to one filename pattern. var validUnit = regexp.MustCompile(`^[a-zA-Z0-9@_.\-:\\]+\.(service|socket|target|timer|path|mount|slice|scope)$`) diff --git a/web/src/components/PublicBindBanner.tsx b/web/src/components/PublicBindBanner.tsx index c783ff4..58c430e 100644 --- a/web/src/components/PublicBindBanner.tsx +++ b/web/src/components/PublicBindBanner.tsx @@ -41,6 +41,11 @@ function isPubliclyAccessed(host: string): boolean { const second = parseInt(host.split('.')[1] ?? '', 10) if (second >= 16 && second <= 31) return false } + // Tailscale / RFC 6598 CGNAT — 100.64.0.0/10. + if (host.startsWith('100.')) { + const second = parseInt(host.split('.')[1] ?? '', 10) + if (second >= 64 && second <= 127) return false + } // IPv6 unique-local (fc00::/7) and link-local (fe80::/10). const lower = host.toLowerCase() diff --git a/web/src/routes/Logs.tsx b/web/src/routes/Logs.tsx index 6c0c142..2549159 100644 --- a/web/src/routes/Logs.tsx +++ b/web/src/routes/Logs.tsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown, ChevronRight, Pause, Play, RefreshCw, Search } from 'lucide-react' +import { ApiError } from '@/lib/api' +import { type ContainerSummary, type LogFrame, useContainers } from '@/lib/containers' import { type LogEntry, type LogQuery, tailURL, useLogs } from '@/lib/logs' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' @@ -27,47 +29,70 @@ const PRIORITIES = [ { label: 'Debug', value: 7 }, ] +type Source = 'journal' | 'containers' + export function Logs() { + const [source, setSource] = useState('journal') + + return ( +
+
+

Logs

+
+ setSource('journal')}> + Journal + + setSource('containers')}> + Containers + +
+
+ {source === 'journal' ? setSource('containers')} /> : } +
+ ) +} + +function SourceTab({ + active, + onClick, + children, +}: { + active: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +// ---- Journal ---- + +function JournalView({ onSwitchToContainers }: { onSwitchToContainers: () => void }) { const [unit, setUnit] = useState('') const [search, setSearch] = useState('') const [since, setSince] = useState('-15min') const [priority, setPriority] = useState(-1) const [live, setLive] = useState(false) - // Static query when not live; live tail keeps its own state. const query: LogQuery = useMemo( () => ({ unit: unit || undefined, q: search || undefined, since, priority, n: 200 }), [unit, search, since, priority] ) const stat = useLogs(query, !live) - return ( -
-
-

Logs

-
- {!live && ( - - )} - -
-
+ const unavailable = stat.error instanceof ApiError && stat.error.status === 503 + return ( + <>
@@ -124,23 +149,53 @@ export function Logs() { ))}
+
+ {!live && ( + + )} + +
{live ? ( + ) : unavailable ? ( + + +

{stat.error?.message}

+ +
+
) : ( <> {stat.error && ( - Could not fetch logs. + {stat.error.message || 'Could not fetch logs.'} )} )} - + ) } @@ -293,3 +348,190 @@ function formatTimestamp(s: string): string { if (idx < 0) return s return s.slice(idx + 1, idx + 9) } + +// ---- Containers ---- + +interface DockerLine { + stream: 'stdout' | 'stderr' | 'stdin' + line: string +} + +function ContainersView() { + const list = useContainers() + const [selectedId, setSelectedId] = useState('') + const [search, setSearch] = useState('') + const [paused, setPaused] = useState(false) + + const containers = list.data?.containers ?? [] + // Auto-pick the first running container the first time the list loads. + useEffect(() => { + if (selectedId || containers.length === 0) return + const running = containers.find((c) => c.state === 'running') ?? containers[0] + if (running) setSelectedId(running.id) + }, [containers, selectedId]) + + const unavailable = list.error instanceof ApiError && list.error.status === 503 + const errMsg = list.error?.message + + if (unavailable) { + return ( + + + {errMsg ?? 'Docker is not available on this host.'} + + + ) + } + + return ( + <> + + +
+ + +
+
+ +
+ + setSearch(e.target.value)} + placeholder="Filter visible lines (substring)" + className="border-0 bg-transparent shadow-none focus-visible:ring-0" + /> +
+
+
+
+ + {!selectedId ? ( + + + {list.isLoading ? 'Loading containers…' : 'Pick a container to start tailing.'} + + + ) : ( + + )} + + ) +} + +function labelFor(c: ContainerSummary): string { + const tag = c.state === 'running' ? '●' : '○' + return `${tag} ${c.name} (${c.image})` +} + +function DockerLogStream({ + id, + search, + paused, + setPaused, +}: { + id: string + search: string + paused: boolean + setPaused: (v: boolean) => void +}) { + const [lines, setLines] = useState([]) + const [error, setError] = useState(null) + const containerRef = useRef(null) + + useEffect(() => { + setLines([]) + setError(null) + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const ws = new WebSocket(`${proto}//${window.location.host}/ws/containers/${id}/logs`) + ws.onmessage = (evt) => { + try { + const f = JSON.parse(evt.data) as LogFrame + if (f.type === 'error') { + setError(f.err ?? 'log stream error') + return + } + if (f.type === 'line' && f.line !== undefined) { + setLines((prev) => { + const next = [...prev, { stream: (f.stream ?? 'stdout') as DockerLine['stream'], line: f.line! }] + return next.length > MAX_LIVE_ENTRIES ? next.slice(-MAX_LIVE_ENTRIES) : next + }) + } + } catch { + // ignore + } + } + ws.onerror = () => setError('connection error') + return () => ws.close() + }, [id]) + + const filtered = useMemo(() => { + if (!search) return lines + const needle = search.toLowerCase() + return lines.filter((l) => l.line.toLowerCase().includes(needle)) + }, [lines, search]) + + useEffect(() => { + if (paused) return + const el = containerRef.current + if (el) el.scrollTop = el.scrollHeight + }, [filtered, paused]) + + return ( + + +
+ + {filtered.length} + {search ? ` / ${lines.length}` : ''} line{filtered.length === 1 ? '' : 's'} + {paused && ' · paused'} + +
+ + +
+
+ {error && ( + + {error} + + )} +
+ {filtered.length === 0 ? ( +

{lines.length === 0 ? 'Waiting for output…' : 'No lines match the filter.'}

+ ) : ( + filtered.map((l, i) => ( +
+ {l.line} +
+ )) + )} +
+
+
+ ) +} From 8627e0897d39d23aa734dceece88d3f95d81ab73 Mon Sep 17 00:00:00 2001 From: tm4rtin17 Date: Fri, 8 May 2026 15:37:10 -0700 Subject: [PATCH 02/25] gitignore: ignore .claude/ and stray *.crt files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .claude/ — local Claude Code config and project agent definitions (.claude/agents/*.md). These contain working-tree-specific tooling and should not be published. - *.crt — round out the existing *.pem / *.key entries so a stray TLS certificate dropped in the tree can't be committed. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index dab5b87..ce585c2 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ web/dist/* # Stray credential material *.pem *.key +*.crt + +# Local Claude Code config / agent definitions — do not publish +.claude/ From 395b640e96006cb7209b84cba69b933ef0a6d6af Mon Sep 17 00:00:00 2001 From: tm4rtin17 Date: Fri, 8 May 2026 15:51:25 -0700 Subject: [PATCH 03/25] nav: hide Services / Containers tabs when backend lacks the capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /api/system/capabilities — `{systemd, docker, journal}` booleans derived from the existing nil-client / logs.Available() patterns — and threads useCapabilities() into AppLayout to filter the sidebar. In the container deployment this hides the Services tab (no dbus socket mounted, no privilege to drive host systemd) instead of letting the operator click into a dead-end 503. The Containers tab gets the same treatment so a host without /var/run/docker.sock isn't shown a broken tab either. The Logs tab stays visible regardless: it has its own Journal/Containers source toggle and degrades gracefully on its own. --- internal/api/router.go | 7 ++++++- internal/api/system/system.go | 26 ++++++++++++++++++++++++++ web/src/components/AppLayout.tsx | 30 +++++++++++++++++++++++++----- web/src/lib/system.ts | 14 ++++++++++++++ 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index 626e3fb..53d564d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -98,7 +98,12 @@ func NewRouter(d Deps) *fiber.App { ) authapi.MountAuthenticated(guarded, authDeps) - systemDeps := systemapi.Deps{Aggregator: d.Aggregator, Logger: d.Logger} + systemDeps := systemapi.Deps{ + Aggregator: d.Aggregator, + Logger: d.Logger, + SystemD: d.SystemD, + Docker: d.Docker, + } systemapi.MountHTTP(guarded, systemDeps) servicesDeps := servicesapi.Deps{Client: d.SystemD, DB: d.DB, Logger: d.Logger} diff --git a/internal/api/system/system.go b/internal/api/system/system.go index 8bd8a56..4e105c8 100644 --- a/internal/api/system/system.go +++ b/internal/api/system/system.go @@ -14,11 +14,19 @@ import ( "github.com/rs/zerolog" "github.com/tm4rtin17/controlroom/internal/collectors" + "github.com/tm4rtin17/controlroom/internal/docker" + "github.com/tm4rtin17/controlroom/internal/logs" + "github.com/tm4rtin17/controlroom/internal/systemd" ) type Deps struct { Aggregator *collectors.Aggregator Logger zerolog.Logger + // SystemD and Docker are nil-or-set; mirror the same wiring used by the + // services / containers handlers so /api/system/capabilities can report + // which features the SPA should expose. + SystemD systemd.Client + Docker docker.Client } // MountHTTP registers the REST endpoint. Caller is responsible for applying @@ -26,6 +34,24 @@ type Deps struct { func MountHTTP(authed fiber.Router, d Deps) { g := authed.Group("/system") g.Get("/overview", d.overviewHandler) + g.Get("/capabilities", d.capabilitiesHandler) +} + +// capabilitiesResp tells the SPA which feature areas have a working backend +// on this host. The frontend uses it to hide nav entries that would otherwise +// dead-end with a 503 (e.g. the Services tab in container deployments). +type capabilitiesResp struct { + Systemd bool `json:"systemd"` + Docker bool `json:"docker"` + Journal bool `json:"journal"` +} + +func (d Deps) capabilitiesHandler(c *fiber.Ctx) error { + return c.JSON(capabilitiesResp{ + Systemd: d.SystemD != nil, + Docker: d.Docker != nil, + Journal: logs.Available(), + }) } // MountWS registers the WebSocket route. Caller applies auth (cookie-based) diff --git a/web/src/components/AppLayout.tsx b/web/src/components/AppLayout.tsx index 928f25f..cc114a2 100644 --- a/web/src/components/AppLayout.tsx +++ b/web/src/components/AppLayout.tsx @@ -20,15 +20,25 @@ import { Button } from '@/components/ui/button' import { ThemeToggle } from '@/components/ThemeToggle' import { PublicBindBanner } from '@/components/PublicBindBanner' import { useLogout, useMe } from '@/lib/auth' -import { useSystemOverview } from '@/lib/system' +import { type SystemCapabilities, useCapabilities, useSystemOverview } from '@/lib/system' import { cn } from '@/lib/utils' import { useNavigate } from 'react-router-dom' -const NAV: { to: string; label: string; Icon: typeof Activity; ready?: boolean }[] = [ +// `requires` names a backend capability that must be true (per +// /api/system/capabilities) for the entry to render. Unset = always show. +type NavEntry = { + to: string + label: string + Icon: typeof Activity + ready?: boolean + requires?: keyof SystemCapabilities +} + +const NAV: NavEntry[] = [ { to: '/', label: 'Dashboard', Icon: Activity, ready: true }, { to: '/updates', label: 'Updates', Icon: Download, ready: true }, - { to: '/services', label: 'Services', Icon: Cog, ready: true }, - { to: '/containers', label: 'Containers', Icon: Container, ready: true }, + { to: '/services', label: 'Services', Icon: Cog, ready: true, requires: 'systemd' }, + { to: '/containers', label: 'Containers', Icon: Container, ready: true, requires: 'docker' }, { to: '/terminal', label: 'Terminal', Icon: TerminalIcon, ready: true }, { to: '/network', label: 'Network', Icon: Wifi, ready: true }, { to: '/logs', label: 'Logs', Icon: ScrollText, ready: true }, @@ -50,6 +60,16 @@ export function AppLayout({ children }: { children: React.ReactNode }) { } function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) { + const caps = useCapabilities() + // While capabilities are loading we show every entry; if the request fails + // we also fall back to showing them (better to surface a 503 once than to + // hide tabs that should be available). + const visible = NAV.filter((entry) => { + if (!entry.requires) return true + if (!caps.data) return true + return caps.data[entry.requires] + }) + return ( <> {/* Mobile drawer overlay */} @@ -78,7 +98,7 @@ function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) {