Add a "Sync from Tailscale" action to IP Addresses (IPAM)

IPAM was entirely manual — Tailscale device IPs never showed up there
even though the Tailscale integration already lists them. Add an
explicit sync action (matching this app's existing pattern of
user-triggered syncs rather than silent background polling).

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 23:16:17 +02:00
1 parent bc0e54fea8
commit d714a87754
8 files changed
+1160 -8

No files matched your search

+4 -1
View File
@@ -18,7 +18,10 @@ All modules from the original plan are built:
- Monorepo scaffold, Tabler-themed app shell/navigation - Monorepo scaffold, Tabler-themed app shell/navigation
- Authentik OIDC login, roles (first user to sign in becomes admin), audit log - Authentik OIDC login, roles (first user to sign in becomes admin), audit log
- **Secrets** — expiry tracking for API tokens/certs/passwords - **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** — zone/record management across Cloudflare, Loopia, Pi-hole, Azure
DNS, cPanel, and Technitium; providers are configured in-app (not via env DNS, cPanel, and Technitium; providers are configured in-app (not via env
vars) and their credentials are encrypted at rest vars) and their credentials are encrypted at rest
@@ -0,0 +1 @@
ALTER TABLE `ipam_entries` ADD `source` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -15,6 +15,13 @@
"when": 1789468464784, "when": 1789468464784,
"tag": "0001_yummy_luke_cage", "tag": "0001_yummy_luke_cage",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1789506601790,
"tag": "0002_motionless_lord_hawal",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -74,6 +74,7 @@ export const ipamEntries = sqliteTable("ipam_entries", {
vendor: text("vendor"), vendor: text("vendor"),
location: text("location"), location: text("location"),
notes: text("notes"), notes: text("notes"),
source: text("source"), // null = entered manually; "tailscale" = auto-synced from a Tailscale integration
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
+89 -6
View File
@@ -1,21 +1,40 @@
import { Router } from "express"; import { Router } from "express";
import { isIP } from "node:net"; import { isIP } from "node:net";
import { eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { ipamEntries } from "../db/schema.js"; import { ipamEntries, integrations, dnsRecordsCache } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js"; import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js"; import { recordAudit } from "../services/audit.js";
import { asyncHandler } from "../utils/asyncHandler.js"; import { asyncHandler } from "../utils/asyncHandler.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createTailscaleAdapter } from "../integrations/tailscale/adapter.js";
export const ipamRouter = Router(); export const ipamRouter = Router();
ipamRouter.use(requireAuth); ipamRouter.use(requireAuth);
async function matchingDnsRecordsByIp(ips: string[]): Promise<Map<string, string[]>> {
const byIp = new Map<string, string[]>();
if (ips.length === 0) return byIp;
const rows = await db
.select({ ip: dnsRecordsCache.content, name: dnsRecordsCache.name })
.from(dnsRecordsCache)
.where(inArray(dnsRecordsCache.content, ips));
for (const row of rows) {
const list = byIp.get(row.ip) ?? [];
list.push(row.name);
byIp.set(row.ip, list);
}
return byIp;
}
ipamRouter.get("/", asyncHandler(async (_req, res) => { ipamRouter.get("/", asyncHandler(async (_req, res) => {
const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress); const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress);
// matchingDnsRecords will be populated once the DNS module (phase 3) has cached records for cross-reference. const byIp = await matchingDnsRecordsByIp(rows.map((r) => r.ipAddress));
res.json({ entries: rows.map((r) => ({ ...r, matchingDnsRecords: [] as string[] })) }); res.json({ entries: rows.map((r) => ({ ...r, matchingDnsRecords: byIp.get(r.ipAddress) ?? [] })) });
})); }));
const createInput = z.object({ const createInput = z.object({
@@ -54,7 +73,8 @@ ipamRouter.post("/", requireRole("operator"), asyncHandler(async (req, res) => {
detail: { ipAddress: created.ipAddress }, detail: { ipAddress: created.ipAddress },
}); });
res.status(201).json({ entry: { ...created, matchingDnsRecords: [] } }); const byIp = await matchingDnsRecordsByIp([created.ipAddress]);
res.status(201).json({ entry: { ...created, matchingDnsRecords: byIp.get(created.ipAddress) ?? [] } });
})); }));
ipamRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res) => { ipamRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res) => {
@@ -84,7 +104,8 @@ ipamRouter.patch("/:id", requireRole("operator"), asyncHandler(async (req, res)
detail: { ipAddress: updated.ipAddress }, detail: { ipAddress: updated.ipAddress },
}); });
res.json({ entry: { ...updated, matchingDnsRecords: [] } }); const byIp = await matchingDnsRecordsByIp([updated.ipAddress]);
res.json({ entry: { ...updated, matchingDnsRecords: byIp.get(updated.ipAddress) ?? [] } });
})); }));
ipamRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => { ipamRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res) => {
@@ -107,3 +128,65 @@ ipamRouter.delete("/:id", requireRole("operator"), asyncHandler(async (req, res)
res.status(204).end(); 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 });
}));
+10
View File
@@ -63,11 +63,20 @@ export interface IpamEntry {
vendor: string | null; vendor: string | null;
location: string | null; location: string | null;
notes: string | null; notes: string | null;
source: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
matchingDnsRecords: string[]; matchingDnsRecords: string[];
} }
export interface TailscaleSyncResult {
added: number;
updated: number;
skipped: number;
skippedIps: string[];
errors: string[];
}
export interface IpamInput { export interface IpamInput {
ipAddress: string; ipAddress: string;
label?: string; label?: string;
@@ -479,6 +488,7 @@ export const api = {
update: (id: number, data: Partial<Omit<IpamInput, "ipAddress">>) => update: (id: number, data: Partial<Omit<IpamInput, "ipAddress">>) =>
request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }), request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
remove: (id: number) => request<void>(`/api/ipam/${id}`, { method: "DELETE" }), remove: (id: number) => request<void>(`/api/ipam/${id}`, { method: "DELETE" }),
syncTailscale: () => request<TailscaleSyncResult>("/api/ipam/sync-tailscale", { method: "POST" }),
}, },
dns: { dns: {
providerFields: () => providerFields: () =>
+30 -1
View File
@@ -17,6 +17,8 @@ export default function Ipam({ user }: { user: CurrentUser }) {
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [form, setForm] = useState<IpamInput>(emptyForm); const [form, setForm] = useState<IpamInput>(emptyForm);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null);
function load() { function load() {
api.ipam 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() { function exportCsv() {
downloadCsv( downloadCsv(
"ip-addresses.csv", "ip-addresses.csv",
@@ -112,6 +132,7 @@ export default function Ipam({ user }: { user: CurrentUser }) {
<> <>
<h2 className="page-title mb-3">IP Addresses</h2> <h2 className="page-title mb-3">IP Addresses</h2>
{error && <div className="alert alert-danger">{error}</div>} {error && <div className="alert alert-danger">{error}</div>}
{syncResult && <div className="alert alert-success">Synced from Tailscale: {syncResult}</div>}
{canEdit && showForm && ( {canEdit && showForm && (
<div className="card mb-3"> <div className="card mb-3">
@@ -195,6 +216,11 @@ export default function Ipam({ user }: { user: CurrentUser }) {
Add IP address Add IP address
</button> </button>
)} )}
{canEdit && (
<button className="btn btn-outline-secondary" onClick={syncTailscale} disabled={syncing}>
{syncing ? "Syncing…" : "Sync from Tailscale"}
</button>
)}
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv}> <button className="btn btn-outline-secondary ms-auto" onClick={exportCsv}>
Export CSV Export CSV
</button> </button>
@@ -222,7 +248,10 @@ export default function Ipam({ user }: { user: CurrentUser }) {
{entry.ipAddress} {entry.ipAddress}
</td> </td>
<td>{entry.label ?? "—"}</td> <td>{entry.label ?? "—"}</td>
<td>{entry.vendor ?? "—"}</td> <td>
{entry.vendor ?? "—"}
{entry.source === "tailscale" && <span className="badge bg-cyan-lt ms-2">synced</span>}
</td>
<td>{entry.location ?? "—"}</td> <td>{entry.location ?? "—"}</td>
<td className="text-secondary"> <td className="text-secondary">
{entry.matchingDnsRecords.length > 0 ? entry.matchingDnsRecords.join(", ") : "—"} {entry.matchingDnsRecords.length > 0 ? entry.matchingDnsRecords.join(", ") : "—"}