homeio
PublicForked from doctor-io/homeio
network-service.mjs
import { randomUUID } from "node:crypto";
import { networkInterfaces } from "node:os";
import dbus from "dbus-next";
import {
CONNECT_RATE_LIMIT_PER_MINUTE,
DBUS_ACCESS_POINT_IFACE,
DBUS_DEVICE_IFACE,
DBUS_IFACE,
DBUS_PROPS_IFACE,
DBUS_SERVICE,
DBUS_SETTINGS_CONNECTION_IFACE,
DBUS_SETTINGS_IFACE,
DBUS_SETTINGS_PATH,
DBUS_WIRELESS_IFACE,
DEVICE_STATE_ACTIVATED,
DEVICE_TYPE_WIFI,
WIRED_DEVICE_TYPES,
DBUS_PATH,
} from "./config.mjs";
import { HelperServiceError } from "./errors.mjs";
import { withTimeout } from "./async-utils.mjs";
const { Variant } = dbus;
function decodeSsid(raw) {
if (!raw) return "";
if (Buffer.isBuffer(raw)) return raw.toString("utf8").replace(/\0/g, "").trim();
if (Array.isArray(raw)) {
return Buffer.from(raw).toString("utf8").replace(/\0/g, "").trim();
}
return typeof raw === "string" ? raw.trim() : "";
}
function toOptionalNumber(value) {
if (typeof value !== "number" || Number.isNaN(value)) return null;
return value;
}
function frequencyToChannel(frequencyMhz) {
if (typeof frequencyMhz !== "number" || frequencyMhz <= 0) return null;
if (frequencyMhz >= 2412 && frequencyMhz <= 2484) {
if (frequencyMhz === 2484) return 14;
return Math.round((frequencyMhz - 2407) / 5);
}
if (frequencyMhz >= 5000 && frequencyMhz <= 5900) {
return Math.round((frequencyMhz - 5000) / 5);
}
return null;
}
function dbusVariantValue(value) {
if (value && typeof value === "object" && "value" in value) {
return value.value;
}
return value;
}
function resolveInterfaceIpv4(iface) {
if (!iface) return null;
const interfaces = networkInterfaces();
const records = interfaces[iface];
if (!records || records.length === 0) return null;
const ipv4 = records.find(
(entry) => entry.family === "IPv4" || entry.family === 4,
);
return ipv4?.address ?? null;
}
function createNetworkService({ log }) {
const connectAttempts = new Map();
let bus = null;
async function getBus() {
if (!bus) {
bus = dbus.systemBus();
bus.connection.on("error", (error) => {
log({
level: "warn",
action: "dbus.network.bus.error",
status: "error",
message: "System DBus connection error",
error,
});
});
}
return bus;
}
async function getProxyObject(path) {
const systemBus = await getBus();
return systemBus.getProxyObject(DBUS_SERVICE, path);
}
async function getNmRoot() {
try {
const object = await getProxyObject(DBUS_PATH);
return {
object,
nm: object.getInterface(DBUS_IFACE),
props: object.getInterface(DBUS_PROPS_IFACE),
};
} catch (error) {
throw new HelperServiceError("NetworkManager is unavailable", {
code: "network_manager_unavailable",
statusCode: 503,
cause: error,
});
}
}
async function getProperty(propsInterface, iface, property) {
const variant = await propsInterface.Get(iface, property);
return dbusVariantValue(variant);
}
async function getWifiDevices() {
const { nm } = await getNmRoot();
const devicePaths = await withTimeout(nm.GetDevices());
const devices = [];
for (const path of devicePaths) {
const deviceObject = await getProxyObject(path);
const props = deviceObject.getInterface(DBUS_PROPS_IFACE);
const deviceType = Number(await getProperty(props, DBUS_DEVICE_IFACE, "DeviceType"));
if (deviceType !== DEVICE_TYPE_WIFI) continue;
const iface = String(await getProperty(props, DBUS_DEVICE_IFACE, "Interface"));
const state = Number(await getProperty(props, DBUS_DEVICE_IFACE, "State"));
devices.push({
path,
iface,
state,
object: deviceObject,
props,
device: deviceObject.getInterface(DBUS_DEVICE_IFACE),
wireless: deviceObject.getInterface(DBUS_WIRELESS_IFACE),
});
}
return devices;
}
async function getEthernetDevices() {
const { nm } = await getNmRoot();
const devicePaths = await withTimeout(nm.GetDevices());
const devices = [];
for (const path of devicePaths) {
const deviceObject = await getProxyObject(path);
const props = deviceObject.getInterface(DBUS_PROPS_IFACE);
const deviceType = Number(await getProperty(props, DBUS_DEVICE_IFACE, "DeviceType"));
// Accept physical Ethernet (1) and virtual Ethernet/VETH (20) used in LXC/containers
if (!WIRED_DEVICE_TYPES.has(deviceType)) continue;
const iface = String(await getProperty(props, DBUS_DEVICE_IFACE, "Interface"));
const state = Number(await getProperty(props, DBUS_DEVICE_IFACE, "State"));
devices.push({ path, iface, state });
}
return devices;
}
async function getAccessPointSnapshot(apPath) {
if (!apPath || apPath === "/") return null;
const apObject = await getProxyObject(apPath);
const props = apObject.getInterface(DBUS_PROPS_IFACE);
const ssid = decodeSsid(await getProperty(props, DBUS_ACCESS_POINT_IFACE, "Ssid"));
const bssid = String(await getProperty(props, DBUS_ACCESS_POINT_IFACE, "HwAddress") ?? "");
const strength = toOptionalNumber(
Number(await getProperty(props, DBUS_ACCESS_POINT_IFACE, "Strength")),
);
const frequency = toOptionalNumber(
Number(await getProperty(props, DBUS_ACCESS_POINT_IFACE, "Frequency")),
);
const wpaFlags = Number(await getProperty(props, DBUS_ACCESS_POINT_IFACE, "WpaFlags"));
const rsnFlags = Number(await getProperty(props, DBUS_ACCESS_POINT_IFACE, "RsnFlags"));
return {
ssid,
bssid: bssid.trim().length > 0 ? bssid : null,
signalPercent: strength,
channel: frequencyToChannel(frequency),
frequencyMhz: frequency,
security: wpaFlags > 0 || rsnFlags > 0 ? "WPA/WPA2" : null,
path: apPath,
};
}
async function getNetworkStatus() {
const wifiDevices = await getWifiDevices();
const connectedWifi =
wifiDevices.find((device) => device.state === DEVICE_STATE_ACTIVATED) ??
wifiDevices[0] ??
null;
// Wi-Fi device present — report its status
if (connectedWifi) {
const activeAccessPointPath = await getProperty(
connectedWifi.props,
DBUS_WIRELESS_IFACE,
"ActiveAccessPoint",
);
const activeAccessPoint = await getAccessPointSnapshot(activeAccessPointPath);
return {
connected: connectedWifi.state === DEVICE_STATE_ACTIVATED,
iface: connectedWifi.iface,
ssid: activeAccessPoint?.ssid ?? null,
ipv4: resolveInterfaceIpv4(connectedWifi.iface),
signalPercent: activeAccessPoint?.signalPercent ?? null,
};
}
// No Wi-Fi — check for an active Ethernet connection (e.g. LXC/Proxmox)
const ethernetDevices = await getEthernetDevices();
const connectedEthernet =
ethernetDevices.find((device) => device.state === DEVICE_STATE_ACTIVATED) ??
null;
if (connectedEthernet) {
return {
connected: true,
iface: connectedEthernet.iface,
ssid: null,
ipv4: resolveInterfaceIpv4(connectedEthernet.iface),
signalPercent: null,
};
}
return {
connected: false,
iface: null,
ssid: null,
ipv4: null,
signalPercent: null,
};
}
async function scanNetworks() {
const wifiDevices = await getWifiDevices();
if (wifiDevices.length === 0) return [];
await Promise.all(
wifiDevices.map(async (device) => {
try {
await withTimeout(device.wireless.RequestScan({}), 4_000);
} catch {
return;
}
}),
);
await new Promise((resolve) => {
setTimeout(resolve, 1_000);
});
const bySsid = new Map();
for (const device of wifiDevices) {
const apPaths = await withTimeout(device.wireless.GetAllAccessPoints(), 6_000);
for (const apPath of apPaths) {
const snapshot = await getAccessPointSnapshot(apPath);
if (!snapshot?.ssid) continue;
const existing = bySsid.get(snapshot.ssid);
if (!existing || (snapshot.signalPercent ?? 0) > (existing.signalPercent ?? 0)) {
bySsid.set(snapshot.ssid, {
ssid: snapshot.ssid,
bssid: snapshot.bssid,
signalPercent: snapshot.signalPercent,
channel: snapshot.channel,
frequencyMhz: snapshot.frequencyMhz,
security: snapshot.security,
path: snapshot.path,
devicePath: device.path,
});
}
}
}
return [...bySsid.values()]
.sort((left, right) => (right.signalPercent ?? 0) - (left.signalPercent ?? 0))
.map((network) => ({
ssid: network.ssid,
bssid: network.bssid,
signalPercent: network.signalPercent,
channel: network.channel,
frequencyMhz: network.frequencyMhz,
security: network.security,
}));
}
async function listSavedConnections() {
const settingsObject = await getProxyObject(DBUS_SETTINGS_PATH);
const settingsInterface = settingsObject.getInterface(DBUS_SETTINGS_IFACE);
const paths = await withTimeout(settingsInterface.ListConnections(), 5_000);
const connections = [];
for (const path of paths) {
const connectionObject = await getProxyObject(path);
const settingsConnection = connectionObject.getInterface(
DBUS_SETTINGS_CONNECTION_IFACE,
);
const settings = await withTimeout(settingsConnection.GetSettings(), 5_000);
const connectionSettings = settings.connection ?? {};
const wirelessSettings = settings["802-11-wireless"] ?? {};
const type = dbusVariantValue(connectionSettings.type);
const id = dbusVariantValue(connectionSettings.id);
const ssid = decodeSsid(dbusVariantValue(wirelessSettings.ssid));
connections.push({
path,
type: typeof type === "string" ? type : "",
id: typeof id === "string" ? id : "",
ssid,
});
}
return connections;
}
async function findDeviceForSsid(ssid) {
const wifiDevices = await getWifiDevices();
if (wifiDevices.length === 0) {
throw new HelperServiceError("No Wi-Fi device found", {
code: "network_manager_unavailable",
statusCode: 503,
});
}
for (const device of wifiDevices) {
const apPaths = await withTimeout(device.wireless.GetAllAccessPoints(), 6_000);
for (const apPath of apPaths) {
const accessPoint = await getAccessPointSnapshot(apPath);
if (accessPoint?.ssid === ssid) {
return {
device,
accessPoint,
};
}
}
}
return {
device: wifiDevices[0],
accessPoint: null,
};
}
function enforceConnectRateLimit(requestId) {
const key = requestId && requestId.length > 0 ? requestId : "anonymous";
const now = Date.now();
const oneMinuteAgo = now - 60_000;
const history = connectAttempts.get(key) ?? [];
const activeHistory = history.filter((timestamp) => timestamp > oneMinuteAgo);
if (activeHistory.length >= CONNECT_RATE_LIMIT_PER_MINUTE) {
throw new HelperServiceError("Too many network connect attempts", {
code: "invalid_request",
statusCode: 429,
});
}
activeHistory.push(now);
connectAttempts.set(key, activeHistory);
}
async function connectNetwork({ ssid, password }, requestId) {
enforceConnectRateLimit(requestId);
const { nm } = await getNmRoot();
const { device, accessPoint } = await findDeviceForSsid(ssid);
const savedConnections = await listSavedConnections();
const existingConnection =
savedConnections.find(
(connection) =>
connection.type === "802-11-wireless" &&
(connection.ssid === ssid || connection.id === ssid),
) ?? null;
if (existingConnection) {
await withTimeout(
nm.ActivateConnection(
existingConnection.path,
device.path,
accessPoint?.path ?? "/",
),
10_000,
);
return getNetworkStatus();
}
const settings = {
connection: {
id: new Variant("s", ssid),
type: new Variant("s", "802-11-wireless"),
uuid: new Variant("s", randomUUID()),
autoconnect: new Variant("b", true),
},
"802-11-wireless": {
ssid: new Variant("ay", [...Buffer.from(ssid, "utf8")]),
mode: new Variant("s", "infrastructure"),
},
ipv4: {
method: new Variant("s", "auto"),
},
ipv6: {
method: new Variant("s", "auto"),
},
};
if (typeof password === "string") {
settings["802-11-wireless-security"] = {
"key-mgmt": new Variant("s", "wpa-psk"),
psk: new Variant("s", password),
};
}
await withTimeout(
nm.AddAndActivateConnection(
settings,
device.path,
accessPoint?.path ?? "/",
),
15_000,
);
return getNetworkStatus();
}
async function disconnectNetwork({ iface }) {
const wifiDevices = await getWifiDevices();
const selectedDevice =
(iface
? wifiDevices.find((device) => device.iface === iface)
: wifiDevices.find((device) => device.state === DEVICE_STATE_ACTIVATED)) ??
null;
if (!selectedDevice) {
return getNetworkStatus();
}
await withTimeout(selectedDevice.device.Disconnect(), 8_000);
return getNetworkStatus();
}
async function emitConnectionChangedEvent(broadcastEvent) {
try {
const status = await getNetworkStatus();
broadcastEvent({
type: "network.connection.changed",
timestamp: new Date().toISOString(),
iface: status.iface,
ssid: status.ssid,
connected: status.connected,
});
} catch (error) {
log({
level: "warn",
action: "dbus.network.event.connection",
status: "error",
message: "Failed to emit connection change event",
error,
});
}
}
async function attachDbusEventBridge(broadcastEvent) {
const { nm, props } = await getNmRoot();
const wifiDevices = await getWifiDevices();
props.on("PropertiesChanged", async (iface, changed) => {
if (iface !== DBUS_IFACE) return;
const changedRecord = changed ?? {};
if ("ActiveConnections" in changedRecord || "PrimaryConnection" in changedRecord) {
await emitConnectionChangedEvent(broadcastEvent);
}
});
for (const device of wifiDevices) {
device.device.on("StateChanged", async (newState, _oldState, reason) => {
let status = {
connected: false,
iface: device.iface,
ssid: null,
};
try {
const current = await getNetworkStatus();
status = {
connected: current.connected,
iface: current.iface,
ssid: current.ssid,
};
} catch {
// ignore and emit best effort event
}
broadcastEvent({
type: "network.device.state.changed",
timestamp: new Date().toISOString(),
iface: status.iface,
ssid: status.ssid,
connected: status.connected,
state: String(newState),
reason: String(reason),
});
});
}
nm.on("DeviceAdded", async () => {
await emitConnectionChangedEvent(broadcastEvent);
});
nm.on("DeviceRemoved", async () => {
await emitConnectionChangedEvent(broadcastEvent);
});
}
return {
attachDbusEventBridge,
connectNetwork,
disconnectNetwork,
getNetworkStatus,
scanNetworks,
};
}
export { createNetworkService, decodeSsid };