added phpipam and tailscale integration

This commit is contained in:
bobban committed 2026-09-14 21:05:04 +02:00
1 parent 812c8e5016
commit b5a64fdeac
12 files changed
+846 -66

No files matched your search

+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 };
+138 -6
View File
@@ -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;