diff --git a/README.md b/README.md index 961bd4c..c88e8e6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,10 @@ All modules from the original plan are built: - Monorepo scaffold, Tabler-themed app shell/navigation - 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 +- **IP Addresses (IPAM)** — inventory of IPs across vendors/locations, + with a "Sync from Tailscale" action to pull in tailnet device IPs + (never overwrites a manually-entered IP), and each entry now shows + its matching DNS record(s) from the DNS module's cache - **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 diff --git a/server/drizzle/0002_motionless_lord_hawal.sql b/server/drizzle/0002_motionless_lord_hawal.sql new file mode 100644 index 0000000..0dd44a7 --- /dev/null +++ b/server/drizzle/0002_motionless_lord_hawal.sql @@ -0,0 +1 @@ +ALTER TABLE `ipam_entries` ADD `source` text; \ No newline at end of file diff --git a/server/drizzle/meta/0002_snapshot.json b/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..fe7224a --- /dev/null +++ b/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1018 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9a36d1de-4337-427c-8cce-24d46bc29acb", + "prevId": "b877d379-67c6-47db-970b-ba73f85d44b0", + "tables": { + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_label": { + "name": "actor_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_providers": { + "name": "dns_providers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "dns_providers_credential_id_integration_credentials_id_fk": { + "name": "dns_providers_credential_id_integration_credentials_id_fk", + "tableFrom": "dns_providers", + "tableTo": "integration_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_records_cache": { + "name": "dns_records_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_id": { + "name": "record_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ttl": { + "name": "ttl", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxied": { + "name": "proxied", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "dns_records_cache_provider_id_dns_providers_id_fk": { + "name": "dns_records_cache_provider_id_dns_providers_id_fk", + "tableFrom": "dns_records_cache", + "tableTo": "dns_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "dns_zones_cache": { + "name": "dns_zones_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "dns_zones_cache_provider_zone_idx": { + "name": "dns_zones_cache_provider_zone_idx", + "columns": [ + "provider_id", + "zone_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dns_zones_cache_provider_id_dns_providers_id_fk": { + "name": "dns_zones_cache_provider_id_dns_providers_id_fk", + "tableFrom": "dns_zones_cache", + "tableTo": "dns_providers", + "columnsFrom": [ + "provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_credentials": { + "name": "integration_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_secret": { + "name": "encrypted_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integrations": { + "name": "integrations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "integrations_credential_id_integration_credentials_id_fk": { + "name": "integrations_credential_id_integration_credentials_id_fk", + "tableFrom": "integrations", + "tableTo": "integration_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ipam_entries": { + "name": "ipam_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "ipam_entries_ip_address_unique": { + "name": "ipam_entries_ip_address_unique", + "columns": [ + "ip_address" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "server_id": { + "name": "server_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "schedule_expression": { + "name": "schedule_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_metadata": { + "name": "raw_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_stale": { + "name": "is_stale", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "scheduled_tasks_server_id_servers_id_fk": { + "name": "scheduled_tasks_server_id_servers_id_fk", + "tableFrom": "scheduled_tasks", + "tableTo": "servers", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "secrets": { + "name": "secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'generic'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiry_date": { + "name": "expiry_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "warn_days": { + "name": "warn_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "servers": { + "name": "servers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "os_type": { + "name": "os_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'linux'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_token_hash": { + "name": "api_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_token_prefix": { + "name": "api_token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_addresses": { + "name": "ip_addresses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_model": { + "name": "cpu_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_cores": { + "name": "cpu_cores", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_load_percent": { + "name": "cpu_load_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_total_bytes": { + "name": "mem_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mem_used_bytes": { + "name": "mem_used_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disks": { + "name": "disks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_integration_id": { + "name": "proxmox_integration_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_node": { + "name": "proxmox_node", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_guest_type": { + "name": "proxmox_guest_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxmox_vmid": { + "name": "proxmox_vmid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "servers_proxmox_integration_id_integrations_id_fk": { + "name": "servers_proxmox_integration_id_integrations_id_fk", + "tableFrom": "servers", + "tableTo": "integrations", + "columnsFrom": [ + "proxmox_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "last_login_at": { + "name": "last_login_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_oidc_sub_unique": { + "name": "users_oidc_sub_unique", + "columns": [ + "oidc_sub" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index a935c9b..f38dc5b 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1789468464784, "tag": "0001_yummy_luke_cage", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1789506601790, + "tag": "0002_motionless_lord_hawal", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index a30f847..f0bf314 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -74,6 +74,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)`), diff --git a/server/src/routes/ipam.ts b/server/src/routes/ipam.ts index 5bc7018..67055c0 100644 --- a/server/src/routes/ipam.ts +++ b/server/src/routes/ipam.ts @@ -1,21 +1,40 @@ 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"; export const ipamRouter = Router(); ipamRouter.use(requireAuth); +async function matchingDnsRecordsByIp(ips: string[]): Promise> { + const byIp = new Map(); + 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)); + res.json({ entries: rows.map((r) => ({ ...r, matchingDnsRecords: byIp.get(r.ipAddress) ?? [] })) }); })); const createInput = z.object({ @@ -54,7 +73,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 +104,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 +128,65 @@ ipamRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) res.status(204).end(); })); + +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 [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.ipAddress, ip)).limit(1); + if (!existing) { + await db.insert(ipamEntries).values({ ipAddress: ip, label, vendor: "Tailscale", notes, source: "tailscale" }); + added++; + } else if (existing.source === "tailscale") { + await db + .update(ipamEntries) + .set({ label, notes, updatedAt: new Date().toISOString() }) + .where(eq(ipamEntries.id, existing.id)); + 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 }); +})); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0545dbc..44e7795 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -63,11 +63,20 @@ export interface IpamEntry { vendor: string | null; location: string | null; notes: string | null; + source: string | null; createdAt: string; updatedAt: string; matchingDnsRecords: string[]; } +export interface TailscaleSyncResult { + added: number; + updated: number; + skipped: number; + skippedIps: string[]; + errors: string[]; +} + export interface IpamInput { ipAddress: string; label?: string; @@ -479,6 +488,7 @@ export const api = { update: (id: number, data: Partial>) => request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }), remove: (id: number) => request(`/api/ipam/${id}`, { method: "DELETE" }), + syncTailscale: () => request("/api/ipam/sync-tailscale", { method: "POST" }), }, dns: { providerFields: () => diff --git a/web/src/pages/Ipam.tsx b/web/src/pages/Ipam.tsx index 33f2d99..9c87d92 100644 --- a/web/src/pages/Ipam.tsx +++ b/web/src/pages/Ipam.tsx @@ -17,6 +17,8 @@ export default function Ipam({ user }: { user: CurrentUser }) { const [adding, setAdding] = useState(false); const [form, setForm] = useState(emptyForm); const [saving, setSaving] = useState(false); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); function load() { api.ipam @@ -91,6 +93,24 @@ export default function Ipam({ user }: { user: CurrentUser }) { } } + async function syncTailscale() { + setError(null); + setSyncResult(null); + setSyncing(true); + try { + const res = await api.ipam.syncTailscale(); + const parts = [`${res.added} added`, `${res.updated} updated`]; + if (res.skipped > 0) parts.push(`${res.skipped} skipped (already tracked manually)`); + setSyncResult(parts.join(", ")); + if (res.errors.length > 0) setError(res.errors.join("; ")); + load(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSyncing(false); + } + } + function exportCsv() { downloadCsv( "ip-addresses.csv", @@ -112,6 +132,7 @@ export default function Ipam({ user }: { user: CurrentUser }) { <>

IP Addresses

{error &&
{error}
} + {syncResult &&
Synced from Tailscale: {syncResult}
} {canEdit && showForm && (
@@ -195,6 +216,11 @@ export default function Ipam({ user }: { user: CurrentUser }) { Add IP address )} + {canEdit && ( + + )} @@ -222,7 +248,10 @@ export default function Ipam({ user }: { user: CurrentUser }) { {entry.ipAddress} {entry.label ?? "—"} - {entry.vendor ?? "—"} + + {entry.vendor ?? "—"} + {entry.source === "tailscale" && synced} + {entry.location ?? "—"} {entry.matchingDnsRecords.length > 0 ? entry.matchingDnsRecords.join(", ") : "—"}