feat: Guest WiFi, network intelligence, firmware orchestration, platform automation, api-ssl (v0.16.3-beta)
Guest WiFi (Hotspot): one-click captive-portal wizard that creates the guest SSID (virtual AP per radio), VLAN/bridge segregation + L3 VLAN interface, pool, DHCP, portal, rate-limited guest profile, and masquerade; vouchers with limits + printable sheets; live guest sessions; walled garden. Network intelligence: per-client device fingerprinting (vendor/hostname → category) with a persisted per-client override on the detail page; rogue/neighbor AP detection from stored AP scans (evil-twin flagging); Mist-style anomaly insights (client-count/CPU vs same-hour 14-day baseline + error bursts) in the Operations engine. Firmware orchestration: staged wave-based RouterOS rollouts (canary first) with pre-upgrade backup, reboot-ride-out, version verification, halt-on-failure, and scheduling; live per-device progress + cancel. Platform & automation: scoped API tokens (mtm_, read/write, hashed at rest, Bearer auth in requireAuth); outbound webhooks (12 events, HMAC-signed) fired from the alert pipeline + firmware orchestrator; scheduled email reports. api-ssl: RouterOSClient negotiates TLS on port 8729 and accepts RouterOS's default self-signed certs (rejectUnauthorized:false), so SSL-secured API devices connect without manual cert trust. Fixes #57. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
efb430d934
commit
9dfb8a5333
@@ -1580,6 +1580,10 @@ export class DeviceCollector {
|
||||
return this.client.execute('/interface/bridge/print').catch(() => []);
|
||||
}
|
||||
|
||||
async getInterfacesLive(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/interface/print').catch(() => []);
|
||||
}
|
||||
|
||||
async getBridgePorts(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/interface/bridge/port/print').catch(() => []);
|
||||
}
|
||||
@@ -1935,6 +1939,318 @@ export class DeviceCollector {
|
||||
await this.client.execute('/queue/simple/remove', { '.id': id });
|
||||
}
|
||||
|
||||
// ─── Hotspot (Guest WiFi captive portal + vouchers) ─────────────────────────
|
||||
|
||||
async getHotspotServers(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/hotspot/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async setHotspotServerDisabled(id: string, disabled: boolean): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/set', { '.id': id, disabled: disabled ? 'yes' : 'no' });
|
||||
}
|
||||
|
||||
async removeHotspotServer(id: string): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/remove', { '.id': id });
|
||||
}
|
||||
|
||||
async getHotspotProfiles(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/hotspot/profile/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async addHotspotProfile(params: Record<string, string>): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/profile/add', params);
|
||||
}
|
||||
|
||||
async getHotspotUserProfiles(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/hotspot/user/profile/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async addHotspotUserProfile(params: Record<string, string>): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/user/profile/add', params);
|
||||
}
|
||||
|
||||
async getHotspotUsers(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/hotspot/user/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async addHotspotUser(params: Record<string, string>): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/user/add', params);
|
||||
}
|
||||
|
||||
async removeHotspotUser(id: string): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/user/remove', { '.id': id });
|
||||
}
|
||||
|
||||
async getHotspotActive(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/hotspot/active/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async disconnectHotspotActive(id: string): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/active/remove', { '.id': id });
|
||||
}
|
||||
|
||||
async getWalledGarden(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/hotspot/walled-garden/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async addWalledGardenEntry(dstHost: string, comment?: string): Promise<void> {
|
||||
const params: Record<string, string> = { 'dst-host': dstHost, action: 'allow' };
|
||||
if (comment) params['comment'] = comment;
|
||||
await this.client.execute('/ip/hotspot/walled-garden/add', params);
|
||||
}
|
||||
|
||||
async removeWalledGardenEntry(id: string): Promise<void> {
|
||||
await this.client.execute('/ip/hotspot/walled-garden/remove', { '.id': id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Full guest-network orchestration. Optionally creates the guest SSID itself
|
||||
* (a virtual AP on every physical radio) and segregates the traffic onto a
|
||||
* VLAN, then runs the captive-portal chain on the resulting interface:
|
||||
*
|
||||
* [virtual APs] → [bridge/VLAN wiring] → pool → gw address → DHCP →
|
||||
* hotspot profile → portal server → guest user profile → [masquerade NAT]
|
||||
*
|
||||
* Topologies:
|
||||
* - ssid + vlanId: new SSIDs joined to the main bridge with PVID=vlanId,
|
||||
* portal binds to a new /interface/vlan on that bridge.
|
||||
* - ssid only: new SSIDs joined to a dedicated guest bridge, portal
|
||||
* binds to that bridge (guests stay off the main LAN).
|
||||
* - neither: portal binds to the provided existing interface.
|
||||
*/
|
||||
async setupGuestNetwork(opts: {
|
||||
name: string;
|
||||
gatewayCidr: string;
|
||||
poolRange: string;
|
||||
dnsName?: string;
|
||||
rateLimit?: string;
|
||||
interfaceName?: string; // existing-interface path
|
||||
ssid?: { ssid: string; passphrase?: string };
|
||||
vlanId?: number;
|
||||
masquerade?: boolean;
|
||||
}): Promise<{
|
||||
server: string; profile: string; userProfile: string; pool: string;
|
||||
targetInterface: string; ssidInterfaces: string[]; vlanInterface?: string;
|
||||
warnings: string[];
|
||||
}> {
|
||||
const { name, ssid, vlanId, masquerade } = opts;
|
||||
const warnings: string[] = [];
|
||||
const ssidInterfaces: string[] = [];
|
||||
let targetInterface = opts.interfaceName || '';
|
||||
let vlanInterface: string | undefined;
|
||||
|
||||
if (ssid) {
|
||||
// 1. Create a virtual AP for the guest SSID on every physical radio
|
||||
const pkg = await this.detectWifiPackage();
|
||||
if (pkg === 'none') throw new Error('No wireless package detected on this device — cannot create a guest SSID');
|
||||
const raw = pkg === 'wifi'
|
||||
? await this.client.execute('/interface/wifi/print').catch(() => [] as Record<string, string>[])
|
||||
: await this.client.execute('/interface/wireless/print').catch(() => [] as Record<string, string>[]);
|
||||
const physicals = raw.filter(r => !r['master-interface'] && r['name']);
|
||||
if (physicals.length === 0) throw new Error('No physical radios found to attach the guest SSID to');
|
||||
|
||||
const existingBySsid = raw.filter(r =>
|
||||
(r['ssid'] || r['configuration.ssid'] || '') === ssid.ssid && r['master-interface']);
|
||||
if (existingBySsid.length > 0) {
|
||||
// Idempotency: SSID already exists — reuse it
|
||||
for (const e of existingBySsid) ssidInterfaces.push(e['name']);
|
||||
} else {
|
||||
for (const radio of physicals) {
|
||||
const ifaceName = await this.getNextInterfaceName();
|
||||
const params: Record<string, string> = {
|
||||
name: ifaceName,
|
||||
'master-interface': radio['name'],
|
||||
ssid: ssid.ssid,
|
||||
disabled: 'no',
|
||||
};
|
||||
if (pkg !== 'wifi') params['mode'] = 'ap-bridge';
|
||||
if (ssid.passphrase) {
|
||||
params['passphrase'] = ssid.passphrase;
|
||||
params['authentication-types'] = 'wpa2-psk';
|
||||
}
|
||||
await this.addWirelessInterface(params);
|
||||
ssidInterfaces.push(ifaceName);
|
||||
}
|
||||
if (pkg !== 'wifi' && ssid.passphrase) {
|
||||
warnings.push('Legacy wireless package: passphrase requires a security profile — the guest SSID was created OPEN. Assign a security profile manually if you need WPA2.');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Wire the new SSIDs into the L2 topology
|
||||
const bridges = await this.getBridges();
|
||||
if (vlanId) {
|
||||
const mainBridge = bridges[0]?.['name'];
|
||||
if (!mainBridge) throw new Error('No bridge found on this device to attach the guest VLAN to');
|
||||
for (const iface of ssidInterfaces) {
|
||||
await this.setInterfaceBridge(iface, mainBridge, vlanId);
|
||||
await this.ensureVlanMembership(iface, vlanId);
|
||||
}
|
||||
// The bridge itself must be a tagged member so the L3 VLAN interface works
|
||||
await this.ensureBridgeTaggedMember(mainBridge, vlanId);
|
||||
// L3 VLAN interface the portal binds to
|
||||
vlanInterface = `${name}-vlan${vlanId}`;
|
||||
const vlans = await this.client.execute('/interface/vlan/print').catch(() => [] as Record<string, string>[]);
|
||||
const existing = vlans.find(v => v['interface'] === mainBridge && v['vlan-id'] === String(vlanId));
|
||||
if (existing) {
|
||||
vlanInterface = existing['name'];
|
||||
} else {
|
||||
await this.client.execute('/interface/vlan/add', {
|
||||
name: vlanInterface, 'vlan-id': String(vlanId), interface: mainBridge,
|
||||
});
|
||||
}
|
||||
targetInterface = vlanInterface;
|
||||
if (bridges[0]?.['vlan-filtering'] !== 'true') {
|
||||
warnings.push(`Bridge "${mainBridge}" has VLAN filtering disabled — guest VLAN ${vlanId} tagging won't isolate traffic until you enable it (Ports → bridge → VLAN filtering). Enabling it can interrupt management access, so review trunk/tagged config first.`);
|
||||
}
|
||||
} else {
|
||||
// Dedicated guest bridge keeps guests off the main LAN
|
||||
const guestBridge = `${name}-br`;
|
||||
if (!bridges.some(b => b['name'] === guestBridge)) {
|
||||
await this.client.execute('/interface/bridge/add', { name: guestBridge });
|
||||
}
|
||||
for (const iface of ssidInterfaces) {
|
||||
await this.setInterfaceBridge(iface, guestBridge);
|
||||
}
|
||||
targetInterface = guestBridge;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetInterface) throw new Error('No target interface — provide interfaceName or ssid');
|
||||
|
||||
// 3. Captive-portal chain on the target interface
|
||||
const chain = await this.setupHotspot({
|
||||
name, interfaceName: targetInterface,
|
||||
gatewayCidr: opts.gatewayCidr, poolRange: opts.poolRange,
|
||||
dnsName: opts.dnsName, rateLimit: opts.rateLimit,
|
||||
});
|
||||
|
||||
// 4. Masquerade so guests can reach the internet
|
||||
if (masquerade) {
|
||||
const gwIp = opts.gatewayCidr.split('/')[0];
|
||||
const prefix = opts.gatewayCidr.split('/')[1] || '24';
|
||||
const network = `${this.networkAddressOf(gwIp, parseInt(prefix, 10))}/${prefix}`;
|
||||
const natComment = `${name}-guest-masquerade`;
|
||||
const natRules = await this.client.execute('/ip/firewall/nat/print').catch(() => [] as Record<string, string>[]);
|
||||
if (!natRules.some(r => (r['comment'] || '') === natComment)) {
|
||||
await this.client.execute('/ip/firewall/nat/add', {
|
||||
chain: 'srcnat', action: 'masquerade', 'src-address': network, comment: natComment,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ...chain, targetInterface, ssidInterfaces, vlanInterface, warnings };
|
||||
}
|
||||
|
||||
/** Ensure the bridge itself is a tagged member of a VLAN (needed for L3 VLAN interfaces). */
|
||||
private async ensureBridgeTaggedMember(bridge: string, vlanId: number): Promise<void> {
|
||||
const rows = await this.client
|
||||
.execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`])
|
||||
.catch(() => [] as Record<string, string>[]);
|
||||
const entry = rows[0];
|
||||
if (entry?.['.id']) {
|
||||
const tagged = (entry['tagged'] || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (!tagged.includes(bridge)) {
|
||||
await this.client.execute('/interface/bridge/vlan/set', {
|
||||
'.id': entry['.id'], tagged: [...tagged, bridge].join(','),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this.client.execute('/interface/bridge/vlan/add', {
|
||||
bridge, 'vlan-ids': String(vlanId), tagged: bridge,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrated guest-hotspot setup — the API equivalent of RouterOS's
|
||||
* interactive `/ip hotspot setup` wizard. Creates (idempotently, keyed on the
|
||||
* guest network name): IP pool → gateway address on the interface → DHCP
|
||||
* server + network → hotspot profile → hotspot server → guest user profile.
|
||||
* Returns the names it created so the route can report/rollback.
|
||||
*/
|
||||
async setupHotspot(opts: {
|
||||
name: string; // logical name, e.g. "guest" → pool/profile/server names derive from it
|
||||
interfaceName: string; // bridge/vlan/wifi interface to run the portal on
|
||||
gatewayCidr: string; // e.g. "10.5.50.1/24" — address added to the interface
|
||||
poolRange: string; // e.g. "10.5.50.10-10.5.50.254"
|
||||
dnsName?: string; // portal hostname, e.g. "wifi.guest"
|
||||
rateLimit?: string; // default guest speed, e.g. "10M/10M" (rx/tx)
|
||||
}): Promise<{ server: string; profile: string; userProfile: string; pool: string }> {
|
||||
const { name, interfaceName, gatewayCidr, poolRange, dnsName, rateLimit } = opts;
|
||||
const poolName = `${name}-pool`;
|
||||
const profileName = `${name}-profile`;
|
||||
const userProfileName = `${name}-guest`;
|
||||
const gwIp = gatewayCidr.split('/')[0];
|
||||
|
||||
// 1. IP pool
|
||||
const pools = await this.client.execute('/ip/pool/print').catch(() => []);
|
||||
if (!pools.some(p => p['name'] === poolName)) {
|
||||
await this.client.execute('/ip/pool/add', { name: poolName, ranges: poolRange });
|
||||
}
|
||||
|
||||
// 2. Gateway address on the interface
|
||||
const addrs = await this.client.execute('/ip/address/print').catch(() => []);
|
||||
if (!addrs.some(a => (a['address'] || '') === gatewayCidr && (a['interface'] || '') === interfaceName)) {
|
||||
await this.client.execute('/ip/address/add', { address: gatewayCidr, interface: interfaceName });
|
||||
}
|
||||
|
||||
// 3. DHCP server + network
|
||||
const dhcpServers = await this.client.execute('/ip/dhcp-server/print').catch(() => []);
|
||||
if (!dhcpServers.some(s => s['name'] === `${name}-dhcp`)) {
|
||||
await this.client.execute('/ip/dhcp-server/add', {
|
||||
name: `${name}-dhcp`, interface: interfaceName, 'address-pool': poolName, disabled: 'no',
|
||||
});
|
||||
}
|
||||
const prefix = gatewayCidr.split('/')[1] || '24';
|
||||
const network = this.networkAddressOf(gwIp, parseInt(prefix, 10));
|
||||
const dhcpNets = await this.client.execute('/ip/dhcp-server/network/print').catch(() => []);
|
||||
if (!dhcpNets.some(n => (n['address'] || '') === `${network}/${prefix}`)) {
|
||||
await this.client.execute('/ip/dhcp-server/network/add', {
|
||||
address: `${network}/${prefix}`, gateway: gwIp, 'dns-server': gwIp,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Hotspot profile
|
||||
const profiles = await this.getHotspotProfiles();
|
||||
if (!profiles.some(p => p['name'] === profileName)) {
|
||||
const params: Record<string, string> = {
|
||||
name: profileName, 'hotspot-address': gwIp, 'login-by': 'http-chap,http-pap',
|
||||
};
|
||||
if (dnsName) params['dns-name'] = dnsName;
|
||||
await this.addHotspotProfile(params);
|
||||
}
|
||||
|
||||
// 5. Hotspot server
|
||||
const servers = await this.getHotspotServers();
|
||||
if (!servers.some(s => s['name'] === name)) {
|
||||
await this.client.execute('/ip/hotspot/add', {
|
||||
name, interface: interfaceName, 'address-pool': poolName, profile: profileName, disabled: 'no',
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Guest user profile (bandwidth cap + no inter-guest traffic)
|
||||
const userProfiles = await this.getHotspotUserProfiles();
|
||||
if (!userProfiles.some(p => p['name'] === userProfileName)) {
|
||||
const params: Record<string, string> = {
|
||||
name: userProfileName, 'shared-users': '1',
|
||||
};
|
||||
if (rateLimit) params['rate-limit'] = rateLimit;
|
||||
await this.addHotspotUserProfile(params);
|
||||
}
|
||||
|
||||
return { server: name, profile: profileName, userProfile: userProfileName, pool: poolName };
|
||||
}
|
||||
|
||||
/** Compute the network address for an IPv4 + prefix (e.g. 10.5.50.1/24 → 10.5.50.0). */
|
||||
private networkAddressOf(ip: string, prefix: number): string {
|
||||
const parts = ip.split('.').map(n => parseInt(n, 10));
|
||||
if (parts.length !== 4 || parts.some(isNaN)) return ip;
|
||||
const addr = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
||||
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
|
||||
const net = (addr & mask) >>> 0;
|
||||
return [net >>> 24, (net >>> 16) & 255, (net >>> 8) & 255, net & 255].join('.');
|
||||
}
|
||||
|
||||
// ─── IP Services (for security-posture audit + hardening) ───────────────────
|
||||
|
||||
async getServices(): Promise<Record<string, string>[]> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as net from 'net';
|
||||
import * as tls from 'tls';
|
||||
import * as crypto from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
@@ -34,15 +35,21 @@ export class RouterOSClient extends EventEmitter {
|
||||
|
||||
private tagCounter = 0;
|
||||
|
||||
private readonly useTls: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly host: string,
|
||||
private readonly port: number = 8728,
|
||||
private readonly username: string,
|
||||
private readonly password: string,
|
||||
private readonly connectTimeoutMs: number = 15000,
|
||||
private readonly readTimeoutMs: number = 30000
|
||||
private readonly readTimeoutMs: number = 30000,
|
||||
useTls?: boolean
|
||||
) {
|
||||
super();
|
||||
// api-ssl runs on 8729 by RouterOS convention (the same heuristic the
|
||||
// security-posture code uses); callers can override explicitly.
|
||||
this.useTls = useTls ?? port === 8729;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
@@ -54,9 +61,7 @@ export class RouterOSClient extends EventEmitter {
|
||||
this.socket?.destroy();
|
||||
}, this.connectTimeoutMs);
|
||||
|
||||
this.socket = net.createConnection({ host: this.host, port: this.port });
|
||||
|
||||
this.socket.on('connect', async () => {
|
||||
const onReady = async () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
await this.authenticate();
|
||||
@@ -67,7 +72,21 @@ export class RouterOSClient extends EventEmitter {
|
||||
this.socket?.destroy();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (this.useTls) {
|
||||
// api-ssl: RouterOS devices ship self-signed certificates by default,
|
||||
// so certificate verification is intentionally disabled — the same
|
||||
// trust model as the plaintext API this replaces, but with the
|
||||
// credentials and session encrypted in transit.
|
||||
this.socket = tls.connect(
|
||||
{ host: this.host, port: this.port, rejectUnauthorized: false },
|
||||
() => { void onReady(); }
|
||||
);
|
||||
} else {
|
||||
this.socket = net.createConnection({ host: this.host, port: this.port });
|
||||
this.socket.on('connect', () => { void onReady(); });
|
||||
}
|
||||
|
||||
this.socket.on('data', (data: Buffer) => {
|
||||
this.buffer = Buffer.concat([this.buffer, data]);
|
||||
|
||||
Reference in New Issue
Block a user