servers.proxmox_integration_id was created (migration 0001) without an ON DELETE rule, although schema.ts asks for "set null", so with foreign keys on, deleting an integration that a server was linked to failed with a constraint error. The delete route now clears the four proxmox_* columns on those servers first and records how many it unlinked in the audit entry. schema.ts notes the gap. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
413 lines
19 KiB
TypeScript
413 lines
19 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
|
|
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", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
serverId: integer("server_id")
|
|
.notNull()
|
|
.references(() => servers.id, { onDelete: "cascade" }),
|
|
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"),
|
|
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",
|
|
"uptimekuma",
|
|
"phpipam",
|
|
"pbs",
|
|
"osticket",
|
|
] 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)],
|
|
);
|
|
|
|
// 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)`),
|
|
});
|