- Drop all desktop/Electron branches, scripts, and UI (title bar, pets, desktop bridge); LAN endpoint kind is now web/custom only - Replace the bundled hermes CLI with user-side installation: add GET /api/hermes/cli-status plus a HermesMissingView onboarding guide - Remove the version-management UI (snapshots handled by git) and prune dead website/mcu/claude scripts and lockfile entries
392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
import dgram from 'dgram'
|
|
import { networkInterfaces } from 'os'
|
|
import { config } from '../config'
|
|
import { logger } from './logger'
|
|
import { deviceIdFromPublicKey, getPublicSystemInfo, type PublicSystemInfo } from './system-info'
|
|
|
|
const DISCOVERY_VERSION = 1
|
|
export const HERMES_DISCOVERY_PORT = 48640
|
|
const DISCOVERY_PORT_OFFSET = 40_000
|
|
const DEFAULT_HTTP_PORTS = [8648]
|
|
const DEFAULT_SCAN_TIMEOUT_MS = 1000
|
|
|
|
type DiscoverySocket = dgram.Socket
|
|
|
|
export type LanDeviceInfo = PublicSystemInfo & {
|
|
id: string
|
|
ip: string
|
|
http_port: number
|
|
endpoint_kind: LanEndpointKind
|
|
url: string
|
|
response_ms: number
|
|
last_seen_at: string
|
|
}
|
|
|
|
export type LanEndpointKind = 'web' | 'custom'
|
|
|
|
export type LanDiscoveryState = {
|
|
scanning: boolean
|
|
last_scanned_at: string | null
|
|
devices: LanDeviceInfo[]
|
|
}
|
|
|
|
type DiscoveryRequest = {
|
|
type?: string
|
|
version?: number
|
|
request_id?: string
|
|
}
|
|
|
|
type DiscoveryAnnouncement = PublicSystemInfo & {
|
|
type: 'hermes.announce'
|
|
version: number
|
|
request_id?: string
|
|
http_port: number
|
|
endpoint_kind: LanEndpointKind
|
|
url: string
|
|
}
|
|
|
|
type StartResponderOptions = {
|
|
httpPort?: number
|
|
getSystemInfo?: () => Promise<PublicSystemInfo>
|
|
}
|
|
|
|
type ScanOptions = {
|
|
timeoutMs?: number
|
|
httpPorts?: number[]
|
|
targetAddresses?: string[]
|
|
includeSelf?: boolean
|
|
}
|
|
|
|
let responderSockets: DiscoverySocket[] = []
|
|
let cache: LanDiscoveryState = {
|
|
scanning: false,
|
|
last_scanned_at: null,
|
|
devices: [],
|
|
}
|
|
let scanInFlight: Promise<LanDiscoveryState> | null = null
|
|
let localInfoCache: { value: PublicSystemInfo; expiresAt: number } | null = null
|
|
|
|
function envFlagDisabled(name: string): boolean {
|
|
const value = String(process.env[name] || '').trim().toLowerCase()
|
|
return ['0', 'false', 'no', 'off'].includes(value)
|
|
}
|
|
|
|
export function isLanDiscoveryEnabled(): boolean {
|
|
return !envFlagDisabled('HERMES_LAN_DISCOVERY_ENABLED')
|
|
}
|
|
|
|
function parsePortList(value: string | undefined): number[] {
|
|
return String(value || '')
|
|
.split(/[\s,]+/)
|
|
.map(item => Number.parseInt(item, 10))
|
|
.filter(port => Number.isInteger(port) && port > 0 && port <= 65535)
|
|
}
|
|
|
|
export function discoveryPortForHttpPort(httpPort: number): number {
|
|
const port = DISCOVERY_PORT_OFFSET + httpPort
|
|
if (port > 65535) throw new Error(`HTTP port ${httpPort} cannot be mapped to a UDP discovery port`)
|
|
return port
|
|
}
|
|
|
|
function discoveryPortsForHttpPorts(httpPorts: number[]): number[] {
|
|
const ports = new Set<number>([HERMES_DISCOVERY_PORT])
|
|
for (const httpPort of httpPorts) {
|
|
try {
|
|
ports.add(discoveryPortForHttpPort(httpPort))
|
|
} catch {
|
|
// Fixed discovery port still covers endpoints with unmappable HTTP ports.
|
|
}
|
|
}
|
|
return [...ports]
|
|
}
|
|
|
|
export function getLanEndpointKind(httpPort: number, currentPort = config.port): LanEndpointKind {
|
|
if (httpPort === 8648 || httpPort === currentPort) return 'web'
|
|
return 'custom'
|
|
}
|
|
|
|
export function getDiscoveryHttpPorts(currentPort = config.port): number[] {
|
|
const configured = parsePortList(process.env.HERMES_LAN_DISCOVERY_HTTP_PORTS)
|
|
return [...new Set([...(configured.length ? configured : DEFAULT_HTTP_PORTS), currentPort])]
|
|
}
|
|
|
|
function ipv4ToInt(ip: string): number | null {
|
|
const parts = ip.split('.').map(part => Number.parseInt(part, 10))
|
|
if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255)) return null
|
|
return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0
|
|
}
|
|
|
|
function intToIpv4(value: number): string {
|
|
return [
|
|
(value >>> 24) & 255,
|
|
(value >>> 16) & 255,
|
|
(value >>> 8) & 255,
|
|
value & 255,
|
|
].join('.')
|
|
}
|
|
|
|
export function isPrivateOrLoopbackIPv4(ip: string): boolean {
|
|
const value = ipv4ToInt(ip)
|
|
if (value === null) return false
|
|
const first = (value >>> 24) & 255
|
|
const second = (value >>> 16) & 255
|
|
if (first === 10 || first === 127) return true
|
|
if (first === 172 && second >= 16 && second <= 31) return true
|
|
if (first === 192 && second === 168) return true
|
|
if (first === 169 && second === 254) return true
|
|
return false
|
|
}
|
|
|
|
function getIpv4Interfaces() {
|
|
try {
|
|
return Object.values(networkInterfaces())
|
|
.flat()
|
|
.filter(item => item && item.family === 'IPv4' && !item.internal && item.address && item.netmask)
|
|
.map(item => ({ address: item!.address, netmask: item!.netmask }))
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function getLocalIPv4Addresses(): Set<string> {
|
|
return new Set([
|
|
'127.0.0.1',
|
|
...getIpv4Interfaces().map(iface => iface.address),
|
|
])
|
|
}
|
|
|
|
function broadcastAddress(address: string, netmask: string): string | null {
|
|
const ip = ipv4ToInt(address)
|
|
const mask = ipv4ToInt(netmask)
|
|
if (ip === null || mask === null) return null
|
|
return intToIpv4((ip | (~mask >>> 0)) >>> 0)
|
|
}
|
|
|
|
function selectLocalAddress(remoteAddress: string): string {
|
|
const remote = ipv4ToInt(remoteAddress)
|
|
const interfaces = getIpv4Interfaces()
|
|
if (remote !== null) {
|
|
for (const iface of interfaces) {
|
|
const ip = ipv4ToInt(iface.address)
|
|
const mask = ipv4ToInt(iface.netmask)
|
|
if (ip !== null && mask !== null && (ip & mask) === (remote & mask)) return iface.address
|
|
}
|
|
}
|
|
return interfaces[0]?.address || '127.0.0.1'
|
|
}
|
|
|
|
export function getDiscoveryTargetAddresses(): string[] {
|
|
const targets = new Set<string>(['255.255.255.255'])
|
|
for (const iface of getIpv4Interfaces()) {
|
|
const broadcast = broadcastAddress(iface.address, iface.netmask)
|
|
if (broadcast) targets.add(broadcast)
|
|
}
|
|
return [...targets]
|
|
}
|
|
|
|
async function getCachedLocalInfo(getSystemInfo: () => Promise<PublicSystemInfo>): Promise<PublicSystemInfo> {
|
|
const now = Date.now()
|
|
if (localInfoCache && localInfoCache.expiresAt > now) return localInfoCache.value
|
|
const value = await getSystemInfo()
|
|
localInfoCache = { value, expiresAt: now + 60_000 }
|
|
return value
|
|
}
|
|
|
|
function parseJson(buffer: Buffer): any | null {
|
|
try {
|
|
return JSON.parse(buffer.toString('utf8'))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function buildAnnouncement(
|
|
request: DiscoveryRequest,
|
|
remoteAddress: string,
|
|
httpPort: number,
|
|
getSystemInfo: () => Promise<PublicSystemInfo>,
|
|
): Promise<DiscoveryAnnouncement> {
|
|
const info = await getCachedLocalInfo(getSystemInfo)
|
|
const localAddress = selectLocalAddress(remoteAddress)
|
|
return {
|
|
type: 'hermes.announce',
|
|
version: DISCOVERY_VERSION,
|
|
request_id: request.request_id,
|
|
http_port: httpPort,
|
|
endpoint_kind: getLanEndpointKind(httpPort),
|
|
url: `http://${localAddress}:${httpPort}`,
|
|
...info,
|
|
}
|
|
}
|
|
|
|
export function startLanDiscoveryResponder(options: StartResponderOptions = {}): DiscoverySocket | null {
|
|
if (!isLanDiscoveryEnabled()) return null
|
|
if (responderSockets.length > 0) return responderSockets[0]
|
|
|
|
const httpPort = options.httpPort || config.port
|
|
const getSystemInfo = options.getSystemInfo || getPublicSystemInfo
|
|
const discoveryPorts = discoveryPortsForHttpPorts([httpPort])
|
|
|
|
for (const discoveryPort of discoveryPorts) {
|
|
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
|
|
socket.on('message', (message, rinfo) => {
|
|
if (!isPrivateOrLoopbackIPv4(rinfo.address)) return
|
|
const request = parseJson(message) as DiscoveryRequest | null
|
|
if (!request || request.type !== 'hermes.discover' || request.version !== DISCOVERY_VERSION) return
|
|
|
|
void buildAnnouncement(request, rinfo.address, httpPort, getSystemInfo)
|
|
.then(announcement => {
|
|
const response = Buffer.from(JSON.stringify(announcement))
|
|
socket.send(response, rinfo.port, rinfo.address)
|
|
})
|
|
.catch(err => logger.warn(err, '[lan-discovery] failed to build discovery response'))
|
|
})
|
|
|
|
socket.on('error', err => {
|
|
logger.warn(err, '[lan-discovery] UDP responder error on port %d', discoveryPort)
|
|
})
|
|
|
|
socket.bind(discoveryPort, '0.0.0.0', () => {
|
|
socket.setBroadcast(true)
|
|
socket.unref()
|
|
logger.info('[lan-discovery] responder listening on udp://0.0.0.0:%d for http port %d', discoveryPort, httpPort)
|
|
})
|
|
|
|
responderSockets.push(socket)
|
|
}
|
|
|
|
return responderSockets[0] || null
|
|
}
|
|
|
|
export function stopLanDiscoveryResponder(): void {
|
|
for (const socket of responderSockets) {
|
|
try {
|
|
socket.close()
|
|
} catch {
|
|
// Ignore close races during shutdown/tests.
|
|
}
|
|
}
|
|
responderSockets = []
|
|
}
|
|
|
|
function normalizeDevice(data: any, sourceAddress: string, responseMs: number, seenAt: string): LanDeviceInfo | null {
|
|
if (!data || data.type !== 'hermes.announce' || data.version !== DISCOVERY_VERSION) return null
|
|
const httpPort = Number(data.http_port)
|
|
if (!Number.isInteger(httpPort) || httpPort <= 0 || httpPort > 65535) return null
|
|
const deviceId = typeof data.device_id === 'string' && data.device_id ? data.device_id : ''
|
|
const devicePublicKey = typeof data.device_public_key === 'string' ? data.device_public_key : ''
|
|
if (!deviceId || !devicePublicKey || deviceIdFromPublicKey(devicePublicKey) !== deviceId) return null
|
|
const endpointKind = data.endpoint_kind === 'web' || data.endpoint_kind === 'custom'
|
|
? data.endpoint_kind
|
|
: getLanEndpointKind(httpPort)
|
|
const url = `http://${sourceAddress}:${httpPort}`
|
|
return {
|
|
id: deviceId,
|
|
device_id: deviceId,
|
|
device_public_key: devicePublicKey,
|
|
ip: sourceAddress,
|
|
http_port: httpPort,
|
|
endpoint_kind: endpointKind,
|
|
url,
|
|
computer_name: String(data.computer_name || ''),
|
|
os: {
|
|
type: String(data.os?.type || ''),
|
|
platform: String(data.os?.platform || '') as NodeJS.Platform,
|
|
release: String(data.os?.release || ''),
|
|
arch: String(data.os?.arch || ''),
|
|
},
|
|
hermes_agent_version: String(data.hermes_agent_version || ''),
|
|
hermes_web_ui_version: String(data.hermes_web_ui_version || ''),
|
|
response_ms: responseMs,
|
|
last_seen_at: seenAt,
|
|
}
|
|
}
|
|
|
|
function isSelfDevice(device: LanDeviceInfo, localAddresses: Set<string>): boolean {
|
|
return localAddresses.has(device.ip)
|
|
}
|
|
|
|
export function getLanDiscoveryCache(): LanDiscoveryState {
|
|
return {
|
|
scanning: cache.scanning,
|
|
last_scanned_at: cache.last_scanned_at,
|
|
devices: [...cache.devices],
|
|
}
|
|
}
|
|
|
|
export async function scanLanDevices(options: ScanOptions = {}): Promise<LanDiscoveryState> {
|
|
if (!isLanDiscoveryEnabled()) return getLanDiscoveryCache()
|
|
if (scanInFlight) return scanInFlight
|
|
|
|
const timeoutMs = Math.max(100, Math.min(options.timeoutMs || DEFAULT_SCAN_TIMEOUT_MS, 5000))
|
|
const httpPorts = [...new Set(options.httpPorts || getDiscoveryHttpPorts())]
|
|
const targetAddresses = options.targetAddresses || getDiscoveryTargetAddresses()
|
|
const localAddresses = getLocalIPv4Addresses()
|
|
const requestId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
|
|
cache = { ...cache, scanning: true }
|
|
const startedAt = Date.now()
|
|
|
|
scanInFlight = new Promise<LanDiscoveryState>((resolve) => {
|
|
const socket = dgram.createSocket('udp4')
|
|
const found = new Map<string, LanDeviceInfo>()
|
|
|
|
socket.on('message', (message, rinfo) => {
|
|
if (!isPrivateOrLoopbackIPv4(rinfo.address)) return
|
|
const data = parseJson(message)
|
|
if (data?.request_id && data.request_id !== requestId) return
|
|
const device = normalizeDevice(data, rinfo.address, Date.now() - startedAt, new Date().toISOString())
|
|
if (device && !options.includeSelf && isSelfDevice(device, localAddresses)) return
|
|
if (device) found.set(device.id, device)
|
|
})
|
|
|
|
socket.on('error', err => {
|
|
logger.warn(err, '[lan-discovery] UDP scan error')
|
|
})
|
|
|
|
socket.bind(0, '0.0.0.0', () => {
|
|
socket.setBroadcast(true)
|
|
const packet = Buffer.from(JSON.stringify({
|
|
type: 'hermes.discover',
|
|
version: DISCOVERY_VERSION,
|
|
request_id: requestId,
|
|
}))
|
|
for (const discoveryPort of discoveryPortsForHttpPorts(httpPorts)) {
|
|
for (const target of targetAddresses) {
|
|
socket.send(packet, discoveryPort, target)
|
|
}
|
|
}
|
|
})
|
|
|
|
setTimeout(() => {
|
|
try {
|
|
socket.close()
|
|
} catch {
|
|
// Ignore close races.
|
|
}
|
|
cache = {
|
|
scanning: false,
|
|
last_scanned_at: new Date().toISOString(),
|
|
devices: [...found.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
}
|
|
resolve(getLanDiscoveryCache())
|
|
}, timeoutMs)
|
|
}).finally(() => {
|
|
scanInFlight = null
|
|
})
|
|
|
|
return scanInFlight
|
|
}
|
|
|
|
export function resetLanDiscoveryState(): void {
|
|
cache = {
|
|
scanning: false,
|
|
last_scanned_at: null,
|
|
devices: [],
|
|
}
|
|
scanInFlight = null
|
|
localInfoCache = null
|
|
stopLanDiscoveryResponder()
|
|
}
|