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
}
}