Compare commits

...
103 Commits
Author SHA1 Message Date
bobbanandClaude Sonnet 5.5 fae7089448 Document the database schema in DATABASE.md
Every table, column, foreign key and unique index (checked against a database built
from the migrations), the JSON stored in text columns, the settings keys, delete
behaviour, retention and backups, and how to change the schema. Linked from the
README alongside the other documents.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:53:08 +02:00
bobbanandClaude Sonnet 5.5 f246410f24 Encrypt the notification channels' credentials at rest
The Gotify and ntfy tokens, the SMTP password and the webhook secret were stored
as plain text in the settings table. They are now encrypted with the same key as
integration credentials (CREDENTIALS_ENCRYPTION_KEY), marked with an "enc:v1:"
prefix. Settings are decrypted when read and encrypted when written, so nothing
else changes; values saved before this are converted at startup.

An edit that doesn't touch a credential keeps its stored ciphertext, so a wrong or
missing key (which reads as empty) can't be made permanent by an unrelated edit.
Without a key new credentials fall back to plain storage, and the startup warning,
.env.example and the Privacy page say so.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:53:07 +02:00
bobbanandClaude Sonnet 5.5 86dfa9ae2e Unlink servers before deleting a Proxmox integration
servers.proxmox_integration_id was created (migration 0001) without an ON DELETE
rule, although schema.ts asks for "set null", so with foreign keys on, deleting
an integration that a server was linked to failed with a constraint error.
The delete route now clears the four proxmox_* columns on those servers first
and records how many it unlinked in the audit entry. schema.ts notes the gap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:53:06 +02:00
bobbanandClaude Sonnet 5.5 3035d7fc08 Make the Generator's server-name lists editable, add themes, import names from Skatteverket
Name lists now live in settings instead of the web bundle. Admins edit them under
Settings -> Names (add/remove names, create/rename/delete lists, reset a built-in
list). Names are folded to hostname-safe form (a-z, 0-9, hyphen) and validated on
the server; saves are audited.

Adds Swedish boy names, Pixar, Norse mythology and Astrid Lindgren lists, and
lengthens the Swedish girl and Disney lists. "Mixed" is now every list with each
name counted once.

Admins can preview and import the most common Swedish names from Skatteverket's
open "Namn pa nyfodda" data (girls or boys, latest 1-5 full years); nothing is
stored until the editor is saved. The old comment that credited SCB statistics is
gone: SCB stopped publishing name statistics after 2023.

Privacy page, README and ROLES updated for the new outbound call and page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 01:53:31 +02:00
bobbanandClaude Sonnet 5.5 447f33fff6 Add an Alerts page under Operations listing everything that's wrong now
One list of the current problems across servers and integrations, instead
of waiting for a notification or visiting each page: servers that stopped
reporting, full or nearly full disks and volumes (critical from 95%),
Synology volume/disk problems, failed or uncovered Proxmox backups,
failed Proxmox Backup Server verifications, container image updates,
expired or expiring secrets/domains/Tailscale keys, failed Semaphore and
Gitea runs, Uptime Kuma monitors that are down, overdue osTicket tickets,
and integrations whose calls keep failing. Visible to every role, with
severity and kind filters, search, sorting, CSV export and "Check now".

It runs the same checks that send the notifications rather than a second
copy of them: the detection in the health, automation, Proxmox backup,
PBS, Docker update and Tailscale key checks is pulled out into shared
collectors that both the schedulers and the page call, so the two can't
disagree about what counts as a problem. Notification behaviour is
unchanged, including the scheduled backup checks skipping integrations
under a maintenance window. Unlike the notifications the page ignores the
on/off toggles, and keeps problems under a maintenance window, marked
silenced and counted apart.

It reads live, so a result is reused for a minute (and Refresh can't
re-run everything more than once every ten seconds), and every source has
a 20 s limit so one hung integration can't hang the page. Anything it
couldn't read is called out at the top instead of looking like all clear,
and server checks pause for the same 20 minutes after a restart as the
notifications do, with a note saying so.

Also gives the newer integrations (PBS, osTicket, Uptime Kuma, phpIPAM)
proper names in "integration down" notifications instead of their ids.

Verified through the real routes against a scratch database with fake
backends (offline and full-disk servers, secrets and domains, a silenced
server, a fake PBS with failed verification, a hanging integration, a
refused one, a failing-calls streak, caching, the restart grace period,
auth), and by rendering the real page against that data in a browser:
filters, search, silenced toggle, sorting, Check now, dark mode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 23:07:57 +02:00
bobbanandClaude Sonnet 5.5 ad1fb5338f Replace the browser's confirm() and prompt() pop-ups with in-app dialogs
All 31 native dialogs (27 confirms, 4 text prompts: ignore reason, two
"exclude a range" boxes, tag rename) now use the app's own Tabler-styled
modals, which follow the light/dark theme.

A small promise-based API (utils/dialogs.ts: confirmDialog, promptDialog)
and a single DialogHost mounted once in App mean call sites just await
it in place of the browser call - no hooks or per-page modal state. Every
call site was already in an async function, so each is a one-line swap.

Each dialog now has a title and a main button that names the action
("Delete", "Stop now", "Run now") instead of "OK", with destructive ones
in red. Escape cancels, Enter confirms, Tab stays inside the dialog, the
page behind stops scrolling, and focus returns to the button that was
clicked. Destructive dialogs start with focus on Cancel so a stray Enter
can't delete anything. Text prompts pre-select their default and disable
the main button until something is typed where it's required, and still
tell cancelling (null) apart from confirming an empty box (""). Clicking
the backdrop cancels, but releasing a text selection over it doesn't.
Dialogs asked for together appear one after another.

Verified in a browser on a test page using the real component and the
app's real stylesheet: Escape, Enter, Tab trapping, focus handling,
required and optional prompts, backdrop clicks, queuing, scroll lock and
dark mode. The 31 call sites themselves were checked by search and
typecheck rather than clicked through in the logged-in app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 23:07:43 +02:00
bobbanandClaude Sonnet 5.5 88d9c8e097 Record sign-ins, new accounts, automatic log trimming and what settings changed
A check of every write path found gaps in what the audit log captured:

- Sign-ins and sign-outs are now recorded with the IP they came from
  (sign-out is recorded first and can't block signing out).
- A new account is recorded when it's created on first sign-in, including
  when the very first user becomes admin - so the log shows who gained
  access, not only who changed things.
- The automatic log purge, which deletes audit entries, now records
  itself, attributed to "system". recordAudit() takes an optional actor
  for this. It only records when something was actually deleted.
- Settings updates record what changed (before and after) instead of only
  which sections were touched. The notification channels (Gotify, ntfy,
  SMTP, webhook) record field names only: they hold credentials, and
  webhook URLs and public ntfy topics act as secrets, while the audit log
  is readable by operators and Settings is admin-only.
- Integration edits record renames, enabling/disabling, whether
  credentials were replaced (never the credentials), and which settings
  fields changed (names only).

The Privacy page and README now say sign-ins store an IP in the audit log.

Verified through the real routes against a scratch database: user
creation, the logout route, the automatic purge, settings and
integration edits - including that a secret token and a webhook URL
appear nowhere in the stored entries. The sign-in callback itself needs
a real identity provider and wasn't run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 22:40:46 +02:00
bobbanandClaude Sonnet 5.5 22cfdbede0 Fix how the audit log displays entries; record admin-link and domain checks
Entries were stored in UTC without a zone marker and the Audit and
Diagnostic Log pages read them as local time, so every entry showed
shifted by the viewer's UTC offset (two hours early in Sweden). A shared
parseDbTimestamp() now reads them as UTC, and replaces the inline
workaround the Consistency page had.

The Audit Log never displayed an entry's details at all, so adding an
admin link showed only "server #1". Link entries now carry the server
name and the label/URL, and a new Details column shows them, along with
things like a port scan's address and range. Entries made within the
same second are now ordered by id instead of arbitrarily.

A domain's "Check now" was the one user-triggered action that wasn't
audited; it is now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 22:40:35 +02:00
bobbanandClaude Sonnet 5 21054aaa8c Fill an admin link's URL from a server's own known ports
Both places you add an admin link - a server's own Admin Links section
and the Operations > Admin Links summary page - now offer a "fill from
a known port" dropdown once a server is picked, listing its
agent-reported and manually-noted ports (the same data the Ports
page/card shows). Picking one fills in the URL as
http(s)://<hostname-or-IP>:<port>, guessing https for a handful of
common admin-panel ports, and fills the label too if it's still empty.
The URL field stays fully editable either way.

Shared the address/label logic in a small adminLinkUrl.ts helper used
by both pages. No server-side changes needed - built entirely on the
existing per-server ports and detail endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 00:23:52 +02:00
bobbanandClaude Sonnet 5 1ff1c6550c Share the excluded-ranges filter with IP Addresses; add Admin Links page
IP Addresses (IPAM) now reads the same excluded-ranges setting the
Consistency page manages, so "not interesting" addresses - a Docker
bridge network repeating on every host, say - can be hidden there too.
Adds a "Hide excluded addresses" toggle (on by default, with a live
count), an inline ranges editor matching Consistency's, an "excluded"
badge on rows shown anyway, and a per-row "Exclude..." shortcut that
suggests a /24 (or /64 for IPv6) around that address. Editing ranges
from either page updates both, since it's one shared setting.

Also adds Operations > Admin Links: a single page summarizing every
admin bookmark added across all servers (Dockge, Webmin, Cockpit, etc,
previously only visible per-server on each server's own detail page),
sortable and searchable, with the same add/edit/delete capability -
adding one here just asks which server it belongs to.

Verified both with real HTTP-level tests: a genuine Express app, a
scratch SQLite DB, and forged sessions, covering the exclusion
matching, the shared-setting round trip, the links aggregation and
join, and role enforcement - the real dev DB was confirmed untouched
throughout. Both packages build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 00:07:27 +02:00
bobbanandClaude Sonnet 5 236b1da0dc Add a Network > Ports page: agent-reported ports plus manual openings
Summarizes every server's agent-reported listening ports in one
cross-server table (grouped by protocol+port, addresses merged,
loopback-only flagged) - previously this only existed per-server on
each server's own detail page.

Adds a second table for ports this app has no way to see on its own:
manually-recorded openings on a router, edge firewall, or cloud
security group, each with a label, external port/protocol, an optional
link to a tracked server (with its own internal port when NAT changes
it) or a freeform destination, a free-text source, and a comment.
Viewer-readable; adding/editing/deleting needs operator or admin.

The agent-port grouping logic (dedupe by protocol+port, detect
loopback-only sockets) was shared with the existing per-server Ports
card via a new agentPorts.ts service instead of duplicating it.

Verified with a real HTTP-level test: a genuine Express app with the
actual routers, a scratch SQLite DB, and forged admin/viewer sessions,
covering grouping correctness, the server-name join, input validation,
and role enforcement - the real dev DB was confirmed untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 23:48:35 +02:00
bobbanandClaude Sonnet 5 4e48377348 Document every notification this app sends in NOTIFICATIONS.md
Covers all 16 notification types (daily reminders, state-based health/
automation alerts, real-time DNS and integration-down alerts, and the
quiet-hours digest), what triggers each, and what does and doesn't
respect quiet hours and maintenance windows. Every trigger condition
and threshold was cross-checked against the actual scheduler/monitor
code, not just the settings labels.

Also fixes the "Daily reminder time" hint text in NotificationSettings,
which had gone stale — it listed only 4 of the 6 checks that actually
share that schedule (missing domain expiry and PBS verification).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 23:48:19 +02:00
bobbanandClaude Sonnet 5 de5c39dddf Add an osTicket integration: list open tickets from its database
osTicket's own REST API only supports creating tickets, not listing or
reading them, so this reads osTicket's MySQL/MariaDB database directly
with a read-only user instead - the only integration in this app that
isn't a REST API. Joins the ticket, status, priority, department,
staff, team, and user tables, filtered to tickets in the "open" state
(status names are customizable per install, but that state flag isn't).

Surfaces per-ticket subject, priority, department, assignee, requester,
and osTicket's own overdue/awaiting-reply flags, plus a page at
/osticket and a Dashboard widget with open/overdue/awaiting-reply
counts.

Not verified against a live instance: unlike the HTTP-based
integrations, there was no way to fake a MySQL server to test against
in this environment, so the query is built from osTicket's published
schema but has never actually run against a real database. See
INTEGRATIONS.md for the read-only grant needed and further caveats.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 23:09:56 +02:00
bobbanandClaude Sonnet 5 69e9325927 Document role-based menu access in ROLES.md
Adds a reference table of which sidebar items each role (viewer,
operator, admin) can see, plus a summary of which in-page actions on
otherwise-visible pages are held back to operator/admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 20:41:26 +02:00
bobbanandClaude Sonnet 5 df2a5ce42b Add a Proxmox Backup Server integration: datastore/snapshot verification status
Proxmox VE already shows whether the last vzdump push to PBS succeeded, but
has no visibility into PBS's own backup verification, GC/prune health, or
host status. This adds PBS as its own integration (own adapter, page, nav
entry, and Dashboard widget) that reads datastore usage and, for every
stored snapshot, its verification state directly from PBS.

A new daily check (mirroring the existing Proxmox backup-failure check)
notifies when a snapshot has failed verification or a datastore couldn't be
read, with its own toggle in Settings -> Notifications and its own
maintenance-window silencing.

Not verified against a live PBS instance — built from PBS's published API
docs and a scratch test against a mocked PBS server exercising the adapter's
parsing and auth-header format (PBSAPIToken uses a colon separator, unlike
PVE's PVEAPIToken which uses =). See INTEGRATIONS.md for details and the
"not verified" caveat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 20:32:52 +02:00
bobbanandClaude Sonnet 5 70ba60c7da Add a phpIPAM import to the IP Addresses page
New "Sync from phpIPAM" action on IP Addresses, alongside the existing
"Sync from Tailscale" / "Sync from Proxmox" ones and built the same way:
a new integration type (config in-app, URL + API app ID + app token,
credentials encrypted at rest) that this page pulls from on demand.
Deliberately import-only, not a full integration -- no dedicated page,
dashboard widget, or nav entry, since that's all this was asked for.

Auth is phpIPAM's static "App token" method: create an API app under
Administration -> API with its security set to "SSL with App token", and
its one-time code goes straight in as the `token` header (also sent as
`phpipam-token`, in case a given version expects that name instead) --
no login call, no token to renew. The user/password "User token" method
isn't implemented.

Addresses are read the standard way: GET /subnets/, then GET
/subnets/{id}/addresses/ for each, rather than assuming a single
"all addresses" endpoint exists on every version. phpIPAM wraps every
response as {code, success, data} -- including an empty result: a subnet
with nothing in it answers success:false, message:"No addresses found"
rather than success:true, data:[]. That's read as "nothing here", not a
failure; anything else with success:false throws with phpIPAM's own
message. One subnet failing outright (e.g. the app lacks permission on
it) is skipped with a note rather than aborting the whole sync. Every
address field is read defensively -- optional, independently
type-checked -- so a field phpIPAM renames or drops in some version
leaves that value blank instead of breaking the import.

Imported entries: label from hostname or description, "phpIPAM" as
vendor, the subnet's own description (or its CIDR, if it has none) as
location, and description/note/MAC folded into notes. Existing sync
plumbing (upsertSyncedEntry) gained a location parameter so this and any
future sync can set it; the two existing syncs pass null, unchanged.

Endpoints, the token header, and the address/subnet field names are
cross-checked against phpIPAM's own published API documentation. Not
verified against a live instance -- there wasn't one available while
building this, so if a real sync comes back empty or with the wrong
fields, that's the next thing to check.

Verified with 23 backend checks against a fake phpIPAM server matching
that documented shape (the empty-subnet quirk, a subnet that fails
outright, malformed/missing fields, a non-JSON response) and the real
route (added/updated/skipped counts, a manually-entered IP never
overwritten, roles, no-enabled-integration, upstream failure surfaced
per-integration rather than as a 500, audit entries) plus a browser check
of the real IP Addresses page against the real routers: the sync button,
its result message, re-syncing (updates rather than duplicates), the
manual entry staying untouched, and the viewer view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 19:49:10 +02:00
bobbanandClaude Sonnet 5 bf7f73f6b6 Import maintenance windows from Uptime Kuma
New "Import from Uptime Kuma" action on the Maintenance page (operator):
pick an Uptime Kuma integration and a duration, and it starts (or
extends) a maintenance window here for every server whose address
matches a monitor Uptime Kuma currently reports as being in maintenance,
reusing the existing monitor-to-server matching from the Uptime Kuma
integration itself.

Uptime Kuma's metrics endpoint only exposes a monitor's *current* status,
not its scheduled start/end time (there's no API for that), so this
deliberately doesn't try to mirror Uptime Kuma's own schedule -- it starts
a window for the duration you choose, the same bounded/required-end
window this feature has always used. Running it again while Kuma is
still in maintenance extends the same window rather than stacking a
second one; when Kuma later shows nothing in maintenance, already-active
windows are left alone rather than force-ended, since ending them isn't
something only Uptime Kuma's state should decide. Two Kuma monitors that
match the same server are deduped to one window. Monitors with no
matching server are reported back by name so nothing is silently missed,
and monitors that aren't in maintenance are ignored entirely.

The manual "Start maintenance" endpoint's start-or-extend logic (dedupe,
pruning old rows, the response shape) is now a shared
services/maintenance.ts function instead of living only in that route
handler, so the import path can't drift from how a manual window behaves.
Likewise the server-matching helper gained a small toMatchableServers()
so the existing Uptime Kuma monitors route and this new one build the
same match input the same way instead of each parsing server rows on
their own.

No schema change -- imported windows are ordinary maintenance windows;
their Uptime-Kuma origin is only in the reason text ("Imported from
Uptime Kuma (<integration>): <monitor>"), visible in the Active table and
the audit log like any other window.

Verified with 28 backend checks (the refactored manual start/extend
flow as a regression check; roles; unknown/wrong-type/disabled
integration; validation; nothing-in-maintenance; matching including
same-server dedup and unmatched monitors; re-running extends rather than
duplicating; windows left alone once Kuma exits maintenance; upstream
failure; audit entries) and by driving the real Maintenance page against
the real routers in a browser: import, re-import (extends), the
nothing-in-maintenance state, and the viewer view (no edit card, active
windows still visible). Real dev database mtime untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 19:11:35 +02:00
bobbanandClaude Sonnet 5 26de6cb243 Move Uptime Kuma from Infrastructure to Operations in the sidebar
It watches over things rather than being a machine/platform itself,
closer in spirit to Maintenance than to Proxmox or Tailscale. Also
balances the two groups (6/2 -> 5/3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:52:41 +02:00
bobbanandClaude Sonnet 5 1a2dd19736 Add an Uptime Kuma integration: monitor status and which server each one watches
New integration, following the existing pattern: config in-app (URL +
API key, credentials encrypted at rest), its own Uptime Kuma page, an
Integrations list entry, a Dashboard widget, and diagnostic-log/
integration-down-alert coverage for free via the shared withDiagLogging
wrapper. Read-only -- no start/stop equivalent exists for a monitor.

Uptime Kuma has no conventional REST API (the dashboard talks to it over
Socket.IO); researched before writing any code, since guessing wrong here
would have cost real time. The one machine-readable, authenticated
endpoint that lists every monitor is its Prometheus exporter at
GET /metrics, gated by HTTP Basic auth -- an API key as the password with
the username left blank on current installs, or the real dashboard
login on installs from before the API-key feature existed. This adapter
authenticates the same way and parses that endpoint's text-exposition
format itself (metrics: monitor_status, monitor_response_time,
monitor_cert_days_remaining, monitor_uptime_ratio; labels: monitor_id,
monitor_name, monitor_type, monitor_url, monitor_hostname, monitor_port),
verified against the documented metric/label set and the actual upstream
source (server/prometheus.js). A malformed line is skipped rather than
failing the whole scrape.

"What server is being monitored for what": each monitor's target (an IP
for TCP checks, or the hostname out of the URL for HTTP/keyword checks)
is matched against your servers' own IPs and hostnames -- reusing the
same kind of match already used in the consistency report -- and linked
to that server's page. Monitors with no single network target (groups,
push monitors, DNS/keyword checks with a complex URL) are left unmatched
rather than guessed at. Uptime Kuma's tags aren't read, since the
Prometheus endpoint doesn't reliably distinguish a tag label from any
other label it might add later.

The username field is the first genuinely optional integration config
field this app has had; IntegrationField gained an `optional` flag
(server validation and both the add/edit web forms honor it) rather than
special-casing Uptime Kuma.

Verified with 48 backend checks (Prometheus text parsing including
escaped quotes, decimals, negative numbers, and malformed lines; TCP vs.
HTTP target/port extraction; every documented status code; server
matching by IP, hostname, and short name, including no-match cases; the
route's real HTTP round trip against a fake Uptime Kuma server, wrong
credentials, upstream failures, roles, wrong/disabled/missing
integration, diagnostic-log entries; the optional-field validation rule)
and by driving the real page and the real Dashboard widget in a browser
against the real routers, including CSV export and column sorting. Real
dev database mtime untouched.

Not verified: a real Uptime Kuma instance. Everything here was checked
against Uptime Kuma's documented metric format, its actual upstream
source, and a fake server built to match both -- not against a live
installation. If your instance's /metrics output differs from what's
documented (older version, unusual monitor types), the parser should
degrade to an empty or partial monitor list rather than error, but that
degradation itself hasn't been observed against the real thing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:50:27 +02:00
bobbanandClaude Sonnet 5 ada2e648e9 Group the sidebar into collapsible submenus
The flat 22-item menu becomes 7 entries: Dashboard and Secrets as plain
links, and five groups.
- Infrastructure: Servers, Proxmox, Synology, Docker, Tailscale
- Network: DNS, Domains, IP Addresses, Consistency
- Automation: Semaphore, Gitea
- Operations: Maintenance, Generator
- Administration: Integrations, Users, Sessions, Audit Log, Diagnostic
  Log, Settings
Privacy moves to the sidebar footer beside the theme toggle and sign-out,
since it's about the person rather than the homelab.

Behaviour:
- Groups open on demand. The group holding the current page is always
  open (deep routes count: /servers/7 keeps Servers active), and its
  header stays emphasised even if you close it. Which groups you leave
  open is remembered in the browser, and the menu still works if storage
  is blocked.
- Role handling: pages a role can't use are dropped, an emptied group
  disappears, and a group reduced to a single page is drawn as that
  page's own link. A viewer therefore sees a plain Integrations link
  where an admin sees Administration, and an operator sees Administration
  with Integrations and Audit Log.
- Toggling a group on mobile keeps the menu open; choosing a page closes
  it, as before.
- Group headers are real buttons with aria-expanded.

Front end only: one component, plus a small CSS rule tightening submenu
rows so several open groups still fit on one screen. Routes, permissions
and backend are unchanged.

Verified in a browser with the real shell as admin, operator and viewer:
structure per role, open/close and aria state, active highlighting on
direct and deep routes, auto-open of the current page's group, state
surviving a reload, and the mobile toggler behaviour. Not done: the
attention dots on closed groups (deliberately held for a later step).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 03:00:22 +02:00
bobbanandClaude Sonnet 5 9229ea2ed6 Add a Domains widget to the dashboard
A Domains card completing the Overview row (DNS, Secrets, Servers,
Domains), shaped like the Secrets card: a status badge (N expired / N
expiring / All OK / No expiry dates / Not configured), tracked, expiring
and expired counts, an OK/expiring/expired/no-date bar, and details --
the expired domains and the soonest-expiring ones (first three, soonest
first), the next one to expire when everything is fine, and a note when
some domains couldn't be refreshed, linking to the Domains page.

Purely front end: the existing /api/domains already carries each domain's
status, days left and last-check error, computed against the warning
window from Settings. A registry that doesn't publish expiry dates (.de,
.eu) shows as "no date" and is not counted as a failed refresh; anything
else that stopped a refresh is.

Verified in a browser against the real domains router across four states:
problems (expired, several expiring, an undated one, a failed refresh),
all healthy, only undated domains, and none tracked. Web build only; no
backend or database changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 02:52:05 +02:00
bobbanandClaude Sonnet 5 91796a3c3a Add a Servers widget to the dashboard
A Servers card in the dashboard's Overview row, in the same shape as the
DNS and Secrets cards: a status badge (N offline / N disks nearly full /
All online / No agent data / Not configured), total, online and offline
counts, an online/offline/no-data bar, and the details that matter --
which servers are offline and for how long (the first three, linking to
their pages), which disks are at or above the usage threshold, and a
Linux/Windows split when there are Windows machines.

The numbers come from a new GET /api/servers/summary, computed on the
server with the health monitor's own evaluateHealth. That means "offline"
and "disk full" are decided by exactly the rules the alerts use, so the
widget can't say a server is fine while an alert says it isn't, and it
follows the thresholds set in Settings, which viewers can't read
themselves and so couldn't have applied client-side. Servers under a
maintenance window are marked as such. A server that has never reported
(no agent, or tracked only through Proxmox) counts as "no data" rather
than offline, an offline server's stale disk figure isn't reported, and a
garbled report doesn't blank the widget.

Readable by every signed-in user, like the dashboard itself.

Verified with 23 backend checks (offline/online/no-data classification
including the bare SQLite timestamp format, ordering, colons in Windows
mounts, threshold changes, maintenance flags, agreement with the alert
path's own offline and disk sets, empty and garbled inputs, and the route
as a viewer following Settings) and in a browser against the real router
across five states: problems, disks only, all healthy, no data, and empty.
Real dev database mtime untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 02:27:18 +02:00
bobbanandClaude Sonnet 5 fea20456e4 Add a Windows agent (PowerShell)
Reports a Windows machine the way the Linux agent does, replacing the
"planned" stub in agent/windows: scheduled tasks plus hostname, IPv4
addresses, CPU model/cores/current load, memory, every fixed disk, and
TCP/UDP listening ports with the owning process (which feed the Ports
card, localhost-only listeners included).

Scripts (plain ASCII by design -- they are downloaded as text and Windows
PowerShell 5.1 reads BOM-less files as ANSI):
- report-tasks.ps1: collects and POSTs to /api/agent/report. Works in
  Windows PowerShell 5.1 and PowerShell 7. -DryRun prints the JSON.
  Microsoft's own \Microsoft\ tasks (hundreds) are left out unless
  INCLUDE_MICROSOFT_TASKS is set. Triggers are turned into readable text
  ("Weekly on Mon, Wed at 03:00", "At logon", "..., repeating every 15 min").
  Self-signed certificates work via API_INSECURE on both PowerShell
  versions (they need different mechanisms).
- install.ps1: elevated only; downloads the agent to ProgramData, writes
  agent.json with permissions locked to SYSTEM and Administrators *before*
  the token goes in, and registers a SYSTEM scheduled task (every 15 min
  plus at startup with a 2 min delay). Reinstalling replaces the task.
- uninstall.ps1: removes the task and only the files the agent installed.

Server: accepts schedule_type "windows_task"; a server can be registered
as Windows (Add a server has an operating system choice); an agent's
reported os_type ("linux"/"windows", anything else ignored) corrects the
stored one. The Servers page shows the right install and uninstall
command for each OS (Windows PowerShell 5.1 one-liners, with a self-signed
variant and a note about PowerShell 7), and Windows tasks are labelled
"Windows scheduled tasks". The Linux commands are unchanged.

Verified on this Windows machine, in both PowerShell 5.1 and 7:
- Real dry runs found and fixed bugs before anything shipped: tasks and
  ports came out as one nested item (return , $out wrapped twice), integer
  keys in an ordered dictionary index by position (wrong weekday names),
  and generic "Trigger" labels.
- End to end against the real agent-report router: HTTP, self-signed HTTPS
  refused by default and accepted with API_INSECURE, wrong token gives a
  clear one-line error and exit 1, and Swedish letters plus a euro sign
  survive JSON -> UTF-8 -> HTTP -> SQLite.
- 35 checks on trigger/action/duration descriptions, 20 on the installer's
  building blocks (task parts built but not registered, credentials file
  content and ACL, download over HTTP and self-signed HTTPS), 18 on the
  server rules, and the generated one-liners run through PowerShell's
  parser. The documented one-liners were run through iex and stop at the
  administrator check without changing anything.
- Found that PowerShell 7 ignores the ServicePointManager certificate
  override, so the installer's own download now uses -SkipCertificateCheck
  there.

NOT verified: the elevated install itself. Registering a SYSTEM scheduled
task needs elevation and changes the machine, so it was not run: the task
registration, that the repeating trigger really runs indefinitely, and
the agent running as SYSTEM under Task Scheduler have not been exercised.
Windows 10 / Server 2016 or newer is assumed; older is untested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 00:09:00 +02:00
bobbanandClaude Sonnet 5 ae64cb345c Manage server tags in Settings: pre-add tags, recolour, rename, delete
New Settings > Tags tab (admin) listing every tag -- ones servers use and
ones added ahead of time -- with how many servers carry each:
- Add a tag before anything uses it, optionally with a colour. Such tags
  are offered as one-click "Add:" chips (and datalist suggestions) when
  tagging a server, so the same word gets spelled the same way everywhere.
- Give any tag a colour of your choosing, in use or not, or reset it to the
  automatic one. Changes are drafted with Save/Cancel rather than saved as
  the picker drags. Colours show on the Servers page, its tag filter bar,
  the detail page and the editor, and update everywhere without a reload
  through one shared cached colour map.
- Rename a tag; every server that has it is rewritten. Renaming to a name
  that already exists merges the two after a confirmation naming what will
  happen; the target keeps its own colour unless it had none, and a server
  carrying both ends up with one.
- Delete a tag, which removes it from every server that has it, with a
  confirmation stating how many.

Tags still live on the servers (servers.tags); a new tag_definitions table
holds only what a server can't: existence before use, and a colour. A
defined tag stays listed until an admin deletes it, even with no servers.
Rename and delete change the servers and the catalogue in one transaction
so they can't disagree. Only servers that actually carry the tag are
rewritten and counted -- an earlier draft also counted servers whose tags
merely weren't in sorted order, which the tests caught.

Reading the list and colours is open to everyone signed in (needed to draw
tags anywhere); changing the catalogue is admin-only, while tagging a
server stays an operator action. Names go through the same normalisation
as before, colours must be #rrggbb, and every change is audit-logged.

New table tag_definitions (migration 0013).

Verified with 44 backend checks (list/counts, roles, create/adopt/
duplicate/rejects, colour set/reset, rename incl. defined, undefined and
unused tags, merge colour rules and both-sides servers, delete, audit, and
that normal tagging still works afterwards) and in a browser against the
real routers: add with colour, set/reset a colour and see it change on the
Servers page live, merge with confirmation, delete, and the error path.
Not clicked through: the quick-add chips inside the tag editor on a
server's detail page (typechecked; same colour code as the rest).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 21:17:09 +02:00
bobbanandClaude Sonnet 5 72f8c85406 Add a Privacy page: what is stored, where it goes, and your own data
A page every signed-in user can open. It states what the installation
stores (accounts, sign-in sessions, audit and diagnostic logs, server
reports, the secrets tracker, credentials, inventory) and for how long,
where data goes (Authentik, integrations, DNS providers, notification
channels, domain registries, certificate checks, port scans), what lives
in the browser, who can see what, and how to limit or remove data.

Written from what the code actually does, including the uncomfortable
parts: the session file keeps the user's Authentik ID token plus the IP
and browser from sign-in; audit entries keep a name snapshot after an
account is gone; the app has no delete-account function; cron commands
in agent reports can contain sensitive text. It also says what isn't
there -- no telemetry, update checks, third-party scripts, fonts or
tracking cookies -- which was checked against the web build and the
server's outbound calls before being asserted.

Live values rather than boilerplate: log retention (and whether it's on),
which integration and DNS provider types are enabled, how many domains,
certificate checks and reporting servers, and which notification channels
are on. Channel addresses are shown to admins only, and only the host --
never a path, query string or token -- since a webhook URL can embed a key.

Each user also sees their own account and active sign-ins, and can
"Download my data": their account, their sign-ins and the audit-log entries
made under their account, as JSON. Only their own -- never another user's --
and without session ids or ID tokens. The export is itself audit-logged, so
a later export shows it.

Verified with 23 backend checks (own-vs-others isolation for audit counts,
sessions and export; no session ids, ID tokens or channel secrets in any
response; admin-vs-viewer channel visibility; live counts; audit of the
export; auth) using an isolated session directory so real sessions are
never read, and in a browser against the real router, including the
download. Real dev database and session files untouched.

Not legal text: this is a transparency page for the people using the app,
not a privacy policy or a GDPR compliance document.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 20:28:03 +02:00
bobbanandClaude Sonnet 5 07d20bd2b7 Add arm64 compose files: one builds the image, one runs the published one
docker-compose.arm64.build.yml builds the image for linux/arm64 from
source and tags it for the registry (docker compose ... build, then push);
it can also build and run directly on an arm64 machine. Building on x86
works through QEMU, with the one-time binfmt setup noted in the file.

docker-compose.arm64.yml runs that published image on the arm64 machine
with no build there. Both use the same image name and an :arm64 tag, kept
apart from the default image so the existing docker-compose.yml is
untouched. IMAGE_REPO and ARM64_TAG (in .env or the shell) switch the
registry or pin a release tag.

Also adds a .dockerignore, which the repo lacked. The Dockerfile runs
COPY . . after npm ci, so a local build from a developer checkout would
overwrite the container's node_modules with the host's (Windows/x86
binaries) and break the build -- most visibly when cross-building for
arm64. It also keeps .env, data/ and .git out of the build context.

libsql's arm64 musl binary is present in package-lock.json, and the
Dockerfile's node:22-alpine base is multi-arch.

Not built or run: Docker isn't available in this environment, so neither
the emulated arm64 build nor the compose files themselves have been
executed. The YAML was only checked for whitespace and against the
runtime section of docker-compose.yml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 19:25:52 +02:00
bobbanandClaude Sonnet 5 3f2b5da7be Let the consistency report exclude address ranges
Docker reuses the same subnet on many hosts, and those networks aren't
part of the LAN, so they show up as conflicts and unlisted addresses. The
report only knew about 172.16.0.0/12 through a hardcoded rule; Docker can
just as well pick 192.168.x or 10.x.

The Consistency page now has an Excluded ranges card: CIDR ranges (IPv4
or IPv6) and single addresses, added with a form and removed with one
click, shown to everyone and editable by operators. There's also an
"Exclude range" button on each finding that pre-fills a /24 (or /64) around
its address to edit. Exclusions are applied to servers, IPAM and DNS
before anything is compared, so an excluded address never appears in any
kind of finding, whichever source it came from, and the card says how many
addresses are currently being hidden so it's clear the filter is doing
something.

The old hardcoded rule becomes a visible default (172.16.0.0/12) that can
be removed -- it was silently wrong for anyone using 172.16/12 as a real
LAN. That default is also slightly stronger than before: an address in the
range is now left out even if it is in IPAM or DNS, where the old rule only
skipped it when nothing else mentioned it. Remove or narrow it if that
isn't wanted.

Ranges are validated and normalised on the server (both families, prefix
bounds, no /0, at most 50), a bad one is rejected with a message naming it
and nothing is saved, and changes are audit-logged with before/after.
Matching uses Node's BlockList. Stored as a settings value; managed from
the report rather than admin-only Settings, like ignoring a finding.

Verified with 44 checks (range parsing and rejection, boundary addresses
just inside and outside a range, IPv6, single addresses, exclusion across
all sources and finding kinds, the hidden-address count, route
validation/roles/audit) and in a browser against the real router: add,
invalid, remove the default, exclude from a finding. Real dev database
mtime untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 18:57:28 +02:00
bobbanandClaude Sonnet 5 2352689fd3 Add a consistency report across IPAM, DNS and servers
New Consistency page listing where the three places this app records
what lives at an address disagree:
- Address conflicts: the same address reported by more than one server.
- DNS out of date: a record named after a server (its hostname, or its
  short name) that points at an address the server doesn't report.
- IPAM out of date: an entry labelled with a server's name at an
  address the server doesn't report.
- Not in IPAM: addresses a server reports or DNS points at that IPAM
  doesn't list, merged into one finding per address, with a one-click
  "Add to IPAM" that pre-fills a label.
- No DNS record: server LAN addresses no cached A/AAAA record resolves to.

It compares data the app already holds and fetches nothing when opened,
so the page states how many servers had reported addresses and how many
DNS zones are synced (and how old the oldest sync is) -- DNS records are
only cached for zones that have been synced, and a report that silently
treated missing data as "no records" would mislead.

Rules chosen to keep it from crying wolf:
- Only private addresses are compared; public DNS records aren't expected
  to be in IPAM.
- Servers with no reported addresses are never judged.
- Agents report IPv4 only, so records are only compared within an address
  family (an AAAA record isn't "stale" for lacking an IPv6 address).
- Docker bridge networks (172.16/12) are ignored: shared ones aren't
  conflicts, and they aren't listed unless someone put them in DNS.
- Tailscale addresses don't need DNS records (MagicDNS), and IPAM entries
  kept current by the Tailscale/Proxmox syncs aren't second-guessed.

Findings anyone has decided are fine can be ignored (operators) with a
reason. An ignore is keyed on the finding's stable identity so it stays
ignored across runs, its stored text comes from the finding rather than
the request, and it is marked "no longer occurring" once the condition
goes away. Ignore/restore are audit-logged.

New table consistency_ignores (migration 0012). portScan's private-address
helper is now exported and shared.

Verified with 36 checks (each rule and its exclusions, address-family and
case/trailing-dot handling, IPv6 case, ordering, stable keys, the report
route including a garbled agent report, source counts, ignore/unignore
rules and audit entries) and by driving the page against the real routers
in a browser: Add to IPAM actually created the entry, ignore and restore,
severity filter, "show all", the viewer view, and narrow-width layout
(which found and fixed a squeezed badge and clipped buttons). Real dev
database mtime untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 04:08:51 +02:00
bobbanandClaude Sonnet 5 ca0fa817f8 Track domain registration expiry, with daily reminders
New Domains page listing when each domain registration expires, read from
the registry. Domains behind the DNS zones already synced are picked up
automatically; others can be added by hand. You're reminded daily from N
days before expiry (Settings > Notifications, default 30) until it's
renewed, and told when an expiry date hasn't been refreshable for several
days so a stale date isn't trusted silently.

RDAP alone would not have covered this homelab: .se, .nu, .io, .eu and .de
are not in IANA's RDAP bootstrap. Lookups therefore try RDAP where the TLD
publishes a server and fall back to WHOIS on port 43, found via IANA's
own referral, parsing the expiry line out of the free-text answer. Only
the expiry date and registrar are read or stored. Verified live against
the real registries: .se and .nu via WHOIS, .com/.org/.dev via RDAP.

Behaviour worth knowing:
- A DNS zone that is a subdomain (lab.example.se) resolves to the
  registration that actually expires by trying the name and then its
  parents, so no public-suffix list is needed. Zones already covered by a
  tracked domain are not looked up again.
- "Couldn't ask" is never confused with "not registered": network errors,
  rate limits and garbled answers are errors, and a transient error at any
  level stops the walk from concluding the domain doesn't exist.
- A failed refresh keeps the last known expiry and records why, rather
  than blanking a date that's still relied on.
- Zones that don't resolve to a real registration (.lan, .local, unregistered
  names) simply get no row. Zone-derived rows disappear when their zone
  does; manual rows stay. Zone-derived rows can't be deleted by hand.
- Registries that don't publish an expiry (.de, .eu) are tracked with a
  note instead of a date.
- Input like "example.com/path" is refused rather than silently reduced
  to its host.
- Runs on the daily secret-expiry schedule and reminder time, on demand
  (Check all now / per domain), and once at startup if nothing has been
  read in a day. Never blocks startup, one lookup at a time with a pause.
  The warning window lives with the other thresholds in settings.

New table domains (migration 0011); two settings fields (toggle and
warning days).

Verified with 90 checks against fake RDAP/WHOIS backends (name
normalization, date formats, WHOIS parsing including rate-limit and
no-expiry answers, bootstrap and referral caching, stale-cache fallback,
parent walking, add/sync/check/refresh, concurrency guard, alert
selection and stale detection, the daily notification and its toggle,
role rules) plus a live smoke test against real registries and a browser
check of the page against the real router. Real dev database mtime
untouched. Not checked: a screenshot of the finished page (the capture
timed out); structure, sorting, errors and the viewer view were verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 03:31:02 +02:00
bobbanandClaude Sonnet 5 017014586f Show when each Gitea repository was last updated
The API already returned Gitea's updated_at for every repo but the page
never displayed it. The Gitea table now has a sortable "Last updated"
column with a relative age ("3 d ago") over the exact date/time (in the
app's configured date format), and the CSV export includes it.

Gitea only exposes a single updated_at per repository (there is no
separate last-push time), so this is Gitea's own notion of "updated".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 03:20:43 +02:00
bobbanandClaude Sonnet 5 4118062405 Alert when a Semaphore template or Gitea workflow run fails
Every 15 minutes (on the existing health-check timer) the app reads the
latest run of each Semaphore template and each Gitea repo's latest
workflow run. A failed one raises one notification, and another when a
later run succeeds. It is state-based like the server health alerts, so a
job that fails every night alerts on the first failure, not every night.
Gitea alerts include the run's link. Toggle: Settings > Notifications.

What counts:
- Semaphore "error" is a failure, "success" is a pass. A run that is
  waiting, running, stopped by hand or rejected is neither, so it leaves
  the previous state alone: a run in progress must not clear a failure it
  hasn't fixed yet, and a manual stop isn't a failure.
- Gitea failure/success likewise; running, waiting, blocked, cancelled and
  skipped leave things as they were.
- A failing template that gets another failing run does not re-alert.

Not mistaking "couldn't read" for "fixed":
- Semaphore's template listing swallowed per-project errors, so a project
  that failed to load looked like a project with no templates. A new
  checkTemplates adapter method reports which projects failed, and their
  failures are held rather than cleared.
- Gitea reports a run it couldn't fetch as null, the same as "no runs";
  both leave the repo's state alone.
- An unreachable integration holds all of its failures. Nothing is cleared
  or re-announced while it is down.

The first pass only records what is already failing without announcing
it, so upgrading (or adding an integration to a fresh install) doesn't
produce a wall of alerts about months-old failures. That baseline is not
spent while nothing could be read.

Maintenance windows on a Semaphore or Gitea integration silence its
failure alerts with the same rules as the health alerts: a problem that
starts during a window alerts when it ends, and one already announced
stays known. The diff logic is reused from the health monitor rather than
copied. Maintenance page text updated.

Known limit: for Gitea this follows the repo's most recent run on any
workflow or branch, matching what the Gitea page shows; a failure in one
workflow can be masked by a later success of another.

Verified with 53 checks against fake Semaphore and Gitea servers and a
webhook receiver: classification, baseline (including not being consumed
when nothing is readable), single alert per failure, no repeat, in-progress/
stopped/cancelled runs, recovery and re-failure, unreadable project,
unreadable integration, run-fetch errors, maintenance windows (silenced,
then announced after), the toggle, disabled integrations and repos without
Actions. Real dev database mtime untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 03:11:59 +02:00
bobbanandClaude Sonnet 5 9f1609c4ed Add tags to servers, with a tag filter on the Servers page
Servers can be tagged (prod, media, rack-1, ...) for grouping. Operators
and admins edit tags inline on a server's detail page; the input suggests
tags already used on other servers so the same word ends up spelled the
same way everywhere. Tags show as chips that keep one colour per tag, on
the server cards, in the Manage table (and its CSV export), and on the
detail page, where each chip links to the Servers list filtered by that
tag. The Servers page has a tag bar with counts; picking several tags
narrows to servers that have all of them. The filter lives in the URL, so
it survives a refresh and can be linked to. Tags are also matched by the
global search.

Tags are normalized on the server (trimmed, lowercased, spaces become "-",
duplicates merged, sorted); letters in any language are allowed, plus
digits and - _ . : /, at most 30 characters and 12 per server. Invalid
input is rejected with a message naming the offending tag, and nothing is
saved. Changes are audit-logged with the before and after lists.

Stored as a JSON column on servers (migration 0010). Every server response
now returns tags as an array, and the shared response shaping strips the
token hash in one place instead of five.

Verified with 27 backend checks (normalization edge cases including
Swedish letters, roles, validation, search, audit, PATCH/detail/list
shapes) and by driving the real Servers and detail pages against the real
router in a browser (filtering, editing, invalid tag, viewer view). Real
dev database mtime untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 03:06:29 +02:00
bobbanandClaude Sonnet 5 4c11158e98 Add a Ports card to server pages: scan for open ports, find free ones, and keep notes
Each server's detail page now has a Ports card. "Scan…" runs a TCP connect
scan of a chosen range from the app and shows what's open, along with the
ranges that were actually confirmed free; clicking a free range starts a
reservation. Any port can carry a service name and a comment, so the page
also answers "what is this port for". A port with a note counts as taken
even when nothing is listening, which is what makes a reservation work.
Operators can scan and edit; everyone can read. Scans and note changes are
audit-logged.

Details that matter for correctness:
- "Free" means the host actively refused the connection AND nobody has
  claimed the port. A port that never answers (firewall drop, host down)
  is reported as not answering, not as free.
- A scan from elsewhere can't see services bound to localhost only, so the
  agent now also reports what is bound on the host (ss -tulnp) and those
  ports are treated as taken. They show as "local only". Existing agents
  keep working; re-run the install one-liner to add this. The field is
  validated leniently so one odd line can never cost an agent its whole
  report, tasks included.
- If nothing answers at all during a scan, existing results are left
  alone instead of being marked all-closed.
- Scan targets are limited to private addresses (RFC1918, Tailscale
  100.64/10, link-local, IPv6 ULA/link-local); loopback and public
  addresses are refused. Ranges are capped at 20,000 ports, and only one
  scan runs per server at a time.
- Rows exist only while they carry information: an open port, or one with
  a note. A closed port with no note disappears on the next scan; one with
  a note stays as "reserved".

New table server_ports plus two columns on servers (migration 0009).

Verified with 76 backend checks (scanner open/refused/filtered, address
rules, agent report leniency, note/reserve/clear semantics, free-range
calculation including the localhost-only case, roles, concurrency lock,
no-response guard, audit entries, cascade delete) and by driving the real
component against the real router in a browser. Real dev database mtime
untouched.

Not verified: the agent's ss/awk/jq pipeline on a real host — the awk step
was checked against sample ss output and the script passes bash -n, but
jq isn't available here to run the whole thing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 02:39:43 +02:00
bobbanandClaude Sonnet 5 aae4f0d74f Add maintenance mode to silence alerts while working on a server or integration
Rebooting Proxmox or patching a server triggered failure/offline alerts
you then had to dismiss. A maintenance window silences alerts about one
server, integration, or DNS provider for a chosen time. New Maintenance
page (start with a duration and optional reason, end early, see what's
silenced and what isn't) and a banner in the app shell so every signed-in
user can see what is currently silenced. Starting/ending is operator-only
and audit-logged; starting one on a target that already has a window
restarts its clock instead of stacking.

Silenced for the target: server offline/disk alerts, Proxmox/Synology
storage and health alerts, Proxmox backup alerts, and "integration down"
alerts. Not silenced: expiry and update reminders, DNS change notices.

The design goal is that this cannot hide a real outage:
- Every window has a required end (5 min to 7 days); there is no
  open-ended option, so a forgotten window expires by itself.
- A silenced problem is deliberately NOT recorded as "known". If it is
  still present when the window ends it alerts then, as new. A problem
  that was already alerted before the window stays known, so it isn't
  repeated, and is reported cleared only after the window ends.
- Failure alerts keep counting failures during a window without marking
  themselves alerted, so an outage that outlasts the window alerts on the
  very next failed call.

Known limitation, stated on the page: integration-failure alerts are
tracked per service TYPE (all "proxmox"), not per configured instance, so
a window on one Proxmox integration also silences a failure on a second
Proxmox integration while it's open. Fixing that means threading the
integration id through every adapter and the diagnostic log, which is a
much larger change than this feature.

Also moved the API-error-message helper out of Secrets.tsx into a shared
util now that two pages use it. New table maintenance_windows (migration
0008).

Verified with 44 checks: the condition-key-to-subject mapping (including
server:3 vs server:33), the diff rules with silenced subjects (new problem
not recorded, alerts when the window ends; already-known one carried and
not repeated; clears only after the window), window expiry and
integration/DNS-provider source matching, the failure tracker end to end
against a webhook (silent during a window while an unrelated service still
alerts; outage that outlasts the window alerts on the next failure and
only once; fail-and-recover fully inside a window sends nothing), a full
health pass against a real window, and the real router with a stubbed
session (role rules, duration bounds including the missing-duration case,
extend-not-stack, 404s, deleted targets hidden, audit entries). Real dev
database mtime untouched.

Not done: I haven't clicked through the new page or banner in a browser
(they sit behind the Authentik login); it builds and the API behind it is
tested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 02:23:06 +02:00
bobbanandClaude Sonnet 5 1688de3ea2 Alert when a server goes silent, a disk fills up, or a Synology volume degrades
The data was all being collected (agent last-seen, per-disk usage,
Proxmox storage, Synology volume/disk health) but nothing acted on
it, so a dead server or a full disk was only noticed by opening the
right page.

A new health pass runs every 15 minutes (matching the agent's default
report interval) and raises one notification when a problem starts and
one when it clears: a server's agent silent past a threshold (default
60 min), a server disk / Proxmox storage or root filesystem / Synology
volume at or above a usage threshold (default 90%), and a Synology
volume or disk that isn't "normal", has bad SMART, bad sectors past the
threshold, or life remaining below it. Both thresholds and an on/off
toggle live under Settings -> Notifications.

The parts that make this trustworthy rather than noisy:
- A problem is keyed by identity, so it alerts once and not every run;
  a shared Proxmox storage listed by every node is one problem, not
  one per node.
- Active problems persist across restarts, so a rebuild doesn't
  re-alert everything already known.
- If a source can't be read on a given run (Proxmox/Synology
  unreachable, one node lacking privileges) its existing problems are
  held, not reported "cleared" and then re-alerted when it comes back —
  the integration-failure alert already owns "the integration is down".
- For 20 minutes after startup server-derived problems are held too:
  agents couldn't report while the app was down, so judging them then
  would report every server offline after any restart.
- An offline server's disk figures are stale and are not judged; a
  server that never reported has no agent and raises nothing.
- Tracking continues while the toggle is off (only sending is gated),
  so turning it back on doesn't dump every long-standing problem.

Timestamps without a zone (SQLite's format) are read as UTC; the test
runs on a UTC+2 machine, where reading them as local time gives a
different answer.

Verified with 32 checks: the evaluation rules and the state diff as
pure functions (exact thresholds, the proxmox:1 vs proxmox:10 prefix
trap, the flapping sequence), then a whole pass against a real
Proxmox adapter talking to a fake HTTPS cluster (one node returning
403, the whole API down, a shared storage on two nodes, a node that
recovers), a webhook receiver, the real DB, and the persisted state.

Not exercised end-to-end: the Synology collection path — its rules are
tested on data shaped exactly like the adapter's output types, but I
did not stand up a fake DSM. Real dev database mtime untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 02:15:19 +02:00
bobbanandClaude Sonnet 5 bf03a15337 Make the app installable as a PWA
Adds a web manifest, icons (standard 192/512, a full-bleed maskable
512 with the glyph kept inside the safe zone, and an iOS touch icon),
theme-color/apple meta tags, and a service worker, so "Install app" /
"Add to Home Screen" gives it its own icon and a standalone window.

The service worker is a deliberate no-cache pass-through: it registers
a fetch listener (so browsers treat the app as installable) but never
calls respondWith(), so every request goes to the network exactly as
without a worker. A caching worker would keep serving an old JS bundle
after each rebuild — the same stale-build confusion that already cost
time on the settings layout fix — and this app is a live view of
authenticated data with no useful offline mode. Registered in
production builds only.

Icons are generated procedurally (server-rack glyph on the sidebar's
dark colour) and encoded as real PNGs; visually checked, including
that the maskable variant keeps the glyph inside the safe zone.

Verified in a real Chromium (headless Edge) over the DevTools protocol
against the built bundle served with the same express.static setup as
production: the DevTools installability audit reported no errors, the
manifest parsed with no errors, the worker registered at scope "/",
activated and took control, and a navigation through the worker
returned the app page normally. The manifest is served as
application/manifest+json and sw.js as JavaScript. The in-app preview
pane silently blocks service-worker script fetches, so it could not
be used for this — hence the real browser. Real dev database mtime
untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 02:07:04 +02:00
bobbanandClaude Sonnet 5 7e81306aa7 Read SSL certificate expiry from the live server instead of trusting a typed-in date
A certificate secret's expiry was only ever what someone typed in, so
a renewed cert (or a wrong date) meant the app's reminders were
silently wrong. A certificate secret can now be given a host:port; the
app opens a real TLS connection and reads the certificate's actual
expiry — on create/edit (if the host changes), daily, and via a
per-row "Check now" — and keeps expiryDate in sync. Because the daily
refresh runs before the existing expiry check, the reminder is always
computed from what's actually being served.

Verification is deliberately off for the connection: homelab services
routinely serve self-signed/internal-CA certs, and an already-expired
one is exactly the case worth reporting, which a verifying connection
would refuse before exposing the dates.

Failure handling avoids the silent-staleness this is meant to fix: a
failed check keeps the last known date, records why on the row (shown
as a "Check failed" badge), and is listed in the daily secrets
notification. Creating a monitored secret whose host can't be reached
and with no manual date is rejected with the reason rather than saved
blank. A non-TLS port (the likeliest typo) gets a plain-language
error instead of raw OpenSSL output.

Server-side connections to a user-supplied host:port need the same
operator role that already gates editing secrets (and running Semaphore
templates, which is strictly more powerful); the host is validated
against a strict character set before any connection is made.

New nullable secrets columns (check_host, check_port, last_checked_at,
last_check_error) via migration 0007; existing rows are unaffected.

Verified against real TLS servers (openssl-generated certs) and the
real secrets router with a stubbed session: a live 45-day cert read
back as the correct date via both an IP host (no SNI) and a hostname;
an already-expired cert reported its past date and shows as expired;
refused connections, a server that accepts but never answers (times
out), and a plain non-TLS server each produced a descriptive error
rather than a hang or crash. Through the router: create with a host
and no date reads the date; unreachable host with no date -> 400 with
the reason; unreachable with a manual date -> saved with the error
recorded; host on a non-certificate type and an invalid host string
-> 400; a hand-typed date on a monitored secret is ignored; changing
the host re-checks immediately; changing the type away from
certificate ends monitoring; a viewer gets 403 on Check now. 23 checks,
all passing (a first re-run showed 2 spurious failures that were leftover
rows from the previous run's scratch database, confirmed by a clean re-run).
Real dev database mtime untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 23:41:29 +02:00
bobbanandClaude Sonnet 5 0da73711d9 Add a Generator page for server names, usernames, and passwords
New "Generator" nav item, open to every role since it's a pure
client-side utility with no data mutation. Three independent cards:
- Server name: picks from Swedish girl names or Disney characters (or
  both), skipping any name already used by an existing server —
  checked against the real server list, not just avoiding duplicates
  within one session.
- Username: adjective+animal with a configurable separator and an
  optional 2-digit suffix.
- Password: length slider, per-charset toggles, an "exclude ambiguous
  characters" option (0/O, 1/l/I), and a rough entropy/strength
  readout. Uses crypto.getRandomValues with rejection sampling (not
  Math.random or a plain modulo), since a biased password generator
  is a real security footgun.

Nothing generated here is sent to or stored on the server — it's
computed entirely in the browser and only reaches the backend if the
user pastes it into some other form themselves (e.g. Secrets, adding
a server).

Verified generators.ts directly: collision avoidance always returns
the one remaining unused name when every other option in a themed
list is taken, the full-list-exhausted fallback produces a numbered
name that still doesn't collide, matching is case-insensitive,
per-charset password generation only ever produces characters from
the selected sets, excludeAmbiguous holds across 200 40-character
samples, entropy math matches the expected log2 formula, and a
5000-sample single-character distribution came out uniform (469-521
per digit against an expected 500, no modulo bias) confirming the
rejection-sampling RNG is unbiased.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 20:56:15 +02:00
bobbanandClaude Sonnet 5 35979cb043 Add bulk actions to Secrets, IP Addresses, and Tailscale devices
One row at a time was tedious for anything beyond a handful of items.
New useSelection hook (id-based Set, so picks made on page 1 survive
moving to page 2 rather than resetting per page) backs a checkbox
column and a bulk-actions bar on each of the three tables:
- Secrets / IP Addresses: bulk delete.
- Tailscale devices: bulk authorize/deauthorize/remove.

No new backend endpoints — each bulk action is a client-side
Promise.allSettled loop over the existing single-item endpoints, so
one failure doesn't block the rest, and the bar reports how many (if
any) failed. Selection clears after a bulk action completes or when
switching Tailscale integrations (device ids aren't comparable across
different tailnets).

Verified the selection hook's Set logic directly (extracted as pure
functions, no React renderer needed): select-all/deselect-all
toggling, individual toggle, and — the case most likely to have a
subtle bug — that selecting items on one page and then selecting
different items on another page keeps both, with toggleAll on either
page only ever touching that page's own ids.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 19:13:56 +02:00
bobbanandClaude Sonnet 5 e081eecba4 Stop stretching the settings nav to match content height
Confirmed via real getBoundingClientRect measurements from the user's
own browser that the previous fix's text alignment was already exact
(navItem/heading/saveBtn all centerY=88, pixel-perfect) — so that
wasn't the remaining problem. The actual issue was navCard itself:
1764px tall, because it was stretched (h-100 + row align-items-stretch)
to match the Notifications page's own long column of cards, leaving
~1500px of empty space below the last nav item ("Backup").

Dropped the stretch. The nav card now sits at its own natural, compact
height — still top-aligned with the content column (that part was
never broken) and still using the tightened list-group-item padding
so its text lines up with the content heading, just without forcing
its box to match a content column that can be arbitrarily tall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:46:20 +02:00
bobbanandClaude Sonnet 5 439eccaebb Align settings nav text with the content heading, not just box height
The previous fix made the nav card and content column exactly equal
height, but the two boxes' TOP EDGES were already aligned all along —
what actually looked "off" was that the nav's first item text sat
~12px lower than the content heading/Save button, because Tabler's
default list-group-item padding (1.25rem) is sized for a standalone
list card, not a compact sidebar nav.

Verified with the same pixel-measurement technique as the previous
fix, against the real markup rendered in a browser: before, the nav's
first-item text center sat at y=95.5 while the content heading/button
center sat at y=84 (both card tops already matched at y=64). Tightening
this list-group's own item padding to 0.5rem (scoped via its own
--tblr-list-group-item-padding-y CSS variable, not a global override)
brought the nav text center to y=83.5 — 0.5px off target, imperceptible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:22:45 +02:00
bobbanandClaude Sonnet 5 6ff20d42fd Match the settings sidebar's height to the content column
The nav was a bare list-group with no card wrapper, so it only stood
as tall as its own 6 items while the content column (full of cards)
ran on much longer below it — the two looked like they were floating
at different levels instead of one cohesive layout.

Wrapped the nav in a card (list-group-flush inside it, matching the
visual weight of the content's own cards) and stretched it to the row
's full height. That alone left a stray 16px gap between the two
columns' bottoms — traced to the nav column's mb-3 (meant for spacing
when the columns stack on mobile) counting against the flex-stretch
calculation even at desktop widths, where the columns sit side by
side and don't need it. Changed it to mb-3 mb-md-0, which is what
actually eliminated the gap.

Verified directly in a browser against the compiled markup: measured
the two columns' rendered heights before and after — before, a
16px/only-cosmetic h-100 attempt left a residual gap (traced to the
mb-3 collision above); after fixing that, both columns are exactly
byte-for-byte equal height with matching bottom edges at desktop
width, confirmed via getBoundingClientRect rather than by eye.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:08:49 +02:00
bobbanandClaude Sonnet 5 ca61f2a915 Surface Proxmox VMs/LXCs with no backup coverage at all
A failing backup run is visible now, but a guest with no backup job
covering it in the first place was still a silent gap. Rather than
depending on Proxmox's /cluster/backup-info/not-backed-up-guests
endpoint (only exists on newer PVE versions), this derives coverage
from data already fetched: a guest counts as covered if any enabled
job either lists its vmid directly, or backs up "all guests" (scoped
to the job's node, if it has one) without excluding it.

Adds a warning banner plus a full table to the Proxmox page's Backups
card, and extends the existing daily "Proxmox backup failed"
notification (relabeled to mention this too) to also list uncovered
guests, gated by the same toggle.

Verified the coverage logic directly (it's a pure function, so no
fake server needed) across 7 cases: no jobs at all, an all-guests job
with an exclude list, a specific-vmids job, a node-scoped job that
shouldn't cover a guest on a different node, a disabled job providing
no real coverage, two jobs whose combined scope covers everything
neither would alone, and a realistic mixed scenario — all passed.
Confirmed the real dev database's mtime was untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:01:48 +02:00
bobbanandClaude Sonnet 5 73d649377e Add notification quiet hours with digest delivery
Instant notifications (DNS changes, integration failure/recovery
alerts) had no way to avoid pinging overnight. Adds a "Quiet hours"
window under Settings -> Notifications: notifications that would fire
during the window are held in a new notification_queue table instead
of sent immediately, then delivered as one combined digest at the end
time (in the same timezone already used for the daily checks) via a
new scheduled flush job. The scheduled daily checks (secret expiry,
Tailscale key, Docker updates, Proxmox backups) already only fire
once at a chosen time, so this mainly matters for the instant ones.
Includes a live "N queued" indicator with a manual "Flush now" button
for visibility, and correctly falls back to sending immediately
whenever the feature is disabled (the default).

Gating lives at the single choke point every notification already
flows through (notify()), so no per-event-type wiring was needed.

Verified against a fake webhook receiver on an isolated scratch
database: exhaustively checked the midnight-wraparound window math
(9 cases including exact-boundary inclusive/exclusive edges) against
synthetic "now" values rather than depending on when the test
happens to run, then end-to-end through the real notify()/
flushQuietHoursQueue() functions with a window constructed around the
actual current time — confirmed a notification during the window
queues instead of sending, the flush produces one digest with the
original title/message intact and clears the queue, a notification
outside the window sends immediately, and disabling the feature
entirely sends immediately regardless of the window. Confirmed the
real dev database's mtime was untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:56:33 +02:00
bobbanandClaude Sonnet 5 99db7e1cf0 Surface Proxmox backup job status, with a daily failure notification
Proxmox already runs vzdump backups, but nothing in the app said
whether they were actually succeeding — a silent backup failure is
one of the more dangerous blind spots a homelab admin can have. Adds
a "Backups" card to the Proxmox page: configured backup job
schedules (storage target, which guests, enabled/disabled) from
GET /cluster/backup, and recent vzdump task history per node from
GET /nodes/{node}/tasks?typefilter=vzdump, with a banner at the top
if the most recent run didn't succeed.

New "Proxmox backup failed" notification toggle under Settings ->
Notifications, on the same daily schedule as the other checks. The
scheduler checks each node's own most-recent vzdump run independently
(not just the single most recent task overall) so one node's healthy
backup can't mask another node's failing one in a multi-node cluster.

Known limitation, documented in the adapter's own header comment:
Proxmox's task list doesn't reliably expose which specific guest
failed within an "all guests" job — only the task's own log text has
that — so this surfaces job- and task-level status rather than
guessing at per-guest outcomes.

Verified against a fake Proxmox server (real self-signed HTTPS, since
the adapter's node:https usage can't be monkey-patched under ESM)
reproducing the documented /cluster/backup and task-list response
shapes: job parsing (all-guests+exclude vs specific-vmids+disabled)
correct, task OK/failure parsing correct, and the critical multi-node
scenario confirmed — one node's failing latest run flagged, the
other's healthy latest run correctly left alone, with exactly one
notification of the right content. This reproduces Proxmox's
documented API shape rather than a live-verified one; flag if the
real cluster's response differs in some way this didn't anticipate.
Confirmed the real dev database's mtime was untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:42:22 +02:00
bobbanandClaude Sonnet 5 6d673db9ec Add session management: see who's signed in, revoke a session
No visibility existed into who was currently signed in or a way to
force a device out. Sessions already live as files via
session-file-store, so this reads that store directly rather than
adding a new DB table: new Settings-adjacent "Sessions" page
(admin-only, alongside Users) lists every live session with the
user's name/email/resolved role, IP, a friendly "Browser on OS"
summary parsed from the user-agent, last-active time, and expiry, with
a Revoke button per row (extra confirmation if you revoke your own
current session, since that signs you out immediately).

IP and user-agent are now captured into the session at login
(auth/router.ts) since express-session doesn't track them itself.
session-file-store's own Store type doesn't declare its list()
method, so sessionStore.ts adds a narrow local interface for it rather
than losing type safety on the rest of the store.

Verified against a real session directory seeded through the actual
session-file-store APIs (not hand-written JSON): confirmed correct
field resolution including a session whose user row was later deleted
(role resolves to null instead of crashing), correctly excluded a
mid-OIDC-login session with no completed user yet, correctly excluded
an already-expired session, and confirmed revoke actually deletes the
right session file and only that one. Confirmed the real dev
database's mtime was untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 20:29:29 +02:00
bobbanandClaude Sonnet 5 1cae35a59e Add a daily Docker image-update notification
Dockhand's pending-update counts were only visible if you happened to
open the Docker page. New "Docker image update available" toggle
under Settings -> Notifications, sharing the same daily
time/timezone as the secret and Tailscale key expiry reminders (same
node-schedule reschedule-on-settings-change pattern as those two).
Reads each enabled Dockhand integration's already-cached update-check
results via listContainers() rather than triggering a fresh
per-container registry lookup, so it costs nothing extra beyond what
the Docker page itself already fetches, and lists every container
with an update pending across all environments/integrations in one
notification.

Verified end-to-end against a fake local Dockhand server (one
environment, two containers, one flagged with a pending update) and a
fake webhook receiver on an isolated scratch database: the check
correctly found only the flagged container (with its newerVersion),
sent exactly one notification with the right title/content, excluded
the up-to-date container, and sent nothing at all when the setting
was toggled off despite still finding the same pending update.
Confirmed the real dev database's mtime was untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 23:44:47 +02:00
bobbanandClaude Sonnet 5 9c07718d2d Fix command palette results being unclickable
The backdrop div was nested inside .modal instead of rendered as its
sibling. Tabler/Bootstrap's backdrop (z-index 1050) is only supposed
to sit below the modal (z-index 1055) because normally they're both
direct children of the same stacking context; nesting it inside
.modal instead made it establish z-index inside .modal's own stacking
context, where it painted on top of .modal-dialog and silently
absorbed every click meant for a result row (confirmed via
elementFromPoint in a real browser: clicking a result hit
.modal-backdrop, not the result div). Moved the backdrop to be a
sibling rendered before .modal, matching the structure elementFromPoint
now confirms resolves to the actual result element.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 23:02:55 +02:00
bobbanandClaude Sonnet 5 5c1376cf1c Make search results open the actual entry, not just its list page
Previously a search result for a secret/IP/integration/DNS record
only filtered or scrolled to the right page — you still had to find
and click it yourself. Now:
- Secrets/IPAM results pass ?editId= and the page auto-opens that
  row's existing edit form on load (viewers still just get the ?q=
  filter, since they can't edit).
- Integration results now link to /integrations?editId= (which
  auto-opens IntegrationEditForm for that row) instead of the type's
  live dashboard page (/proxmox, /tailscale, etc.) — the dashboard
  can't distinguish between two integrations of the same type anyway,
  so it wasn't landing on "the" entry when more than one existed.
- DNS record results now also pass recordType/recordName/recordContent
  so Dns.tsx, after auto-selecting the right zone, finds that exact
  record by (type, name, content) and opens its edit form too. That
  triple was used instead of an id because the records-list endpoint
  returns both the cache table's internal integer id and the
  provider's own string record id under different field names, and
  matching by content sidesteps that ambiguity entirely rather than
  risking picking the wrong one.

Servers and DNS zones/providers already landed on their real entry
(server detail page; zone/provider auto-selected) so those are
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 19:16:09 +02:00
bobbanandClaude Sonnet 5 009bb3e027 Add a global search / command palette (Ctrl/Cmd+K)
With 6 integrations, DNS, secrets, IPAM, and servers all in one app,
there was no single place to type a hostname/IP/name and jump
straight to it. New GET /api/search aggregates a LIKE-based search
across servers, secrets, IPAM, integrations, DNS providers, DNS
zones, and DNS records (joining zone/provider names onto each record
result) in one round trip — homelab-scale row counts make a naive
LIKE scan plenty fast, no FTS needed. Every underlying resource's own
list endpoint already only requires requireAuth (viewer role
included), so the aggregate endpoint uses the same single check.

Frontend: a self-contained CommandPalette component (Tabler's modal
CSS classes driven by React state, since the app doesn't load
Bootstrap's JS) opens via a sidebar search button or Ctrl/Cmd+K from
anywhere, debounces input, and supports arrow-key navigation. Results
link to the right page: servers to their existing /servers/:id
detail route; DNS zone/record results deep-link via new ?providerId=
&zoneId= query-param handling added to Dns.tsx (auto-selects that
provider/zone on load, since Dns.tsx previously held selection only
in local state with no URL sync); secrets/IPAM results link with
?q= to prefill each page's existing client-side search box;
integrations link to their type's dashboard page (no per-instance
route exists yet, so same-type integrations share one link).

Verified the query logic (joins, case-insensitive LIKE, correct
zone/provider name resolution) against an isolated scratch database
seeded with realistic cross-referencing rows — a server name match, a
case-mismatched match against both a secret and a DNS provider
sharing "cloudflare", an IP address matching both an IPAM entry and
the DNS A record pointing at it (confirming the record's joined zone
and provider names came through correctly), an integration name
match, and a no-match query returning every category empty. Did not
re-verify the requireAuth/asyncHandler wiring itself, since it's the
same one-line pattern already proven across every other router in
this app. Confirmed the real dev database's mtime was untouched
throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 03:20:05 +02:00
bobbanandClaude Sonnet 5 23eb7f0d70 Add passphrase-protected export/import for integrations, DNS providers, and settings
Nothing let you back up or migrate the app's own configuration short
of copying the raw SQLite file. Adds Settings -> Backup: export
decrypts every integration/DNS provider credential (normally
encrypted at rest with this server's CREDENTIALS_ENCRYPTION_KEY) and
re-encrypts the whole payload with a passphrase you choose (scrypt-
derived key, AES-256-GCM), so the file is portable to a different
instance with a different encryption key rather than being tied to
this one. Import decrypts with that passphrase and merges settings
onto the current ones; integrations/DNS providers are only added when
no existing row shares their type+name, so re-running an import never
duplicates or overwrites a working credential.

Scope is configuration only — no DNS records, secrets, IPAM, servers,
or audit/diagnostic log data.

Verified end-to-end against two isolated scratch databases with
different encryption keys (proving actual cross-instance portability,
not just round-tripping through the same key): export -> encrypt ->
write file -> decrypt on the other DB -> import -> re-decrypt the
newly created integration/provider using the target's own key,
confirming the plaintext credentials survived correctly; a wrong
passphrase failed loudly (GCM auth failure) as expected; and
re-running the same import a second time skipped both rows instead of
duplicating them. Confirmed the real dev database's mtime was
untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 21:18:41 +02:00
bobbanandClaude Sonnet 5 b5a4c6e2d9 Alert when an integration or DNS provider fails repeatedly
The Diagnostic Log already records every outbound call's success or
failure, but nothing acted on it — you'd only notice an integration
was down by happening to open its page. Adds a per-source consecutive-
failure counter (in-memory, reset on restart, same durability tier as
the diag log's own ring buffer) hooked into recordDiagEntry: crossing
the configurable threshold (default 3) sends one "down" notification
on every configured channel, and a "recovered" notification fires once
it succeeds again — no repeat spam while it stays down. New
"Integration/DNS provider failing repeatedly" toggle and threshold
field under Settings -> Notifications.

Verified end-to-end against an isolated scratch database with a real
local HTTP server standing in for the webhook channel: 5 consecutive
failures produced exactly one "Down" notification (at the 3rd
failure, correctly naming "3 calls"), a subsequent success produced
exactly one "Recovered" notification, and two more failures on a
fresh streak triggered nothing (below threshold) — confirmed the real
dev database's mtime was untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 14:51:00 +02:00
bobbanandClaude Sonnet 5 10d123b18a Add automatic retention purging for the Diagnostic and Audit logs
The diagnostic log already rings-buffer to 500 rows, but the audit
log had no cap at all and would grow forever. Adds an opt-in
age-based purge under Settings -> Logs: keep entries for N days,
checked on a configurable interval (hourly through monthly), plus a
manual "Purge now" button. Reuses the existing node-schedule-style
reschedule-on-settings-change pattern from the secret/Tailscale
expiry checkers, but as a plain setInterval since "how often" here is
an interval rather than a specific daily time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 02:22:32 +02:00
bobbanandClaude Sonnet 5 af3f7e77d2 Document required access per integration and DNS provider
Each adapter performs write actions, not just reads (start/stop a
guest, edit a DNS record, rerun a CI job, etc.), so a read-only
credential silently works for the dashboard views but fails the
moment you use an action. Lists the exact endpoints/permissions
needed per target system, drawn from each adapter's own auth code and
header comments (e.g. Proxmox's Sys.Audit/Datastore.Audit split,
Azure's DNS Zone Contributor role, Loopia/Pi-hole/cPanel having no
scoped-credential option at all).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 02:08:40 +02:00
bobban 19f84838cc Revert "Move dark mode toggle from sidebar to Settings > Display"
This reverts commit 4ba86429d5.
2026-09-19 01:58:18 +02:00
bobban 4ba86429d5 Move dark mode toggle from sidebar to Settings > Display 2026-09-19 01:56:08 +02:00
bobbanandClaude Sonnet 5 5c35080e22 Add a dark mode toggle
Personal, per-browser preference (localStorage), not an admin-wide
Display setting like date/time format — every role can pick their own.
Sets data-bs-theme on <html> to hook into Tabler's built-in dark
variant, applied via an inline script in index.html before React
mounts so there's no light-mode flash on load. Toggle button lives in
the sidebar footer next to Sign out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 01:51:59 +02:00
bobbanandClaude Sonnet 5 82ed25b63f Fix checkbox/label spacing on Notifications and other form checkboxes
Tabler's form-check-single modifier zeroes margin on .form-check-input,
which cancels the -2rem margin-inline-start the base .form-check rule
relies on to offset the checkbox into its label's padding gutter. That
left checkboxes floating almost flush against their label text.

Removed the form-check-single class from all 9 affected checkboxes:
6 in NotificationSettings, 1 each in DnsProviderForm, IntegrationForm,
and IntegrationEditForm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 01:43:45 +02:00
bobbanandClaude Sonnet 5 08eb0bd87e Make DNS and Secrets dashboard widgets match the integration widgets
DNS and Secrets each rendered as three separate stat-tile cards plus a
fourth full-width breakdown card -- visually a different family from
the single self-contained card each integration widget uses (label +
status badge, a compact stat row, then its own breakdown bar inline).

Extracted the integration widgets' card shell into a shared WidgetCard
component (label + badge header, children below) and rebuilt DNS and
Secrets on top of it instead of StatTile/TypeBreakdown, which are now
unused and removed. Also refactored the Integrations map itself onto
WidgetCard, so all eight dashboard widgets (DNS, Secrets, and the six
integrations) are now literally the same component, not just visually
similar.

DNS and Secrets get a badge like the integrations' Connected/Not
connected -- "Not configured" (gray) when nothing's set up yet, or a
status summary otherwise ("X enabled" for DNS; "All OK" / "X expiring"
/ "X expired" for Secrets, mirroring how a failing integration shows a
warning-colored count instead of a plain badge). They now sit under
one "Overview" heading in a shared row instead of two separate
sections, matching how the integration widgets already share one
row-cards grid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 00:07:32 +02:00
bobbanandClaude Sonnet 5 b40234a557 Make table page size a configurable setting, not a hardcoded 20
Pagination just landed hardcoded to 20 rows everywhere; add a way to
change that instead of leaving it fixed for every table in the app.

pageSize joins dateFormat/timeFormat on the existing display settings
object (server-side default 20, 5-500 range enforced by the PUT
schema) rather than becoming its own settings section, since it's the
same kind of thing -- an admin-configured, globally-applied display
preference read by every signed-in role via the already-public GET
/api/settings/display endpoint, same as the date/time format already
works.

Renamed the "Date & Time" settings tab/page/route to "Display" (still
just one component, now covering both date/time format and table
pagination) since its scope no longer matches the old name -- kept
Settings.tsx's usual pattern of one page per concern rather than
adding a second, oddly-scoped tab just for one number field.

New web/src/utils/pageSize.ts mirrors utils/date.ts's existing
module-level "set once at startup, read anywhere without prop-
drilling" pattern; usePagination()'s pageSize parameter now defaults
to getPageSize() instead of a literal 20, evaluated fresh on every
call so it picks up a saved change without touching any of the ten
pages already using the hook.

Verified server-side against a temp SQLite DB: pageSize defaults to
20, a partial update sets it without disturbing dateFormat/timeFormat
and vice versa, and it persists across a fresh settings read. Also
checked the default-parameter mechanics directly (re-evaluates the
global value on every call rather than capturing it once, and an
explicit override still wins).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 23:58:14 +02:00
bobbanandClaude Sonnet 5 7bf9b03839 Paginate tables that can grow large
Sorting/CSV export were already app-wide; tables with a real chance of
growing into dozens or hundreds of rows (a busy tailnet, a big DNS
zone, a homelab's full IP inventory, a Gitea org with many repos, ...)
had no pagination at all, making them a long unbroken scroll.

New usePagination hook (client-side slicing over an already-sorted/
filtered array, 20 rows per page) and a matching Pagination component
(Prev/Next + "Page X of Y (N total)", hidden entirely when everything
fits on one page). The current page is clamped to the valid range on
every render rather than reset via an effect, so switching to a
smaller data set (a different selected integration, a filter that
narrows the result) can never strand the view on a now-nonexistent
page -- no per-page "reset on change" wiring needed anywhere.

Applied to Audit Log, DNS zones and records, IP Addresses, Secrets,
Servers (manage table), Docker containers, Proxmox guests, Semaphore
templates, Gitea repos, and Tailscale devices. CSV export keeps
exporting the full sorted/filtered array regardless of which page is
currently shown -- pagination only affects what's rendered on screen.
Left the already-small tables (Synology volumes/disks, Users,
Integrations, per-node Proxmox storage) unpaginated, and left the
Diagnostic Log's existing server-driven pagination as-is rather than
bolting a second, different pagination scheme onto it.

Verified the clamping logic directly: a normal page, the trailing
partial page, a requested page beyond the end (clamps to the last
valid page instead of rendering empty), and an empty result set
(clamps to page 0 with a page count of 1 instead of a negative range).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 23:51:14 +02:00
bobbanandClaude Sonnet 5 81e55fc792 Show per-disk usage for Proxmox-linked servers too, not just a total
The agent path showed a per-mount usage table; a Proxmox-linked server
only ever got a single "Disk (allocated)" figure, since that's all the
VM/LXC config alone can tell you -- it's the attached disk's declared
size, not how full it actually is inside the guest. Fix the actual gap
instead of just matching the display: fetch real usage where Proxmox
can see it.

adapter.getGuestDetail() gained a `disks` field (same {mount,
sizeBytes, usedBytes} shape the agent already reports, so the frontend
renders both identically):
- LXC: the host can read straight into the container's root
  filesystem, no agent needed -- status/current's disk/maxdisk fields
  are real usage, not just allocation.
- QEMU: the hypervisor can't see inside a virtual disk at all without
  help, so this calls the QEMU guest agent's get-fsinfo command (same
  "gracefully degrade if the agent's missing/older" tolerance already
  used for its IP-address lookup, and independent of it -- one
  command failing doesn't take out the other). Pseudo-filesystems
  (tmpfs, etc.) are filtered out by checking for a non-empty backing
  `disk` array, the common convention for this endpoint.

Extracted the disks-table JSX (previously only in the agent branch)
into a shared DisksTable component and used it in both branches, and
added a note explaining an empty result when a running QEMU VM's
guest agent doesn't support get-fsinfo (an older agent version).

Verified against a mock Proxmox API over real TLS: an LXC's root
usage, a QEMU VM's real fsinfo mounts (with the disk-less tmpfs entry
correctly filtered), and a QEMU VM whose get-fsinfo fails outright --
confirming that degrades to an empty disks list without throwing and
without affecting the separate network-get-interfaces result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 23:32:46 +02:00
bobbanandClaude Sonnet 5 08a984719f Let admins hide the Proxmox-link card per server
Not every registered server is a Proxmox VM/LXC -- bare-metal boxes
and other hosts had no reason to show a "link to Proxmox" option, but
it appeared unconditionally on every server's detail page.

New hideProxmoxLink column on servers (default false, so existing
behavior is unchanged until someone opts in). A "Not a VM? Hide this"
link in the card's header sets it; once hidden, a small "+ Show
Proxmox link options" link takes its place so it's still reachable,
not buried in a settings form. The card always shows regardless of
this flag once a server IS actually linked, so unlinking never becomes
unreachable by hiding the card out from under an active link.

Verified against a temp SQLite DB with real migrations: a new server
defaults to false, and toggling true/false both persist correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 21:34:52 +02:00
bobbanandClaude Sonnet 5 f2a6253ddf Show container image-update status from Dockhand
Dockhand already tracks per-container image updates internally (its
UI shows this) via a cached check plus an on-demand recheck -- surface
that here instead of only showing running/stopped state.

adapter.listContainers() now also reads GET /api/containers/pending-
updates per environment (a cached read, no registry hit) and merges
each container's hasImageUpdate/newerVersion/checkedAt onto it.
updateAvailable is a tri-state: true (update pending), false (checked,
up to date), or null (never checked) -- distinguishing "no update"
from "we don't know yet" matters since a container can sit unchecked
indefinitely until someone triggers a check.

New adapter.checkForUpdates() triggers a fresh check across every
environment (POST /api/containers/check-updates, one registry lookup
per container so this can take a while) and new POST /:id/dockhand/
check-updates route (operator+, matching the existing container
action's role gating).

Docker page: new "Update" column (badge + tooltip with the newer
version), an "updates available" count next to the running/total
count, and a "Check for updates" button. Dashboard's Dockhand widget
also gained an "Updates" mini-stat, swapped in for the less useful
"Not running" figure (already inferable from running/total).

Field names (hasImageUpdate, newerVersion, checkedAt, the check-
updates response shape) came from Dockhand's own published OpenAPI
spec, not guessed -- and verified against a local mock Dockhand server
covering all three update states (pending, up to date, never checked)
plus the check-updates aggregation across environments, since this
sandbox can't reach the user's real Dockhand instance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 21:04:15 +02:00
bobbanandClaude Sonnet 5 7564796c39 Add a per-row Test button to the Integrations list
The add/edit forms already had "Test connection", backed by existing
POST /api/integrations/:id/test (admin-only, pings with the stored
config) and POST /api/integrations/test (for a not-yet-saved one) --
but there was no way to re-test an already-configured integration
without opening its edit form. Add a "Test" button next to Edit/
Delete on each row, showing an OK/latency or Failed/error badge
inline once it returns. Pure frontend wiring to the existing endpoint,
no server changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 20:31:04 +02:00
bobbanandClaude Sonnet 5 d5b6c55383 Simplify Integrations to a plain list + Add button
Flagged back when Proxmox/Synology got their own pages: once all six
integration types had moved to dedicated pages, the "browsing"
dropdown here only ever showed one of six near-identical "go manage
this on its own page" redirects, and its final fallback branch was
dead code (every IntegrationType was already covered). Now that all
the actual data views have moved out, drop that mode entirely.

The page is now just what "Manage integrations" already was: a
sortable, CSV-exportable table of configured integrations (name/type/
status), visible to every role since none of that is sensitive, with
an admin-only "Add integration" button and per-row edit/enable-disable/
delete actions. No more mode toggle, no provider-picker dropdown, no
per-type redirect cards -- the six dedicated pages (and the sidebar
nav that already points at them) are how you actually use each
integration now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 19:57:03 +02:00
bobbanandClaude Sonnet 5 16d64c42d8 Enrich the Dashboard integration widgets to match the new DNS/Secrets sections
The six integration cards only ever showed one headline number each
(e.g. "3/5 devices online") -- thin compared to the new DNS/Secrets
sections' stat-row-plus-breakdown layout. Each widget now shows 2-3
mini stats plus a compact breakdown bar, using data these endpoints
already return (so no new API calls except one extra Synology call for
CPU/RAM, matching what the Synology page itself already fetches):

- Tailscale: online/unauthorized/expiring-soon stats + devices by OS
- Proxmox: running/total + VM vs LXC counts + guests by node
- Dockhand: running/total + host count + containers by state
- Semaphore: template/failing counts + templates by last-run status
- Gitea: repo/private/failing counts + repos by last-run status
- Synology: volumes/unhealthy/CPU load + RAM used-of-total + disks by
  health status

Extracted the breakdown-bar rendering out of the DNS/Secrets-only
TypeBreakdown into a bare BreakdownBar (no card wrapper, optional
compact sizing) so it drops into each integration card directly, and
factored the per-item counting into a small breakdownFrom() helper
used by all six. Status/state colors reuse the same palette each
integration's own dedicated page already uses for its badges (Docker's
container-state colors, Semaphore/Gitea's run-status colors); open-
ended categories (OS names, Proxmox node names) get a generated
palette instead since there's no fixed enum to hardcode against.
Widget cards moved from a 3-column to a 2-column grid to fit the
extra content without feeling cramped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:14:18 +02:00
bobbanandClaude Sonnet 5 a5ec099c13 Bring Sloth Manager's DNS/Secrets dashboard here, alongside integrations
Sloth Manager's dashboard showed per-system stats for DNS and Secrets
(domains/records by type, expiry counts by type) that this app's
Dashboard never had -- it only ever showed the six live integration
widgets. Port those two sections over, generalized to sit alongside
the integrations this app added that Sloth Manager never had.

New server/src/dns/stats.ts (getDnsStats(), used by new GET
/api/dns/stats): zone counts are fetched live per enabled provider
(matching what the DNS page itself shows, since the zone cache table
only gets a row once a zone has been synced and would undercount) --
one unreachable provider surfaces its error without blanking the rest.
Record-type breakdown comes from the local cache instead, since
records are only ever shown from cache elsewhere in this app too
(fetching every zone's records live on every dashboard load would be
far more expensive for no real accuracy gain).

Dashboard.tsx gained three sections under clear headers: DNS (domain/
provider/record stat tiles + a "records by type" breakdown), Secrets
(monitored/expiring/expired tiles + a "secrets by type" breakdown,
computed client-side from the already-fetched secrets list, same as
Sloth Manager did), and the existing Integrations widgets grouped
under their own header for visual parity with the two new sections.
Breakdowns render as a stacked proportion bar with a color-coded
legend rather than a pie chart -- this app has no charting library
anywhere yet, and a plain CSS progress bar (an idiom Tabler itself
uses) gets the same "see the mix at a glance" value without adding one
for a single dashboard.

Verified getDnsStats() against a temp SQLite DB with real migrations
and an enabled + a disabled DNS provider: enabled/total provider
counts, live zone counting, and cached-record-type aggregation (sorted
by count) all came back correct, and the disabled provider was
correctly excluded from the zone count while still counting toward
the total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:06:42 +02:00
bobbanandClaude Sonnet 5 655862c94e Add per-server admin-page links (Dockge, Webmin, Cockpit, etc.)
Each server's detail page gets an "Admin Links" card for bookmarking
that host's own web UIs -- container managers, Webmin, Cockpit, or
anything else reachable by URL -- so there's a quick way to jump
there without hunting down the address each time.

New server_links table (serverId FK, label, url, ON DELETE CASCADE so
removing a server cleans up its links automatically) and three new
routes: POST/PATCH/DELETE /api/servers/:id/links, gated to operator+
like the rest of this page's editing actions; the existing GET
/:id/detail now includes the server's links alongside hardware/DNS
info. URLs are validated to start with http:// or https:// server-side
(rejecting e.g. a javascript: URL that would otherwise render as a
clickable link).

Rendered as a row of pill buttons (label opens the URL in a new tab),
with inline edit/remove controls next to each when the viewer can
edit, and an "Add link" form matching the existing task-form style on
the same page.

Verified against a temp SQLite DB with real migrations: create/list/
update/delete all work, and deleting the parent server cascades to
remove its links rather than leaving them orphaned.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 22:51:10 +02:00
bobbanandClaude Sonnet 5 f4274c1ad8 Make every table sortable and exportable to CSV
Applies the useSortable/SortableTh infrastructure introduced in the
Diagnostic Log commit to the rest of the app's tables: Audit Log,
Users, Servers (manage table), server task tables (grouped by
schedule type, on both the all-servers and per-server views), DNS
providers/zones/records, Docker containers, Gitea repos, Integrations
(manage table), IP Addresses, Proxmox guests, Secrets (already had
CSV, gained sorting), Semaphore templates, Synology volumes/disks, and
Tailscale devices.

Every column header is now click-to-sort (again on the raw field, not
its formatted display -- a byte count sorts numerically even though
the cell shows "1.2 GB", a date sorts chronologically even though the
cell shows "5d 15h 2m"), and every table got an "Export CSV" button
next to its Refresh button, exporting whatever's currently
sorted/filtered via the existing downloadCsv util.

Grouped tables (ServerTaskTable renders one sub-table per schedule
type) can't call the useSortable hook per group without breaking the
Rules of Hooks, so extracted its comparison core as a standalone
sortItems() function, driven by one shared sort-state pair at the
component's top level and applied per group.

Deliberately left two small (1-6 row) tables embedded inside stat
cards unsorted -- Proxmox's per-node storage list and ServerDetail's
agent-reported disk list -- since they're secondary detail inside an
already-scannable card, not primary list content; happy to add if
useful in practice.

Verified the shared sort core (sortItems) directly: numeric-aware
string compare (so "item2" sorts before "item10"), numbers, booleans,
and that null/undefined always sort to the end regardless of
direction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:54:36 +02:00
bobbanandClaude Sonnet 5 e35da87886 Add a Diagnostic Log, ported and generalized from Sloth Manager
Sloth Manager tracked every API call made to DNS providers for
connectivity troubleshooting. Port that here, generalized to cover
every outbound integration this app makes, not just DNS -- Tailscale,
Proxmox, Synology, Semaphore, Gitea, and Dockhand calls now show up
too, since a broken API token or unreachable host on any of them is
just as worth diagnosing.

New services/diagLog.ts: a generic withDiagLogging(source, adapter)
wraps every async method of any adapter object with timing +
success/failure recording, without touching a single adapter's
request/error-handling internals -- every DNS and integration adapter
interface here is already just a flat set of async methods, so this
one wrapper works for all twelve of them. Applied it at each adapter
factory's own return statement (one line each) rather than at the
route layer, so background jobs that construct adapters directly
(the Tailscale key-expiry scheduler, IPAM sync, agent-driven Proxmox
lookups) get logged too, not just requests through routes/integrations.ts.

New diag_log table (ring-buffered to the last 500 rows, mirroring
Sloth Manager's approach -- this is for live troubleshooting, not a
durable record) and admin-only GET/DELETE /api/diag-log routes, source/
result filters, pagination.

New admin-only Diagnostic Log page: filterable, paginated table with a
Clear button. Also introduces the shared useSortable hook + SortableTh
component used here for the first time -- a follow-up commit applies
the same sorting (and CSV export) to the rest of the app's tables, per
the same request.

Verified end-to-end against a temp SQLite DB with real migrations: a
fake wrapped adapter's successful and failing calls both land correctly
in the log with the right source/operation/latency/error, and the
source/ok filters and clear-log operation all behave correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:41:06 +02:00
bobbanandClaude Sonnet 5 42f073df81 Reorder sidebar nav to group related pages together
Dashboard, Servers, Proxmox, Docker, Synology, Secrets, DNS, IP
Addresses, Tailscale, Semaphore, Gitea, Integrations, Users, Audit
Log, Settings -- as requested, no routes or labels changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:16:39 +02:00
bobbanandClaude Sonnet 5 96dd911a9d Make Proxmox node cards full-width to match the VM/LXC table
Node cards were col-lg-6 (half width, side by side), while the
VM/LXC table below is full width -- looked mismatched, especially
with a single node. Full width also gives the left/right system-
info/storage split inside each card more room to breathe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 20:59:23 +02:00
bobbanandClaude Sonnet 5 7b39b7be4d Split Proxmox node card into system info (left) / storage (right)
Was a single stacked column (uptime/CPU/memory/swap, then the storage
table below); now a two-column layout within the card body — system
stats on the left, storage table (or the missing-permission hint) on
the right — so the card reads more like a dashboard tile at a glance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 20:50:16 +02:00
bobbanandClaude Sonnet 5 846953194c Show a hint when Proxmox storage list comes back empty, not silently
Confirmed via the user's rebuild: my previous fix caught a *thrown*
storage-fetch error, but Proxmox's /nodes/{node}/storage instead
returns 200 with an empty array when the API token has no
Datastore.Audit on any storage — it filters the list per-token rather
than erroring the whole call. That shape has error === null and
storages.length === 0, so it fell through both my error banner and
the "no storages" render guard, showing nothing.

The Storage section now renders a hint (naming the missing
Datastore.Audit privilege) whenever storages is empty and no error
was recorded, instead of just disappearing. CPU/RAM/uptime are
unaffected by this since they come from the separate /status call.

Verified against a mock Proxmox API returning this exact 200-with-
empty-array shape for /storage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 20:05:19 +02:00
bobbanandClaude Sonnet 5 5919929dfb Fix "No online Proxmox nodes found" masking real host-stats errors
listNodeStats() was silently dropping any node whose /status or
/storage call failed and filtering it out of the result — with every
node dropped, the page showed the same "no nodes" empty state as a
genuinely nodeless tailnet, even though the node was online and the
existing VM/LXC table below it worked fine.

The likely real cause: Sys.Audit (host status) and Datastore.Audit
(storage) are different ACL privileges from the VM/LXC management
scope this integration originally needed, so a token created before
this feature existed may lack them.

getNodeStats() now fetches /status and /storage independently via
Promise.allSettled instead of Promise.all, so one endpoint failing
doesn't discard data the other successfully returned, and records a
per-endpoint error message. listNodeStats() no longer filters failed
nodes out at all -- it always returns one entry per online node, with
`error` set and the rest of the fields null when nothing could be
fetched. The Proxmox page now shows that error inline on the node's
card instead of it vanishing.

Verified against a mock Proxmox API returning 403 on /storage only
(partial data still shows) and on both endpoints (node stays visible
with its error surfaced, not silently dropped) — reproducing the
reported bug and confirming the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 19:49:16 +02:00
bobbanandClaude Sonnet 5 2d062a9ac4 Add host stats to the Proxmox page
The Proxmox page only showed guest (VM/LXC) status; there was no view
of the underlying host(s) themselves -- uptime, CPU/RAM usage, or how
full each storage pool is.

New adapter.listNodeStats() calls GET /nodes/{node}/status and GET
/nodes/{node}/storage for every online node (a node going down
shouldn't blank the whole page, same tolerance as listGuests()) and
returns uptime, CPU usage % + core count + load average, RAM/swap
usage, and per-storage (local, LVM-thin, ZFS, NFS, ...) used/total.
New GET /:id/proxmox/nodes route alongside the existing /guests one.

One card per online node now sits above the VM/LXC table, showing
these stats plus a small storage table with usage bars (reusing the
same green/yellow/red thresholds as the Servers detail page and the
Synology page). Handles a multi-node cluster by rendering one card
per node, and storages with no active/total data (e.g. an offline NFS
mount) render as an "Inactive" badge with blank usage instead of
throwing.

Verified end-to-end against a local mock Proxmox API server over real
TLS (a throwaway self-signed cert, matching how the adapter's
`insecure` option is meant to be used) since this sandbox can't reach
the user's actual Proxmox cluster -- confirmed CPU fraction-to-percent
conversion, load average parsing, byte fields, and that a
null-valued/inactive storage doesn't break parsing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 19:26:33 +02:00
bobbanandClaude Sonnet 5 1ff59afb40 Notify on expiring Tailscale device keys
The Tailscale page already showed per-device key expiry; extend the
existing daily-reminder infrastructure (currently only for Secrets)
to push it out through the configured notification channels too, the
same way expiring secrets already are.

New tailscaleKeyExpiryScheduler.ts mirrors secretExpiryScheduler.ts:
runs once at startup (skipped if already run today) and daily
thereafter, checking every enabled Tailscale integration's devices for
keys expiring within the warning window and calling notify() with the
results. Reuses the exact same daily time/timezone setting as the
secret-expiry check (one "Daily reminder time" control, two
independent on/off toggles) rather than adding a second schedule for
users to configure.

Centralized the expiring-soon threshold and check (previously only
duplicated in the /synology and /tailscale route summaries) into
adapter.ts as `KEY_EXPIRY_WARN_DAYS` / `isKeyExpiringSoon()`, and
updated the devices route to use it instead of its own inline copy.

New `tailscaleKeyCheck` notification-event toggle (default on) in
settings, alongside the existing secret-expiry one.

Verified end-to-end against a temp SQLite DB + real migrations: a
tailscale integration pointed at a mock Tailscale API (one device
expiring in 10 days, one with key-expiry disabled) with the webhook
channel enabled and pointed at a mock receiver — confirmed the
scheduler's startup check queries the DB correctly, decrypts the
integration's credential, calls the adapter, filters out the
disabled-expiry device, and delivers a webhook payload naming only
the expiring device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:59:27 +02:00
bobbanandClaude Sonnet 5 c0af546d08 Show Tailscale device key expiry
Tailscale node keys expire (180 days by default unless disabled per
device) and an expired key drops the device off the tailnet until
re-authenticated -- worth surfacing before it happens.

adapter.listDevices() now reads `expires` and `keyExpiryDisabled` from
the device list API (`?fields=all`, already being fetched). Go's zero
time ("0001-01-01T00:00:00Z") is what Tailscale returns for "no real
expiry set" and is treated as null rather than shown as a bogus 1AD
date.

New "Key expiry" column on the Tailscale page: "Never" when disabled,
otherwise the date plus a badge (green/yellow/red matching the
Secrets module's ok/expiring/expired convention) using the same
30-day warning window as that module's default. The devices summary
gained `expiringSoon` (<=30 days left, including already-expired),
surfaced in the page header and as a new warning line on the
Dashboard's Tailscale widget, alongside the existing "awaiting
authorization" one.

Verified the parsing (real expiry, disabled/zero-time, already-expired,
far-future) against the compiled adapter with fetch calls to
api.tailscale.com redirected to a local mock server, since this
sandbox can't reach the user's real tailnet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:49:42 +02:00
bobbanandClaude Sonnet 5 a781df9c51 Trim the Servers page down to a server list + add-server flow
Scheduled-task browsing/editing already lives on each server's detail
page (ServerDetail.tsx), so the overview page's filters, task-group
listing, and add/edit-task form were pure duplication -- the only
things it needs to do are list servers (as clickable cards) and let
an admin register new ones / issue or rotate agent tokens.

Renamed ServersTasks.tsx -> Servers.tsx to match its new, narrower
scope; updated the nav label, route, and ServerDetail's back-link
text from "Servers & Tasks" to "Servers" accordingly. No server-side
or API changes -- this only removes now-redundant frontend code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:38:46 +02:00
bobbanandClaude Sonnet 5 9f2d27e2c4 Add system/hardware info to the Synology page
Beyond volume/disk health, DSM exposes hostname, CPU, RAM, IP
addresses, model/serial/firmware, and uptime -- surface those too as a
"System" card on the Synology page.

New adapter.getSystemInfo() combines three DSM Web API calls (fields
confirmed against the actively-maintained mib1185/py-synologydsm-api
client, which documents the same SYNO.Core.System / SYNO.Core.System.
Utilization / SYNO.DSM.Network endpoints this adapter already uses the
discover-then-call pattern for):
- SYNO.Core.System "info" -- model, serial, firmware_ver, cpu_cores,
  cpu_clock_speed, up_time (a raw uptime-command-style string, not a
  duration -- parsed client-side into "5d 15h 2m" with a fallback to
  the raw string if DSM ever returns an unexpected format).
- SYNO.Core.System.Utilization "get" -- live CPU load % (user+system+
  other) and real memory usage, in KB.
- SYNO.DSM.Network "list" -- hostname and interface IP addresses
  (loopback filtered out).

New GET /:id/synology/system route alongside the existing /storage
one, read-only like the rest of this integration.

Verified end-to-end against a local mock DSM server standing in for
the real API (login/session flow, all three endpoint shapes, IP
filtering, byte math) since this sandbox can't reach the user's LAN;
also unit-checked the client-side uptime regex against DSM's three
uptime-command formats (with/without days, minutes-only) plus its
unmatched-format fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 00:35:39 +02:00
bobbanandClaude Sonnet 5 95bd831d81 Move Proxmox and Synology to their own top-level pages
Same move as Docker/Tailscale/Semaphore/Gitea: each was buried inside
the generic Integrations browsing view. Give them dedicated pages too
— this was the last pair, so every integration type with a browsing UI
now has its own page.

New web/src/pages/{Proxmox,Synology}.tsx reuse the existing, unchanged
API routes and adapters (no server changes) with their own integration
picker scoped to just that type. Added matching nav items and routes.
Synology stays read-only, matching its original design.

Removed the corresponding state/handlers/tables from Integrations.tsx
(296 lines). It's now down to just the "Manage integrations" CRUD
table plus a browsing dropdown whose six branches are all redirects to
the type's own page — genuinely pointless now that every type has
moved out, worth simplifying separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 00:21:07 +02:00
bobbanandClaude Sonnet 5 8fef68c27a Move Tailscale, Semaphore, and Gitea to their own top-level pages
Same move as Docker: each was buried inside the generic Integrations
browsing view, sharing one "pick any integration" dropdown across six
unrelated types. Give them dedicated pages instead.

New web/src/pages/{Tailscale,Semaphore,Gitea}.tsx each reuse the
existing, unchanged API routes and adapters (no server changes) with
their own integration picker scoped to just that type. Added matching
nav items (Tailscale, Semaphore, Gitea, right after Docker) and routes.

Removed the corresponding state/handlers/tables from Integrations.tsx
entirely (374 lines). Adding, editing, enabling/disabling, and
deleting each credential itself still happens under Integrations ->
Manage integrations, same as Proxmox and Synology, which stay on the
Integrations page. Selecting one of these three types in Integrations'
generic browsing dropdown now points to its new page instead of
rendering a table there too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 00:09:02 +02:00
bobbanandClaude Sonnet 5 35afcc4a37 Add a host filter to the Docker page
Dockhand aggregates containers across every Docker host it manages
into one list, with no way to narrow it down to a single host. Add a
host dropdown next to Refresh (only shown when more than one host is
present) that filters the table client-side over the already-fetched
container list. Resets whenever the selected Dockhand integration
changes, so a stale host name from a previous integration can't linger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 00:01:17 +02:00
bobbanandClaude Sonnet 5 52dc7ed1f5 Move Dockhand container management to its own top-level Docker page
Container management was buried inside the generic Integrations
browsing view, mixed in with five unrelated integration types behind
a single "pick any integration" dropdown. Give it a dedicated page,
matching how Servers & Tasks and DNS already get their own top-level
spot instead of living inside Integrations.

New web/src/pages/Docker.tsx reuses the existing, unchanged Dockhand
API routes and adapter (no server changes) — it just has its own
integration picker scoped to Dockhand only, rather than sharing
Integrations' any-type dropdown. Added a Docker nav item (right after
Integrations) and route.

Removed the Dockhand-specific state/handlers/table from Integrations
entirely; adding, editing, enabling/disabling, and deleting the
Dockhand credential itself still happens under Integrations -> Manage
integrations like every other integration. Selecting a Dockhand row in
Integrations' generic browsing dropdown now points to the Docker page
instead of rendering a container table there too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:57:11 +02:00
bobbanandClaude Sonnet 5 e7ac6fea3b Add a "Sync from Proxmox" action to IP Addresses (IPAM)
Extends the Tailscale IPAM sync to Proxmox: pulls every VM/LXC's IP
address(es) across all enabled Proxmox integrations into the
inventory, reusing the same never-overwrite-a-manual-entry semantics.

Proxmox guests can have multiple NICs (each IP synced as its own
entry) and, for QEMU VMs, IP discovery depends on a responsive guest
agent — a VM with none simply contributes zero entries rather than
erroring, matching getGuestDetail()'s existing best-effort behavior.
listGuests() doesn't include IPs, so this fetches getGuestDetail() per
guest; acceptable for an explicit, user-triggered sync rather than a
background poll.

Extracted the shared "insert, update only if I own this row, else
skip and report" upsert logic (previously inline in the Tailscale sync
handler) into upsertSyncedEntry(), now used by both sync routes so the
core safety rule can't drift between them.

Verified end-to-end against a mock Proxmox server covering: an LXC
with two NICs (both synced), a QEMU VM with a responsive guest agent,
a QEMU VM with no agent (zero entries, no error), a manual-entry
collision (skipped and reported, never overwritten), and idempotent
re-sync (add -> update on the second run).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:45:10 +02:00
bobbanandClaude Sonnet 5 d714a87754 Add a "Sync from Tailscale" action to IP Addresses (IPAM)
IPAM was entirely manual — Tailscale device IPs never showed up there
even though the Tailscale integration already lists them. Add an
explicit sync action (matching this app's existing pattern of
user-triggered syncs rather than silent background polling).

- New source column on ipam_entries (null = manual, "tailscale" =
  auto-synced) so a re-sync only ever touches rows it created itself —
  a manually-entered IP that happens to collide with a tailnet address
  is left untouched and reported back as skipped, never overwritten.
- POST /api/ipam/sync-tailscale pulls every enabled Tailscale
  integration's device list, upserting by primary IP (label, OS in
  notes, vendor "Tailscale"); one unreachable Tailscale integration
  doesn't block others.
- New "Sync from Tailscale" button on the IP Addresses page, with a
  small "synced" badge marking which rows came from it.

Also fixed a longstanding TODO found in the same file: the "DNS
records" column always showed "-" because matchingDnsRecords was
hardcoded to an empty array from before the DNS module existed. It
now does the same content-based reverse lookup against the DNS
module's record cache used elsewhere in the app.

Verified end-to-end by running the real server with Tailscale's fetch
call intercepted at the process level (its adapter hardcodes
api.tailscale.com with no configurable URL, so it can't be pointed at
a mock server the way Proxmox/Synology can): confirmed add, the
manual-entry skip/never-overwrite behavior, idempotent re-sync
(add -> update), and the DNS-matching fix, all against the real
route and adapter code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:16:17 +02:00
bobbanandClaude Sonnet 5 bc0e54fea8 Respect the 12h/24h setting in cron schedule descriptions too
The Date & Time setting drove formatDateTime() everywhere, but the
human-readable cron description ("At 08:30 AM every day") came from
cronstrue, a separate library with its own independent AM/PM default
that the setting never touched. Pass its use24HourTimeFormat option
from the same shared setting so "Schedule" columns match the rest of
the app.

Added utils/date.ts's is24HourFormat() getter for this, since
formatDateTime() itself doesn't apply here (cronstrue does its own
cron-to-English rendering, not just time formatting).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 22:35:05 +02:00
bobbanandClaude Sonnet 5 f92f8de96e Add a Date & Time setting and fix inconsistent date formats app-wide
Different tables used different date formats: Servers & Tasks used a
fixed "YYYY-MM-DD HH:mm:ss" (24h), while Audit Log, DNS, Integrations
(Tailscale/Semaphore), and Users called plain .toLocaleString() with
no options, which renders using the browser's own locale — different
per browser/OS, and inconsistent with the other pages' fixed format.

- New Settings -> Date & Time page: pick date order (YYYY-MM-DD,
  DD/MM/YYYY, MM/DD/YYYY) and 12h vs 24h clock, with a live preview.
- utils/date.ts's formatDateTime() now reads these settings instead of
  being hardcoded to sv-SE/24h; the setting is fetched once at app
  startup (alongside /api/me) via a new non-secret GET
  /api/settings/display (any signed-in user, same rationale as the
  badge-color endpoints) and applied immediately on save too, without
  needing a page reload.
- Switched every remaining raw new Date(...).toLocaleString() call
  (Audit Log, DNS zone sync time, Tailscale/Semaphore last-seen,
  Users' last login) over to the shared formatter, so every table now
  renders dates identically.

Verified the formatter's date-order x 12h logic against all six
combinations plus the midnight/noon 12h edge cases, and the settings
endpoints end-to-end (defaults, partial updates, validation
rejection, audit logging) against the real dev server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 22:17:06 +02:00
bobbanandClaude Sonnet 5 f7357141eb Wire the Servers overview's Proxmox badge to the integration color setting
The new integration badge-color setting only reached the Integrations
page's Manage table — the Servers & Tasks overview card grid had its
own hardcoded bg-purple-lt badge for Proxmox-linked servers that never
picked it up. Reuses the same typeBadgeStyle pattern already used
there and in the DNS module.

Note: until a Proxmox color is set in Settings -> Badges, this badge
now renders with Tabler's default neutral badge style rather than
purple, matching how every other badge customized this way already
behaves before a color is chosen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 21:34:50 +02:00
bobbanandClaude Sonnet 5 ad6783b6a1 Extend badge color settings to cover integration types too
Settings → DNS Badges only let you customize DNS provider badge
colors. Expand it into a general Badges page with a second section
for the six integration types (Tailscale, Proxmox, Synology,
Semaphore, Gitea, Dockhand), applied to the type badge in the Manage
integrations table.

- New integrationColors key on AppSettings, stored/merged the same way
  as providerColors via the existing settingsStore.
- New GET /api/settings/integration-colors — non-secret, any signed-in
  user, mirroring /provider-colors — so the badge color can be read
  without needing admin access to the full settings payload.
- Renamed DnsBadgeSettings.tsx -> BadgeSettings.tsx (route
  /settings/dns-badges -> /settings/badges, sub-nav label "DNS
  Badges" -> "Badges") with both color sections saved together.

Verified end-to-end against the real dev server: PUT persists
integration colors independently of provider colors, the public
integration-colors endpoint reflects updates immediately, and the
change is audit-logged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 21:28:26 +02:00
bobbanandClaude Sonnet 5 7fed1dbfa4 Move the signed-in-as/sign-out block to the bottom of the sidebar
Was a separate top page-header bar above every page's content. Moved
into the vertical navbar itself using Tabler's own .navbar-footer
class (order:1 + margin-top:auto), which pins it to the bottom of the
sidebar regardless of how many nav items are above it. Removed the
now-empty page-header entirely.

Verified visually in the browser at both desktop width (sidebar is
position:fixed there — footer sits flush at the bottom) and mobile
width (collapsed dropdown menu — footer just follows the nav list).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 21:10:07 +02:00
bobbanandClaude Sonnet 5 a6ab69316d Add a graceful Shutdown action alongside Proxmox's Start/Restart/Stop
Stop maps to Proxmox's hard power-off (/status/stop) — fine for a
crashed guest, but risky for anything with a filesystem that'd rather
flush cleanly first. Proxmox exposes a separate /status/shutdown
endpoint that asks the guest to power itself down (ACPI event for a
VM, SIGTERM-then-wait for a container), so add it as its own action
rather than overloading Stop.

- New shutdownGuest() on the Proxmox adapter, calling /status/shutdown.
- The existing generic action route/loop already dispatches by
  ${action}Guest, so adding "shutdown" to that list was enough on the
  server side — no new route needed.
- New Shutdown button next to Restart/Stop on both the Integrations
  page's guest table and a Proxmox-linked server's detail page.
  Stop's confirm prompt now explicitly points at Shutdown as the
  gentler alternative.

Verified end-to-end against a mock Proxmox server: the shutdown call
hits /status/shutdown (never /status/stop) and is audit-logged as
shutdown_guest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 20:52:31 +02:00
bobbanandClaude Sonnet 5 de9b6a2d6a Add start/restart/stop buttons to Proxmox-linked servers' detail page
The Proxmox start/stop/restart routes already existed (used by the
Integrations page's guest table) but weren't reachable from a server's
own detail page, even when that server was linked to a Proxmox guest.

Reuses the existing POST /api/integrations/:id/proxmox/nodes/:node/
:type/:vmid/{start,stop,restart} routes and the operator+ role gate
already enforced there — no server-side changes needed. Buttons only
render for hardware.source === "proxmox", mirroring the Integrations
page's running/stopped button-set logic, with the same confirm-before-
stop prompt for the non-reversible action.

Verified end-to-end against the real dev server with a mock Proxmox
HTTPS server: linked a server to a mock guest, confirmed the detail
endpoint's live status, then triggered restart/stop/start and
confirmed the mock actually received each action and every call was
audit-logged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 20:46:35 +02:00
bobbanandClaude Sonnet 5 1a054d6c2f Report a CPU model on ARM boards (Raspberry Pi 5 and others)
x86's /proc/cpuinfo has a per-core "model name" line; most 64-bit ARM
kernels don't. Raspberry Pi's kernel instead puts a single friendly
"Model" line at the end of /proc/cpuinfo (e.g. "Raspberry Pi 5 Model B
Rev 1.0"), and /proc/device-tree/model has the same string on any
device-tree-based board as a further fallback if even that's missing.
Try each in turn, matching real Pi 5 /proc/cpuinfo formatting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:21:03 +02:00
bobbanandClaude Sonnet 5 ae41a02864 Fix agent silently failing to report on hosts without a cpuinfo model name
report-tasks.sh's new hardware-collection code ran under set -euo
pipefail, so a single failing command inside it aborted the whole
script before the report was ever sent — with no error message,
since nothing in that path had explicit error handling. On real
hardware this hit immediately: `grep -m1 "model name" /proc/cpuinfo`
exits 1 when there's no match, and many ARM boards (e.g. Raspberry Pi)
have no such line at all. Confirmed via journalctl showing the
systemd service failing every 15 minutes with exit 1 and zero output,
and via a minimal repro of the exact bash control flow.

Wrap the hardware/network collection call so any failure inside it is
non-fatal: task reporting (the actual core function) must never be
taken down by a quirk in the best-effort hardware-gathering code, on
this host or any other. Also fixed a related gap found while testing
the fallback path: the server only accepted the system field being
absent, not explicitly null (what the script now sends if collection
fails outright), which would have turned graceful degradation into a
rejected report.

Verified end-to-end against the real dev server: system:null, system
omitted, and a normal populated report all now return 202 and persist
correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 19:05:14 +02:00
bobbanandClaude Sonnet 5 130212baec Add an opt-in insecure-TLS mode for the agent, for self-signed certs
Installing the agent against a Homelab Manager instance with a
self-signed cert failed: curl verifies TLS by default on the install
download, the report-tasks.sh fetch inside install.sh, and every
periodic check-in — not just the outer one-liner, so passing -k to only
that first curl wasn't enough. Mirrors the existing Proxmox/Synology
"insecure" toggle pattern already in this app.

- install.sh and report-tasks.sh accept API_INSECURE=true, adding -k to
  their own curl calls; install.sh persists it into the agent's env
  file so the periodic systemd timer picks it up too.
- The Servers & Tasks page has a new checkbox next to the generated
  install/uninstall commands that adds -k and API_INSECURE=true for
  you, so the outer one-liner (which install.sh's own logic can't
  touch) also skips verification.

Off by default — only for a trusted LAN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 18:26:19 +02:00
bobbanandClaude Sonnet 5 f5d3c25c89 Add editing existing integrations, so an expired secret can be rotated
Manage integrations only supported add/toggle/delete — fixing an
expired API token meant deleting and recreating the whole integration.

- New GET /api/integrations/:id/config returns only the non-secret
  config fields (never the decrypted secret) so an edit form can
  pre-fill URL/tailnet/etc. fields.
- New POST /api/integrations/:id/test merges the stored, decrypted
  config with any freshly-typed overrides and pings the real adapter —
  lets "Test connection" work during an edit without ever sending the
  current secret back to the browser.
- New IntegrationEditForm component: secret fields render blank with a
  "leave blank to keep the current value" placeholder; submitting only
  sends the fields that were actually filled in, so a name/URL edit
  can't accidentally wipe a secret and a secret rotation can't touch
  anything else. Reuses the existing PATCH /:id route, which already
  merged partial config updates correctly.

Verified end-to-end against the real dev server: confirmed via direct
DB decryption that a non-secret-only edit leaves the stored secret
byte-for-byte unchanged, and that a secret-only edit rotates it without
touching other config; the test route was confirmed to make a real
network call (got a genuine "API token invalid" from Tailscale's API
against a fake key).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 13:12:17 +02:00
bobbanandClaude Sonnet 5 b9409d3095 Add server hardware/network detail view with Proxmox sync and agent reporting
Servers & Tasks only tracked scheduled tasks — there was no overview of
the servers themselves and no way to see CPU/RAM/disk/IP info. Add a
clickable server overview (visible to every role, not just admins) that
opens a per-server detail page.

- servers table gains an agent-reported hardware/network snapshot
  (IPs, CPU model/cores/load, memory, disks) and an optional link to a
  Proxmox VM/LXC (integration + node + guest type + vmid).
- Proxmox adapter gains getGuestDetail(): live cores/memory/disk from
  /config, live cpu/mem/uptime from /status/current, and IPs (LXC net
  config directly, QEMU via a best-effort guest-agent call that degrades
  gracefully when the agent isn't installed).
- New GET /api/servers/:id/detail combines whichever hardware source
  applies (live Proxmox vs. last agent report) with a DNS reverse-lookup
  against the DNS module's own record cache, so matching hostnames show
  up next to each IP. New PATCH /api/servers/:id manages the Proxmox
  link.
- agent/linux/report-tasks.sh now also collects and reports IPs,
  CPU/memory/disk info on every check-in (load-average-based CPU number,
  not instantaneous, to keep the agent a cheap oneshot).
- Extracted the per-server task table into a shared ServerTaskTable
  component so the all-servers view and the new detail page render
  tasks identically.

Verified server-side end-to-end against the real dev server (agent
report -> detail endpoint -> DNS match) and the Proxmox adapter against
a mock HTTPS server covering LXC/QEMU config parsing and the
guest-agent-unavailable fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 13:02:50 +02:00
bobbanandClaude Sonnet 5 1f0e6a9d4c Split Settings into sub-pages and add DNS cache clearing
Settings was a single long page. Break it into a sub-nav (Notifications,
DNS Badges, Cache) with its own route per section, each loading and
saving independently. Also add the DNS record-cache clearing action
that Sloth Manager had — a new admin-only POST /api/dns/cache/clear
truncates the zone/record cache tables so stale data can be wiped and
zones re-synced from scratch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 12:23:12 +02:00
bobbanandClaude Sonnet 5 3255314402 Build the Settings module: notification channels, event toggles, DNS badge colors
The Settings page was a "coming soon" placeholder. Port Sloth Manager's
settings feature set: Gotify/ntfy/SMTP/webhook notification channels
(each with its own test-send button), per-event toggles (DNS record
added/updated/deleted, a daily secret-expiry digest with configurable
time/timezone), and per-provider DNS badge color customization.

Settings persist in the existing `settings` key/value table via a new
settingsStore service; a notify service fans a message out to every
enabled channel. DNS record add/update/delete now fire notifications,
and a node-schedule job re-arms itself whenever the notification
settings change. Removed the now-superseded GOTIFY_URL/GOTIFY_TOKEN
env vars in favor of in-app configuration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 12:14:05 +02:00
bobbanandClaude Sonnet 5 3a53a86ce0 Show real target names in the Audit Log instead of type #id
The Target column only ever printed "<type> #<id>", so newly created
integrations (and other records) appeared as "integration 1",
"integration 2", etc. instead of the display name set in the form.
Audit entries already log the name in their detail JSON on
create/update/delete; parse it and show it when present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 12:01:00 +02:00
bobbanandClaude Sonnet 5 e064b1caf7 Update README: add repository link, mark all integrations as verified
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 01:22:19 +02:00
202 changed files with 43990 additions and 1935 deletions

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
# Keep the build context to source. The important one is node_modules: the Dockerfile runs `COPY . .` after
# `npm ci`, so a host node_modules (Windows/macOS/x86 binaries) would overwrite the container's own and break the
# build — most visibly when cross-building for arm64.
**/node_modules
**/dist
**/build
**/*.tsbuildinfo
# Never send secrets or data into a build.
.env
.env.*
!.env.example
data
.git
.gitignore
.claude
*.log
.DS_Store
# Compose files aren't needed inside the image.
docker-compose*.yml
+10 -6
View File
@@ -5,14 +5,19 @@ APP_BASE_URL=https://homelab.example.lan
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=change-me-to-a-random-64-char-hex-string SESSION_SECRET=change-me-to-a-random-64-char-hex-string
# 32-byte (64 hex char) key used to encrypt stored integration API tokens at # 32-byte (64 hex char) key used to encrypt stored integration API tokens, and the
# rest (AES-256-GCM). Generate the same way as SESSION_SECRET. Losing/changing # notification channels' credentials (Gotify/ntfy tokens, SMTP password, webhook
# this key makes previously-stored integration credentials unreadable. # secret), at rest (AES-256-GCM). Generate the same way as SESSION_SECRET.
# Losing/changing this key makes those stored credentials unreadable.
CREDENTIALS_ENCRYPTION_KEY=change-me-to-a-random-64-char-hex-string CREDENTIALS_ENCRYPTION_KEY=change-me-to-a-random-64-char-hex-string
# Port docker-compose publishes on the host (container always listens on 3000). # Port docker-compose publishes on the host (container always listens on 3000).
HOST_PORT=3000 HOST_PORT=3000
# Optional, for the arm64 compose files (docker-compose.arm64*.yml): registry image and tag.
#IMAGE_REPO=gitea.labsconnect.se/bobban/homelabmanager-homelab-manager
#ARM64_TAG=arm64
# --- Authentik OIDC application/provider --- # --- Authentik OIDC application/provider ---
# Create an OAuth2/OIDC "Provider" in Authentik with: # Create an OAuth2/OIDC "Provider" in Authentik with:
# Redirect URI: <APP_BASE_URL>/auth/callback # Redirect URI: <APP_BASE_URL>/auth/callback
@@ -23,6 +28,5 @@ AUTHENTIK_ISSUER_URL=https://authentik.example.lan/application/o/homelab-manager
AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET= AUTHENTIK_CLIENT_SECRET=
# --- Optional: Gotify notifications (secret expiry, integration offline, etc.) --- # Notification channels (Gotify, ntfy, SMTP, webhook) are configured in-app
GOTIFY_URL= # under Settings, not here.
GOTIFY_TOKEN=
+553
View File
@@ -0,0 +1,553 @@
# Database schema
Homelab Manager keeps its data in one SQLite file, accessed through
[drizzle-orm](https://orm.drizzle.team) and the libSQL client. The schema is
defined in one place — [`server/src/db/schema.ts`](server/src/db/schema.ts) —
and this document describes it. It was checked against a fresh database built
from the migrations, so the tables, columns, foreign keys and indexes below are
what the app actually creates.
- [The basics](#the-basics)
- [How the tables relate](#how-the-tables-relate)
- [Tables](#tables)
- [What's stored inside the JSON columns](#whats-stored-inside-the-json-columns)
- [Settings keys](#settings-keys)
- [What happens on delete](#what-happens-on-delete)
- [The Proxmox link's foreign key](#the-proxmox-links-foreign-key)
- [Retention and backups](#retention-and-backups)
- [Changing the schema](#changing-the-schema)
## The basics
| | |
|---|---|
| **File** | `DATABASE_PATH`, default `../data/homelab-manager.sqlite` (relative to `server/`; `/app/data/...` in Docker). The folder is created if missing. |
| **Foreign keys** | Switched on for every connection (`PRAGMA foreign_keys = ON`), so the `ON DELETE` rules below are enforced. |
| **Migrations** | SQL files in [`server/drizzle/`](server/drizzle) (`0000` … `0014`), applied automatically when the server starts. Which ones have run is recorded in the table `__drizzle_migrations`. |
| **Tables** | 21 application tables, plus drizzle's own `__drizzle_migrations`. |
**Not in the database:**
- **Login sessions** are files in `SESSION_DIR` (default `../data/sessions`), not rows.
- **The encryption key** for integration credentials (`CREDENTIALS_ENCRYPTION_KEY`) and the session secret live in the environment, never in the file.
- **Agent tokens** are never stored: only a SHA-256 hash and a short prefix of each (`servers.api_token_hash`, `api_token_prefix`). The token itself is shown once, when the server is registered.
### Conventions
- **Primary keys** are `INTEGER PRIMARY KEY AUTOINCREMENT` named `id` — except `settings`, which uses its text `key`.
- **Booleans** are `INTEGER` 0/1 (shown as `bool` below).
- **Timestamps are text, in two formats**, so read them with care:
- Columns the database fills in itself (`created_at`, and most `updated_at`) use SQLite's `current_timestamp`: `2026-10-03 14:05:09`, **UTC, with no zone marker**. Treat it as UTC, not local time.
- Columns the app fills in (`last_seen_at`, `last_login_at`, `synced_at`, `last_checked_at`, …) use ISO 8601: `2026-10-03T14:05:09.123Z`.
- Dates without a time (`secrets.expiry_date`, `domains.expires_at`) are `YYYY-MM-DD`.
- **JSON columns** are `TEXT` holding JSON; see [what's inside](#whats-stored-inside-the-json-columns).
- **Enumerations** (roles, types) are plain text — SQLite doesn't enforce the allowed values; the app does.
- **Indexes:** besides primary keys, the only indexes are the unique ones listed per table. There are no secondary indexes; the data sets here (hundreds to a few thousand rows) don't need them.
## How the tables relate
```mermaid
erDiagram
users ||--o{ audit_log : "actor (set null)"
integration_credentials ||--o{ integrations : "credential (set null)"
integration_credentials ||--o{ dns_providers : "credential (set null)"
dns_providers ||--o{ dns_zones_cache : "cascade"
dns_providers ||--o{ dns_records_cache : "cascade"
servers ||--o{ scheduled_tasks : "cascade"
servers ||--o{ server_links : "cascade"
servers ||--o{ server_ports : "cascade"
servers ||--o{ port_forwards : "optional (set null)"
integrations ||--o{ servers : "proxmox link (app clears it)"
```
Eight tables stand alone, with no foreign keys: `settings`, `secrets`,
`ipam_entries`, `domains`, `diag_log`, `notification_queue`,
`consistency_ignores`, `tag_definitions`. `maintenance_windows` also has no
foreign key — see [soft references](#soft-references-not-foreign-keys).
## Tables
Grouped by what they're for. "Null" in the notes means the column allows NULL;
everything not marked **NOT NULL** may be empty.
### People and activity
#### `users`
Everyone who has signed in through Authentik. The first one becomes admin.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `oidc_sub` | text NOT NULL, **unique** | The user's stable ID from Authentik (`sub` claim). This is what a session is matched against. |
| `email`, `name` | text | From the sign-in; may be empty. |
| `role` | text NOT NULL, default `viewer` | `admin` \| `operator` \| `viewer`. |
| `created_at` | text NOT NULL | SQLite UTC. |
| `last_login_at` | text | ISO. |
#### `audit_log`
Who changed what. Written by the app on every change (see
[ROLES.md](ROLES.md) for who can read it).
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `actor_user_id` | integer → `users.id`, **set null** on delete | Null for automatic actions, and for entries whose user was deleted. |
| `actor_label` | text | The user's name/email as it was at the time (or `system`), so an entry still reads correctly after the account is gone. |
| `category` | text NOT NULL | Free text. In use: `server`, `integration`, `dns`, `settings`, `ipam`, `secret`, `domain`, `tag`, `task`, `session`, `network`, `maintenance`, `consistency`, `user`, `privacy`, `diag_log`. |
| `action` | text NOT NULL | `create`, `update`, `delete`, `start`, `stop`, … — free text. |
| `target_type`, `target_id` | text | What it happened to. `target_id` is text so it can hold any kind of ID; there's no foreign key. |
| `detail` | text | JSON, free-form context (what changed, names, counts). Secrets are never put here. |
| `created_at` | text NOT NULL | SQLite UTC. |
#### `diag_log`
One row per outbound call to an integration or DNS provider — the source of
the Diagnostic Log page and of the "integration down" alert.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `source` | text NOT NULL | The integration/provider type, e.g. `proxmox`, `cloudflare`. |
| `operation` | text NOT NULL | The adapter method, e.g. `listZones`. |
| `ok` | bool NOT NULL | |
| `latency_ms` | integer NOT NULL | |
| `error` | text | Message when `ok` is false. |
| `created_at` | text NOT NULL | SQLite UTC. |
#### `notification_queue`
Notifications held back during quiet hours, delivered as one digest and then
cleared.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `title`, `message` | text NOT NULL | |
| `created_at` | text NOT NULL | SQLite UTC. |
#### `maintenance_windows`
While a window is open, alerts about its target are silenced. An end time is
required, so a forgotten window can't silence real problems forever.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `target_type` | text NOT NULL | `server` \| `integration` \| `dns_provider`. |
| `target_id` | integer NOT NULL | The ID in the table `target_type` names. **Not a foreign key**; a window whose target was deleted is simply ignored. |
| `reason` | text | |
| `started_at`, `ends_at` | text NOT NULL | ISO. |
| `created_by` | text | Name of the person who opened it. |
### Servers
#### `servers`
A machine that reports in through the agent (or is registered by hand), plus
what it last reported.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `name` | text NOT NULL | Not unique. |
| `hostname` | text | |
| `os_type` | text NOT NULL, default `linux` | |
| `description` | text | |
| `api_token_hash` | text NOT NULL | SHA-256 of the agent's token. |
| `api_token_prefix` | text NOT NULL | First characters of the token, to tell tokens apart in the UI. |
| `created_at` | text NOT NULL | SQLite UTC. |
| `last_seen_at` | text | ISO; when the agent last reported. Drives "server offline". |
| `ip_addresses` | text | JSON `string[]`. Agent-reported. |
| `cpu_model` | text | Agent-reported. |
| `cpu_cores` | integer | |
| `cpu_load_percent` | real | Load average ÷ cores × 100 — an approximation, not instantaneous usage. |
| `mem_total_bytes`, `mem_used_bytes` | integer | |
| `disks` | text | JSON, see below. Agent-reported. |
| `listening_ports` | text | JSON, see below. What the agent sees bound on the host. |
| `last_port_scan` | text | JSON summary of the latest network scan *from this app*. |
| `tags` | text | JSON `string[]` of normalised tag names. |
| `proxmox_integration_id` | integer → `integrations.id` | The database has no `ON DELETE` rule here; the app clears the link itself — see [below](#the-proxmox-links-foreign-key). |
| `proxmox_node` | text | |
| `proxmox_guest_type` | text | `qemu` \| `lxc`. |
| `proxmox_vmid` | integer | The four `proxmox_*` columns are set together or cleared together (enforced by the API), by an admin — never by the agent. |
| `hide_proxmox_link` | bool NOT NULL, default 0 | Hides the "Proxmox link" card for servers that aren't Proxmox guests. Ignored while the server is actually linked. |
#### `scheduled_tasks`
Cron jobs, systemd timers and Windows tasks the agent found on a server, plus
tasks added by hand.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `server_id` | integer NOT NULL → `servers.id`, **cascade** | |
| `schedule_type` | text NOT NULL | `cron` \| `systemd_timer` \| `windows_task` from agents; manual tasks may use others (`docker`, `backup`, `update`, `n8n_workflow`, `manual`). |
| `origin` | text NOT NULL, default `agent` | `agent` \| `manual`. Manual rows are never touched by agent sync. |
| `name` | text NOT NULL | |
| `command`, `schedule_expression`, `source` | text | |
| `enabled` | bool NOT NULL, default 1 | |
| `next_run_at` | text | |
| `raw_metadata` | text | JSON as the agent reported it. |
| `is_stale` | bool NOT NULL, default 0 | Set when the agent stops reporting the task. |
| `first_seen_at`, `last_seen_at` | text NOT NULL | SQLite UTC at first insert; later updates are written by the app. |
#### `server_links`
Admin-page bookmarks for a server (Dockge, Webmin, Cockpit, …). Shown on the
server's page and summarised under Operations → Admin Links.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `server_id` | integer NOT NULL → `servers.id`, **cascade** | |
| `label`, `url` | text NOT NULL | |
| `created_at` | text NOT NULL | SQLite UTC. |
#### `server_ports`
A port on one server that's been seen open by a scan, or that someone wrote a
note about. Rows exist only while they carry information. What the *agent*
sees is stored on the server row instead (`servers.listening_ports`).
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `server_id` | integer NOT NULL → `servers.id`, **cascade** | |
| `port` | integer NOT NULL | |
| `protocol` | text NOT NULL, default `tcp` | `tcp` \| `udp`. |
| `label`, `comment` | text | |
| `open` | bool NOT NULL, default 0 | True when the last scan connected to it. |
| `last_seen_open_at` | text | |
| `updated_at` | text NOT NULL | |
Unique index **`server_ports_unique`** on (`server_id`, `port`, `protocol`).
#### `port_forwards`
Manually recorded port openings on something this app doesn't monitor — a
router's port forward, an edge firewall rule, a cloud security group. It
records them; it can't check or change them.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `label` | text NOT NULL | |
| `external_port` | integer NOT NULL | |
| `protocol` | text NOT NULL, default `tcp` | `tcp` \| `udp`. |
| `server_id` | integer → `servers.id`, **set null** | Optional: the tracked server it points at. |
| `destination` | text | Anything else — a bare IP, an untracked device — or detail alongside `server_id`. |
| `internal_port` | integer | When NAT changes the port. |
| `source` | text | Free text: where the rule lives ("Home router", "OPNsense WAN rule"). |
| `comment` | text | |
| `created_at`, `updated_at` | text NOT NULL | |
### Integrations and credentials
#### `integrations`
A connected system: Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand,
Uptime Kuma, phpIPAM, Proxmox Backup Server, osTicket.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `type` | text NOT NULL | `proxmox` \| `synology` \| `semaphore` \| `tailscale` \| `gitea` \| `dockhand` \| `uptimekuma` \| `phpipam` \| `pbs` \| `osticket`. |
| `name` | text NOT NULL | |
| `base_url` | text NOT NULL | For osTicket (a direct database connection) this holds the database host. |
| `credential_id` | integer → `integration_credentials.id`, **set null** | |
| `config` | text | JSON of the *non-secret* settings, see below. |
| `enabled` | bool NOT NULL, default 1 | |
| `created_at` | text NOT NULL | |
#### `integration_credentials`
The secret half of an integration or DNS provider, encrypted at rest.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `name` | text NOT NULL | `"<type>:<name>"`, for recognising a row when looking at the file. |
| `encrypted_secret` | text NOT NULL | `iv:authTag:ciphertext`, each in hex — AES-256-GCM with `CREDENTIALS_ENCRYPTION_KEY`. The plaintext is a JSON object of the secret fields (an API token, a password). Without the same key it can't be read. |
| `created_at` | text NOT NULL | |
The notification channels' credentials aren't in this table; they're encrypted in place in `settings` — see [Retention and backups](#retention-and-backups).
### DNS
#### `dns_providers`
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `provider_type` | text NOT NULL | `cloudflare` \| `loopia` \| `pihole` \| `azure` \| `cpanel` \| `technitium`. |
| `name` | text NOT NULL | |
| `credential_id` | integer → `integration_credentials.id`, **set null** | |
| `config` | text | JSON, non-secret provider config (base URL, zone list, …). |
| `enabled` | bool NOT NULL, default 1 | |
| `created_at` | text NOT NULL | |
#### `dns_zones_cache` and `dns_records_cache`
Local copies of what the providers returned at the last sync, so pages load
without calling every provider. The providers remain the source of truth.
`dns_zones_cache`
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `provider_id` | integer NOT NULL → `dns_providers.id`, **cascade** | |
| `zone_id` | text NOT NULL | The provider's own identifier for the zone. |
| `zone_name` | text NOT NULL | |
| `synced_at` | text | ISO. |
Unique index **`dns_zones_cache_provider_zone_idx`** on (`provider_id`, `zone_id`).
`dns_records_cache`
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `provider_id` | integer NOT NULL → `dns_providers.id`, **cascade** | |
| `zone_id` | text NOT NULL | The provider's zone identifier — matches `dns_zones_cache.zone_id` for the same provider, but is **not a foreign key**. |
| `record_id` | text NOT NULL | The provider's own record identifier. |
| `type` | text NOT NULL | `A`, `AAAA`, `CNAME`, `TXT`, `MX`, … |
| `name`, `content` | text NOT NULL | |
| `ttl`, `priority` | integer | |
| `proxied` | bool | Cloudflare only. |
### Address and name tracking
#### `ipam_entries`
IP addresses with a label and notes, entered by hand or synced.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `ip_address` | text NOT NULL, **unique** | |
| `label`, `vendor`, `location`, `notes` | text | |
| `source` | text | Null = typed in by hand; otherwise the sync that created it: `tailscale`, `proxmox` or `phpipam`. A sync only updates rows it created itself and never overwrites a manual one. |
| `created_at`, `updated_at` | text NOT NULL | |
#### `domains`
Registered domains whose expiry is tracked.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `name` | text NOT NULL, **unique** | The registrable domain, lowercase ASCII. |
| `origin` | text NOT NULL, default `manual` | `manual` (typed in) or `zone` (created from a synced DNS zone, removed again when the zone goes away). |
| `expires_at` | text | `YYYY-MM-DD`, as the registry reports it. Null for registries that don't publish one. |
| `registrar` | text | |
| `lookup_source` | text | `rdap` \| `whois`. |
| `last_checked_at` | text | Last attempt. |
| `last_checked_ok_at` | text | Last *successful* attempt. |
| `last_check_error` | text | |
| `created_at` | text NOT NULL | |
#### `secrets`
The expiry tracker for API tokens, certificates, passwords and the like. It
tracks *when* something expires; it does not store the secret itself.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `name` | text NOT NULL | |
| `type` | text NOT NULL, default `generic` | `api_token` \| `ssl_certificate` \| `password` \| `generic`. |
| `description`, `notes` | text | |
| `expiry_date` | text NOT NULL | `YYYY-MM-DD`. For a certificate with a host to check, overwritten from the live certificate. |
| `warn_days` | integer NOT NULL, default 30 | How long before expiry to start reminding. |
| `check_host`, `check_port` | text / integer | Certificates only: read the expiry from the live certificate at this host:port. |
| `last_checked_at`, `last_check_error` | text | Result of the last live check. |
| `created_at`, `updated_at` | text NOT NULL | |
### Housekeeping
#### `settings`
Key/value store for app settings. One row per settings section (the value is
JSON) plus a few internal bookkeeping rows. Details under
[Settings keys](#settings-keys).
| Column | Type | Notes |
|---|---|---|
| `key` | text PK | |
| `value` | text NOT NULL | JSON (or a plain date/time for internal flags). |
| `updated_at` | text NOT NULL | |
#### `tag_definitions`
Tags themselves live on the servers that carry them (`servers.tags`). A row
here adds what a server can't: a tag that exists before anything uses it, and a
chosen colour. A tag with no row is simply one in use with an automatic colour.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `name` | text NOT NULL, **unique** | Normalised. |
| `color` | text | `#rrggbb`, or null for automatic. |
| `created_at` | text NOT NULL | |
#### `consistency_ignores`
Consistency findings someone looked at and decided are fine.
| Column | Type | Notes |
|---|---|---|
| `id` | integer PK | |
| `key` | text NOT NULL, **unique** | The finding's stable key, so it stays ignored across runs. |
| `title` | text NOT NULL | What the finding said when it was ignored, so the list still reads sensibly after it's gone. |
| `reason`, `created_by` | text | |
| `created_at` | text NOT NULL | |
## What's stored inside the JSON columns
| Column | Shape |
|---|---|
| `servers.ip_addresses` | `["10.0.0.5", "fd00::5"]` |
| `servers.disks` | `[{ "mount": "/", "sizeBytes": 32000000000, "usedBytes": 9000000000 }]` |
| `servers.listening_ports` | `[{ "protocol": "tcp", "port": 22, "address": "0.0.0.0", "process": "sshd" }]` |
| `servers.last_port_scan` | `{ "at": "<ISO>", "address": "10.0.0.5", "from": 1, "to": 1024, "open": 3, "refused": 1010, "filtered": 11, "responded": true }` |
| `servers.tags` | `["prod", "media"]` |
| `audit_log.detail` | Free-form per action — e.g. `{ "name": "pve1" }`, or for settings `{ "sections": [...], "changes": { "<section>": { "<field>": { "from": …, "to": … } } } }`. For the notification channels (Gotify, ntfy, SMTP, webhook) only the field *names* that changed are recorded, never values. |
| `integrations.config` | The non-secret fields for that integration type (table below). |
| `dns_providers.config` | Non-secret provider settings (base URL, zone list, …). |
**`integrations.config` by type** (the secret fields go to `integration_credentials` instead):
| Type | In `config` | Encrypted separately |
|---|---|---|
| `proxmox` | `url`, `tokenId`, `insecure` | `tokenSecret` |
| `pbs` | `url`, `tokenId`, `insecure` | `tokenSecret` |
| `synology` | `url`, `username`, `insecure` | `password` |
| `semaphore`, `gitea`, `dockhand` | `url` | `token` |
| `tailscale` | `tailnet` | `apiKey` |
| `uptimekuma` | `url`, `username` | `password` (an API key, or the password on old installs) |
| `phpipam` | `url`, `appId`, `insecure` | `token` |
| `osticket` | `host`, `port`, `database`, `username`, `tablePrefix` | `password` |
## Settings keys
Each section is one row in `settings`, with a JSON object as its value. Fields
that were never changed aren't stored; the app fills in defaults when reading.
| Key | Holds |
|---|---|
| `gotify`, `ntfy`, `smtp`, `webhook` | The four notification channels: enabled, address, and credentials (token / password / secret). The credential fields are stored encrypted (prefix `enc:v1:`) — see [Retention and backups](#retention-and-backups). |
| `notifications` | Which events notify (`dnsAdd`, `healthAlerts`, …), the daily-reminder time (`secretCheckTime`, default `08:00`) and `timezone` (default `UTC`), and the integration-failure threshold (default 3). |
| `quietHours` | `enabled`, `start`, `end`. |
| `healthChecks` | `serverOfflineMinutes` (60), `diskUsagePercent` (90), `domainWarnDays` (30). |
| `logRetention` | `enabled`, `retentionDays` (90), `intervalHours` (24). |
| `display` | `dateFormat`, `timeFormat`, `pageSize`. |
| `providerColors`, `integrationColors` | Badge colours, by provider / integration type. |
| `consistency` | `excludedRanges` — addresses the Consistency and IP Addresses pages ignore. Default `["172.16.0.0/12"]` (Docker's networks). |
| `nameGenerator` | `themes` — the Generator's server-name lists: `[{ "id", "label", "names": [...], "lastImport"? }]`. Edited under Settings → Names. |
Rows whose key starts with **`_internal:`** are scheduler bookkeeping, not
settings, and aren't part of the app's settings object:
| Key | Value |
|---|---|
| `_internal:secretCheckLastRunDate`, `tailscaleKeyCheckLastRunDate`, `dockerUpdateCheckLastRunDate`, `proxmoxBackupCheckLastRunDate`, `pbsVerificationCheckLastRunDate` | The date a daily check last ran, so a restart doesn't repeat it (or skip it). |
| `_internal:logRetentionLastRunAt` | When the log purge last ran. |
| `_internal:healthActiveConditions`, `_internal:automationActiveConditions` | JSON: the problems currently active, so each is announced once and again when it clears, and survives a restart. |
## What happens on delete
| Deleting… | Effect |
|---|---|
| a **server** | Its `scheduled_tasks`, `server_links` and `server_ports` are deleted with it. Port forwards that pointed at it stay, with `server_id` cleared. |
| a **DNS provider** | Its cached zones and records are deleted. |
| an **integration** or **DNS provider** | The credential row it used is deleted by the app (not by the database). |
| an **integration credential** | The integration / provider using it keeps existing, with `credential_id` cleared. |
| a **user** | Their audit entries stay; `actor_user_id` is cleared and `actor_label` keeps the name. |
| a **Proxmox integration** that servers are linked to | Those servers keep existing and lose their Proxmox link (all four `proxmox_*` columns). The app does this before deleting; the database alone would refuse — see the next section. |
### Soft references (not foreign keys)
These point at other rows by ID or name without the database enforcing it, so
a stale value is possible and the app treats it as "nothing there":
- `maintenance_windows.target_id` (→ a server, integration or DNS provider, by `target_type`)
- `audit_log.target_id`
- `dns_records_cache.zone_id` (→ `dns_zones_cache.zone_id`, same provider)
- `servers.tags` (→ `tag_definitions.name`)
- `consistency_ignores.key`
## The Proxmox link's foreign key
`servers.proxmox_integration_id` is meant to clear itself when its integration
is deleted: `schema.ts` says `onDelete: "set null"`. The database doesn't do
that. Migration `0001` added the column as a plain
`REFERENCES integrations(id)`, and SQLite can't change a foreign key afterwards
without rebuilding the table, so the rule *in the database* is **no action**:
with foreign keys on, deleting an integration that a server points at is refused
with `FOREIGN KEY constraint failed`.
The app works around it rather than rebuilding the table. The delete route in
[`server/src/routes/integrations.ts`](server/src/routes/integrations.ts) first
clears the four `proxmox_*` columns on every server linked to that integration,
then deletes it, and the audit entry records how many servers were unlinked
(`unlinkedServers`). Deleting an integration therefore works whether or not
servers are linked to it. Anything that deletes integrations some other way —
a hand-written SQL statement, say — has to do the same first.
## Retention and backups
**Retention.** Only the two logs are trimmed: `audit_log` and `diag_log` entries
older than `logRetention.retentionDays` are deleted by the purge job (off by
default), and `notification_queue` is emptied each time the quiet-hours digest is
sent. Everything else stays until someone deletes it.
**Credentials at rest.** Integration and DNS provider credentials are
encrypted in `integration_credentials` (above). The notification channels'
credentials — the Gotify and ntfy tokens, the SMTP password and the webhook
secret — are in the `settings` rows, and are encrypted in place with the same key:
each is stored as `enc:v1:` followed by the same `iv:authTag:ciphertext` form, and the
rest of the row (URLs, topics, priorities) stays readable. The app decrypts them when
settings are read and encrypts them when they're written, so nothing else sees the
difference.
- A value saved by an older version (plain text) still works, and is encrypted the
next time the server starts.
- Without `CREDENTIALS_ENCRYPTION_KEY`, new notification credentials can only be
stored as plain text, and the server warns about it at startup. They are encrypted
at the next start once the key is set.
- If the key is changed or lost, the stored credentials can't be read: they show as
empty, and the server logs which ones. Enter them again under Settings →
Notifications. Editing a *different* field of the same channel meanwhile doesn't
overwrite the old ciphertext, so putting the right key back restores them.
Everything else in the file — IP addresses, hostnames, secret *names* and expiry
dates, the audit log — is readable by anyone who can read the file, so treat the
file and its backups as sensitive anyway.
**Backups.**
- **Settings → Backup** exports the settings, the integrations and the DNS
providers (with their credentials decrypted, then wrapped in a file encrypted
with a passphrase you choose). Importing merges the settings over the current ones, and adds
integrations and providers that don't exist yet (matched by type and name) — it never
overwrites an existing integration. It does **not** include servers, tasks,
secrets, IP addresses, domains, ports, tags, maintenance windows or logs.
- **A full backup** is a copy of the SQLite file, together with the value of
`CREDENTIALS_ENCRYPTION_KEY` — without that key the stored integration
credentials can't be decrypted. Copy the file while the app is stopped, or use
SQLite's `.backup` command, so you don't capture it mid-write.
## Changing the schema
1. Edit [`server/src/db/schema.ts`](server/src/db/schema.ts).
2. From the repo root run `npm run db:generate`; it writes a new numbered SQL
file to `server/drizzle/` (and updates `server/drizzle/meta/`).
3. Read the generated SQL. SQLite can add a column but can't change or drop a
foreign key, so some changes become a table rebuild — which is also why the
[Proxmox link](#the-proxmox-links-foreign-key) described above is the way it is.
4. Start the server (or run `npm run db:migrate`); the migration is applied and
recorded in `__drizzle_migrations`.
5. Commit the schema, the SQL file and the `meta/` changes together, and update
this document.
+213
View File
@@ -0,0 +1,213 @@
# Integration & DNS provider access requirements
What credential to create in each target system, and the minimum
access it needs, when adding an integration or DNS provider in
Homelab Manager (Integrations → Add integration, or DNS → Add
provider). All credentials are entered in-app and encrypted at rest —
nothing is read from environment variables.
Every adapter listed here performs write actions (starting/stopping
things, editing DNS records, etc.), not just reads — a read-only
credential will fail as soon as you use one of those actions, even if
the dashboard views themselves load fine.
## Integrations
### Tailscale
- **Config fields:** Tailnet, API key
- **Auth:** Bearer token against `api.tailscale.com`
- **Actions used:** list devices, remove a device, authorize/deauthorize a device
- **Required access:** An API access token (or OAuth client) with **Devices Core: Read + Write** — create one under the admin console → Settings → Keys. Read-only isn't enough since remove/authorize are write calls.
### Proxmox VE
- **Config fields:** Proxmox URL, API token ID, API token secret, allow self-signed certificate
- **Auth:** `PVEAPIToken=<tokenId>=<tokenSecret>` header
- **Actions used:** list nodes/VMs/LXCs, read guest config and live status, read node host stats and storage usage, start/stop/reboot/shutdown a guest, read backup job schedules and recent `vzdump` task history
- **Required access:** A dedicated API token (Datacenter → Permissions → API Tokens) with a role granting **VM.Audit + VM.PowerMgmt** (e.g. `PVEVMAdmin`) on the VMs/LXCs to manage, **plus Sys.Audit and Datastore.Audit** on the node(s) for the host CPU/RAM/storage cards to populate. A token scoped to VM management only will still work for the guest list and power actions — it'll just show an error on the per-node hardware card instead of failing outright. Backup job schedules and run history need that same **Sys.Audit** — no separate permission to grant if the node-stats card already works.
- **Notes:** self-signed certificates are supported via the "Allow self-signed certificate" checkbox — common for an internal PVE host.
### Synology DSM
- **Config fields:** Synology DSM URL, Username, Password, allow self-signed certificate
- **Auth:** DSM session login (`SYNO.API.Auth`), session id reused until it expires
- **Actions used:** read-only — storage/volume/disk health, system info, network info. No writes.
- **Required access:** A DSM user account that can view **Storage Manager** and **System Information** (a regular admin-group account is simplest; a dedicated read-only user works too since nothing is ever changed). **2FA/OTP on the account is not supported** — use an account without it enabled, or DSM's application-specific password if your setup requires 2FA elsewhere.
- **Notes:** works over plain HTTP or HTTPS depending on the URL you enter; self-signed certs supported.
### Semaphore (Ansible Semaphore / Semaphore UI)
- **Config fields:** Semaphore URL, API token
- **Auth:** Bearer token
- **Actions used:** list projects, list templates, run a template (creates a task)
- **Required access:** A user API token with access to every project you want visible, and **permission to run templates** in those projects — a viewer/guest-level project role can list templates but will fail on "Run", so the account needs at least the operator-equivalent role Semaphore's own project permissions define.
### Gitea
- **Config fields:** Gitea URL, API token
- **Auth:** `Authorization: token <token>` header
- **Actions used:** list repos, list Actions workflow runs, re-run failed jobs in a run
- **Required access:** A personal access token with **`repo`** scope (read access to repos, including private ones you want tracked) and Actions read/write — generate it under the account → Settings → Applications → Generate New Token, with the `repository` and `write:repository`/Actions permission groups enabled (exact grouping depends on your Gitea version's token scope UI). Read-only Actions access isn't enough since re-running jobs is a write call.
### Dockhand
- **Config fields:** Dockhand URL, API token
- **Auth:** Bearer token against Dockhand's own API
- **Actions used:** list environments/containers, check for image updates, start/stop/restart a container
- **Required access:** A Dockhand API token belonging to a user with access to every environment (Docker host) you want visible, with permission to **start/stop/restart containers and trigger update checks** in each — not just view them.
### Uptime Kuma
- **Config fields:** Uptime Kuma URL, username (leave blank — see below), API key
- **Auth:** HTTP Basic, with the API key as the password and the username left empty. Uptime Kuma has no
conventional REST API — the dashboard talks to it over Socket.IO — so this integration reads its
Prometheus-metrics endpoint (`GET /metrics`) instead and parses that. On installs from before the API-key
feature existed (Uptime Kuma < 1.23), that endpoint instead checks your real dashboard login, so put your
Uptime Kuma username and password in those two fields rather than leaving the username blank.
- **Required access:** In Uptime Kuma, go to Settings → API Keys → **Add API Key**, and paste the value it
shows you (once — it isn't shown again) into this integration's "API key" field. No other permission is
needed; the metrics endpoint is read-only.
- **What you get:** every monitor's status (up/down/pending/maintenance), response time, uptime over 24
hours/30 days, and certificate days remaining where applicable. A monitor is linked to one of your servers
when its target — an IP, or a TCP/HTTP hostname — matches that server's own address or hostname; monitors
with no single network target (groups, push monitors, keyword checks with a complex URL) are shown
unmatched rather than guessed at. Uptime Kuma's tags aren't read, since the metrics endpoint doesn't
reliably distinguish a tag from any other label.
- **Maintenance import:** the Maintenance page can read which monitors are currently in maintenance in Uptime
Kuma and start (or extend) a maintenance window here for the matching server, for a duration you pick — the
metrics endpoint only exposes current status, not a monitor's scheduled start/end time, so this reflects
what's in maintenance right now rather than mirroring Uptime Kuma's own schedule.
### Proxmox Backup Server
- **Config fields:** Proxmox Backup Server URL, API token ID, API token secret, allow self-signed certificate
- **Auth:** `PBSAPIToken=<tokenId>:<tokenSecret>` header — note the **colon** between the token id and secret; Proxmox
VE's own token header uses `=` there instead, so a PVE token/secret pair copied verbatim into this integration's
fields will still format correctly (the adapter supplies the colon itself) as long as the id/secret values
themselves are right.
- **Actions used:** read-only — list datastores and their usage, read every stored snapshot's verification status,
read the PBS host's own CPU/RAM/disk. No writes; nothing here can prune, delete, or re-verify a backup.
- **Required access:** A dedicated API token (Configuration → Access Control → API Token) belonging to a user with
**Datastore.Audit** on the datastore(s) to show, and **Sys.Audit** for the node status card to populate. A token
scoped to just `Datastore.Audit` on one datastore will still work — other datastores it can't read are shown with
an error rather than failing the whole page.
- **What you get, and why it's separate from the Proxmox VE integration:** Proxmox VE (the "Proxmox" integration
above) already shows whether the last `vzdump` push to PBS succeeded, but has no visibility at all into PBS's own
backup **verification** — whether the data PBS actually stored still passes an integrity check, run separately by
PBS's verify jobs. This integration reads that directly from PBS (`verification.state` on each stored snapshot),
and a daily check (mirroring the Proxmox backup-failure check) sends a notification when a snapshot has failed
verification or a datastore couldn't be read at all — the "Notify on" section in Settings → Notifications has a
dedicated toggle for it, sharing the same daily reminder time as the other daily checks.
- **Not verified against a live instance** — built from PBS's own published API documentation (endpoints, the
`PBSAPIToken` header format, and the datastore/snapshot field names all cross-checked there), but nobody has run
it against a real Proxmox Backup Server yet. If a datastore comes back empty or with the wrong fields, tell us
what your instance actually returned and we'll adjust.
### osTicket
- **Config fields:** Database host, Database port (optional, default 3306), Database name, Database username,
Database password, Table prefix (optional, default `ost_` — only needed if you changed it at install time)
- **Auth:** a plain MySQL/MariaDB connection (host/port/database/username/password) — not HTTP, and not osTicket's
own API.
- **Why this one reads the database directly:** osTicket's official REST API only supports **creating** tickets
(`POST /api/tickets.json`) — there is no documented endpoint to list or read existing ones (osTicket's own
developer docs say as much: *"For now, only ticket creation is supported..."*). Listing tickets therefore means
reading osTicket's own MySQL/MariaDB database directly, the same way osTicket's own admin panel does internally.
This makes it the only integration in this app that isn't a REST API.
- **Actions used:** read-only — a single `SELECT` joining the ticket, status, priority, department, staff, team,
and requester tables for every ticket whose status is in the "open" state. Nothing here can create, update, or
close a ticket.
- **Required access:** a MySQL/MariaDB user with **read-only (`SELECT`) access to the osTicket database only** —
never reuse osTicket's own application database user, which has full read/write access. Create one with
something like:
```sql
CREATE USER 'homelab_manager'@'%' IDENTIFIED BY 'a-strong-password';
GRANT SELECT ON osticket.* TO 'homelab_manager'@'%';
FLUSH PRIVILEGES;
```
(narrow the host part — `'%'` — to this app's actual server address if your MySQL/MariaDB setup allows it, and
make sure the database's own network/firewall rules allow that connection in the first place; this integration
connects over plain TCP, unencrypted, so it's meant for a same-host or same-LAN database, not one reachable over
the internet).
- **What you get:** every currently-open ticket's number, subject, status, priority, department, assigned staff
member or team (or "Unassigned"), requester name/email, source, created/last-activity/due dates, and two flags
osTicket already tracks natively — **overdue** and **awaiting our reply** (i.e. the customer replied last and
nobody on staff has answered yet) — which are the two things most worth a glance on a dashboard.
- **A reliability note on subject/priority specifically:** those two fields aren't columns on osTicket's main
ticket table — osTicket normalizes them into its dynamic custom-fields system, and reads them back here from
`ost_ticket__cdata`, a cache table osTicket's own admin panel also uses for ticket lists (faster than joining the
generic form-fields tables). osTicket's own GitHub issue tracker documents that cache occasionally going stale or
briefly missing right after a custom-field change; this integration's query is written so a ticket with no
matching cache row still shows up in the list, just with an empty subject/priority instead of being silently
dropped.
- **Not verified against a live instance** — built from osTicket's own published database schema and developer
docs (table/column names, the ticket status `state` classification, and the cdata-cache mechanism all
cross-checked there), but this could not be run against a real osTicket database in this environment either (no
MySQL/MariaDB server was available to test against). If a query comes back empty, errors on a missing column, or
a field looks wrong, tell us what happened and we'll adjust — this one has had less real-world exposure than
every other integration listed here.
### phpIPAM
- **Config fields:** phpIPAM URL, API app ID, App token
- **Auth:** the `token` header (and `phpipam-token`, in case your version expects that name instead), set to a
static per-app code. In phpIPAM, go to **Administration → API**, create (or edit) an API app, and set its
**App security** to **"SSL with App token"** (or "App token" if the install isn't served over HTTPS). Saving
it shows the app's code once — that's what goes in this integration's "App token" field, and the app's own
short id (chosen when you created it) goes in "API app ID". phpIPAM's other auth method — logging in as a
real user to get a short-lived token — isn't supported; use an App-token app instead.
- **Required access:** the API app just needs read access to whichever sections/subnets you want imported.
- **What it does:** this is import-only, not a full integration page — on the **IP Addresses** page, "Sync
from phpIPAM" reads every subnet and the addresses in each, and adds or updates an IPAM entry per address
(label from its hostname or description, the subnet's own description as its location, description/note/MAC
folded into notes), the same way "Sync from Tailscale"/"Sync from Proxmox" already work: an address you
entered by hand, or that a different sync source owns, is never overwritten.
- **Not verified against a live instance** — built from phpIPAM's own published API documentation
(endpoints, the `token` header, and the address/subnet field names all cross-checked there), but nobody has
run it against a real phpIPAM yet. If a sync comes back empty or with the wrong fields, tell us what your
instance actually returned and we'll adjust.
## DNS providers
### Cloudflare
- **Config fields:** API Token
- **Auth:** Bearer token against the Cloudflare v4 API
- **Actions used:** list zones, list/create/update/delete DNS records
- **Required access:** A scoped API token (My Profile → API Tokens → Create Token) with **Zone → Zone → Read** and **Zone → DNS → Edit**, restricted to the zone(s) you want managed. `DNS Read` alone isn't enough — record add/update/delete need `Edit`.
### Loopia
- **Config fields:** Username, Password
- **Auth:** Full account credentials sent on every XML-RPC call — Loopia's API has no scoped API-key concept
- **Actions used:** list domains/subdomains/zone records, add/remove zone records and subdomains
- **Required access:** The full Loopia account username and password. There's no way to scope this down at the API level — the credential has the same access as logging into the Loopia customer portal.
### Pi-hole
- **Config fields:** Pi-hole URL, Web password
- **Auth:** Session login against the Pi-hole v6 API (`/api/auth`) using the admin web password, session id cached until it expires
- **Actions used:** read/add/update/delete local DNS "hosts" (A/AAAA) and CNAME records
- **Required access:** The Pi-hole admin web interface password — Pi-hole v6 doesn't have per-feature roles, so this is effectively full admin access to that Pi-hole instance.
### Azure DNS
- **Config fields:** Tenant ID, Client (application) ID, Client secret, Subscription ID
- **Auth:** OAuth2 client-credentials (Azure AD app registration / service principal), then Bearer token against Azure Resource Manager
- **Actions used:** list DNS zones, list/read/create/update/delete record sets
- **Required access:** The service principal needs the **DNS Zone Contributor** role (or higher) on the subscription or resource group containing the DNS zone(s) — assign it under Azure Portal → the zone or resource group → Access control (IAM) → Add role assignment.
### cPanel
- **Config fields:** cPanel URL, Username, API Token, allow self-signed certificate
- **Auth:** `Authorization: cpanel <username>:<apiToken>` header (UAPI for reads, API 2 for record writes)
- **Actions used:** list zones/domains, read a zone's records, add/remove a zone record
- **Required access:** An API token generated under cPanel → Security → Manage API Tokens for the account that **owns** the domain(s) being managed. cPanel tokens inherit the full account's permissions — there's no separate DNS-only scope to grant.
### Technitium DNS Server
- **Config fields:** Technitium URL, API Token
- **Auth:** Bearer token
- **Actions used:** list zones, read zone records, add/delete records (an "update" is implemented as delete + add)
- **Required access:** A token tied to a Technitium user account with **Zones: View + Modify** permission — view-only will fail on record changes since updates are add/delete calls under the hood.
+126
View File
@@ -0,0 +1,126 @@
# Notifications
Every notification in Homelab Manager is sent through the same pipe
(`server/src/services/notify.ts`) to whichever channels are enabled under
**Settings → Notifications**: Gotify, ntfy, SMTP (email), and a generic
JSON webhook. All four fire for every notification below — there's no
per-event channel routing, only a per-event on/off toggle (the "Notify
on" list on that same page) and, for the daily ones, one shared
time/timezone.
Two settings apply to *every* notification regardless of what triggered
it:
- **Quiet hours** (Settings → Notifications → Quiet hours): while
enabled and inside the configured window, a notification is held
instead of sent immediately, then delivered as a single digest at the
window's end time. This matters most for the real-time alerts below —
the daily reminders already fire once at a time you pick, usually
outside the window anyway.
- **Maintenance windows** (the Maintenance page): opening one for a
server, integration, or DNS provider silences the alerts that target
it specifically (offline/disk-full for a server; storage, backup, and
automation-failure alerts for an integration; "integration down" for
that whole service type) — see the Maintenance page's own "What gets
silenced" panel for the exact list.
Where a trigger has a configurable threshold, that's noted in its row
below; several are fixed and can't be changed from the UI.
## Daily reminders
These six checks share one schedule: **Settings → Notifications → Daily
reminder time** (default 08:00) and **Timezone** (default UTC). Unlike
the state-based alerts further down, these **re-send every day the
condition is still true** — there's no "only once" de-duplication, so an
expired secret you haven't renewed yet will be mentioned again at the
next day's check, and the day after that.
Each one also runs once at server startup if it hasn't already run
today (e.g. after an upgrade or a period offline), so you're not waiting
until the next scheduled time to catch up.
| Check | "Notify on" toggle | Fires when | Configured at |
|---|---|---|---|
| Secret expiry | *Secret expiry reminder* | Any tracked secret (API token, SSL cert, password, generic) is expired or within its own configured warning window. SSL certificates with a host:port are re-checked live first, so this reflects the real current expiry, not a stale saved date. A certificate that couldn't be re-checked live is mentioned separately, so the expiry shown may be stale. | Per-secret warning threshold, set when adding/editing that secret |
| Domain expiry | *Domain registration expiring or expired (daily reminder)* | Any tracked domain registration is expired or within the warning window; every domain is re-looked-up against its registry first. A domain whose registry lookup has been failing for 3+ days is also mentioned, separately, as "may be stale." Registries that don't publish an expiry (e.g. `.de`, `.eu`) are tracked but never trigger this. | Settings → Notifications → Health checks → **Domain expiry warning** (default 30 days) |
| Tailscale key expiry | *Tailscale key expiry reminder* | Any device's node key (across every enabled Tailscale integration) is within 30 days of expiring, or already expired. Devices with key expiry disabled are skipped. | Fixed at 30 days |
| Docker image updates | *Docker image update available* | Any container (across every enabled Dockhand integration) has an image update available, per Dockhand's own cached update-check results — this doesn't trigger a fresh registry lookup, just reads what the Docker page itself would show. | Not configurable (reflects Dockhand's own check interval) |
| Proxmox backups | *Proxmox backup failed or a guest has no coverage* | Two independent conditions, both under this one toggle: (1) a node's most recent `vzdump` backup task (across every enabled Proxmox integration) didn't succeed; (2) a VM/LXC isn't covered by any enabled backup job at all. | Not configurable |
| Proxmox Backup Server verification | *Proxmox Backup Server snapshot failed verification* | Across every enabled PBS integration: any datastore has at least one stored snapshot whose verification state is "failed", or a datastore couldn't be read at all (e.g. a permissions problem). This is distinct from the Proxmox check above — PVE only knows a backup *ran*, PBS is the only place that knows whether the stored data still verifies. | Not configurable |
## State-based alerts
These two run on a fixed **15-minute interval** (matching the agent's
own default report interval) — not the daily reminder time above, and
not user-configurable. Unlike the daily reminders, these are
**state-based**: a problem is announced once when it first appears, and
once more when it clears — not repeated on every 15-minute pass while it
continues. A condition that can't currently be read (an integration
that's down, a server whose agent hasn't reported) is held exactly as it
was rather than cleared or re-announced, so a temporary read failure
can't fake a recovery. On first-ever run (or right after upgrading to
this feature), whatever's already failing is recorded silently as the
starting baseline rather than announced all at once.
| Check | "Notify on" toggle | Fires when | Configured at |
|---|---|---|---|
| Health — server offline | *Server offline, disk nearly full, or Synology volume/disk problem* | A server's agent hasn't reported for longer than the offline threshold. Held for the first 20 minutes after this app restarts, since agents haven't had a chance to report yet. | Settings → Notifications → Health checks → **Server offline after** (default 60 min) |
| Health — disk usage | *(same toggle)* | A server disk, a Proxmox node's root filesystem or any of its storages, or a Synology volume, is at or above the usage threshold. A server that's currently offline isn't also judged on disk usage (its figures are stale). | Settings → Notifications → Health checks → **Disk usage alert at** (default 90%) |
| Health — Synology status | *(same toggle)* | A Synology volume's status isn't "normal", or a disk's status/SMART result isn't normal, or it's over the bad-sector or under the remaining-life threshold. | Not configurable |
| Automation — failed run | *Semaphore template or Gitea workflow run failed* | A Semaphore template's, or a Gitea repo's, most recent run status is a clear failure. A run that's still going, was cancelled, or was stopped by hand doesn't count as failed or as a recovery — it's left exactly as it was. For Gitea this follows the repo's most recent run on any workflow or branch. | Not configurable |
## Real-time alerts
These fire immediately, as the triggering action happens — not on any
schedule.
| Event | "Notify on" toggle | Fires when |
|---|---|---|
| DNS record added | *DNS record added* | A DNS record is created against any DNS provider through this app (manually, or via any automated action that writes one). |
| DNS record updated | *DNS record updated* | An existing DNS record is edited. |
| DNS record deleted | *DNS record deleted* | A DNS record is deleted. |
| Integration/DNS provider down | *Integration/DNS provider failing repeatedly* | Any integration or DNS provider adapter's calls fail a configurable number of times **in a row**. Tracked per adapter *type* (e.g. "proxmox"), not per individual integration row — with two Proxmox integrations, a streak of failures on either one counts toward the same total. Only fires once per failure streak (not on every failure past the threshold), and is skipped entirely if that type is currently in a maintenance window. | Settings → Notifications → **Alert after** (default 3 consecutive failures) |
| Integration/DNS provider recovered | *(same toggle)* | The next call for that source succeeds, after a "down" alert was already sent for the current streak. |
## Digest
| Notification | Fires when |
|---|---|
| "Notifications from quiet hours" | Once, at quiet hours' configured end time, **only if enabled and only if at least one notification was held** during the window. Bundles every held notification's title and message into one message, then clears the queue. |
## The Alerts page
**Operations → Alerts** shows what these notifications are about — the problems that exist *right now* — as a list you can look at, filter, and
export. It uses the same checks as the notifications above, so a problem appears there for exactly the reason it would be notified, but it differs
in three ways:
- It ignores the "Notify on" toggles. Turning a notification off doesn't hide the problem from the page.
- Problems under a **maintenance window** are kept on the list, marked *silenced* and counted separately, instead of being dropped.
- It also lists things nothing notifies about: Uptime Kuma monitors that are down, osTicket tickets that are overdue, and any integration that's
failing its last few calls (before the threshold that triggers an "integration down" notification).
It runs the checks live when opened (a recent result is reused for a minute), and shows what it couldn't read at the top, so a missing section means
"couldn't check" and not "all clear".
## What does *not* send a notification
Worth calling out explicitly, since it's easy to assume everything in
the app alerts on something:
- **osTicket** — the integration lists open tickets, but there is no
scheduled check or alert wired up for it (e.g. nothing pings you about
a newly-overdue ticket). It's a read-only dashboard/page today.
- **phpIPAM sync**, **Uptime Kuma monitor/maintenance import**, and any
other manual "Sync from X" action — these run when you click the
button and report their result on the page itself, not via a
notification.
- **Test buttons** (Settings → Notifications → each channel's "Send
test") send a one-off message through that one channel only, to
confirm it's wired up correctly — not a real event and not affected by
quiet hours.
- **Consistency reports**, **Diagnostic Log**, and **Audit Log** are all
read-only views you check yourself; none of them push a notification
on their own (the Diagnostic Log's failures are what feed the
integration-down alert above, but reading the log itself never
triggers anything).
+336 -34
View File
@@ -1,7 +1,10 @@
# Homelab Manager # Homelab Manager
Repository: `git@10.200.5.13:bobban/Homelab-manager.git` ([gitea.labsconnect.se/bobban/Homelab-manager](https://gitea.labsconnect.se/bobban/Homelab-manager) externally).
A single dashboard for a homelab: Proxmox, Synology DSM, Semaphore, Tailscale, A single dashboard for a homelab: Proxmox, Synology DSM, Semaphore, Tailscale,
Gitea, and Dockhand/Docker status and basic actions, plus DNS record Gitea, Dockhand/Docker, Uptime Kuma, Proxmox Backup Server, and osTicket status
and basic actions, plus DNS record
management, an IP address inventory (IPAM), and a secret-expiry tracker management, an IP address inventory (IPAM), and a secret-expiry tracker
(ported from [Sloth Manager](../Sloth%20manager)) and scheduled-task tracking (ported from [Sloth Manager](../Sloth%20manager)) and scheduled-task tracking
across Debian/Raspbian hosts (ported from across Debian/Raspbian hosts (ported from
@@ -13,46 +16,321 @@ Authentik (OIDC), with local admin/operator/viewer roles.
All modules from the original plan are built: All modules from the original plan are built:
- Monorepo scaffold, Tabler-themed app shell/navigation - Monorepo scaffold, Tabler-themed app shell with a grouped sidebar (Infrastructure, Network, Automation, Operations, Administration; groups open on demand, the one holding the current page is always open, and what you leave open is remembered)
- Authentik OIDC login, roles (first user to sign in becomes admin), audit log - Authentik OIDC login, roles (first user to sign in becomes admin), audit log
- **Secrets** — expiry tracking for API tokens/certs/passwords (changes made in the app, sign-ins and sign-outs with the IP they came from, new accounts, and the log's own
- **IP Addresses (IPAM)** — inventory of IPs across vendors/locations automatic trimming — attributed to "system"; settings changes show what changed, but never credentials)
- **Dashboard** — an overview of every system this app tracks, all sharing
one widget-card design (label + status badge, a small stat row, then its
own breakdown): DNS (domain/record counts per provider, cached records by
type), Secrets (monitored/expiring/expired, by type), and one widget per
integration — Tailscale by OS, Proxmox by node (VM/LXC counts too),
Dockhand by container state (plus host count), Semaphore and Gitea by
last-run status (plus private-repo count), and Synology's CPU/RAM
alongside its disk-health breakdown. Breakdowns render as a stacked
proportion bar with a legend — no charting
library, matching the rest of the app's plain-Tabler-CSS approach.
The Servers widget shows how many servers are online, offline, or have never
reported, which are offline and for how long, and any disk at or above the
usage threshold — decided by the same rules as the health alerts, so the
widget and the notifications always agree, and following the thresholds in
Settings even for viewers who can't open them.
The Domains widget shows how many registrations are tracked, which are
expired or expiring (soonest first), the next one to expire, and how many
couldn't be refreshed.
The Uptime Kuma widget shows the monitor count, how many are down, and how
many are matched to one of your servers.
The Proxmox Backup Server widget shows the datastore count and how many
stored snapshots have failed verification or were never verified.
The osTicket widget shows how many tickets are open, overdue, and awaiting
a staff reply.
- **Diagnostic Log** (admin-only) — every call this app makes to a DNS
provider or integration (Tailscale, Proxmox, Synology, Semaphore, Gitea,
Dockhand, Uptime Kuma, Proxmox Backup Server, osTicket), success or failure, with latency and the error message if it
failed — the last 500 calls, filterable by source/result, for
troubleshooting connectivity issues (ported from Sloth Manager's
provider-diagnostics log, generalized to cover every integration this app
has, not just DNS)
- **Secrets** — expiry tracking for API tokens/certs/passwords. An SSL
certificate can optionally be given a host:port to watch: the app opens a
real TLS connection (daily, and on demand via "Check now"), reads the
certificate's actual expiry, and keeps the date current — so a renewed cert
is picked up automatically and an unreachable host is flagged instead of
silently going stale
- **IP Addresses (IPAM)** — inventory of IPs across vendors/locations,
with "Sync from Tailscale", "Sync from Proxmox", and "Sync from phpIPAM"
actions to pull in tailnet device IPs, VM/LXC IPs, and phpIPAM's own
addresses (never overwrites a manually-entered IP, or one a different sync
owns), and each entry now shows its matching DNS record(s) from the DNS
module's cache. Shares the Consistency page's excluded-ranges setting (see
below) so addresses that aren't interesting to track — a Docker bridge
network repeating on every host, say — can be hidden here too, with a
"Hide excluded addresses" toggle and a per-entry "Exclude…" shortcut to add
a range on the spot; managing ranges from either page updates the other
- **DNS** — zone/record management across Cloudflare, Loopia, Pi-hole, Azure - **DNS** — zone/record management across Cloudflare, Loopia, Pi-hole, Azure
DNS, cPanel, and Technitium; providers are configured in-app (not via env DNS, cPanel, and Technitium; providers are configured in-app (not via env
vars) and their credentials are encrypted at rest vars) and their credentials are encrypted at rest
- **Servers & Tasks** — cron/systemd tracking across Debian/Raspbian servers - **Servers** — cron/systemd tracking across Debian/Raspbian servers via a
via a lightweight push agent (`agent/linux/`), plus manual entries for lightweight push agent (`agent/linux/`), Windows scheduled-task tracking
things an agent can't see (Docker jobs, backups) through a PowerShell agent (`agent/windows/`, see its README), plus manual
- **Integrations → Tailscale** — device list with online/authorized status, entries for things an agent can't see (Docker jobs, backups). The Servers page itself just lists
and authorize/deauthorize/remove actions; a live device-count widget. registered servers and (admin-only) adds new ones / issues agent tokens;
- **Integrations → Gitea** — repo list with each repo's last CI run status, clicking a server opens its detail page with CPU/RAM/disk status, IP
and re-running just the failed jobs in a run; a live repo-count widget addresses, matching DNS names (looked up from the DNS module's cache), and
(with a failing-build warning). its scheduled tasks — live hardware from Proxmox for VM/LXC-backed
- **Integrations → Dockhand** — container status across every Docker host servers, or from the agent's own hardware report for everything else,
Dockhand manages (one credential covers all of them), with both showing the same per-disk usage breakdown (an LXC's root filesystem
start/stop/restart actions; a live running/total widget. read straight from the host; a QEMU VM's actual mounts via its guest
- **Integrations → Semaphore** — Ansible run status per template across agent, alongside the allocated size Proxmox already knew about without
every project, with a "Run" action to trigger a template; a live one). Proxmox-linked servers also get start/stop/restart buttons right on the
template-count widget (with a last-failed warning). detail page. The detail page also has an **Admin Links** section
- **Integrations → Proxmox** — VM/LXC status across every node in the (operator/admin to add/edit/remove) for bookmarking that server's own
cluster, with start/stop/restart actions; a live running/total widget. admin UIs — Dockge, Webmin, Cockpit, Portainer, or anything else reachable
Supports self-signed certificates (common in homelab Proxmox setups). by URL. **Operations → Admin Links** summarizes every server's admin links
- **Integrations → Synology** — volume and disk health (read-only by in one sortable, searchable table (server, label, URL, an "Open" link, and
design). Supports self-signed certificates. the same add/edit/delete as the per-server section — adding one here just
asks which server it belongs to), so finding or managing one doesn't mean
visiting each server's own page. Since not every server is a Proxmox VM, an admin can hide the
"Proxmox link" card per server ("Not a VM? Hide this" / "+ Show Proxmox
link options") — it stays visible regardless once a server actually is
linked, so unlinking is always reachable.
- **Tailscale**, **Proxmox**, **Synology**, **Semaphore**, **Gitea**,
**Docker**, **Uptime Kuma**, **Proxmox Backup Server**, and **osTicket**
each get their own top-level page (backed by the matching integration)
instead of living inside a shared Integrations browsing view:
- **Tailscale** — device list with online/authorized status, and
authorize/deauthorize/remove actions; a live device-count widget.
- **Proxmox** — VM/LXC status across every node in the cluster, with
start/restart/shutdown/stop actions; a live running/total widget. Each
online node also gets its own host-stats card — uptime, CPU usage/cores/
load average, RAM and swap usage, and per-storage usage (local, LVM-thin,
ZFS, NFS, etc). Supports self-signed certificates (common in homelab
setups).
- **Synology** — volume and disk health (read-only by design).
Supports self-signed certificates.
- **Semaphore** — Ansible run status per template across every
project, with a "Run" action to trigger a template; a live
template-count widget (with a last-failed warning).
- **Gitea** — repo list with each repo's last CI run status, and
re-running just the failed jobs in a run; a live repo-count widget
(with a failing-build warning).
- **Docker** — container status across every Docker host Dockhand
manages (one credential covers all of them), with
start/stop/restart actions, a host filter, image-update status per
container (from Dockhand's own cached update check, plus a button
to trigger a fresh one), and a live running/total widget (with an
updates-available count).
- **Uptime Kuma** — every monitor's status (up/down/pending/maintenance),
response time, and 24h/30d uptime, with certificate days remaining where
applicable. Each monitor is matched to one of your servers when its
target (an IP, or a TCP/HTTP hostname) lines up with that server's own
address or hostname, linking straight to it — so you can see what's
actually being watched on each box, not just a flat monitor list.
Uptime Kuma has no conventional REST API (the dashboard talks to it over
Socket.IO), so this reads its Prometheus `/metrics` endpoint instead and
parses that itself; read-only, no actions.
- **Proxmox Backup Server** — every configured datastore's usage and
snapshot count, the PBS host's own CPU/RAM/disk, and each stored
snapshot's verification status, since Proxmox VE only knows whether a
backup *ran*, never whether PBS's own verify pass on the stored data
still passes; a daily check alerts on any snapshot that's failed
verification or a datastore that couldn't be read. Read-only, no
actions (nothing here can prune, delete, or trigger a re-verify).
- **osTicket** — every currently open ticket, with its status, priority,
department, assigned staff member or team, requester, and two flags
worth a glance on their own: **overdue** and **awaiting our reply**.
osTicket's own REST API only supports *creating* tickets, not listing
them, so this reads osTicket's MySQL/MariaDB database directly with a
read-only user — the only integration here that isn't a REST API.
Read-only, no actions.
The Integrations page itself is now just a list of configured
integrations (name/type/status, visible to every role) with an
admin-only "Add integration" button and edit/enable/disable/delete
actions per row — the nine dedicated pages above are where you
actually use each one.
- Every table in the app is click-to-sort on any column (numbers, booleans,
and dates/text sort correctly regardless of how the column formats them)
and has an "Export CSV" button next to it that exports whatever's
currently sorted/filtered. Tables that can realistically grow large
(DNS zones/records, IP Addresses, Secrets, Servers, Audit Log, and each
integration's device/container/guest/repo/template list) are paginated,
20 rows per page by default — adjustable under Settings → Display — and
CSV export still covers every sorted/filtered row, not just the current
page.
- **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP,
generic webhook) with per-channel test buttons, per-event toggles (DNS
record added/updated/deleted, daily secret-expiry reminder and daily
Tailscale key-expiry reminder — both sharing one configurable
time/timezone), badge-color customization for both DNS
providers and integration types, and a **Display** tab (date order,
12/24-hour clock, and rows-per-page for every paginated table) applied
consistently across the app.
All six integrations follow the same config-in-UI + encrypted-credentials All six integrations follow the same config-in-UI + encrypted-credentials
pattern, added through **Integrations → Manage integrations**. pattern, added (and edited — e.g. to rotate an expired API token without
recreating the whole integration) through **Integrations → Manage
integrations**. See [INTEGRATIONS.md](INTEGRATIONS.md) for exactly what
credential to create and what access it needs in each target system,
for every integration and DNS provider.
**What's been verified for real** vs. **what still needs your network**: **Verified for real, end to end**: every module above — including all six
Authentik login and Gitea were both tested against the user's actual live integrations, both their read-only views and their write actions
services. Tailscale, Dockhand, Semaphore, Proxmox, and Synology were built (start/stop/restart, trigger-a-run, authorize/deauthorize) — has been
against each service's real published API spec/source (not guesswork) and exercised against the user's actual live homelab, not just built against
verified with scripted HTTP tests against a bogus/unreachable config, since specs. That pass also found and fixed two real bugs: the Synology adapter
those instances are LAN-only and not reachable from where this was built — assumed HTTPS-only (the NAS is reached over plain HTTP), and the Tailscale
their route wiring, validation, and role gating are confirmed correct, but adapter read `online`/`isExitNode` fields that don't actually exist in the
real data and the write actions (start/stop/restart, trigger-a-run) haven't real API response (fixed to derive them from `connectedToControl` and
been exercised against the user's actual homelab yet. Worth going through `enabledRoutes`). See the git log for the full verification notes per
each one after deploying, per integration. integration. (Uptime Kuma, Proxmox Backup Server, and osTicket, added
later, are not part of that "six" — see their own git log entries, and
[INTEGRATIONS.md](INTEGRATIONS.md), for what was and wasn't verified
against a real instance.)
Server and storage health is watched every 15 minutes: a server whose agent
stops reporting, a server disk / Proxmox storage / Synology volume passing a
usage threshold, and a Synology volume or disk that's degraded or failing each
raise one notification when the problem starts and one when it clears (both
thresholds are set under Settings → Notifications). Active problems are
remembered across restarts, so a rebuild doesn't re-alert them.
**Tags** — servers can be tagged (prod, media, rack-1, …) from their detail
page by operators and admins. Tags show as coloured chips on the Servers page,
which can be filtered by one or several tags (the filter is in the URL, so a
tag on a server's page links to everything sharing it), and they're searchable
from the global search box.
Admins manage tags under Settings → Tags: add tags before any server uses them (they're
offered as one-click suggestions when tagging), give any tag a colour of your choosing
(or leave it on the automatic one), rename tags, and delete them. Renaming to a name
that already exists merges the two, and both rename and delete rewrite every server
that carries the tag.
**Name generator** — Operations → Generator suggests server names (and usernames and
passwords, which are made in your browser and never stored). Server names are picked from
name lists: Swedish girl and boy names, Disney and Pixar characters, Norse mythology and
Astrid Lindgren to start with, or "Mixed" for all of them together. A name already used
by a server isn't suggested. Admins edit the lists under Settings → Names — add and remove
names, rename, delete or create lists, put a built-in list back as it was — and can
import the most common Swedish names from Skatteverket's open name statistics for
girls or boys over the latest one to five years. The import is shown to you first and only
changes a list once you add it and save. (Statistics Sweden used to publish this but
stopped after 2023.) Names are kept to letters, digits and hyphens so they work as
hostnames; å, ä and ö become a, a and o.
**Privacy** — a page every signed-in user can open that says what this
installation stores (accounts, sign-in sessions with their IP and browser, the audit
and diagnostic logs, server reports, the secrets tracker, credentials), where data
goes (Authentik, your integrations and DNS providers, the notification channels
that are switched on, domain registries), what's kept in the browser, who can see
what, and how to limit or remove data. Retention, integrations and channels are
read live from the installation; channel addresses are shown to admins only. Each
user sees their own account and sign-ins there and can download their own data
(account, sign-ins, audit-log entries) as a JSON file.
**Consistency** — a report of where IPAM, DNS and your servers disagree about
an address: the same address on two servers, a DNS record named after a server
that points somewhere it isn't, an IPAM entry labelled with a server's name at
the wrong address, addresses in use that IPAM doesn't list (with an "Add to
IPAM" button), and server addresses no DNS record points at. It only compares
data the app already holds — nothing is fetched when you open it — so it says how
many servers reported addresses and how fresh the synced DNS zones are. Only
private addresses are compared; ranges you exclude (Docker's, which repeat the same
subnet on many hosts — 172.16.0.0/12 is excluded by default, remove it if that's a
real LAN for you) are left out of every source; anything that's fine on purpose
can be ignored with a reason, and stays ignored. The excluded-ranges list is the
same one the IP Addresses page manages — edit it from either page and both
reflect the change.
**Domains** — when each domain registration expires, read from the registry
itself. The domains behind your DNS zones are picked up automatically (a zone
like `lab.example.se` resolves to the `example.se` registration that actually
expires); others can be added by hand. Each is looked up daily over RDAP where
the TLD offers it, and otherwise over WHOIS via IANA's referral — which is what
makes `.se`, `.nu` and `.io` work, since those aren't in the RDAP bootstrap.
You're reminded daily from N days before expiry (Settings → Notifications,
default 30) until it's renewed, and told when an expiry date couldn't be
refreshed for several days. Registries that don't publish an expiry (`.de`,
`.eu`) can be tracked but have no date to warn about. Private zones (`.lan`,
`.local`) are skipped.
**Automation failures** — every 15 minutes the app looks at the latest run of
each Semaphore template and each Gitea repo's latest workflow run. A failed one
raises a single notification (with the project/template or repo, run number, and
for Gitea the run's link), and another when a later run succeeds. It's
state-based, so a job that fails every night alerts on the first failure rather
than every night. A run that's still going, was cancelled, or was stopped by
hand leaves things as they were, and anything that couldn't be read (a Semaphore
project or Gitea repo that errored, or an integration that's down) is neither
cleared nor re-announced. The first check after upgrading only records what's
already failing, so old failures aren't announced. Toggle it under Settings →
Notifications. For Gitea this follows the repo's most recent run on any
workflow or branch, the same as the Gitea page shows.
**Alerts** (Operations → Alerts, visible to every role) lists everything that's
wrong right now in one place, instead of waiting for a notification or visiting
each page: servers that stopped reporting, full or nearly full disks and volumes
(critical from 95%), Synology volume/disk problems, failed or uncovered Proxmox
backups, failed Proxmox Backup Server verifications, container image updates,
secrets, domains and Tailscale keys that are expired or about to be, failed
Semaphore/Gitea runs, Uptime Kuma monitors that are down, overdue osTicket
tickets, and integrations whose calls keep failing. It runs the same checks that
send the notifications — so the two can't disagree — but ignores the on/off
toggles, since it's for looking at rather than being interrupted by. Problems
under a maintenance window stay listed, marked silenced and counted separately.
It checks live (a recent result is reused for a minute; "Check now" forces a
fresh one), and anything it couldn't read is called out at the top rather than
quietly treated as fine.
**Maintenance mode** silences alerts about one server, integration, or DNS
provider while you work on it (server offline / disk, storage and Synology
health, Proxmox backup alerts, Proxmox Backup Server verification alerts, and
"integration down" for that service type).
Every window has a fixed end (5 minutes to 7 days) and expires on its own, and
a problem that began during a window and is still present when it ends alerts
then — a forgotten window can't hide an outage. A banner shows what's currently
silenced to every signed-in user. If you also run Uptime Kuma, "Import from
Uptime Kuma" on the Maintenance page reads which of its monitors are
currently in maintenance and starts (or extends) a window here for whichever
server each one's target matches, for a duration you choose — Uptime Kuma's
metrics only say what's in maintenance right now, not for how long, so this
doesn't try to mirror its schedule, only its current state.
**Ports** — each server's detail page has a Ports card for finding free ports
and remembering what each one is for. "Scan…" runs a TCP scan of a port range
from the app against the server's address (private addresses only, up to
20,000 ports at a time) and lists what's open plus the ranges that were
confirmed free; a free range can be clicked to reserve a port. Any port can
carry a service name and a comment, and a port with a note counts as taken
even when nothing is listening. The agent also reports what is bound on the
host (`ss`), which catches services listening on localhost only — a scan from
elsewhere can't see those, so they'd otherwise look free. Re-run the agent
install one-liner on a host to pick that up.
**Network → Ports** is the cross-server counterpart: one page listing every
port every agent currently reports as listening, across all servers at once
(protocol, address, process, last report time), each linking back to its
server. Below that, a separate, manually-maintained table is for the ports
this app can't see on its own — a router's port forward, an edge firewall
rule, a cloud security group — the same reason people keep a spreadsheet of
"what did I open and why." Each entry has a label, the external port/protocol,
an optional link to a tracked server (plus its own internal port, when NAT
changes it) or a freeform destination, a free-text "source" (which
router/firewall/service it's actually configured on — this app doesn't talk
to any firewall, so it can't manage or verify the rule, only record it), and
a comment. Viewers can see both tables; adding, editing, or deleting a manual
entry needs operator or admin.
The app is installable as a PWA — "Install app" / "Add to Home Screen" from the
browser gives it its own icon and a standalone window on phone or desktop. This
needs the site to be served over HTTPS (browsers only offer install on secure
origins; `localhost` also counts). The bundled service worker deliberately
caches nothing, so an installed copy always shows the current build.
**More documentation**: [ROLES.md](ROLES.md) — who can see and do what;
[NOTIFICATIONS.md](NOTIFICATIONS.md) — every notification the app sends and when;
[INTEGRATIONS.md](INTEGRATIONS.md) — the credentials each integration needs;
[DATABASE.md](DATABASE.md) — the database tables, columns and relationships.
## Requirements ## Requirements
@@ -99,3 +377,27 @@ docker compose up -d
The app listens on `HOST_PORT` (default `3000`); SQLite data persists in The app listens on `HOST_PORT` (default `3000`); SQLite data persists in
`./data` on the host. `./data` on the host.
### arm64 (Raspberry Pi, Apple silicon, ARM servers)
Two compose files, one to build the image and one to run the published one:
```bash
# On any machine with Docker: build the arm64 image and push it to the registry
docker compose -f docker-compose.arm64.build.yml build
docker compose -f docker-compose.arm64.build.yml push
# On the arm64 machine: pull that image and run it (no build there)
docker compose -f docker-compose.arm64.yml pull
docker compose -f docker-compose.arm64.yml up -d
```
`docker-compose.arm64.build.yml` can also build and run right on an arm64 machine
(`up -d --build`). Building on x86 goes through QEMU emulation — slower, and it needs
a one-time `docker run --privileged --rm tonistiigi/binfmt --install arm64`.
Both files use the image `gitea.labsconnect.se/bobban/homelabmanager-homelab-manager:arm64`.
Set `IMAGE_REPO` and/or `ARM64_TAG` in `.env` to use another registry or to pin a release
tag (e.g. `ARM64_TAG=1.4.0-arm64`). The arm64 tag is kept separate from the default image,
so the existing `docker-compose.yml` is unaffected. A `.dockerignore` keeps host
`node_modules`, `.env` and `data/` out of the build.
+92
View File
@@ -0,0 +1,92 @@
# Roles & menu access
Homelab Manager has three roles, ranked lowest to highest: **viewer**,
**operator**, **admin**. The first person to sign in becomes admin;
everyone after that starts as viewer until an admin changes their role
under **Administration → Users**.
A role can do everything the roles below it can, plus what's listed for
it — operator includes everything viewer has, admin includes everything
operator has.
## Sidebar menu, by role
✅ = the menu item appears for that role · ❌ = it's hidden entirely (not
just disabled) — a group that would end up with zero visible items
disappears too, and a group left with exactly one just shows as that
page's own top-level link.
| Menu item | Path | Viewer | Operator | Admin |
|---|---|:---:|:---:|:---:|
| Dashboard | `/` | ✅ | ✅ | ✅ |
| **Infrastructure** | | | | |
| Servers | `/servers` | ✅ | ✅ | ✅ |
| Proxmox | `/proxmox` | ✅ | ✅ | ✅ |
| Synology | `/synology` | ✅ | ✅ | ✅ |
| Proxmox Backup | `/pbs` | ✅ | ✅ | ✅ |
| Docker | `/docker` | ✅ | ✅ | ✅ |
| Tailscale | `/tailscale` | ✅ | ✅ | ✅ |
| **Network** | | | | |
| DNS | `/dns` | ✅ | ✅ | ✅ |
| Domains | `/domains` | ✅ | ✅ | ✅ |
| IP Addresses | `/ipam` | ✅ | ✅ | ✅ |
| Ports | `/ports` | ✅ | ✅ | ✅ |
| Consistency | `/consistency` | ✅ | ✅ | ✅ |
| **Automation** | | | | |
| Semaphore | `/semaphore` | ✅ | ✅ | ✅ |
| Gitea | `/gitea` | ✅ | ✅ | ✅ |
| Secrets | `/secrets` | ✅ | ✅ | ✅ |
| **Operations** | | | | |
| Alerts | `/alerts` | ✅ | ✅ | ✅ |
| Maintenance | `/maintenance` | ✅ | ✅ | ✅ |
| Uptime Kuma | `/uptime-kuma` | ✅ | ✅ | ✅ |
| osTicket | `/osticket` | ✅ | ✅ | ✅ |
| Admin Links | `/admin-links` | ✅ | ✅ | ✅ |
| Generator | `/generator` | ✅ | ✅ | ✅ |
| **Administration** | | | | |
| Integrations | `/integrations` | ✅ | ✅ | ✅ |
| Users | `/users` | ❌ | ❌ | ✅ |
| Sessions | `/sessions` | ❌ | ❌ | ✅ |
| Audit Log | `/audit-log` | ❌ | ✅ | ✅ |
| Diagnostic Log | `/diag-log` | ❌ | ❌ | ✅ |
| Settings | `/settings` | ❌ | ❌ | ✅ |
| Privacy (footer link, not in a group) | `/privacy` | ✅ | ✅ | ✅ |
So in practice: **every page is visible to every role except the five
under Administration** — Users, Sessions, and Diagnostic Log need admin;
Audit Log needs operator or admin; Settings needs admin.
## Being able to see a page isn't the same as being able to change things
Almost every page above is visible to viewers, but most of the buttons
on them aren't — a viewer can look at everything but can't act on
anything. Operators can use the page's normal working actions (starting
a container, syncing DNS, adding a secret, opening a maintenance
window…). Some actions on otherwise-viewer-visible pages are held back
even further, to admin only:
- **Integrations & DNS providers**: any operator can use an integration
once it's configured (start/stop a guest, run a template, sync DNS
records, etc.), but adding, editing, testing, or deleting an
integration or DNS provider is admin-only.
- **Servers**: registering a new server, rotating its agent token, and
editing/deleting a server are admin-only; tagging a server and
linking/unlinking it to a Proxmox guest just need operator.
- **Tags**: creating, renaming, recoloring, or deleting a tag is
admin-only (applying an existing tag to a server needs operator).
- **Ports**: everyone can see both the agent-reported and manual tables;
adding, editing, or deleting a manual port opening needs operator.
- **Admin Links**: everyone can see and open every server's admin
bookmarks, from that server's own page or the summary page; adding,
editing, or removing one needs operator, from either place.
- **Generator**: everyone can use it; the name lists it picks server names from
are edited under Settings → Names, which is admin-only (and so is importing
names from Skatteverket).
- Everything under **Settings** (notification channels, badge colors,
display prefs, log retention, backup/restore) is admin-only, matching
the page itself being admin-only.
If you need the exact role for one specific button rather than this
summary, check the corresponding route in `server/src/routes/` — each
one that needs more than "signed in" calls `requireRole("operator")` or
`requireRole("admin")` right where that action is defined.
+21 -2
View File
@@ -6,16 +6,30 @@
# curl -fsSL https://homelab.example.lan/agent/linux/install.sh | \ # curl -fsSL https://homelab.example.lan/agent/linux/install.sh | \
# sudo API_URL=https://homelab.example.lan API_TOKEN=hlm_xxx bash # sudo API_URL=https://homelab.example.lan API_TOKEN=hlm_xxx bash
# #
# If Homelab Manager is served with a self-signed certificate, also pass
# API_INSECURE=true (skips TLS verification for every request this agent
# makes — only do this on a trusted LAN) AND add -k to the outer curl
# above, since that first fetch of this very script also hits the
# self-signed endpoint before any of this script's logic can run:
# curl -fsSL -k https://homelab.example.lan/agent/linux/install.sh | \
# sudo API_URL=https://homelab.example.lan API_TOKEN=hlm_xxx API_INSECURE=true bash
#
set -euo pipefail set -euo pipefail
: "${API_URL:?Set API_URL to your Homelab Manager URL, e.g. https://homelab.example.lan}" : "${API_URL:?Set API_URL to your Homelab Manager URL, e.g. https://homelab.example.lan}"
: "${API_TOKEN:?Set API_TOKEN to the per-server token generated on the Servers & Tasks page}" : "${API_TOKEN:?Set API_TOKEN to the per-server token generated on the Servers & Tasks page}"
API_INSECURE="${API_INSECURE:-false}"
INSTALL_DIR="/usr/local/bin" INSTALL_DIR="/usr/local/bin"
CONFIG_DIR="/etc" CONFIG_DIR="/etc"
SYSTEMD_DIR="/etc/systemd/system" SYSTEMD_DIR="/etc/systemd/system"
INTERVAL_MINUTES="${INTERVAL_MINUTES:-15}" INTERVAL_MINUTES="${INTERVAL_MINUTES:-15}"
CURL_INSECURE_FLAG=()
case "${API_INSECURE,,}" in
1|true|yes) CURL_INSECURE_FLAG=(-k) ;;
esac
if [[ "$EUID" -ne 0 ]]; then if [[ "$EUID" -ne 0 ]]; then
echo "This installer must be run as root (it installs a systemd timer)." >&2 echo "This installer must be run as root (it installs a systemd timer)." >&2
echo "If you're piping from curl, put sudo right after the pipe so it elevates bash, not curl:" >&2 echo "If you're piping from curl, put sudo right after the pipe so it elevates bash, not curl:" >&2
@@ -55,13 +69,14 @@ fi
echo "Installing Homelab Manager agent from $API_URL ..." echo "Installing Homelab Manager agent from $API_URL ..."
curl -fsSL "$API_URL/agent/linux/report-tasks.sh" -o "$INSTALL_DIR/homelab-manager-agent.sh" curl -fsSL "${CURL_INSECURE_FLAG[@]}" "$API_URL/agent/linux/report-tasks.sh" -o "$INSTALL_DIR/homelab-manager-agent.sh"
chmod 755 "$INSTALL_DIR/homelab-manager-agent.sh" chmod 755 "$INSTALL_DIR/homelab-manager-agent.sh"
umask 077 umask 077
cat > "$CONFIG_DIR/homelab-manager-agent.env" <<EOF cat > "$CONFIG_DIR/homelab-manager-agent.env" <<EOF
API_URL=$API_URL API_URL=$API_URL
API_TOKEN=$API_TOKEN API_TOKEN=$API_TOKEN
API_INSECURE=$API_INSECURE
EOF EOF
chmod 600 "$CONFIG_DIR/homelab-manager-agent.env" chmod 600 "$CONFIG_DIR/homelab-manager-agent.env"
@@ -95,4 +110,8 @@ echo "Installed. Running an initial report now..."
"$INSTALL_DIR/homelab-manager-agent.sh" "$INSTALL_DIR/homelab-manager-agent.sh"
echo "Done. The agent reports every ${INTERVAL_MINUTES} minute(s) via the 'homelab-manager-agent.timer' systemd timer." echo "Done. The agent reports every ${INTERVAL_MINUTES} minute(s) via the 'homelab-manager-agent.timer' systemd timer."
echo "To remove it later: curl -fsSL $API_URL/agent/linux/uninstall.sh | sudo bash" if [[ ${#CURL_INSECURE_FLAG[@]} -gt 0 ]]; then
echo "To remove it later: curl -fsSL -k $API_URL/agent/linux/uninstall.sh | sudo bash"
else
echo "To remove it later: curl -fsSL $API_URL/agent/linux/uninstall.sh | sudo bash"
fi
+104 -2
View File
@@ -5,6 +5,9 @@
# #
# API_URL=https://homelab.example.lan API_TOKEN=hlm_xxx ./report-tasks.sh --dry-run # API_URL=https://homelab.example.lan API_TOKEN=hlm_xxx ./report-tasks.sh --dry-run
# #
# Set API_INSECURE=true (also written to the env file by install.sh when
# passed there) if Homelab Manager uses a self-signed certificate.
#
set -euo pipefail set -euo pipefail
ENV_FILE="${ENV_FILE:-/etc/homelab-manager-agent.env}" ENV_FILE="${ENV_FILE:-/etc/homelab-manager-agent.env}"
@@ -15,6 +18,7 @@ fi
API_URL="${API_URL:-}" API_URL="${API_URL:-}"
API_TOKEN="${API_TOKEN:-}" API_TOKEN="${API_TOKEN:-}"
API_INSECURE="${API_INSECURE:-false}"
DRY_RUN=0 DRY_RUN=0
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 [[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
@@ -23,6 +27,11 @@ if [[ -z "$API_URL" || -z "$API_TOKEN" ]]; then
exit 1 exit 1
fi fi
CURL_INSECURE_FLAG=()
case "${API_INSECURE,,}" in
1|true|yes) CURL_INSECURE_FLAG=(-k) ;;
esac
suggest_package_install() { suggest_package_install() {
local pkg="$1" local pkg="$1"
if command -v apt-get >/dev/null 2>&1; then if command -v apt-get >/dev/null 2>&1; then
@@ -173,23 +182,116 @@ collect_systemd_timers() {
done < <(jq -c '.[]' <<<"$timers_json") done < <(jq -c '.[]' <<<"$timers_json")
} }
collect_system_info() {
local ip_json="[]"
if command -v ip >/dev/null 2>&1; then
ip_json=$(ip -4 -o addr show scope global 2>/dev/null \
| awk '{print $4}' | cut -d/ -f1 \
| jq -R -s -c 'split("\n") | map(select(length > 0))')
fi
local cpu_model="" cpu_cores=0 cpu_load_percent="null"
# x86 /proc/cpuinfo has a per-core "model name" line. Most 64-bit ARM
# kernels (Raspberry Pi included) don't — they instead have a single
# "Model" line at the very end, or nothing at all, in which case
# /proc/device-tree/model (present on any device-tree/SBC board) has the
# board's friendly name. Try each in turn; leave blank if none exist.
cpu_model=$(grep -m1 "^model name" /proc/cpuinfo 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || true)
if [[ -z "$cpu_model" ]]; then
cpu_model=$(grep -m1 "^Model" /proc/cpuinfo 2>/dev/null | cut -d: -f2- | sed 's/^ *//' || true)
fi
if [[ -z "$cpu_model" && -r /proc/device-tree/model ]]; then
cpu_model=$(tr -d '\0' < /proc/device-tree/model 2>/dev/null || true)
fi
cpu_cores=$(nproc 2>/dev/null || echo 0)
if [[ -r /proc/loadavg && "$cpu_cores" -gt 0 ]]; then
local load1
load1=$(awk '{print $1}' /proc/loadavg)
cpu_load_percent=$(awk -v l="$load1" -v c="$cpu_cores" 'BEGIN { printf "%.1f", (l/c)*100 }')
fi
local mem_total=0 mem_used=0
if command -v free >/dev/null 2>&1; then
read -r mem_total mem_used < <(free -b | awk '/^Mem:/ {print $2, $3}')
fi
local disks_json="[]"
if command -v df >/dev/null 2>&1; then
disks_json=$(df -B1 --output=target,size,used -x tmpfs -x devtmpfs -x squashfs -x overlay 2>/dev/null \
| tail -n +2 \
| awk '{print $1"\t"$2"\t"$3}' \
| jq -R -s -c '
split("\n") | map(select(length > 0) | split("\t")) |
map({mount: .[0], size_bytes: (.[1]|tonumber), used_bytes: (.[2]|tonumber)})
')
fi
# Everything bound to a port on this host, including services listening on localhost only (which a network
# scan from elsewhere can't see). `ss -p` needs root to name the process; without it the process is blank.
# One row per socket — the server groups them per port.
local ports_json="[]"
if command -v ss >/dev/null 2>&1; then
ports_json=$(ss -H -tulnp 2>/dev/null \
| awk '{
local = $5; port = local; sub(/.*:/, "", port); addr = local; sub(/:[0-9]+$/, "", addr);
proc = ""; if (match($0, /users:\(\("[^"]+"/)) { proc = substr($0, RSTART + 9, RLENGTH - 10) }
if (port ~ /^[0-9]+$/) print $1 "\t" port "\t" addr "\t" proc
}' \
| jq -R -s -c '
split("\n") | map(select(length > 0) | split("\t")) |
map({protocol: .[0], port: (.[1]|tonumber), address: .[2], process: (.[3] // "")})
')
fi
SYSTEM_JSON=$(jq -n \
--argjson ip_addresses "$ip_json" \
--argjson listening_ports "$ports_json" \
--arg cpu_model "$cpu_model" \
--argjson cpu_cores "${cpu_cores:-0}" \
--argjson cpu_load_percent "$cpu_load_percent" \
--argjson mem_total_bytes "${mem_total:-0}" \
--argjson mem_used_bytes "${mem_used:-0}" \
--argjson disks "$disks_json" \
'{
ip_addresses: $ip_addresses,
cpu: { model: $cpu_model, cores: $cpu_cores, load_percent: $cpu_load_percent },
memory: { total_bytes: $mem_total_bytes, used_bytes: $mem_used_bytes },
disks: $disks,
listening_ports: $listening_ports
}')
}
collect_cron collect_cron
collect_systemd_timers collect_systemd_timers
# Hardware/network facts are best-effort: a quirk on any given host (e.g. no
# "model name" line in /proc/cpuinfo on some ARM boards) must never abort the
# whole report over it, so errexit is relaxed just for this call.
SYSTEM_JSON="null"
set +e
collect_system_info
system_info_status=$?
set -e
if [[ "$system_info_status" -ne 0 ]]; then
echo "Warning: collecting hardware/network info failed (exit $system_info_status) — reporting tasks without it." >&2
SYSTEM_JSON="null"
fi
HOSTNAME_VALUE=$(hostname -f 2>/dev/null || hostname) HOSTNAME_VALUE=$(hostname -f 2>/dev/null || hostname)
PAYLOAD=$(jq -n \ PAYLOAD=$(jq -n \
--arg hostname "$HOSTNAME_VALUE" \ --arg hostname "$HOSTNAME_VALUE" \
--arg os_type "linux" \ --arg os_type "linux" \
--arg reported_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ --arg reported_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--argjson system "$SYSTEM_JSON" \
--argjson tasks "$TASKS_JSON" \ --argjson tasks "$TASKS_JSON" \
'{hostname: $hostname, os_type: $os_type, reported_at: $reported_at, tasks: $tasks}') '{hostname: $hostname, os_type: $os_type, reported_at: $reported_at, system: $system, tasks: $tasks}')
if [[ "$DRY_RUN" == "1" ]]; then if [[ "$DRY_RUN" == "1" ]]; then
echo "$PAYLOAD" | jq . echo "$PAYLOAD" | jq .
exit 0 exit 0
fi fi
response=$(curl -sS -o /tmp/hlm-agent-response.json -w "%{http_code}" \ response=$(curl -sS "${CURL_INSECURE_FLAG[@]}" -o /tmp/hlm-agent-response.json -w "%{http_code}" \
-X POST "$API_URL/api/agent/report" \ -X POST "$API_URL/api/agent/report" \
-H "Authorization: Bearer $API_TOKEN" \ -H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
+80 -12
View File
@@ -1,14 +1,82 @@
# Windows agent (planned, not yet implemented) # Windows agent
v1 of the Servers & Tasks module only supports Linux servers (cron + systemd Reports a Windows machine to Homelab Manager the same way the [Linux agent](../linux/) does: its
timers) — this covers the Debian and Raspbian hosts in the homelab. A Windows scheduled tasks, plus hostname, IP addresses, CPU / memory / disk usage and listening ports. It
agent is a natural future addition and would follow the same contract as the runs as a scheduled task (as SYSTEM, every 15 minutes) and pushes to `POST /api/agent/report`, so
Linux agent in [`../linux/report-tasks.sh`](../linux/report-tasks.sh): the machine never needs to be reachable from Homelab Manager.
- Collect tasks with `Get-ScheduledTask | Get-ScheduledTaskInfo` (name, action/command, ## Install
trigger description, next run time, enabled state).
- POST the same JSON shape to `POST /api/agent/report` with `schedule_type: "windows_task"` 1. In Homelab Manager, **Servers → Manage servers → Add a server**, choose **Windows** as the
(the server and UI already treat `schedule_type` as an open string in storage; only the operating system, and copy the install command shown once for the new token.
`tasks` API and UI schedule-type filter would need the new value added). 2. On the Windows machine, open **Windows PowerShell as administrator** and paste it. It looks like:
- Ship as a scheduled task (naturally) or a small Windows service that runs on a timer,
configured via the same `API_URL` / `API_TOKEN` environment variables as the Linux agent. ```powershell
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$env:API_URL = 'https://homelab.example.lan'; $env:API_TOKEN = 'hlm_xxx'
iex ((New-Object Net.WebClient).DownloadString("$env:API_URL/agent/windows/install.ps1"))
```
The installer downloads the agent to `C:\ProgramData\HomelabManager\`, stores the URL and token
there in `agent.json` (readable only by SYSTEM and Administrators), registers a scheduled task named
**Homelab Manager Agent**, and sends a first report so you see straight away whether it worked.
If Homelab Manager uses a **self-signed certificate**, tick the box in the Servers page: the command
then also skips certificate checks for the download and sets `API_INSECURE`, which makes the agent
skip them for every report. Only do that on a trusted LAN. That form is for Windows PowerShell 5.1
(the one built into Windows); in PowerShell 7 use `-SkipCertificateCheck` for the download step.
Set `INTERVAL_MINUTES` before installing to report more or less often (default 15).
## What it reports
- **Scheduled tasks** — name, what they run (program and arguments), a readable description of their
triggers ("Daily at 02:00", "Weekly on Mon, Wed at 03:00", "At logon", "…, repeating every 15 min"),
next run time and whether they're enabled. They show up under *Windows scheduled tasks*. Microsoft's
own tasks (the `\Microsoft\` folder — several hundred) are left out; set `INCLUDE_MICROSOFT_TASKS=true`
(environment variable, or `"includeMicrosoftTasks": true` in `agent.json`) to report them too.
- **System** — IPv4 addresses (not loopback or 169.254.x), CPU model, cores and current processor load,
memory, and every fixed disk (`C:`, `D:`, …).
- **Listening ports** — TCP listeners and UDP endpoints with the program that owns them, so they appear
on the server's Ports card, including services bound to localhost only.
Unlike the Linux agent's CPU figure (a load average), the Windows one is the processor's actual
current load.
## Try it without installing
```powershell
$env:API_URL = 'https://homelab.example.lan'; $env:API_TOKEN = 'hlm_xxx'
.\report-tasks.ps1 -DryRun # prints the JSON it would send, sends nothing
.\report-tasks.ps1 # sends one report
```
Works in Windows PowerShell 5.1 and PowerShell 7, with or without administrator rights.
## Check on it
```powershell
Get-ScheduledTaskInfo -TaskName 'Homelab Manager Agent' # LastRunTime, LastTaskResult (0 = fine)
Start-ScheduledTask -TaskName 'Homelab Manager Agent' # run it now
```
## Uninstall
In an elevated Windows PowerShell (the Servers page shows the exact command for that server):
```powershell
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
iex ((New-Object Net.WebClient).DownloadString('https://homelab.example.lan/agent/windows/uninstall.ps1'))
```
This removes the scheduled task, the agent script and `agent.json`. The server's entry and history in
Homelab Manager are kept — delete it from the Servers page if you no longer want it tracked.
## Notes
- Written for Windows 10 / Windows Server 2016 or newer. Older releases are untested; the repeating trigger
is created without an end date, which very old Task Scheduler versions may not accept.
- The scripts in this folder are deliberately plain ASCII: they're downloaded as text, and Windows
PowerShell 5.1 reads a file without a byte-order mark as ANSI, so anything else would be garbled.
- Task names, commands and ports are sent to your Homelab Manager server; see its Privacy page for what
it stores.
+123
View File
@@ -0,0 +1,123 @@
# Installs the Homelab Manager agent on Windows as a scheduled task that runs as SYSTEM.
#
# Run in an ELEVATED Windows PowerShell (Run as administrator), with your server's URL and this
# server's token from the Servers page:
#
# [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# $env:API_URL = 'https://homelab.example.lan'; $env:API_TOKEN = 'hlm_xxx'
# iex ((New-Object Net.WebClient).DownloadString("$env:API_URL/agent/windows/install.ps1"))
#
# If Homelab Manager uses a self-signed certificate, also set API_INSECURE (this skips certificate checks
# for every request the agent makes - only on a trusted LAN) and skip the check for the download itself,
# since fetching this very script hits the same certificate:
#
# [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
# $env:API_URL = 'https://homelab.example.lan'; $env:API_TOKEN = 'hlm_xxx'; $env:API_INSECURE = 'true'
# iex ((New-Object Net.WebClient).DownloadString("$env:API_URL/agent/windows/install.ps1"))
#
# Optional: INTERVAL_MINUTES (default 15).
#
# NOTE: keep this file plain ASCII (it is downloaded as text).
$ErrorActionPreference = 'Stop'
$script:TaskName = 'Homelab Manager Agent'
$script:InstallDir = Join-Path $env:ProgramData 'HomelabManager'
function Test-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
return ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Test-Truthy([string]$Value) { return ($Value -match '^(1|true|yes)$') }
# Downloads a file. Windows PowerShell 5.1 needs TLS 1.2 switched on and takes the self-signed-certificate override through
# ServicePointManager; PowerShell 7 ignores that setting and has its own switch.
function Save-Url([string]$Url, [string]$Path, [bool]$Insecure) {
if ($PSVersionTable.PSVersion.Major -ge 6) {
$params = @{ Uri = $Url; OutFile = $Path }
if ($Insecure) { $params['SkipCertificateCheck'] = $true }
Invoke-WebRequest @params
return
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
if ($Insecure) { [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } }
$client = New-Object System.Net.WebClient
try { $client.DownloadFile($Url, $Path) } finally { $client.Dispose() }
}
# Extra principals (as "*SID:(F)") allowed to write the credentials file. Empty in real use - an elevated installer already
# holds Administrators - and only there so a test running without elevation can still write to its own temporary file.
$script:ExtraConfigGrants = @()
# The credentials file holds the token, so only SYSTEM and Administrators may read it. Identified by SID so this works on any Windows language.
function Save-AgentConfig([string]$Path, [string]$ApiUrl, [string]$ApiToken, [bool]$Insecure) {
$config = [ordered]@{ apiUrl = $ApiUrl; apiToken = $ApiToken; insecure = $Insecure }
# Write with restrictive permissions already in place, so the token is never readable by anyone else, even briefly.
[System.IO.File]::WriteAllText($Path, '', (New-Object System.Text.UTF8Encoding($false)))
$grants = @('*S-1-5-18:(F)', '*S-1-5-32-544:(F)') + @($script:ExtraConfigGrants)
& icacls.exe $Path /inheritance:r /grant:r $grants | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Couldn't restrict permissions on $Path (icacls exit $LASTEXITCODE)." }
[System.IO.File]::WriteAllText($Path, (ConvertTo-Json -InputObject $config), (New-Object System.Text.UTF8Encoding($false)))
}
# The pieces of the scheduled task, built but not registered.
function New-AgentTaskParts([string]$AgentScript, [int]$IntervalMinutes) {
$argument = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "{0}"' -f $AgentScript
$repeat = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(1)) -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
$boot = New-ScheduledTaskTrigger -AtStartup
$boot.Delay = 'PT2M' # let the network come up before the first report
return @{
Action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $argument
Triggers = @($repeat, $boot)
Principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -LogonType ServiceAccount -RunLevel Highest
Settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
}
}
function Install-Agent {
if (-not (Test-Administrator)) {
throw 'This installer must be run as Administrator (it registers a scheduled task that runs as SYSTEM). Open PowerShell with "Run as administrator" and try again.'
}
$apiUrl = ([string]$env:API_URL).TrimEnd('/')
$apiToken = [string]$env:API_TOKEN
if (-not $apiUrl) { throw 'Set API_URL to your Homelab Manager URL, e.g. $env:API_URL = ''https://homelab.example.lan''' }
if (-not $apiToken) { throw 'Set API_TOKEN to the per-server token from the Servers page, e.g. $env:API_TOKEN = ''hlm_xxx''' }
$insecure = Test-Truthy $env:API_INSECURE
$interval = 15
if ($env:INTERVAL_MINUTES) {
if (-not ([int]::TryParse($env:INTERVAL_MINUTES, [ref]$interval)) -or $interval -lt 1 -or $interval -gt 1440) {
throw 'INTERVAL_MINUTES must be a whole number from 1 to 1440.'
}
}
Write-Output "Installing Homelab Manager agent from $apiUrl ..."
New-Item -ItemType Directory -Force -Path $script:InstallDir | Out-Null
$agentScript = Join-Path $script:InstallDir 'homelab-manager-agent.ps1'
Save-Url "$apiUrl/agent/windows/report-tasks.ps1" $agentScript $insecure
Save-AgentConfig (Join-Path $script:InstallDir 'agent.json') $apiUrl $apiToken $insecure
# Reinstalling replaces the task rather than failing on it.
if (Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $script:TaskName -Confirm:$false
}
$parts = New-AgentTaskParts $agentScript $interval
$null = Register-ScheduledTask -TaskName $script:TaskName -Action $parts.Action -Trigger $parts.Triggers -Principal $parts.Principal -Settings $parts.Settings -Description 'Reports scheduled tasks and system information to Homelab Manager.'
Write-Output 'Installed. Running an initial report now...'
& powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $agentScript
if ($LASTEXITCODE -ne 0) {
Write-Warning "The initial report failed (see above). The agent is installed and will keep trying every $interval minute(s) - check API_URL and API_TOKEN."
}
Write-Output "Done. The agent reports every $interval minute(s) through the '$script:TaskName' scheduled task."
Write-Output "To remove it later, run in an elevated PowerShell: iex ((New-Object Net.WebClient).DownloadString('$apiUrl/agent/windows/uninstall.ps1'))"
}
# Dot-sourcing (for tests) defines the functions without running anything.
if ($MyInvocation.InvocationName -ne '.') { Install-Agent }
+335
View File
@@ -0,0 +1,335 @@
<#
.SYNOPSIS
Collects scheduled tasks and basic system information on this Windows host and
POSTs them to the Homelab Manager API.
.DESCRIPTION
Intended to run as SYSTEM on a schedule (see install.ps1), but can be run by
hand for testing:
$env:API_URL='https://homelab.example.lan'; $env:API_TOKEN='hlm_xxx'
.\report-tasks.ps1 -DryRun
Configuration comes from the API_URL / API_TOKEN / API_INSECURE environment
variables, or else from %ProgramData%\HomelabManager\agent.json (written by
install.ps1). Works in Windows PowerShell 5.1 and PowerShell 7.
Set API_INSECURE=true if Homelab Manager uses a self-signed certificate. That
skips certificate checks for every request this agent makes - only do it on a
trusted LAN.
Microsoft's built-in scheduled tasks (the \Microsoft\ folder, several hundred
of them) are left out; set INCLUDE_MICROSOFT_TASKS=true to report them too.
NOTE: keep this file plain ASCII. It is downloaded as text and Windows
PowerShell 5.1 reads a file without a BOM as ANSI, so anything else garbles.
#>
[CmdletBinding()]
param(
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
$script:DefaultConfigPath = Join-Path $env:ProgramData 'HomelabManager\agent.json'
# ---- configuration ------------------------------------------------------------
function Get-AgentConfig {
$config = @{ ApiUrl = $null; ApiToken = $null; Insecure = $false; IncludeMicrosoftTasks = $false }
$path = if ($env:HLM_CONFIG) { $env:HLM_CONFIG } else { $script:DefaultConfigPath }
if (Test-Path -LiteralPath $path) {
$file = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
if ($file.apiUrl) { $config.ApiUrl = [string]$file.apiUrl }
if ($file.apiToken) { $config.ApiToken = [string]$file.apiToken }
if ($null -ne $file.insecure) { $config.Insecure = [bool]$file.insecure }
if ($null -ne $file.includeMicrosoftTasks) { $config.IncludeMicrosoftTasks = [bool]$file.includeMicrosoftTasks }
}
# Environment variables win over the file, like the Linux agent.
if ($env:API_URL) { $config.ApiUrl = $env:API_URL }
if ($env:API_TOKEN) { $config.ApiToken = $env:API_TOKEN }
if ($env:API_INSECURE) { $config.Insecure = $env:API_INSECURE -match '^(1|true|yes)$' }
if ($env:INCLUDE_MICROSOFT_TASKS) { $config.IncludeMicrosoftTasks = $env:INCLUDE_MICROSOFT_TASKS -match '^(1|true|yes)$' }
if ($config.ApiUrl) { $config.ApiUrl = $config.ApiUrl.TrimEnd('/') }
return $config
}
# ---- describing scheduled tasks -------------------------------------------------
# "PT15M" -> "15 min", "P1D" -> "1 day", "PT1H30M" -> "1 h 30 min". Returns $null for empty/unparseable.
function Convert-IsoDuration([string]$Iso) {
if (-not $Iso) { return $null }
$m = [regex]::Match($Iso, '^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$')
if (-not $m.Success) { return $null }
$parts = @()
if ($m.Groups[1].Success) { $parts += ('{0} day{1}' -f $m.Groups[1].Value, $(if ($m.Groups[1].Value -eq '1') { '' } else { 's' })) }
if ($m.Groups[2].Success) { $parts += ('{0} h' -f $m.Groups[2].Value) }
if ($m.Groups[3].Success) { $parts += ('{0} min' -f $m.Groups[3].Value) }
if ($m.Groups[4].Success) { $parts += ('{0} s' -f $m.Groups[4].Value) }
if ($parts.Count -eq 0) { return $null }
return ($parts -join ' ')
}
# StartBoundary is "2026-01-01T02:00:00" (local time, sometimes with an offset). Returns @{ Date = 'yyyy-MM-dd'; Time = 'HH:mm' }.
function Split-Boundary([string]$Boundary) {
$m = [regex]::Match([string]$Boundary, '^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})')
if (-not $m.Success) { return @{ Date = $null; Time = $null } }
return @{ Date = $m.Groups[1].Value; Time = $m.Groups[2].Value }
}
# Days of the week as Task Scheduler's bitmask stores them. (A list of pairs, not a dictionary keyed by number: indexing a
# dictionary with an integer reads by position in some PowerShell types, which would silently pick the wrong day.)
$script:DayBits = @(
@{ Bit = 1; Name = 'Sun' }, @{ Bit = 2; Name = 'Mon' }, @{ Bit = 4; Name = 'Tue' }, @{ Bit = 8; Name = 'Wed' },
@{ Bit = 16; Name = 'Thu' }, @{ Bit = 32; Name = 'Fri' }, @{ Bit = 64; Name = 'Sat' }
)
# One trigger as a sentence, e.g. "Daily at 02:00, repeating every 15 min".
function Describe-Trigger($Trigger) {
$kind = [string]$Trigger.CimClass.CimClassName
$at = (Split-Boundary $Trigger.StartBoundary)
$time = $at.Time
$text = switch -Regex ($kind) {
'DailyTrigger$' {
$n = [int]$Trigger.DaysInterval
$when = if ($time) { " at $time" } else { '' }
if ($n -gt 1) { "Every $n days$when" } else { "Daily$when" }
}
'WeeklyTrigger$' {
$days = @()
foreach ($d in $script:DayBits) { if ([int]$Trigger.DaysOfWeek -band $d.Bit) { $days += $d.Name } }
$n = [int]$Trigger.WeeksInterval
$prefix = if ($n -gt 1) { "Every $n weeks" } else { 'Weekly' }
$on = if ($days.Count -gt 0) { ' on ' + ($days -join ', ') } else { '' }
$when = if ($time) { " at $time" } else { '' }
"$prefix$on$when"
}
'MonthlyDOWTrigger$' { $when = if ($time) { " at $time" } else { '' }; "Monthly (by weekday)$when" }
'MonthlyTrigger$' {
$dayNumbers = @()
for ($i = 0; $i -lt 31; $i++) { if ([int64]$Trigger.DaysOfMonth -band ([int64]1 -shl $i)) { $dayNumbers += ($i + 1) } }
$when = if ($time) { " at $time" } else { '' }
$on = if ($dayNumbers.Count -gt 0) { ' on day ' + ($dayNumbers -join ', ') } else { '' }
"Monthly$on$when"
}
'TimeTrigger$' { if ($at.Date) { "Once at $($at.Date) $time" } else { 'Once' } }
'BootTrigger$' { 'At startup' }
'LogonTrigger$' { 'At logon' }
'IdleTrigger$' { 'When idle' }
'EventTrigger$' { 'On an event' }
'SessionStateChangeTrigger$' { 'On session state change' }
'RegistrationTrigger$' { 'When the task is created' }
default { 'Custom trigger' }
}
$interval = $null
if ($Trigger.Repetition -and $Trigger.Repetition.Interval) { $interval = Convert-IsoDuration ([string]$Trigger.Repetition.Interval) }
if ($interval) { $text = "$text, repeating every $interval" }
return $text
}
function Describe-Triggers($Triggers) {
$list = @($Triggers | Where-Object { $_ })
if ($list.Count -eq 0) { return '(no trigger - run manually)' }
return (($list | ForEach-Object { Describe-Trigger $_ }) -join '; ')
}
function Describe-Actions($Actions) {
$parts = @()
foreach ($a in @($Actions | Where-Object { $_ })) {
$kind = [string]$a.CimClass.CimClassName
if ($kind -match 'ExecAction$' -or $a.Execute) {
$argText = if ($a.Arguments) { ' ' + $a.Arguments } else { '' }
$parts += ([string]$a.Execute + $argText).Trim()
} elseif ($a.ClassId) {
$parts += "COM handler $($a.ClassId)"
} elseif ($kind) {
$parts += ($kind -replace '^MSFT_Task', '')
}
}
return ($parts -join ' ; ')
}
# ---- collecting tasks -----------------------------------------------------------
function Get-ReportedTasks([bool]$IncludeMicrosoft) {
$out = @()
foreach ($task in @(Get-ScheduledTask)) {
if (-not $IncludeMicrosoft -and $task.TaskPath -like '\Microsoft\*') { continue }
$info = $null
try { $info = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath } catch { }
$entry = [ordered]@{
schedule_type = 'windows_task'
name = ('{0}{1}' -f $task.TaskPath, $task.TaskName)
command = Describe-Actions $task.Actions
schedule_expression = Describe-Triggers $task.Triggers
source = [string]$task.TaskPath
enabled = ($task.State -ne 'Disabled')
}
# Windows reports "never" as a date in 1999 (or year 1), not as an empty value.
if ($info -and $info.NextRunTime -and $info.NextRunTime.Year -gt 2000) {
$entry['next_run_at'] = $info.NextRunTime.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
}
$meta = [ordered]@{ state = [string]$task.State; run_as = [string]$task.Principal.UserId }
if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {
$meta['last_run_at'] = $info.LastRunTime.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
$meta['last_result'] = [int64]$info.LastTaskResult
}
$entry['metadata'] = $meta
$out += [pscustomobject]$entry
}
# No leading comma: callers wrap the call in @(...), and returning an array wrapped in another array would
# turn every task into one nested item.
return $out
}
# ---- collecting system info -----------------------------------------------------
function Get-Fqdn {
$name = [System.Net.Dns]::GetHostName()
try {
$cs = Get-CimInstance -ClassName Win32_ComputerSystem
if ($cs.PartOfDomain -and $cs.Domain -and ($name -notlike '*.*')) { return ("$name.$($cs.Domain)").ToLowerInvariant() }
} catch { }
return $name.ToLowerInvariant()
}
function Get-ListeningPorts {
$names = @{}
foreach ($p in Get-Process -ErrorAction SilentlyContinue) { $names[[int]$p.Id] = $p.ProcessName }
$processOf = { param($id) if ($id -eq 0 -or $id -eq 4) { 'System' } elseif ($names.ContainsKey([int]$id)) { $names[[int]$id] } else { '' } }
$clean = { param($addr) ([string]$addr) -replace '%.*$', '' } # drop an IPv6 zone id ("fe80::1%12")
$seen = @{}
$rows = @()
foreach ($c in @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue)) {
$row = [ordered]@{ protocol = 'tcp'; port = [int]$c.LocalPort; address = (& $clean $c.LocalAddress); process = (& $processOf $c.OwningProcess) }
$key = "tcp|$($row.port)|$($row.address)"
if (-not $seen.ContainsKey($key)) { $seen[$key] = 1; $rows += [pscustomobject]$row }
}
foreach ($u in @(Get-NetUDPEndpoint -ErrorAction SilentlyContinue)) {
$row = [ordered]@{ protocol = 'udp'; port = [int]$u.LocalPort; address = (& $clean $u.LocalAddress); process = (& $processOf $u.OwningProcess) }
$key = "udp|$($row.port)|$($row.address)"
if (-not $seen.ContainsKey($key)) { $seen[$key] = 1; $rows += [pscustomobject]$row }
}
return @($rows | Where-Object { $_.port -ge 1 -and $_.port -le 65535 } | Select-Object -First 2000)
}
function Get-SystemInfo {
$ips = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.AddressState -eq 'Preferred' } |
ForEach-Object { $_.IPAddress } | Select-Object -Unique)
$cpus = @(Get-CimInstance -ClassName Win32_Processor)
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$totalBytes = [int64]$os.TotalVisibleMemorySize * 1024
$freeBytes = [int64]$os.FreePhysicalMemory * 1024
# Unlike the Linux agent's load average, this is the processor's actual current load.
$load = ($cpus | Where-Object { $null -ne $_.LoadPercentage } | Measure-Object -Property LoadPercentage -Average).Average
$cores = ($cpus | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
$disks = @()
foreach ($d in @(Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3')) {
if (-not $d.Size) { continue } # an unformatted or unavailable volume
$disks += [pscustomobject][ordered]@{ mount = [string]$d.DeviceID; size_bytes = [int64]$d.Size; used_bytes = [int64]($d.Size - $d.FreeSpace) }
}
return [ordered]@{
ip_addresses = @($ips)
cpu = [ordered]@{ model = [string]($cpus[0].Name).Trim(); cores = [int]$cores; load_percent = $(if ($null -ne $load) { [math]::Round([double]$load, 1) } else { $null }) }
memory = [ordered]@{ total_bytes = $totalBytes; used_bytes = ($totalBytes - $freeBytes) }
disks = @($disks)
listening_ports = @(Get-ListeningPorts)
}
}
# ---- sending --------------------------------------------------------------------
function Send-Report($Config, [string]$Json) {
$body = [System.Text.Encoding]::UTF8.GetBytes($Json)
$params = @{
Uri = "$($Config.ApiUrl)/api/agent/report"
Method = 'Post'
Headers = @{ Authorization = "Bearer $($Config.ApiToken)" }
Body = $body
ContentType = 'application/json; charset=utf-8'
TimeoutSec = 60
}
if ($PSVersionTable.PSVersion.Major -ge 6) {
if ($Config.Insecure) { $params['SkipCertificateCheck'] = $true }
} else {
# Windows PowerShell 5.1: modern TLS is off by default, and there is no per-request switch for self-signed certificates.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
if ($Config.Insecure) { [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } }
$params['UseBasicParsing'] = $true
}
return Invoke-RestMethod @params
}
# ---- main -----------------------------------------------------------------------
# A plain one-line message on stderr: Write-Error would bury it in a stack trace on every manual run.
function Stop-Agent([string]$Message) {
[Console]::Error.WriteLine($Message)
exit 1
}
function Invoke-Agent {
$config = Get-AgentConfig
if (-not $config.ApiUrl -or -not $config.ApiToken) {
Stop-Agent "API_URL and API_TOKEN must be set (environment variables, or $script:DefaultConfigPath)."
}
$tasks = @()
try {
$tasks = @(Get-ReportedTasks $config.IncludeMicrosoftTasks)
} catch {
# Still worth reporting the hardware and ports if tasks can't be read.
Write-Warning "Collecting scheduled tasks failed: $($_.Exception.Message)"
}
# Hardware and network facts are best-effort, like the Linux agent: a quirk on one host must not cost the whole report.
$system = $null
try {
$system = Get-SystemInfo
} catch {
Write-Warning "Collecting hardware/network info failed - reporting tasks without it. ($($_.Exception.Message))"
}
$payload = [ordered]@{
hostname = Get-Fqdn
os_type = 'windows'
reported_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
system = $system
tasks = @($tasks)
}
$json = ConvertTo-Json -InputObject $payload -Depth 8 -Compress
if ($DryRun) {
ConvertTo-Json -InputObject $payload -Depth 8
return
}
try {
$null = Send-Report $config $json
} catch {
$detail = ''
try {
if ($_.Exception.Response) {
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$detail = ' ' + $reader.ReadToEnd()
}
} catch { }
Stop-Agent "Report failed: $($_.Exception.Message)$detail"
}
Write-Output "Reported $(@($tasks).Count) task(s) successfully."
}
# Dot-sourcing (for tests) defines the functions without running anything.
if ($MyInvocation.InvocationName -ne '.') { Invoke-Agent }
+46
View File
@@ -0,0 +1,46 @@
# Removes the Homelab Manager agent from this Windows machine: deletes its scheduled task, the installed
# script, and its credentials file.
#
# Run in an ELEVATED Windows PowerShell (Run as administrator):
#
# [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# iex ((New-Object Net.WebClient).DownloadString('https://homelab.example.lan/agent/windows/uninstall.ps1'))
#
# (With a self-signed certificate, also run
# [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
# first.)
#
# NOTE: keep this file plain ASCII (it is downloaded as text).
$ErrorActionPreference = 'Stop'
$script:TaskName = 'Homelab Manager Agent'
$script:InstallDir = Join-Path $env:ProgramData 'HomelabManager'
function Uninstall-Agent {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
if (-not ([Security.Principal.WindowsPrincipal]$identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'This uninstaller must be run as Administrator. Open PowerShell with "Run as administrator" and try again.'
}
Write-Output 'Removing Homelab Manager agent...'
if (Get-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue) {
Stop-ScheduledTask -TaskName $script:TaskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $script:TaskName -Confirm:$false
}
# Only the files this agent installed - never the folder wholesale, in case something else was put beside them.
foreach ($name in 'homelab-manager-agent.ps1', 'agent.json') {
$path = Join-Path $script:InstallDir $name
if (Test-Path -LiteralPath $path) { [System.IO.File]::Delete($path) }
}
if ((Test-Path -LiteralPath $script:InstallDir) -and -not (Get-ChildItem -LiteralPath $script:InstallDir -Force)) {
[System.IO.Directory]::Delete($script:InstallDir)
}
Write-Output 'Done. The agent no longer runs or reports from this machine.'
Write-Output 'Its entry (and task history) in Homelab Manager is untouched - delete it from the Servers page if you no longer want it tracked.'
}
Uninstall-Agent
+33
View File
@@ -0,0 +1,33 @@
# Builds the arm64 image from source (Raspberry Pi, Apple silicon, Ampere/Graviton, ...).
#
# Build and publish it, so docker-compose.arm64.yml can pull it on the arm64 machine:
# docker compose -f docker-compose.arm64.build.yml build
# docker compose -f docker-compose.arm64.build.yml push
#
# Or build and run it right here on an arm64 machine:
# docker compose -f docker-compose.arm64.build.yml up -d --build
#
# Building on an x86 machine works too, through QEMU emulation (slower). One-time setup:
# docker run --privileged --rm tonistiigi/binfmt --install arm64
# Needs Docker Compose v2 with buildx (bundled with current Docker Desktop and Docker Engine).
services:
homelab-manager:
build:
context: .
platforms:
- linux/arm64
platform: linux/arm64
# Same image name and tag docker-compose.arm64.yml pulls. Override with IMAGE_REPO / ARM64_TAG in .env or the shell.
image: ${IMAGE_REPO:-gitea.labsconnect.se/bobban/homelabmanager-homelab-manager}:${ARM64_TAG:-arm64}
container_name: homelab-manager
restart: unless-stopped
ports:
- "${HOST_PORT:-3000}:3000"
env_file:
- .env
environment:
PORT: 3000
NODE_ENV: production
volumes:
- ./data:/app/data
+24
View File
@@ -0,0 +1,24 @@
# Runs the published arm64 image from the registry — no build on this machine.
# (Publish it first with docker-compose.arm64.build.yml, or from CI.)
#
# docker compose -f docker-compose.arm64.yml pull
# docker compose -f docker-compose.arm64.yml up -d
#
# Update later with the same two commands.
services:
homelab-manager:
# Override with IMAGE_REPO / ARM64_TAG in .env or the shell — e.g. ARM64_TAG=1.4.0-arm64 to pin a release.
image: ${IMAGE_REPO:-gitea.labsconnect.se/bobban/homelabmanager-homelab-manager}:${ARM64_TAG:-arm64}
platform: linux/arm64
container_name: homelab-manager
restart: unless-stopped
ports:
- "${HOST_PORT:-3000}:3000"
env_file:
- .env
environment:
PORT: 3000
NODE_ENV: production
volumes:
- ./data:/app/data
+182
View File
@@ -1979,6 +1979,26 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/node-schedule": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@types/node-schedule/-/node-schedule-2.1.8.tgz",
"integrity": "sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.24",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz",
"integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2136,6 +2156,15 @@
"safer-buffer": "^2.1.0" "safer-buffer": "^2.1.0"
} }
}, },
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/bagpipe": { "node_modules/bagpipe": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/bagpipe/-/bagpipe-0.3.5.tgz", "resolved": "https://registry.npmjs.org/bagpipe/-/bagpipe-0.3.5.tgz",
@@ -2953,6 +2982,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -3111,6 +3149,12 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/is-typedarray": { "node_modules/is-typedarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
@@ -3214,6 +3258,18 @@
"@libsql/win32-x64-msvc": "0.4.7" "@libsql/win32-x64-msvc": "0.4.7"
} }
}, },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/long-timeout": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz",
"integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==",
"license": "MIT"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -3236,6 +3292,21 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lru.min": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz",
"integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/luxon": { "node_modules/luxon": {
"version": "3.7.2", "version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
@@ -3326,6 +3397,55 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": {
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.5.tgz",
"integrity": "sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.3",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.5.1"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.19", "version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
@@ -3402,6 +3522,42 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/node-schedule": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz",
"integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.2.0",
"long-timeout": "0.1.1",
"sorted-array-functions": "^1.3.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/node-schedule/node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"deprecated": "v4 is no longer maintained, upgrade to v5",
"license": "MIT",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/oauth4webapi": { "node_modules/oauth4webapi": {
"version": "3.8.8", "version": "3.8.8",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
@@ -3968,6 +4124,12 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sorted-array-functions": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz",
"integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==",
"license": "MIT"
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -3999,6 +4161,21 @@
"source-map": "^0.6.0" "source-map": "^0.6.0"
} }
}, },
"node_modules/sql-escaper": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.2.tgz",
"integrity": "sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -4825,6 +5002,9 @@
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^4.21.2", "express": "^4.21.2",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"mysql2": "^3.24.5",
"node-schedule": "^2.1.1",
"nodemailer": "^6.9.14",
"openid-client": "^6.1.7", "openid-client": "^6.1.7",
"session-file-store": "^1.5.0", "session-file-store": "^1.5.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
@@ -4834,6 +5014,8 @@
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/express-session": "^1.18.1", "@types/express-session": "^1.18.1",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/node-schedule": "^2.1.7",
"@types/nodemailer": "^6.4.17",
"@types/session-file-store": "^1.2.5", "@types/session-file-store": "^1.2.5",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
+11
View File
@@ -0,0 +1,11 @@
ALTER TABLE `servers` ADD `ip_addresses` text;--> statement-breakpoint
ALTER TABLE `servers` ADD `cpu_model` text;--> statement-breakpoint
ALTER TABLE `servers` ADD `cpu_cores` integer;--> statement-breakpoint
ALTER TABLE `servers` ADD `cpu_load_percent` real;--> statement-breakpoint
ALTER TABLE `servers` ADD `mem_total_bytes` integer;--> statement-breakpoint
ALTER TABLE `servers` ADD `mem_used_bytes` integer;--> statement-breakpoint
ALTER TABLE `servers` ADD `disks` text;--> statement-breakpoint
ALTER TABLE `servers` ADD `proxmox_integration_id` integer REFERENCES integrations(id);--> statement-breakpoint
ALTER TABLE `servers` ADD `proxmox_node` text;--> statement-breakpoint
ALTER TABLE `servers` ADD `proxmox_guest_type` text;--> statement-breakpoint
ALTER TABLE `servers` ADD `proxmox_vmid` integer;
@@ -0,0 +1 @@
ALTER TABLE `ipam_entries` ADD `source` text;
@@ -0,0 +1,9 @@
CREATE TABLE `diag_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`source` text NOT NULL,
`operation` text NOT NULL,
`ok` integer NOT NULL,
`latency_ms` integer NOT NULL,
`error` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE `server_links` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`server_id` integer NOT NULL,
`label` text NOT NULL,
`url` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `servers` ADD `hide_proxmox_link` integer DEFAULT false NOT NULL;
@@ -0,0 +1,6 @@
CREATE TABLE `notification_queue` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`title` text NOT NULL,
`message` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE `secrets` ADD `check_host` text;--> statement-breakpoint
ALTER TABLE `secrets` ADD `check_port` integer;--> statement-breakpoint
ALTER TABLE `secrets` ADD `last_checked_at` text;--> statement-breakpoint
ALTER TABLE `secrets` ADD `last_check_error` text;
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE `maintenance_windows` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`target_type` text NOT NULL,
`target_id` integer NOT NULL,
`reason` text,
`started_at` text NOT NULL,
`ends_at` text NOT NULL,
`created_by` text
);
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE `server_ports` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`server_id` integer NOT NULL,
`port` integer NOT NULL,
`protocol` text DEFAULT 'tcp' NOT NULL,
`label` text,
`comment` text,
`open` integer DEFAULT false NOT NULL,
`last_seen_open_at` text,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `server_ports_unique` ON `server_ports` (`server_id`,`port`,`protocol`);--> statement-breakpoint
ALTER TABLE `servers` ADD `listening_ports` text;--> statement-breakpoint
ALTER TABLE `servers` ADD `last_port_scan` text;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `servers` ADD `tags` text;
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `domains` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`origin` text DEFAULT 'manual' NOT NULL,
`expires_at` text,
`registrar` text,
`lookup_source` text,
`last_checked_at` text,
`last_checked_ok_at` text,
`last_check_error` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `domains_name_unique` ON `domains` (`name`);
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE `consistency_ignores` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`key` text NOT NULL,
`title` text NOT NULL,
`reason` text,
`created_by` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `consistency_ignores_key_unique` ON `consistency_ignores` (`key`);
@@ -0,0 +1,8 @@
CREATE TABLE `tag_definitions` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`color` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `tag_definitions_name_unique` ON `tag_definitions` (`name`);
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `port_forwards` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`label` text NOT NULL,
`external_port` integer NOT NULL,
`protocol` text DEFAULT 'tcp' NOT NULL,
`server_id` integer,
`destination` text,
`internal_port` integer,
`source` text,
`comment` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE set null
);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+98
View File
@@ -8,6 +8,104 @@
"when": 1789419241921, "when": 1789419241921,
"tag": "0000_colossal_violations", "tag": "0000_colossal_violations",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1789468464784,
"tag": "0001_yummy_luke_cage",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1789506601790,
"tag": "0002_motionless_lord_hawal",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1789673523898,
"tag": "0003_fantastic_randall",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1789677988329,
"tag": "0004_brown_gambit",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1789759852933,
"tag": "0005_bent_violations",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1790103102037,
"tag": "0006_aberrant_darkhawk",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1790372043652,
"tag": "0007_secret_madripoor",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1790381917814,
"tag": "0008_thin_boom_boom",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1790382571470,
"tag": "0009_sharp_magus",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1790384497980,
"tag": "0010_magenta_alice",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1790385846612,
"tag": "0011_strange_mongoose",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1790388232273,
"tag": "0012_cool_harry_osborn",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1790449897833,
"tag": "0013_sturdy_bloodstorm",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1790718056783,
"tag": "0014_empty_gabe_jones",
"breakpoints": true
} }
] ]
} }
+5
View File
@@ -16,6 +16,9 @@
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^4.21.2", "express": "^4.21.2",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"mysql2": "^3.24.5",
"node-schedule": "^2.1.1",
"nodemailer": "^6.9.14",
"openid-client": "^6.1.7", "openid-client": "^6.1.7",
"session-file-store": "^1.5.0", "session-file-store": "^1.5.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
@@ -25,6 +28,8 @@
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/express-session": "^1.18.1", "@types/express-session": "^1.18.1",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/node-schedule": "^2.1.7",
"@types/nodemailer": "^6.4.17",
"@types/session-file-store": "^1.2.5", "@types/session-file-store": "^1.2.5",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
+54 -2
View File
@@ -1,8 +1,12 @@
import { Router } from "express"; import { Router } from "express";
import * as client from "openid-client"; import * as client from "openid-client";
import { eq } from "drizzle-orm";
import { getOidcConfig } from "./oidc.js"; import { getOidcConfig } from "./oidc.js";
import { upsertUserFromLogin } from "./users.js"; import { upsertUserFromLogin } from "./users.js";
import { env } from "../env.js"; import { env } from "../env.js";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
import { recordAudit } from "../services/audit.js";
export const authRouter = Router(); export const authRouter = Router();
@@ -63,10 +67,38 @@ authRouter.get("/callback", async (req, res, next) => {
// fall back to ID token claims already captured above // fall back to ID token claims already captured above
} }
await upsertUserFromLogin({ sub: claims.sub, email, name }); const { user, created } = await upsertUserFromLogin({ sub: claims.sub, email, name });
// Access to the app is itself something worth being able to look back on: who got an account (and the very first
// one becomes admin), and every sign-in with where it came from.
if (created) {
await recordAudit({
actor: user,
category: "user",
action: "create",
targetType: "user",
targetId: user.id,
detail: { name: user.name ?? user.email ?? user.oidcSub, role: user.role, firstUser: user.role === "admin" },
});
}
await recordAudit({
actor: user,
category: "session",
action: "login",
targetType: "user",
targetId: user.id,
detail: { name: user.name ?? user.email ?? user.oidcSub, ip: req.ip },
});
delete req.session.pendingAuth; delete req.session.pendingAuth;
req.session.user = { sub: claims.sub, email, name, idToken: tokens.id_token }; req.session.user = {
sub: claims.sub,
email,
name,
idToken: tokens.id_token,
ip: req.ip,
userAgent: req.headers["user-agent"],
};
req.session.save((err) => { req.session.save((err) => {
if (err) return next(err); if (err) return next(err);
res.redirect("/"); res.redirect("/");
@@ -78,6 +110,26 @@ authRouter.get("/callback", async (req, res, next) => {
authRouter.get("/logout", async (req, res, next) => { authRouter.get("/logout", async (req, res, next) => {
const idToken = req.session.user?.idToken; const idToken = req.session.user?.idToken;
// Recorded first, and never allowed to get in the way of signing out.
if (req.session.user) {
try {
const [user] = await db.select().from(users).where(eq(users.oidcSub, req.session.user.sub)).limit(1);
if (user) {
await recordAudit({
actor: user,
category: "session",
action: "logout",
targetType: "user",
targetId: user.id,
detail: { name: user.name ?? user.email ?? user.oidcSub, ip: req.ip },
});
}
} catch (err) {
console.error("[auth] couldn't record sign-out:", err);
}
}
try { try {
const config = await getOidcConfig(); const config = await getOidcConfig();
let endSessionUrl: URL | undefined; let endSessionUrl: URL | undefined;
+3 -3
View File
@@ -11,7 +11,7 @@ export async function upsertUserFromLogin(params: {
sub: string; sub: string;
email?: string; email?: string;
name?: string; name?: string;
}) { }): Promise<{ user: typeof users.$inferSelect; created: boolean }> {
const [existing] = await db.select().from(users).where(eq(users.oidcSub, params.sub)).limit(1); const [existing] = await db.select().from(users).where(eq(users.oidcSub, params.sub)).limit(1);
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -25,7 +25,7 @@ export async function upsertUserFromLogin(params: {
}) })
.where(eq(users.id, existing.id)) .where(eq(users.id, existing.id))
.returning(); .returning();
return updated; return { user: updated, created: false };
} }
const anyUser = await db.select({ id: users.id }).from(users).limit(1); const anyUser = await db.select({ id: users.id }).from(users).limit(1);
@@ -41,5 +41,5 @@ export async function upsertUserFromLogin(params: {
lastLoginAt: now, lastLoginAt: now,
}) })
.returning(); .returning();
return created; return { user: created, created: true };
} }
+199 -2
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core"; import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";
// ─── Users & roles ────────────────────────────────────────────────────────── // ─── Users & roles ──────────────────────────────────────────────────────────
@@ -34,6 +34,46 @@ export const auditLog = sqliteTable("audit_log", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
// ─── Diagnostic log — every outbound call to a DNS provider or integration ──
export const diagLog = sqliteTable("diag_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
source: text("source").notNull(), // e.g. 'cloudflare' | 'tailscale' | 'proxmox' | ...
operation: text("operation").notNull(), // adapter method name, e.g. 'listZones' | 'listDevices'
ok: integer("ok", { mode: "boolean" }).notNull(),
latencyMs: integer("latency_ms").notNull(),
error: text("error"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Notification queue — pending notifications held during quiet hours ────
export const notificationQueue = sqliteTable("notification_queue", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
message: text("message").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Maintenance windows — alerts about a target are silenced until endsAt ──
export const maintenanceTargetTypes = ["server", "integration", "dns_provider"] as const;
export type MaintenanceTargetType = (typeof maintenanceTargetTypes)[number];
export const maintenanceWindows = sqliteTable("maintenance_windows", {
id: integer("id").primaryKey({ autoIncrement: true }),
targetType: text("target_type").$type<MaintenanceTargetType>().notNull(),
targetId: integer("target_id").notNull(), // polymorphic — no FK; a deleted target's window is simply ignored
reason: text("reason"),
startedAt: text("started_at").notNull(), // ISO
endsAt: text("ends_at").notNull(), // ISO — required: a forgotten open-ended window would silence real problems forever
createdBy: text("created_by"),
});
// ─── Settings (key/value) ─────────────────────────────────────────────────── // ─── Settings (key/value) ───────────────────────────────────────────────────
export const settings = sqliteTable("settings", { export const settings = sqliteTable("settings", {
@@ -57,6 +97,11 @@ export const secrets = sqliteTable("secrets", {
expiryDate: text("expiry_date").notNull(), // ISO date, e.g. 2026-03-01 expiryDate: text("expiry_date").notNull(), // ISO date, e.g. 2026-03-01
warnDays: integer("warn_days").notNull().default(30), warnDays: integer("warn_days").notNull().default(30),
notes: text("notes"), notes: text("notes"),
// ssl_certificate secrets only: when set, expiryDate is read from the live certificate on this host:port instead of typed in.
checkHost: text("check_host"),
checkPort: integer("check_port"),
lastCheckedAt: text("last_checked_at"),
lastCheckError: text("last_check_error"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
@@ -74,6 +119,7 @@ export const ipamEntries = sqliteTable("ipam_entries", {
vendor: text("vendor"), vendor: text("vendor"),
location: text("location"), location: text("location"),
notes: text("notes"), notes: text("notes"),
source: text("source"), // null = entered manually; "tailscale" = auto-synced from a Tailscale integration
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
@@ -150,6 +196,9 @@ export const dnsRecordsCache = sqliteTable("dns_records_cache", {
// ─── Servers & scheduled tasks (ported from Schedule Task Manager) ───────── // ─── Servers & scheduled tasks (ported from Schedule Task Manager) ─────────
export const proxmoxGuestTypes = ["qemu", "lxc"] as const;
export type ProxmoxGuestTypeCol = (typeof proxmoxGuestTypes)[number];
export const servers = sqliteTable("servers", { export const servers = sqliteTable("servers", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(), name: text("name").notNull(),
@@ -162,6 +211,35 @@ export const servers = sqliteTable("servers", {
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
lastSeenAt: text("last_seen_at"), lastSeenAt: text("last_seen_at"),
// Agent-reported hardware/network snapshot — updated on every agent report.
ipAddresses: text("ip_addresses"), // JSON string[]
cpuModel: text("cpu_model"),
cpuCores: integer("cpu_cores"),
cpuLoadPercent: real("cpu_load_percent"),
memTotalBytes: integer("mem_total_bytes"),
memUsedBytes: integer("mem_used_bytes"),
disks: text("disks"), // JSON string: {mount, sizeBytes, usedBytes}[]
listeningPorts: text("listening_ports"), // JSON string: {protocol, port, address, process}[] — what the agent sees bound on the host
lastPortScan: text("last_port_scan"), // JSON string: summary of the most recent network scan from this app
tags: text("tags"), // JSON string: string[] — free-form labels for grouping and filtering, normalized by services/serverTags
// Optional link to a Proxmox VM/LXC — set by an admin, not the agent. The "set null" below is what this file asks for,
// but migration 0001 created the column without it, so the database itself has no ON DELETE rule here: deleting an
// integration clears these columns in routes/integrations.ts instead. (See DATABASE.md.)
proxmoxIntegrationId: integer("proxmox_integration_id").references(() => integrations.id, {
onDelete: "set null",
}),
proxmoxNode: text("proxmox_node"),
proxmoxGuestType: text("proxmox_guest_type").$type<ProxmoxGuestTypeCol>(),
proxmoxVmid: integer("proxmox_vmid"),
// Not every server is a Proxmox guest (bare-metal boxes, other hosts) — an
// admin can hide the "Proxmox link" card on this server's detail page
// rather than seeing an irrelevant option on every server. Ignored (the
// card always shows) once a server IS actually linked, so unlinking stays
// reachable.
hideProxmoxLink: integer("hide_proxmox_link", { mode: "boolean" }).notNull().default(false),
}); });
export const scheduledTasks = sqliteTable("scheduled_tasks", { export const scheduledTasks = sqliteTable("scheduled_tasks", {
@@ -169,7 +247,7 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", {
serverId: integer("server_id") serverId: integer("server_id")
.notNull() .notNull()
.references(() => servers.id, { onDelete: "cascade" }), .references(() => servers.id, { onDelete: "cascade" }),
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | 'docker' | 'backup' | 'update' | 'n8n_workflow' | 'manual' scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | 'windows_task' | 'docker' | 'backup' | 'update' | 'n8n_workflow' | 'manual'
origin: text("origin").notNull().default("agent"), // 'agent' | 'manual' — manual rows are never touched by agent sync origin: text("origin").notNull().default("agent"), // 'agent' | 'manual' — manual rows are never touched by agent sync
name: text("name").notNull(), name: text("name").notNull(),
command: text("command"), command: text("command"),
@@ -187,6 +265,20 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", {
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
// ─── Server links — admin-page bookmarks per server (Dockge, Webmin, Cockpit, etc.) ─
export const serverLinks = sqliteTable("server_links", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
label: text("label").notNull(),
url: text("url").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Live integrations (Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand) ─ // ─── Live integrations (Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand) ─
export const integrationTypes = [ export const integrationTypes = [
@@ -196,6 +288,10 @@ export const integrationTypes = [
"tailscale", "tailscale",
"gitea", "gitea",
"dockhand", "dockhand",
"uptimekuma",
"phpipam",
"pbs",
"osticket",
] as const; ] as const;
export type IntegrationType = (typeof integrationTypes)[number]; export type IntegrationType = (typeof integrationTypes)[number];
@@ -213,3 +309,104 @@ export const integrations = sqliteTable("integrations", {
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
}); });
// A port on a server that's either been seen open (by a scan or the agent) or that someone wrote a note about.
// Rows exist only while they carry information: an open port, or one with a label/comment ("reserved").
export const serverPorts = sqliteTable(
"server_ports",
{
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
port: integer("port").notNull(),
protocol: text("protocol").$type<"tcp" | "udp">().notNull().default("tcp"),
label: text("label"),
comment: text("comment"),
// True when the last network scan connected to it. The agent's view is stored on the server row instead.
open: integer("open", { mode: "boolean" }).notNull().default(false),
lastSeenOpenAt: text("last_seen_open_at"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(t) => [uniqueIndex("server_ports_unique").on(t.serverId, t.port, t.protocol)],
);
// A manually-recorded port opening on something this app doesn't monitor directly — a router's port forward, an
// edge firewall rule, a cloud provider's security group, etc. Distinct from serverPorts (which is what a server
// itself, or a scan of it, reports): this is what someone tells the app is open further out on the network path,
// for the same reason people keep a spreadsheet of "what did I open on the router and why."
export const portForwards = sqliteTable("port_forwards", {
id: integer("id").primaryKey({ autoIncrement: true }),
label: text("label").notNull(),
externalPort: integer("external_port").notNull(),
protocol: text("protocol").$type<"tcp" | "udp">().notNull().default("tcp"),
// Optional link to a tracked server this forward points at; "destination" covers anything else (a bare IP,
// an untracked device) or extra detail alongside a linked server.
serverId: integer("server_id").references(() => servers.id, { onDelete: "set null" }),
destination: text("destination"),
// The port it's actually forwarded to, when NAT changes it (a router forwarding external 8443 to internal 443).
internalPort: integer("internal_port"),
// Free text: where this rule actually lives ("Home router", "OPNsense WAN rule", "Cloudflare Tunnel") — this
// app has no integration with any firewall/router, so it can't verify or manage the rule, only record it.
source: text("source"),
comment: text("comment"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Domain registrations ───────────────────────────────────────────────────
export const domainOrigins = ["manual", "zone"] as const;
export type DomainOrigin = (typeof domainOrigins)[number];
// A registered domain whose expiry we track. "zone" rows are created from the DNS zones already synced from the
// providers (and removed again when the zone goes away); "manual" rows were typed in — for domains whose DNS lives elsewhere.
export const domains = sqliteTable("domains", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(), // the registrable domain, lowercase ASCII
origin: text("origin").$type<DomainOrigin>().notNull().default("manual"),
expiresAt: text("expires_at"), // YYYY-MM-DD (UTC), as reported by the registry
registrar: text("registrar"),
lookupSource: text("lookup_source"), // "rdap" | "whois"
lastCheckedAt: text("last_checked_at"),
lastCheckedOkAt: text("last_checked_ok_at"),
lastCheckError: text("last_check_error"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Consistency report ─────────────────────────────────────────────────────
// Findings someone has looked at and decided are fine (a DNS record that intentionally points elsewhere, a Docker bridge
// address, ...). Keyed by the finding's stable key so it stays ignored across runs.
export const consistencyIgnores = sqliteTable("consistency_ignores", {
id: integer("id").primaryKey({ autoIncrement: true }),
key: text("key").notNull().unique(),
title: text("title").notNull(), // what the finding said when it was ignored, so the list still makes sense once it's gone
reason: text("reason"),
createdBy: text("created_by"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Tag catalogue ──────────────────────────────────────────────────────────
// Tags themselves live on the servers that carry them (servers.tags). A row here adds two things a server can't: a tag
// that exists before anything uses it (so it's offered when tagging), and a chosen colour. A defined tag is kept until an
// admin deletes it, even with no server using it; a tag with no row is simply one that's in use and has an automatic colour.
export const tagDefinitions = sqliteTable("tag_definitions", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(), // normalised, as in services/serverTags
color: text("color"), // "#rrggbb"; null = automatic
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
+2 -1
View File
@@ -9,6 +9,7 @@
* resource group to target for record operations. * resource group to target for record operations.
*/ */
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
import { withDiagLogging } from "../../services/diagLog.js";
const ARM_BASE = "https://management.azure.com"; const ARM_BASE = "https://management.azure.com";
const API_VERSION = "2018-05-01"; const API_VERSION = "2018-05-01";
@@ -326,5 +327,5 @@ export function createAzureAdapter(config: AzureConfig): DnsAdapter {
} }
} }
return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; return withDiagLogging("azure", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
} }
+2 -1
View File
@@ -3,6 +3,7 @@
* Requires config: apiToken * Requires config: apiToken
*/ */
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
import { withDiagLogging } from "../../services/diagLog.js";
const BASE = "https://api.cloudflare.com/client/v4"; const BASE = "https://api.cloudflare.com/client/v4";
@@ -94,5 +95,5 @@ export function createCloudflareAdapter(config: CloudflareConfig): DnsAdapter {
await cfFetch(`/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" }); await cfFetch(`/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" });
} }
return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; return withDiagLogging("cloudflare", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
} }
+2 -1
View File
@@ -8,6 +8,7 @@
import * as https from "node:https"; import * as https from "node:https";
import * as http from "node:http"; import * as http from "node:http";
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
import { withDiagLogging } from "../../services/diagLog.js";
export interface CpanelConfig { export interface CpanelConfig {
url: string; url: string;
@@ -273,5 +274,5 @@ export function createCpanelAdapter(config: CpanelConfig): DnsAdapter {
await api2("remove_zone_record", { domain, line: String(lineIndex) }); await api2("remove_zone_record", { domain, line: String(lineIndex) });
} }
return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; return withDiagLogging("cpanel", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
} }
+2 -1
View File
@@ -6,6 +6,7 @@
*/ */
import { parseStringPromise } from "xml2js"; import { parseStringPromise } from "xml2js";
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
import { withDiagLogging } from "../../services/diagLog.js";
const ENDPOINT = "https://api.loopia.se/RPCSERV"; const ENDPOINT = "https://api.loopia.se/RPCSERV";
@@ -179,5 +180,5 @@ export function createLoopiaAdapter(config: LoopiaConfig): DnsAdapter {
} }
} }
return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; return withDiagLogging("loopia", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
} }
+2 -1
View File
@@ -7,6 +7,7 @@
* treated as a single zone. Record types are limited to A, AAAA, CNAME. * treated as a single zone. Record types are limited to A, AAAA, CNAME.
*/ */
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
import { withDiagLogging } from "../../services/diagLog.js";
export interface PiholeConfig { export interface PiholeConfig {
url: string; url: string;
@@ -138,5 +139,5 @@ export function createPiholeAdapter(config: PiholeConfig): DnsAdapter {
} }
} }
return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; return withDiagLogging("pihole", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
} }
+2 -1
View File
@@ -6,6 +6,7 @@
* Record ID format: "type||name||content||priority" * Record ID format: "type||name||content||priority"
*/ */
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js"; import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
import { withDiagLogging } from "../../services/diagLog.js";
export interface TechnitiumConfig { export interface TechnitiumConfig {
url: string; url: string;
@@ -148,5 +149,5 @@ export function createTechnitiumAdapter(config: TechnitiumConfig): DnsAdapter {
await apiPost("/api/zones/records/delete", { domain: name, zone, type, ...typeParams }); await apiPost("/api/zones/records/delete", { domain: name, zone, type, ...typeParams });
} }
return { listZones, listRecords, addRecord, updateRecord, deleteRecord }; return withDiagLogging("technitium", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
} }
+48
View File
@@ -0,0 +1,48 @@
import { db } from "../db/client.js";
import { dnsProviders, dnsRecordsCache } from "../db/schema.js";
import { getDnsAdapterForProvider } from "./loadProvider.js";
export async function getDnsStats() {
const providers = await db.select().from(dnsProviders);
const enabledProviders = providers.filter((p) => p.enabled);
// Zone counts are live (matching what the DNS page itself shows) since
// dnsZonesCache only gets a row once a zone has been synced at least once
// and would otherwise undercount. One unreachable provider shouldn't blank
// the whole dashboard, so failures are caught per-provider.
const perProvider = await Promise.all(
enabledProviders.map(async (p) => {
try {
const found = await getDnsAdapterForProvider(p.id);
const zones = found ? await found.adapter.listZones() : [];
return { providerId: p.id, name: p.name, providerType: p.providerType, zoneCount: zones.length, error: null as string | null };
} catch (err) {
return {
providerId: p.id,
name: p.name,
providerType: p.providerType,
zoneCount: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}),
);
// Records, unlike zones, are only ever shown from the local cache elsewhere
// in this app (never fetched live per-zone), so the breakdown here matches
// that same "as of last sync" scope rather than hitting every zone's API.
const recordRows = await db.select({ type: dnsRecordsCache.type }).from(dnsRecordsCache);
const recordsByType = new Map<string, number>();
for (const r of recordRows) recordsByType.set(r.type, (recordsByType.get(r.type) ?? 0) + 1);
return {
totalProviders: providers.length,
enabledProviders: enabledProviders.length,
totalZones: perProvider.reduce((sum, p) => sum + p.zoneCount, 0),
perProvider,
totalRecords: recordRows.length,
recordsByType: [...recordsByType.entries()]
.map(([type, count]) => ({ type, count }))
.sort((a, b) => b.count - a.count),
};
}
+2 -5
View File
@@ -11,10 +11,6 @@ export const env = {
clientId: process.env.AUTHENTIK_CLIENT_ID ?? "", clientId: process.env.AUTHENTIK_CLIENT_ID ?? "",
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET ?? "", clientSecret: process.env.AUTHENTIK_CLIENT_SECRET ?? "",
}, },
gotify: {
url: process.env.GOTIFY_URL ?? "",
token: process.env.GOTIFY_TOKEN ?? "",
},
get authEnabled() { get authEnabled() {
return Boolean(this.authentik.issuerUrl && this.authentik.clientId && this.authentik.clientSecret); return Boolean(this.authentik.issuerUrl && this.authentik.clientId && this.authentik.clientSecret);
}, },
@@ -33,7 +29,8 @@ export function warnIfAuthNotConfigured() {
if (!env.credentialsEncryptionEnabled) { if (!env.credentialsEncryptionEnabled) {
console.warn( console.warn(
"CREDENTIALS_ENCRYPTION_KEY is not set to a 64-character hex string. " + "CREDENTIALS_ENCRYPTION_KEY is not set to a 64-character hex string. " +
"Saving integration credentials (Proxmox/Synology/etc API tokens) will fail until it is configured.", "Saving integration credentials (Proxmox/Synology/etc API tokens) will fail until it is configured, and the " +
"notification channels' credentials (Gotify/ntfy tokens, SMTP password, webhook secret) are stored unencrypted.",
); );
} }
} }
+45
View File
@@ -8,10 +8,12 @@ import { mkdirSync, existsSync } from "node:fs";
import { env, warnIfAuthNotConfigured } from "./env.js"; import { env, warnIfAuthNotConfigured } from "./env.js";
import { resolveDataPath } from "./paths.js"; import { resolveDataPath } from "./paths.js";
import { runMigrations } from "./db/migrate.js"; import { runMigrations } from "./db/migrate.js";
import { encryptStoredSettingsSecrets } from "./services/settingsStore.js";
import { authRouter } from "./auth/router.js"; import { authRouter } from "./auth/router.js";
import { meRouter } from "./routes/me.js"; import { meRouter } from "./routes/me.js";
import { usersRouter } from "./routes/users.js"; import { usersRouter } from "./routes/users.js";
import { auditLogRouter } from "./routes/auditLog.js"; import { auditLogRouter } from "./routes/auditLog.js";
import { diagLogRouter } from "./routes/diagLog.js";
import { secretsRouter } from "./routes/secrets.js"; import { secretsRouter } from "./routes/secrets.js";
import { ipamRouter } from "./routes/ipam.js"; import { ipamRouter } from "./routes/ipam.js";
import { dnsRouter } from "./routes/dns.js"; import { dnsRouter } from "./routes/dns.js";
@@ -19,9 +21,40 @@ import { serversRouter } from "./routes/servers.js";
import { tasksRouter } from "./routes/tasks.js"; import { tasksRouter } from "./routes/tasks.js";
import { agentReportRouter } from "./routes/agentReport.js"; import { agentReportRouter } from "./routes/agentReport.js";
import { integrationsRouter } from "./routes/integrations.js"; import { integrationsRouter } from "./routes/integrations.js";
import { settingsRouter } from "./routes/settings.js";
import { searchRouter } from "./routes/search.js";
import { sessionsRouter } from "./routes/sessions.js";
import { maintenanceRouter } from "./routes/maintenance.js";
import { domainsRouter } from "./routes/domains.js";
import { consistencyRouter } from "./routes/consistency.js";
import { privacyRouter } from "./routes/privacy.js";
import { tagsRouter } from "./routes/tags.js";
import { portsRouter } from "./routes/ports.js";
import { alertsRouter } from "./routes/alerts.js";
import { generatorRouter } from "./routes/generator.js";
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
import { initTailscaleKeyExpiryScheduler } from "./services/tailscaleKeyExpiryScheduler.js";
import { initLogRetentionScheduler } from "./services/logRetentionScheduler.js";
import { initDockerUpdateScheduler } from "./services/dockerUpdateScheduler.js";
import { initProxmoxBackupScheduler } from "./services/proxmoxBackupScheduler.js";
import { initPbsVerificationScheduler } from "./services/pbsVerificationScheduler.js";
import { initQuietHoursScheduler } from "./services/quietHoursScheduler.js";
import { initHealthScheduler } from "./services/healthScheduler.js";
warnIfAuthNotConfigured(); warnIfAuthNotConfigured();
await runMigrations(); await runMigrations();
{
const converted = await encryptStoredSettingsSecrets();
if (converted > 0) console.log(`Encrypted the stored credentials of ${converted} notification channel${converted === 1 ? "" : "s"}.`);
}
await initSecretExpiryScheduler();
await initTailscaleKeyExpiryScheduler();
await initLogRetentionScheduler();
await initDockerUpdateScheduler();
await initProxmoxBackupScheduler();
await initPbsVerificationScheduler();
await initQuietHoursScheduler();
await initHealthScheduler();
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const webDist = join(__dirname, "..", "..", "web", "dist"); const webDist = join(__dirname, "..", "..", "web", "dist");
@@ -61,6 +94,7 @@ app.use("/auth", authRouter);
app.use("/api/me", meRouter); app.use("/api/me", meRouter);
app.use("/api/users", usersRouter); app.use("/api/users", usersRouter);
app.use("/api/audit-log", auditLogRouter); app.use("/api/audit-log", auditLogRouter);
app.use("/api/diag-log", diagLogRouter);
app.use("/api/secrets", secretsRouter); app.use("/api/secrets", secretsRouter);
app.use("/api/ipam", ipamRouter); app.use("/api/ipam", ipamRouter);
app.use("/api/dns", dnsRouter); app.use("/api/dns", dnsRouter);
@@ -68,6 +102,17 @@ app.use("/api/servers", serversRouter);
app.use("/api/tasks", tasksRouter); app.use("/api/tasks", tasksRouter);
app.use("/api/agent/report", agentReportRouter); app.use("/api/agent/report", agentReportRouter);
app.use("/api/integrations", integrationsRouter); app.use("/api/integrations", integrationsRouter);
app.use("/api/settings", settingsRouter);
app.use("/api/search", searchRouter);
app.use("/api/sessions", sessionsRouter);
app.use("/api/maintenance", maintenanceRouter);
app.use("/api/domains", domainsRouter);
app.use("/api/consistency", consistencyRouter);
app.use("/api/privacy", privacyRouter);
app.use("/api/tags", tagsRouter);
app.use("/api/ports", portsRouter);
app.use("/api/alerts", alertsRouter);
app.use("/api/generator", generatorRouter);
if (existsSync(webDist)) { if (existsSync(webDist)) {
app.use(express.static(webDist)); app.use(express.static(webDist));
+50 -11
View File
@@ -9,6 +9,7 @@
* API reference verified against Dockhand's published OpenAPI spec * API reference verified against Dockhand's published OpenAPI spec
* (https://github.com/strausmann/mcp-dockhand/blob/main/docs/dockhand-openapi.json). * (https://github.com/strausmann/mcp-dockhand/blob/main/docs/dockhand-openapi.json).
*/ */
import { withDiagLogging } from "../../services/diagLog.js";
export interface DockhandConfig { export interface DockhandConfig {
url: string; url: string;
@@ -29,6 +30,10 @@ export interface DockhandContainer {
status: string; // human string, e.g. "Up 2 hours (healthy)" status: string; // human string, e.g. "Up 2 hours (healthy)"
environmentId: number; environmentId: number;
environmentName: string; environmentName: string;
/** null = this container has never been checked for updates. */
updateAvailable: boolean | null;
newerVersion: string | null;
checkedAt: string | null;
} }
export interface DockhandAdapter { export interface DockhandAdapter {
@@ -37,6 +42,8 @@ export interface DockhandAdapter {
startContainer(environmentId: number, containerId: string): Promise<void>; startContainer(environmentId: number, containerId: string): Promise<void>;
stopContainer(environmentId: number, containerId: string): Promise<void>; stopContainer(environmentId: number, containerId: string): Promise<void>;
restartContainer(environmentId: number, containerId: string): Promise<void>; restartContainer(environmentId: number, containerId: string): Promise<void>;
/** Triggers a fresh image-update check across every environment. Can take a while — one registry lookup per container. */
checkForUpdates(): Promise<{ total: number; updatesFound: number }>;
} }
export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter { export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter {
@@ -81,16 +88,29 @@ export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter {
const perEnv = await Promise.all( const perEnv = await Promise.all(
environments.map(async (env) => { environments.map(async (env) => {
try { try {
const data = await api("GET", `/api/containers?env=${env.id}&all=true`); const [data, pending] = await Promise.all([
return (Array.isArray(data) ? data : []).map((c: any) => ({ api("GET", `/api/containers?env=${env.id}&all=true`),
id: c.id, // Cached read (no fresh registry hit) — a not-yet-checked environment
name: c.name, // shouldn't fail the whole container list, so it just leaves every
image: c.image, // container's update status as "never checked" (null).
state: c.state, api("GET", `/api/containers/pending-updates?env=${env.id}`).catch(() => null),
status: c.status, ]);
environmentId: env.id, const pendingById = new Map<string, any>((pending?.pendingUpdates ?? []).map((p: any) => [p.containerId, p]));
environmentName: env.name, return (Array.isArray(data) ? data : []).map((c: any) => {
})); const record = pendingById.get(c.id);
return {
id: c.id,
name: c.name,
image: c.image,
state: c.state,
status: c.status,
environmentId: env.id,
environmentName: env.name,
updateAvailable: record ? !!record.hasImageUpdate : null,
newerVersion: record?.newerVersion ?? null,
checkedAt: record?.checkedAt ?? null,
};
});
} catch { } catch {
// one unreachable host shouldn't take down the whole dashboard view // one unreachable host shouldn't take down the whole dashboard view
return []; return [];
@@ -100,6 +120,25 @@ export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter {
return perEnv.flat(); return perEnv.flat();
} }
async function checkForUpdates(): Promise<{ total: number; updatesFound: number }> {
const environments = await listEnvironments();
const results = await Promise.all(
environments.map(async (env) => {
try {
const data = await api("POST", `/api/containers/check-updates?env=${env.id}`);
return { total: data?.total ?? 0, updatesFound: data?.updatesFound ?? 0 };
} catch {
// one unreachable host shouldn't abort checking the others
return { total: 0, updatesFound: 0 };
}
}),
);
return results.reduce((acc, r) => ({ total: acc.total + r.total, updatesFound: acc.updatesFound + r.updatesFound }), {
total: 0,
updatesFound: 0,
});
}
async function startContainer(environmentId: number, containerId: string): Promise<void> { async function startContainer(environmentId: number, containerId: string): Promise<void> {
await api("POST", `/api/containers/${encodeURIComponent(containerId)}/start?env=${environmentId}`); await api("POST", `/api/containers/${encodeURIComponent(containerId)}/start?env=${environmentId}`);
} }
@@ -122,5 +161,5 @@ export function createDockhandAdapter(config: DockhandConfig): DockhandAdapter {
} }
} }
return { ping, listContainers, startContainer, stopContainer, restartContainer }; return withDiagLogging("dockhand", { ping, listContainers, startContainer, stopContainer, restartContainer, checkForUpdates });
} }
+38 -1
View File
@@ -39,6 +39,43 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
{ key: "password", label: "Password", secret: true, type: "password" }, { key: "password", label: "Password", secret: true, type: "password" },
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" }, { key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
], ],
uptimekuma: [
{ key: "url", label: "Uptime Kuma URL", secret: false, placeholder: "https://kuma.example.lan" },
{
key: "username",
label: "Username (leave blank when using an API key)",
secret: false,
optional: true,
placeholder: "only needed on very old installs without API keys",
},
{ key: "password", label: "API key (or password, on old installs)", secret: true, type: "password" },
],
phpipam: [
{ key: "url", label: "phpIPAM URL", secret: false, placeholder: "https://ipam.example.lan" },
{ key: "appId", label: "API app ID", secret: false, placeholder: "as set under Administration → API" },
{ key: "token", label: "App token (API code)", secret: true, type: "password" },
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
],
pbs: [
{ key: "url", label: "Proxmox Backup Server URL", secret: false, placeholder: "https://pbs.example.lan:8007" },
{ key: "tokenId", label: "API token ID", secret: false, placeholder: "root@pam!homelab-manager" },
{ key: "tokenSecret", label: "API token secret", secret: true, type: "password" },
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
],
osticket: [
{ key: "host", label: "Database host", secret: false, placeholder: "osticket-db.example.lan" },
{ key: "port", label: "Database port", secret: false, optional: true, placeholder: "3306" },
{ key: "database", label: "Database name", secret: false, placeholder: "osticket" },
{ key: "username", label: "Database username", secret: false, placeholder: "read-only user" },
{ key: "password", label: "Database password", secret: true, type: "password" },
{
key: "tablePrefix",
label: "Table prefix",
secret: false,
optional: true,
placeholder: "ost_ (osTicket's default, unless changed at install)",
},
],
}; };
/** Fixed base URL per integration type, stored on the row for display/reference. */ /** Fixed base URL per integration type, stored on the row for display/reference. */
@@ -81,7 +118,7 @@ export function validateIntegrationConfig(
): string[] { ): string[] {
const fields = INTEGRATION_FIELDS[type] ?? []; const fields = INTEGRATION_FIELDS[type] ?? [];
return fields return fields
.filter((f) => f.type !== "checkbox") .filter((f) => f.type !== "checkbox" && !f.optional)
.filter((f) => merged[f.key] === undefined || merged[f.key] === "") .filter((f) => merged[f.key] === undefined || merged[f.key] === "")
.map((f) => f.key); .map((f) => f.key);
} }
+2 -1
View File
@@ -5,6 +5,7 @@
* API docs: https://gitea.labsconnect.se/api/swagger (or any instance's /api/swagger) * API docs: https://gitea.labsconnect.se/api/swagger (or any instance's /api/swagger)
* Verified against Gitea 1.27. * Verified against Gitea 1.27.
*/ */
import { withDiagLogging } from "../../services/diagLog.js";
export interface GiteaConfig { export interface GiteaConfig {
url: string; url: string;
@@ -150,5 +151,5 @@ export function createGiteaAdapter(config: GiteaConfig): GiteaAdapter {
} }
} }
return { ping, listReposWithStatus, rerunFailedJobs }; return withDiagLogging("gitea", { ping, listReposWithStatus, rerunFailedJobs });
} }
+162
View File
@@ -0,0 +1,162 @@
/**
* osTicket adapter — reads directly from osTicket's own MySQL/MariaDB database.
* Requires config: host, database, username, password; optional: port (default 3306), tablePrefix
* (default "ost_", configurable at osTicket install time), insecure (skip TLS cert verification,
* only meaningful if the DB itself is reached over TLS).
*
* osTicket's own REST API only supports *creating* tickets (POST /api/tickets.json) — there is no
* official endpoint to list or read existing ones (confirmed against osTicket's own developer
* docs). Listing tickets therefore means reading the database directly with a read-only user, the
* same way osTicket's own admin panel does internally. This is the only integration in this app
* that isn't a REST API for that reason.
*
* Ticket status names are fully customizable per install ("Open" might be renamed), but every
* status maps to a fixed `state` column of either "open" or "closed" — filtering on `state` stays
* correct regardless of what the admin renamed things to.
*
* Subject and priority aren't columns on the ticket table itself — osTicket normalizes them into
* its dynamic custom-fields system. `ost_ticket__cdata` is a denormalized cache of exactly those
* two fields that osTicket's own admin panel reads from for ticket lists (faster than joining the
* generic form-fields tables), but by osTicket's own design it's a regenerated cache tied to the
* "Ticket Details" form — GitHub issues on the osTicket repo document it occasionally going stale
* or briefly missing after a form change. It's LEFT JOINed here (not required) so a ticket with no
* matching cdata row still shows up, just with an empty subject/priority rather than being dropped.
*/
import mysql from "mysql2/promise";
import { withDiagLogging } from "../../services/diagLog.js";
export interface OsTicketConfig {
host: string;
port?: string;
database: string;
username: string;
password: string;
tablePrefix?: string;
}
export interface OsTicketTicket {
ticketId: number;
number: string;
subject: string | null;
statusName: string;
priorityName: string | null;
priorityColor: string | null;
departmentName: string | null;
staffName: string | null;
teamName: string | null;
requesterName: string | null;
requesterEmail: string | null;
source: string | null;
isOverdue: boolean;
isAnswered: boolean;
createdAt: string;
lastActivityAt: string | null;
dueAt: string | null;
}
export interface OsTicketAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listOpenTickets(): Promise<OsTicketTicket[]>;
}
function toIso(value: unknown): string | null {
if (value instanceof Date) return value.toISOString();
return null;
}
// The prefix is spliced directly into table names below (MySQL has no way to parameterize an
// identifier), so it's restricted to what a real identifier can contain rather than trusted as-is.
const SAFE_PREFIX = /^[A-Za-z0-9_]*$/;
export function createOsTicketAdapter(config: OsTicketConfig): OsTicketAdapter {
const prefix = config.tablePrefix?.trim() || "ost_";
if (!SAFE_PREFIX.test(prefix)) {
throw new Error("Table prefix may only contain letters, numbers, and underscores");
}
const port = Number(config.port) || 3306;
async function withConnection<T>(fn: (conn: mysql.Connection) => Promise<T>): Promise<T> {
const conn = await mysql.createConnection({
host: config.host,
port,
database: config.database,
user: config.username,
password: config.password,
connectTimeout: 10_000,
});
try {
return await fn(conn);
} finally {
await conn.end().catch(() => {});
}
}
async function listOpenTickets(): Promise<OsTicketTicket[]> {
const sql = `
SELECT
t.ticket_id AS ticketId,
t.number AS number,
cdata.subject AS subject,
ts.name AS statusName,
tp.priority AS priorityName,
tp.priority_color AS priorityColor,
d.name AS departmentName,
CASE WHEN s.staff_id IS NOT NULL THEN TRIM(CONCAT(s.firstname, ' ', s.lastname)) ELSE NULL END AS staffName,
tm.name AS teamName,
u.name AS requesterName,
ue.address AS requesterEmail,
t.source AS source,
t.isoverdue AS isOverdue,
t.isanswered AS isAnswered,
t.created AS createdAt,
t.lastupdate AS lastActivityAt,
t.duedate AS dueAt
FROM ${prefix}ticket t
JOIN ${prefix}ticket_status ts ON ts.id = t.status_id
LEFT JOIN ${prefix}ticket__cdata cdata ON cdata.ticket_id = t.ticket_id
LEFT JOIN ${prefix}ticket_priority tp ON tp.priority_id = cdata.priority
LEFT JOIN ${prefix}department d ON d.id = t.dept_id
LEFT JOIN ${prefix}staff s ON s.staff_id = t.staff_id
LEFT JOIN ${prefix}team tm ON tm.team_id = t.team_id
LEFT JOIN ${prefix}user u ON u.id = t.user_id
LEFT JOIN ${prefix}user_email ue ON ue.id = t.user_email_id
WHERE ts.state = 'open'
ORDER BY t.isoverdue DESC, t.created ASC
`;
return withConnection(async (conn) => {
const [rows] = await conn.query<mysql.RowDataPacket[]>(sql);
return rows.map((row) => ({
ticketId: Number(row.ticketId),
number: String(row.number),
subject: row.subject ?? null,
statusName: String(row.statusName),
priorityName: row.priorityName ?? null,
priorityColor: row.priorityColor ?? null,
departmentName: row.departmentName ?? null,
staffName: row.staffName || null,
teamName: row.teamName ?? null,
requesterName: row.requesterName ?? null,
requesterEmail: row.requesterEmail ?? null,
source: row.source ?? null,
isOverdue: Boolean(row.isOverdue),
isAnswered: Boolean(row.isAnswered),
createdAt: toIso(row.createdAt) ?? new Date(0).toISOString(),
lastActivityAt: toIso(row.lastActivityAt),
dueAt: toIso(row.dueAt),
}));
});
}
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
await withConnection((conn) => conn.query("SELECT 1"));
return { ok: true, latencyMs: Date.now() - start };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
return withDiagLogging("osticket", { ping, listOpenTickets });
}
+239
View File
@@ -0,0 +1,239 @@
/**
* Proxmox Backup Server adapter — uses PBS's REST API (api2/json), the same overall shape as
* Proxmox VE's (both are built on the same Rust API framework), but a distinct product with its
* own auth scheme and endpoints. Requires config: url, tokenId, tokenSecret; optional: insecure
*
* Auth: `Authorization: PBSAPIToken=<tokenId>:<tokenSecret>` — note the colon, not the `=` PVE
* uses between the id and the secret; the id itself is the same shape either product uses
* ("user@realm!tokenname"). See https://pbs.proxmox.com/docs/user-management.html.
*
* PBS's dashboard/API is HTTPS-only (default port 8007) and, like Proxmox VE, commonly runs with
* a self-signed certificate in a homelab — hence the same "insecure" opt-out via node:https.
*
* There is no single "is this backup okay" flag anywhere in Proxmox VE — vzdump only reports that
* the push to the datastore finished, never whether the stored data still verifies. This adapter
* reads that directly from PBS: GET /admin/datastore lists the configured datastores, GET
* /admin/datastore/{store}/status gives its usage, and GET /admin/datastore/{store}/snapshots
* lists every stored backup with its own verification state — read from the snapshot data
* itself rather than by trying to correlate verify-job schedules with task-log entries, since the
* snapshot's own state is the ground truth and doesn't depend on guessing a task "worker type"
* string. GET /nodes/localhost/status gives the server's own CPU/RAM/disk — PBS is a single
* node, and "localhost" is the documented way to address it without needing its real hostname.
*
* Endpoints, the token header format, and the datastore/snapshot field names are cross-checked
* against PBS's own published documentation and API-derived community write-ups, but this has
* not been run against a live instance. Every field is read defensively (optional, independently
* type-checked), so a field PBS renames or omits in some version leaves that value blank rather
* than breaking the whole read.
*/
import * as https from "node:https";
import { withDiagLogging } from "../../services/diagLog.js";
export interface PbsConfig {
url: string;
tokenId: string;
tokenSecret: string;
insecure?: boolean;
}
export interface PbsFailedSnapshot {
backupType: string; // "vm" | "ct" | "host"
backupId: string;
/** Unix seconds. */
backupTime: number;
}
export interface PbsDatastore {
name: string;
comment: string | null;
totalBytes: number | null;
usedBytes: number | null;
availBytes: number | null;
/** null when the datastore couldn't be read at all (e.g. this token lacks Datastore.Audit on it) — distinct from "0 snapshots". */
error: string | null;
snapshotCount: number;
/** Verified and found bad. */
failedCount: number;
/** Present in the datastore but never checked by a verify job. */
unverifiedCount: number;
/** Newest snapshot across the whole datastore, if any (unix seconds). */
latestSnapshotAt: number | null;
/** Up to 20 of the most recent verification failures, newest first. */
recentFailures: PbsFailedSnapshot[];
}
export interface PbsNodeStatus {
cpuUsagePercent: number | null;
cpuCores: number | null;
memTotalBytes: number | null;
memUsedBytes: number | null;
rootfsTotalBytes: number | null;
rootfsUsedBytes: number | null;
uptime: number | null;
}
export interface PbsAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listDatastores(): Promise<PbsDatastore[]>;
getNodeStatus(): Promise<PbsNodeStatus>;
}
interface RawResponse {
status: number;
text: () => string;
}
function request(url: string, insecure: boolean, headers: Record<string, string>): Promise<RawResponse> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const req = https.request(
{
hostname: parsed.hostname,
port: parsed.port || 8007,
path: parsed.pathname + parsed.search,
method: "GET",
headers,
rejectUnauthorized: !insecure,
},
(res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body }));
},
);
req.on("error", reject);
req.end();
});
}
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
const str = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null);
export function createPbsAdapter(config: PbsConfig): PbsAdapter {
const insecure = config.insecure === true;
function base() {
return config.url.replace(/\/$/, "");
}
function headers() {
return { Authorization: `PBSAPIToken=${config.tokenId}:${config.tokenSecret}`, Accept: "application/json" };
}
async function api(path: string): Promise<any> {
const res = await request(`${base()}/api2/json${path}`, insecure, headers());
let data: any = null;
try {
data = res.text() ? JSON.parse(res.text()) : null;
} catch {
// non-JSON error page
}
if (res.status < 200 || res.status >= 300) {
const message = data?.errors ? JSON.stringify(data.errors) : data?.message;
throw new Error(message || `Proxmox Backup Server API error: HTTP ${res.status}`);
}
return data?.data;
}
async function listDatastoreNames(): Promise<{ name: string; comment: string | null }[]> {
const data = await api("/admin/datastore");
return (Array.isArray(data) ? data : [])
.map((d: any) => ({ name: str(d?.name ?? d?.store), comment: str(d?.comment) }))
.filter((d: { name: string | null }): d is { name: string; comment: string | null } => d.name !== null);
}
async function getDatastoreStatus(name: string): Promise<{ total: number | null; used: number | null; avail: number | null }> {
const data = await api(`/admin/datastore/${encodeURIComponent(name)}/status`);
return { total: num(data?.total), used: num(data?.used), avail: num(data?.avail) };
}
async function getSnapshotSummary(name: string) {
const data = await api(`/admin/datastore/${encodeURIComponent(name)}/snapshots`);
const snapshots = Array.isArray(data) ? data : [];
let failedCount = 0;
let unverifiedCount = 0;
let latestSnapshotAt: number | null = null;
const failures: PbsFailedSnapshot[] = [];
for (const s of snapshots) {
const backupTime = num(s?.["backup-time"]);
if (backupTime !== null && (latestSnapshotAt === null || backupTime > latestSnapshotAt)) latestSnapshotAt = backupTime;
const state = str(s?.verification?.state)?.toLowerCase() ?? null;
if (state === "failed") {
failedCount++;
const backupType = str(s?.["backup-type"]);
const backupId = str(s?.["backup-id"]);
if (backupType && backupId && backupTime !== null) failures.push({ backupType, backupId, backupTime });
} else if (state === null) {
unverifiedCount++;
}
}
failures.sort((a, b) => b.backupTime - a.backupTime);
return { snapshotCount: snapshots.length, failedCount, unverifiedCount, latestSnapshotAt, recentFailures: failures.slice(0, 20) };
}
async function listDatastores(): Promise<PbsDatastore[]> {
const names = await listDatastoreNames();
return Promise.all(
names.map(async ({ name, comment }): Promise<PbsDatastore> => {
try {
const [status, snapshots] = await Promise.all([getDatastoreStatus(name), getSnapshotSummary(name)]);
return {
name,
comment,
totalBytes: status.total,
usedBytes: status.used,
availBytes: status.avail,
error: null,
...snapshots,
};
} catch (err) {
return {
name,
comment,
totalBytes: null,
usedBytes: null,
availBytes: null,
error: err instanceof Error ? err.message : String(err),
snapshotCount: 0,
failedCount: 0,
unverifiedCount: 0,
latestSnapshotAt: null,
recentFailures: [],
};
}
}),
);
}
async function getNodeStatus(): Promise<PbsNodeStatus> {
const data = await api("/nodes/localhost/status");
return {
cpuUsagePercent: num(data?.cpu) !== null ? num(data.cpu)! * 100 : null,
cpuCores: num(data?.cpuinfo?.cpus),
memTotalBytes: num(data?.memory?.total),
memUsedBytes: num(data?.memory?.used),
rootfsTotalBytes: num(data?.root?.total),
rootfsUsedBytes: num(data?.root?.used),
uptime: num(data?.uptime),
};
}
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
await api("/admin/datastore");
return { ok: true, latencyMs: Date.now() - start };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
return withDiagLogging("pbs", { ping, listDatastores, getNodeStatus });
}
+166
View File
@@ -0,0 +1,166 @@
/**
* phpIPAM adapter.
* Requires config: url, appId, token; optional: insecure
*
* Auth: phpIPAM's REST API lives at `<url>/api/<appId>/...` and is enabled per "API app" under
* Administration -> API. This adapter expects that app's security method set to **"SSL with App
* token"** (or, on a LAN-only install, "App token" without SSL) — a static code shown once when
* the app is created, sent on every request as the `token` header. That's the simplest of
* phpIPAM's auth methods (no separate login call, no token expiry to renew), so it's the only one
* this adapter implements; the user/password "User token" method (POST /user/ to obtain a
* short-lived token) is not supported.
*
* Every response is wrapped as {code, success, data} — including, unusually, an *empty* result:
* a subnet with no addresses answers `success:false, message:"No addresses found"` rather than
* `success:true, data:[]`. That's read as "nothing here", not an error; anything else with
* success:false is a real failure and throws with phpIPAM's own message.
*
* Addresses are read per subnet (GET /subnets/, then GET /subnets/{id}/addresses/ for each) — the
* standard, long-documented way to enumerate every address in phpIPAM — rather than assuming a
* single "all addresses" endpoint exists across every version. Object fields are read
* defensively (each one is optional and independently type-checked): a field phpIPAM renames or
* drops in some version leaves that value blank rather than breaking the sync.
*
* Endpoints, the `token` header, and the address/subnet field names are cross-checked against
* phpIPAM's own published API documentation (phpipam.net/api-documentation) — but not verified
* against a live instance, and the "no addresses found" empty-result shape specifically is from
* long-standing third-party-client convention rather than the docs themselves. If your instance's
* response shapes differ, tell us what came back and we'll adjust.
*/
import * as http from "node:http";
import * as https from "node:https";
import { withDiagLogging } from "../../services/diagLog.js";
export interface PhpIpamConfig {
url: string;
appId: string;
token: string;
insecure?: boolean;
}
export interface PhpIpamAddress {
ip: string;
hostname: string | null;
description: string | null;
note: string | null;
mac: string | null;
/** The subnet's own description, or its CIDR if it has none — "where" this address lives in phpIPAM. */
subnetLabel: string;
}
export interface PhpIpamAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listAddresses(): Promise<PhpIpamAddress[]>;
}
interface RawResponse {
status: number;
text: () => string;
}
// phpIPAM is a plain web app (unlike e.g. Synology's fixed 5000/5001) — no default port override, just the URL's own scheme.
function request(url: string, insecure: boolean, token: string): Promise<RawResponse> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const isHttps = parsed.protocol === "https:";
const lib = isHttps ? https : http;
const req = lib.request(
{
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method: "GET",
// phpIPAM's docs name this header "token"; some versions instead look for "phpipam-token" — send both.
headers: { token, "phpipam-token": token, Accept: "application/json" },
...(isHttps ? { rejectUnauthorized: !insecure } : {}),
},
(res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body }));
},
);
req.on("error", reject);
req.end();
});
}
const str = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null);
export function createPhpIpamAdapter(config: PhpIpamConfig): PhpIpamAdapter {
const insecure = config.insecure === true;
function base() {
return `${config.url.replace(/\/$/, "")}/api/${config.appId.replace(/^\/|\/$/g, "")}`;
}
/** GETs one endpoint and returns its `data` array — [] for phpIPAM's "no X found" not-really-an-error shape. */
async function apiList(path: string): Promise<any[]> {
const res = await request(`${base()}${path}`, insecure, config.token);
let body: any = null;
try {
body = res.text() ? JSON.parse(res.text()) : null;
} catch {
// non-JSON error page
}
if (!body || typeof body !== "object") {
throw new Error(`phpIPAM API error: HTTP ${res.status}`);
}
if (body.success === false) {
if (/no .*found/i.test(String(body.message ?? ""))) return [];
throw new Error(body.message || `phpIPAM API error: HTTP ${res.status}`);
}
if (res.status < 200 || res.status >= 300) {
throw new Error(body.message || `phpIPAM API error: HTTP ${res.status}`);
}
return Array.isArray(body.data) ? body.data : [];
}
async function listAddresses(): Promise<PhpIpamAddress[]> {
const subnets = await apiList("/subnets/");
const out: PhpIpamAddress[] = [];
for (const s of subnets) {
const subnetId = s?.id;
if (subnetId === undefined || subnetId === null) continue;
const subnetLabel = str(s.description) ?? (str(s.subnet) && str(s.mask) ? `${s.subnet}/${s.mask}` : `subnet ${subnetId}`);
let addresses: any[];
try {
addresses = await apiList(`/subnets/${subnetId}/addresses/`);
} catch (err) {
// One unreadable subnet (e.g. this app lacks permission on it) shouldn't fail the whole sync.
console.error(`[phpipam] couldn't read addresses for subnet ${subnetId}:`, err instanceof Error ? err.message : err);
continue;
}
for (const a of addresses) {
const ip = str(a?.ip);
if (!ip) continue;
out.push({
ip,
hostname: str(a?.hostname),
description: str(a?.description),
note: str(a?.note),
mac: str(a?.mac),
subnetLabel,
});
}
}
return out;
}
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
await apiList("/subnets/");
return { ok: true, latencyMs: Date.now() - start };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
return withDiagLogging("phpipam", { ping, listAddresses });
}
+386 -2
View File
@@ -9,13 +9,20 @@
* (https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js): GET /nodes, GET * (https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js): GET /nodes, GET
* /nodes/{node}/qemu, GET /nodes/{node}/lxc, and POST * /nodes/{node}/qemu, GET /nodes/{node}/lxc, and POST
* /nodes/{node}/{qemu,lxc}/{vmid}/status/{start,stop,reboot} (all * /nodes/{node}/{qemu,lxc}/{vmid}/status/{start,stop,reboot} (all
* token-auth-eligible per that spec's "allowtoken" flag). * token-auth-eligible per that spec's "allowtoken" flag). Backup visibility
* uses GET /cluster/backup (job schedules — requires Sys.Audit on /) and GET
* /nodes/{node}/tasks?typefilter=vzdump (run history — same Sys.Audit as the
* existing node-stats card already needs). Per-guest outcome within an
* "all guests" job isn't reliably exposed by the task list itself (only the
* task's own log text has that), so this surfaces job-level and task-level
* status rather than guessing at per-guest results.
* *
* Proxmox commonly runs with a self-signed certificate in homelab setups, so * Proxmox commonly runs with a self-signed certificate in homelab setups, so
* (like the cPanel DNS adapter) this uses node:https directly rather than * (like the cPanel DNS adapter) this uses node:https directly rather than
* fetch, to support an "insecure" opt-out of certificate verification. * fetch, to support an "insecure" opt-out of certificate verification.
*/ */
import * as https from "node:https"; import * as https from "node:https";
import { withDiagLogging } from "../../services/diagLog.js";
export interface ProxmoxConfig { export interface ProxmoxConfig {
url: string; url: string;
@@ -38,12 +45,149 @@ export interface ProxmoxGuest {
uptime: number | null; uptime: number | null;
} }
export interface ProxmoxGuestDetail {
vmid: number;
node: string;
type: ProxmoxGuestType;
name: string;
status: string;
cpuCores: number | null;
cpuUsagePercent: number | null;
memoryBytes: number | null;
memUsedBytes: number | null;
diskBytes: number | null;
/**
* Per-mount usage where Proxmox can actually see it: the LXC root
* filesystem (host can see straight into it, no agent needed) or, for a
* QEMU VM, whatever the QEMU guest agent reports from inside the guest.
* Empty when neither is available (e.g. no guest agent) — diskBytes above
* (allocated size) is still shown in that case, just not usage.
*/
disks: { mount: string; sizeBytes: number; usedBytes: number }[];
uptime: number | null;
ipAddresses: string[];
/** QEMU only — false when the guest agent call failed (not installed/running). Always true for LXC (IPs/disk usage read directly, no agent needed). */
guestAgentAvailable: boolean;
}
export interface ProxmoxStorage {
id: string;
type: string;
active: boolean;
shared: boolean;
totalBytes: number | null;
usedBytes: number | null;
availBytes: number | null;
}
export interface ProxmoxNodeStats {
node: string;
/** Set (with every other field null/empty) when this node's status/storage couldn't be fetched — e.g. the API token lacks Sys.Audit/Datastore.Audit. */
error: string | null;
uptime: number | null;
cpuUsagePercent: number | null;
cpuCores: number | null;
loadAverage: [number, number, number] | null;
memTotalBytes: number | null;
memUsedBytes: number | null;
swapTotalBytes: number | null;
swapUsedBytes: number | null;
rootfsTotalBytes: number | null;
rootfsUsedBytes: number | null;
pveVersion: string | null;
storages: ProxmoxStorage[];
}
export interface ProxmoxBackupJob {
id: string;
enabled: boolean;
schedule: string;
storage: string;
/** null = a cluster-wide job not pinned to one node. */
node: string | null;
allGuests: boolean;
/** Comma-separated guest IDs this job backs up — present when allGuests is false. */
vmids: string | null;
/** Comma-separated guest IDs excluded from an allGuests job. */
exclude: string | null;
}
export interface ProxmoxBackupTask {
node: string;
upid: string;
/**
* Proxmox's own task "id" field — for a single-guest vzdump run this is
* that guest's vmid, but for an "all guests" job it can be blank (the
* per-guest outcomes only exist in the task's own log text, which this
* doesn't fetch/parse) — shown as-is rather than guessed at.
*/
guestId: string | null;
/** "OK", an error string, or "running" for a task with no endtime yet. */
status: string;
ok: boolean;
startTime: string; // ISO
endTime: string | null; // ISO, null while still running
}
/**
* Guests not covered by any enabled backup job — derived entirely from data
* this adapter already fetches (listGuests + listBackupJobs), rather than
* depending on Proxmox's own `/cluster/backup-info/not-backed-up-guests`
* endpoint, which only exists on newer PVE versions. A guest is "covered" by
* a job if: the job has no node restriction or matches the guest's node, and
* either the job backs up "all guests" and doesn't exclude this vmid, or the
* job explicitly lists this vmid.
*/
export function guestsWithoutBackupCoverage(guests: ProxmoxGuest[], jobs: ProxmoxBackupJob[]): ProxmoxGuest[] {
const enabledJobs = jobs.filter((j) => j.enabled);
function splitIds(csv: string | null): string[] {
return (csv ?? "").split(",").map((s) => s.trim()).filter(Boolean);
}
function isCovered(guest: ProxmoxGuest): boolean {
return enabledJobs.some((job) => {
if (job.node && job.node !== guest.node) return false;
if (job.allGuests) return !splitIds(job.exclude).includes(String(guest.vmid));
return splitIds(job.vmids).includes(String(guest.vmid));
});
}
return guests.filter((g) => !isCovered(g));
}
export interface ProxmoxAdapter { export interface ProxmoxAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listGuests(): Promise<ProxmoxGuest[]>; listGuests(): Promise<ProxmoxGuest[]>;
getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise<ProxmoxGuestDetail>;
listNodeStats(): Promise<ProxmoxNodeStats[]>;
startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>; startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
stopGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>; stopGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>; restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
/** Graceful shutdown (ACPI power event for a VM, SIGTERM-then-wait for a container) — unlike stopGuest, this asks the guest OS to shut itself down. */
shutdownGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
listBackupJobs(): Promise<ProxmoxBackupJob[]>;
/** Most recent vzdump task runs across every online node, newest first. */
listRecentBackupTasks(limitPerNode?: number): Promise<ProxmoxBackupTask[]>;
}
function parseSizeToBytes(size: string): number | null {
const match = size.match(/^(\d+(?:\.\d+)?)\s*([KMGT])?$/i);
if (!match) return null;
const value = parseFloat(match[1]);
const unit = (match[2] ?? "").toUpperCase();
const multiplier = ({ "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 } as Record<string, number>)[unit] ?? 1;
return Math.round(value * multiplier);
}
function extractSizeParam(configValue: string): number | null {
const match = configValue.match(/(?:^|,)size=([\d.]+[KMGT]?)/i);
return match ? parseSizeToBytes(match[1]) : null;
}
function extractIpFromNetConfig(configValue: string): string | null {
const match = configValue.match(/(?:^|,)ip=([^,]+)/i);
if (!match) return null;
const ip = match[1];
if (ip.toLowerCase() === "dhcp" || ip.toLowerCase() === "manual") return null;
return ip.split("/")[0];
} }
interface RawResponse { interface RawResponse {
@@ -147,6 +291,234 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
return perNode.flat(); return perNode.flat();
} }
async function getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise<ProxmoxGuestDetail> {
const [config, status] = await Promise.all([
api("GET", `/nodes/${node}/${type}/${vmid}/config`),
api("GET", `/nodes/${node}/${type}/${vmid}/status/current`),
]);
let cpuCores: number | null = null;
let diskBytes: number | null = null;
const ipAddresses: string[] = [];
const disks: { mount: string; sizeBytes: number; usedBytes: number }[] = [];
let guestAgentAvailable = true;
if (type === "lxc") {
cpuCores = typeof config.cores === "number" ? config.cores : null;
if (typeof config.rootfs === "string") {
diskBytes = extractSizeParam(config.rootfs);
}
for (const key of Object.keys(config)) {
if (/^net\d+$/.test(key) && typeof config[key] === "string") {
const ip = extractIpFromNetConfig(config[key]);
if (ip) ipAddresses.push(ip);
}
}
// The host can see straight into an LXC's root filesystem — no agent
// needed — but the API only exposes the root mount this way, not any
// additional mount points configured on the container.
if (typeof status.disk === "number" && typeof status.maxdisk === "number" && status.maxdisk > 0) {
disks.push({ mount: "/", sizeBytes: status.maxdisk, usedBytes: status.disk });
}
} else {
const sockets = typeof config.sockets === "number" ? config.sockets : 1;
cpuCores = typeof config.cores === "number" ? config.cores * sockets : null;
let totalDisk = 0;
let foundDisk = false;
for (const key of Object.keys(config)) {
if (/^(scsi|virtio|sata|ide)\d+$/.test(key) && typeof config[key] === "string") {
const size = extractSizeParam(config[key]);
if (size !== null) {
totalDisk += size;
foundDisk = true;
}
}
}
diskBytes = foundDisk ? totalDisk : null;
try {
const agentData = await api("GET", `/nodes/${node}/qemu/${vmid}/agent/network-get-interfaces`);
const interfaces = agentData?.result ?? [];
for (const iface of interfaces) {
for (const addr of iface["ip-addresses"] ?? []) {
if (addr["ip-address-type"] === "ipv4" && addr["ip-address"] !== "127.0.0.1") {
ipAddresses.push(addr["ip-address"]);
}
}
}
} catch {
guestAgentAvailable = false;
}
// Unlike LXC, the hypervisor can't see inside a QEMU disk image at
// all — actual filesystem usage only exists if the guest agent
// reports it from inside the guest, same availability caveat as the
// network call above (a separate try/catch since one agent command
// failing, e.g. on an older guest agent version, shouldn't hide IPs
// the other command already got, or vice versa).
try {
const fsData = await api("GET", `/nodes/${node}/qemu/${vmid}/agent/get-fsinfo`);
for (const fs of fsData?.result ?? []) {
// Entries with no backing "disk" (tmpfs, proc, overlay, snap loop
// mounts, ...) aren't real storage — skip them, same convention
// widely used for this endpoint.
if (!Array.isArray(fs.disk) || fs.disk.length === 0) continue;
if (typeof fs["total-bytes"] !== "number" || typeof fs["used-bytes"] !== "number") continue;
disks.push({ mount: fs.mountpoint ?? fs.name ?? "?", sizeBytes: fs["total-bytes"], usedBytes: fs["used-bytes"] });
}
} catch {
// Guest agent unavailable or too old to support get-fsinfo — leave
// disks empty, diskBytes (allocated size) is still shown.
}
}
const memoryBytes = typeof config.memory === "number" ? config.memory * 1024 * 1024 : null;
return {
vmid,
node,
type,
name: config.name ?? `${type}/${vmid}`,
status: status.status,
cpuCores,
cpuUsagePercent: typeof status.cpu === "number" ? status.cpu * 100 : null,
memoryBytes,
memUsedBytes: typeof status.mem === "number" ? status.mem : null,
diskBytes,
disks,
uptime: typeof status.uptime === "number" ? status.uptime : null,
ipAddresses,
guestAgentAvailable: type === "qemu" ? guestAgentAvailable : true,
};
}
const emptyNodeStats = (node: string, error: string | null): ProxmoxNodeStats => ({
node,
error,
uptime: null,
cpuUsagePercent: null,
cpuCores: null,
loadAverage: null,
memTotalBytes: null,
memUsedBytes: null,
swapTotalBytes: null,
swapUsedBytes: null,
rootfsTotalBytes: null,
rootfsUsedBytes: null,
pveVersion: null,
storages: [],
});
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : String(reason);
}
async function getNodeStats(node: string): Promise<ProxmoxNodeStats> {
// Host status and storage usage need different ACL privileges
// (Sys.Audit vs Datastore.Audit) — a token scoped only for VM/LXC
// management (this integration's original scope) may have one but not
// the other, so fetch them independently rather than losing both to
// Promise.all's fail-fast behavior.
const [statusResult, storageResult] = await Promise.allSettled([
api("GET", `/nodes/${node}/status`),
api("GET", `/nodes/${node}/storage`),
]);
const status = statusResult.status === "fulfilled" ? statusResult.value : null;
const storages = storageResult.status === "fulfilled" ? storageResult.value : null;
const errors: string[] = [];
if (statusResult.status === "rejected") errors.push(`host stats: ${errorMessage(statusResult.reason)}`);
if (storageResult.status === "rejected") errors.push(`storage: ${errorMessage(storageResult.reason)}`);
const loadavgRaw = Array.isArray(status?.loadavg) ? status.loadavg.map((v: string) => Number(v)) : null;
const loadAverage: [number, number, number] | null =
loadavgRaw && loadavgRaw.length === 3 && loadavgRaw.every((n: number) => Number.isFinite(n))
? (loadavgRaw as [number, number, number])
: null;
return {
node,
error: errors.length > 0 ? errors.join("; ") : null,
uptime: typeof status?.uptime === "number" ? status.uptime : null,
cpuUsagePercent: typeof status?.cpu === "number" ? status.cpu * 100 : null,
cpuCores: typeof status?.cpuinfo?.cpus === "number" ? status.cpuinfo.cpus : null,
loadAverage,
memTotalBytes: typeof status?.memory?.total === "number" ? status.memory.total : null,
memUsedBytes: typeof status?.memory?.used === "number" ? status.memory.used : null,
swapTotalBytes: typeof status?.swap?.total === "number" ? status.swap.total : null,
swapUsedBytes: typeof status?.swap?.used === "number" ? status.swap.used : null,
rootfsTotalBytes: typeof status?.rootfs?.total === "number" ? status.rootfs.total : null,
rootfsUsedBytes: typeof status?.rootfs?.used === "number" ? status.rootfs.used : null,
pveVersion: typeof status?.pveversion === "string" ? status.pveversion : null,
storages: (Array.isArray(storages) ? storages : []).map((s: any) => ({
id: s.storage,
type: s.type,
active: !!s.active,
shared: !!s.shared,
totalBytes: typeof s.total === "number" ? s.total : null,
usedBytes: typeof s.used === "number" ? s.used : null,
availBytes: typeof s.avail === "number" ? s.avail : null,
})),
};
}
async function listNodeStats(): Promise<ProxmoxNodeStats[]> {
const nodes = await listNodes();
return Promise.all(
nodes.map(async (node) => {
try {
return await getNodeStats(node);
} catch (err) {
// Surface the failure on this node's card instead of silently
// dropping it — that previously showed a misleading "no online
// nodes" empty state even when nodes existed but stats couldn't
// be fetched (e.g. missing ACL privileges on the API token).
return emptyNodeStats(node, errorMessage(err));
}
}),
);
}
async function listBackupJobs(): Promise<ProxmoxBackupJob[]> {
const data = await api("GET", "/cluster/backup");
return (Array.isArray(data) ? data : []).map((j: any) => ({
id: j.id,
enabled: j.enabled !== 0 && j.enabled !== "0",
schedule: j.schedule ?? "",
storage: j.storage ?? "",
node: j.node ?? null,
allGuests: j.all === 1 || j.all === "1",
vmids: typeof j.vmid === "string" ? j.vmid : null,
exclude: typeof j.exclude === "string" ? j.exclude : null,
}));
}
async function listRecentBackupTasks(limitPerNode = 20): Promise<ProxmoxBackupTask[]> {
const nodes = await listNodes();
const perNode = await Promise.all(
nodes.map(async (node) => {
try {
const data = await api("GET", `/nodes/${node}/tasks?typefilter=vzdump&limit=${limitPerNode}`);
return (Array.isArray(data) ? data : []).map((t: any) => ({
node,
upid: t.upid,
guestId: t.id || null,
status: t.status ?? (t.endtime ? "unknown" : "running"),
ok: t.status === "OK",
startTime: new Date(t.starttime * 1000).toISOString(),
endTime: typeof t.endtime === "number" ? new Date(t.endtime * 1000).toISOString() : null,
}));
} catch {
// one unreachable/offline node shouldn't take down the whole backup view
return [];
}
}),
);
return perNode.flat().sort((a, b) => b.startTime.localeCompare(a.startTime));
}
async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise<void> { async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise<void> {
await api("POST", `/nodes/${node}/${type}/${vmid}/status/${action}`); await api("POST", `/nodes/${node}/${type}/${vmid}/status/${action}`);
} }
@@ -154,6 +526,7 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
const startGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "start"); const startGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "start");
const stopGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "stop"); const stopGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "stop");
const restartGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "reboot"); const restartGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "reboot");
const shutdownGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "shutdown");
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now(); const start = Date.now();
@@ -165,5 +538,16 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
} }
} }
return { ping, listGuests, startGuest, stopGuest, restartGuest }; return withDiagLogging("proxmox", {
ping,
listGuests,
getGuestDetail,
listNodeStats,
startGuest,
stopGuest,
restartGuest,
shutdownGuest,
listBackupJobs,
listRecentBackupTasks,
});
} }
+12
View File
@@ -6,6 +6,10 @@ import { createDockhandAdapter } from "./dockhand/adapter.js";
import { createSemaphoreAdapter } from "./semaphore/adapter.js"; import { createSemaphoreAdapter } from "./semaphore/adapter.js";
import { createProxmoxAdapter } from "./proxmox/adapter.js"; import { createProxmoxAdapter } from "./proxmox/adapter.js";
import { createSynologyAdapter } from "./synology/adapter.js"; import { createSynologyAdapter } from "./synology/adapter.js";
import { createUptimeKumaAdapter } from "./uptimekuma/adapter.js";
import { createPhpIpamAdapter } from "./phpipam/adapter.js";
import { createPbsAdapter } from "./pbs/adapter.js";
import { createOsTicketAdapter } from "./osticket/adapter.js";
export interface PingableAdapter { export interface PingableAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
@@ -32,6 +36,14 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat
return createProxmoxAdapter(config as any); return createProxmoxAdapter(config as any);
case "synology": case "synology":
return createSynologyAdapter(config as any); return createSynologyAdapter(config as any);
case "uptimekuma":
return createUptimeKumaAdapter(config as any);
case "phpipam":
return createPhpIpamAdapter(config as any);
case "pbs":
return createPbsAdapter(config as any);
case "osticket":
return createOsTicketAdapter(config as any);
default: default:
throw new Error(`Integration type "${type}" is not implemented yet`); throw new Error(`Integration type "${type}" is not implemented yet`);
} }
+12 -3
View File
@@ -7,6 +7,7 @@
* source (db/Task.go, pkg/task_logger/task_logger.go) for the exact task * source (db/Task.go, pkg/task_logger/task_logger.go) for the exact task
* status enum, since the swagger doc itself doesn't enumerate it. * status enum, since the swagger doc itself doesn't enumerate it.
*/ */
import { withDiagLogging } from "../../services/diagLog.js";
export interface SemaphoreConfig { export interface SemaphoreConfig {
url: string; url: string;
@@ -47,6 +48,8 @@ export interface SemaphoreTemplate {
export interface SemaphoreAdapter { export interface SemaphoreAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listTemplatesWithStatus(): Promise<SemaphoreTemplate[]>; listTemplatesWithStatus(): Promise<SemaphoreTemplate[]>;
/** Like listTemplatesWithStatus, but says which projects couldn't be read — for callers that must not mistake "couldn't read" for "no templates". */
checkTemplates(): Promise<{ templates: SemaphoreTemplate[]; failedProjectIds: number[] }>;
runTemplate(projectId: number, templateId: number): Promise<SemaphoreTask>; runTemplate(projectId: number, templateId: number): Promise<SemaphoreTask>;
} }
@@ -111,18 +114,24 @@ export function createSemaphoreAdapter(config: SemaphoreConfig): SemaphoreAdapte
})); }));
} }
async function listTemplatesWithStatus(): Promise<SemaphoreTemplate[]> { async function checkTemplates(): Promise<{ templates: SemaphoreTemplate[]; failedProjectIds: number[] }> {
const projects = await listProjects(); const projects = await listProjects();
const failedProjectIds: number[] = [];
const perProject = await Promise.all( const perProject = await Promise.all(
projects.map(async (p) => { projects.map(async (p) => {
try { try {
return await listTemplatesForProject(p.id, p.name); return await listTemplatesForProject(p.id, p.name);
} catch { } catch {
failedProjectIds.push(p.id);
return []; return [];
} }
}), }),
); );
return perProject.flat(); return { templates: perProject.flat(), failedProjectIds };
}
async function listTemplatesWithStatus(): Promise<SemaphoreTemplate[]> {
return (await checkTemplates()).templates;
} }
async function runTemplate(projectId: number, templateId: number): Promise<SemaphoreTask> { async function runTemplate(projectId: number, templateId: number): Promise<SemaphoreTask> {
@@ -140,5 +149,5 @@ export function createSemaphoreAdapter(config: SemaphoreConfig): SemaphoreAdapte
} }
} }
return { ping, listTemplatesWithStatus, runTemplate }; return withDiagLogging("semaphore", { ping, listTemplatesWithStatus, checkTemplates, runTemplate });
} }
+64 -1
View File
@@ -23,6 +23,7 @@
*/ */
import * as https from "node:https"; import * as https from "node:https";
import * as http from "node:http"; import * as http from "node:http";
import { withDiagLogging } from "../../services/diagLog.js";
export interface SynologyConfig { export interface SynologyConfig {
url: string; url: string;
@@ -55,9 +56,28 @@ export interface SynologyStorageInfo {
disks: SynologyDisk[]; disks: SynologyDisk[];
} }
export interface SynologySystemInfo {
hostname: string | null;
model: string | null;
serial: string | null;
firmwareVersion: string | null;
uptime: string | null;
ipAddresses: string[];
cpu: {
cores: number | null;
clockSpeedMHz: number | null;
loadPercent: number | null;
};
memory: {
totalBytes: number | null;
usedBytes: number | null;
};
}
export interface SynologyAdapter { export interface SynologyAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
getStorageInfo(): Promise<SynologyStorageInfo>; getStorageInfo(): Promise<SynologyStorageInfo>;
getSystemInfo(): Promise<SynologySystemInfo>;
} }
interface RawResponse { interface RawResponse {
@@ -218,5 +238,48 @@ export function createSynologyAdapter(config: SynologyConfig): SynologyAdapter {
} }
} }
return { ping, getStorageInfo }; async function getSystemInfo(): Promise<SynologySystemInfo> {
const [info, utilization, network] = await Promise.all([
callApi("SYNO.Core.System", "info"),
callApi("SYNO.Core.System.Utilization", "get"),
callApi("SYNO.DSM.Network", "list"),
]);
const cpuLoadPercent =
utilization.cpu?.user_load !== undefined
? Number(utilization.cpu.user_load) + Number(utilization.cpu.system_load) + Number(utilization.cpu.other_load)
: null;
const memTotalBytes = utilization.memory?.total_real !== undefined ? Number(utilization.memory.total_real) * 1024 : null;
const memAvailBytes = utilization.memory?.avail_real !== undefined ? Number(utilization.memory.avail_real) * 1024 : null;
const ipAddresses: string[] = [];
for (const iface of network.interfaces ?? []) {
for (const ip of iface.ip ?? []) {
if (ip.address && ip.address !== "127.0.0.1" && !ipAddresses.includes(ip.address)) {
ipAddresses.push(ip.address);
}
}
}
return {
hostname: network.hostname ?? null,
model: info.model ?? null,
serial: info.serial ?? null,
firmwareVersion: info.firmware_ver ?? null,
uptime: info.up_time ?? null,
ipAddresses,
cpu: {
cores: info.cpu_cores !== undefined ? Number(info.cpu_cores) : null,
clockSpeedMHz: info.cpu_clock_speed !== undefined ? Number(info.cpu_clock_speed) : null,
loadPercent: cpuLoadPercent,
},
memory: {
totalBytes: memTotalBytes,
usedBytes: memTotalBytes !== null && memAvailBytes !== null ? memTotalBytes - memAvailBytes : null,
},
};
}
return withDiagLogging("synology", { ping, getStorageInfo, getSystemInfo });
} }
+21 -1
View File
@@ -16,9 +16,19 @@
* in the same call as the basic fields, so no per-device follow-up is * in the same call as the basic fields, so no per-device follow-up is
* needed. * needed.
*/ */
import { withDiagLogging } from "../../services/diagLog.js";
const BASE = "https://api.tailscale.com"; const BASE = "https://api.tailscale.com";
export const KEY_EXPIRY_WARN_DAYS = 30;
/** True if a device's key has already expired or expires within the warning window. */
export function isKeyExpiringSoon(device: Pick<TailscaleDevice, "keyExpiry" | "keyExpiryDisabled">, now = Date.now()): boolean {
if (device.keyExpiryDisabled || !device.keyExpiry) return false;
const daysLeft = (new Date(device.keyExpiry).getTime() - now) / 86_400_000;
return daysLeft <= KEY_EXPIRY_WARN_DAYS;
}
export interface TailscaleConfig { export interface TailscaleConfig {
tailnet: string; tailnet: string;
apiKey: string; apiKey: string;
@@ -36,6 +46,8 @@ export interface TailscaleDevice {
isExitNode: boolean; isExitNode: boolean;
authorized: boolean; authorized: boolean;
online: boolean; online: boolean;
keyExpiry: string | null;
keyExpiryDisabled: boolean;
} }
export interface TailscaleAdapter { export interface TailscaleAdapter {
@@ -76,6 +88,12 @@ export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapte
const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices?fields=all`); const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices?fields=all`);
return (data.devices || []).map((d: any) => { return (data.devices || []).map((d: any) => {
const enabledRoutes: string[] = d.enabledRoutes || []; const enabledRoutes: string[] = d.enabledRoutes || [];
// Tailscale returns Go's zero time ("0001-01-01T00:00:00Z") for
// `expires` when a device has no expiry set (distinct from
// keyExpiryDisabled, which is the explicit "never expire" override) —
// treat both as "no expiry" rather than showing a bogus 1AD date.
const expires: string | undefined = d.expires;
const hasRealExpiry = !!expires && !expires.startsWith("0001-01-01");
return { return {
id: d.id, id: d.id,
nodeId: d.nodeId || d.id, nodeId: d.nodeId || d.id,
@@ -88,6 +106,8 @@ export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapte
isExitNode: enabledRoutes.includes("0.0.0.0/0") && enabledRoutes.includes("::/0"), isExitNode: enabledRoutes.includes("0.0.0.0/0") && enabledRoutes.includes("::/0"),
authorized: !!d.authorized, authorized: !!d.authorized,
online: !!d.connectedToControl, online: !!d.connectedToControl,
keyExpiry: hasRealExpiry ? expires! : null,
keyExpiryDisabled: !!d.keyExpiryDisabled,
}; };
}); });
} }
@@ -110,5 +130,5 @@ export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapte
} }
} }
return { ping, listDevices, setAuthorized, deleteDevice }; return withDiagLogging("tailscale", { ping, listDevices, setAuthorized, deleteDevice });
} }
+2
View File
@@ -4,6 +4,8 @@ export interface IntegrationField {
secret: boolean; secret: boolean;
type?: "text" | "password" | "checkbox"; type?: "text" | "password" | "checkbox";
placeholder?: string; placeholder?: string;
/** Not required to save the integration (e.g. a username that's normally left blank in favor of an API key). */
optional?: boolean;
} }
export type IntegrationConfig = Record<string, string | boolean | undefined>; export type IntegrationConfig = Record<string, string | boolean | undefined>;
@@ -0,0 +1,221 @@
/**
* Uptime Kuma adapter.
* Requires config: url, password (an API key, or — on installs older than the API-key
* feature — the account password); username is optional and normally left blank.
*
* Uptime Kuma has no conventional REST API (the dashboard talks to it over Socket.IO).
* The one machine-readable endpoint that lists every monitor is its Prometheus exporter
* at GET /metrics, gated by HTTP Basic auth — empty username + an API key as the
* password once one exists, or the real login username/password on older installs
* (https://github.com/louislam/uptime-kuma/wiki/Prometheus-API-Keys). This adapter reads
* that endpoint and parses the Prometheus text-exposition format itself; there is no
* JSON alternative.
*
* Verified against the documented metric/label set (server/prometheus.js upstream):
* gauges monitor_status (1=up, 0=down, 2=pending, 3=maintenance), monitor_response_time
* (ms), monitor_cert_days_remaining, monitor_uptime_ratio{window="1d"|"30d"|"365d"}, each
* carrying labels monitor_id, monitor_name, monitor_type, monitor_url, monitor_hostname,
* monitor_port (plus the monitor's own tags, which this adapter doesn't try to separate
* out from the fixed labels, since tag label *names* are user-defined and not reliably
* distinguishable from any other label Uptime Kuma might add later).
*/
import { withDiagLogging } from "../../services/diagLog.js";
export interface UptimeKumaConfig {
url: string;
username?: string;
password: string;
}
export type MonitorStatus = "up" | "down" | "pending" | "maintenance" | "unknown";
const STATUS_BY_CODE: Record<number, MonitorStatus> = { 0: "down", 1: "up", 2: "pending", 3: "maintenance" };
export interface UptimeKumaMonitor {
id: string;
name: string;
type: string;
/** The host Uptime Kuma actually checks — a bare hostname/IP for TCP-style monitors, or the host part of the URL for HTTP/keyword ones. Null for types with no single network target (group, push, docker, ...). */
target: string | null;
port: number | null;
status: MonitorStatus;
responseTimeMs: number | null;
certDaysRemaining: number | null;
/** Percent, 0–100. */
uptime24h: number | null;
uptime30d: number | null;
uptime1y: number | null;
}
export interface UptimeKumaAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listMonitors(): Promise<UptimeKumaMonitor[]>;
}
// ─── Prometheus text-exposition parsing (pure) ─────────────────────────────
export interface PromSample {
metric: string;
labels: Record<string, string>;
value: number;
}
const SAMPLE_LINE = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{(.*)\})?\s+(\S+)\s*$/;
// key="value" pairs; the value may contain an escaped quote (\") or backslash (\\), per the exposition format.
const LABEL_PAIR = /([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"/g;
function unescapeLabelValue(raw: string): string {
return raw.replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
}
/** Parses Prometheus's plain-text exposition format into flat samples. Comment (#) and blank lines are skipped; a line that doesn't parse as a sample is skipped rather than failing the whole scrape — one odd line from a future Uptime Kuma version shouldn't blank the page. */
export function parsePrometheusText(text: string): PromSample[] {
const samples: PromSample[] = [];
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = SAMPLE_LINE.exec(trimmed);
if (!m) continue;
const value = Number(m[4]);
if (!Number.isFinite(value)) continue;
const labels: Record<string, string> = {};
if (m[3]) {
LABEL_PAIR.lastIndex = 0;
let lm: RegExpExecArray | null;
while ((lm = LABEL_PAIR.exec(m[3]))) labels[lm[1]] = unescapeLabelValue(lm[2]);
}
samples.push({ metric: m[1], labels, value });
}
return samples;
}
/** Groups flat samples into one row per monitor_id, reading whichever of the known metrics are present for it. */
export function monitorsFromSamples(samples: PromSample[]): UptimeKumaMonitor[] {
interface Acc {
name: string;
type: string;
url: string;
hostname: string;
port: string;
status: MonitorStatus;
responseTimeMs: number | null;
certDaysRemaining: number | null;
uptime: Partial<Record<"1d" | "30d" | "365d", number>>;
}
const byId = new Map<string, Acc>();
const get = (id: string, labels: Record<string, string>) => {
let acc = byId.get(id);
if (!acc) {
acc = {
name: labels.monitor_name ?? id,
type: labels.monitor_type ?? "unknown",
url: labels.monitor_url ?? "",
hostname: labels.monitor_hostname ?? "",
port: labels.monitor_port ?? "",
status: "unknown",
responseTimeMs: null,
certDaysRemaining: null,
uptime: {},
};
byId.set(id, acc);
}
return acc;
};
for (const s of samples) {
const id = s.labels.monitor_id;
if (!id) continue;
const acc = get(id, s.labels);
switch (s.metric) {
case "monitor_status":
acc.status = STATUS_BY_CODE[s.value] ?? "unknown";
break;
case "monitor_response_time":
acc.responseTimeMs = s.value;
break;
case "monitor_cert_days_remaining":
acc.certDaysRemaining = s.value;
break;
case "monitor_uptime_ratio": {
const window = s.labels.window;
if (window === "1d" || window === "30d" || window === "365d") acc.uptime[window] = s.value;
break;
}
default:
break;
}
}
const target = (acc: Acc): { target: string | null; port: number | null } => {
if (acc.hostname) {
const port = Number(acc.port);
return { target: acc.hostname, port: Number.isFinite(port) && port > 0 ? port : null };
}
if (acc.url) {
try {
const u = new URL(acc.url);
const port = u.port ? Number(u.port) : null;
return { target: u.hostname, port };
} catch {
return { target: null, port: null };
}
}
return { target: null, port: null };
};
const pct = (v: number | undefined): number | null => (v === undefined ? null : Math.round(v * 1000) / 10);
return [...byId.entries()]
.map(([id, acc]) => {
const { target: t, port } = target(acc);
return {
id,
name: acc.name,
type: acc.type,
target: t,
port,
status: acc.status,
responseTimeMs: acc.responseTimeMs,
certDaysRemaining: acc.certDaysRemaining,
uptime24h: pct(acc.uptime["1d"]),
uptime30d: pct(acc.uptime["30d"]),
uptime1y: pct(acc.uptime["365d"]),
};
})
.sort((a, b) => a.name.localeCompare(b.name));
}
// ─── HTTP ───────────────────────────────────────────────────────────────────
export function createUptimeKumaAdapter(config: UptimeKumaConfig): UptimeKumaAdapter {
function base() {
return config.url.replace(/\/$/, "");
}
async function fetchMetrics(): Promise<string> {
const auth = Buffer.from(`${config.username ?? ""}:${config.password}`).toString("base64");
const res = await fetch(`${base()}/metrics`, { headers: { Authorization: `Basic ${auth}` } });
if (res.status === 401) {
throw new Error("Uptime Kuma rejected the credentials — check the API key (or username/password) and try again.");
}
if (!res.ok) {
throw new Error(`Uptime Kuma API error: HTTP ${res.status}`);
}
return res.text();
}
async function listMonitors(): Promise<UptimeKumaMonitor[]> {
return monitorsFromSamples(parsePrometheusText(await fetchMetrics()));
}
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
await fetchMetrics();
return { ok: true, latencyMs: Date.now() - start };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
return withDiagLogging("uptimekuma", { ping, listMonitors });
}
+28 -1
View File
@@ -7,13 +7,38 @@ import { asyncHandler } from "../utils/asyncHandler.js";
export const agentReportRouter = Router(); export const agentReportRouter = Router();
const listeningPortSchema = z.object({
protocol: z.enum(["tcp", "udp"]),
port: z.number().int().min(1).max(65535),
address: z.string().max(100),
process: z.string().max(100).optional(),
});
const systemSchema = z.object({
ip_addresses: z.array(z.string()).optional(),
cpu: z.object({ model: z.string().optional(), cores: z.number().optional(), load_percent: z.number().nullable().optional() }).optional(),
memory: z.object({ total_bytes: z.number().optional(), used_bytes: z.number().optional() }).optional(),
disks: z.array(z.object({ mount: z.string(), size_bytes: z.number(), used_bytes: z.number() })).optional(),
// Deliberately lenient: one odd line from `ss` must never cost the agent its whole report (tasks included),
// so entries are validated one by one and bad ones dropped rather than failing the request.
listening_ports: z
.array(z.unknown())
.max(5000)
.optional()
.transform((entries) => entries?.flatMap((e) => {
const parsed = listeningPortSchema.safeParse(e);
return parsed.success ? [parsed.data] : [];
})),
});
const reportSchema = z.object({ const reportSchema = z.object({
hostname: z.string().max(255).optional(), hostname: z.string().max(255).optional(),
os_type: z.string().optional(), os_type: z.string().optional(),
reported_at: z.string().optional(), reported_at: z.string().optional(),
system: systemSchema.nullable().optional(),
tasks: z.array( tasks: z.array(
z.object({ z.object({
schedule_type: z.enum(["cron", "systemd_timer"]), schedule_type: z.enum(["cron", "systemd_timer", "windows_task"]),
name: z.string().min(1), name: z.string().min(1),
command: z.string().optional(), command: z.string().optional(),
schedule_expression: z.string().optional(), schedule_expression: z.string().optional(),
@@ -47,6 +72,8 @@ agentReportRouter.post("/", asyncHandler(async (req, res) => {
await syncServerTasks(server.id, { await syncServerTasks(server.id, {
hostname: parsed.data.hostname, hostname: parsed.data.hostname,
osType: parsed.data.os_type,
system: parsed.data.system,
tasks: parsed.data.tasks.map((t) => ({ tasks: parsed.data.tasks.map((t) => ({
scheduleType: t.schedule_type, scheduleType: t.schedule_type,
name: t.name, name: t.name,
+14
View File
@@ -0,0 +1,14 @@
import { Router } from "express";
import { requireAuth } from "../auth/middleware.js";
import { getAlerts } from "../services/alerts.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const alertsRouter = Router();
alertsRouter.use(requireAuth);
// Everyone signed in can see it — it's the same server, disk and backup status the other pages already show.
// `?refresh=1` asks for a fresh check instead of a recent one.
alertsRouter.get("/", asyncHandler(async (req, res) => {
res.json(await getAlerts(req.query.refresh === "1"));
}));
+2 -1
View File
@@ -11,6 +11,7 @@ auditLogRouter.use(requireAuth, requireRole("operator"));
auditLogRouter.get("/", asyncHandler(async (req, res) => { auditLogRouter.get("/", asyncHandler(async (req, res) => {
const limit = Math.min(Number(req.query.limit ?? 200), 500); const limit = Math.min(Number(req.query.limit ?? 200), 500);
const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(limit); // createdAt only has one-second resolution, so entries made within the same second are ordered by id.
const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt), desc(auditLog.id)).limit(limit);
res.json({ entries: rows }); res.json({ entries: rows });
})); }));
+161
View File
@@ -0,0 +1,161 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { consistencyIgnores, dnsProviders, dnsRecordsCache, dnsZonesCache, ipamEntries, servers } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import {
buildFindings,
countHiddenAddresses,
InvalidRangeError,
MAX_EXCLUDED_RANGES,
normalizeRange,
type Finding,
} from "../services/consistency.js";
import { getSettings, updateSettings } from "../services/settingsStore.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const consistencyRouter = Router();
consistencyRouter.use(requireAuth);
function parseIps(stored: string | null): string[] {
if (!stored) return [];
try {
const value = JSON.parse(stored);
return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
} catch {
return [];
}
}
async function computeFindings(): Promise<{ findings: Finding[]; sources: Record<string, unknown>; excludedRanges: string[]; hiddenAddresses: number }> {
const [serverRows, ipamRows, dnsRows, zoneRows] = await Promise.all([
db.select({ id: servers.id, name: servers.name, hostname: servers.hostname, ips: servers.ipAddresses }).from(servers),
db.select({ id: ipamEntries.id, ip: ipamEntries.ipAddress, label: ipamEntries.label, source: ipamEntries.source }).from(ipamEntries),
db
.select({ name: dnsRecordsCache.name, type: dnsRecordsCache.type, content: dnsRecordsCache.content, providerName: dnsProviders.name })
.from(dnsRecordsCache)
.innerJoin(dnsProviders, eq(dnsRecordsCache.providerId, dnsProviders.id)),
db.select({ syncedAt: dnsZonesCache.syncedAt }).from(dnsZonesCache),
]);
const serverInputs = serverRows.map((s) => ({ id: s.id, name: s.name, hostname: s.hostname, ips: parseIps(s.ips) }));
const { consistency } = await getSettings();
const excludedRanges = consistency.excludedRanges;
const findings = buildFindings({ servers: serverInputs, ipam: ipamRows, dns: dnsRows, excludedRanges });
const hiddenAddresses = countHiddenAddresses({ servers: serverInputs, ipam: ipamRows, dns: dnsRows }, excludedRanges);
const synced = zoneRows.map((z) => z.syncedAt).filter((t): t is string => !!t).sort();
const sources = {
servers: { total: serverInputs.length, withAddresses: serverInputs.filter((s) => s.ips.length > 0).length },
ipam: ipamRows.length,
dns: {
zones: zoneRows.length,
syncedZones: synced.length,
records: dnsRows.length,
oldestSyncedAt: synced[0] ?? null,
newestSyncedAt: synced[synced.length - 1] ?? null,
},
};
return { findings, sources, excludedRanges, hiddenAddresses };
}
consistencyRouter.get("/", asyncHandler(async (_req, res) => {
const { findings, sources, excludedRanges, hiddenAddresses } = await computeFindings();
const ignores = await db.select().from(consistencyIgnores).orderBy(consistencyIgnores.createdAt);
const ignoredKeys = new Set(ignores.map((i) => i.key));
const present = new Set(findings.map((f) => f.key));
const active = findings.filter((f) => !ignoredKeys.has(f.key));
const counts = { error: 0, warning: 0, info: 0 };
for (const f of active) counts[f.severity]++;
res.json({
findings: active,
counts,
ignored: ignores.map((i) => ({ ...i, stillPresent: present.has(i.key) })),
sources,
excludedRanges,
hiddenAddresses,
generatedAt: new Date().toISOString(),
});
}));
const rangesSchema = z.object({ ranges: z.array(z.string().max(100)).max(200) });
// Managed here rather than in admin-only Settings: like ignoring a finding, it's a judgement about the network that
// whoever is looking at the report is best placed to make.
consistencyRouter.put("/excluded-ranges", requireRole("operator"), asyncHandler(async (req, res) => {
const parsed = rangesSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Ranges must be a list of text.", details: parsed.error.flatten() });
let ranges: string[];
try {
ranges = [...new Set(parsed.data.ranges.filter((r) => r.trim()).map(normalizeRange))];
} catch (err) {
if (err instanceof InvalidRangeError) return res.status(400).json({ error: "invalid_range", message: err.message });
throw err;
}
if (ranges.length > MAX_EXCLUDED_RANGES) {
return res.status(400).json({ error: "too_many", message: `At most ${MAX_EXCLUDED_RANGES} ranges.` });
}
const before = (await getSettings()).consistency.excludedRanges;
await updateSettings({ consistency: { excludedRanges: ranges } });
await recordAudit({
actor: req.currentUser!,
category: "consistency",
action: "set_excluded_ranges",
targetType: "consistency_settings",
detail: { before, after: ranges },
});
res.json({ excludedRanges: ranges });
}));
const ignoreSchema = z.object({ key: z.string().min(1).max(500), reason: z.string().trim().max(300).optional() });
// The key must belong to a finding that exists right now, and the stored title comes from that finding rather than the
// request — so the ignore list can't be filled with arbitrary text.
consistencyRouter.post("/ignore", requireRole("operator"), asyncHandler(async (req, res) => {
const parsed = ignoreSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Missing finding.", details: parsed.error.flatten() });
const { findings } = await computeFindings();
const finding = findings.find((f) => f.key === parsed.data.key);
if (!finding) return res.status(404).json({ error: "not_found", message: "That finding no longer exists — refresh the report." });
const [existing] = await db.select({ id: consistencyIgnores.id }).from(consistencyIgnores).where(eq(consistencyIgnores.key, finding.key)).limit(1);
if (existing) return res.status(409).json({ error: "already_ignored", message: "That finding is already ignored." });
const [row] = await db
.insert(consistencyIgnores)
.values({ key: finding.key, title: finding.title, reason: parsed.data.reason || null, createdBy: req.currentUser!.email ?? req.currentUser!.name ?? req.currentUser!.oidcSub })
.returning();
await recordAudit({
actor: req.currentUser!,
category: "consistency",
action: "ignore",
targetType: "consistency_finding",
targetId: row.id,
detail: { key: finding.key, title: finding.title, reason: row.reason },
});
res.status(201).json({ ignore: row });
}));
consistencyRouter.delete("/ignore/:id", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const deleted = await db.delete(consistencyIgnores).where(eq(consistencyIgnores.id, id)).returning();
if (deleted.length === 0) return res.status(404).json({ error: "not_found" });
await recordAudit({
actor: req.currentUser!,
category: "consistency",
action: "unignore",
targetType: "consistency_finding",
targetId: id,
detail: { key: deleted[0].key, title: deleted[0].title },
});
res.status(204).end();
}));
+26
View File
@@ -0,0 +1,26 @@
import { Router } from "express";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { getDiagEntries, clearDiagLog } from "../services/diagLog.js";
import { recordAudit } from "../services/audit.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const diagLogRouter = Router();
diagLogRouter.use(requireAuth, requireRole("admin"));
diagLogRouter.get("/", asyncHandler(async (req, res) => {
const { source, ok, limit, offset } = req.query;
const result = await getDiagEntries({
source: typeof source === "string" && source ? source : undefined,
ok: ok === "true" ? true : ok === "false" ? false : undefined,
limit: Math.min(Number(limit) || 100, 200),
offset: Number(offset) || 0,
});
res.json(result);
}));
diagLogRouter.delete("/", asyncHandler(async (req, res) => {
await clearDiagLog();
await recordAudit({ actor: req.currentUser!, category: "diag_log", action: "clear" });
res.status(204).end();
}));
+56
View File
@@ -13,7 +13,18 @@ import {
} from "../dns/providerSchemas.js"; } from "../dns/providerSchemas.js";
import { createDnsAdapter } from "../dns/registry.js"; import { createDnsAdapter } from "../dns/registry.js";
import { loadDnsProviderConfig, getDnsAdapterForProvider } from "../dns/loadProvider.js"; import { loadDnsProviderConfig, getDnsAdapterForProvider } from "../dns/loadProvider.js";
import { getDnsStats } from "../dns/stats.js";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
import { notifyDnsRecordAdded, notifyDnsRecordUpdated, notifyDnsRecordDeleted } from "../services/notify.js";
async function zoneNameFor(providerId: number, zoneId: string): Promise<string> {
const [zone] = await db
.select()
.from(dnsZonesCache)
.where(and(eq(dnsZonesCache.providerId, providerId), eq(dnsZonesCache.zoneId, zoneId)))
.limit(1);
return zone?.zoneName ?? zoneId;
}
export const dnsRouter = Router(); export const dnsRouter = Router();
@@ -25,6 +36,12 @@ dnsRouter.get("/provider-fields", (_req, res) => {
res.json({ fields: DNS_PROVIDER_FIELDS }); res.json({ fields: DNS_PROVIDER_FIELDS });
}); });
// ─── Dashboard stats ─────────────────────────────────────────────────────────
dnsRouter.get("/stats", asyncHandler(async (_req, res) => {
res.json(await getDnsStats());
}));
// ─── Providers ─────────────────────────────────────────────────────────────── // ─── Providers ───────────────────────────────────────────────────────────────
dnsRouter.get("/providers", asyncHandler(async (_req, res) => { dnsRouter.get("/providers", asyncHandler(async (_req, res) => {
@@ -383,6 +400,9 @@ dnsRouter.post("/providers/:id/zones/:zoneId/records", requireRole("operator"),
targetId: result.id, targetId: result.id,
detail: { providerId, zoneId, name: result.name, type: result.type }, detail: { providerId, zoneId, name: result.name, type: result.type },
}); });
notifyDnsRecordAdded(found.provider.name, await zoneNameFor(providerId, zoneId), result).catch((err) =>
console.error("[dns] add-record notification failed:", err),
);
res.status(201).json({ record: result }); res.status(201).json({ record: result });
} catch (err) { } catch (err) {
@@ -433,6 +453,9 @@ dnsRouter.put("/providers/:id/zones/:zoneId/records/:recordId", requireRole("ope
targetId: result.id, targetId: result.id,
detail: { providerId, zoneId, name: result.name, type: result.type }, detail: { providerId, zoneId, name: result.name, type: result.type },
}); });
notifyDnsRecordUpdated(found.provider.name, await zoneNameFor(providerId, zoneId), result).catch((err) =>
console.error("[dns] update-record notification failed:", err),
);
res.json({ record: result }); res.json({ record: result });
} catch (err) { } catch (err) {
@@ -448,6 +471,18 @@ dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole("
if (!found) return res.status(404).json({ error: "not_found" }); if (!found) return res.status(404).json({ error: "not_found" });
try { try {
const [existingCached] = await db
.select()
.from(dnsRecordsCache)
.where(
and(
eq(dnsRecordsCache.providerId, providerId),
eq(dnsRecordsCache.zoneId, zoneId),
eq(dnsRecordsCache.recordId, recordId),
),
)
.limit(1);
await found.adapter.deleteRecord(zoneId, recordId); await found.adapter.deleteRecord(zoneId, recordId);
await db await db
.delete(dnsRecordsCache) .delete(dnsRecordsCache)
@@ -467,9 +502,30 @@ dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole("
targetId: recordId, targetId: recordId,
detail: { providerId, zoneId }, detail: { providerId, zoneId },
}); });
if (existingCached) {
notifyDnsRecordDeleted(found.provider.name, await zoneNameFor(providerId, zoneId), existingCached).catch((err) =>
console.error("[dns] delete-record notification failed:", err),
);
}
res.status(204).end(); res.status(204).end();
} catch (err) { } catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
} }
})); }));
// ─── Cache ───────────────────────────────────────────────────────────────────
dnsRouter.post("/cache/clear", requireRole("admin"), asyncHandler(async (req, res) => {
await db.delete(dnsRecordsCache);
await db.delete(dnsZonesCache);
await recordAudit({
actor: req.currentUser!,
category: "dns",
action: "clear_cache",
targetType: "dns_cache",
});
res.json({ ok: true });
}));
+89
View File
@@ -0,0 +1,89 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { domains } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { addManualDomain, checkDomain, domainStatus, isDomainCheckRunning, refreshAllDomains, type DomainRow } from "../services/domainMonitor.js";
import { getSettings } from "../services/settingsStore.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const domainsRouter = Router();
domainsRouter.use(requireAuth);
function present(row: DomainRow, warnDays: number) {
return { ...row, ...domainStatus(row, warnDays) };
}
async function listPresented() {
const { healthChecks } = await getSettings();
const rows = await db.select().from(domains).orderBy(domains.name);
return { domains: rows.map((r) => present(r, healthChecks.domainWarnDays)), warnDays: healthChecks.domainWarnDays, checking: isDomainCheckRunning() };
}
domainsRouter.get("/", asyncHandler(async (_req, res) => {
res.json(await listPresented());
}));
const addSchema = z.object({ name: z.string().min(1).max(253) });
domainsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => {
const parsed = addSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Enter a domain name.", details: parsed.error.flatten() });
const result = await addManualDomain(parsed.data.name);
if (!result.ok) return res.status(result.status).json({ error: "cannot_add", message: result.message });
await recordAudit({
actor: req.currentUser!,
category: "domain",
action: "add",
targetType: "domain",
targetId: result.row.id,
detail: { name: result.row.name, expiresAt: result.row.expiresAt },
});
const { healthChecks } = await getSettings();
res.status(201).json({ domain: present(result.row, healthChecks.domainWarnDays), resolvedFrom: result.resolvedFrom });
}));
// Registered before "/:id/..." so "refresh" isn't taken for an id.
domainsRouter.post("/refresh", requireRole("operator"), asyncHandler(async (req, res) => {
const started = await refreshAllDomains();
if (!started) return res.status(409).json({ error: "already_running", message: "A domain check is already running." });
await recordAudit({ actor: req.currentUser!, category: "domain", action: "refresh_all", targetType: "domain", detail: {} });
res.json(await listPresented());
}));
domainsRouter.post("/:id/check", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const row = await checkDomain(id);
if (!row) return res.status(404).json({ error: "not_found" });
await recordAudit({
actor: req.currentUser!,
category: "domain",
action: "check",
targetType: "domain",
targetId: id,
detail: { name: row.name, error: row.lastCheckError },
});
const { healthChecks } = await getSettings();
res.json({ domain: present(row, healthChecks.domainWarnDays) });
}));
domainsRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const [row] = await db.select().from(domains).where(eq(domains.id, id)).limit(1);
if (!row) return res.status(404).json({ error: "not_found" });
if (row.origin === "zone") {
return res.status(409).json({
error: "zone_domain",
message: `${row.name} is tracked because a DNS zone for it is configured. It goes away by itself when that zone is removed.`,
});
}
await db.delete(domains).where(eq(domains.id, id));
await recordAudit({ actor: req.currentUser!, category: "domain", action: "remove", targetType: "domain", targetId: id, detail: { name: row.name } });
res.status(204).end();
}));
+128
View File
@@ -0,0 +1,128 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { getSettings, updateSettings } from "../services/settingsStore.js";
import {
DEFAULT_THEME_IDS,
InvalidThemesError,
MAX_NAME_LENGTH,
cleanThemes,
defaultThemes,
importSkatteverketNames,
readStoredThemes,
type NameTheme,
type NameThemeSource,
} from "../services/nameThemes.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const generatorRouter = Router();
generatorRouter.use(requireAuth);
async function currentThemes(): Promise<NameTheme[]> {
return readStoredThemes((await getSettings()).nameGenerator.themes);
}
// Read by everyone signed in — the Generator page needs the lists to pick from. Editing them is a Settings matter.
generatorRouter.get("/themes", asyncHandler(async (_req, res) => {
res.json({ themes: await currentThemes(), builtinIds: DEFAULT_THEME_IDS });
}));
generatorRouter.get("/themes/defaults", requireRole("admin"), (_req, res) => {
res.json({ themes: defaultThemes() });
});
const sourceSchema = z.object({
kind: z.literal("skatteverket"),
sex: z.enum(["girls", "boys"]),
years: z.array(z.number().int()).max(10),
count: z.number().int(),
importedAt: z.string().max(40),
});
const saveSchema = z.object({
themes: z
.array(
z.object({
id: z.string().max(40).optional(),
label: z.string().max(200),
names: z.array(z.string().max(MAX_NAME_LENGTH * 3)).max(10000),
lastImport: sourceSchema.optional(),
}),
)
.max(100),
});
/** A short, readable account of what an edit changed, for the audit log. */
function describeThemeChanges(before: NameTheme[], after: NameTheme[]) {
const beforeById = new Map(before.map((t) => [t.id, t]));
const afterIds = new Set(after.map((t) => t.id));
const created = after.filter((t) => !beforeById.has(t.id)).map((t) => `${t.label} (${t.names.length} names)`);
const deleted = before.filter((t) => !afterIds.has(t.id)).map((t) => t.label);
const edited: { list: string; renamedFrom?: string; added: number; removed: number }[] = [];
for (const t of after) {
const old = beforeById.get(t.id);
if (!old) continue;
const had = new Set(old.names);
const has = new Set(t.names);
const added = t.names.filter((n) => !had.has(n)).length;
const removed = old.names.filter((n) => !has.has(n)).length;
if (added || removed || old.label !== t.label) edited.push({ list: t.label, ...(old.label !== t.label ? { renamedFrom: old.label } : {}), added, removed });
}
return { created, deleted, edited };
}
generatorRouter.put("/themes", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = saveSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "That doesn't look like a set of name lists.", details: parsed.error.flatten() });
let themes: NameTheme[];
try {
themes = cleanThemes(parsed.data.themes);
} catch (err) {
if (err instanceof InvalidThemesError) return res.status(400).json({ error: "invalid_names", message: err.message, invalid: err.invalid });
throw err;
}
const before = await currentThemes();
const changes = describeThemeChanges(before, themes);
await updateSettings({ nameGenerator: { themes } });
if (changes.created.length || changes.deleted.length || changes.edited.length) {
await recordAudit({
actor: req.currentUser!,
category: "settings",
action: "update_name_lists",
targetType: "name_generator",
detail: changes,
});
}
res.json({ themes });
}));
const importSchema = z.object({
sex: z.enum(["girls", "boys"]),
years: z.number().int().min(1).max(5),
count: z.number().int().min(10).max(500),
});
// Only fetches and returns a preview — nothing is stored until the editor's own Save, so an import can be looked at (and
// thrown away) first. The address is fixed; none of the request's values go into it unchecked.
generatorRouter.post("/themes/import", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = importSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Pick girls or boys, 1–5 years and 10–500 names.", details: parsed.error.flatten() });
try {
const result = await importSkatteverketNames(parsed.data.sex, parsed.data.years, parsed.data.count);
const source: NameThemeSource = {
kind: "skatteverket",
sex: parsed.data.sex,
years: result.years,
count: parsed.data.count,
importedAt: new Date().toISOString(),
};
res.json({ names: result.names, source, missingYears: result.missingYears, skipped: result.skipped });
} catch (err) {
res.status(502).json({ error: "import_failed", message: err instanceof Error ? err.message : String(err) });
}
}));
+272 -6
View File
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from "express";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { integrations, integrationCredentials, integrationTypes } from "../db/schema.js"; import { integrations, integrationCredentials, integrationTypes, servers } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js"; import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js"; import { recordAudit } from "../services/audit.js";
import { encryptSecret } from "../crypto.js"; import { encryptSecret } from "../crypto.js";
@@ -14,12 +14,16 @@ import {
} from "../integrations/fieldSchemas.js"; } from "../integrations/fieldSchemas.js";
import { createIntegrationAdapter } from "../integrations/registry.js"; import { createIntegrationAdapter } from "../integrations/registry.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js"; import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js"; import { createTailscaleAdapter, isKeyExpiringSoon } from "../integrations/tailscale/adapter.js";
import { createGiteaAdapter } from "../integrations/gitea/adapter.js"; import { createGiteaAdapter } from "../integrations/gitea/adapter.js";
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js"; import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js"; import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js";
import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js"; import { createProxmoxAdapter, guestsWithoutBackupCoverage } from "../integrations/proxmox/adapter.js";
import { createSynologyAdapter } from "../integrations/synology/adapter.js"; import { createSynologyAdapter } from "../integrations/synology/adapter.js";
import { createUptimeKumaAdapter } from "../integrations/uptimekuma/adapter.js";
import { createPbsAdapter } from "../integrations/pbs/adapter.js";
import { createOsTicketAdapter } from "../integrations/osticket/adapter.js";
import { attachMatchedServers, summarizeMonitors, toMatchableServers } from "../services/uptimeKumaMatch.js";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
export const integrationsRouter = Router(); export const integrationsRouter = Router();
@@ -135,10 +139,18 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
let credentialId = existing.credentialId; let credentialId = existing.credentialId;
let configJson = existing.config; let configJson = existing.config;
let baseUrl = existing.baseUrl; let baseUrl = existing.baseUrl;
// For the audit entry: what this edit actually changed. Names only for settings (operators can read the audit
// log but not an integration's config), and never anything about the credentials beyond "they were replaced".
let credentialsReplaced = false;
let fieldsChanged: string[] = [];
if (parsed.data.config) { if (parsed.data.config) {
const loaded = await loadIntegrationConfig(id); const loaded = await loadIntegrationConfig(id);
const { secretFields, nonSecretFields } = splitIntegrationConfig(existing.type, parsed.data.config); const { secretFields, nonSecretFields } = splitIntegrationConfig(existing.type, parsed.data.config);
credentialsReplaced = Object.keys(secretFields).length > 0;
fieldsChanged = Object.keys(nonSecretFields).filter(
(key) => String(loaded?.config[key] ?? "") !== String(nonSecretFields[key] ?? ""),
);
const mergedNonSecret = { ...(loaded?.config ?? {}), ...nonSecretFields }; const mergedNonSecret = { ...(loaded?.config ?? {}), ...nonSecretFields };
for (const field of INTEGRATION_FIELDS[existing.type] ?? []) { for (const field of INTEGRATION_FIELDS[existing.type] ?? []) {
if (field.secret) delete (mergedNonSecret as Record<string, unknown>)[field.key]; if (field.secret) delete (mergedNonSecret as Record<string, unknown>)[field.key];
@@ -184,7 +196,13 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
action: "update", action: "update",
targetType: "integration", targetType: "integration",
targetId: id, targetId: id,
detail: { name: updated.name }, detail: {
name: updated.name,
...(existing.name !== updated.name ? { renamedFrom: existing.name } : {}),
...(existing.enabled !== updated.enabled ? { enabled: updated.enabled } : {}),
credentialsReplaced,
fieldsChanged,
},
}); });
res.json({ res.json({
@@ -199,6 +217,54 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
}); });
})); }));
integrationsRouter.get("/:id/config", requireRole("admin"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const loaded = await loadIntegrationConfig(id);
if (!loaded) {
return res.status(404).json({ error: "not_found" });
}
// Never return secret fields (API tokens/passwords) to the browser — the edit
// form pre-fills only non-secret fields and leaves secret inputs blank.
const nonSecretConfig: Record<string, string | boolean | undefined> = { ...loaded.config };
for (const field of INTEGRATION_FIELDS[loaded.integration.type] ?? []) {
if (field.secret) delete nonSecretConfig[field.key];
}
res.json({
integration: {
id: loaded.integration.id,
type: loaded.integration.type,
name: loaded.integration.name,
enabled: loaded.integration.enabled,
},
config: nonSecretConfig,
});
}));
const testExistingIntegrationSchema = z.object({ config: z.record(configValueSchema).optional() });
integrationsRouter.post("/:id/test", requireRole("admin"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const parsed = testExistingIntegrationSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const loaded = await loadIntegrationConfig(id);
if (!loaded) {
return res.status(404).json({ error: "not_found" });
}
// Merge any freshly-typed fields (e.g. a replacement token) over the
// already-stored, decrypted config — lets "Test connection" work during an
// edit without ever sending the current secret value back to the browser.
const mergedConfig = { ...loaded.config, ...(parsed.data.config ?? {}) };
const adapter = createIntegrationAdapter(loaded.integration.type, mergedConfig);
const result = await adapter.ping();
res.json(result);
}));
integrationsRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => { integrationsRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const [existing] = await db.select().from(integrations).where(eq(integrations.id, id)).limit(1); const [existing] = await db.select().from(integrations).where(eq(integrations.id, id)).limit(1);
@@ -206,6 +272,14 @@ integrationsRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req,
return res.status(404).json({ error: "not_found" }); return res.status(404).json({ error: "not_found" });
} }
// servers.proxmox_integration_id was meant to clear itself, but the database only has a plain REFERENCES on it (see
// DATABASE.md), so a linked server would block the delete. Clear the whole link here — the four columns go together.
const unlinked = await db
.update(servers)
.set({ proxmoxIntegrationId: null, proxmoxNode: null, proxmoxGuestType: null, proxmoxVmid: null })
.where(eq(servers.proxmoxIntegrationId, id))
.returning({ id: servers.id });
await db.delete(integrations).where(eq(integrations.id, id)); await db.delete(integrations).where(eq(integrations.id, id));
if (existing.credentialId) { if (existing.credentialId) {
await db.delete(integrationCredentials).where(eq(integrationCredentials.id, existing.credentialId)); await db.delete(integrationCredentials).where(eq(integrationCredentials.id, existing.credentialId));
@@ -217,7 +291,7 @@ integrationsRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req,
action: "delete", action: "delete",
targetType: "integration", targetType: "integration",
targetId: id, targetId: id,
detail: { name: existing.name }, detail: { name: existing.name, ...(unlinked.length > 0 ? { unlinkedServers: unlinked.length } : {}) },
}); });
res.status(204).end(); res.status(204).end();
@@ -279,6 +353,7 @@ integrationsRouter.get("/:id/tailscale/devices", asyncHandler(async (req, res) =
total: devices.length, total: devices.length,
online: devices.filter((d) => d.online).length, online: devices.filter((d) => d.online).length,
unauthorized: devices.filter((d) => !d.authorized).length, unauthorized: devices.filter((d) => !d.authorized).length,
expiringSoon: devices.filter((d) => isKeyExpiringSoon(d)).length,
}, },
}); });
} catch (err) { } catch (err) {
@@ -440,6 +515,30 @@ integrationsRouter.get("/:id/dockhand/containers", asyncHandler(async (req, res)
} }
})); }));
integrationsRouter.post(
"/:id/dockhand/check-updates",
requireRole("operator"),
asyncHandler(async (req, res) => {
const found = await requireDockhandAdapter(req, res);
if (!found) return;
try {
const result = await found.adapter.checkForUpdates();
await recordAudit({
actor: req.currentUser!,
category: "integration",
action: "check_updates",
targetType: "dockhand_integration",
targetId: found.integration.id,
detail: result,
});
res.json(result);
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}),
);
const dockhandActions = ["start", "stop", "restart"] as const; const dockhandActions = ["start", "stop", "restart"] as const;
for (const action of dockhandActions) { for (const action of dockhandActions) {
@@ -579,7 +678,35 @@ integrationsRouter.get("/:id/proxmox/guests", asyncHandler(async (req, res) => {
} }
})); }));
const proxmoxActions = ["start", "stop", "restart"] as const; integrationsRouter.get("/:id/proxmox/nodes", asyncHandler(async (req, res) => {
const found = await requireProxmoxAdapter(req, res);
if (!found) return;
try {
const nodes = await found.adapter.listNodeStats();
res.json({ nodes });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
integrationsRouter.get("/:id/proxmox/backups", asyncHandler(async (req, res) => {
const found = await requireProxmoxAdapter(req, res);
if (!found) return;
try {
const [jobs, tasks, guests] = await Promise.all([
found.adapter.listBackupJobs(),
found.adapter.listRecentBackupTasks(),
found.adapter.listGuests(),
]);
res.json({ jobs, tasks, uncoveredGuests: guestsWithoutBackupCoverage(guests, jobs) });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
const proxmoxActions = ["start", "stop", "restart", "shutdown"] as const;
for (const action of proxmoxActions) { for (const action of proxmoxActions) {
integrationsRouter.post( integrationsRouter.post(
@@ -656,3 +783,142 @@ integrationsRouter.get("/:id/synology/storage", asyncHandler(async (req, res) =>
res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
} }
})); }));
integrationsRouter.get("/:id/synology/system", asyncHandler(async (req, res) => {
const found = await requireSynologyAdapter(req, res);
if (!found) return;
try {
const info = await found.adapter.getSystemInfo();
res.json(info);
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
// ─── Uptime Kuma ─────────────────────────────────────────────────────────────
async function requireUptimeKumaAdapter(req: Request, res: Response) {
const id = Number(req.params.id);
const loaded = await loadIntegrationConfig(id);
if (!loaded) {
res.status(404).json({ error: "not_found" });
return null;
}
if (loaded.integration.type !== "uptimekuma") {
res.status(400).json({ error: "wrong_type" });
return null;
}
if (!loaded.integration.enabled) {
res.status(400).json({ error: "integration_disabled" });
return null;
}
return { integration: loaded.integration, adapter: createUptimeKumaAdapter(loaded.config as any) };
}
integrationsRouter.get("/:id/uptimekuma/monitors", asyncHandler(async (req, res) => {
const found = await requireUptimeKumaAdapter(req, res);
if (!found) return;
try {
const monitors = await found.adapter.listMonitors();
const serverRows = await db.select({ id: servers.id, name: servers.name, hostname: servers.hostname, ips: servers.ipAddresses }).from(servers);
const withServers = attachMatchedServers(monitors, toMatchableServers(serverRows));
res.json({ monitors: withServers, summary: summarizeMonitors(monitors) });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
// ─── Proxmox Backup Server ───────────────────────────────────────────────────
// Read-only — no destructive actions (pruning/GC/deleting snapshots) are exposed.
async function requirePbsAdapter(req: Request, res: Response) {
const id = Number(req.params.id);
const loaded = await loadIntegrationConfig(id);
if (!loaded) {
res.status(404).json({ error: "not_found" });
return null;
}
if (loaded.integration.type !== "pbs") {
res.status(400).json({ error: "wrong_type" });
return null;
}
if (!loaded.integration.enabled) {
res.status(400).json({ error: "integration_disabled" });
return null;
}
return { integration: loaded.integration, adapter: createPbsAdapter(loaded.config as any) };
}
integrationsRouter.get("/:id/pbs/datastores", asyncHandler(async (req, res) => {
const found = await requirePbsAdapter(req, res);
if (!found) return;
try {
const datastores = await found.adapter.listDatastores();
res.json({
datastores,
summary: {
datastoreCount: datastores.length,
failedSnapshotCount: datastores.reduce((sum, d) => sum + d.failedCount, 0),
unverifiedSnapshotCount: datastores.reduce((sum, d) => sum + d.unverifiedCount, 0),
},
});
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
integrationsRouter.get("/:id/pbs/status", asyncHandler(async (req, res) => {
const found = await requirePbsAdapter(req, res);
if (!found) return;
try {
const status = await found.adapter.getNodeStatus();
res.json(status);
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
// ─── osTicket ────────────────────────────────────────────────────────────────
// Read-only, and the only integration that reads a database directly rather
// than an HTTP API — see server/src/integrations/osticket/adapter.ts for why.
async function requireOsTicketAdapter(req: Request, res: Response) {
const id = Number(req.params.id);
const loaded = await loadIntegrationConfig(id);
if (!loaded) {
res.status(404).json({ error: "not_found" });
return null;
}
if (loaded.integration.type !== "osticket") {
res.status(400).json({ error: "wrong_type" });
return null;
}
if (!loaded.integration.enabled) {
res.status(400).json({ error: "integration_disabled" });
return null;
}
return { integration: loaded.integration, adapter: createOsTicketAdapter(loaded.config as any) };
}
integrationsRouter.get("/:id/osticket/tickets", asyncHandler(async (req, res) => {
const found = await requireOsTicketAdapter(req, res);
if (!found) return;
try {
const tickets = await found.adapter.listOpenTickets();
res.json({
tickets,
summary: {
total: tickets.length,
overdue: tickets.filter((t) => t.isOverdue).length,
awaitingReply: tickets.filter((t) => !t.isAnswered).length,
},
});
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
+241 -6
View File
@@ -1,21 +1,51 @@
import { Router } from "express"; import { Router } from "express";
import { isIP } from "node:net"; import { isIP } from "node:net";
import { eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { ipamEntries } from "../db/schema.js"; import { ipamEntries, integrations, dnsRecordsCache } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js"; import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js"; import { recordAudit } from "../services/audit.js";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js";
import { createPhpIpamAdapter } from "../integrations/phpipam/adapter.js";
import { makeExclusion } from "../services/consistency.js";
import { getSettings } from "../services/settingsStore.js";
export const ipamRouter = Router(); export const ipamRouter = Router();
ipamRouter.use(requireAuth); ipamRouter.use(requireAuth);
async function matchingDnsRecordsByIp(ips: string[]): Promise<Map<string, string[]>> {
const byIp = new Map<string, string[]>();
if (ips.length === 0) return byIp;
const rows = await db
.select({ ip: dnsRecordsCache.content, name: dnsRecordsCache.name })
.from(dnsRecordsCache)
.where(inArray(dnsRecordsCache.content, ips));
for (const row of rows) {
const list = byIp.get(row.ip) ?? [];
list.push(row.name);
byIp.set(row.ip, list);
}
return byIp;
}
ipamRouter.get("/", asyncHandler(async (_req, res) => { ipamRouter.get("/", asyncHandler(async (_req, res) => {
const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress); const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress);
// matchingDnsRecords will be populated once the DNS module (phase 3) has cached records for cross-reference. const byIp = await matchingDnsRecordsByIp(rows.map((r) => r.ipAddress));
res.json({ entries: rows.map((r) => ({ ...r, matchingDnsRecords: [] as string[] })) }); // Same excluded-ranges setting the Consistency page manages — "not interesting" addresses (Docker's bridge
// network repeating on every host, say) can be hidden here too, without duplicating that configuration.
const { consistency } = await getSettings();
const excluded = makeExclusion(consistency.excludedRanges);
res.json({
entries: rows.map((r) => ({ ...r, matchingDnsRecords: byIp.get(r.ipAddress) ?? [], excluded: excluded(r.ipAddress) })),
excludedRanges: consistency.excludedRanges,
});
})); }));
const createInput = z.object({ const createInput = z.object({
@@ -54,7 +84,8 @@ ipamRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => {
detail: { ipAddress: created.ipAddress }, detail: { ipAddress: created.ipAddress },
}); });
res.status(201).json({ entry: { ...created, matchingDnsRecords: [] } }); const byIp = await matchingDnsRecordsByIp([created.ipAddress]);
res.status(201).json({ entry: { ...created, matchingDnsRecords: byIp.get(created.ipAddress) ?? [] } });
})); }));
ipamRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res) => { ipamRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res) => {
@@ -84,7 +115,8 @@ ipamRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res)
detail: { ipAddress: updated.ipAddress }, detail: { ipAddress: updated.ipAddress },
}); });
res.json({ entry: { ...updated, matchingDnsRecords: [] } }); const byIp = await matchingDnsRecordsByIp([updated.ipAddress]);
res.json({ entry: { ...updated, matchingDnsRecords: byIp.get(updated.ipAddress) ?? [] } });
})); }));
ipamRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { ipamRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => {
@@ -107,3 +139,206 @@ ipamRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res)
res.status(204).end(); res.status(204).end();
})); }));
/**
* Inserts a new IPAM entry for `ip`, or updates one this same sync source
* previously created — but never touches an entry that already exists from
* a manual entry or a different sync source, so two syncs (or a sync and a
* human) can never clobber each other.
*/
async function upsertSyncedEntry(
ip: string,
label: string,
vendor: string,
notes: string | null,
location: string | null,
source: string,
): Promise<"added" | "updated" | "skipped"> {
const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.ipAddress, ip)).limit(1);
if (!existing) {
await db.insert(ipamEntries).values({ ipAddress: ip, label, vendor, notes, location, source });
return "added";
}
if (existing.source === source) {
await db
.update(ipamEntries)
.set({ label, notes, location, updatedAt: new Date().toISOString() })
.where(eq(ipamEntries.id, existing.id));
return "updated";
}
return "skipped";
}
ipamRouter.post("/sync-tailscale", requireRole("operator"), asyncHandler(async (req, res) => {
const tailscaleIntegrations = await db
.select()
.from(integrations)
.where(and(eq(integrations.type, "tailscale"), eq(integrations.enabled, true)));
if (tailscaleIntegrations.length === 0) {
return res.status(400).json({ error: "no_tailscale_integration" });
}
let added = 0;
let updated = 0;
let skipped = 0;
const skippedIps: string[] = [];
const errors: string[] = [];
for (const integration of tailscaleIntegrations) {
const loaded = await loadIntegrationConfig(integration.id);
if (!loaded || loaded.integration.type !== "tailscale") continue;
let devices;
try {
const adapter = createTailscaleAdapter(loaded.config as { tailnet: string; apiKey: string });
devices = await adapter.listDevices();
} catch (err) {
errors.push(`${integration.name}: ${err instanceof Error ? err.message : String(err)}`);
continue;
}
for (const device of devices) {
const ip = device.primaryAddress;
if (!ip) continue;
const label = device.label || device.hostname || ip;
const notes = device.os ? `OS: ${device.os}` : null;
const result = await upsertSyncedEntry(ip, label, "Tailscale", notes, null, "tailscale");
if (result === "added") added++;
else if (result === "updated") updated++;
else {
skipped++;
skippedIps.push(ip);
}
}
}
await recordAudit({
actor: req.currentUser!,
category: "ipam",
action: "sync_tailscale",
detail: { added, updated, skipped },
});
res.json({ added, updated, skipped, skippedIps, errors });
}));
ipamRouter.post("/sync-proxmox", requireRole("operator"), asyncHandler(async (req, res) => {
const proxmoxIntegrations = await db
.select()
.from(integrations)
.where(and(eq(integrations.type, "proxmox"), eq(integrations.enabled, true)));
if (proxmoxIntegrations.length === 0) {
return res.status(400).json({ error: "no_proxmox_integration" });
}
let added = 0;
let updated = 0;
let skipped = 0;
const skippedIps: string[] = [];
const errors: string[] = [];
for (const integration of proxmoxIntegrations) {
const loaded = await loadIntegrationConfig(integration.id);
if (!loaded || loaded.integration.type !== "proxmox") continue;
const adapter = createProxmoxAdapter(
loaded.config as { url: string; tokenId: string; tokenSecret: string; insecure?: boolean },
);
let guests;
try {
guests = await adapter.listGuests();
} catch (err) {
errors.push(`${integration.name}: ${err instanceof Error ? err.message : String(err)}`);
continue;
}
for (const guest of guests) {
let detail;
try {
detail = await adapter.getGuestDetail(guest.node, guest.type, guest.vmid);
} catch (err) {
errors.push(`${integration.name}/${guest.name}: ${err instanceof Error ? err.message : String(err)}`);
continue;
}
const label = guest.name || `${guest.type}/${guest.vmid}`;
const notes = `Proxmox ${guest.type === "qemu" ? "VM" : "LXC"} #${guest.vmid} on ${guest.node}`;
for (const ip of detail.ipAddresses) {
const result = await upsertSyncedEntry(ip, label, "Proxmox", notes, null, "proxmox");
if (result === "added") added++;
else if (result === "updated") updated++;
else {
skipped++;
skippedIps.push(ip);
}
}
}
}
await recordAudit({
actor: req.currentUser!,
category: "ipam",
action: "sync_proxmox",
detail: { added, updated, skipped },
});
res.json({ added, updated, skipped, skippedIps, errors });
}));
ipamRouter.post("/sync-phpipam", requireRole("operator"), asyncHandler(async (req, res) => {
const phpIpamIntegrations = await db
.select()
.from(integrations)
.where(and(eq(integrations.type, "phpipam"), eq(integrations.enabled, true)));
if (phpIpamIntegrations.length === 0) {
return res.status(400).json({ error: "no_phpipam_integration" });
}
let added = 0;
let updated = 0;
let skipped = 0;
const skippedIps: string[] = [];
const errors: string[] = [];
for (const integration of phpIpamIntegrations) {
const loaded = await loadIntegrationConfig(integration.id);
if (!loaded || loaded.integration.type !== "phpipam") continue;
let addresses;
try {
const adapter = createPhpIpamAdapter(loaded.config as { url: string; appId: string; token: string; insecure?: boolean });
addresses = await adapter.listAddresses();
} catch (err) {
errors.push(`${integration.name}: ${err instanceof Error ? err.message : String(err)}`);
continue;
}
for (const addr of addresses) {
const label = addr.hostname || addr.description || addr.ip;
const notes = [addr.description, addr.note, addr.mac ? `MAC ${addr.mac}` : null].filter((v): v is string => !!v).join(" — ") || null;
const result = await upsertSyncedEntry(addr.ip, label, "phpIPAM", notes, addr.subnetLabel, "phpipam");
if (result === "added") added++;
else if (result === "updated") updated++;
else {
skipped++;
skippedIps.push(addr.ip);
}
}
}
await recordAudit({
actor: req.currentUser!,
category: "ipam",
action: "sync_phpipam",
detail: { added, updated, skipped },
});
res.json({ added, updated, skipped, skippedIps, errors });
}));
+159
View File
@@ -0,0 +1,159 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { dnsProviders, integrations, maintenanceTargetTypes, maintenanceWindows, servers } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { describeTarget, listActiveWindows, startOrExtendWindow } from "../services/maintenance.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createUptimeKumaAdapter } from "../integrations/uptimekuma/adapter.js";
import { attachMatchedServers, toMatchableServers } from "../services/uptimeKumaMatch.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const maintenanceRouter = Router();
// Anyone signed in can see what's currently silenced (so nobody is surprised by missing alerts);
// starting and ending a window is an operator action, like the other things that change how the homelab behaves.
maintenanceRouter.use(requireAuth);
maintenanceRouter.get("/", asyncHandler(async (_req, res) => {
const windows = [];
for (const w of await listActiveWindows()) {
const target = await describeTarget(w.targetType, w.targetId);
if (!target) continue; // target was deleted — nothing left to silence
windows.push({ ...w, targetName: target.name, targetKind: target.kind });
}
windows.sort((a, b) => a.endsAt.localeCompare(b.endsAt));
const [serverRows, integrationRows, providerRows] = await Promise.all([
db.select({ id: servers.id, name: servers.name }).from(servers).orderBy(servers.name),
db.select({ id: integrations.id, name: integrations.name, type: integrations.type }).from(integrations).orderBy(integrations.name),
db.select({ id: dnsProviders.id, name: dnsProviders.name, providerType: dnsProviders.providerType }).from(dnsProviders).orderBy(dnsProviders.name),
]);
res.json({ windows, targets: { servers: serverRows, integrations: integrationRows, dnsProviders: providerRows } });
}));
const startSchema = z.object({
targetType: z.enum(maintenanceTargetTypes),
targetId: z.number().int().positive(),
// Required and capped: an open-ended window is the way this feature could quietly hide a real outage.
minutes: z.number().int().min(5).max(7 * 24 * 60),
reason: z.string().max(200).optional(),
});
maintenanceRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => {
const parsed = startSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const { targetType, targetId, minutes, reason } = parsed.data;
const actor = req.currentUser!;
const createdBy = actor.name ?? actor.email ?? actor.oidcSub;
// Starting maintenance on something already in maintenance restarts its clock rather than stacking windows.
const result = await startOrExtendWindow(targetType, targetId, minutes, reason ?? null, createdBy);
if (!result) {
return res.status(404).json({ error: "not_found", message: "That target doesn't exist." });
}
await recordAudit({
actor,
category: "maintenance",
action: result.extended ? "extend" : "start",
targetType,
targetId,
detail: { name: result.target.name, minutes, reason: reason ?? null },
});
res.status(201).json({ window: { ...result.window, targetName: result.target.name, targetKind: result.target.kind } });
}));
const importUptimeKumaSchema = z.object({
integrationId: z.number().int().positive(),
// Same bounds as a manual window: required and capped, so an import can't quietly create an open-ended one.
minutes: z.number().int().min(5).max(7 * 24 * 60),
});
/**
* Uptime Kuma has no scheduled-maintenance API to read (see the integration's own notes) — only each monitor's
* *current* status, which is "maintenance" for exactly as long as a window is active there. So rather than
* mirroring Kuma's schedule, this reads what's in maintenance right now and starts (or extends) a matching
* window here for that long, on whichever server the monitor's target matches. Re-running it while Kuma is
* still in maintenance just extends the same window rather than stacking a new one.
*/
maintenanceRouter.post("/import/uptimekuma", requireRole("operator"), asyncHandler(async (req, res) => {
const parsed = importUptimeKumaSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", message: "Choose an integration and a duration.", details: parsed.error.flatten() });
}
const { integrationId, minutes } = parsed.data;
const loaded = await loadIntegrationConfig(integrationId);
if (!loaded) return res.status(404).json({ error: "not_found" });
if (loaded.integration.type !== "uptimekuma") return res.status(400).json({ error: "wrong_type" });
if (!loaded.integration.enabled) return res.status(400).json({ error: "integration_disabled" });
let monitors;
try {
monitors = await createUptimeKumaAdapter(loaded.config as any).listMonitors();
} catch (err) {
return res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
const serverRows = await db.select({ id: servers.id, name: servers.name, hostname: servers.hostname, ips: servers.ipAddresses }).from(servers);
const inMaintenance = attachMatchedServers(monitors, toMatchableServers(serverRows)).filter((m) => m.status === "maintenance");
const actor = req.currentUser!;
const createdBy = actor.name ?? actor.email ?? actor.oidcSub;
const imported: { serverId: number; serverName: string; monitorName: string; extended: boolean }[] = [];
const unmatched: string[] = [];
// Two monitors on the same server would otherwise start, then immediately re-extend, the same window —
// harmless, but the response would misleadingly list the server twice.
const seenServers = new Set<number>();
for (const m of inMaintenance) {
if (!m.matchedServer) {
unmatched.push(m.name);
continue;
}
if (seenServers.has(m.matchedServer.id)) continue;
seenServers.add(m.matchedServer.id);
const reason = `Imported from Uptime Kuma (${loaded.integration.name}): ${m.name}`;
const result = await startOrExtendWindow("server", m.matchedServer.id, minutes, reason, createdBy);
if (!result) continue; // the server was removed between the query above and now
await recordAudit({
actor,
category: "maintenance",
action: result.extended ? "extend" : "start",
targetType: "server",
targetId: m.matchedServer.id,
detail: { name: result.target.name, minutes, reason },
});
imported.push({ serverId: m.matchedServer.id, serverName: result.target.name, monitorName: m.name, extended: result.extended });
}
res.json({ imported, unmatched, monitorsInMaintenance: inMaintenance.length });
}));
maintenanceRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const [existing] = await db.select().from(maintenanceWindows).where(eq(maintenanceWindows.id, id)).limit(1);
if (!existing) {
return res.status(404).json({ error: "not_found" });
}
// Ending moves the end to now, so it stops applying immediately; the row is pruned later like any expired one.
await db.update(maintenanceWindows).set({ endsAt: new Date().toISOString() }).where(eq(maintenanceWindows.id, id));
const target = await describeTarget(existing.targetType, existing.targetId);
await recordAudit({
actor: req.currentUser!,
category: "maintenance",
action: "end",
targetType: existing.targetType,
targetId: existing.targetId,
detail: { name: target?.name ?? null },
});
res.status(204).end();
}));
+180
View File
@@ -0,0 +1,180 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { servers, portForwards } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { type AgentPort, groupAgentPorts, parseJson, splitPortKey } from "../services/agentPorts.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const portsRouter = Router();
portsRouter.use(requireAuth);
// ─── Ports reported by agents, across every server ──────────────────────────
export interface AgentPortRow {
serverId: number;
serverName: string;
serverHostname: string | null;
protocol: "tcp" | "udp";
port: number;
addresses: string[];
process: string | null;
localOnly: boolean;
lastSeenAt: string | null;
}
portsRouter.get("/agent", asyncHandler(async (_req, res) => {
const rows = await db
.select({ id: servers.id, name: servers.name, hostname: servers.hostname, listeningPorts: servers.listeningPorts, lastSeenAt: servers.lastSeenAt })
.from(servers);
const out: AgentPortRow[] = [];
for (const s of rows) {
const raw = parseJson<AgentPort[] | null>(s.listeningPorts, null);
if (!raw) continue;
for (const [key, grouped] of groupAgentPorts(raw)) {
const { protocol, port } = splitPortKey(key);
out.push({
serverId: s.id,
serverName: s.name,
serverHostname: s.hostname,
protocol,
port,
addresses: grouped.addresses,
process: grouped.process,
localOnly: grouped.localOnly,
lastSeenAt: s.lastSeenAt,
});
}
}
out.sort((a, b) => a.serverName.localeCompare(b.serverName) || a.port - b.port || a.protocol.localeCompare(b.protocol));
res.json({ ports: out, reportingServers: rows.filter((s) => s.listeningPorts !== null).length, totalServers: rows.length });
}));
// ─── Manually-recorded port openings (router/firewall/cloud security group, etc.) ──
// Blank/omitted optional fields normalize to null right here, so every downstream handler can
// just use parsed.data as-is (matching the DB columns, which store NULL, not empty strings).
const optionalText = (max: number) =>
z
.string()
.trim()
.max(max)
.nullish()
.transform((v) => v || null);
const forwardInput = z.object({
label: z.string().trim().min(1).max(200),
externalPort: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"]).default("tcp"),
serverId: z
.number()
.int()
.nullish()
.transform((v) => v ?? null),
destination: optionalText(255),
internalPort: z
.number()
.int()
.min(1)
.max(65535)
.nullish()
.transform((v) => v ?? null),
source: optionalText(200),
comment: optionalText(2000),
});
const updateForwardInput = forwardInput.partial();
portsRouter.get("/forwards", asyncHandler(async (_req, res) => {
const rows = await db.query.portForwards.findMany({ orderBy: (p, { asc }) => [asc(p.externalPort)] });
const serverRows = await db.select({ id: servers.id, name: servers.name }).from(servers);
const nameById = new Map(serverRows.map((s) => [s.id, s.name]));
res.json({
forwards: rows.map((r) => ({ ...r, serverName: r.serverId !== null ? nameById.get(r.serverId) ?? null : null })),
});
}));
portsRouter.post("/forwards", requireRole("operator"), asyncHandler(async (req, res) => {
const parsed = forwardInput.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const data = parsed.data;
if (data.serverId) {
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, data.serverId)).limit(1);
if (!server) return res.status(400).json({ error: "invalid_server" });
}
const [created] = await db.insert(portForwards).values(data).returning();
await recordAudit({
actor: req.currentUser!,
category: "network",
action: "create_port_forward",
targetType: "port_forward",
targetId: created.id,
detail: { label: created.label, externalPort: created.externalPort, protocol: created.protocol },
});
res.status(201).json({ forward: created });
}));
portsRouter.patch("/forwards/:id", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const parsed = updateForwardInput.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const data = parsed.data;
const [existing] = await db.select().from(portForwards).where(eq(portForwards.id, id)).limit(1);
if (!existing) return res.status(404).json({ error: "not_found" });
if (data.serverId) {
const [server] = await db.select({ id: servers.id }).from(servers).where(eq(servers.id, data.serverId)).limit(1);
if (!server) return res.status(400).json({ error: "invalid_server" });
}
const [updated] = await db
.update(portForwards)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(portForwards.id, id))
.returning();
await recordAudit({
actor: req.currentUser!,
category: "network",
action: "update_port_forward",
targetType: "port_forward",
targetId: id,
detail: { label: updated.label, externalPort: updated.externalPort, protocol: updated.protocol },
});
res.json({ forward: updated });
}));
portsRouter.delete("/forwards/:id", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const [existing] = await db.select().from(portForwards).where(eq(portForwards.id, id)).limit(1);
if (!existing) return res.status(404).json({ error: "not_found" });
await db.delete(portForwards).where(eq(portForwards.id, id));
await recordAudit({
actor: req.currentUser!,
category: "network",
action: "delete_port_forward",
targetType: "port_forward",
targetId: id,
detail: { label: existing.label, externalPort: existing.externalPort, protocol: existing.protocol },
});
res.status(204).end();
}));
+100
View File
@@ -0,0 +1,100 @@
import { Router } from "express";
import { count, eq, isNotNull } from "drizzle-orm";
import { db } from "../db/client.js";
import { auditLog, dnsProviders, domains, integrations, secrets, servers, users } from "../db/schema.js";
import { requireAuth } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { getSettings } from "../services/settingsStore.js";
import { listSessions } from "../services/sessionStore.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const privacyRouter = Router();
privacyRouter.use(requireAuth);
function hostOf(url: string): string | null {
try {
return new URL(url).host || null;
} catch {
return null;
}
}
/** The signed-in user's own sessions. The session id and the stored ID token are deliberately not passed on. */
async function ownSessions(sub: string, currentSessionId: string) {
return (await listSessions())
.filter((s) => s.sub === sub)
.map((s) => ({ ip: s.ip, userAgent: s.userAgent, lastAccess: s.lastAccess, expiresAt: s.expiresAt, current: s.id === currentSessionId }));
}
privacyRouter.get("/", asyncHandler(async (req, res) => {
const me = req.currentUser!;
const isAdmin = me.role === "admin";
const settings = await getSettings();
const [{ n: auditEntries }] = await db.select({ n: count() }).from(auditLog).where(eq(auditLog.actorUserId, me.id));
const integrationRows = await db.select({ type: integrations.type }).from(integrations).where(eq(integrations.enabled, true));
const integrationTypes = [...new Set(integrationRows.map((r) => r.type))].sort();
const dnsRows = await db.select({ type: dnsProviders.providerType }).from(dnsProviders).where(eq(dnsProviders.enabled, true));
const dnsTypes = [...new Set(dnsRows.map((r) => r.type))].sort();
const [{ n: domainCount }] = await db.select({ n: count() }).from(domains);
const [{ n: tlsChecks }] = await db.select({ n: count() }).from(secrets).where(isNotNull(secrets.checkHost));
const [{ n: serverCount }] = await db.select({ n: count() }).from(servers);
const [{ n: agentCount }] = await db.select({ n: count() }).from(servers).where(isNotNull(servers.lastSeenAt));
// Where a notification goes is infrastructure detail — every signed-in user is told a channel is on, only admins see the address.
const channel = (enabled: boolean, host: string | null) => ({ enabled, host: isAdmin ? host : null });
res.json({
me: {
user: { email: me.email, name: me.name, role: me.role, subject: me.oidcSub, createdAt: me.createdAt, lastLoginAt: me.lastLoginAt },
auditEntries,
sessions: await ownSessions(me.oidcSub, req.sessionID),
},
retention: settings.logRetention,
outbound: {
integrationTypes,
dnsProviderTypes: dnsTypes,
domainsTracked: domainCount,
tlsCertificateChecks: tlsChecks,
servers: serverCount,
serversWithAgent: agentCount,
channels: {
gotify: channel(settings.gotify.enabled, hostOf(settings.gotify.url)),
ntfy: channel(settings.ntfy.enabled, hostOf(settings.ntfy.url)),
smtp: channel(settings.smtp.enabled, settings.smtp.host || null),
webhook: channel(settings.webhook.enabled, hostOf(settings.webhook.url)),
},
},
});
}));
// Everything the app holds that is specifically about the signed-in user, as a file. Only their own — never another user's.
privacyRouter.get("/export", asyncHandler(async (req, res) => {
const me = req.currentUser!;
const [row] = await db.select().from(users).where(eq(users.id, me.id)).limit(1);
const entries = await db
.select({ createdAt: auditLog.createdAt, category: auditLog.category, action: auditLog.action, targetType: auditLog.targetType, targetId: auditLog.targetId, detail: auditLog.detail })
.from(auditLog)
.where(eq(auditLog.actorUserId, me.id))
.orderBy(auditLog.id);
await recordAudit({ actor: me, category: "privacy", action: "export_own_data", targetType: "user", targetId: me.id });
const body = {
exportedAt: new Date().toISOString(),
note: "Everything Homelab Manager holds that is specifically about you. Other people's data, server data and shared inventory are not included.",
account: { email: row.email, name: row.name, role: row.role, subject: row.oidcSub, createdAt: row.createdAt, lastLoginAt: row.lastLoginAt },
sessions: await ownSessions(me.oidcSub, req.sessionID),
auditLog: entries.map((e) => ({ ...e, detail: e.detail ? safeJson(e.detail) : null })),
};
res.setHeader("Content-Disposition", 'attachment; filename="homelab-manager-my-data.json"');
res.json(body);
}));
function safeJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
+109
View File
@@ -0,0 +1,109 @@
import { Router } from "express";
import { and, eq, like, or } from "drizzle-orm";
import { db } from "../db/client.js";
import { servers, secrets, ipamEntries, integrations, dnsProviders, dnsZonesCache, dnsRecordsCache } from "../db/schema.js";
import { requireAuth } from "../auth/middleware.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const searchRouter = Router();
// Every list endpoint this aggregates (servers, secrets, IPAM, DNS,
// integrations) only requires requireAuth itself — viewer role included —
// so this can safely search across all of them under the same check.
searchRouter.use(requireAuth);
const RESULT_LIMIT = 8;
const MIN_QUERY_LENGTH = 2;
const EMPTY_RESULTS = {
servers: [],
secrets: [],
ipam: [],
integrations: [],
dnsProviders: [],
dnsZones: [],
dnsRecords: [],
};
searchRouter.get("/", asyncHandler(async (req, res) => {
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
if (q.length < MIN_QUERY_LENGTH) {
return res.json(EMPTY_RESULTS);
}
const term = `%${q}%`;
const [serverRows, secretRows, ipamRows, integrationRows, providerRows, zoneRows, recordRows] = await Promise.all([
db
.select({ id: servers.id, name: servers.name, hostname: servers.hostname })
.from(servers)
.where(or(like(servers.name, term), like(servers.hostname, term), like(servers.description, term), like(servers.tags, term)))
.limit(RESULT_LIMIT),
db
.select({ id: secrets.id, name: secrets.name, type: secrets.type })
.from(secrets)
.where(or(like(secrets.name, term), like(secrets.description, term), like(secrets.notes, term)))
.limit(RESULT_LIMIT),
db
.select({ id: ipamEntries.id, ipAddress: ipamEntries.ipAddress, label: ipamEntries.label })
.from(ipamEntries)
.where(
or(
like(ipamEntries.ipAddress, term),
like(ipamEntries.label, term),
like(ipamEntries.vendor, term),
like(ipamEntries.location, term),
like(ipamEntries.notes, term),
),
)
.limit(RESULT_LIMIT),
db
.select({ id: integrations.id, type: integrations.type, name: integrations.name })
.from(integrations)
.where(like(integrations.name, term))
.limit(RESULT_LIMIT),
db
.select({ id: dnsProviders.id, providerType: dnsProviders.providerType, name: dnsProviders.name })
.from(dnsProviders)
.where(like(dnsProviders.name, term))
.limit(RESULT_LIMIT),
db
.select({
providerId: dnsZonesCache.providerId,
zoneId: dnsZonesCache.zoneId,
zoneName: dnsZonesCache.zoneName,
providerName: dnsProviders.name,
})
.from(dnsZonesCache)
.innerJoin(dnsProviders, eq(dnsZonesCache.providerId, dnsProviders.id))
.where(like(dnsZonesCache.zoneName, term))
.limit(RESULT_LIMIT),
db
.select({
providerId: dnsRecordsCache.providerId,
zoneId: dnsRecordsCache.zoneId,
zoneName: dnsZonesCache.zoneName,
type: dnsRecordsCache.type,
name: dnsRecordsCache.name,
content: dnsRecordsCache.content,
providerName: dnsProviders.name,
})
.from(dnsRecordsCache)
.innerJoin(dnsProviders, eq(dnsRecordsCache.providerId, dnsProviders.id))
.innerJoin(
dnsZonesCache,
and(eq(dnsRecordsCache.providerId, dnsZonesCache.providerId), eq(dnsRecordsCache.zoneId, dnsZonesCache.zoneId)),
)
.where(or(like(dnsRecordsCache.name, term), like(dnsRecordsCache.content, term)))
.limit(RESULT_LIMIT),
]);
res.json({
servers: serverRows,
secrets: secretRows,
ipam: ipamRows,
integrations: integrationRows,
dnsProviders: providerRows,
dnsZones: zoneRows,
dnsRecords: recordRows,
});
}));
+87 -7
View File
@@ -6,6 +6,7 @@ import { secrets, secretTypes } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js"; import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js"; import { recordAudit } from "../services/audit.js";
import { computeSecretStatus } from "../services/secretStatus.js"; import { computeSecretStatus } from "../services/secretStatus.js";
import { fetchCertExpiry, refreshTlsSecret } from "../services/tlsCheck.js";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
export const secretsRouter = Router(); export const secretsRouter = Router();
@@ -23,9 +24,12 @@ const secretInput = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
type: z.enum(secretTypes), type: z.enum(secretTypes),
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
expiryDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD"), // Optional only because a monitored certificate gets its date from the live cert; validated below.
expiryDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD").optional(),
warnDays: z.number().int().min(0).max(3650).default(30), warnDays: z.number().int().min(0).max(3650).default(30),
notes: z.string().max(4000).optional(), notes: z.string().max(4000).optional(),
checkHost: z.string().min(1).max(253).regex(/^[A-Za-z0-9._:-]+$/, "Invalid host").nullable().optional(),
checkPort: z.number().int().min(1).max(65535).nullable().optional(),
}); });
secretsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => { secretsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => {
@@ -33,8 +37,37 @@ secretsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) =
if (!parsed.success) { if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
} }
const { checkHost, checkPort, expiryDate: manualExpiry, ...rest } = parsed.data;
const [created] = await db.insert(secrets).values(parsed.data).returning(); if (checkHost && rest.type !== "ssl_certificate") {
return res.status(400).json({ error: "invalid_body", message: "Only SSL certificates can be checked against a host." });
}
let expiryDate = manualExpiry;
let lastCheckedAt: string | null = null;
let lastCheckError: string | null = null;
if (checkHost) {
lastCheckedAt = new Date().toISOString();
try {
expiryDate = await fetchCertExpiry(checkHost, checkPort ?? 443);
} catch (err) {
lastCheckError = err instanceof Error ? err.message : String(err);
if (!manualExpiry) {
return res.status(400).json({
error: "tls_check_failed",
message: `Couldn't read the certificate from ${checkHost}:${checkPort ?? 443} (${lastCheckError}). Fix the host, or enter an expiry date manually.`,
});
}
}
}
if (!expiryDate) {
return res.status(400).json({ error: "invalid_body", message: "An expiry date is required unless a host to check is given." });
}
const [created] = await db
.insert(secrets)
.values({ ...rest, expiryDate, checkHost: checkHost ?? null, checkPort: checkHost ? (checkPort ?? 443) : null, lastCheckedAt, lastCheckError })
.returning();
await recordAudit({ await recordAudit({
actor: req.currentUser!, actor: req.currentUser!,
@@ -60,11 +93,38 @@ secretsRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, re
return res.status(404).json({ error: "not_found" }); return res.status(404).json({ error: "not_found" });
} }
const [updated] = await db const { checkHost, checkPort, ...rest } = parsed.data;
.update(secrets) const nextType = rest.type ?? existing.type;
.set({ ...parsed.data, updatedAt: new Date().toISOString() }) if (checkHost && nextType !== "ssl_certificate") {
.where(eq(secrets.id, id)) return res.status(400).json({ error: "invalid_body", message: "Only SSL certificates can be checked against a host." });
.returning(); }
// Changing a monitored secret to a non-certificate type quietly ends the monitoring rather than leaving a stale check behind.
const nextHost = nextType !== "ssl_certificate" ? null : checkHost !== undefined ? checkHost : existing.checkHost;
const nextPort = checkPort !== undefined ? checkPort : existing.checkPort;
const now = new Date().toISOString();
const changes: Partial<typeof secrets.$inferInsert> = { ...rest, updatedAt: now };
if (!nextHost) {
Object.assign(changes, { checkHost: null, checkPort: null, lastCheckedAt: null, lastCheckError: null });
} else {
const port = nextPort ?? 443;
Object.assign(changes, { checkHost: nextHost, checkPort: port });
if (nextHost !== existing.checkHost || port !== (existing.checkPort ?? 443)) {
changes.lastCheckedAt = now;
try {
changes.expiryDate = await fetchCertExpiry(nextHost, port);
changes.lastCheckError = null;
} catch (err) {
// Keep whatever date we already have (a manually supplied one, else the existing one) and record why the check failed.
changes.lastCheckError = err instanceof Error ? err.message : String(err);
}
} else {
// Same host as before: the live certificate stays the source of truth, so ignore a hand-typed date.
delete changes.expiryDate;
}
}
const [updated] = await db.update(secrets).set(changes).where(eq(secrets.id, id)).returning();
await recordAudit({ await recordAudit({
actor: req.currentUser!, actor: req.currentUser!,
@@ -78,6 +138,26 @@ secretsRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, re
res.json({ secret: { ...updated, ...computeSecretStatus(updated.expiryDate, updated.warnDays) } }); res.json({ secret: { ...updated, ...computeSecretStatus(updated.expiryDate, updated.warnDays) } });
})); }));
secretsRouter.post("/:id/check-tls", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const result = await refreshTlsSecret(id);
if (!result) {
return res.status(400).json({ error: "not_monitored", message: "This secret has no host to check." });
}
const [updated] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1);
await recordAudit({
actor: req.currentUser!,
category: "secret",
action: "check_tls",
targetType: "secret",
targetId: id,
detail: { name: result.name, host: result.host, port: result.port, ok: result.ok, error: result.error },
});
res.json({ secret: { ...updated, ...computeSecretStatus(updated.expiryDate, updated.warnDays) }, result });
}));
secretsRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { secretsRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1); const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1);
+300
View File
@@ -0,0 +1,300 @@
import { Router } from "express";
import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { servers, serverPorts } from "../db/schema.js";
import { requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { beginScan, endScan, MAX_SCAN_SPAN, resolveScanTarget, scanPorts, toRanges } from "../services/portScan.js";
import { type AgentPort, groupAgentPorts, parseJson } from "../services/agentPorts.js";
import { asyncHandler } from "../utils/asyncHandler.js";
// Mounted under /api/servers/:id/ports by the servers router, which has already required a signed-in user.
export const serverPortsRouter = Router({ mergeParams: true });
interface StoredScan {
at: string;
address: string;
from: number;
to: number;
open: number;
refused: number;
filtered: number;
responded: boolean;
}
export interface PortEntry {
/** Null for a port that's only known from the agent and has no note yet. */
id: number | null;
port: number;
protocol: "tcp" | "udp";
label: string | null;
comment: string | null;
/** The last scan from this app connected to it. */
scanOpen: boolean;
lastSeenOpenAt: string | null;
/** What the agent sees bound on the host, when it reports listening ports. */
agent: { addresses: string[]; process: string | null; localOnly: boolean } | null;
/** "open" if anything is using it; "reserved" if it only has a note. */
state: "open" | "reserved";
}
async function buildPortList(serverId: number) {
const [server] = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1);
if (!server) return null;
const rows = await db.select().from(serverPorts).where(eq(serverPorts.serverId, serverId));
const agentRaw = parseJson<AgentPort[] | null>(server.listeningPorts, null);
const agent = groupAgentPorts(agentRaw ?? []);
const entries = new Map<string, PortEntry>();
for (const row of rows) {
const key = `${row.protocol}:${row.port}`;
entries.set(key, {
id: row.id,
port: row.port,
protocol: row.protocol,
label: row.label,
comment: row.comment,
scanOpen: row.open,
lastSeenOpenAt: row.lastSeenOpenAt,
agent: agent.get(key) ?? null,
state: "reserved",
});
}
for (const [key, info] of agent) {
if (entries.has(key)) continue;
const [protocol, port] = key.split(":");
entries.set(key, {
id: null,
port: Number(port),
protocol: protocol as "tcp" | "udp",
label: null,
comment: null,
scanOpen: false,
lastSeenOpenAt: null,
agent: info,
state: "reserved",
});
}
for (const entry of entries.values()) {
if (entry.scanOpen || entry.agent) entry.state = "open";
}
const ports = [...entries.values()].sort((a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol));
return {
server,
ports,
agentReporting: agentRaw !== null,
agentReportedAt: agentRaw !== null ? server.lastSeenAt : null,
lastScan: parseJson<StoredScan | null>(server.lastPortScan, null),
};
}
function serverIdOf(req: { params: Record<string, string> }): number | null {
const id = Number(req.params.id);
return Number.isInteger(id) && id > 0 ? id : null;
}
serverPortsRouter.get("/", asyncHandler(async (req, res) => {
const id = serverIdOf(req);
if (!id) return res.status(400).json({ error: "invalid_id" });
const list = await buildPortList(id);
if (!list) return res.status(404).json({ error: "not_found" });
const { server: _server, ...out } = list;
res.json(out);
}));
const scanSchema = z
.object({
address: z.string().min(1).max(255),
from: z.number().int().min(1).max(65535),
to: z.number().int().min(1).max(65535),
})
.refine((d) => d.to >= d.from, { message: "The end of the range is before the start." })
.refine((d) => d.to - d.from + 1 <= MAX_SCAN_SPAN, { message: `Scan at most ${MAX_SCAN_SPAN} ports at a time.` });
serverPortsRouter.post("/scan", requireRole("operator"), asyncHandler(async (req, res) => {
const serverId = serverIdOf(req);
if (!serverId) return res.status(400).json({ error: "invalid_id" });
const parsed = scanSchema.safeParse(req.body);
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Invalid scan request.";
return res.status(400).json({ error: "invalid_body", message, details: parsed.error.flatten() });
}
const { address, from, to } = parsed.data;
const [server] = await db.select({ id: servers.id, name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
if (!server) return res.status(404).json({ error: "not_found" });
let target: string;
try {
target = await resolveScanTarget(address);
} catch (err) {
return res.status(400).json({ error: "invalid_address", message: err instanceof Error ? err.message : String(err) });
}
if (!beginScan(serverId)) {
return res.status(409).json({ error: "scan_in_progress", message: "A scan of this server is already running." });
}
let scan;
try {
scan = await scanPorts(target, from, to);
} finally {
endScan(serverId);
}
const now = new Date().toISOString();
// If nothing at all answered, the host is probably down or dropping everything — that says nothing about
// which ports are open, so leave what we knew before rather than marking it all closed.
const responded = scan.open.length + scan.refused.length > 0;
if (responded) {
const existing = await db
.select()
.from(serverPorts)
.where(and(eq(serverPorts.serverId, serverId), eq(serverPorts.protocol, "tcp")));
const inRange = existing.filter((r) => r.port >= from && r.port <= to);
const openSet = new Set(scan.open);
const known = new Set(existing.map((r) => r.port));
const newlyFound = scan.open.filter((p) => !known.has(p));
for (let i = 0; i < newlyFound.length; i += 50) {
await db.insert(serverPorts).values(
newlyFound.slice(i, i + 50).map((port) => ({ serverId, port, protocol: "tcp" as const, open: true, lastSeenOpenAt: now })),
);
}
const stillOpen = inRange.filter((r) => openSet.has(r.port)).map((r) => r.id);
if (stillOpen.length > 0) {
await db.update(serverPorts).set({ open: true, lastSeenOpenAt: now }).where(inArray(serverPorts.id, stillOpen));
}
// No longer open: keep it if someone wrote a note about it (it's now "reserved"), otherwise it carries no information.
const gone = inRange.filter((r) => r.open && !openSet.has(r.port));
const keep = gone.filter((r) => r.label || r.comment).map((r) => r.id);
const drop = gone.filter((r) => !(r.label || r.comment)).map((r) => r.id);
if (keep.length > 0) await db.update(serverPorts).set({ open: false }).where(inArray(serverPorts.id, keep));
if (drop.length > 0) await db.delete(serverPorts).where(inArray(serverPorts.id, drop));
}
const summary: StoredScan = {
at: now,
address: target,
from,
to,
open: scan.open.length,
refused: scan.refused.length,
filtered: scan.filtered,
responded,
};
await db.update(servers).set({ lastPortScan: JSON.stringify(summary) }).where(eq(servers.id, serverId));
// "Free" is what the host actively refused AND nobody has claimed — by a note, or by the agent seeing it bound
// (which catches services listening only on localhost, invisible to a scan from elsewhere).
const list = (await buildPortList(serverId))!;
const taken = new Set(list.ports.filter((p) => p.protocol === "tcp").map((p) => p.port));
const free = scan.refused.filter((p) => !taken.has(p));
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "scan_ports",
targetType: "server",
targetId: serverId,
detail: { name: server.name, address: target, from, to, open: scan.open.length },
});
res.json({
scan: summary,
freeCount: free.length,
freeRanges: toRanges(free),
ports: list.ports,
agentReporting: list.agentReporting,
agentReportedAt: list.agentReportedAt,
});
}));
const noteSchema = z.object({
port: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"]).default("tcp"),
label: z.string().trim().max(100).nullish(),
comment: z.string().trim().max(500).nullish(),
});
serverPortsRouter.put("/", requireRole("operator"), asyncHandler(async (req, res) => {
const serverId = serverIdOf(req);
if (!serverId) return res.status(400).json({ error: "invalid_id" });
const parsed = noteSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", message: "Invalid port note.", details: parsed.error.flatten() });
}
const { port, protocol } = parsed.data;
const label = parsed.data.label || null;
const comment = parsed.data.comment || null;
const [server] = await db.select({ id: servers.id, name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
if (!server) return res.status(404).json({ error: "not_found" });
const [existing] = await db
.select()
.from(serverPorts)
.where(and(eq(serverPorts.serverId, serverId), eq(serverPorts.port, port), eq(serverPorts.protocol, protocol)))
.limit(1);
if (!existing && !label && !comment) {
return res.status(400).json({ error: "invalid_body", message: "Add a label or a comment to reserve a port." });
}
const now = new Date().toISOString();
if (existing) {
if (!label && !comment && !existing.open) {
await db.delete(serverPorts).where(eq(serverPorts.id, existing.id));
} else {
await db.update(serverPorts).set({ label, comment, updatedAt: now }).where(eq(serverPorts.id, existing.id));
}
} else {
await db.insert(serverPorts).values({ serverId, port, protocol, label, comment, updatedAt: now });
}
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "set_port_note",
targetType: "server",
targetId: serverId,
detail: { name: server.name, port, protocol, label },
});
const list = (await buildPortList(serverId))!;
res.json({ ports: list.ports });
}));
serverPortsRouter.delete("/:portId", requireRole("operator"), asyncHandler(async (req, res) => {
const serverId = serverIdOf(req);
const portId = Number(req.params.portId);
if (!serverId || !Number.isInteger(portId)) return res.status(400).json({ error: "invalid_id" });
const [row] = await db
.select()
.from(serverPorts)
.where(and(eq(serverPorts.id, portId), eq(serverPorts.serverId, serverId)))
.limit(1);
if (!row) return res.status(404).json({ error: "not_found" });
// A port that's currently open stays listed — removing its note just blanks it. A reserved-only port disappears.
if (row.open) {
await db.update(serverPorts).set({ label: null, comment: null }).where(eq(serverPorts.id, portId));
} else {
await db.delete(serverPorts).where(eq(serverPorts.id, portId));
}
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "remove_port_note",
targetType: "server",
targetId: serverId,
detail: { port: row.port, protocol: row.protocol, label: row.label },
});
res.status(204).end();
}));
+333 -6
View File
@@ -1,27 +1,42 @@
import { Router } from "express"; import { Router } from "express";
import { eq } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { servers } from "../db/schema.js"; import { servers, serverLinks, integrations, dnsRecordsCache, dnsZonesCache, dnsProviders } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js"; import { requireAuth, requireRole } from "../auth/middleware.js";
import { generateApiToken } from "../services/tokens.js"; import { generateApiToken } from "../services/tokens.js";
import { recordAudit } from "../services/audit.js"; import { recordAudit } from "../services/audit.js";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createProxmoxAdapter, type ProxmoxGuestType } from "../integrations/proxmox/adapter.js";
import { serverPortsRouter } from "./serverPorts.js";
import { InvalidTagError, normalizeTags, parseTags } from "../services/serverTags.js";
import { buildServerSummary } from "../services/serverSummary.js";
import { activeSubjects } from "../services/maintenance.js";
import { getSettings } from "../services/settingsStore.js";
export const serversRouter = Router(); export const serversRouter = Router();
/** A server row as the API returns it: no token hash, and tags as an array rather than the stored JSON. */
function publicServer<T extends { apiTokenHash: string; tags: string | null }>(row: T) {
const { apiTokenHash: _hash, tags, ...rest } = row;
return { ...rest, tags: parseTags(tags) };
}
serversRouter.use(requireAuth); serversRouter.use(requireAuth);
serversRouter.use("/:id/ports", serverPortsRouter);
const createServerSchema = z.object({ const createServerSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100),
hostname: z.string().max(255).optional(), hostname: z.string().max(255).optional(),
osType: z.literal("linux").default("linux"), osType: z.enum(["linux", "windows"]).default("linux"),
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
}); });
serversRouter.get("/", asyncHandler(async (_req, res) => { serversRouter.get("/", asyncHandler(async (_req, res) => {
const rows = await db.query.servers.findMany({ orderBy: (s, { asc }) => [asc(s.name)] }); const rows = await db.query.servers.findMany({ orderBy: (s, { asc }) => [asc(s.name)] });
res.json({ res.json({
servers: rows.map(({ apiTokenHash, ...rest }) => rest), servers: rows.map(publicServer),
}); });
})); }));
@@ -54,7 +69,7 @@ serversRouter.post("/", requireRole("admin"), asyncHandler(async (req, res) => {
detail: { name: created.name }, detail: { name: created.name },
}); });
const { apiTokenHash, ...serverOut } = created; const serverOut = publicServer(created);
// The full token is only ever shown once, at creation time. // The full token is only ever shown once, at creation time.
res.status(201).json({ server: serverOut, token }); res.status(201).json({ server: serverOut, token });
})); }));
@@ -81,10 +96,322 @@ serversRouter.post("/:id/rotate-token", requireRole("admin"), asyncHandler(async
detail: { name: updated.name }, detail: { name: updated.name },
}); });
const { apiTokenHash, ...serverOut } = updated; const serverOut = publicServer(updated);
res.json({ server: serverOut, token }); res.json({ server: serverOut, token });
})); }));
const updateServerSchema = z
.object({
name: z.string().min(1).max(100).optional(),
hostname: z.string().max(255).optional(),
description: z.string().max(500).optional(),
proxmoxIntegrationId: z.number().int().nullable().optional(),
proxmoxNode: z.string().nullable().optional(),
proxmoxGuestType: z.enum(["qemu", "lxc"]).nullable().optional(),
proxmoxVmid: z.number().int().nullable().optional(),
hideProxmoxLink: z.boolean().optional(),
})
.refine(
(data) => {
const proxmoxFields = [data.proxmoxIntegrationId, data.proxmoxNode, data.proxmoxGuestType, data.proxmoxVmid];
if (proxmoxFields.every((f) => f === undefined)) return true;
const allNull = proxmoxFields.every((f) => f === null);
const allSet = proxmoxFields.every((f) => f !== undefined && f !== null);
return allNull || allSet;
},
{ message: "proxmoxIntegrationId/proxmoxNode/proxmoxGuestType/proxmoxVmid must be set together or all cleared to null" },
);
serversRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const parsed = updateServerSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const [existing] = await db.select().from(servers).where(eq(servers.id, id)).limit(1);
if (!existing) return res.status(404).json({ error: "not_found" });
if (parsed.data.proxmoxIntegrationId) {
const [integration] = await db
.select()
.from(integrations)
.where(eq(integrations.id, parsed.data.proxmoxIntegrationId))
.limit(1);
if (!integration || integration.type !== "proxmox") {
return res.status(400).json({ error: "invalid_proxmox_integration" });
}
}
const [updated] = await db.update(servers).set(parsed.data).where(eq(servers.id, id)).returning();
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "update",
targetType: "server",
targetId: id,
detail: { name: updated.name },
});
const serverOut = publicServer(updated);
res.json({ server: serverOut });
}));
// For the dashboard widget. Computed here, with the health monitor's own rules and the thresholds from Settings
// (which viewers can't read), so the widget and the alerts always agree about what "offline" and "full" mean.
serversRouter.get("/summary", asyncHandler(async (_req, res) => {
const rows = await db
.select({ id: servers.id, name: servers.name, osType: servers.osType, lastSeenAt: servers.lastSeenAt, disks: servers.disks })
.from(servers);
const { healthChecks } = await getSettings();
res.json(buildServerSummary(rows, healthChecks, Date.now(), await activeSubjects()));
}));
// For the Operations > Admin Links page — every server's admin bookmarks in one place, instead of visiting
// each server's own detail page to find them.
serversRouter.get("/links", asyncHandler(async (_req, res) => {
const rows = await db
.select({
id: serverLinks.id,
serverId: serverLinks.serverId,
serverName: servers.name,
serverHostname: servers.hostname,
label: serverLinks.label,
url: serverLinks.url,
})
.from(serverLinks)
.innerJoin(servers, eq(serverLinks.serverId, servers.id))
.orderBy(servers.name, serverLinks.label);
res.json({ links: rows });
}));
serversRouter.get("/:id/detail", asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const [server] = await db.select().from(servers).where(eq(servers.id, id)).limit(1);
if (!server) return res.status(404).json({ error: "not_found" });
let ipAddresses: string[] = [];
let hardware: Record<string, unknown>;
if (server.proxmoxIntegrationId && server.proxmoxNode && server.proxmoxGuestType && server.proxmoxVmid !== null) {
const loaded = await loadIntegrationConfig(server.proxmoxIntegrationId);
if (!loaded || loaded.integration.type !== "proxmox") {
hardware = { source: "proxmox", error: "The linked Proxmox integration no longer exists or has changed type." };
} else {
try {
const adapter = createProxmoxAdapter(loaded.config as { url: string; tokenId: string; tokenSecret: string; insecure?: boolean });
const detail = await adapter.getGuestDetail(
server.proxmoxNode,
server.proxmoxGuestType as ProxmoxGuestType,
server.proxmoxVmid,
);
ipAddresses = detail.ipAddresses;
hardware = {
source: "proxmox",
integrationId: loaded.integration.id,
integrationName: loaded.integration.name,
status: detail.status,
cpuCores: detail.cpuCores,
cpuUsagePercent: detail.cpuUsagePercent,
memTotalBytes: detail.memoryBytes,
memUsedBytes: detail.memUsedBytes,
diskBytes: detail.diskBytes,
disks: detail.disks,
uptime: detail.uptime,
guestAgentAvailable: detail.guestAgentAvailable,
};
} catch (err) {
hardware = { source: "proxmox", error: err instanceof Error ? err.message : String(err) };
}
}
} else if (server.cpuModel || server.cpuCores || server.memTotalBytes || server.disks) {
ipAddresses = server.ipAddresses ? JSON.parse(server.ipAddresses) : [];
hardware = {
source: "agent",
cpuModel: server.cpuModel,
cpuCores: server.cpuCores,
cpuLoadPercent: server.cpuLoadPercent,
memTotalBytes: server.memTotalBytes,
memUsedBytes: server.memUsedBytes,
disks: server.disks ? JSON.parse(server.disks) : [],
};
} else {
hardware = { source: "none" };
}
let dnsMatches: { recordName: string; recordType: string; ip: string; zoneName: string | null; providerName: string; providerType: string }[] = [];
if (ipAddresses.length > 0) {
dnsMatches = await db
.select({
recordName: dnsRecordsCache.name,
recordType: dnsRecordsCache.type,
ip: dnsRecordsCache.content,
zoneName: dnsZonesCache.zoneName,
providerName: dnsProviders.name,
providerType: dnsProviders.providerType,
})
.from(dnsRecordsCache)
.innerJoin(dnsProviders, eq(dnsRecordsCache.providerId, dnsProviders.id))
.leftJoin(
dnsZonesCache,
and(eq(dnsZonesCache.providerId, dnsRecordsCache.providerId), eq(dnsZonesCache.zoneId, dnsRecordsCache.zoneId)),
)
.where(inArray(dnsRecordsCache.content, ipAddresses));
}
const links = await db
.select({ id: serverLinks.id, label: serverLinks.label, url: serverLinks.url })
.from(serverLinks)
.where(eq(serverLinks.serverId, id))
.orderBy(serverLinks.label);
const {
apiTokenHash: _apiTokenHash,
tags: rawTags,
ipAddresses: _rawIpAddresses,
disks: _rawDisks,
cpuModel: _cpuModel,
cpuCores: _cpuCores,
cpuLoadPercent: _cpuLoadPercent,
memTotalBytes: _memTotalBytes,
memUsedBytes: _memUsedBytes,
listeningPorts: _listeningPorts,
lastPortScan: _lastPortScan,
...serverOut
} = server;
res.json({ server: { ...serverOut, tags: parseTags(rawTags) }, ipAddresses, hardware, dnsMatches, links });
}));
const tagsSchema = z.object({ tags: z.array(z.string().max(100)).max(50) });
// Tags are lightweight labels, edited by operators like port notes and admin links — not admin-only like renaming a server.
serversRouter.put("/:id/tags", requireRole("operator"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const parsed = tagsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", message: "Tags must be a list of text.", details: parsed.error.flatten() });
}
let tags: string[];
try {
tags = normalizeTags(parsed.data.tags);
} catch (err) {
if (err instanceof InvalidTagError) return res.status(400).json({ error: "invalid_tag", message: err.message });
throw err;
}
const [existing] = await db.select().from(servers).where(eq(servers.id, id)).limit(1);
if (!existing) return res.status(404).json({ error: "not_found" });
await db.update(servers).set({ tags: tags.length > 0 ? JSON.stringify(tags) : null }).where(eq(servers.id, id));
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "set_tags",
targetType: "server",
targetId: id,
detail: { name: existing.name, before: parseTags(existing.tags), after: tags },
});
res.json({ tags });
}));
const linkSchema = z.object({
label: z.string().min(1).max(60),
url: z
.string()
.url()
.refine((u) => u.startsWith("http://") || u.startsWith("https://"), { message: "URL must start with http:// or https://" }),
});
serversRouter.post("/:id/links", requireRole("operator"), asyncHandler(async (req, res) => {
const serverId = Number(req.params.id);
if (!Number.isInteger(serverId)) return res.status(400).json({ error: "invalid_id" });
const parsed = linkSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const [server] = await db.select({ id: servers.id, name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
if (!server) return res.status(404).json({ error: "not_found" });
const [created] = await db.insert(serverLinks).values({ serverId, ...parsed.data }).returning();
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "add_link",
targetType: "server",
targetId: serverId,
detail: { name: server.name, label: created.label, url: created.url },
});
res.status(201).json({ link: { id: created.id, label: created.label, url: created.url } });
}));
serversRouter.patch("/:id/links/:linkId", requireRole("operator"), asyncHandler(async (req, res) => {
const serverId = Number(req.params.id);
const linkId = Number(req.params.linkId);
if (!Number.isInteger(serverId) || !Number.isInteger(linkId)) return res.status(400).json({ error: "invalid_id" });
const parsed = linkSchema.partial().safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const [updated] = await db
.update(serverLinks)
.set(parsed.data)
.where(and(eq(serverLinks.id, linkId), eq(serverLinks.serverId, serverId)))
.returning();
if (!updated) return res.status(404).json({ error: "not_found" });
const [owner] = await db.select({ name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "update_link",
targetType: "server",
targetId: serverId,
detail: { name: owner?.name, label: updated.label, url: updated.url },
});
res.json({ link: { id: updated.id, label: updated.label, url: updated.url } });
}));
serversRouter.delete("/:id/links/:linkId", requireRole("operator"), asyncHandler(async (req, res) => {
const serverId = Number(req.params.id);
const linkId = Number(req.params.linkId);
if (!Number.isInteger(serverId) || !Number.isInteger(linkId)) return res.status(400).json({ error: "invalid_id" });
const deleted = await db
.delete(serverLinks)
.where(and(eq(serverLinks.id, linkId), eq(serverLinks.serverId, serverId)))
.returning();
if (deleted.length === 0) return res.status(404).json({ error: "not_found" });
const [owner] = await db.select({ name: servers.name }).from(servers).where(eq(servers.id, serverId)).limit(1);
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "remove_link",
targetType: "server",
targetId: serverId,
detail: { name: owner?.name, label: deleted[0].label, url: deleted[0].url },
});
res.status(204).end();
}));
serversRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => { serversRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => {
const id = Number(req.params.id); const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" }); if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
+26
View File
@@ -0,0 +1,26 @@
import { Router } from "express";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { listSessions, destroySession } from "../services/sessionStore.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const sessionsRouter = Router();
sessionsRouter.use(requireAuth, requireRole("admin"));
sessionsRouter.get("/", asyncHandler(async (req, res) => {
const sessions = await listSessions();
res.json({ sessions, currentSessionId: req.sessionID });
}));
sessionsRouter.delete("/:id", asyncHandler(async (req, res) => {
await destroySession(req.params.id);
await recordAudit({
actor: req.currentUser!,
category: "session",
action: "revoke",
targetType: "session",
targetId: req.params.id,
});
res.status(204).end();
}));
+272
View File
@@ -0,0 +1,272 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { getSettings, updateSettings } from "../services/settingsStore.js";
import { describeSettingsChanges } from "../services/settingsDiff.js";
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryScheduler.js";
import { scheduleDockerUpdateCheck } from "../services/dockerUpdateScheduler.js";
import { scheduleProxmoxBackupCheck } from "../services/proxmoxBackupScheduler.js";
import { schedulePbsVerificationCheck } from "../services/pbsVerificationScheduler.js";
import { scheduleLogRetentionPurge } from "../services/logRetentionScheduler.js";
import { purgeOldLogs } from "../services/logRetention.js";
import { scheduleQuietHoursFlush } from "../services/quietHoursScheduler.js";
import { testGotify, testNtfy, testSmtp, testWebhook, flushQuietHoursQueue } from "../services/notify.js";
import { listQueuedNotifications } from "../services/notificationQueue.js";
import { buildExportPayload, encryptExport, decryptExport, applyImportPayload, type EncryptedExportFile } from "../services/configBackup.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const settingsRouter = Router();
settingsRouter.use(requireAuth);
settingsRouter.get("/", requireRole("admin"), asyncHandler(async (_req, res) => {
res.json({ settings: await getSettings() });
}));
// Non-secret subset any signed-in user can read, so badge colors can be applied
// throughout the app without exposing Gotify/SMTP/webhook credentials.
settingsRouter.get("/provider-colors", asyncHandler(async (_req, res) => {
const { providerColors } = await getSettings();
res.json({ providerColors });
}));
settingsRouter.get("/integration-colors", asyncHandler(async (_req, res) => {
const { integrationColors } = await getSettings();
res.json({ integrationColors });
}));
// Display prefs (date/time format) affect every page, so any signed-in user
// can read them — same non-secret rationale as the badge-color endpoints.
settingsRouter.get("/display", asyncHandler(async (_req, res) => {
const { display } = await getSettings();
res.json({ display });
}));
const updateSchema = z.object({
gotify: z.object({ enabled: z.boolean(), url: z.string(), token: z.string(), priority: z.number() }).partial().optional(),
ntfy: z.object({ enabled: z.boolean(), url: z.string(), topic: z.string(), token: z.string(), priority: z.number() }).partial().optional(),
smtp: z
.object({
enabled: z.boolean(),
host: z.string(),
port: z.number(),
secure: z.boolean(),
username: z.string(),
password: z.string(),
from: z.string(),
to: z.string(),
})
.partial()
.optional(),
webhook: z.object({ enabled: z.boolean(), url: z.string(), secret: z.string() }).partial().optional(),
notifications: z
.object({
dnsAdd: z.boolean(),
dnsUpdate: z.boolean(),
dnsDelete: z.boolean(),
secretCheck: z.boolean(),
tailscaleKeyCheck: z.boolean(),
dockerUpdateCheck: z.boolean(),
proxmoxBackupCheck: z.boolean(),
pbsVerificationCheck: z.boolean(),
healthAlerts: z.boolean(),
automationAlerts: z.boolean(),
domainExpiryCheck: z.boolean(),
secretCheckTime: z.string().regex(/^\d{2}:\d{2}$/),
timezone: z.string(),
integrationFailureAlerts: z.boolean(),
integrationFailureThreshold: z.number().int().min(1).max(20),
})
.partial()
.optional(),
providerColors: z.record(z.string()).optional(),
integrationColors: z.record(z.string()).optional(),
display: z
.object({ dateFormat: z.enum(["ymd", "dmy", "mdy"]), timeFormat: z.enum(["24h", "12h"]), pageSize: z.number().int().min(5).max(500) })
.partial()
.optional(),
logRetention: z
.object({ enabled: z.boolean(), retentionDays: z.number().int().min(1).max(3650), intervalHours: z.number().int().min(1).max(720) })
.partial()
.optional(),
healthChecks: z
.object({ serverOfflineMinutes: z.number().int().min(15).max(10080), diskUsagePercent: z.number().int().min(50).max(99), domainWarnDays: z.number().int().min(1).max(365) })
.partial()
.optional(),
quietHours: z
.object({ enabled: z.boolean(), start: z.string().regex(/^\d{2}:\d{2}$/), end: z.string().regex(/^\d{2}:\d{2}$/) })
.partial()
.optional(),
});
settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = updateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const before = await getSettings();
const updated = await updateSettings(parsed.data);
if (parsed.data.notifications) {
await scheduleSecretExpiryCheck();
await scheduleTailscaleKeyExpiryCheck();
await scheduleDockerUpdateCheck();
await scheduleProxmoxBackupCheck();
await schedulePbsVerificationCheck();
}
if (parsed.data.logRetention) {
await scheduleLogRetentionPurge();
}
if (parsed.data.quietHours || parsed.data.notifications) {
await scheduleQuietHoursFlush();
}
await recordAudit({
actor: req.currentUser!,
category: "settings",
action: "update",
targetType: "settings",
detail: { sections: Object.keys(parsed.data), changes: describeSettingsChanges(before, parsed.data) },
});
res.json({ settings: updated });
}));
settingsRouter.post("/test-gotify", requireRole("admin"), asyncHandler(async (req, res) => {
const schema = z.object({ url: z.string().min(1), token: z.string().min(1), priority: z.number().optional() });
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
try {
await testGotify(parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
settingsRouter.post("/test-ntfy", requireRole("admin"), asyncHandler(async (req, res) => {
const schema = z.object({ url: z.string().min(1), topic: z.string().min(1), token: z.string().optional(), priority: z.number().optional() });
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
try {
await testNtfy(parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
settingsRouter.post("/test-smtp", requireRole("admin"), asyncHandler(async (req, res) => {
const schema = z.object({
host: z.string().min(1),
port: z.number().optional(),
secure: z.boolean().optional(),
username: z.string().optional(),
password: z.string().optional(),
from: z.string().min(1),
to: z.string().min(1),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
try {
await testSmtp(parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
settingsRouter.post("/test-webhook", requireRole("admin"), asyncHandler(async (req, res) => {
const schema = z.object({ url: z.string().min(1), secret: z.string().optional() });
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
try {
await testWebhook(parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
settingsRouter.post("/purge-logs", requireRole("admin"), asyncHandler(async (req, res) => {
const { logRetention } = await getSettings();
const result = await purgeOldLogs(logRetention.retentionDays);
await recordAudit({
actor: req.currentUser!,
category: "settings",
action: "purge_logs",
detail: { retentionDays: logRetention.retentionDays, ...result },
});
res.json(result);
}));
settingsRouter.get("/quiet-hours-queue", requireRole("admin"), asyncHandler(async (_req, res) => {
const items = await listQueuedNotifications();
res.json({ count: items.length, items });
}));
settingsRouter.post("/flush-quiet-hours", requireRole("admin"), asyncHandler(async (req, res) => {
const flushed = await flushQuietHoursQueue();
await recordAudit({ actor: req.currentUser!, category: "settings", action: "flush_quiet_hours", detail: { flushed } });
res.json({ flushed });
}));
const exportSchema = z.object({ passphrase: z.string().min(8) });
settingsRouter.post("/export", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = exportSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
const payload = await buildExportPayload();
const file = encryptExport(payload, parsed.data.passphrase);
await recordAudit({
actor: req.currentUser!,
category: "settings",
action: "export_config",
detail: { integrations: payload.integrations.length, dnsProviders: payload.dnsProviders.length },
});
res.json(file);
}));
const encryptedFileSchema = z.object({
app: z.literal("homelab-manager-backup"),
version: z.literal(1),
salt: z.string().min(1),
iv: z.string().min(1),
authTag: z.string().min(1),
ciphertext: z.string().min(1),
});
const importSchema = z.object({ passphrase: z.string().min(1), file: encryptedFileSchema });
settingsRouter.post("/import", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = importSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
let payload;
try {
payload = decryptExport(parsed.data.file as EncryptedExportFile, parsed.data.passphrase);
} catch {
return res.status(400).json({ error: "decrypt_failed", message: "Wrong passphrase, or the file is corrupted." });
}
if (!payload || typeof payload !== "object" || !Array.isArray(payload.integrations) || !Array.isArray(payload.dnsProviders) || !payload.settings) {
return res.status(400).json({ error: "invalid_payload", message: "Decrypted file doesn't look like a Homelab Manager backup." });
}
const result = await applyImportPayload(payload);
await recordAudit({
actor: req.currentUser!,
category: "settings",
action: "import_config",
detail: result,
});
res.json(result);
}));
+148
View File
@@ -0,0 +1,148 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { servers, tagDefinitions } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { InvalidTagError, normalizeColor, normalizeTag, parseTags } from "../services/serverTags.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const tagsRouter = Router();
tagsRouter.use(requireAuth);
interface TagEntry {
name: string;
/** "#rrggbb", or null for the automatic colour. */
color: string | null;
/** Servers currently carrying it. */
count: number;
}
async function listTags(): Promise<TagEntry[]> {
const [defs, rows] = await Promise.all([db.select().from(tagDefinitions), db.select({ tags: servers.tags }).from(servers)]);
const counts = new Map<string, number>();
for (const r of rows) for (const t of new Set(parseTags(r.tags))) counts.set(t, (counts.get(t) ?? 0) + 1);
const defined = new Map(defs.map((d) => [d.name, d.color]));
const names = new Set([...defined.keys(), ...counts.keys()]);
return [...names]
.map((name) => ({ name, color: defined.get(name) ?? null, count: counts.get(name) ?? 0 }))
.sort((a, b) => a.name.localeCompare(b.name, "sv"));
}
// Read by everyone signed in: colours are needed to draw tags anywhere, and operators need the list to offer suggestions.
tagsRouter.get("/", asyncHandler(async (_req, res) => {
res.json({ tags: await listTags() });
}));
// Changing the catalogue is a Settings matter, so admin-only — tagging a server (operator) is separate.
const nameField = z.string().min(1).max(100);
const colorField = z.string().max(20);
/** Turns the shared validation failures (bad name, bad colour) into a 400 with a message worth showing. */
function handleInvalid(err: unknown, res: import("express").Response) {
if (err instanceof InvalidTagError) return res.status(400).json({ error: "invalid_tag", message: err.message });
throw err;
}
tagsRouter.post("/", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = z.object({ name: nameField, color: colorField.nullish() }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Give the tag a name.", details: parsed.error.flatten() });
try {
const name = normalizeTag(parsed.data.name);
const color = parsed.data.color ? normalizeColor(parsed.data.color) : null;
const [existing] = await db.select().from(tagDefinitions).where(eq(tagDefinitions.name, name)).limit(1);
if (existing) return res.status(409).json({ error: "exists", message: `"${name}" already exists.` });
await db.insert(tagDefinitions).values({ name, color });
await recordAudit({ actor: req.currentUser!, category: "tag", action: "create", targetType: "tag", detail: { name, color } });
res.status(201).json({ tags: await listTags() });
} catch (err) {
return handleInvalid(err, res);
}
}));
tagsRouter.put("/color", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = z.object({ name: nameField, color: colorField.nullable() }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Choose a tag and a colour.", details: parsed.error.flatten() });
try {
const name = normalizeTag(parsed.data.name);
const color = parsed.data.color === null ? null : normalizeColor(parsed.data.color);
const known = (await listTags()).some((t) => t.name === name);
if (!known) return res.status(404).json({ error: "not_found", message: `There's no tag "${name}".` });
await db
.insert(tagDefinitions)
.values({ name, color })
.onConflictDoUpdate({ target: tagDefinitions.name, set: { color } });
await recordAudit({ actor: req.currentUser!, category: "tag", action: "set_color", targetType: "tag", detail: { name, color } });
res.json({ tags: await listTags() });
} catch (err) {
return handleInvalid(err, res);
}
}));
/** Rewrites every server's tag list through `change`, saving only the ones that differ. Returns how many servers changed. */
async function rewriteServerTags(tx: Pick<typeof db, "select" | "update">, change: (tags: string[]) => string[]): Promise<number> {
let changed = 0;
for (const row of await tx.select({ id: servers.id, tags: servers.tags }).from(servers)) {
const before = parseTags(row.tags);
if (before.length === 0) continue;
const sorted = (tags: string[]) => [...new Set(tags)].sort((a, b) => a.localeCompare(b, "sv"));
const after = sorted(change(before));
// Compare against the same tags in the same order, so a server is only rewritten (and counted) when the change really touched it.
if (JSON.stringify(after) === JSON.stringify(sorted(before))) continue;
await tx.update(servers).set({ tags: after.length > 0 ? JSON.stringify(after) : null }).where(eq(servers.id, row.id));
changed++;
}
return changed;
}
tagsRouter.post("/rename", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = z.object({ from: nameField, to: nameField }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Give the current and the new name.", details: parsed.error.flatten() });
try {
const from = normalizeTag(parsed.data.from);
const to = normalizeTag(parsed.data.to);
const all = await listTags();
if (!all.some((t) => t.name === from)) return res.status(404).json({ error: "not_found", message: `There's no tag "${from}".` });
if (from === to) return res.status(400).json({ error: "same_name", message: "That's already its name." });
const merged = all.some((t) => t.name === to);
// One transaction: the servers and the catalogue must never disagree about what a tag is called.
const updatedServers = await db.transaction(async (tx) => {
const n = await rewriteServerTags(tx, (tags) => tags.map((t) => (t === from ? to : t)));
const [fromDef] = await tx.select().from(tagDefinitions).where(eq(tagDefinitions.name, from)).limit(1);
const [toDef] = await tx.select().from(tagDefinitions).where(eq(tagDefinitions.name, to)).limit(1);
if (fromDef && toDef) {
// Merging into a tag that already exists: it keeps its own colour, unless it never had one.
if (!toDef.color && fromDef.color) await tx.update(tagDefinitions).set({ color: fromDef.color }).where(eq(tagDefinitions.id, toDef.id));
await tx.delete(tagDefinitions).where(eq(tagDefinitions.id, fromDef.id));
} else if (fromDef) {
await tx.update(tagDefinitions).set({ name: to }).where(eq(tagDefinitions.id, fromDef.id));
}
return n;
});
await recordAudit({ actor: req.currentUser!, category: "tag", action: merged ? "merge" : "rename", targetType: "tag", detail: { from, to, servers: updatedServers } });
res.json({ tags: await listTags(), updatedServers, merged });
} catch (err) {
return handleInvalid(err, res);
}
}));
tagsRouter.post("/delete", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = z.object({ name: nameField }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", message: "Choose a tag.", details: parsed.error.flatten() });
try {
const name = normalizeTag(parsed.data.name);
if (!(await listTags()).some((t) => t.name === name)) return res.status(404).json({ error: "not_found", message: `There's no tag "${name}".` });
const updatedServers = await db.transaction(async (tx) => {
const n = await rewriteServerTags(tx, (tags) => tags.filter((t) => t !== name));
await tx.delete(tagDefinitions).where(eq(tagDefinitions.name, name));
return n;
});
await recordAudit({ actor: req.currentUser!, category: "tag", action: "delete", targetType: "tag", detail: { name, servers: updatedServers } });
res.json({ tags: await listTags(), updatedServers });
} catch (err) {
return handleInvalid(err, res);
}
}));
+1 -1
View File
@@ -60,7 +60,7 @@ tasksRouter.get("/", asyncHandler(async (req, res) => {
const manualTaskSchema = z.object({ const manualTaskSchema = z.object({
serverId: z.number().int(), serverId: z.number().int(),
scheduleType: z.enum(["cron", "systemd_timer", "docker", "backup", "update", "n8n_workflow", "manual"]), scheduleType: z.enum(["cron", "systemd_timer", "windows_task", "docker", "backup", "update", "n8n_workflow", "manual"]),
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
command: z.string().max(1000).optional(), command: z.string().max(1000).optional(),
scheduleExpression: z.string().max(200).optional(), scheduleExpression: z.string().max(200).optional(),
+53
View File
@@ -0,0 +1,53 @@
// Shared between the per-server Ports card (routes/serverPorts.ts) and the cross-server
// Network > Ports page (routes/ports.ts) — both read the same agent-reported "listeningPorts"
// JSON column and need the same one-row-per-socket -> one-row-per-protocol+port grouping.
export interface AgentPort {
protocol: "tcp" | "udp";
port: number;
address: string;
process?: string;
}
export interface GroupedAgentPort {
addresses: string[];
process: string | null;
localOnly: boolean;
}
export function parseJson<T>(text: string | null | undefined, fallback: T): T {
if (!text) return fallback;
try {
return JSON.parse(text) as T;
} catch {
return fallback;
}
}
export function isLoopback(address: string): boolean {
const bare = address.replace(/%.*$/, "").replace(/^\[|\]$/g, "");
return bare.startsWith("127.") || bare === "::1";
}
/** Groups the agent's raw one-row-per-socket report into one entry per protocol+port, keyed "tcp:443". */
export function groupAgentPorts(raw: AgentPort[]): Map<string, GroupedAgentPort> {
const grouped = new Map<string, { addresses: Set<string>; process: string | null }>();
for (const p of raw) {
const key = `${p.protocol}:${p.port}`;
const entry = grouped.get(key) ?? { addresses: new Set<string>(), process: null };
entry.addresses.add(p.address);
if (!entry.process && p.process) entry.process = p.process;
grouped.set(key, entry);
}
const out = new Map<string, GroupedAgentPort>();
for (const [key, entry] of grouped) {
const addresses = [...entry.addresses];
out.set(key, { addresses, process: entry.process, localOnly: addresses.every(isLoopback) });
}
return out;
}
export function splitPortKey(key: string): { protocol: "tcp" | "udp"; port: number } {
const [protocol, port] = key.split(":");
return { protocol: protocol as "tcp" | "udp", port: Number(port) };
}
+28
View File
@@ -0,0 +1,28 @@
// Shared by the alerts page's collector (services/alerts.ts) and the scheduled checks it reuses, which can't import
// that file themselves without going round in a circle.
export type AlertSeverity = "critical" | "warning" | "info";
/** What kind of problem — drives the filter on the Alerts page. */
export type AlertCategory = "offline" | "disk" | "backup" | "updates" | "expiry" | "automation" | "integration" | "monitoring" | "tickets";
export interface Alert {
/** Stable, so the page can key rows on it. */
id: string;
severity: AlertSeverity;
category: AlertCategory;
/** Where it comes from, as a short name: "Server", "Proxmox", "Secrets", ... */
source: string;
message: string;
/** In-app page that shows more, if there is one. */
link: string | null;
/** Under an active maintenance window: still a real problem, but notifications for it are held back. */
silenced: boolean;
}
/** One integration that couldn't be read while collecting — so the page never mistakes "couldn't check" for "all clear". */
export interface SourceFailure {
integrationId: number;
integrationName: string;
message: string;
}
+387
View File
@@ -0,0 +1,387 @@
/**
* Everything that's wrong right now, in one list — the Alerts page. Nothing here decides what counts as a problem: it asks
* the same checks that send the notifications (health, backups, updates, expiry, automation, ...) and turns what they find
* into a flat list, so the page and the notifications can't disagree. Unlike the notifications it ignores the per-event
* on/off toggles (the page is for looking at, not for being interrupted by) and keeps problems that are under a
* maintenance window, flagged as silenced rather than dropped.
*
* It reads live (a handful of API calls per integration), so a result is kept for a short while rather than re-run for
* every viewer, and every source has a time limit so one hung integration can't hang the page. A source that can't be
* read is reported as such — silence from it must never look like "all clear".
*/
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { integrations, secrets } from "../db/schema.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createUptimeKumaAdapter } from "../integrations/uptimekuma/adapter.js";
import { createOsTicketAdapter } from "../integrations/osticket/adapter.js";
import { activeSubjects, isSourceInMaintenance, subjectOfConditionKey } from "./maintenance.js";
import { collectSnapshot, evaluateHealth, startupGraceRemainingMs } from "./healthMonitor.js";
import { collectAutomation, evaluateAutomation } from "./automationMonitor.js";
import { collectDockerUpdates } from "./dockerUpdateScheduler.js";
import { collectProxmoxBackupProblems } from "./proxmoxBackupScheduler.js";
import { collectPbsProblems } from "./pbsVerificationScheduler.js";
import { collectTailscaleKeyExpiries } from "./tailscaleKeyExpiryScheduler.js";
import { collectDomainAlerts } from "./domainMonitor.js";
import { computeSecretStatus } from "./secretStatus.js";
import { getFailingSources } from "./integrationHealthMonitor.js";
import { getSettings } from "./settingsStore.js";
import { sourceLabel } from "./notify.js";
import type { Alert, AlertCategory, AlertSeverity, SourceFailure } from "./alertTypes.js";
export interface AlertsReport {
alerts: Alert[];
/** Active problems by severity — those under a maintenance window are counted apart, not in these. */
counts: { critical: number; warning: number; info: number; silenced: number };
/** Things that couldn't be checked, and which checks that leaves blind. */
couldntCheck: { name: string; error: string; affects: string[] }[];
/** Context worth knowing about how complete the picture is. */
notes: string[];
generatedAt: string;
}
/** Where each kind of source lives in the app, and what to call it. */
const SOURCES: Record<string, { label: string; link: string }> = {
server: { label: "Server", link: "/servers" },
proxmox: { label: "Proxmox", link: "/proxmox" },
synology: { label: "Synology", link: "/synology" },
semaphore: { label: "Semaphore", link: "/semaphore" },
gitea: { label: "Gitea", link: "/gitea" },
dockhand: { label: "Docker", link: "/docker" },
tailscale: { label: "Tailscale", link: "/tailscale" },
pbs: { label: "Proxmox Backup", link: "/pbs" },
uptimekuma: { label: "Uptime Kuma", link: "/uptime-kuma" },
osticket: { label: "osTicket", link: "/osticket" },
secrets: { label: "Secrets", link: "/secrets" },
domains: { label: "Domains", link: "/domains" },
};
const SOURCE_TIMEOUT_MS = 20_000;
/** How long a result is reused. */
const CACHE_MS = 60_000;
/** Pressing Refresh over and over shouldn't hammer every integration — a result younger than this is reused even then. */
const MIN_REFRESH_MS = 10_000;
const SEVERITY_ORDER: Record<AlertSeverity, number> = { critical: 0, warning: 1, info: 2 };
/** Which kind of source, and which one, a health/automation condition key belongs to. */
function originOfConditionKey(key: string): { type: string; id: number | null; category: AlertCategory } {
let m = /^(?:offline|disk):server:(\d+)/.exec(key);
if (m) return { type: "server", id: Number(m[1]), category: key.startsWith("offline") ? "offline" : "disk" };
m = /^disk:proxmox:(\d+):/.exec(key);
if (m) return { type: "proxmox", id: Number(m[1]), category: "disk" };
m = /^(?:synology-volume|synology-disk|disk:synology):(\d+):/.exec(key);
if (m) return { type: "synology", id: Number(m[1]), category: "disk" };
m = /^automation:(semaphore|gitea):(\d+):/.exec(key);
if (m) return { type: m[1], id: Number(m[2]), category: "automation" };
return { type: "server", id: null, category: "disk" };
}
function withTimeout<T>(work: Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const limit = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`timed out after ${SOURCE_TIMEOUT_MS / 1000}s`)), SOURCE_TIMEOUT_MS);
});
return Promise.race([work, limit]).finally(() => clearTimeout(timer));
}
const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err));
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
export async function collectAlerts(): Promise<AlertsReport> {
const alerts: Alert[] = [];
const notes: string[] = [];
const blind = new Map<string, { error: string; affects: Set<string> }>();
const subjects = await activeSubjects();
const intRows = await db.select({ id: integrations.id, name: integrations.name, type: integrations.type, enabled: integrations.enabled }).from(integrations);
const intById = new Map(intRows.map((r) => [r.id, r]));
function push(a: { category: AlertCategory; severity: AlertSeverity; type: string; message: string; key: string; link?: string | null; silenced?: boolean }) {
const meta = SOURCES[a.type];
alerts.push({
id: `${a.category}:${a.key}`,
severity: a.severity,
category: a.category,
source: meta?.label ?? sourceLabel(a.type),
message: a.message,
link: a.link === undefined ? (meta?.link ?? null) : a.link,
silenced: !!a.silenced,
});
}
function cannotCheck(name: string, error: string, check: string) {
const entry = blind.get(name) ?? { error, affects: new Set<string>() };
entry.affects.add(check);
blind.set(name, entry);
}
const cannotCheckIntegration = (f: SourceFailure, check: string) => {
const row = intById.get(f.integrationId);
cannotCheck(`${row ? (SOURCES[row.type]?.label ?? row.type) : "Integration"} “${f.integrationName}”`, f.message, check);
};
const silencedIntegration = (id: number) => subjects.has(`integration:${id}`);
// One entry per check. A check that blows up, or hangs, only costs its own section of the page.
async function check(name: string, work: () => Promise<void>) {
try {
await withTimeout(work());
} catch (err) {
cannotCheck(name, errorText(err), name);
}
}
await Promise.all([
check("Server and storage health", async () => {
const { healthChecks } = await getSettings();
const { snapshot, held } = await collectSnapshot();
const now = Date.now();
const grace = startupGraceRemainingMs(now);
if (grace > 0) {
notes.push(
`Server-offline and server-disk checks are paused for another ${Math.ceil(grace / 60_000)} min after the app restarted, so agents get a chance to report before any server is judged.`,
);
}
for (const c of evaluateHealth(snapshot, healthChecks, now, { skipServers: grace > 0 })) {
const origin = originOfConditionKey(c.key);
const subject = subjectOfConditionKey(c.key);
push({
category: origin.category,
severity: c.severity ?? "warning",
type: origin.type,
message: c.message,
key: c.key,
link: origin.type === "server" && origin.id !== null ? `/servers/${origin.id}` : undefined,
silenced: subject !== null && subjects.has(subject),
});
}
// Integrations the check couldn't read this time. (A whole-integration entry covers its nodes, so skip those.)
for (const h of held) {
const m = /^(proxmox|synology):(\d+)(?::(.+))?$/.exec(h);
if (!m || (m[3] && held.has(`${m[1]}:${m[2]}`))) continue;
const row = intById.get(Number(m[2]));
cannotCheck(`${SOURCES[m[1]].label} “${row?.name ?? `#${m[2]}`}”${m[3] ? ` (node ${m[3]})` : ""}`, "couldn't be read — see the Diagnostic Log", "Server and storage health");
}
}),
check("Automation runs", async () => {
const { items, held } = await collectAutomation();
for (const c of evaluateAutomation(items).conditions) {
const origin = originOfConditionKey(c.key);
const subject = subjectOfConditionKey(c.key);
push({ category: "automation", severity: "warning", type: origin.type, message: c.message, key: c.key, silenced: subject !== null && subjects.has(subject) });
}
for (const h of held) {
const m = /^(semaphore|gitea):(\d+)$/.exec(h);
if (!m) continue;
const row = intById.get(Number(m[2]));
cannotCheck(`${SOURCES[m[1]].label} “${row?.name ?? `#${m[2]}`}”`, "couldn't be read — see the Diagnostic Log", "Automation runs");
}
}),
check("Proxmox backups", async () => {
const { failures, uncovered, sourceFailures } = await collectProxmoxBackupProblems({ skipSilenced: false });
for (const f of failures) {
push({
category: "backup",
severity: "critical",
type: "proxmox",
message: `Latest backup on ${f.node}${f.guestId ? ` (guest ${f.guestId})` : ""} didn't succeed [${f.integrationName}]: ${f.status}`,
key: `proxmox-backup:${f.integrationId}:${f.node}`,
silenced: f.silenced,
});
}
for (const u of uncovered) {
push({
category: "backup",
severity: "warning",
type: "proxmox",
message: `${u.guestName} (#${u.vmid}) on ${u.node} isn't covered by any backup job [${u.integrationName}]`,
key: `proxmox-uncovered:${u.integrationId}:${u.vmid}`,
silenced: u.silenced,
});
}
sourceFailures.forEach((f) => cannotCheckIntegration(f, "Proxmox backups"));
}),
check("Backup verification", async () => {
const { problems, sourceFailures } = await collectPbsProblems({ skipSilenced: false });
for (const p of problems) {
push({
category: "backup",
severity: p.error ? "warning" : "critical",
type: "pbs",
message: p.error
? `Datastore "${p.datastore}" couldn't be read [${p.integrationName}]: ${p.error}`
: `Datastore "${p.datastore}" has ${plural(p.failedCount, "snapshot")} that failed verification [${p.integrationName}]`,
key: `pbs:${p.integrationId}:${p.datastore}`,
silenced: p.silenced,
});
}
sourceFailures.forEach((f) => cannotCheckIntegration(f, "Backup verification"));
}),
check("Image updates", async () => {
const { items, failures } = await collectDockerUpdates();
for (const u of items) {
push({
category: "updates",
severity: "info",
type: "dockhand",
message: `${u.containerName} [${u.environmentName}, ${u.integrationName}] has an image update available${u.newerVersion ? ` → ${u.newerVersion}` : ""}`,
key: `docker:${u.integrationId}:${u.environmentName}:${u.containerName}`,
silenced: silencedIntegration(u.integrationId),
});
}
failures.forEach((f) => cannotCheckIntegration(f, "Image updates"));
}),
check("Tailscale keys", async () => {
const { items, failures } = await collectTailscaleKeyExpiries();
for (const k of items) {
push({
category: "expiry",
severity: k.daysLeft < 0 ? "critical" : "warning",
type: "tailscale",
message: k.daysLeft < 0 ? `Key for ${k.deviceLabel} [${k.integrationName}] has expired` : `Key for ${k.deviceLabel} [${k.integrationName}] expires in ${plural(k.daysLeft, "day")}`,
key: `tailscale-key:${k.integrationId}:${k.deviceLabel}`,
silenced: silencedIntegration(k.integrationId),
});
}
failures.forEach((f) => cannotCheckIntegration(f, "Tailscale keys"));
}),
check("Uptime Kuma", async () => {
for (const row of intRows.filter((r) => r.type === "uptimekuma" && r.enabled)) {
try {
const loaded = await loadIntegrationConfig(row.id);
if (!loaded) continue;
for (const m of await createUptimeKumaAdapter(loaded.config as any).listMonitors()) {
if (m.status !== "down") continue;
push({
category: "monitoring",
severity: "critical",
type: "uptimekuma",
message: `Monitor "${m.name}" is down${m.target ? ` (${m.target}${m.port ? `:${m.port}` : ""})` : ""} [${row.name}]`,
key: `kuma:${row.id}:${m.id}`,
silenced: silencedIntegration(row.id),
});
}
} catch (err) {
cannotCheckIntegration({ integrationId: row.id, integrationName: row.name, message: errorText(err) }, "Uptime Kuma");
}
}
}),
check("osTicket", async () => {
for (const row of intRows.filter((r) => r.type === "osticket" && r.enabled)) {
try {
const loaded = await loadIntegrationConfig(row.id);
if (!loaded) continue;
const overdue = (await createOsTicketAdapter(loaded.config as any).listOpenTickets()).filter((t) => t.isOverdue).length;
if (overdue > 0) {
push({
category: "tickets",
severity: "warning",
type: "osticket",
message: `${plural(overdue, "open ticket")} ${overdue === 1 ? "is" : "are"} overdue [${row.name}]`,
key: `osticket:${row.id}`,
silenced: silencedIntegration(row.id),
});
}
} catch (err) {
cannotCheckIntegration({ integrationId: row.id, integrationName: row.name, message: errorText(err) }, "osTicket");
}
}
}),
check("Secrets", async () => {
for (const s of await db.select().from(secrets)) {
const status = computeSecretStatus(s.expiryDate, s.warnDays);
if (status.status === "expired") {
push({ category: "expiry", severity: "critical", type: "secrets", message: `Secret "${s.name}" expired ${plural(Math.abs(status.daysLeft), "day")} ago (${s.expiryDate})`, key: `secret:${s.id}` });
} else if (status.status === "expiring") {
push({ category: "expiry", severity: "warning", type: "secrets", message: `Secret "${s.name}" expires in ${plural(status.daysLeft, "day")} (${s.expiryDate})`, key: `secret:${s.id}` });
}
if (s.checkHost && s.lastCheckError) {
push({
category: "expiry",
severity: "warning",
type: "secrets",
message: `Couldn't read the live certificate for "${s.name}" (${s.checkHost}:${s.checkPort ?? 443}) — the expiry shown may be stale: ${s.lastCheckError}`,
key: `secret-check:${s.id}`,
});
}
}
}),
check("Domains", async () => {
const { expiring, staleChecks } = await collectDomainAlerts();
for (const d of expiring) {
push({
category: "expiry",
severity: d.status === "expired" ? "critical" : "warning",
type: "domains",
message: d.status === "expired" ? `Domain ${d.name} expired on ${d.expiresAt}` : `Domain ${d.name} expires in ${plural(d.daysLeft, "day")} (${d.expiresAt})`,
key: `domain:${d.name}`,
});
}
for (const s of staleChecks) {
push({ category: "expiry", severity: "info", type: "domains", message: `Couldn't refresh the registration for ${s.name} — the expiry shown may be stale: ${s.error}`, key: `domain-stale:${s.name}` });
}
}),
check("Integration failures", async () => {
const { notifications } = await getSettings();
for (const f of getFailingSources()) {
push({
category: "integration",
severity: f.alerted ? "critical" : "warning",
type: f.source,
message: `${sourceLabel(f.source)} has failed its last ${plural(f.consecutiveFailures, "call")} in a row${f.alerted ? "" : ` (a notification goes out after ${notifications.integrationFailureThreshold})`} — see the Diagnostic Log`,
key: `failing:${f.source}`,
link: null,
silenced: await isSourceInMaintenance(f.source),
});
}
}),
]);
alerts.sort(
(a, b) =>
Number(a.silenced) - Number(b.silenced) ||
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
a.source.localeCompare(b.source) ||
a.message.localeCompare(b.message),
);
const active = alerts.filter((a) => !a.silenced);
return {
alerts,
counts: {
critical: active.filter((a) => a.severity === "critical").length,
warning: active.filter((a) => a.severity === "warning").length,
info: active.filter((a) => a.severity === "info").length,
silenced: alerts.length - active.length,
},
couldntCheck: [...blind.entries()].map(([name, v]) => ({ name, error: v.error, affects: [...v.affects] })),
notes,
generatedAt: new Date().toISOString(),
};
}
let cache: { at: number; report: AlertsReport } | null = null;
let inFlight: Promise<AlertsReport> | null = null;
/** The current alerts, reusing a recent result unless `force` asks for a fresh one (and even then not more than once every few seconds). */
export async function getAlerts(force: boolean): Promise<AlertsReport & { cached: boolean }> {
const age = cache ? Date.now() - cache.at : Infinity;
if (cache && age < (force ? MIN_REFRESH_MS : CACHE_MS)) return { ...cache.report, cached: true };
inFlight ??= collectAlerts()
.then((report) => {
cache = { at: Date.now(), report };
return report;
})
.finally(() => {
inFlight = null;
});
return { ...(await inFlight), cached: false };
}
+7 -4
View File
@@ -3,9 +3,12 @@ import { auditLog, users } from "../db/schema.js";
type CurrentUser = typeof users.$inferSelect; type CurrentUser = typeof users.$inferSelect;
/** Records one audit-log entry. Call this from any route that mutates state or takes an action. */ /** Who an automatic, no-one-clicked-anything entry is attributed to. */
export const SYSTEM_ACTOR_LABEL = "system";
/** Records one audit-log entry. Call this from any route that mutates state or takes an action. Leave `actor` out for something the app did by itself. */
export async function recordAudit(params: { export async function recordAudit(params: {
actor: CurrentUser; actor?: CurrentUser;
category: string; category: string;
action: string; action: string;
targetType?: string; targetType?: string;
@@ -13,8 +16,8 @@ export async function recordAudit(params: {
detail?: unknown; detail?: unknown;
}) { }) {
await db.insert(auditLog).values({ await db.insert(auditLog).values({
actorUserId: params.actor.id, actorUserId: params.actor?.id,
actorLabel: params.actor.name ?? params.actor.email ?? params.actor.oidcSub, actorLabel: params.actor ? (params.actor.name ?? params.actor.email ?? params.actor.oidcSub) : SYSTEM_ACTOR_LABEL,
category: params.category, category: params.category,
action: params.action, action: params.action,
targetType: params.targetType, targetType: params.targetType,
+158
View File
@@ -0,0 +1,158 @@
import { and, eq, inArray } from "drizzle-orm";
import { db } from "../db/client.js";
import { integrations } from "../db/schema.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js";
import { createGiteaAdapter } from "../integrations/gitea/adapter.js";
import { diffConditions, type ActiveState, type HealthCondition } from "./healthMonitor.js";
import { activeSubjects } from "./maintenance.js";
import { notifyAutomationFailed, notifyAutomationRecovered } from "./notify.js";
import { getInternalFlag, setInternalFlag } from "./settingsStore.js";
const STATE_FLAG = "automationActiveConditions";
/**
* What a piece of automation's most recent run tells us. "unknown" covers everything that is neither a clear
* pass nor a clear failure — still running, waiting, cancelled, skipped, stopped by hand — and means "don't
* change what we were saying": a run in progress must not clear a failure it hasn't yet fixed, and a manual
* stop isn't a failure.
*/
export type RunOutcome = "failed" | "passed" | "unknown";
export interface AutomationItem {
/** Stable identity: the same template/repo always has the same key. */
key: string;
/** Hierarchical source, "semaphore:3:12:45" — held when the run's outcome isn't known. */
source: string;
outcome: RunOutcome;
/** What to say when it has failed. */
message: string;
}
// ─── Classification (pure) ──────────────────────────────────────────────────
export function classifySemaphoreStatus(status: string | null | undefined): RunOutcome {
if (status === "error") return "failed";
if (status === "success") return "passed";
return "unknown"; // waiting, starting, running, stopping, stopped, rejected, confirmed, ...
}
export function classifyGiteaRun(run: { status: string; conclusion: string | null }): RunOutcome {
if (run.conclusion === "failure" || run.status === "failure") return "failed";
if (run.conclusion === "success" || run.status === "success") return "passed";
return "unknown"; // running, waiting, blocked, cancelled, skipped, ...
}
function endedSuffix(end: string | null): string {
if (!end) return "";
const t = Date.parse(end);
return Number.isFinite(t) ? ` (${new Date(t).toISOString().slice(0, 16).replace("T", " ")} UTC)` : "";
}
/** Splits the items into the failures to report and the sources whose state must be left alone this pass. */
export function evaluateAutomation(items: AutomationItem[]): { conditions: HealthCondition[]; held: Set<string> } {
const conditions: HealthCondition[] = [];
const held = new Set<string>();
for (const item of items) {
if (item.outcome === "failed") conditions.push({ key: item.key, source: item.source, message: item.message });
else if (item.outcome === "unknown") held.add(item.source);
}
return { conditions, held };
}
// ─── Collection (I/O) ───────────────────────────────────────────────────────
export async function collectAutomation(): Promise<{ items: AutomationItem[]; held: Set<string>; readable: number }> {
const items: AutomationItem[] = [];
const held = new Set<string>();
let readable = 0;
const rows = await db
.select({ id: integrations.id, name: integrations.name, type: integrations.type })
.from(integrations)
.where(and(eq(integrations.enabled, true), inArray(integrations.type, ["semaphore", "gitea"])));
for (const row of rows) {
try {
const loaded = await loadIntegrationConfig(row.id);
if (!loaded) continue;
if (row.type === "semaphore") {
const { templates, failedProjectIds } = await createSemaphoreAdapter(loaded.config as any).checkTemplates();
// A project that couldn't be listed contributes no templates — that's "couldn't read", not "all fixed".
for (const pid of failedProjectIds) held.add(`semaphore:${row.id}:${pid}`);
for (const t of templates) {
if (!t.lastTask) continue;
items.push({
key: `automation:semaphore:${row.id}:${t.projectId}:${t.id}`,
source: `semaphore:${row.id}:${t.projectId}:${t.id}`,
outcome: classifySemaphoreStatus(t.lastTask.status),
message: `${row.name} / ${t.projectName} / ${t.name}: run #${t.lastTask.id} failed${endedSuffix(t.lastTask.end)}`,
});
}
} else {
const repos = await createGiteaAdapter(loaded.config as any).listReposWithStatus();
for (const r of repos) {
if (!r.hasActions) continue;
const source = `gitea:${row.id}:${r.fullName}`;
// The adapter reports a run it couldn't fetch as null, the same as "no runs yet" — either way there's nothing to judge.
if (!r.latestRun) {
held.add(source);
continue;
}
const run = r.latestRun;
items.push({
key: `automation:gitea:${row.id}:${r.fullName}`,
source,
outcome: classifyGiteaRun(run),
message: `${row.name} / ${r.fullName}: "${run.displayTitle || "workflow"}" (run #${run.runNumber}) failed${run.headBranch ? ` on ${run.headBranch}` : ""}${run.htmlUrl ? ` — ${run.htmlUrl}` : ""}`,
});
}
}
readable++;
} catch (err) {
console.error(`[automation] couldn't read ${row.type} integration ${row.id}:`, err instanceof Error ? err.message : err);
held.add(`${row.type}:${row.id}`);
}
}
return { items, held, readable };
}
// ─── The scheduled pass ─────────────────────────────────────────────────────
async function loadState(): Promise<ActiveState | null> {
const raw = await getInternalFlag(STATE_FLAG);
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
return {};
}
}
/**
* Reports each template/repo whose latest run failed, once, and again when it succeeds. State-based like the
* health monitor: a template that keeps failing every night alerts on the first failure, not every night.
*
* The very first pass only records what is already failing, without announcing it — on a fresh install (or
* the first time this feature runs) that would otherwise be a wall of alerts about failures that are months old.
*/
export async function runAutomationCheck(now: number = Date.now()): Promise<{ added: number; resolved: number; baseline: boolean }> {
const { items, held: readHeld, readable } = await collectAutomation();
const stored = await loadState();
// Nothing could be read at all (or nothing is configured): don't spend the "first pass" on an empty picture.
if (stored === null && readable === 0) return { added: 0, resolved: 0, baseline: false };
const { conditions, held: unknownHeld } = evaluateAutomation(items);
const held = new Set([...readHeld, ...unknownHeld]);
const { added, resolved, next } = diffConditions(stored ?? {}, conditions, held, await activeSubjects(new Date(now)));
await setInternalFlag(STATE_FLAG, JSON.stringify(next));
if (stored === null) return { added: 0, resolved: 0, baseline: true };
// State is tracked even with the alert toggle off (the notify functions check it), so turning it back on isn't a flood.
if (added.length > 0) await notifyAutomationFailed(added);
if (resolved.length > 0) await notifyAutomationRecovered(resolved);
return { added: added.length, resolved: resolved.length, baseline: false };
}
+189
View File
@@ -0,0 +1,189 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
import { eq, and } from "drizzle-orm";
import { db } from "../db/client.js";
import { integrations, integrationCredentials, dnsProviders, type IntegrationType, type DnsProviderType } from "../db/schema.js";
import { encryptSecret, decryptSecret } from "../crypto.js";
import { getSettings, updateSettings, type AppSettings } from "./settingsStore.js";
import { resolveBaseUrl } from "../integrations/fieldSchemas.js";
const ALGO = "aes-256-gcm";
const SCRYPT_KEYLEN = 32;
export interface EncryptedExportFile {
app: "homelab-manager-backup";
version: 1;
salt: string;
iv: string;
authTag: string;
ciphertext: string;
}
export interface ExportPayload {
exportedAt: string;
settings: AppSettings;
integrations: {
type: IntegrationType;
name: string;
enabled: boolean;
config: Record<string, string | boolean>;
secretFields: Record<string, string | boolean>;
}[];
dnsProviders: {
providerType: DnsProviderType;
name: string;
enabled: boolean;
config: Record<string, string | boolean>;
secretFields: Record<string, string | boolean>;
}[];
}
async function decryptCredential(credentialId: number | null): Promise<Record<string, string | boolean>> {
if (!credentialId) return {};
const [cred] = await db.select().from(integrationCredentials).where(eq(integrationCredentials.id, credentialId)).limit(1);
if (!cred) return {};
return JSON.parse(decryptSecret(cred.encryptedSecret));
}
/** Gathers every integration, DNS provider (with credentials decrypted), and app setting into one exportable payload. */
export async function buildExportPayload(): Promise<ExportPayload> {
const settings = await getSettings();
const integrationRows = await db.select().from(integrations);
const exportedIntegrations = await Promise.all(
integrationRows.map(async (row) => ({
type: row.type,
name: row.name,
enabled: row.enabled,
config: row.config ? JSON.parse(row.config) : {},
secretFields: await decryptCredential(row.credentialId),
})),
);
const providerRows = await db.select().from(dnsProviders);
const exportedProviders = await Promise.all(
providerRows.map(async (row) => ({
providerType: row.providerType,
name: row.name,
enabled: row.enabled,
config: row.config ? JSON.parse(row.config) : {},
secretFields: await decryptCredential(row.credentialId),
})),
);
return {
exportedAt: new Date().toISOString(),
settings,
integrations: exportedIntegrations,
dnsProviders: exportedProviders,
};
}
/** Encrypts an export payload with a user-chosen passphrase (scrypt-derived key, AES-256-GCM) so the file is portable across instances with different CREDENTIALS_ENCRYPTION_KEY values. */
export function encryptExport(payload: ExportPayload, passphrase: string): EncryptedExportFile {
const salt = randomBytes(16);
const key = scryptSync(passphrase, salt, SCRYPT_KEYLEN);
const iv = randomBytes(12);
const cipher = createCipheriv(ALGO, key, iv);
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
app: "homelab-manager-backup",
version: 1,
salt: salt.toString("hex"),
iv: iv.toString("hex"),
authTag: authTag.toString("hex"),
ciphertext: ciphertext.toString("hex"),
};
}
/** Reverses encryptExport(). Throws (GCM auth failure) if the passphrase is wrong or the file was tampered with/corrupted. */
export function decryptExport(file: EncryptedExportFile, passphrase: string): ExportPayload {
const salt = Buffer.from(file.salt, "hex");
const key = scryptSync(passphrase, salt, SCRYPT_KEYLEN);
const decipher = createDecipheriv(ALGO, key, Buffer.from(file.iv, "hex"));
decipher.setAuthTag(Buffer.from(file.authTag, "hex"));
const plaintext = Buffer.concat([decipher.update(Buffer.from(file.ciphertext, "hex")), decipher.final()]);
return JSON.parse(plaintext.toString("utf8"));
}
export interface ImportResult {
integrationsCreated: number;
integrationsSkipped: string[];
dnsProvidersCreated: number;
dnsProvidersSkipped: string[];
}
/**
* Applies an imported payload: settings are merged onto current settings
* (same per-key merge as a normal settings update); integrations and DNS
* providers are only created when no existing row shares their type/name —
* an import never overwrites or deletes an existing integration, so it's
* safe to re-run against a live instance without risking a working
* credential you didn't mean to touch.
*/
export async function applyImportPayload(payload: ExportPayload): Promise<ImportResult> {
await updateSettings(payload.settings);
const result: ImportResult = { integrationsCreated: 0, integrationsSkipped: [], dnsProvidersCreated: 0, dnsProvidersSkipped: [] };
for (const item of payload.integrations) {
const [existing] = await db
.select({ id: integrations.id })
.from(integrations)
.where(and(eq(integrations.type, item.type), eq(integrations.name, item.name)))
.limit(1);
if (existing) {
result.integrationsSkipped.push(item.name);
continue;
}
let credentialId: number | null = null;
if (Object.keys(item.secretFields).length > 0) {
const [cred] = await db
.insert(integrationCredentials)
.values({ name: `${item.type}:${item.name}`, encryptedSecret: encryptSecret(JSON.stringify(item.secretFields)) })
.returning();
credentialId = cred.id;
}
await db.insert(integrations).values({
type: item.type,
name: item.name,
baseUrl: resolveBaseUrl(item.type, item.config),
credentialId,
config: JSON.stringify(item.config),
enabled: item.enabled,
});
result.integrationsCreated++;
}
for (const item of payload.dnsProviders) {
const [existing] = await db
.select({ id: dnsProviders.id })
.from(dnsProviders)
.where(and(eq(dnsProviders.providerType, item.providerType), eq(dnsProviders.name, item.name)))
.limit(1);
if (existing) {
result.dnsProvidersSkipped.push(item.name);
continue;
}
let credentialId: number | null = null;
if (Object.keys(item.secretFields).length > 0) {
const [cred] = await db
.insert(integrationCredentials)
.values({ name: `${item.providerType}:${item.name}`, encryptedSecret: encryptSecret(JSON.stringify(item.secretFields)) })
.returning();
credentialId = cred.id;
}
await db.insert(dnsProviders).values({
providerType: item.providerType,
name: item.name,
credentialId,
config: JSON.stringify(item.config),
enabled: item.enabled,
});
result.dnsProvidersCreated++;
}
return result;
}
+250
View File
@@ -0,0 +1,250 @@
import * as net from "node:net";
import { isPrivateAddress } from "./portScan.js";
/**
* Cross-checks the three places this app records what lives at an IP address — the IPAM inventory, the DNS records
* synced from the providers, and what each server's agent reports — and lists where they disagree. Pure: it takes
* plain data and returns findings, so every rule can be tested exactly.
*/
export type FindingKind = "ip_conflict" | "dns_stale" | "ipam_stale" | "not_in_ipam" | "no_dns";
export type Severity = "error" | "warning" | "info";
export interface ServerInput {
id: number;
name: string;
hostname: string | null;
/** What the agent last reported. Empty for a server with no agent or no report — such a server is never judged. */
ips: string[];
}
export interface IpamInput {
id: number;
ip: string;
label: string | null;
/** null = entered by hand; "tailscale"/"proxmox" = kept up to date by a sync. */
source: string | null;
}
export interface DnsInput {
name: string;
type: string;
content: string;
providerName: string;
}
export interface Finding {
/** Stable identity, so an ignored finding stays ignored across runs. */
key: string;
kind: FindingKind;
severity: Severity;
title: string;
detail: string;
ip: string | null;
servers: { id: number; name: string }[];
dnsNames: string[];
/** For not_in_ipam: a label to pre-fill when adding the address to IPAM. */
suggestedLabel: string | null;
}
// ─── helpers ────────────────────────────────────────────────────────────────
const norm = (ip: string) => ip.trim().toLowerCase();
const cleanName = (n: string) => n.trim().toLowerCase().replace(/\.$/, "");
const firstLabel = (n: string) => cleanName(n).split(".")[0];
// ─── Excluded ranges ────────────────────────────────────────────────────────
export const MAX_EXCLUDED_RANGES = 50;
export class InvalidRangeError extends Error {}
/** "10.0.0.0/8", "fd00::/8" or a single address, in a canonical lowercase form. Throws InvalidRangeError with a message fit to show. */
export function normalizeRange(input: string): string {
const text = input.trim().toLowerCase();
const [addr, prefixText, ...extra] = text.split("/");
const family = net.isIP(addr);
if (!text || extra.length > 0 || family === 0) {
throw new InvalidRangeError(`"${input.trim()}" isn't an address or range — use something like 192.168.16.0/20 or 10.1.2.3.`);
}
if (prefixText === undefined) return addr;
const max = family === 4 ? 32 : 128;
if (!/^\d{1,3}$/.test(prefixText) || Number(prefixText) > max) {
throw new InvalidRangeError(`"${input.trim()}": the part after the slash must be a number from 1 to ${max}.`);
}
if (Number(prefixText) === 0) throw new InvalidRangeError(`"${input.trim()}" would hide every address.`);
return `${addr}/${Number(prefixText)}`;
}
/** A matcher for a list of already-normalised ranges/addresses. */
export function makeExclusion(ranges: string[]): (ip: string) => boolean {
if (ranges.length === 0) return () => false;
const list = new net.BlockList();
for (const r of ranges) {
const [addr, prefix] = r.split("/");
const family = net.isIP(addr) === 6 ? "ipv6" : "ipv4";
if (prefix === undefined) list.addAddress(addr, family);
else list.addSubnet(addr, Number(prefix), family);
}
return (ip) => {
const family = net.isIP(ip);
return family !== 0 && list.check(ip, family === 6 ? "ipv6" : "ipv4");
};
}
/** How many distinct addresses the exclusions are currently hiding, so the page can show that they're doing something. */
export function countHiddenAddresses(input: { servers: ServerInput[]; ipam: IpamInput[]; dns: DnsInput[] }, ranges: string[]): number {
const excluded = makeExclusion(ranges);
const hidden = new Set<string>();
const consider = (ip: string) => {
const n = norm(ip);
if (excluded(n)) hidden.add(n);
};
for (const s of input.servers) s.ips.forEach(consider);
for (const e of input.ipam) consider(e.ip);
for (const r of input.dns) if (r.type === "A" || r.type === "AAAA") consider(r.content);
return hidden.size;
}
/** 100.64.0.0/10 — where Tailscale addresses live. MagicDNS names them, so a missing DNS record isn't a gap. */
function isCgnat(ip: string): boolean {
if (net.isIP(ip) !== 4) return false;
const [a, b] = ip.split(".").map(Number);
return a === 100 && b >= 64 && b <= 127;
}
/** Agents report IPv4 only, so an IPv6 record can't be judged against them (and vice versa) — only compare within a family. */
const sameFamilyAsAny = (ip: string, ips: string[]) => ips.some((o) => net.isIP(o) === net.isIP(ip));
const SEVERITY_ORDER: Record<Severity, number> = { error: 0, warning: 1, info: 2 };
/** Which of these servers does a DNS name or an IPAM label refer to? Exact hostname, or the same short name. */
function serversNamed(name: string, servers: ServerInput[]): ServerInput[] {
const n = cleanName(name);
const short = firstLabel(name);
return servers.filter((s) => {
const host = s.hostname ? cleanName(s.hostname) : null;
return (host !== null && n === host) || short === cleanName(s.name);
});
}
// ─── the checks ─────────────────────────────────────────────────────────────
/**
* `excludedRanges` are dropped from all three sources before anything is compared — an excluded address never
* appears in a finding, whichever side it came from. That's what lets Docker networks, which reuse the same subnet
* on many hosts and don't belong to the LAN, be kept out.
*/
export function buildFindings(input: { servers: ServerInput[]; ipam: IpamInput[]; dns: DnsInput[]; excludedRanges?: string[] }): Finding[] {
const findings: Finding[] = [];
const excluded = makeExclusion(input.excludedRanges ?? []);
const servers = input.servers.map((s) => ({ ...s, ips: [...new Set(s.ips.map(norm))].filter((ip) => !excluded(ip)) }));
const withIps = servers.filter((s) => s.ips.length > 0);
const ipamByIp = new Map(input.ipam.filter((e) => !excluded(norm(e.ip))).map((e) => [norm(e.ip), e]));
// Public DNS records are for the outside world, not this inventory — only private addresses are compared.
const privateDns = input.dns
.filter((r) => (r.type === "A" || r.type === "AAAA") && isPrivateAddress(r.content.trim()) && !excluded(norm(r.content)))
.map((r) => ({ ...r, name: cleanName(r.name), content: norm(r.content) }));
const dnsByIp = new Map<string, typeof privateDns>();
for (const r of privateDns) dnsByIp.set(r.content, [...(dnsByIp.get(r.content) ?? []), r]);
const add = (f: Omit<Finding, "servers" | "dnsNames" | "suggestedLabel" | "ip"> & Partial<Pick<Finding, "servers" | "dnsNames" | "suggestedLabel" | "ip">>) =>
findings.push({ servers: [], dnsNames: [], suggestedLabel: null, ip: null, ...f });
// 1. The same address reported by two servers.
const byServerIp = new Map<string, ServerInput[]>();
for (const s of withIps) for (const ip of s.ips) byServerIp.set(ip, [...(byServerIp.get(ip) ?? []), s]);
for (const [ip, owners] of byServerIp) {
if (owners.length < 2) continue;
add({
key: `ip_conflict|${ip}`,
kind: "ip_conflict",
severity: "error",
title: `${ip} is reported by ${owners.length} servers`,
detail: `${owners.map((o) => o.name).join(", ")} all claim this address — an IP conflict, or a stale agent report.`,
ip,
servers: owners.map((o) => ({ id: o.id, name: o.name })),
});
}
// 2. DNS names a server's own name, but pointing somewhere the server isn't.
const seenDns = new Set<string>();
for (const r of privateDns) {
for (const s of serversNamed(r.name, withIps)) {
if (s.ips.includes(r.content) || !sameFamilyAsAny(r.content, s.ips)) continue;
const key = `dns_stale|${s.id}|${r.name}|${r.content}`;
if (seenDns.has(key)) continue;
seenDns.add(key);
add({
key,
kind: "dns_stale",
severity: "warning",
title: `${r.name} points to ${r.content}, but ${s.name} reports ${s.ips.join(", ")}`,
detail: `The DNS record (${r.providerName}) doesn't match any address ${s.name} reports — likely out of date after an address change.`,
ip: r.content,
servers: [{ id: s.id, name: s.name }],
dnsNames: [r.name],
});
}
}
// 3. IPAM labels a server's name, at an address the server doesn't have.
for (const e of input.ipam) {
if (excluded(norm(e.ip))) continue;
if (!e.label || e.source === "tailscale" || e.source === "proxmox") continue; // kept current by their own syncs
const ip = norm(e.ip);
for (const s of serversNamed(e.label, withIps)) {
if (s.ips.includes(ip) || !sameFamilyAsAny(ip, s.ips)) continue;
add({
key: `ipam_stale|${s.id}|${ip}`,
kind: "ipam_stale",
severity: "warning",
title: `IPAM lists ${e.label} at ${ip}, but ${s.name} reports ${s.ips.join(", ")}`,
detail: `The IPAM entry doesn't match any address ${s.name} reports — update IPAM, or the server moved.`,
ip,
servers: [{ id: s.id, name: s.name }],
});
}
}
// 4. In use (a server reports it, or DNS points at it) but not in IPAM.
const candidates = new Set<string>([...byServerIp.keys(), ...dnsByIp.keys()]);
for (const ip of candidates) {
if (ipamByIp.has(ip)) continue;
const owners = byServerIp.get(ip) ?? [];
const records = dnsByIp.get(ip) ?? [];
const names = [...new Set(records.map((r) => r.name))];
const label = owners.length === 1 ? owners[0].name : names.length > 0 ? firstLabel(names[0]) : null;
const who = [...owners.map((o) => `reported by ${o.name}`), ...(names.length > 0 ? [`in DNS as ${names.join(", ")}`] : [])].join(" and ");
add({
key: `not_in_ipam|${ip}`,
kind: "not_in_ipam",
severity: records.length > 0 ? "warning" : "info",
title: `${ip} isn't in IPAM`,
detail: `${who}.`,
ip,
servers: owners.map((o) => ({ id: o.id, name: o.name })),
dnsNames: names,
suggestedLabel: label,
});
}
// 4b. A server address nothing in DNS points at — only meaningful once there are DNS records to compare against.
if (input.dns.length > 0) {
for (const s of withIps) {
for (const ip of s.ips) {
if (dnsByIp.has(ip) || net.isIP(ip) === 0 || !isPrivateAddress(ip) || isCgnat(ip)) continue;
add({
key: `no_dns|${s.id}|${ip}`,
kind: "no_dns",
severity: "info",
title: `No DNS record points at ${ip} (${s.name})`,
detail: `${s.name} reports ${ip}, but no cached A/AAAA record resolves to it.`,
ip,
servers: [{ id: s.id, name: s.name }],
});
}
}
}
return findings.sort(
(a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.kind.localeCompare(b.kind) || (a.ip ?? "").localeCompare(b.ip ?? "", undefined, { numeric: true }),
);
}
+89
View File
@@ -0,0 +1,89 @@
import { and, desc, eq, lt, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import { diagLog } from "../db/schema.js";
import { trackIntegrationHealth } from "./integrationHealthMonitor.js";
const MAX_ENTRIES = 500;
/** Records one outbound-call result. Never throws — a logging failure must not break the call it's logging. */
async function recordDiagEntry(entry: { source: string; operation: string; ok: boolean; latencyMs: number; error: string | null }) {
try {
await db.insert(diagLog).values(entry);
// Trim to the most recent MAX_ENTRIES rows (a simple ring buffer, mirroring
// Sloth Manager's diagnostic log — this table is for live troubleshooting,
// not a durable audit trail, so unbounded growth isn't worth guarding here).
const [cutoff] = await db.select({ id: diagLog.id }).from(diagLog).orderBy(desc(diagLog.id)).limit(1).offset(MAX_ENTRIES);
if (cutoff) {
await db.delete(diagLog).where(lt(diagLog.id, cutoff.id));
}
await trackIntegrationHealth(entry.source, entry.ok);
} catch (err) {
console.error("[diagLog] failed to record entry:", err);
}
}
/**
* Wraps every method of an adapter (DNS provider or integration) so each call
* is timed and recorded to the diagnostic log, success or failure, without
* touching the adapter's own request/error-handling logic. Safe for any
* adapter whose interface is entirely async methods (true for every DNS and
* integration adapter in this codebase).
*/
export function withDiagLogging<T extends object>(source: string, adapter: T): T {
const wrapped = {} as T;
for (const key of Object.keys(adapter) as (keyof T)[]) {
const value = adapter[key];
if (typeof value !== "function") {
wrapped[key] = value;
continue;
}
const original = value as (...args: unknown[]) => Promise<unknown>;
wrapped[key] = (async (...args: unknown[]) => {
const start = Date.now();
try {
const result = await original.apply(adapter, args);
recordDiagEntry({ source, operation: String(key), ok: true, latencyMs: Date.now() - start, error: null });
return result;
} catch (err) {
recordDiagEntry({
source,
operation: String(key),
ok: false,
latencyMs: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
});
throw err;
}
}) as T[keyof T];
}
return wrapped;
}
export interface DiagLogQuery {
source?: string;
ok?: boolean;
limit?: number;
offset?: number;
}
export async function getDiagEntries({ source, ok, limit = 100, offset = 0 }: DiagLogQuery) {
const conditions = [];
if (source) conditions.push(eq(diagLog.source, source));
if (ok !== undefined) conditions.push(eq(diagLog.ok, ok));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [{ total }] = await db.select({ total: sql<number>`count(*)` }).from(diagLog).where(where);
const entries = await db
.select()
.from(diagLog)
.where(where)
.orderBy(desc(diagLog.id))
.limit(Math.min(limit, 200))
.offset(offset);
return { total, entries };
}
export async function clearDiagLog(): Promise<void> {
await db.delete(diagLog);
}
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff. Show more