OverviewFilesCommitsIssuesDocsReleasesPeers

7rnx-proxy / lib / registry.js

279 lines · javascript ref:
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'

const CONFIG_DIR = path.join(os.homedir(), '.config', '7rnx-proxy')
const REGISTRY_PATH = path.join(CONFIG_DIR, 'registry.json')

// Input validation — everything here ends up interpolated into an Apache config
// or an ssh host argument, so reject anything outside a safe character set at
// the point of entry rather than trusting the config generator to catch it.
const PETNAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/
const PATH_RE = /^\/[A-Za-z0-9/_-]*$/
// FQDN for host-routed vhosts (ServerName), e.g. p2p.7rnx.net — at least one dot.
const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i

function validIp (ip) {
  const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip)
  if (!m) return false
  return m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255)
}

function validPort (p) {
  const n = Number(p)
  return Number.isInteger(n) && n > 0 && n < 65536
}

const DEFAULT_REGISTRY = {
  server: {
    host: '10.22.0.3',
    user: 'fleeky',
    configPath: '/etc/apache2/7rnx-proxy.conf'
  },
  nodes: [],
  services: [],
  hosts: []
}

export class Registry {
  constructor (configDir) {
    this.configDir = configDir || CONFIG_DIR
    this.configFile = path.join(this.configDir, 'registry.json')
    this.data = null
  }

  load () {
    if (!fs.existsSync(this.configFile)) {
      this.data = structuredClone(DEFAULT_REGISTRY)
      return this
    }
    this.data = JSON.parse(fs.readFileSync(this.configFile, 'utf8'))
    if (!this.data.hosts) this.data.hosts = [] // back-compat: registries predating vhost mode
    return this
  }

  save () {
    fs.mkdirSync(this.configDir, { recursive: true })
    fs.writeFileSync(this.configFile, JSON.stringify(this.data, null, 2) + '\n')
    return this
  }

  addNode (petname, ip, keetIdent) {
    if (!petname || !ip) throw new Error('petname and ip are required')
    if (!PETNAME_RE.test(petname)) throw new Error(`invalid petname "${petname}" (allowed: letters, digits, "-", "_"; must start alphanumeric)`)
    if (!validIp(ip)) throw new Error(`invalid IPv4 address "${ip}"`)

    const existing = this.data.nodes.find(n => n.petname === petname)
    if (existing) throw new Error(`Node "${petname}" already exists`)

    const ipExists = this.data.nodes.find(n => n.ip === ip)
    if (ipExists) throw new Error(`IP ${ip} already used by node "${ipExists.petname}"`)

    this.data.nodes.push({
      petname,
      ip,
      keetIdent: keetIdent || null,
      addedAt: new Date().toISOString()
    })
    this.save()
    return this
  }

  removeNode (petname) {
    const idx = this.data.nodes.findIndex(n => n.petname === petname)
    if (idx === -1) throw new Error(`Node "${petname}" not found`)

    this.data.nodes.splice(idx, 1)
    this.data.services = this.data.services.filter(s => s.node !== petname)
    this.data.hosts = this.data.hosts.filter(h => h.node !== petname)
    this.save()
    return this
  }

  listNodes () {
    return this.data.nodes
  }

  getNode (petname) {
    return this.data.nodes.find(n => n.petname === petname) || null
  }

  addService (nodePetname, serviceName, port, prefix, rootPath) {
    const node = this.getNode(nodePetname)
    if (!node) throw new Error(`Node "${nodePetname}" not found`)

    if (!serviceName || !port) throw new Error('serviceName and port are required')
    if (!PETNAME_RE.test(serviceName)) throw new Error(`invalid service name "${serviceName}" (allowed: letters, digits, "-", "_")`)
    if (!validPort(port)) throw new Error(`invalid port "${port}"`)
    if (rootPath && !PATH_RE.test(rootPath)) throw new Error(`invalid root path "${rootPath}" (must start with "/", allowed: letters, digits, "/", "-", "_")`)
    if (prefix && !PATH_RE.test(prefix)) throw new Error(`invalid prefix "${prefix}" (must start with "/")`)

    const existing = this.data.services.find(
      s => s.node === nodePetname && s.name === serviceName
    )
    if (existing) throw new Error(`Service "${serviceName}" already exists on node "${nodePetname}"`)

    this.data.services.push({
      node: nodePetname,
      name: serviceName,
      port: Number(port),
      prefix: prefix || '/',
      rootPath: rootPath || null,
      addedAt: new Date().toISOString()
    })
    this.save()
    return this
  }

  removeService (nodePetname, serviceName) {
    const idx = this.data.services.findIndex(
      s => s.node === nodePetname && s.name === serviceName
    )
    if (idx === -1) throw new Error(`Service "${serviceName}" not found on node "${nodePetname}"`)

    this.data.services.splice(idx, 1)
    this.save()
    return this
  }

  // --- Host-routed vhosts (a distinct mode from path-based services) ---
  // A vhost proxies a whole subdomain (ServerName) to a backend at the URL ROOT —
  // required for atproto PDS hosts, which serve /xrpc/* and /.well-known/* at the root
  // (a path prefix would be dropped by clients resolving /xrpc against the origin).
  addHost (name, nodePetname, port, serverName) {
    const node = this.getNode(nodePetname)
    if (!node) throw new Error(`Node "${nodePetname}" not found`)
    if (!name || !port || !serverName) throw new Error('name, port, and serverName are required')
    if (!PETNAME_RE.test(name)) throw new Error(`invalid host name "${name}" (allowed: letters, digits, "-", "_")`)
    if (!validPort(port)) throw new Error(`invalid port "${port}"`)
    // Allow a wildcard serverName (*.7rnx.net) — one vhost fronts every subdomain, routed to
    // the edge which resolves each handle via the fleet directory. Validate the base domain.
    const wildcard = serverName.startsWith('*.')
    if (!HOSTNAME_RE.test(wildcard ? serverName.slice(2) : serverName)) {
      throw new Error(`invalid serverName "${serverName}" (must be a FQDN like p2p.7rnx.net, or a wildcard like *.7rnx.net)`)
    }

    if (this.data.hosts.find(h => h.name === name)) throw new Error(`Host "${name}" already exists`)
    const dupServer = this.data.hosts.find(h => h.serverName === serverName)
    if (dupServer) throw new Error(`serverName "${serverName}" already used by host "${dupServer.name}"`)

    this.data.hosts.push({
      name,
      node: nodePetname,
      port: Number(port),
      serverName,
      addedAt: new Date().toISOString()
    })
    this.save()
    return this
  }

  removeHost (name) {
    const idx = this.data.hosts.findIndex(h => h.name === name)
    if (idx === -1) throw new Error(`Host "${name}" not found`)
    this.data.hosts.splice(idx, 1)
    this.save()
    return this
  }

  listHosts () { return this.data.hosts }
  getHost (name) { return this.data.hosts.find(h => h.name === name) || null }

  addBackend (serviceName, nodePetname, port, mode) {
    const service = this.data.services.find(s => s.name === serviceName)
    if (!service) throw new Error(`Service "${serviceName}" not found`)

    const node = this.getNode(nodePetname)
    if (!node) throw new Error(`Node "${nodePetname}" not found`)

    if (!validPort(port)) throw new Error(`invalid port "${port}"`)
    if (mode && mode !== 'active' && mode !== 'standby') throw new Error(`invalid mode "${mode}" (allowed: active, standby)`)

    if (!service.backends) {
      service.backends = [{
        node: service.node,
        port: service.port,
        mode: 'active'
      }]
    }

    const duplicate = service.backends.find(b => b.node === nodePetname)
    if (duplicate) throw new Error(`Backend for node "${nodePetname}" already exists on service "${serviceName}"`)

    service.backends.push({
      node: nodePetname,
      port: Number(port),
      mode: mode || 'active'
    })
    this.save()
    return this
  }

  removeBackend (serviceName, nodePetname) {
    const service = this.data.services.find(s => s.name === serviceName)
    if (!service) throw new Error(`Service "${serviceName}" not found`)

    if (!service.backends) throw new Error(`Service "${serviceName}" has no backends`)

    const idx = service.backends.findIndex(b => b.node === nodePetname)
    if (idx === -1) throw new Error(`Backend for node "${nodePetname}" not found on service "${serviceName}"`)

    if (service.backends.length <= 1) throw new Error(`Cannot remove last backend from service "${serviceName}"`)

    service.backends.splice(idx, 1)

    if (service.backends.length === 1) {
      const remaining = service.backends[0]
      service.node = remaining.node
      service.port = remaining.port
      delete service.backends
    }

    this.save()
    return this
  }

  getServiceWithBackends (serviceName) {
    const service = this.data.services.find(s => s.name === serviceName)
    if (!service) throw new Error(`Service "${serviceName}" not found`)

    const result = { ...service }

    if (service.backends) {
      result.resolvedBackends = service.backends.map(b => {
        const node = this.getNode(b.node)
        return {
          node: b.node,
          ip: node ? node.ip : null,
          port: b.port,
          mode: b.mode
        }
      })
    } else {
      const node = this.getNode(service.node)
      result.resolvedBackends = [{
        node: service.node,
        ip: node ? node.ip : null,
        port: service.port,
        mode: 'active'
      }]
    }

    return result
  }

  listServices (nodePetname) {
    if (nodePetname) {
      return this.data.services.filter(s => s.node === nodePetname)
    }
    return this.data.services
  }

  setServer (host, user) {
    if (host) this.data.server.host = host
    if (user) this.data.server.user = user
    this.save()
    return this
  }
}