Compare commits
113
Commits
f54709c7a8
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fae7089448 | ||
|
|
f246410f24 | ||
|
|
86dfa9ae2e | ||
|
|
3035d7fc08 | ||
|
|
447f33fff6 | ||
|
|
ad1fb5338f | ||
|
|
88d9c8e097 | ||
|
|
22cfdbede0 | ||
|
|
21054aaa8c | ||
|
|
1ff1c6550c | ||
|
|
236b1da0dc | ||
|
|
4e48377348 | ||
|
|
de5c39dddf | ||
|
|
69e9325927 | ||
|
|
df2a5ce42b | ||
|
|
70ba60c7da | ||
|
|
bf7f73f6b6 | ||
|
|
26de6cb243 | ||
|
|
1a2dd19736 | ||
|
|
ada2e648e9 | ||
|
|
9229ea2ed6 | ||
|
|
91796a3c3a | ||
|
|
fea20456e4 | ||
|
|
ae64cb345c | ||
|
|
72f8c85406 | ||
|
|
07d20bd2b7 | ||
|
|
3f2b5da7be | ||
|
|
2352689fd3 | ||
|
|
ca0fa817f8 | ||
|
|
017014586f | ||
|
|
4118062405 | ||
|
|
9f1609c4ed | ||
|
|
4c11158e98 | ||
|
|
aae4f0d74f | ||
|
|
1688de3ea2 | ||
|
|
bf03a15337 | ||
|
|
7e81306aa7 | ||
|
|
0da73711d9 | ||
|
|
35979cb043 | ||
|
|
e081eecba4 | ||
|
|
439eccaebb | ||
|
|
6ff20d42fd | ||
|
|
ca61f2a915 | ||
|
|
73d649377e | ||
|
|
99db7e1cf0 | ||
|
|
6d673db9ec | ||
|
|
1cae35a59e | ||
|
|
9c07718d2d | ||
|
|
5c1376cf1c | ||
|
|
009bb3e027 | ||
|
|
23eb7f0d70 | ||
|
|
b5a4c6e2d9 | ||
|
|
10d123b18a | ||
|
|
af3f7e77d2 | ||
|
|
19f84838cc | ||
|
|
4ba86429d5 | ||
|
|
5c35080e22 | ||
|
|
82ed25b63f | ||
|
|
08eb0bd87e | ||
|
|
b40234a557 | ||
|
|
7bf9b03839 | ||
|
|
81e55fc792 | ||
|
|
08a984719f | ||
|
|
f2a6253ddf | ||
|
|
7564796c39 | ||
|
|
d5b6c55383 | ||
|
|
16d64c42d8 | ||
|
|
a5ec099c13 | ||
|
|
655862c94e | ||
|
|
f4274c1ad8 | ||
|
|
e35da87886 | ||
|
|
42f073df81 | ||
|
|
96dd911a9d | ||
|
|
7b39b7be4d | ||
|
|
846953194c | ||
|
|
5919929dfb | ||
|
|
2d062a9ac4 | ||
|
|
1ff59afb40 | ||
|
|
c0af546d08 | ||
|
|
a781df9c51 | ||
|
|
9f2d27e2c4 | ||
|
|
95bd831d81 | ||
|
|
8fef68c27a | ||
|
|
35afcc4a37 | ||
|
|
52dc7ed1f5 | ||
|
|
e7ac6fea3b | ||
|
|
d714a87754 | ||
|
|
bc0e54fea8 | ||
|
|
f92f8de96e | ||
|
|
f7357141eb | ||
|
|
ad6783b6a1 | ||
|
|
7fed1dbfa4 | ||
|
|
a6ab69316d | ||
|
|
de9b6a2d6a | ||
|
|
1a054d6c2f | ||
|
|
ae41a02864 | ||
|
|
130212baec | ||
|
|
f5d3c25c89 | ||
|
|
b9409d3095 | ||
|
|
1f0e6a9d4c | ||
|
|
3255314402 | ||
|
|
3a53a86ce0 | ||
|
|
e064b1caf7 | ||
|
|
da34be08d5 | ||
|
|
4ed1ac8cad | ||
|
|
afc920e96e | ||
|
|
a83a4b3b11 | ||
|
|
ae39f18755 | ||
|
|
bc72b9e152 | ||
|
|
257ec3ec0a | ||
|
|
abb5335a52 | ||
|
|
1504c19bbd | ||
|
|
9c45a806c8 |
No files matched your search
@@ -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
@@ -5,14 +5,19 @@ APP_BASE_URL=https://homelab.example.lan
|
||||
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
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
|
||||
# rest (AES-256-GCM). Generate the same way as SESSION_SECRET. Losing/changing
|
||||
# this key makes previously-stored integration credentials unreadable.
|
||||
# 32-byte (64 hex char) key used to encrypt stored integration API tokens, and the
|
||||
# notification channels' credentials (Gotify/ntfy tokens, SMTP password, webhook
|
||||
# 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
|
||||
|
||||
# Port docker-compose publishes on the host (container always listens on 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 ---
|
||||
# Create an OAuth2/OIDC "Provider" in Authentik with:
|
||||
# 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_SECRET=
|
||||
|
||||
# --- Optional: Gotify notifications (secret expiry, integration offline, etc.) ---
|
||||
GOTIFY_URL=
|
||||
GOTIFY_TOKEN=
|
||||
# Notification channels (Gotify, ntfy, SMTP, webhook) are configured in-app
|
||||
# under Settings, not here.
|
||||
+553
@@ -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
@@ -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.
|
||||
@@ -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).
|
||||
@@ -1,7 +1,10 @@
|
||||
# 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,
|
||||
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
|
||||
(ported from [Sloth Manager](../Sloth%20manager)) and scheduled-task tracking
|
||||
across Debian/Raspbian hosts (ported from
|
||||
@@ -11,32 +14,323 @@ Authentik (OIDC), with local admin/operator/viewer roles.
|
||||
|
||||
## Status
|
||||
|
||||
Built so far:
|
||||
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
|
||||
- **Secrets** — expiry tracking for API tokens/certs/passwords
|
||||
- **IP Addresses (IPAM)** — inventory of IPs across vendors/locations
|
||||
(changes made in the app, sign-ins and sign-outs with the IP they came from, new accounts, and the log's own
|
||||
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, cPanel, and Technitium; providers are configured in-app (not via env
|
||||
vars) and their credentials are encrypted at rest
|
||||
- **Servers & Tasks** — cron/systemd tracking across Debian/Raspbian servers
|
||||
via a lightweight push agent (`agent/linux/`), plus manual entries for
|
||||
things an agent can't see (Docker jobs, backups)
|
||||
- **Integrations → Tailscale** — device list with online/authorized status,
|
||||
and authorize/deauthorize/remove actions; a live device-count widget on
|
||||
the Dashboard.
|
||||
- **Integrations → 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) on the Dashboard.
|
||||
- **Servers** — cron/systemd tracking across Debian/Raspbian servers via a
|
||||
lightweight push agent (`agent/linux/`), Windows scheduled-task tracking
|
||||
through a PowerShell agent (`agent/windows/`, see its README), plus manual
|
||||
entries for things an agent can't see (Docker jobs, backups). The Servers page itself just lists
|
||||
registered servers and (admin-only) adds new ones / issues agent tokens;
|
||||
clicking a server opens its detail page with CPU/RAM/disk status, IP
|
||||
addresses, matching DNS names (looked up from the DNS module's cache), and
|
||||
its scheduled tasks — live hardware from Proxmox for VM/LXC-backed
|
||||
servers, or from the agent's own hardware report for everything else,
|
||||
both showing the same per-disk usage breakdown (an LXC's root filesystem
|
||||
read straight from the host; a QEMU VM's actual mounts via its guest
|
||||
agent, alongside the allocated size Proxmox already knew about without
|
||||
one). Proxmox-linked servers also get start/stop/restart buttons right on the
|
||||
detail page. The detail page also has an **Admin Links** section
|
||||
(operator/admin to add/edit/remove) for bookmarking that server's own
|
||||
admin UIs — Dockge, Webmin, Cockpit, Portainer, or anything else reachable
|
||||
by URL. **Operations → Admin Links** summarizes every server's admin links
|
||||
in one sortable, searchable table (server, label, URL, an "Open" link, and
|
||||
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.
|
||||
|
||||
Both integrations follow the same config-in-UI + encrypted-credentials
|
||||
pattern as DNS providers, so the remaining ones slot into the same
|
||||
"Add integration" form as they're built.
|
||||
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.
|
||||
|
||||
Not yet built (see `.claude/plans` for the full delivery plan):
|
||||
All six integrations follow the same config-in-UI + encrypted-credentials
|
||||
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.
|
||||
|
||||
- Remaining live integrations: Proxmox, Synology, Semaphore, Dockhand
|
||||
**Verified for real, end to end**: every module above — including all six
|
||||
integrations, both their read-only views and their write actions
|
||||
(start/stop/restart, trigger-a-run, authorize/deauthorize) — has been
|
||||
exercised against the user's actual live homelab, not just built against
|
||||
specs. That pass also found and fixed two real bugs: the Synology adapter
|
||||
assumed HTTPS-only (the NAS is reached over plain HTTP), and the Tailscale
|
||||
adapter read `online`/`isExitNode` fields that don't actually exist in the
|
||||
real API response (fixed to derive them from `connectedToControl` and
|
||||
`enabledRoutes`). See the git log for the full verification notes per
|
||||
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
|
||||
|
||||
@@ -83,3 +377,27 @@ docker compose up -d
|
||||
|
||||
The app listens on `HOST_PORT` (default `3000`); SQLite data persists in
|
||||
`./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.
|
||||
@@ -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
@@ -6,16 +6,30 @@
|
||||
# curl -fsSL https://homelab.example.lan/agent/linux/install.sh | \
|
||||
# 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
|
||||
|
||||
: "${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_INSECURE="${API_INSECURE:-false}"
|
||||
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
CONFIG_DIR="/etc"
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
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
|
||||
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
|
||||
@@ -55,13 +69,14 @@ fi
|
||||
|
||||
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"
|
||||
|
||||
umask 077
|
||||
cat > "$CONFIG_DIR/homelab-manager-agent.env" <<EOF
|
||||
API_URL=$API_URL
|
||||
API_TOKEN=$API_TOKEN
|
||||
API_INSECURE=$API_INSECURE
|
||||
EOF
|
||||
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"
|
||||
|
||||
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
@@ -5,6 +5,9 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/etc/homelab-manager-agent.env}"
|
||||
@@ -15,6 +18,7 @@ fi
|
||||
|
||||
API_URL="${API_URL:-}"
|
||||
API_TOKEN="${API_TOKEN:-}"
|
||||
API_INSECURE="${API_INSECURE:-false}"
|
||||
DRY_RUN=0
|
||||
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
|
||||
|
||||
@@ -23,6 +27,11 @@ if [[ -z "$API_URL" || -z "$API_TOKEN" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURL_INSECURE_FLAG=()
|
||||
case "${API_INSECURE,,}" in
|
||||
1|true|yes) CURL_INSECURE_FLAG=(-k) ;;
|
||||
esac
|
||||
|
||||
suggest_package_install() {
|
||||
local pkg="$1"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
@@ -173,23 +182,116 @@ collect_systemd_timers() {
|
||||
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_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)
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg hostname "$HOSTNAME_VALUE" \
|
||||
--arg os_type "linux" \
|
||||
--arg reported_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||
--argjson system "$SYSTEM_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
|
||||
echo "$PAYLOAD" | jq .
|
||||
exit 0
|
||||
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" \
|
||||
-H "Authorization: Bearer $API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
||||
+80
-12
@@ -1,14 +1,82 @@
|
||||
# Windows agent (planned, not yet implemented)
|
||||
# Windows agent
|
||||
|
||||
v1 of the Servers & Tasks module only supports Linux servers (cron + systemd
|
||||
timers) — this covers the Debian and Raspbian hosts in the homelab. A Windows
|
||||
agent is a natural future addition and would follow the same contract as the
|
||||
Linux agent in [`../linux/report-tasks.sh`](../linux/report-tasks.sh):
|
||||
Reports a Windows machine to Homelab Manager the same way the [Linux agent](../linux/) does: its
|
||||
scheduled tasks, plus hostname, IP addresses, CPU / memory / disk usage and listening ports. It
|
||||
runs as a scheduled task (as SYSTEM, every 15 minutes) and pushes to `POST /api/agent/report`, so
|
||||
the machine never needs to be reachable from Homelab Manager.
|
||||
|
||||
- Collect tasks with `Get-ScheduledTask | Get-ScheduledTaskInfo` (name, action/command,
|
||||
trigger description, next run time, enabled state).
|
||||
- POST the same JSON shape to `POST /api/agent/report` with `schedule_type: "windows_task"`
|
||||
(the server and UI already treat `schedule_type` as an open string in storage; only the
|
||||
`tasks` API and UI schedule-type filter would need the new value added).
|
||||
- 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.
|
||||
## Install
|
||||
|
||||
1. In Homelab Manager, **Servers → Manage servers → Add a server**, choose **Windows** as the
|
||||
operating system, and copy the install command shown once for the new token.
|
||||
2. On the Windows machine, open **Windows PowerShell as administrator** and paste it. It looks like:
|
||||
|
||||
```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.
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+182
@@ -1979,6 +1979,26 @@
|
||||
"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": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -2136,6 +2156,15 @@
|
||||
"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": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/bagpipe/-/bagpipe-0.3.5.tgz",
|
||||
@@ -2953,6 +2982,15 @@
|
||||
"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": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -3111,6 +3149,12 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
|
||||
@@ -3214,6 +3258,18 @@
|
||||
"@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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -3236,6 +3292,21 @@
|
||||
"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": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
@@ -3326,6 +3397,55 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"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": {
|
||||
"version": "3.3.19",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||
@@ -3402,6 +3522,42 @@
|
||||
"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": {
|
||||
"version": "3.8.8",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
|
||||
@@ -3968,6 +4124,12 @@
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"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": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -3999,6 +4161,21 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -4825,6 +5002,9 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"mysql2": "^3.24.5",
|
||||
"node-schedule": "^2.1.1",
|
||||
"nodemailer": "^6.9.14",
|
||||
"openid-client": "^6.1.7",
|
||||
"session-file-store": "^1.5.0",
|
||||
"xml2js": "^0.6.2",
|
||||
@@ -4834,6 +5014,8 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/node-schedule": "^2.1.7",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/session-file-store": "^1.2.5",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `servers` ADD `tags` text;
|
||||
@@ -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`);
|
||||
@@ -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`);
|
||||
@@ -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
@@ -8,6 +8,104 @@
|
||||
"when": 1789419241921,
|
||||
"tag": "0000_colossal_violations",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,9 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"mysql2": "^3.24.5",
|
||||
"node-schedule": "^2.1.1",
|
||||
"nodemailer": "^6.9.14",
|
||||
"openid-client": "^6.1.7",
|
||||
"session-file-store": "^1.5.0",
|
||||
"xml2js": "^0.6.2",
|
||||
@@ -25,6 +28,8 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/node-schedule": "^2.1.7",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/session-file-store": "^1.2.5",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Router } from "express";
|
||||
import * as client from "openid-client";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getOidcConfig } from "./oidc.js";
|
||||
import { upsertUserFromLogin } from "./users.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();
|
||||
|
||||
@@ -63,10 +67,38 @@ authRouter.get("/callback", async (req, res, next) => {
|
||||
// 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;
|
||||
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) => {
|
||||
if (err) return next(err);
|
||||
res.redirect("/");
|
||||
@@ -78,6 +110,26 @@ authRouter.get("/callback", async (req, res, next) => {
|
||||
|
||||
authRouter.get("/logout", async (req, res, next) => {
|
||||
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 {
|
||||
const config = await getOidcConfig();
|
||||
let endSessionUrl: URL | undefined;
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function upsertUserFromLogin(params: {
|
||||
sub: string;
|
||||
email?: 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 now = new Date().toISOString();
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function upsertUserFromLogin(params: {
|
||||
})
|
||||
.where(eq(users.id, existing.id))
|
||||
.returning();
|
||||
return updated;
|
||||
return { user: updated, created: false };
|
||||
}
|
||||
|
||||
const anyUser = await db.select({ id: users.id }).from(users).limit(1);
|
||||
@@ -41,5 +41,5 @@ export async function upsertUserFromLogin(params: {
|
||||
lastLoginAt: now,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
return { user: created, created: true };
|
||||
}
|
||||
+199
-2
@@ -1,5 +1,5 @@
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,6 +34,46 @@ export const auditLog = sqliteTable("audit_log", {
|
||||
.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) ───────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
warnDays: integer("warn_days").notNull().default(30),
|
||||
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")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
@@ -74,6 +119,7 @@ export const ipamEntries = sqliteTable("ipam_entries", {
|
||||
vendor: text("vendor"),
|
||||
location: text("location"),
|
||||
notes: text("notes"),
|
||||
source: text("source"), // null = entered manually; "tailscale" = auto-synced from a Tailscale integration
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
@@ -150,6 +196,9 @@ export const dnsRecordsCache = sqliteTable("dns_records_cache", {
|
||||
|
||||
// ─── 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", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
@@ -162,6 +211,35 @@ export const servers = sqliteTable("servers", {
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
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", {
|
||||
@@ -169,7 +247,7 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", {
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.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
|
||||
name: text("name").notNull(),
|
||||
command: text("command"),
|
||||
@@ -187,6 +265,20 @@ export const scheduledTasks = sqliteTable("scheduled_tasks", {
|
||||
.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) ─
|
||||
|
||||
export const integrationTypes = [
|
||||
@@ -196,6 +288,10 @@ export const integrationTypes = [
|
||||
"tailscale",
|
||||
"gitea",
|
||||
"dockhand",
|
||||
"uptimekuma",
|
||||
"phpipam",
|
||||
"pbs",
|
||||
"osticket",
|
||||
] as const;
|
||||
export type IntegrationType = (typeof integrationTypes)[number];
|
||||
|
||||
@@ -213,3 +309,104 @@ export const integrations = sqliteTable("integrations", {
|
||||
.notNull()
|
||||
.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)`),
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
* resource group to target for record operations.
|
||||
*/
|
||||
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
const ARM_BASE = "https://management.azure.com";
|
||||
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 });
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* Requires config: apiToken
|
||||
*/
|
||||
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
return { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
return withDiagLogging("cloudflare", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
import * as https from "node:https";
|
||||
import * as http from "node:http";
|
||||
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface CpanelConfig {
|
||||
url: string;
|
||||
@@ -273,5 +274,5 @@ export function createCpanelAdapter(config: CpanelConfig): DnsAdapter {
|
||||
await api2("remove_zone_record", { domain, line: String(lineIndex) });
|
||||
}
|
||||
|
||||
return { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
return withDiagLogging("cpanel", { listZones, listRecords, addRecord, updateRecord, deleteRecord });
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { parseStringPromise } from "xml2js";
|
||||
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
* treated as a single zone. Record types are limited to A, AAAA, CNAME.
|
||||
*/
|
||||
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface PiholeConfig {
|
||||
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 });
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
* Record ID format: "type||name||content||priority"
|
||||
*/
|
||||
import type { DnsAdapter, DnsRecord, DnsRecordInput, DnsZone } from "../types.js";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface TechnitiumConfig {
|
||||
url: string;
|
||||
@@ -148,5 +149,5 @@ export function createTechnitiumAdapter(config: TechnitiumConfig): DnsAdapter {
|
||||
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 });
|
||||
}
|
||||
@@ -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
@@ -11,10 +11,6 @@ export const env = {
|
||||
clientId: process.env.AUTHENTIK_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET ?? "",
|
||||
},
|
||||
gotify: {
|
||||
url: process.env.GOTIFY_URL ?? "",
|
||||
token: process.env.GOTIFY_TOKEN ?? "",
|
||||
},
|
||||
get authEnabled() {
|
||||
return Boolean(this.authentik.issuerUrl && this.authentik.clientId && this.authentik.clientSecret);
|
||||
},
|
||||
@@ -33,7 +29,8 @@ export function warnIfAuthNotConfigured() {
|
||||
if (!env.credentialsEncryptionEnabled) {
|
||||
console.warn(
|
||||
"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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import { mkdirSync, existsSync } from "node:fs";
|
||||
import { env, warnIfAuthNotConfigured } from "./env.js";
|
||||
import { resolveDataPath } from "./paths.js";
|
||||
import { runMigrations } from "./db/migrate.js";
|
||||
import { encryptStoredSettingsSecrets } from "./services/settingsStore.js";
|
||||
import { authRouter } from "./auth/router.js";
|
||||
import { meRouter } from "./routes/me.js";
|
||||
import { usersRouter } from "./routes/users.js";
|
||||
import { auditLogRouter } from "./routes/auditLog.js";
|
||||
import { diagLogRouter } from "./routes/diagLog.js";
|
||||
import { secretsRouter } from "./routes/secrets.js";
|
||||
import { ipamRouter } from "./routes/ipam.js";
|
||||
import { dnsRouter } from "./routes/dns.js";
|
||||
@@ -19,9 +21,40 @@ import { serversRouter } from "./routes/servers.js";
|
||||
import { tasksRouter } from "./routes/tasks.js";
|
||||
import { agentReportRouter } from "./routes/agentReport.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();
|
||||
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 webDist = join(__dirname, "..", "..", "web", "dist");
|
||||
@@ -61,6 +94,7 @@ app.use("/auth", authRouter);
|
||||
app.use("/api/me", meRouter);
|
||||
app.use("/api/users", usersRouter);
|
||||
app.use("/api/audit-log", auditLogRouter);
|
||||
app.use("/api/diag-log", diagLogRouter);
|
||||
app.use("/api/secrets", secretsRouter);
|
||||
app.use("/api/ipam", ipamRouter);
|
||||
app.use("/api/dns", dnsRouter);
|
||||
@@ -68,6 +102,17 @@ app.use("/api/servers", serversRouter);
|
||||
app.use("/api/tasks", tasksRouter);
|
||||
app.use("/api/agent/report", agentReportRouter);
|
||||
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)) {
|
||||
app.use(express.static(webDist));
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Dockhand adapter — uses Dockhand's own aggregating REST API (not the raw
|
||||
* Docker Engine API on each host directly). Requires config: url, token
|
||||
*
|
||||
* Dockhand is a multi-host Docker manager; "environments" are the individual
|
||||
* Docker hosts/agents it's connected to, and containers are listed/controlled
|
||||
* per environment.
|
||||
*
|
||||
* API reference verified against Dockhand's published OpenAPI spec
|
||||
* (https://github.com/strausmann/mcp-dockhand/blob/main/docs/dockhand-openapi.json).
|
||||
*/
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface DockhandConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface DockhandEnvironment {
|
||||
id: number;
|
||||
name: string;
|
||||
connectionType: string;
|
||||
}
|
||||
|
||||
export interface DockhandContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
state: string; // "running" | "exited" | "paused" | "restarting" | "created" | "dead"
|
||||
status: string; // human string, e.g. "Up 2 hours (healthy)"
|
||||
environmentId: number;
|
||||
environmentName: string;
|
||||
/** null = this container has never been checked for updates. */
|
||||
updateAvailable: boolean | null;
|
||||
newerVersion: string | null;
|
||||
checkedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DockhandAdapter {
|
||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
listContainers(): Promise<DockhandContainer[]>;
|
||||
startContainer(environmentId: number, containerId: string): Promise<void>;
|
||||
stopContainer(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 {
|
||||
function base() {
|
||||
return config.url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function api(method: string, path: string): Promise<any> {
|
||||
const res = await fetch(`${base()}${path}`, { method, headers: headers() });
|
||||
const text = await res.text();
|
||||
let data: any = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
// non-JSON error page
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.message || data?.error || `Dockhand API error: HTTP ${res.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function listEnvironments(): Promise<DockhandEnvironment[]> {
|
||||
const data = await api("GET", "/api/environments");
|
||||
return (Array.isArray(data) ? data : []).map((e: any) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
connectionType: e.connectionType,
|
||||
}));
|
||||
}
|
||||
|
||||
async function listContainers(): Promise<DockhandContainer[]> {
|
||||
const environments = await listEnvironments();
|
||||
const perEnv = await Promise.all(
|
||||
environments.map(async (env) => {
|
||||
try {
|
||||
const [data, pending] = await Promise.all([
|
||||
api("GET", `/api/containers?env=${env.id}&all=true`),
|
||||
// Cached read (no fresh registry hit) — a not-yet-checked environment
|
||||
// shouldn't fail the whole container list, so it just leaves every
|
||||
// container's update status as "never checked" (null).
|
||||
api("GET", `/api/containers/pending-updates?env=${env.id}`).catch(() => null),
|
||||
]);
|
||||
const pendingById = new Map<string, any>((pending?.pendingUpdates ?? []).map((p: any) => [p.containerId, p]));
|
||||
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 {
|
||||
// one unreachable host shouldn't take down the whole dashboard view
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
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> {
|
||||
await api("POST", `/api/containers/${encodeURIComponent(containerId)}/start?env=${environmentId}`);
|
||||
}
|
||||
|
||||
async function stopContainer(environmentId: number, containerId: string): Promise<void> {
|
||||
await api("POST", `/api/containers/${encodeURIComponent(containerId)}/stop?env=${environmentId}`);
|
||||
}
|
||||
|
||||
async function restartContainer(environmentId: number, containerId: string): Promise<void> {
|
||||
await api("POST", `/api/containers/${encodeURIComponent(containerId)}/restart?env=${environmentId}`);
|
||||
}
|
||||
|
||||
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await api("GET", "/api/environments");
|
||||
return { ok: true, latencyMs: Date.now() - start };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
return withDiagLogging("dockhand", { ping, listContainers, startContainer, stopContainer, restartContainer, checkForUpdates });
|
||||
}
|
||||
@@ -19,6 +19,63 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
|
||||
{ key: "url", label: "Gitea URL", secret: false, placeholder: "https://gitea.example.lan" },
|
||||
{ key: "token", label: "API token", secret: true, type: "password" },
|
||||
],
|
||||
dockhand: [
|
||||
{ key: "url", label: "Dockhand URL", secret: false, placeholder: "https://dockhand.example.lan" },
|
||||
{ key: "token", label: "API token", secret: true, type: "password", placeholder: "dh_..." },
|
||||
],
|
||||
semaphore: [
|
||||
{ key: "url", label: "Semaphore URL", secret: false, placeholder: "https://semaphore.example.lan" },
|
||||
{ key: "token", label: "API token", secret: true, type: "password" },
|
||||
],
|
||||
proxmox: [
|
||||
{ key: "url", label: "Proxmox URL", secret: false, placeholder: "https://pve.example.lan:8006" },
|
||||
{ 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" },
|
||||
],
|
||||
synology: [
|
||||
{ key: "url", label: "Synology DSM URL", secret: false, placeholder: "https://nas.example.lan:5001" },
|
||||
{ key: "username", label: "Username", secret: false },
|
||||
{ key: "password", label: "Password", secret: true, type: "password" },
|
||||
{ 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. */
|
||||
@@ -61,7 +118,7 @@ export function validateIntegrationConfig(
|
||||
): string[] {
|
||||
const fields = INTEGRATION_FIELDS[type] ?? [];
|
||||
return fields
|
||||
.filter((f) => f.type !== "checkbox")
|
||||
.filter((f) => f.type !== "checkbox" && !f.optional)
|
||||
.filter((f) => merged[f.key] === undefined || merged[f.key] === "")
|
||||
.map((f) => f.key);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
* API docs: https://gitea.labsconnect.se/api/swagger (or any instance's /api/swagger)
|
||||
* Verified against Gitea 1.27.
|
||||
*/
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface GiteaConfig {
|
||||
url: string;
|
||||
@@ -150,5 +151,5 @@ export function createGiteaAdapter(config: GiteaConfig): GiteaAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
return { ping, listReposWithStatus, rerunFailedJobs };
|
||||
return withDiagLogging("gitea", { ping, listReposWithStatus, rerunFailedJobs });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* Proxmox VE adapter — uses the Proxmox VE REST API (api2/json).
|
||||
* Requires config: url, tokenId, tokenSecret; optional: insecure
|
||||
*
|
||||
* Auth: `Authorization: PVEAPIToken=<tokenId>=<tokenSecret>` — see
|
||||
* https://pve.proxmox.com/wiki/Proxmox_VE_API#API_Tokens
|
||||
*
|
||||
* Endpoints verified against Proxmox's own published API tree
|
||||
* (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,lxc}/{vmid}/status/{start,stop,reboot} (all
|
||||
* 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
|
||||
* (like the cPanel DNS adapter) this uses node:https directly rather than
|
||||
* fetch, to support an "insecure" opt-out of certificate verification.
|
||||
*/
|
||||
import * as https from "node:https";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface ProxmoxConfig {
|
||||
url: string;
|
||||
tokenId: string;
|
||||
tokenSecret: string;
|
||||
insecure?: boolean;
|
||||
}
|
||||
|
||||
export type ProxmoxGuestType = "qemu" | "lxc";
|
||||
|
||||
export interface ProxmoxGuest {
|
||||
vmid: number;
|
||||
name: string;
|
||||
node: string;
|
||||
type: ProxmoxGuestType;
|
||||
status: string; // "running" | "stopped"
|
||||
cpu: number | null;
|
||||
maxmem: number | null;
|
||||
mem: 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 {
|
||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
listGuests(): Promise<ProxmoxGuest[]>;
|
||||
getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise<ProxmoxGuestDetail>;
|
||||
listNodeStats(): Promise<ProxmoxNodeStats[]>;
|
||||
startGuest(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>;
|
||||
/** 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 {
|
||||
status: number;
|
||||
text: () => string;
|
||||
}
|
||||
|
||||
function request(url: string, insecure: boolean, options: { method?: string; 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 || 8006,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: options.method || "GET",
|
||||
headers: options.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();
|
||||
});
|
||||
}
|
||||
|
||||
export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
|
||||
const insecure = config.insecure === true;
|
||||
|
||||
function base() {
|
||||
return config.url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
Authorization: `PVEAPIToken=${config.tokenId}=${config.tokenSecret}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function api(method: string, path: string): Promise<any> {
|
||||
const res = await request(`${base()}/api2/json${path}`, insecure, { method, headers: 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 API error: HTTP ${res.status}`);
|
||||
}
|
||||
return data?.data;
|
||||
}
|
||||
|
||||
async function listNodes(): Promise<string[]> {
|
||||
const data = await api("GET", "/nodes");
|
||||
return (Array.isArray(data) ? data : [])
|
||||
.filter((n: any) => n.status === "online")
|
||||
.map((n: any) => n.node);
|
||||
}
|
||||
|
||||
async function listGuestsForNode(node: string, type: ProxmoxGuestType): Promise<ProxmoxGuest[]> {
|
||||
const data = await api("GET", `/nodes/${node}/${type}`);
|
||||
return (Array.isArray(data) ? data : []).map((g: any) => ({
|
||||
vmid: g.vmid,
|
||||
name: g.name,
|
||||
node,
|
||||
type,
|
||||
status: g.status,
|
||||
cpu: g.cpu ?? null,
|
||||
maxmem: g.maxmem ?? null,
|
||||
mem: g.mem ?? null,
|
||||
uptime: g.uptime ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function listGuests(): Promise<ProxmoxGuest[]> {
|
||||
const nodes = await listNodes();
|
||||
const perNode = await Promise.all(
|
||||
nodes.map(async (node) => {
|
||||
try {
|
||||
const [vms, containers] = await Promise.all([
|
||||
listGuestsForNode(node, "qemu"),
|
||||
listGuestsForNode(node, "lxc"),
|
||||
]);
|
||||
return [...vms, ...containers];
|
||||
} catch {
|
||||
// one unreachable/offline node shouldn't take down the whole dashboard view
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
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> {
|
||||
await api("POST", `/nodes/${node}/${type}/${vmid}/status/${action}`);
|
||||
}
|
||||
|
||||
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 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 }> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await api("GET", "/nodes");
|
||||
return { ok: true, latencyMs: Date.now() - start };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
return withDiagLogging("proxmox", {
|
||||
ping,
|
||||
listGuests,
|
||||
getGuestDetail,
|
||||
listNodeStats,
|
||||
startGuest,
|
||||
stopGuest,
|
||||
restartGuest,
|
||||
shutdownGuest,
|
||||
listBackupJobs,
|
||||
listRecentBackupTasks,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,14 @@ import type { IntegrationType } from "../db/schema.js";
|
||||
import type { IntegrationConfig } from "./types.js";
|
||||
import { createTailscaleAdapter } from "./tailscale/adapter.js";
|
||||
import { createGiteaAdapter } from "./gitea/adapter.js";
|
||||
import { createDockhandAdapter } from "./dockhand/adapter.js";
|
||||
import { createSemaphoreAdapter } from "./semaphore/adapter.js";
|
||||
import { createProxmoxAdapter } from "./proxmox/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 {
|
||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
@@ -20,6 +28,22 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat
|
||||
return createTailscaleAdapter(config as any);
|
||||
case "gitea":
|
||||
return createGiteaAdapter(config as any);
|
||||
case "dockhand":
|
||||
return createDockhandAdapter(config as any);
|
||||
case "semaphore":
|
||||
return createSemaphoreAdapter(config as any);
|
||||
case "proxmox":
|
||||
return createProxmoxAdapter(config as any);
|
||||
case "synology":
|
||||
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:
|
||||
throw new Error(`Integration type "${type}" is not implemented yet`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Semaphore (Ansible Semaphore / Semaphore UI) adapter.
|
||||
* Requires config: url, token
|
||||
*
|
||||
* API reference verified against the official spec
|
||||
* (https://github.com/semaphoreui/semaphore/blob/develop/api-docs.yml) and
|
||||
* 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.
|
||||
*/
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface SemaphoreConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
// waiting -> starting -> running -> (success | error | stopped)
|
||||
// waiting_confirmation/confirmed/rejected apply only to templates requiring approval.
|
||||
export type SemaphoreTaskStatus =
|
||||
| "waiting"
|
||||
| "starting"
|
||||
| "waiting_confirmation"
|
||||
| "confirmed"
|
||||
| "rejected"
|
||||
| "running"
|
||||
| "stopping"
|
||||
| "stopped"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
export interface SemaphoreTask {
|
||||
id: number;
|
||||
status: SemaphoreTaskStatus;
|
||||
created: string | null;
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
}
|
||||
|
||||
export interface SemaphoreTemplate {
|
||||
id: number;
|
||||
projectId: number;
|
||||
projectName: string;
|
||||
name: string;
|
||||
playbook: string;
|
||||
lastTask: SemaphoreTask | null;
|
||||
}
|
||||
|
||||
export interface SemaphoreAdapter {
|
||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
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>;
|
||||
}
|
||||
|
||||
export function createSemaphoreAdapter(config: SemaphoreConfig): SemaphoreAdapter {
|
||||
function base() {
|
||||
return config.url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function api(method: string, path: string, body?: unknown): Promise<any> {
|
||||
const res = await fetch(`${base()}/api${path}`, {
|
||||
method,
|
||||
headers: headers(),
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 204) return null;
|
||||
const text = await res.text();
|
||||
let data: any = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
// non-JSON error page
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error((typeof data === "string" ? data : data?.error) || `Semaphore API error: HTTP ${res.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function mapTask(t: any): SemaphoreTask | null {
|
||||
if (!t) return null;
|
||||
return {
|
||||
id: t.id,
|
||||
status: t.status,
|
||||
created: t.created ?? null,
|
||||
start: t.start ?? null,
|
||||
end: t.end ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function listProjects(): Promise<{ id: number; name: string }[]> {
|
||||
const data = await api("GET", "/projects");
|
||||
return (Array.isArray(data) ? data : []).map((p: any) => ({ id: p.id, name: p.name }));
|
||||
}
|
||||
|
||||
async function listTemplatesForProject(projectId: number, projectName: string): Promise<SemaphoreTemplate[]> {
|
||||
const data = await api("GET", `/project/${projectId}/templates?sort=name&order=asc`);
|
||||
return (Array.isArray(data) ? data : []).map((t: any) => ({
|
||||
id: t.id,
|
||||
projectId,
|
||||
projectName,
|
||||
name: t.name,
|
||||
playbook: t.playbook,
|
||||
lastTask: mapTask(t.last_task),
|
||||
}));
|
||||
}
|
||||
|
||||
async function checkTemplates(): Promise<{ templates: SemaphoreTemplate[]; failedProjectIds: number[] }> {
|
||||
const projects = await listProjects();
|
||||
const failedProjectIds: number[] = [];
|
||||
const perProject = await Promise.all(
|
||||
projects.map(async (p) => {
|
||||
try {
|
||||
return await listTemplatesForProject(p.id, p.name);
|
||||
} catch {
|
||||
failedProjectIds.push(p.id);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { templates: perProject.flat(), failedProjectIds };
|
||||
}
|
||||
|
||||
async function listTemplatesWithStatus(): Promise<SemaphoreTemplate[]> {
|
||||
return (await checkTemplates()).templates;
|
||||
}
|
||||
|
||||
async function runTemplate(projectId: number, templateId: number): Promise<SemaphoreTask> {
|
||||
const task = await api("POST", `/project/${projectId}/tasks`, { template_id: templateId });
|
||||
return mapTask(task)!;
|
||||
}
|
||||
|
||||
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await api("GET", "/projects");
|
||||
return { ok: true, latencyMs: Date.now() - start };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
return withDiagLogging("semaphore", { ping, listTemplatesWithStatus, checkTemplates, runTemplate });
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Synology DSM adapter — uses the DSM Web API (webapi/*.cgi).
|
||||
* Requires config: url, username, password; optional: insecure
|
||||
*
|
||||
* Read-only by design (per the delivery plan — DSM write actions are riskier
|
||||
* and out of scope for v1): reports volume/pool usage and disk health only.
|
||||
*
|
||||
* DSM's API paths and versions vary by DSM release, so — like every
|
||||
* well-behaved DSM client (this follows the same discover-then-call pattern
|
||||
* as hacf-fr/synologydsm-api, the library behind Home Assistant's Synology
|
||||
* integration) — this first queries SYNO.API.Info to learn the real path and
|
||||
* version for SYNO.API.Auth and SYNO.Storage.CGI.Storage rather than
|
||||
* hardcoding them. SYNO.Storage.CGI.Storage's `load_info` method returns
|
||||
* disks, volumes, and storage pools in one call (verified against that
|
||||
* library's storage.py wrapper).
|
||||
*
|
||||
* DSM ships with a self-signed certificate unless the admin configured a
|
||||
* real one, so (like the cPanel/Proxmox adapters) this uses node:https
|
||||
* directly to support an "insecure" opt-out of certificate verification —
|
||||
* but DSM is just as commonly reached over plain HTTP (default port 5000)
|
||||
* inside a trusted LAN, so the request helper picks http vs https from the
|
||||
* configured URL's own protocol rather than assuming HTTPS.
|
||||
*/
|
||||
import * as https from "node:https";
|
||||
import * as http from "node:http";
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
export interface SynologyConfig {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
insecure?: boolean;
|
||||
}
|
||||
|
||||
export interface SynologyVolume {
|
||||
id: string;
|
||||
status: string; // "normal" | "degraded" | "crashed" | ...
|
||||
deviceType: string;
|
||||
sizeTotal: number | null;
|
||||
sizeUsed: number | null;
|
||||
}
|
||||
|
||||
export interface SynologyDisk {
|
||||
id: string;
|
||||
name: string;
|
||||
device: string;
|
||||
status: string; // "normal" | "system_partition_failed" | "crashed" | ...
|
||||
smartStatus: string;
|
||||
temp: number | null;
|
||||
exceedBadSectorThreshold: boolean;
|
||||
belowRemainLifeThreshold: boolean;
|
||||
}
|
||||
|
||||
export interface SynologyStorageInfo {
|
||||
volumes: SynologyVolume[];
|
||||
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 {
|
||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
getStorageInfo(): Promise<SynologyStorageInfo>;
|
||||
getSystemInfo(): Promise<SynologySystemInfo>;
|
||||
}
|
||||
|
||||
interface RawResponse {
|
||||
status: number;
|
||||
text: () => string;
|
||||
}
|
||||
|
||||
function request(url: string, insecure: boolean): Promise<RawResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === "https:";
|
||||
const lib = isHttps ? https : http;
|
||||
const defaultPort = isHttps ? 5001 : 5000;
|
||||
|
||||
const req = lib.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || defaultPort,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: "GET",
|
||||
...(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();
|
||||
});
|
||||
}
|
||||
|
||||
interface ApiInfo {
|
||||
path: string;
|
||||
maxVersion: number;
|
||||
}
|
||||
|
||||
export function createSynologyAdapter(config: SynologyConfig): SynologyAdapter {
|
||||
const insecure = config.insecure === true;
|
||||
let apiMap: Record<string, ApiInfo> | null = null;
|
||||
let sid: string | null = null;
|
||||
|
||||
function base() {
|
||||
return config.url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
async function rawGet(path: string, params: Record<string, string | number>): Promise<any> {
|
||||
const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])).toString();
|
||||
const res = await request(`${base()}/webapi/${path}?${qs}`, insecure);
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(res.text());
|
||||
} catch {
|
||||
throw new Error(`Synology API returned non-JSON response (HTTP ${res.status})`);
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`Synology API error: HTTP ${res.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function discoverApis(): Promise<Record<string, ApiInfo>> {
|
||||
if (apiMap) return apiMap;
|
||||
const data = await rawGet("query.cgi", { api: "SYNO.API.Info", version: 1, method: "query", query: "all" });
|
||||
if (!data.success) throw new Error(data.error?.code ? `Synology API.Info error ${data.error.code}` : "Synology API.Info query failed");
|
||||
apiMap = data.data;
|
||||
return apiMap!;
|
||||
}
|
||||
|
||||
async function login(): Promise<string> {
|
||||
const apis = await discoverApis();
|
||||
const authInfo = apis["SYNO.API.Auth"];
|
||||
if (!authInfo) throw new Error("SYNO.API.Auth not available on this DSM instance");
|
||||
|
||||
const data = await rawGet(authInfo.path, {
|
||||
api: "SYNO.API.Auth",
|
||||
version: authInfo.maxVersion,
|
||||
method: "login",
|
||||
account: config.username,
|
||||
passwd: config.password,
|
||||
format: "sid",
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
const code = data.error?.code;
|
||||
const messages: Record<number, string> = {
|
||||
400: "Invalid username or password",
|
||||
401: "Account disabled",
|
||||
402: "Permission denied",
|
||||
403: "2-factor authentication is required — not supported by this integration",
|
||||
404: "2-factor authentication code was rejected",
|
||||
};
|
||||
throw new Error((code && messages[code]) || `Synology login failed (error ${code ?? "unknown"})`);
|
||||
}
|
||||
|
||||
sid = data.data.sid;
|
||||
return sid!;
|
||||
}
|
||||
|
||||
async function callApi(apiName: string, method: string, extraParams: Record<string, string | number> = {}): Promise<any> {
|
||||
const apis = await discoverApis();
|
||||
const info = apis[apiName];
|
||||
if (!info) throw new Error(`${apiName} is not available on this DSM instance`);
|
||||
|
||||
if (!sid) await login();
|
||||
|
||||
const doCall = async () =>
|
||||
rawGet(info.path, { api: apiName, version: info.maxVersion, method, _sid: sid!, ...extraParams });
|
||||
|
||||
let data = await doCall();
|
||||
// Session-related error codes (105 permission/session, 106 session timeout,
|
||||
// 119 invalid session) -> re-login once and retry.
|
||||
if (!data.success && [105, 106, 119].includes(data.error?.code)) {
|
||||
sid = null;
|
||||
await login();
|
||||
data = await doCall();
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(`${apiName} error ${data.error?.code ?? "unknown"}`);
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async function getStorageInfo(): Promise<SynologyStorageInfo> {
|
||||
const data = await callApi("SYNO.Storage.CGI.Storage", "load_info");
|
||||
const volumes: SynologyVolume[] = (data.volumes ?? []).map((v: any) => ({
|
||||
id: v.id,
|
||||
status: v.status,
|
||||
deviceType: v.device_type,
|
||||
sizeTotal: v.size?.total !== undefined ? Number(v.size.total) : null,
|
||||
sizeUsed: v.size?.used !== undefined ? Number(v.size.used) : null,
|
||||
}));
|
||||
const disks: SynologyDisk[] = (data.disks ?? []).map((d: any) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
device: d.device,
|
||||
status: d.status,
|
||||
smartStatus: d.smart_status,
|
||||
temp: d.temp ?? null,
|
||||
exceedBadSectorThreshold: !!d.exceed_bad_sector_thr,
|
||||
belowRemainLifeThreshold: !!d.below_remain_life_thr,
|
||||
}));
|
||||
return { volumes, disks };
|
||||
}
|
||||
|
||||
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await login();
|
||||
return { ok: true, latencyMs: Date.now() - start };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -3,10 +3,32 @@
|
||||
* Requires config: tailnet, apiKey
|
||||
*
|
||||
* API docs: https://tailscale.com/api
|
||||
*
|
||||
* Note: the real device object has no "online" or "isExitNode" field at all
|
||||
* (verified against a live tailnet — this diverges from what Sloth Manager's
|
||||
* original adapter assumed). "Online" is derived from `connectedToControl`
|
||||
* (whether the device currently has an active session with Tailscale's
|
||||
* control plane); "is an exit node" is derived from `enabledRoutes`
|
||||
* containing both default routes (0.0.0.0/0 and ::/0) — a device can
|
||||
* *advertise* those routes without them being approved, so `enabledRoutes`
|
||||
* (not `advertisedRoutes`) is the correct "is actually acting as an exit
|
||||
* node right now" signal. `?fields=all` on the list endpoint returns both
|
||||
* in the same call as the basic fields, so no per-device follow-up is
|
||||
* needed.
|
||||
*/
|
||||
import { withDiagLogging } from "../../services/diagLog.js";
|
||||
|
||||
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 {
|
||||
tailnet: string;
|
||||
apiKey: string;
|
||||
@@ -23,7 +45,9 @@ export interface TailscaleDevice {
|
||||
lastSeen: string | null;
|
||||
isExitNode: boolean;
|
||||
authorized: boolean;
|
||||
online: boolean | null;
|
||||
online: boolean;
|
||||
keyExpiry: string | null;
|
||||
keyExpiryDisabled: boolean;
|
||||
}
|
||||
|
||||
export interface TailscaleAdapter {
|
||||
@@ -61,20 +85,31 @@ export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapte
|
||||
}
|
||||
|
||||
async function listDevices(): Promise<TailscaleDevice[]> {
|
||||
const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices`);
|
||||
return (data.devices || []).map((d: any) => ({
|
||||
id: d.id,
|
||||
nodeId: d.nodeId || d.id,
|
||||
hostname: d.hostname || "",
|
||||
label: d.displayName || d.hostname || "",
|
||||
addresses: d.addresses || [],
|
||||
primaryAddress: d.addresses?.[0] || "",
|
||||
os: d.os || "",
|
||||
lastSeen: d.lastSeen || null,
|
||||
isExitNode: !!d.isExitNode,
|
||||
authorized: !!d.authorized,
|
||||
online: d.online ?? null,
|
||||
}));
|
||||
const data = await api("GET", `/api/v2/tailnet/${tailnetPath()}/devices?fields=all`);
|
||||
return (data.devices || []).map((d: any) => {
|
||||
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 {
|
||||
id: d.id,
|
||||
nodeId: d.nodeId || d.id,
|
||||
hostname: d.hostname || "",
|
||||
label: d.displayName || d.hostname || "",
|
||||
addresses: d.addresses || [],
|
||||
primaryAddress: d.addresses?.[0] || "",
|
||||
os: d.os || "",
|
||||
lastSeen: d.lastSeen || null,
|
||||
isExitNode: enabledRoutes.includes("0.0.0.0/0") && enabledRoutes.includes("::/0"),
|
||||
authorized: !!d.authorized,
|
||||
online: !!d.connectedToControl,
|
||||
keyExpiry: hasRealExpiry ? expires! : null,
|
||||
keyExpiryDisabled: !!d.keyExpiryDisabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteDevice(deviceId: string): Promise<void> {
|
||||
@@ -95,5 +130,5 @@ export function createTailscaleAdapter(config: TailscaleConfig): TailscaleAdapte
|
||||
}
|
||||
}
|
||||
|
||||
return { ping, listDevices, setAuthorized, deleteDevice };
|
||||
return withDiagLogging("tailscale", { ping, listDevices, setAuthorized, deleteDevice });
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export interface IntegrationField {
|
||||
secret: boolean;
|
||||
type?: "text" | "password" | "checkbox";
|
||||
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>;
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -7,13 +7,38 @@ import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
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({
|
||||
hostname: z.string().max(255).optional(),
|
||||
os_type: z.string().optional(),
|
||||
reported_at: z.string().optional(),
|
||||
system: systemSchema.nullable().optional(),
|
||||
tasks: z.array(
|
||||
z.object({
|
||||
schedule_type: z.enum(["cron", "systemd_timer"]),
|
||||
schedule_type: z.enum(["cron", "systemd_timer", "windows_task"]),
|
||||
name: z.string().min(1),
|
||||
command: z.string().optional(),
|
||||
schedule_expression: z.string().optional(),
|
||||
@@ -47,6 +72,8 @@ agentReportRouter.post("/", asyncHandler(async (req, res) => {
|
||||
|
||||
await syncServerTasks(server.id, {
|
||||
hostname: parsed.data.hostname,
|
||||
osType: parsed.data.os_type,
|
||||
system: parsed.data.system,
|
||||
tasks: parsed.data.tasks.map((t) => ({
|
||||
scheduleType: t.schedule_type,
|
||||
name: t.name,
|
||||
|
||||
@@ -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"));
|
||||
}));
|
||||
@@ -11,6 +11,7 @@ auditLogRouter.use(requireAuth, requireRole("operator"));
|
||||
|
||||
auditLogRouter.get("/", asyncHandler(async (req, res) => {
|
||||
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 });
|
||||
}));
|
||||
@@ -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();
|
||||
}));
|
||||
@@ -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();
|
||||
}));
|
||||
@@ -13,7 +13,18 @@ import {
|
||||
} from "../dns/providerSchemas.js";
|
||||
import { createDnsAdapter } from "../dns/registry.js";
|
||||
import { loadDnsProviderConfig, getDnsAdapterForProvider } from "../dns/loadProvider.js";
|
||||
import { getDnsStats } from "../dns/stats.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();
|
||||
|
||||
@@ -25,6 +36,12 @@ dnsRouter.get("/provider-fields", (_req, res) => {
|
||||
res.json({ fields: DNS_PROVIDER_FIELDS });
|
||||
});
|
||||
|
||||
// ─── Dashboard stats ─────────────────────────────────────────────────────────
|
||||
|
||||
dnsRouter.get("/stats", asyncHandler(async (_req, res) => {
|
||||
res.json(await getDnsStats());
|
||||
}));
|
||||
|
||||
// ─── Providers ───────────────────────────────────────────────────────────────
|
||||
|
||||
dnsRouter.get("/providers", asyncHandler(async (_req, res) => {
|
||||
@@ -383,6 +400,9 @@ dnsRouter.post("/providers/:id/zones/:zoneId/records", requireRole("operator"),
|
||||
targetId: result.id,
|
||||
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 });
|
||||
} catch (err) {
|
||||
@@ -433,6 +453,9 @@ dnsRouter.put("/providers/:id/zones/:zoneId/records/:recordId", requireRole("ope
|
||||
targetId: result.id,
|
||||
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 });
|
||||
} 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" });
|
||||
|
||||
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 db
|
||||
.delete(dnsRecordsCache)
|
||||
@@ -467,9 +502,30 @@ dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole("
|
||||
targetId: recordId,
|
||||
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();
|
||||
} catch (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 });
|
||||
}));
|
||||
@@ -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();
|
||||
}));
|
||||
@@ -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) });
|
||||
}
|
||||
}));
|
||||
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
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 { recordAudit } from "../services/audit.js";
|
||||
import { encryptSecret } from "../crypto.js";
|
||||
@@ -14,8 +14,16 @@ import {
|
||||
} from "../integrations/fieldSchemas.js";
|
||||
import { createIntegrationAdapter } from "../integrations/registry.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 { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
|
||||
import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js";
|
||||
import { createProxmoxAdapter, guestsWithoutBackupCoverage } from "../integrations/proxmox/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";
|
||||
|
||||
export const integrationsRouter = Router();
|
||||
@@ -131,10 +139,18 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
|
||||
let credentialId = existing.credentialId;
|
||||
let configJson = existing.config;
|
||||
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) {
|
||||
const loaded = await loadIntegrationConfig(id);
|
||||
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 };
|
||||
for (const field of INTEGRATION_FIELDS[existing.type] ?? []) {
|
||||
if (field.secret) delete (mergedNonSecret as Record<string, unknown>)[field.key];
|
||||
@@ -180,7 +196,13 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req,
|
||||
action: "update",
|
||||
targetType: "integration",
|
||||
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({
|
||||
@@ -195,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) => {
|
||||
const id = Number(req.params.id);
|
||||
const [existing] = await db.select().from(integrations).where(eq(integrations.id, id)).limit(1);
|
||||
@@ -202,6 +272,14 @@ integrationsRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req,
|
||||
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));
|
||||
if (existing.credentialId) {
|
||||
await db.delete(integrationCredentials).where(eq(integrationCredentials.id, existing.credentialId));
|
||||
@@ -213,7 +291,7 @@ integrationsRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req,
|
||||
action: "delete",
|
||||
targetType: "integration",
|
||||
targetId: id,
|
||||
detail: { name: existing.name },
|
||||
detail: { name: existing.name, ...(unlinked.length > 0 ? { unlinkedServers: unlinked.length } : {}) },
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
@@ -275,6 +353,7 @@ integrationsRouter.get("/:id/tailscale/devices", asyncHandler(async (req, res) =
|
||||
total: devices.length,
|
||||
online: devices.filter((d) => d.online).length,
|
||||
unauthorized: devices.filter((d) => !d.authorized).length,
|
||||
expiringSoon: devices.filter((d) => isKeyExpiringSoon(d)).length,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -397,3 +476,449 @@ integrationsRouter.post(
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Dockhand ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function requireDockhandAdapter(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 !== "dockhand") {
|
||||
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: createDockhandAdapter(loaded.config as any) };
|
||||
}
|
||||
|
||||
integrationsRouter.get("/:id/dockhand/containers", asyncHandler(async (req, res) => {
|
||||
const found = await requireDockhandAdapter(req, res);
|
||||
if (!found) return;
|
||||
|
||||
try {
|
||||
const containers = await found.adapter.listContainers();
|
||||
res.json({
|
||||
containers,
|
||||
summary: {
|
||||
total: containers.length,
|
||||
running: containers.filter((c) => c.state === "running").length,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}));
|
||||
|
||||
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;
|
||||
|
||||
for (const action of dockhandActions) {
|
||||
integrationsRouter.post(
|
||||
`/:id/dockhand/environments/:envId/containers/:containerId/${action}`,
|
||||
requireRole("operator"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const found = await requireDockhandAdapter(req, res);
|
||||
if (!found) return;
|
||||
|
||||
const envId = Number(req.params.envId);
|
||||
if (!Number.isInteger(envId)) {
|
||||
return res.status(400).json({ error: "invalid_environment_id" });
|
||||
}
|
||||
|
||||
try {
|
||||
await found.adapter[`${action}Container`](envId, req.params.containerId);
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "integration",
|
||||
action: `${action}_container`,
|
||||
targetType: "dockhand_container",
|
||||
targetId: req.params.containerId,
|
||||
detail: { integrationId: found.integration.id, environmentId: envId },
|
||||
});
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Semaphore ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function requireSemaphoreAdapter(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 !== "semaphore") {
|
||||
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: createSemaphoreAdapter(loaded.config as any) };
|
||||
}
|
||||
|
||||
integrationsRouter.get("/:id/semaphore/templates", asyncHandler(async (req, res) => {
|
||||
const found = await requireSemaphoreAdapter(req, res);
|
||||
if (!found) return;
|
||||
|
||||
try {
|
||||
const templates = await found.adapter.listTemplatesWithStatus();
|
||||
res.json({
|
||||
templates,
|
||||
summary: {
|
||||
total: templates.length,
|
||||
failing: templates.filter((t) => t.lastTask?.status === "error").length,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}));
|
||||
|
||||
integrationsRouter.post(
|
||||
"/:id/semaphore/projects/:projectId/templates/:templateId/run",
|
||||
requireRole("operator"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const found = await requireSemaphoreAdapter(req, res);
|
||||
if (!found) return;
|
||||
|
||||
const projectId = Number(req.params.projectId);
|
||||
const templateId = Number(req.params.templateId);
|
||||
if (!Number.isInteger(projectId) || !Number.isInteger(templateId)) {
|
||||
return res.status(400).json({ error: "invalid_id" });
|
||||
}
|
||||
|
||||
try {
|
||||
const task = await found.adapter.runTemplate(projectId, templateId);
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "integration",
|
||||
action: "run_template",
|
||||
targetType: "semaphore_template",
|
||||
targetId: templateId,
|
||||
detail: { integrationId: found.integration.id, projectId, taskId: task.id },
|
||||
});
|
||||
res.status(201).json({ task });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Proxmox ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function requireProxmoxAdapter(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 !== "proxmox") {
|
||||
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: createProxmoxAdapter(loaded.config as any) };
|
||||
}
|
||||
|
||||
integrationsRouter.get("/:id/proxmox/guests", asyncHandler(async (req, res) => {
|
||||
const found = await requireProxmoxAdapter(req, res);
|
||||
if (!found) return;
|
||||
|
||||
try {
|
||||
const guests = await found.adapter.listGuests();
|
||||
res.json({
|
||||
guests,
|
||||
summary: {
|
||||
total: guests.length,
|
||||
running: guests.filter((g) => g.status === "running").length,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}));
|
||||
|
||||
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) {
|
||||
integrationsRouter.post(
|
||||
`/:id/proxmox/nodes/:node/:type/:vmid/${action}`,
|
||||
requireRole("operator"),
|
||||
asyncHandler(async (req, res) => {
|
||||
const found = await requireProxmoxAdapter(req, res);
|
||||
if (!found) return;
|
||||
|
||||
const vmid = Number(req.params.vmid);
|
||||
if (!Number.isInteger(vmid)) {
|
||||
return res.status(400).json({ error: "invalid_vmid" });
|
||||
}
|
||||
const type = req.params.type;
|
||||
if (type !== "qemu" && type !== "lxc") {
|
||||
return res.status(400).json({ error: "invalid_type" });
|
||||
}
|
||||
|
||||
try {
|
||||
await found.adapter[`${action}Guest`](req.params.node, type, vmid);
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "integration",
|
||||
action: `${action}_guest`,
|
||||
targetType: "proxmox_guest",
|
||||
targetId: vmid,
|
||||
detail: { integrationId: found.integration.id, node: req.params.node, guestType: type },
|
||||
});
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Synology ────────────────────────────────────────────────────────────────
|
||||
// Read-only per the delivery plan — no start/stop/etc actions.
|
||||
|
||||
async function requireSynologyAdapter(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 !== "synology") {
|
||||
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: createSynologyAdapter(loaded.config as any) };
|
||||
}
|
||||
|
||||
integrationsRouter.get("/:id/synology/storage", asyncHandler(async (req, res) => {
|
||||
const found = await requireSynologyAdapter(req, res);
|
||||
if (!found) return;
|
||||
|
||||
try {
|
||||
const info = await found.adapter.getStorageInfo();
|
||||
res.json({
|
||||
...info,
|
||||
summary: {
|
||||
volumeCount: info.volumes.length,
|
||||
volumesNotNormal: info.volumes.filter((v) => v.status !== "normal").length,
|
||||
diskCount: info.disks.length,
|
||||
disksNotNormal: info.disks.filter((d) => d.status !== "normal").length,
|
||||
},
|
||||
});
|
||||
} catch (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
@@ -1,21 +1,51 @@
|
||||
import { Router } from "express";
|
||||
import { isIP } from "node:net";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
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 { recordAudit } from "../services/audit.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();
|
||||
|
||||
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) => {
|
||||
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.
|
||||
res.json({ entries: rows.map((r) => ({ ...r, matchingDnsRecords: [] as string[] })) });
|
||||
const byIp = await matchingDnsRecordsByIp(rows.map((r) => r.ipAddress));
|
||||
// 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({
|
||||
@@ -54,7 +84,8 @@ ipamRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => {
|
||||
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) => {
|
||||
@@ -84,7 +115,8 @@ ipamRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res)
|
||||
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) => {
|
||||
@@ -107,3 +139,206 @@ ipamRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res)
|
||||
|
||||
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 });
|
||||
}));
|
||||
@@ -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();
|
||||
}));
|
||||
@@ -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();
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}));
|
||||
@@ -6,6 +6,7 @@ import { secrets, secretTypes } from "../db/schema.js";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
import { computeSecretStatus } from "../services/secretStatus.js";
|
||||
import { fetchCertExpiry, refreshTlsSecret } from "../services/tlsCheck.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
export const secretsRouter = Router();
|
||||
@@ -23,9 +24,12 @@ const secretInput = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
type: z.enum(secretTypes),
|
||||
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),
|
||||
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) => {
|
||||
@@ -33,8 +37,37 @@ secretsRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) =
|
||||
if (!parsed.success) {
|
||||
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({
|
||||
actor: req.currentUser!,
|
||||
@@ -60,11 +93,38 @@ secretsRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, re
|
||||
return res.status(404).json({ error: "not_found" });
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(secrets)
|
||||
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(secrets.id, id))
|
||||
.returning();
|
||||
const { checkHost, checkPort, ...rest } = parsed.data;
|
||||
const nextType = rest.type ?? existing.type;
|
||||
if (checkHost && nextType !== "ssl_certificate") {
|
||||
return res.status(400).json({ error: "invalid_body", message: "Only SSL certificates can be checked against a host." });
|
||||
}
|
||||
// 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({
|
||||
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) } });
|
||||
}));
|
||||
|
||||
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) => {
|
||||
const id = Number(req.params.id);
|
||||
const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1);
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
@@ -1,27 +1,42 @@
|
||||
import { Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
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 { generateApiToken } from "../services/tokens.js";
|
||||
import { recordAudit } from "../services/audit.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();
|
||||
|
||||
/** 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("/:id/ports", serverPortsRouter);
|
||||
|
||||
const createServerSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
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(),
|
||||
});
|
||||
|
||||
serversRouter.get("/", asyncHandler(async (_req, res) => {
|
||||
const rows = await db.query.servers.findMany({ orderBy: (s, { asc }) => [asc(s.name)] });
|
||||
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 },
|
||||
});
|
||||
|
||||
const { apiTokenHash, ...serverOut } = created;
|
||||
const serverOut = publicServer(created);
|
||||
// The full token is only ever shown once, at creation time.
|
||||
res.status(201).json({ server: serverOut, token });
|
||||
}));
|
||||
@@ -81,10 +96,322 @@ serversRouter.post("/:id/rotate-token", requireRole("admin"), asyncHandler(async
|
||||
detail: { name: updated.name },
|
||||
});
|
||||
|
||||
const { apiTokenHash, ...serverOut } = updated;
|
||||
const serverOut = publicServer(updated);
|
||||
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) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
@@ -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);
|
||||
}));
|
||||
@@ -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);
|
||||
}
|
||||
}));
|
||||
@@ -60,7 +60,7 @@ tasksRouter.get("/", asyncHandler(async (req, res) => {
|
||||
|
||||
const manualTaskSchema = z.object({
|
||||
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),
|
||||
command: z.string().max(1000).optional(),
|
||||
scheduleExpression: z.string().max(200).optional(),
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -3,9 +3,12 @@ import { auditLog, users } from "../db/schema.js";
|
||||
|
||||
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: {
|
||||
actor: CurrentUser;
|
||||
actor?: CurrentUser;
|
||||
category: string;
|
||||
action: string;
|
||||
targetType?: string;
|
||||
@@ -13,8 +16,8 @@ export async function recordAudit(params: {
|
||||
detail?: unknown;
|
||||
}) {
|
||||
await db.insert(auditLog).values({
|
||||
actorUserId: params.actor.id,
|
||||
actorLabel: params.actor.name ?? params.actor.email ?? params.actor.oidcSub,
|
||||
actorUserId: params.actor?.id,
|
||||
actorLabel: params.actor ? (params.actor.name ?? params.actor.email ?? params.actor.oidcSub) : SYSTEM_ACTOR_LABEL,
|
||||
category: params.category,
|
||||
action: params.action,
|
||||
targetType: params.targetType,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user