added phpipam and tailscale integration
This commit is contained in:
1 parent
812c8e5016
commit
b5a64fdeac
12 files changed
+846
-66
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
Reference in new issue
Block a user