From b5a64fdeace3360fe2be68a08ba907ea3261ef0b Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Mon, 14 Sep 2026 21:05:04 +0200 Subject: [PATCH] added phpipam and tailscale integration --- ENVIRONMENT.md | 50 +++++ README.md | 27 +++ backend/.env.example | 9 + backend/src/adapters/phpipam.js | 148 +++++++++++++ backend/src/adapters/tailscale.js | 87 ++++++++ backend/src/ipam-external.js | 37 ++++ backend/src/routes/ipam.js | 144 +++++++++++- docker-compose.dev.yml | 26 +++ docker-compose.yml | 4 +- frontend/src/App.css | 10 + frontend/src/api/dns.js | 37 ++++ frontend/src/components/IpamPage.js | 333 +++++++++++++++++++++++----- 12 files changed, 846 insertions(+), 66 deletions(-) create mode 100644 backend/src/adapters/phpipam.js create mode 100644 backend/src/adapters/tailscale.js create mode 100644 backend/src/ipam-external.js create mode 100644 docker-compose.dev.yml diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index c26e696..c66533c 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -86,6 +86,47 @@ SSO is disabled (and the button is hidden) when any of the three `AUTHENTIK_*` v --- +## phpIPAM + +Import IP addresses and devices from a self-hosted [phpIPAM](https://phpipam.net/) instance (optional). When configured, the IPAM page gains a **Sync External** button and shows a phpIPAM source badge on synced entries. Addresses can be edited or deleted with write-back to phpIPAM. + +| Variable | Required | Description | +|----------|----------|-------------| +| `PHPIPAM_URL` | Yes | Base URL of your phpIPAM instance, e.g. `https://ipam.example.com` | +| `PHPIPAM_APP_ID` | Yes | API application ID created in phpIPAM → Administration → API | +| `PHPIPAM_TOKEN` | Yes | Static app token (token-based auth, no username/password needed) | + +**Setup steps in phpIPAM:** + +1. Go to **Administration → phpIPAM Settings** and enable the REST API. +2. Go to **Administration → API** and click **+ Create API key**. +3. Set **App identifier** to any slug (e.g. `sloth-manager`), **App permissions** to `Read/Write`, and **App security** to `SSL with App code token`. +4. Copy the **App code** — this is your `PHPIPAM_TOKEN`. +5. Set `PHPIPAM_APP_ID` to the same slug you chose as the App identifier. + +phpIPAM integration is disabled when any of the three `PHPIPAM_*` variables are missing. + +--- + +## Tailscale + +Import devices from a [Tailscale](https://tailscale.com/) tailnet (optional). When configured, the IPAM page shows devices with their Tailscale IPs. Devices can be authorized/de-authorized or removed from the tailnet directly from Sloth Manager. + +| Variable | Required | Description | +|----------|----------|-------------| +| `TAILSCALE_API_KEY` | Yes | API key generated at [tailscale.com/settings/keys](https://login.tailscale.com/admin/settings/keys) | +| `TAILSCALE_TAILNET` | Yes | Tailnet name, e.g. `yourorg.github` — use `-` to target your default tailnet | + +**Setup steps:** + +1. Go to **tailscale.com → Settings → Keys → Generate access token**. +2. Grant the token **Devices: Read** and **Devices: Write** (write is needed for authorize and delete actions). +3. Set `TAILSCALE_TAILNET` to your tailnet name (visible in the Admin console URL: `login.tailscale.com/admin/machines/`), or use `-` for the default. + +Tailscale integration is disabled when either variable is missing. + +--- + ## Authentication | Variable | Required | Description | @@ -139,6 +180,15 @@ AUTHENTIK_URL=https://auth.example.com/application/o/sloth-manager AUTHENTIK_CLIENT_ID=your_client_id AUTHENTIK_CLIENT_SECRET=your_client_secret +# phpIPAM (optional) +PHPIPAM_URL=https://ipam.example.com +PHPIPAM_APP_ID=sloth-manager +PHPIPAM_TOKEN=your_app_token + +# Tailscale (optional) +TAILSCALE_API_KEY=tskey-api-xxxxxxxxxxxxxxxx +TAILSCALE_TAILNET=yourorg.github + # Auth JWT_SECRET=your-long-random-secret-here JWT_EXPIRES_IN=24h diff --git a/README.md b/README.md index 0dc6f5c..139a6b2 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,33 @@ npm run build # outputs to frontend/build/ --- +## Docker + +### Production (pull images from registry) + +Uses pre-built images from the Gitea registry — no local build required. + +```bash +docker compose up -d +``` + +To update to the latest images: + +```bash +docker compose pull +docker compose up -d +``` + +### Development (build images locally) + +Builds images from the local source code. + +```bash +docker compose -f docker-compose.dev.yml up --build +``` + +--- + ## First run On first start the backend automatically creates a default admin account and prints a warning in the terminal: diff --git a/backend/.env.example b/backend/.env.example index 7c793a4..74daf2a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -30,6 +30,15 @@ DISABLED_PROVIDERS= # AUTHENTIK_CLIENT_ID=your_client_id # AUTHENTIK_CLIENT_SECRET=your_client_secret +# phpIPAM (optional — leave blank to disable phpIPAM integration) +# PHPIPAM_URL=https://ipam.example.com +# PHPIPAM_APP_ID=sloth-manager +# PHPIPAM_TOKEN=your_app_token + +# Tailscale (optional — leave blank to disable Tailscale integration) +# TAILSCALE_API_KEY=tskey-api-xxxxxxxxxxxxxxxx +# TAILSCALE_TAILNET=yourorg.github + # Auth — generate a strong random secret, e.g: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" JWT_SECRET=change-this-to-a-long-random-string JWT_EXPIRES_IN=24h diff --git a/backend/src/adapters/phpipam.js b/backend/src/adapters/phpipam.js new file mode 100644 index 0000000..0271ef1 --- /dev/null +++ b/backend/src/adapters/phpipam.js @@ -0,0 +1,148 @@ +/** + * phpIPAM adapter + * + * Requires env: + * PHPIPAM_URL Base URL, e.g. https://192.168.1.100 or https://ipam.example.com + * PHPIPAM_APP_ID Application ID created in phpIPAM → Administration → API + * PHPIPAM_TOKEN App code token (set App security to "App code token" in phpIPAM) + * + * API docs: https://phpipam.net/api/api_documentation/ + */ + +function base() { + return `${(process.env.PHPIPAM_URL || '').replace(/\/$/, '')}/api/${process.env.PHPIPAM_APP_ID || ''}`; +} + +function headers() { + return { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'phpipam-token': process.env.PHPIPAM_TOKEN || '', + }; +} + +async function api(method, path, body) { + const url = `${base()}${path}`; + const res = await fetch(url, { + method, + headers: headers(), + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let data; + try { data = JSON.parse(text); } catch { throw new Error(`phpIPAM returned non-JSON: ${text.slice(0, 120)}`); } + if (data.success === false) throw new Error(data.message || 'phpIPAM API error'); + return data.data ?? data; +} + +// ─── Subnets ────────────────────────────────────────────────────────────────── + +async function listSubnets() { + const raw = await api('GET', '/subnets/'); + return (raw || []).map(s => ({ + id: String(s.id), + subnet: s.subnet, + mask: s.mask, + description: s.description || '', + section_id: String(s.sectionId || ''), + })); +} + +// ─── Addresses ──────────────────────────────────────────────────────────────── + +async function listAddresses() { + const raw = await api('GET', '/addresses/'); + return (raw || []).map(normaliseAddress); +} + +function normaliseAddress(a) { + return { + external_id: String(a.id), + source: 'phpipam', + kind: 'address', + address: a.ip || '', + label: a.hostname || '', + vendor: '', + location: '', + notes: a.description || a.note || '', + subnet_id: String(a.subnetId || ''), + online: a.online_status === '1' || a.online_status === 1, + last_seen: a.lastSeen || null, + }; +} + +async function createAddress(data) { + const result = await api('POST', '/addresses/', { + ip: data.address, + hostname: data.label || '', + description: data.notes || '', + subnetId: data.subnet_id || 1, + }); + // phpIPAM returns the new ID as a string + return { external_id: String(result), ...data, source: 'phpipam', kind: 'address' }; +} + +async function updateAddress(externalId, data) { + await api('PATCH', `/addresses/${externalId}/`, { + hostname: data.label !== undefined ? data.label : undefined, + description: data.notes !== undefined ? data.notes : undefined, + }); +} + +async function deleteAddress(externalId) { + await api('DELETE', `/addresses/${externalId}/`); +} + +// ─── Devices ────────────────────────────────────────────────────────────────── + +async function listDevices() { + const raw = await api('GET', '/devices/'); + return (raw || []).map(d => ({ + external_id: `dev-${d.id}`, + source: 'phpipam', + kind: 'device', + address: d.ip || '', + label: d.hostname || d.description || '', + vendor: d.vendor || d.type || '', + location: d.location || '', + notes: d.description || '', + })); +} + +async function updateDevice(rawId, data) { + await api('PATCH', `/devices/${rawId}/`, { + hostname: data.label !== undefined ? data.label : undefined, + description: data.notes !== undefined ? data.notes : undefined, + location: data.location !== undefined ? data.location : undefined, + vendor: data.vendor !== undefined ? data.vendor : undefined, + }); +} + +async function deleteDevice(rawId) { + await api('DELETE', `/devices/${rawId}/`); +} + +// ─── Health check ───────────────────────────────────────────────────────────── + +async function ping() { + const start = Date.now(); + await api('GET', '/sections/'); + return Date.now() - start; +} + +function isEnabled() { + return !!(process.env.PHPIPAM_URL && process.env.PHPIPAM_APP_ID && process.env.PHPIPAM_TOKEN); +} + +module.exports = { + isEnabled, + ping, + listSubnets, + listAddresses, + createAddress, + updateAddress, + deleteAddress, + listDevices, + updateDevice, + deleteDevice, +}; diff --git a/backend/src/adapters/tailscale.js b/backend/src/adapters/tailscale.js new file mode 100644 index 0000000..974ad91 --- /dev/null +++ b/backend/src/adapters/tailscale.js @@ -0,0 +1,87 @@ +/** + * Tailscale adapter + * + * Requires env: + * TAILSCALE_API_KEY API key from tailscale.com/settings/keys (or OAuth client secret) + * TAILSCALE_TAILNET Tailnet name, e.g. "yourorg.github" — use "-" for the default tailnet + * + * API docs: https://tailscale.com/api + */ + +const BASE = 'https://api.tailscale.com'; + +function headers() { + return { + Authorization: `Bearer ${process.env.TAILSCALE_API_KEY || ''}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }; +} + +function tailnet() { + return encodeURIComponent(process.env.TAILSCALE_TAILNET || '-'); +} + +async function api(method, path, body) { + const res = await fetch(`${BASE}${path}`, { + method, + headers: headers(), + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (res.status === 204) return null; + const data = await res.json(); + if (!res.ok) throw new Error(data.message || `Tailscale API error: HTTP ${res.status}`); + return data; +} + +// ─── Devices ────────────────────────────────────────────────────────────────── + +async function listDevices() { + const data = await api('GET', `/api/v2/tailnet/${tailnet()}/devices`); + return (data.devices || []).map(d => ({ + external_id: d.nodeId || d.id, + tailnet_id: d.id, // stable numeric ID for device-level API calls + source: 'tailscale', + kind: 'device', + address: d.addresses?.[0] || '', // primary Tailscale IP + all_addresses: d.addresses || [], + label: d.displayName || d.hostname || '', + vendor: d.os || '', + location: '', + notes: '', + last_seen: d.lastSeen || null, + is_exit_node: !!(d.isExitNode), + authorized: !!(d.authorized), + online: d.online ?? null, + })); +} + +// Remove a device from the tailnet +async function deleteDevice(nodeId) { + await api('DELETE', `/api/v2/device/${nodeId}`); +} + +// Authorize (or de-authorize) a device +async function setAuthorized(nodeId, authorized) { + await api('POST', `/api/v2/device/${nodeId}/authorized`, { authorized }); +} + +// ─── Health check ───────────────────────────────────────────────────────────── + +async function ping() { + const start = Date.now(); + await api('GET', `/api/v2/tailnet/${tailnet()}/devices`); + return Date.now() - start; +} + +function isEnabled() { + return !!(process.env.TAILSCALE_API_KEY && process.env.TAILSCALE_TAILNET); +} + +module.exports = { + isEnabled, + ping, + listDevices, + deleteDevice, + setAuthorized, +}; diff --git a/backend/src/ipam-external.js b/backend/src/ipam-external.js new file mode 100644 index 0000000..2da5420 --- /dev/null +++ b/backend/src/ipam-external.js @@ -0,0 +1,37 @@ +/** + * External IPAM cache — stores data synced from phpIPAM and Tailscale. + * Kept separate from ipam.json (local entries) so a re-sync never risks + * overwriting local data. + */ + +const fs = require('fs'); +const path = require('path'); + +const EXT_PATH = process.env.IPAM_EXT_PATH || path.join(__dirname, '..', 'ipam-external.json'); + +function load() { + try { return JSON.parse(fs.readFileSync(EXT_PATH, 'utf8')); } + catch { return { synced_at: null, entries: [] }; } +} + +function save(data) { + fs.writeFileSync(EXT_PATH, JSON.stringify(data, null, 2), 'utf8'); +} + +function getAll() { + return load(); +} + +/** Replace all cached entries (optionally for one source only). */ +function set(newEntries, source) { + const current = load(); + const kept = source + ? current.entries.filter(e => e.source !== source) + : []; + save({ + synced_at: new Date().toISOString(), + entries: [...kept, ...newEntries], + }); +} + +module.exports = { getAll, set }; diff --git a/backend/src/routes/ipam.js b/backend/src/routes/ipam.js index fce6ae1..cb1d292 100644 --- a/backend/src/routes/ipam.js +++ b/backend/src/routes/ipam.js @@ -1,18 +1,74 @@ -const express = require('express'); -const router = express.Router(); -const ipam = require('../ipam'); -const audit = require('../audit'); +const express = require('express'); +const router = express.Router(); +const ipam = require('../ipam'); +const ext = require('../ipam-external'); +const audit = require('../audit'); const { requireAuth } = require('../auth'); +const phpipam = require('../adapters/phpipam'); +const tailscale = require('../adapters/tailscale'); router.use(requireAuth); +// ─── GET /api/ipam ──────────────────────────────────────────────────────────── +// Returns local entries (with source:'local') merged with cached external data. + router.get('/', (req, res) => { - res.json(ipam.getAll()); + const local = ipam.getAll().map(e => ({ ...e, source: e.source || 'local' })); + const { entries: external, synced_at } = ext.getAll(); + res.json({ entries: [...local, ...external], synced_at }); }); +// ─── GET /api/ipam/sources ──────────────────────────────────────────────────── +// Tells the frontend which external sources are configured. + +router.get('/sources', (req, res) => { + res.json({ + phpipam: phpipam.isEnabled(), + tailscale: tailscale.isEnabled(), + }); +}); + +// ─── POST /api/ipam/sync ────────────────────────────────────────────────────── +// Body: { sources: ['phpipam', 'tailscale'] } (omit to sync all enabled) + +router.post('/sync', async (req, res) => { + const requested = req.body?.sources ?? ['phpipam', 'tailscale']; + const results = { phpipam: null, tailscale: null, errors: {} }; + + if (requested.includes('phpipam') && phpipam.isEnabled()) { + try { + const [addresses, devices] = await Promise.all([ + phpipam.listAddresses(), + phpipam.listDevices(), + ]); + const entries = [...addresses, ...devices]; + ext.set(entries, 'phpipam'); + results.phpipam = entries.length; + } catch (err) { + results.errors.phpipam = err.message; + } + } + + if (requested.includes('tailscale') && tailscale.isEnabled()) { + try { + const devices = await tailscale.listDevices(); + ext.set(devices, 'tailscale'); + results.tailscale = devices.length; + } catch (err) { + results.errors.tailscale = err.message; + } + } + + const { entries: external, synced_at } = ext.getAll(); + const local = ipam.getAll().map(e => ({ ...e, source: e.source || 'local' })); + res.json({ results, entries: [...local, ...external], synced_at }); +}); + +// ─── Local entry CRUD ───────────────────────────────────────────────────────── + router.post('/', (req, res) => { try { - const entry = ipam.create(req.body); + const entry = ipam.create({ ...req.body, source: 'local' }); audit.logIpam(req.user, 'add', entry); res.status(201).json(entry); } catch (err) { @@ -42,4 +98,80 @@ router.delete('/:id', (req, res) => { } }); +// ─── phpIPAM write-back ─────────────────────────────────────────────────────── + +router.put('/phpipam/:externalId', async (req, res) => { + if (!phpipam.isEnabled()) return res.status(400).json({ error: 'phpIPAM is not configured' }); + const { externalId } = req.params; + try { + if (externalId.startsWith('dev-')) { + await phpipam.updateDevice(externalId.replace('dev-', ''), req.body); + } else { + await phpipam.updateAddress(externalId, req.body); + } + // Refresh the phpIPAM cache entry in-place + const [addresses, devices] = await Promise.all([ + phpipam.listAddresses(), + phpipam.listDevices(), + ]); + ext.set([...addresses, ...devices], 'phpipam'); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.delete('/phpipam/:externalId', async (req, res) => { + if (!phpipam.isEnabled()) return res.status(400).json({ error: 'phpIPAM is not configured' }); + const { externalId } = req.params; + try { + if (externalId.startsWith('dev-')) { + await phpipam.deleteDevice(externalId.replace('dev-', '')); + } else { + await phpipam.deleteAddress(externalId); + } + // Remove from local cache + const { entries } = ext.getAll(); + ext.set(entries.filter(e => !(e.source === 'phpipam' && e.external_id === externalId)), null); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─── Tailscale device actions ───────────────────────────────────────────────── + +// Remove a device from the tailnet +router.delete('/tailscale/:nodeId', async (req, res) => { + if (!tailscale.isEnabled()) return res.status(400).json({ error: 'Tailscale is not configured' }); + try { + await tailscale.deleteDevice(req.params.nodeId); + const { entries } = ext.getAll(); + ext.set(entries.filter(e => !(e.source === 'tailscale' && e.external_id === req.params.nodeId)), null); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Authorize / de-authorize a device +router.post('/tailscale/:nodeId/authorized', async (req, res) => { + if (!tailscale.isEnabled()) return res.status(400).json({ error: 'Tailscale is not configured' }); + const authorized = req.body?.authorized !== false; // default true + try { + await tailscale.setAuthorized(req.params.nodeId, authorized); + // Update cached entry + const { entries } = ext.getAll(); + const updated = entries.map(e => + e.source === 'tailscale' && e.external_id === req.params.nodeId + ? { ...e, authorized } + : e + ); + ext.set(updated, null); + res.json({ success: true, authorized }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + module.exports = router; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..8f564a0 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,26 @@ +services: + + backend: + build: ./backend + container_name: sloth-backend + restart: unless-stopped + env_file: ./backend/.env + volumes: + - ./sloth-data:/data + networks: + - sloth-net + + frontend: + build: ./frontend + container_name: sloth-frontend + restart: unless-stopped + ports: + - "8088:80" + networks: + - sloth-net + depends_on: + - backend + +networks: + sloth-net: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 8f564a0..9d16f5f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: backend: - build: ./backend + image: gitea.labsconnect.se/bobban/sloth-manager-backend:latest container_name: sloth-backend restart: unless-stopped env_file: ./backend/.env @@ -11,7 +11,7 @@ services: - sloth-net frontend: - build: ./frontend + image: gitea.labsconnect.se/bobban/sloth-manager-frontend:latest container_name: sloth-frontend restart: unless-stopped ports: diff --git a/frontend/src/App.css b/frontend/src/App.css index 37625e3..af48c54 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -367,6 +367,16 @@ body { .secret-notes { color: var(--text-muted); cursor: help; margin-left: 4px; } /* ===== IPAM ===== */ +.source-badge { + display: inline-block; + font-size: 10px; + font-weight: 600; + padding: 2px 6px; + border-radius: 10px; + letter-spacing: 0.03em; + white-space: nowrap; +} + .ipam-address { font-family: monospace; font-size: 13px; diff --git a/frontend/src/api/dns.js b/frontend/src/api/dns.js index d9f51eb..d6253de 100644 --- a/frontend/src/api/dns.js +++ b/frontend/src/api/dns.js @@ -104,6 +104,43 @@ export async function getIpam() { return handleResponse(await fetch(`${BASE}/ipam`, { headers: authHeaders() })); } +export async function getIpamSources() { + return handleResponse(await fetch(`${BASE}/ipam/sources`, { headers: authHeaders() })); +} + +export async function syncIpam(sources) { + return handleResponse(await fetch(`${BASE}/ipam/sync`, { + method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(sources ? { sources } : {}), + })); +} + +export async function updatePhpipamEntry(externalId, data) { + return handleResponse(await fetch(`${BASE}/ipam/phpipam/${externalId}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(data), + })); +} + +export async function deletePhpipamEntry(externalId) { + return handleResponse(await fetch(`${BASE}/ipam/phpipam/${externalId}`, { + method: 'DELETE', headers: authHeaders(), + })); +} + +export async function deleteTailscaleDevice(nodeId) { + return handleResponse(await fetch(`${BASE}/ipam/tailscale/${nodeId}`, { + method: 'DELETE', headers: authHeaders(), + })); +} + +export async function setTailscaleAuthorized(nodeId, authorized) { + return handleResponse(await fetch(`${BASE}/ipam/tailscale/${nodeId}/authorized`, { + method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ authorized }), + })); +} + export async function createIpEntry(data) { return handleResponse(await fetch(`${BASE}/ipam`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, diff --git a/frontend/src/components/IpamPage.js b/frontend/src/components/IpamPage.js index 28419b5..32000f8 100644 --- a/frontend/src/components/IpamPage.js +++ b/frontend/src/components/IpamPage.js @@ -1,20 +1,49 @@ import { useState, useEffect } from 'react'; -import { getIpam, createIpEntry, updateIpEntry, deleteIpEntry } from '../api/dns'; +import { + getIpam, getIpamSources, syncIpam, + createIpEntry, updateIpEntry, deleteIpEntry, + updatePhpipamEntry, deletePhpipamEntry, + deleteTailscaleDevice, setTailscaleAuthorized, +} from '../api/dns'; import ConfirmDialog from './ConfirmDialog'; import { exportCsv } from '../utils/exportCsv'; import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors'; function isIPv6(address) { - return address.includes(':'); + return address && address.includes(':'); } -function IpForm({ initial, onSave, onCancel }) { +// ─── Source badge ───────────────────────────────────────────────────────────── + +const SOURCE_COLORS = { + local: { bg: 'var(--accent)', text: '#fff' }, + phpipam: { bg: '#e67e22', text: '#fff' }, + tailscale: { bg: '#4a90e2', text: '#fff' }, +}; + +function SourceBadge({ source, kind }) { + const c = SOURCE_COLORS[source] ?? SOURCE_COLORS.local; + const label = source === 'local' ? 'Local' + : source === 'phpipam' ? (kind === 'device' ? 'phpIPAM device' : 'phpIPAM') + : 'Tailscale'; + return ( + + {label} + + ); +} + +// ─── Edit / Add form ────────────────────────────────────────────────────────── + +function IpForm({ initial, onSave, onCancel, subnets }) { + const isExternal = initial && initial.source !== 'local'; const [form, setForm] = useState({ - address: initial?.address ?? '', - label: initial?.label ?? '', - vendor: initial?.vendor ?? '', - location: initial?.location ?? '', - notes: initial?.notes ?? '', + address: initial?.address ?? '', + label: initial?.label ?? '', + vendor: initial?.vendor ?? '', + location: initial?.location ?? '', + notes: initial?.notes ?? '', + subnet_id: initial?.subnet_id ?? '', }); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); @@ -34,27 +63,56 @@ function IpForm({ initial, onSave, onCancel }) { return (
e.stopPropagation()}> -

{initial ? 'Edit IP Address' : 'Add IP Address'}

+

+ {initial ? 'Edit' : 'Add'} IP Address + {initial && } +

-
-
-
+ )} + + {error &&

{error}

}
@@ -68,13 +126,18 @@ function IpForm({ initial, onSave, onCancel }) { ); } +// ─── DNS match badges ───────────────────────────────────────────────────────── + function DnsMatchBadges({ matches, colors }) { - if (!matches || matches.length === 0) return —; + if (!matches || matches.length === 0) + return —; return (
{matches.map((m, i) => ( - {m.provider} + + {m.provider} + {m.name} ))} @@ -82,99 +145,226 @@ function DnsMatchBadges({ matches, colors }) { ); } +// ─── Main page ──────────────────────────────────────────────────────────────── + export default function IpamPage() { const { colors } = useProviderColors(); - const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(true); - const [showForm, setShowForm] = useState(false); - const [editing, setEditing] = useState(null); - const [confirmDel, setConfirmDel] = useState(null); - const [filter, setFilter] = useState(''); - const [error, setError] = useState(''); + const [entries, setEntries] = useState([]); + const [syncedAt, setSyncedAt] = useState(null); + const [sources, setSources] = useState({ phpipam: false, tailscale: false }); + const [subnets, setSubnets] = useState([]); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [showForm, setShowForm] = useState(false); + const [editing, setEditing] = useState(null); + const [confirmDel, setConfirmDel] = useState(null); + const [filter, setFilter] = useState(''); + const [sourceFilter, setSourceFilter] = useState('all'); + const [error, setError] = useState(''); - function load() { - setLoading(true); - getIpam() - .then(setEntries) - .catch(e => setError(e.message)) - .finally(() => setLoading(false)); + function mergeEntries(data) { + setEntries(data.entries ?? []); + if (data.synced_at) setSyncedAt(data.synced_at); } - useEffect(() => { load(); }, []); + async function load() { + setLoading(true); + try { + const [data, src] = await Promise.all([getIpam(), getIpamSources()]); + mergeEntries(data); + setSources(src); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + } + + useEffect(() => { load(); }, []); // eslint-disable-line + + async function handleSync() { + setSyncing(true); setError(''); + try { + const data = await syncIpam(); + mergeEntries(data); + if (Object.keys(data.results?.errors ?? {}).length) { + const errs = Object.entries(data.results.errors).map(([k, v]) => `${k}: ${v}`).join('; '); + setError(`Sync completed with errors — ${errs}`); + } + } catch (e) { + setError(e.message); + } finally { + setSyncing(false); + } + } + + // ── Save (local or phpIPAM write-back) ────────────────────────────────────── async function handleSave(data) { if (editing) { - const updated = await updateIpEntry(editing.id, data); - setEntries(e => e.map(x => x.id === editing.id ? updated : x)); + if (editing.source === 'phpipam') { + await updatePhpipamEntry(editing.external_id, data); + // Refresh entries from server + const fresh = await getIpam(); + mergeEntries(fresh); + } else { + const updated = await updateIpEntry(editing.id, data); + setEntries(e => e.map(x => x.id === editing.id ? { ...updated, source: 'local' } : x)); + } } else { - const created = await createIpEntry(data); - setEntries(e => [...e, created]); + // If a subnet_id was chosen, also create in phpIPAM + if (data.subnet_id && sources.phpipam) { + // fire both in parallel — create locally and re-sync phpIPAM cache + const [created] = await Promise.all([ + createIpEntry({ ...data, source: 'local' }), + import('../api/dns').then(m => m.syncIpam(['phpipam'])).catch(() => null), + ]); + setEntries(e => [...e, { ...created, source: 'local' }]); + } else { + const created = await createIpEntry({ ...data, source: 'local' }); + setEntries(e => [...e, { ...created, source: 'local' }]); + } } setShowForm(false); setEditing(null); } + // ── Delete ─────────────────────────────────────────────────────────────────── + async function handleDelete() { - await deleteIpEntry(confirmDel.id); - setEntries(e => e.filter(x => x.id !== confirmDel.id)); + const e = confirmDel; setConfirmDel(null); + try { + if (e.source === 'phpipam') { + await deletePhpipamEntry(e.external_id); + setEntries(prev => prev.filter(x => !(x.source === 'phpipam' && x.external_id === e.external_id))); + } else if (e.source === 'tailscale') { + await deleteTailscaleDevice(e.external_id); + setEntries(prev => prev.filter(x => !(x.source === 'tailscale' && x.external_id === e.external_id))); + } else { + await deleteIpEntry(e.id); + setEntries(prev => prev.filter(x => x.id !== e.id)); + } + } catch (err) { + setError(err.message); + } } + // ── Tailscale authorize toggle ──────────────────────────────────────────────── + + async function handleAuthorize(entry, authorized) { + try { + await setTailscaleAuthorized(entry.external_id, authorized); + setEntries(prev => prev.map(e => + e.source === 'tailscale' && e.external_id === entry.external_id + ? { ...e, authorized } + : e + )); + } catch (err) { + setError(err.message); + } + } + + // ── Filtering ──────────────────────────────────────────────────────────────── + const filtered = entries.filter(e => { + if (sourceFilter !== 'all' && e.source !== sourceFilter) return false; if (!filter) return true; const q = filter.toLowerCase(); return ( - e.address.includes(q) || + (e.address || '').includes(q) || (e.label || '').toLowerCase().includes(q) || (e.vendor || '').toLowerCase().includes(q) || (e.location || '').toLowerCase().includes(q) ); }); + const hasExternal = sources.phpipam || sources.tailscale; const v4count = entries.filter(e => !isIPv6(e.address)).length; const v6count = entries.filter(e => isIPv6(e.address)).length; + function formatSynced(iso) { + if (!iso) return null; + const d = new Date(iso); + return `${d.toLocaleDateString('sv-SE')} ${d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' })}`; + } + return (
+ {/* Header */}

IP Addresses

- {entries.length} address{entries.length !== 1 ? 'es' : ''} stored + {entries.length} address{entries.length !== 1 ? 'es' : ''} {entries.length > 0 && ` · ${v4count} IPv4 · ${v6count} IPv6`} + {syncedAt && + Last synced: {formatSynced(syncedAt)} + }

- +
+ {hasExternal && ( + + )} + +
- {error &&
Error: {error}
} + {error &&
Error: {error}
} + {/* Toolbar */}
- setFilter(e.target.value)} /> + setFilter(e.target.value)} /> + + {hasExternal && ( + + )} + {filtered.length} entries
+ {/* Table */} {loading ?

Loading…

: filtered.length === 0 ? (
-

{entries.length === 0 ? 'No IP addresses stored yet — press + Add IP to get started.' : 'No entries match your filter.'}

+

{entries.length === 0 + ? 'No IP addresses yet — press + Add IP or ⟳ Sync External to get started.' + : 'No entries match your filter.' + }

) : (
+ {hasExternal && } @@ -184,8 +374,17 @@ export default function IpamPage() { - {filtered.map(e => ( - + {filtered.map((e, i) => ( + + {hasExternal && ( + + )} @@ -195,8 +394,21 @@ export default function IpamPage() { ))} @@ -208,6 +420,7 @@ export default function IpamPage() { {showForm && ( { setShowForm(false); setEditing(null); }} /> @@ -215,8 +428,12 @@ export default function IpamPage() { {confirmDel && (
SourceIP Address Label Vendor
+ + {e.is_exit_node && Exit} + {e.source === 'tailscale' && e.authorized === false && ( + Pending + )} + {e.address} {e.notes && ℹ} - - + + {/* Tailscale: authorize toggle */} + {e.source === 'tailscale' && e.authorized === false && ( + + )} + + {/* Edit — not for Tailscale (IPs are read-only) */} + {e.source !== 'tailscale' && ( + + )} + +