added phpipam and tailscale integration

This commit is contained in:
2026-09-14 21:05:04 +02:00
parent 812c8e5016
commit b5a64fdeac
12 changed files with 846 additions and 66 deletions
+50
View File
@@ -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/<tailnet>`), 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
+27
View File
@@ -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:
+9
View File
@@ -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
+148
View File
@@ -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,
};
+87
View File
@@ -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,
};
+37
View File
@@ -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 };
+134 -2
View File
@@ -1,18 +1,74 @@
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;
+26
View File
@@ -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
+2 -2
View File
@@ -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:
+10
View File
@@ -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;
+37
View File
@@ -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() },
+261 -44
View File
@@ -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 (
<span className="source-badge" style={{ background: c.bg, color: c.text }}>
{label}
</span>
);
}
// ─── 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 ?? '',
subnet_id: initial?.subnet_id ?? '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
@@ -34,27 +63,56 @@ function IpForm({ initial, onSave, onCancel }) {
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h3>{initial ? 'Edit IP Address' : 'Add IP Address'}</h3>
<h3>
{initial ? 'Edit' : 'Add'} IP Address
{initial && <SourceBadge source={initial.source} kind={initial.kind} />}
</h3>
<form onSubmit={handleSubmit} className="record-form">
<div className="form-row">
<label>IP Address
<input name="address" value={form.address} onChange={handleChange} placeholder="1.2.3.4 or 2001:db8::1" required autoFocus disabled={!!initial} />
<input name="address" value={form.address} onChange={handleChange}
placeholder="1.2.3.4 or 2001:db8::1" required autoFocus
disabled={!!initial} />
</label>
<label>Label
<input name="label" value={form.label} onChange={handleChange} placeholder="e.g. Web Server 1" />
<label>Label / Hostname
<input name="label" value={form.label} onChange={handleChange}
placeholder="e.g. Web Server 1" />
</label>
</div>
{/* Local + phpIPAM device only */}
{(!isExternal || (initial?.source === 'phpipam' && initial?.kind === 'device')) && (
<div className="form-row">
<label>Vendor / Provider
<input name="vendor" value={form.vendor} onChange={handleChange} placeholder="e.g. Hetzner, DigitalOcean" />
<label>Vendor
<input name="vendor" value={form.vendor} onChange={handleChange}
placeholder="e.g. Cisco, Dell" />
</label>
<label>Location / Region
<input name="location" value={form.location} onChange={handleChange} placeholder="e.g. Frankfurt, US-East" />
<label>Location
<input name="location" value={form.location} onChange={handleChange}
placeholder="e.g. Rack A, Floor 2" />
</label>
</div>
<label>Notes
<input name="notes" value={form.notes} onChange={handleChange} placeholder="Optional notes" />
)}
{/* phpIPAM address — subnet selector */}
{!initial && subnets.length > 0 && (
<label>phpIPAM Subnet (optional)
<select name="subnet_id" value={form.subnet_id} onChange={handleChange}>
<option value="">— Local only —</option>
{subnets.map(s => (
<option key={s.id} value={s.id}>
{s.subnet}/{s.mask}{s.description ? ` — ${s.description}` : ''}
</option>
))}
</select>
</label>
)}
<label>Notes
<input name="notes" value={form.notes} onChange={handleChange}
placeholder="Optional notes" />
</label>
{error && <p className="error">{error}</p>}
<div className="form-actions">
<button type="button" className="btn-secondary" onClick={onCancel} disabled={saving}>Cancel</button>
@@ -68,13 +126,18 @@ function IpForm({ initial, onSave, onCancel }) {
);
}
// ─── DNS match badges ─────────────────────────────────────────────────────────
function DnsMatchBadges({ matches, colors }) {
if (!matches || matches.length === 0) return <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>—</span>;
if (!matches || matches.length === 0)
return <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>—</span>;
return (
<div className="ipam-dns-matches">
{matches.map((m, i) => (
<span key={i} className="ipam-dns-match" title={`${m.type} ${m.name}`}>
<span className="badge" style={{ ...providerBadgeStyle(colors, m.provider), fontSize: 10, padding: '1px 5px' }}>{m.provider}</span>
<span className="badge" style={{ ...providerBadgeStyle(colors, m.provider), fontSize: 10, padding: '1px 5px' }}>
{m.provider}
</span>
<span className="ipam-dns-name">{m.name}</span>
</span>
))}
@@ -82,99 +145,226 @@ function DnsMatchBadges({ matches, colors }) {
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function IpamPage() {
const { colors } = useProviderColors();
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 created = await createIpEntry(data);
setEntries(e => [...e, created]);
const updated = await updateIpEntry(editing.id, data);
setEntries(e => e.map(x => x.id === editing.id ? { ...updated, source: 'local' } : x));
}
} else {
// 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 (
<div className="dashboard">
{/* Header */}
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h2>IP Addresses</h2>
<p className="dashboard-hint">
{entries.length} address{entries.length !== 1 ? 'es' : ''} stored
{entries.length} address{entries.length !== 1 ? 'es' : ''}
{entries.length > 0 && ` · ${v4count} IPv4 · ${v6count} IPv6`}
{syncedAt && <span style={{ marginLeft: 8, color: 'var(--text-muted)', fontSize: 12 }}>
Last synced: {formatSynced(syncedAt)}
</span>}
</p>
</div>
<button className="btn-primary" onClick={() => { setEditing(null); setShowForm(true); }}>+ Add IP</button>
<div style={{ display: 'flex', gap: 8 }}>
{hasExternal && (
<button className="btn-sync" onClick={handleSync} disabled={syncing}>
{syncing ? 'Syncing…' : '⟳ Sync External'}
</button>
)}
<button className="btn-primary" onClick={() => { setEditing(null); setShowForm(true); }}>
+ Add IP
</button>
</div>
</div>
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}<button className="dismiss" onClick={() => setError('')}>✕</button></div>}
{/* Toolbar */}
<div className="records-actions" style={{ marginBottom: 16 }}>
<input className="filter-input" placeholder="Filter by IP, label, vendor…" value={filter} onChange={e => setFilter(e.target.value)} />
<input className="filter-input" placeholder="Filter by IP, label, vendor…"
value={filter} onChange={e => setFilter(e.target.value)} />
{hasExternal && (
<select
className="filter-input"
style={{ width: 'auto' }}
value={sourceFilter}
onChange={e => setSourceFilter(e.target.value)}
>
<option value="all">All sources</option>
<option value="local">Local</option>
{sources.phpipam && <option value="phpipam">phpIPAM</option>}
{sources.tailscale && <option value="tailscale">Tailscale</option>}
</select>
)}
<button className="btn-export" onClick={() =>
exportCsv(
filtered.map(e => ({
address: e.address, label: e.label, vendor: e.vendor,
location: e.location, notes: e.notes,
source: e.source, address: e.address, label: e.label,
vendor: e.vendor, location: e.location, notes: e.notes,
dns_matches: (e.dnsMatches || []).map(m => `${m.name} (${m.provider})`).join('; '),
})),
['address','label','vendor','location','notes','dns_matches'],
{ address:'IP Address', label:'Label', vendor:'Vendor', location:'Location', notes:'Notes', dns_matches:'DNS Records' },
['source','address','label','vendor','location','notes','dns_matches'],
{ source:'Source', address:'IP Address', label:'Label', vendor:'Vendor', location:'Location', notes:'Notes', dns_matches:'DNS Records' },
'ip-addresses.csv'
)
}>⬇ Export CSV</button>
<span className="record-count">{filtered.length} entries</span>
</div>
{/* Table */}
{loading ? <p className="hint">Loading…</p> : filtered.length === 0 ? (
<div className="empty-state">
<p>{entries.length === 0 ? 'No IP addresses stored yet — press + Add IP to get started.' : 'No entries match your filter.'}</p>
<p>{entries.length === 0
? 'No IP addresses yet — press + Add IP or ⟳ Sync External to get started.'
: 'No entries match your filter.'
}</p>
</div>
) : (
<div className="table-wrapper">
<table className="records-table">
<thead>
<tr>
{hasExternal && <th>Source</th>}
<th>IP Address</th>
<th>Label</th>
<th>Vendor</th>
@@ -184,8 +374,17 @@ export default function IpamPage() {
</tr>
</thead>
<tbody>
{filtered.map(e => (
<tr key={e.id}>
{filtered.map((e, i) => (
<tr key={e.id ?? `${e.source}-${e.external_id}-${i}`}>
{hasExternal && (
<td>
<SourceBadge source={e.source} kind={e.kind} />
{e.is_exit_node && <span className="source-badge" style={{ background: '#27ae60', color: '#fff', marginLeft: 4 }}>Exit</span>}
{e.source === 'tailscale' && e.authorized === false && (
<span className="source-badge" style={{ background: '#e74c3c', color: '#fff', marginLeft: 4 }}>Pending</span>
)}
</td>
)}
<td>
<span className={`ipam-address ${isIPv6(e.address) ? 'ipam-v6' : 'ipam-v4'}`}>{e.address}</span>
</td>
@@ -195,8 +394,21 @@ export default function IpamPage() {
<td><DnsMatchBadges matches={e.dnsMatches} colors={colors} /></td>
<td className="actions-cell">
{e.notes && <span className="secret-notes" title={e.notes}>ℹ</span>}
<button className="btn-edit" onClick={() => { setEditing(e); setShowForm(true); }} title="Edit">✎</button>
<button className="btn-delete" onClick={() => setConfirmDel(e)} title="Delete">✕</button>
{/* Tailscale: authorize toggle */}
{e.source === 'tailscale' && e.authorized === false && (
<button className="btn-edit" title="Authorize device"
onClick={() => handleAuthorize(e, true)}>✓</button>
)}
{/* Edit — not for Tailscale (IPs are read-only) */}
{e.source !== 'tailscale' && (
<button className="btn-edit" title="Edit"
onClick={() => { setEditing(e); setShowForm(true); }}>✎</button>
)}
<button className="btn-delete" title="Delete"
onClick={() => setConfirmDel(e)}>✕</button>
</td>
</tr>
))}
@@ -208,6 +420,7 @@ export default function IpamPage() {
{showForm && (
<IpForm
initial={editing}
subnets={subnets}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditing(null); }}
/>
@@ -215,8 +428,12 @@ export default function IpamPage() {
{confirmDel && (
<ConfirmDialog
title="Delete IP Address"
message={`Remove ${confirmDel.address}${confirmDel.label ? ` (${confirmDel.label})` : ''}?`}
title={`Delete ${confirmDel.source === 'tailscale' ? 'Tailscale device' : 'IP Address'}`}
message={
confirmDel.source === 'tailscale'
? `Remove device "${confirmDel.label || confirmDel.address}" from the tailnet? This cannot be undone.`
: `Remove ${confirmDel.address}${confirmDel.label ? ` (${confirmDel.label})` : ''}?${confirmDel.source === 'phpipam' ? ' This will also delete it in phpIPAM.' : ''}`
}
confirmLabel="Delete"
danger
onConfirm={handleDelete}