Files
Homelab-manager/server/src/db/schema.ts
T
bobbanandClaude Sonnet 5 4c11158e98 Add a Ports card to server pages: scan for open ports, find free ones, and keep notes
Each server's detail page now has a Ports card. "Scan…" runs a TCP connect
scan of a chosen range from the app and shows what's open, along with the
ranges that were actually confirmed free; clicking a free range starts a
reservation. Any port can carry a service name and a comment, so the page
also answers "what is this port for". A port with a note counts as taken
even when nothing is listening, which is what makes a reservation work.
Operators can scan and edit; everyone can read. Scans and note changes are
audit-logged.

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 02:39:43 +02:00

328 lines
14 KiB
TypeScript

import { sql } from "drizzle-orm";
import { sqliteTable, text, integer, real, uniqueIndex } from "drizzle-orm/sqlite-core";
// ─── Users & roles ──────────────────────────────────────────────────────────
export const userRoles = ["admin", "operator", "viewer"] as const;
export type UserRole = (typeof userRoles)[number];
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
oidcSub: text("oidc_sub").notNull().unique(),
email: text("email"),
name: text("name"),
role: text("role").$type<UserRole>().notNull().default("viewer"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
lastLoginAt: text("last_login_at"),
});
// ─── Audit log ──────────────────────────────────────────────────────────────
export const auditLog = sqliteTable("audit_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
actorUserId: integer("actor_user_id").references(() => users.id, { onDelete: "set null" }),
actorLabel: text("actor_label"), // denormalized name/email snapshot, survives user deletion
category: text("category").notNull(), // 'secret' | 'ipam' | 'dns' | 'user' | 'integration' | 'task' | ...
action: text("action").notNull(), // 'create' | 'update' | 'delete' | 'start' | 'stop' | ...
targetType: text("target_type"),
targetId: text("target_id"),
detail: text("detail"), // JSON-encoded free-form context
createdAt: text("created_at")
.notNull()
.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", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Secrets expiry tracker (ported from Sloth Manager) ────────────────────
export const secretTypes = ["api_token", "ssl_certificate", "password", "generic"] as const;
export type SecretType = (typeof secretTypes)[number];
export const secrets = sqliteTable("secrets", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
type: text("type").$type<SecretType>().notNull().default("generic"),
description: text("description"),
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)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── IPAM (ported from Sloth Manager) ───────────────────────────────────────
export const ipamEntries = sqliteTable("ipam_entries", {
id: integer("id").primaryKey({ autoIncrement: true }),
ipAddress: text("ip_address").notNull().unique(),
label: text("label"),
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)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Integration credentials (encrypted API tokens) ─────────────────────────
export const integrationCredentials = sqliteTable("integration_credentials", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
encryptedSecret: text("encrypted_secret").notNull(), // AES-256-GCM, see src/crypto.ts
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── DNS providers + record cache (ported from Sloth Manager) ──────────────
export const dnsProviderTypes = [
"cloudflare",
"loopia",
"pihole",
"azure",
"cpanel",
"technitium",
] as const;
export type DnsProviderType = (typeof dnsProviderTypes)[number];
export const dnsProviders = sqliteTable("dns_providers", {
id: integer("id").primaryKey({ autoIncrement: true }),
providerType: text("provider_type").$type<DnsProviderType>().notNull(),
name: text("name").notNull(),
credentialId: integer("credential_id").references(() => integrationCredentials.id, {
onDelete: "set null",
}),
config: text("config"), // JSON: non-secret provider config (base URL, zone list, etc.)
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
export const dnsZonesCache = sqliteTable(
"dns_zones_cache",
{
id: integer("id").primaryKey({ autoIncrement: true }),
providerId: integer("provider_id")
.notNull()
.references(() => dnsProviders.id, { onDelete: "cascade" }),
zoneId: text("zone_id").notNull(), // provider-specific zone identifier
zoneName: text("zone_name").notNull(),
syncedAt: text("synced_at"),
},
(table) => [uniqueIndex("dns_zones_cache_provider_zone_idx").on(table.providerId, table.zoneId)],
);
export const dnsRecordsCache = sqliteTable("dns_records_cache", {
id: integer("id").primaryKey({ autoIncrement: true }),
providerId: integer("provider_id")
.notNull()
.references(() => dnsProviders.id, { onDelete: "cascade" }),
zoneId: text("zone_id").notNull(),
recordId: text("record_id").notNull(), // provider-specific record identifier
type: text("type").notNull(), // A, AAAA, CNAME, TXT, MX, ...
name: text("name").notNull(),
content: text("content").notNull(),
ttl: integer("ttl"),
priority: integer("priority"),
proxied: integer("proxied", { mode: "boolean" }),
});
// ─── 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(),
hostname: text("hostname"),
osType: text("os_type").notNull().default("linux"),
description: text("description"),
apiTokenHash: text("api_token_hash").notNull(),
apiTokenPrefix: text("api_token_prefix").notNull(),
createdAt: text("created_at")
.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
// Optional link to a Proxmox VM/LXC — set by an admin, not the agent.
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", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | '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"),
scheduleExpression: text("schedule_expression"),
source: text("source"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
nextRunAt: text("next_run_at"),
rawMetadata: text("raw_metadata"),
isStale: integer("is_stale", { mode: "boolean" }).notNull().default(false),
firstSeenAt: text("first_seen_at")
.notNull()
.default(sql`(current_timestamp)`),
lastSeenAt: text("last_seen_at")
.notNull()
.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 = [
"proxmox",
"synology",
"semaphore",
"tailscale",
"gitea",
"dockhand",
] as const;
export type IntegrationType = (typeof integrationTypes)[number];
export const integrations = sqliteTable("integrations", {
id: integer("id").primaryKey({ autoIncrement: true }),
type: text("type").$type<IntegrationType>().notNull(),
name: text("name").notNull(),
baseUrl: text("base_url").notNull(),
credentialId: integer("credential_id").references(() => integrationCredentials.id, {
onDelete: "set null",
}),
config: text("config"), // JSON: per-type non-secret config (tailnet name, node name, etc.)
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.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)],
);