OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / core / relay-node / api-fleet.js

535 lines · javascript ref:
/**
 * Operator fleet multi-node view (data-layer v3).
 *
 * Read-only aggregate of public peer /health + /status scrapes.
 * Never merges private Tor detail (no onion, no RD roster, no client-auth).
 *
 * Peer list comes from operator config (config.fleet.peers) — not request body
 * — to avoid open SSRF. Only http(s) base URLs are allowed.
 */

const FLEET_ROUTES = Object.freeze({
  'GET /api/fleet': Object.freeze({
    kind: 'fleet',
    authMessage: 'Unauthorized — API key required for /api/fleet'
  })
})

export const DEFAULT_FLEET_TIMEOUT_MS = 4000
export const MAX_FLEET_PEERS = 32
export const MAX_FLEET_STRING_BYTES = 128
export const MAX_FLEET_ERROR_BYTES = 160

const HEX_64 = /^[a-f0-9]{64}$/i

export function resolveFleetRoute (method, path) {
  const route = FLEET_ROUTES[`${method} ${path}`]
  if (!route) return null
  return { ...route }
}

function objectRecord (value) {
  return !!value && typeof value === 'object' && !Array.isArray(value)
}

function safeString (value, maxBytes = MAX_FLEET_STRING_BYTES) {
  if (typeof value !== 'string') return null
  const text = value.trim()
  if (!text || Buffer.byteLength(text, 'utf8') > maxBytes) return null
  for (let i = 0; i < text.length; i++) {
    const code = text.charCodeAt(i)
    if (code < 32 || code === 127) return null
  }
  return text
}

function safeCounter (value) {
  const n = Number(value)
  if (!Number.isFinite(n) || n < 0) return 0
  return Math.floor(n)
}

function safeMetric (value) {
  const n = Number(value)
  if (!Number.isFinite(n) || n < 0) return null
  return Math.min(n, Number.MAX_SAFE_INTEGER)
}

function safeBool (value) {
  if (value === true) return true
  if (value === false) return false
  return null
}

function truncateKey (value) {
  if (typeof value !== 'string') return null
  const hex = value.trim().toLowerCase()
  if (!HEX_64.test(hex)) {
    // Allow already-short display keys (12–64 hex).
    if (/^[a-f0-9]{12,64}$/i.test(hex)) return hex.slice(0, 12)
    return safeString(value, 64)
  }
  return hex.slice(0, 12)
}

/**
 * Only http(s) absolute base URLs. Rejects credentials in the URL.
 */
export function isAllowedFleetBaseUrl (value) {
  if (typeof value !== 'string' || !value.trim()) return false
  let u
  try {
    u = new URL(value.trim())
  } catch {
    return false
  }
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return false
  if (u.username || u.password) return false
  if (u.hash) return false
  return true
}

export function normalizeFleetBaseUrl (value) {
  if (!isAllowedFleetBaseUrl(value)) return null
  const u = new URL(value.trim())
  // Drop path noise except intentional path prefix (e.g. reverse-proxy mount).
  const base = u.origin + (u.pathname === '/' ? '' : u.pathname.replace(/\/+$/, ''))
  return base
}

/**
 * Normalize config.fleet.peers entries into a bounded, safe list.
 */
export function normalizeFleetPeers (rawPeers, { maxPeers = MAX_FLEET_PEERS } = {}) {
  if (!Array.isArray(rawPeers)) return []
  const out = []
  const seen = new Set()
  for (const entry of rawPeers) {
    if (out.length >= maxPeers) break
    if (!objectRecord(entry) && typeof entry !== 'string') continue
    const baseUrl = normalizeFleetBaseUrl(
      typeof entry === 'string' ? entry : (entry.baseUrl || entry.url)
    )
    if (!baseUrl) continue
    const key = baseUrl.toLowerCase()
    if (seen.has(key)) continue
    seen.add(key)
    const id = safeString(
      (objectRecord(entry) && (entry.id || entry.label || entry.name)) || baseUrl,
      64
    ) || baseUrl
    const region = objectRecord(entry) ? safeString(entry.region, 32) : null
    const label = objectRecord(entry)
      ? safeString(entry.label || entry.name, 64)
      : null
    const declaredPubkey = objectRecord(entry)
      ? truncateKey(entry.pubkey || entry.publicKey || entry.declaredPubkey)
      : null
    out.push({
      id,
      baseUrl,
      region: region || null,
      label: label || id,
      declaredPubkey: declaredPubkey || null
    })
  }
  return out
}

export function resolveFleetConfig (config = {}) {
  const fleet = objectRecord(config.fleet) ? config.fleet : {}
  const timeoutMs = Number.isFinite(Number(fleet.timeoutMs)) && Number(fleet.timeoutMs) > 0
    ? Math.min(Math.floor(Number(fleet.timeoutMs)), 30_000)
    : DEFAULT_FLEET_TIMEOUT_MS
  const maxPeers = Number.isFinite(Number(fleet.maxPeers)) && Number(fleet.maxPeers) > 0
    ? Math.min(Math.floor(Number(fleet.maxPeers)), MAX_FLEET_PEERS)
    : MAX_FLEET_PEERS
  const peers = normalizeFleetPeers(fleet.peers, { maxPeers })
  // includeSelf defaults true so a node with empty peer list still shows local card.
  const includeSelf = fleet.includeSelf !== false
  return { peers, timeoutMs, maxPeers, includeSelf }
}

/**
 * Coarse privacy lane from public status.transports — health only, never onion.
 */
export function sanitizeFleetPrivacy (transports) {
  if (!objectRecord(transports)) return null
  const tor = objectRecord(transports.tor) ? transports.tor : null
  if (!tor) {
    // Legacy boolean-ish shapes
    if (transports.torAvailable === true || transports.tor === true) {
      return [{ id: 'tor-v3-onion-v1', health: 'unknown', running: true }]
    }
    return null
  }
  const health = typeof tor.health === 'string'
    ? safeString(tor.health, 32)
    : (tor.running === true ? 'ready' : tor.running === false ? 'down' : 'unknown')
  return [{
    id: 'tor-v3-onion-v1',
    running: tor.running === true,
    health: health || 'unknown'
  }]
}

/**
 * Build one fleet card from public health + status JSON (or fetch errors).
 * Strips any accidental onion / secret fields by allow-listing.
 */
export function sanitizeFleetPeerCard ({
  id,
  label = null,
  region = null,
  baseUrl = null,
  declaredPubkey = null,
  health = null,
  status = null,
  error = null,
  self = false,
  fetchedAt = null
} = {}) {
  const card = {
    id: safeString(id, 64) || 'peer',
    label: safeString(label, 64) || safeString(id, 64) || 'peer',
    region: safeString(region, 32),
    baseUrl: baseUrl && isAllowedFleetBaseUrl(baseUrl) ? normalizeFleetBaseUrl(baseUrl) : null,
    self: self === true,
    up: false,
    running: null,
    publicKey: declaredPubkey ? truncateKey(declaredPubkey) : null,
    uptimeMs: null,
    connections: null,
    seededApps: null,
    disk: null,
    storage: null,
    healthy: null,
    privacyTransports: null,
    version: null,
    errors: [],
    fetchedAt: Number.isFinite(Number(fetchedAt)) ? Math.floor(Number(fetchedAt)) : null
  }

  if (error) {
    const msg = safeString(String(error), MAX_FLEET_ERROR_BYTES) || 'fetch failed'
    card.errors.push({ endpoint: 'poll', error: msg })
  }

  if (objectRecord(health)) {
    const ok = safeBool(health.ok)
    const running = safeBool(health.running)
    if (ok === true || running === true) card.up = true
    if (running !== null) card.running = running
    if (objectRecord(health.uptime) && health.uptime.ms != null) {
      card.uptimeMs = safeMetric(health.uptime.ms)
    } else if (health.uptimeMs != null) {
      card.uptimeMs = safeMetric(health.uptimeMs)
    }
  }

  if (objectRecord(status)) {
    if (status.running === true) {
      card.up = true
      card.running = true
    } else if (status.running === false && card.running == null) {
      card.running = false
    }
    if (status.publicKey) card.publicKey = truncateKey(status.publicKey) || card.publicKey
    if (status.region) card.region = safeString(status.region, 32) || card.region
    if (status.uptimeMs != null) card.uptimeMs = safeMetric(status.uptimeMs)
    if (status.connections != null) card.connections = safeCounter(status.connections)
    if (status.seededApps != null) card.seededApps = safeCounter(status.seededApps)

    if (objectRecord(status.health)) {
      const h = safeBool(status.health.healthy)
      if (h !== null) card.healthy = h
    }

    if (objectRecord(status.disk)) {
      const disk = {}
      const usedPct = safeMetric(status.disk.usedPct)
      if (usedPct != null) disk.usedPct = usedPct
      if (typeof status.disk.status === 'string') {
        const s = safeString(status.disk.status, 32)
        if (s) disk.status = s
      }
      const freeGB = safeMetric(status.disk.freeGB)
      if (freeGB != null) disk.freeGB = freeGB
      if (Object.keys(disk).length) card.disk = disk
    }

    if (objectRecord(status.storage)) {
      const storage = {}
      if (status.storage.totalBytes != null || status.storage.used != null) {
        const used = safeMetric(status.storage.totalBytes != null ? status.storage.totalBytes : status.storage.used)
        if (used != null) storage.used = used
      }
      if (status.storage.max != null || status.storage.maxBytes != null) {
        const max = safeMetric(status.storage.max != null ? status.storage.max : status.storage.maxBytes)
        if (max != null) storage.max = max
      }
      if (Object.keys(storage).length) card.storage = storage
    }

    card.privacyTransports = sanitizeFleetPrivacy(status.transports)
    // Explicitly never copy onion-bearing fields even if present on raw status.
  }

  // up if health ok OR status running, and no total poll failure without data
  if (!card.up && card.errors.length && !objectRecord(health) && !objectRecord(status)) {
    card.up = false
  }

  return card
}

/**
 * Aggregate fleet cards into operator summary (counts only).
 */
export function aggregateFleet (cards = [], { now = Date.now() } = {}) {
  const list = Array.isArray(cards) ? cards : []
  let up = 0
  let down = 0
  let diskWarn = 0
  let diskCritical = 0
  let unhealthy = 0
  let privacyDegraded = 0
  const regions = new Set()

  for (const c of list) {
    if (!objectRecord(c)) continue
    if (c.up) up++
    else down++
    if (c.region) regions.add(c.region)
    if (c.healthy === false) unhealthy++
    if (objectRecord(c.disk)) {
      if (c.disk.status === 'critical' || (c.disk.usedPct != null && c.disk.usedPct >= 90)) diskCritical++
      else if (c.disk.status === 'warn' || (c.disk.usedPct != null && c.disk.usedPct >= 75)) diskWarn++
    }
    const pt = Array.isArray(c.privacyTransports) ? c.privacyTransports[0] : null
    if (pt && pt.health && pt.health !== 'ready' && pt.health !== 'ok' && pt.health !== 'unknown') {
      privacyDegraded++
    }
  }

  return {
    peerCount: list.length,
    up,
    down,
    regions: regions.size,
    diskWarn,
    diskCritical,
    unhealthy,
    privacyDegraded,
    updatedAt: Math.floor(now)
  }
}

/**
 * Poll configured peers (and optional self card) into a fleet payload.
 * fetchJson(url) must return parsed JSON or throw.
 */
export async function buildFleetPayload ({
  config = {},
  selfStatus = null,
  selfHealth = null,
  selfPublicKey = null,
  selfRegion = null,
  fetchJson = null,
  now = Date.now()
} = {}) {
  const fleetCfg = resolveFleetConfig(config)
  const cards = []

  if (fleetCfg.includeSelf) {
    cards.push(sanitizeFleetPeerCard({
      id: 'self',
      label: 'this node',
      region: selfRegion,
      declaredPubkey: selfPublicKey,
      health: selfHealth,
      status: selfStatus,
      self: true,
      fetchedAt: now
    }))
  }

  if (typeof fetchJson === 'function' && fleetCfg.peers.length) {
    const settled = await Promise.all(fleetCfg.peers.map(async (peer) => {
      const base = peer.baseUrl
      let health = null
      let status = null
      const errors = []
      try {
        health = await fetchJson(`${base}/health`)
      } catch (err) {
        errors.push(err && err.message ? err.message : 'health failed')
      }
      try {
        status = await fetchJson(`${base}/status`)
      } catch (err) {
        errors.push(err && err.message ? err.message : 'status failed')
      }
      const error = (!health && !status)
        ? (errors[0] || 'unreachable')
        : null
      return sanitizeFleetPeerCard({
        id: peer.id,
        label: peer.label,
        region: peer.region,
        baseUrl: peer.baseUrl,
        declaredPubkey: peer.declaredPubkey,
        health,
        status,
        error,
        self: false,
        fetchedAt: now
      })
    }))
    for (const card of settled) cards.push(card)
  }

  // Drop absolute certainty: re-scan cards for any forbidden onion substrings.
  const scrubbed = cards.map(stripForbiddenFleetFields)

  return {
    summary: aggregateFleet(scrubbed, { now }),
    peers: scrubbed,
    config: {
      peerTargets: fleetCfg.peers.length,
      includeSelf: fleetCfg.includeSelf,
      timeoutMs: fleetCfg.timeoutMs
    }
  }
}

function stripForbiddenFleetFields (card) {
  if (!objectRecord(card)) return card
  const json = JSON.stringify(card)
  // Defense-in-depth: if anything onion-shaped leaked, redact the whole string fields.
  if (!/\.onion\b/i.test(json) && !/ED25519-V3:/i.test(json)) return card
  const clone = { ...card }
  for (const key of Object.keys(clone)) {
    if (typeof clone[key] === 'string' && (/\.onion\b/i.test(clone[key]) || /ED25519-V3:/i.test(clone[key]))) {
      clone[key] = '[redacted]'
    }
  }
  // privacyTransports must stay coarse — rebuild without any free-form strings beyond health.
  if (Array.isArray(clone.privacyTransports)) {
    clone.privacyTransports = clone.privacyTransports.map((p) => ({
      id: 'tor-v3-onion-v1',
      running: !!(p && p.running),
      health: (p && typeof p.health === 'string' && !/\.onion/i.test(p.health))
        ? p.health.slice(0, 32)
        : 'unknown'
    }))
  }
  return clone
}

export async function buildFleetRoutePayload ({
  route,
  node = {},
  fetchJson = null,
  now = Date.now()
} = {}) {
  if (!route || route.kind !== 'fleet') {
    return {
      ok: false,
      status: 404,
      payload: { error: 'unknown fleet route' }
    }
  }

  const config = objectRecord(node.config) ? node.config : {}
  const fleetCfg = resolveFleetConfig(config)

  // Default fetchJson uses global fetch with per-request timeout.
  const doFetch = typeof fetchJson === 'function'
    ? fetchJson
    : createTimedFetchJson(fleetCfg.timeoutMs)

  let selfStatus = null
  let selfHealth = null
  let selfPublicKey = null
  let selfRegion = null

  if (fleetCfg.includeSelf) {
    try {
      if (typeof node.getStats === 'function') {
        const stats = node.getStats({ includeSecrets: false })
        selfPublicKey = stats && stats.publicKey
        // Prefer building from local status builder if available via lazy import pattern —
        // callers may pass prebuilt self; here we assemble a minimal status-like object.
        selfStatus = {
          running: stats && stats.running !== false,
          publicKey: stats && stats.publicKey,
          region: Array.isArray(config.regions) ? config.regions[0] : null,
          uptimeMs: node.metrics && Number.isFinite(node.metrics.startedAt)
            ? Math.max(0, now - node.metrics.startedAt)
            : null,
          connections: stats && stats.connections,
          seededApps: stats && stats.seededApps,
          health: typeof node.getHealthStatus === 'function'
            ? (() => {
                try {
                  const h = node.getHealthStatus()
                  return h ? { healthy: h.healthy === true } : null
                } catch { return null }
              })()
            : null,
          disk: stats && stats.disk,
          storage: stats && stats.storage,
          transports: stats && stats.transports
        }
      }
      if (typeof node.getHealthStatus === 'function') {
        const h = node.getHealthStatus()
        selfHealth = {
          ok: h && h.healthy !== false,
          running: true,
          uptimeMs: selfStatus && selfStatus.uptimeMs
        }
      } else {
        selfHealth = { ok: true, running: true }
      }
      selfRegion = Array.isArray(config.regions) ? config.regions[0] : null
    } catch {
      selfStatus = { running: true }
      selfHealth = { ok: true, running: true }
    }
  }

  const payload = await buildFleetPayload({
    config,
    selfStatus,
    selfHealth,
    selfPublicKey,
    selfRegion,
    fetchJson: doFetch,
    now
  })

  return { ok: true, status: 200, payload }
}

export function createTimedFetchJson (timeoutMs = DEFAULT_FLEET_TIMEOUT_MS) {
  return async function timedFetchJson (url) {
    if (typeof globalThis.fetch !== 'function') {
      throw new Error('fetch unavailable')
    }
    const ms = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_FLEET_TIMEOUT_MS
    const controller = typeof AbortController === 'function' ? new AbortController() : null
    const timer = controller
      ? setTimeout(() => controller.abort(), ms)
      : null
    try {
      const res = await globalThis.fetch(url, controller ? { signal: controller.signal } : {})
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return await res.json()
    } finally {
      if (timer) clearTimeout(timer)
    }
  }
}