homeio
PublikFork dari doctor-io/homeio
overview-service.ts
import "server-only";
import { readFile } from "node:fs/promises";
import si from "systeminformation";
import { isContainerRuntime } from "@/lib/server/modules/system/update-service";
import type {
NetworkInterfaceKind,
NetworkInterfaceOverview,
NetworkOverview,
} from "@/lib/shared/contracts/network";
const CACHE_MS = 2_000;
// Docker, bridges and VM plumbing: one per container, and none of them is a
// way in or out of the machine.
const PLUMBING_INTERFACE = /^(veth|br-|docker|virbr|vnet|lxc|cni|flannel)/;
const VPN_INTERFACE = /^(tailscale|wg|tun|tap|utun|zt)/;
// systemd-resolved lists the upstream servers here; /etc/resolv.conf then
// only says 127.0.0.53.
const RESOLV_CONF_PATHS = ["/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"];
function textOrNull(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function countOrNull(value: unknown) {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
}
function bytesPerSecondToMbps(value: unknown) {
const perSecond = countOrNull(value);
return perSecond === null ? null : (perSecond * 8) / 1_000_000;
}
function kindOf(name: string, type: unknown): NetworkInterfaceKind {
if (VPN_INTERFACE.test(name)) return "vpn";
if (type === "wireless") return "wireless";
if (type === "wired") return "wired";
return "other";
}
export function parseNameservers(resolvConf: string) {
const servers: string[] = [];
for (const line of resolvConf.split("\n")) {
const match = /^\s*nameserver\s+(\S+)/.exec(line);
if (match && !servers.includes(match[1])) servers.push(match[1]);
}
return servers;
}
async function readDnsServers() {
for (const filePath of RESOLV_CONF_PATHS) {
try {
const servers = parseNameservers(await readFile(filePath, "utf8"));
if (servers.length > 0) return servers;
} catch {
// Not on this host; try the next file.
}
}
return [];
}
async function collect(): Promise<NetworkOverview> {
const [interfaces, stats, gateway, dnsServers] = await Promise.all([
si.networkInterfaces().catch(() => []),
si.networkStats("*").catch(() => []),
si.networkGatewayDefault().catch(() => ""),
readDnsServers(),
]);
const statsByName = new Map(
(Array.isArray(stats) ? stats : []).map((entry) => [entry.iface, entry]),
);
const list: NetworkInterfaceOverview[] = (Array.isArray(interfaces) ? interfaces : [interfaces])
.filter((iface) => iface.iface && !iface.internal && !PLUMBING_INTERFACE.test(iface.iface))
.map((iface) => {
const traffic = statsByName.get(iface.iface);
return {
iface: iface.iface,
kind: kindOf(iface.iface, iface.type),
up: iface.operstate === "up" ? true : iface.operstate === "down" ? false : null,
isDefault: Boolean(iface.default),
ip4: textOrNull(iface.ip4),
ip4Subnet: textOrNull(iface.ip4subnet),
ip6: textOrNull(iface.ip6),
mac: textOrNull(iface.mac),
speedMbps: typeof iface.speed === "number" && iface.speed > 0 ? iface.speed : null,
duplex: textOrNull(iface.duplex),
dhcp: Boolean(iface.dhcp),
rxBytes: countOrNull(traffic?.rx_bytes),
txBytes: countOrNull(traffic?.tx_bytes),
downloadMbps: bytesPerSecondToMbps(traffic?.rx_sec),
uploadMbps: bytesPerSecondToMbps(traffic?.tx_sec),
rxErrors: countOrNull(traffic?.rx_errors),
txErrors: countOrNull(traffic?.tx_errors),
rxDropped: countOrNull(traffic?.rx_dropped),
txDropped: countOrNull(traffic?.tx_dropped),
};
})
// Default first, then interfaces that are up, then by name.
.sort(
(a, b) =>
Number(b.isDefault) - Number(a.isDefault) ||
Number(b.up === true) - Number(a.up === true) ||
a.iface.localeCompare(b.iface),
);
return {
inContainer: isContainerRuntime(),
interfaces: list,
gateway: textOrNull(gateway),
dnsServers,
checkedAt: new Date().toISOString(),
};
}
let cached: { value: NetworkOverview; expiresAt: number } | null = null;
let inFlight: Promise<NetworkOverview> | null = null;
/** Cached for 2 s so several open Monitors share one round of probes. */
export async function getNetworkOverview(): Promise<NetworkOverview> {
if (cached && cached.expiresAt > Date.now()) return cached.value;
inFlight ??= collect().finally(() => {
inFlight = null;
});
const value = await inFlight;
cached = { value, expiresAt: Date.now() + CACHE_MS };
return value;
}