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:
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `ipam_entries` ADD `source` text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)`),
|
||||
|
||||
@@ -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<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));
|
||||
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 });
|
||||
}));
|
||||
|
||||
@@ -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<Omit<IpamInput, "ipAddress">>) =>
|
||||
request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
remove: (id: number) => request<void>(`/api/ipam/${id}`, { method: "DELETE" }),
|
||||
syncTailscale: () => request<TailscaleSyncResult>("/api/ipam/sync-tailscale", { method: "POST" }),
|
||||
},
|
||||
dns: {
|
||||
providerFields: () =>
|
||||
|
||||
+30
-1
@@ -17,6 +17,8 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [form, setForm] = useState<IpamInput>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<string | null>(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 }) {
|
||||
<>
|
||||
<h2 className="page-title mb-3">IP Addresses</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
{syncResult && <div className="alert alert-success">Synced from Tailscale: {syncResult}</div>}
|
||||
|
||||
{canEdit && showForm && (
|
||||
<div className="card mb-3">
|
||||
@@ -195,6 +216,11 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
Add IP address
|
||||
</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}>
|
||||
Export CSV
|
||||
</button>
|
||||
@@ -222,7 +248,10 @@ export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
{entry.ipAddress}
|
||||
</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 className="text-secondary">
|
||||
{entry.matchingDnsRecords.length > 0 ? entry.matchingDnsRecords.join(", ") : "—"}
|
||||
|
||||
Reference in New Issue
Block a user