199d0da675
New seen_macs table: permanent, insert-only, MAC-keyed record of the first time each device was ever seen -- deliberately decoupled from devices.first_seen (IP-keyed, would false-positive on every DHCP lease change). Bootstrap-safe: first call seeds the baseline from whatever's currently on the network without alerting on all 71+ existing devices at once. Verified locally: bootstrap call reports nothing new, repeat calls with the same MACs report nothing new, one genuinely new MAC gets reported exactly once. Pushes a Home Assistant persistent_notification when a new MAC appears (gated behind HOME_ASSISTANT_TOKEN + homeAssistant.url in hosts.yaml -- missing config just means no push, detection still runs). Also surfaced directly in the dashboard as a blue "new" badge for anything first seen in the last 24h, independent of HA config. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
159 lines
4.5 KiB
TypeScript
159 lines
4.5 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { parse } from "yaml";
|
|
import type { SshHostConfig } from "../collectors/sshHost.js";
|
|
|
|
interface RawSshHost {
|
|
id: string;
|
|
displayName: string;
|
|
group: string;
|
|
host: string;
|
|
port?: number;
|
|
username: string;
|
|
diskPaths?: { path: string; label: string }[];
|
|
}
|
|
|
|
interface RawKnownDevice {
|
|
ip: string;
|
|
name: string;
|
|
}
|
|
|
|
interface RawDeepCheck {
|
|
host: string;
|
|
port?: number;
|
|
username?: string;
|
|
}
|
|
|
|
interface RawHomeAssistant {
|
|
url: string;
|
|
}
|
|
|
|
export interface HostsConfig {
|
|
proxmox: {
|
|
host: string;
|
|
node: string;
|
|
};
|
|
sshHosts?: RawSshHost[];
|
|
knownDevices?: RawKnownDevice[];
|
|
deepCheck?: RawDeepCheck;
|
|
homeAssistant?: RawHomeAssistant;
|
|
}
|
|
|
|
export interface AppConfig {
|
|
port: number;
|
|
dbPath: string;
|
|
pollIntervalSeconds: number;
|
|
snapshotRetentionHours: number;
|
|
sessionSecret: string;
|
|
// Only set once a TLS-terminating reverse proxy sits in front (see issue #13) —
|
|
// browsers silently drop `secure` cookies over plain HTTP.
|
|
cookieSecure: boolean;
|
|
adminUsername: string;
|
|
adminPassword: string | undefined;
|
|
proxmox: {
|
|
host: string;
|
|
node: string;
|
|
tokenId: string;
|
|
tokenSecret: string;
|
|
};
|
|
sshHosts: SshHostConfig[];
|
|
knownDevices: Map<string, string>;
|
|
discoveryFilePath: string;
|
|
oidc: OidcConfig | undefined;
|
|
deepCheck: DeepCheckHostConfig | undefined;
|
|
fingerbankApiKey: string | undefined;
|
|
homeAssistant: HomeAssistantConfig | undefined;
|
|
hosts: HostsConfig;
|
|
}
|
|
|
|
export interface HomeAssistantConfig {
|
|
url: string;
|
|
token: string;
|
|
}
|
|
|
|
export interface DeepCheckHostConfig {
|
|
host: string;
|
|
port: number;
|
|
username: string;
|
|
privateKeyPath: string;
|
|
}
|
|
|
|
export interface OidcConfig {
|
|
issuerUrl: string;
|
|
clientId: string;
|
|
clientSecret: string;
|
|
redirectUri: string;
|
|
// Authentik's cert is self-signed on the LAN, same as Proxmox's.
|
|
allowInsecureTls: boolean;
|
|
}
|
|
|
|
function required(name: string): string {
|
|
const v = process.env[name];
|
|
if (!v) throw new Error(`Missing required env var: ${name}`);
|
|
return v;
|
|
}
|
|
|
|
export function loadConfig(hostsConfigPath: string): AppConfig {
|
|
const hosts = parse(readFileSync(hostsConfigPath, "utf8")) as HostsConfig;
|
|
const sshPrivateKeyPath = process.env.SSH_PRIVATE_KEY_PATH ?? "./ssh/monitor_ed25519";
|
|
|
|
// Local auth (bcrypt + session) is always available. OIDC is an *additional*
|
|
// sign-in option when configured, not a replacement — see issue #12 comments
|
|
// for why (don't want to lock out the already-saved local admin password).
|
|
const oidc: OidcConfig | undefined =
|
|
process.env.OIDC_ENABLED === "true"
|
|
? {
|
|
issuerUrl: required("OIDC_ISSUER_URL"),
|
|
clientId: required("OIDC_CLIENT_ID"),
|
|
clientSecret: required("OIDC_CLIENT_SECRET"),
|
|
redirectUri: required("OIDC_REDIRECT_URI"),
|
|
allowInsecureTls: process.env.OIDC_ALLOW_INSECURE_TLS === "true",
|
|
}
|
|
: undefined;
|
|
|
|
const sshHosts: SshHostConfig[] = (hosts.sshHosts ?? []).map((h) => ({
|
|
id: h.id,
|
|
displayName: h.displayName,
|
|
group: h.group,
|
|
host: h.host,
|
|
port: h.port ?? 22,
|
|
username: h.username,
|
|
privateKeyPath: sshPrivateKeyPath,
|
|
diskPaths: h.diskPaths ?? [{ path: "/", label: "root" }],
|
|
}));
|
|
|
|
return {
|
|
port: Number(process.env.PORT ?? 3000),
|
|
dbPath: process.env.DB_PATH ?? "./data/monitor.db",
|
|
pollIntervalSeconds: Number(process.env.POLL_INTERVAL_SECONDS ?? 30),
|
|
snapshotRetentionHours: Number(process.env.SNAPSHOT_RETENTION_HOURS ?? 24),
|
|
sessionSecret: required("SESSION_SECRET"),
|
|
cookieSecure: process.env.COOKIE_SECURE === "true",
|
|
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
|
|
adminPassword: process.env.ADMIN_PASSWORD,
|
|
proxmox: {
|
|
host: hosts.proxmox.host,
|
|
node: hosts.proxmox.node,
|
|
tokenId: required("PROXMOX_TOKEN_ID"),
|
|
tokenSecret: required("PROXMOX_TOKEN_SECRET"),
|
|
},
|
|
sshHosts,
|
|
knownDevices: new Map((hosts.knownDevices ?? []).map((d) => [d.ip, d.name])),
|
|
discoveryFilePath: process.env.DISCOVERY_FILE_PATH ?? "./data/devices-raw.json",
|
|
oidc,
|
|
deepCheck: hosts.deepCheck
|
|
? {
|
|
host: hosts.deepCheck.host,
|
|
port: hosts.deepCheck.port ?? 22,
|
|
username: hosts.deepCheck.username ?? "root",
|
|
privateKeyPath: sshPrivateKeyPath,
|
|
}
|
|
: undefined,
|
|
fingerbankApiKey: process.env.FINGERBANK_API_KEY,
|
|
homeAssistant:
|
|
hosts.homeAssistant && process.env.HOME_ASSISTANT_TOKEN
|
|
? { url: hosts.homeAssistant.url, token: process.env.HOME_ASSISTANT_TOKEN }
|
|
: undefined,
|
|
hosts,
|
|
};
|
|
}
|