#!/usr/bin/env bare
/**
* Hyperident Gateway - Main Entry Point
*
* P2P gateway that bridges local services to the Hyperswarm network using
* deterministic identity-based addressing via hyperident profiles.
*/
import { GatewayIdentity } from './lib/gateway-identity.js'
import { UTUNManager } from './lib/utun-manager.js'
import { HyperswarmRouter } from './lib/hyperswarm-router.js'
import { configDir, defaultConfigPath, stateDir } from './lib/paths.js'
import { RegistryNode, request as registryRequest } from './lib/registry-node.js'
import { RegistryPolicy } from './lib/registry-policy.js'
import { loadOrCreateSwarmKeyPair, loadDeviceCred } from './lib/node-keys.js'
import { writeHostsIfChanged } from './lib/hosts-file.js'
import { signAttested } from 'hyperident/lib/attestation.js'
import b4a from 'b4a'
import { describeRelease, stagedNotice, statusDoc, autoApplyDecision, APPLY_EXIT_CODE } from './lib/release-info.js'
import path from 'bare-path'
import { supervisorRefusal, capabilityRefusal, tunnelInUseRefusal } from './lib/safeguards.js'
import fs from 'bare-fs/promises'
import fsSync from 'bare-fs'
import os from 'bare-os'
import minimist from 'minimist'
const VERSION = '0.3.2'
// Parse command line arguments
// Bare.argv is [bare, gateway.js, ...args]
const argv = minimist(Bare.argv.slice(2), {
string: ['config', 'admin', 'pool', 'role', 'ip', 'label'],
boolean: ['help', 'version', 'scan-ips'],
alias: {
c: 'config',
h: 'help',
v: 'version'
},
default: {
// Outside the install dir: a pgh update replaces that dir (lib/paths.js)
config: defaultConfigPath(os.homedir())
}
})
// Show help
if (argv.help) {
console.log(`
Hyperident Gateway v${VERSION}
Usage:
bare gateway.js [options]
Options:
-c, --config <path> Path to configuration file (default: ~/.config/hyperident-gateway/gateway-config.json)
--scan-ips Scan network for all active gateway IPs and exit
registry <command> Talk to the running gateway's address registry (#4):
status
found --admin <rootHex> [--admin ...] [--pool <cidr>] (once, on the founding node)
seed [--ip <ip>] [--label <name>] (bind this device, claim ip + name, bind swarm key)
admit <rootHex> [--role admin|member]
expel <rootHex>
-h, --help Show this help message
-v, --version Show version number
Examples:
bare gateway.js
bare gateway.js --config /etc/hyperident-gateway/config.json
sudo systemctl start hyperident-gateway
For more information, see: docs/SETUP.md
`)
Bare.exit(0)
}
// Show version
if (argv.version) {
console.log(`Hyperident Gateway v${VERSION}`)
Bare.exit(0)
}
/**
* Load configuration from file
*/
async function loadConfig(configPath) {
try {
const data = await fs.readFile(configPath, 'utf8')
return JSON.parse(data)
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`Configuration file not found: ${configPath}`)
}
throw new Error(`Failed to load configuration: ${error.message}`)
}
}
/**
* Hyperswarm options from config. gateway.bootstrap (optional) points the
* swarm at a private DHT, e.g. a testnet for benchmarks; absent = public DHT.
*/
function swarmOptionsFromConfig(config) {
if (config.gateway.bootstrap) return { bootstrap: config.gateway.bootstrap }
return {}
}
/**
* Main gateway initialization
*/
async function startGateway(config) {
const startedAt = new Date().toISOString()
console.log('🚀 Starting Hyperident Gateway v' + VERSION)
console.log('============================================================\n')
// Initialize GatewayIdentity
console.log('📝 Initializing GatewayIdentity...')
const gatewayIdentity = new GatewayIdentity({
subnet: config.gateway.subnet,
gatewayIP: config.gateway.virtualIP
})
console.log(` Subnet: ${config.gateway.subnet}`)
console.log(` Gateway IP: ${config.gateway.virtualIP}\n`)
// Address registry (#4), opt-in via gateway.registry.enabled. Log-only in this release: a registry
// that fails to start is reported and the VPN runs without it.
const registryConfig = config.gateway.registry || {}
let swarmOptions = swarmOptionsFromConfig(config)
let registryNode = null
let policy = null
if (registryConfig.enabled) {
try {
const keyPair = loadOrCreateSwarmKeyPair(configDir(os.homedir()))
swarmOptions = { ...swarmOptions, keyPair }
registryNode = new RegistryNode({
dir: path.join(stateDir(os.homedir()), 'registry'),
stateDir: stateDir(os.homedir()),
key: registryConfig.key || null,
swarmOptions: swarmOptionsFromConfig(config),
routerSwarmKey: b4a.toString(keyPair.publicKey, 'hex')
})
await registryNode.start()
policy = new RegistryPolicy({ registry: registryNode.registry })
registryNode.policy = policy
} catch (err) {
console.error('⚠️ Address registry not started (the VPN runs without it): ' + err.message)
if (registryNode) await registryNode.stop().catch(() => {})
registryNode = null
policy = null
}
}
// Initialize HyperswarmRouter (before UTUNManager so we can reference it)
const hyperswarmRouter = new HyperswarmRouter({
gatewayIdentity,
networkTopic: config.gateway.networkTopic || 'hyperident-network',
swarmOptions,
policy,
onError: (error) => {
console.error('❌ Hyperswarm Error:', error.message)
},
onConnection: (connection, isInitiator) => {
console.log(` Connected to peer (${isInitiator ? 'outgoing' : 'incoming'})`)
}
})
// Initialize UTUNManager
console.log('📝 Initializing UTUN Manager...')
const utunManager = new UTUNManager({
deviceName: config.gateway.tunDevice || 'tun0',
gatewayIP: config.gateway.virtualIP,
subnet: config.gateway.subnet,
netmask: '255.255.0.0', // /16 subnet
mtu: config.gateway.mtu || 1500,
gatewayIdentity,
onPacketReceived: (data) => {
// Forward packet to P2P network
hyperswarmRouter.forwardToP2P(data.packet, data.destIP)
},
onError: (error) => {
console.error('❌ UTUN Error:', error.message)
}
})
// Link router to UTUN manager
hyperswarmRouter.utunManager = utunManager
// Create and configure TUN device
console.log('📝 Creating TUN device...')
await utunManager.open()
console.log()
// Start Hyperswarm router and join network topic
await hyperswarmRouter.start()
await hyperswarmRouter.joinNetwork()
// IP conflict detection — scan for peers announcing our IP
console.log('📝 Scanning for IP conflicts (5 seconds)...')
const peerIPs = await hyperswarmRouter.scanNetworkIPs(5000)
if (peerIPs.size > 0) {
console.log(` Found ${peerIPs.size} peer IP(s) on the network:`)
for (const [ip, peers] of peerIPs) {
console.log(` ${ip} (${peers.length} peer(s))`)
}
} else {
console.log(' No peers found (may take longer for DHT discovery)')
}
const conflict = hyperswarmRouter.checkIPConflict(config.gateway.virtualIP)
if (conflict.taken) {
console.error(`\n❌ FATAL: IP ${config.gateway.virtualIP} is already in use by ${conflict.peers} peer(s)!`)
console.error(' Choose a different virtualIP in your config or resolve the conflict.')
await hyperswarmRouter.stop()
await utunManager.close()
Bare.exitCode = 1
return
}
console.log(` ✅ IP ${config.gateway.virtualIP} is available\n`)
// Setup graceful shutdown
let isShuttingDown = false
const shutdown = async (exitCode = 0) => {
if (isShuttingDown) return
isShuttingDown = true
console.log('\n\n🛑 Shutting down gateway...')
try {
await hyperswarmRouter.stop()
console.log('✅ Hyperswarm router stopped')
} catch (error) {
console.error('❌ Error stopping Hyperswarm router:', error.message)
}
try {
await utunManager.close()
console.log('✅ TUN device closed')
} catch (error) {
console.error('❌ Error closing TUN device:', error.message)
}
if (registryNode) {
try {
await registryNode.stop()
console.log('✅ Address registry stopped')
} catch (error) {
console.error('❌ Error stopping the address registry:', error.message)
}
}
console.log('✅ Gateway stopped\n')
Bare.exit(exitCode)
}
Bare.on('SIGINT', () => shutdown(0))
Bare.on('SIGTERM', () => shutdown(0))
console.log('============================================================')
console.log('✅ Gateway is running!')
console.log('============================================================\n')
console.log(`Virtual IP: ${config.gateway.virtualIP}`)
console.log(`Subnet: ${config.gateway.subnet}`)
// The topic string is the network's secret: log only a prefix of its hash
console.log(`Network topic hash: ${hyperswarmRouter.topic.substring(0, 8)}…`)
console.log()
console.log('Press Ctrl+C to stop\n')
// INSTALLED != RUNNING from the inside (lib/release-info.js): name the
// release this process runs, and keep status.json current.
const manifest = readInstallManifest(INSTALL_DIR)
console.log(describeRelease(manifest, INSTALL_DIR))
const reportStatus = () => {
const staged = readInstallManifest(INSTALL_DIR + '.next')
const notice = stagedNotice(staged)
if (notice) console.log('⚠️ ' + notice)
const doc = statusDoc({
pid: Bare.pid,
startedAt,
manifest,
staged,
mtu: config.gateway.mtu || 1500,
topicHash: hyperswarmRouter.topic,
now: new Date().toISOString()
})
if (policy) doc.registry = { mode: 'log-only', now: policy.snapshot(), total: { ...policy.counters } }
writeStatus(doc)
}
reportStatus()
// The registry's names as a hosts fragment for the root-owned resolver helper (lib/hosts-file.js)
const syncHosts = async () => {
if (!registryNode) return
try {
if (writeHostsIfChanged(stateDir(os.homedir()), await registryNode.registry.names())) console.log('📒 hosts.hyperlocal updated')
} catch (err) {
console.error('⚠️ hosts.hyperlocal not written: ' + err.message)
}
}
await syncHosts()
// Apply a staged release by exiting for systemd to restart us through the launcher (lib/release-info.js).
// Once per staged tree: if it is still staged after the restart, say so instead of looping.
const autoApplyFile = path.join(stateDir(os.homedir()), 'auto-apply.json')
const maybeApplyStaged = async () => {
let lastAttempt = null
try { lastAttempt = JSON.parse(fsSync.readFileSync(autoApplyFile, 'utf8')).treeHash || null } catch {}
const decision = autoApplyDecision({
manifest,
staged: readInstallManifest(INSTALL_DIR + '.next'),
lastAttempt,
enabled: config.gateway.autoApply !== false
})
if (!decision.apply) {
if (decision.reason.includes('still staged')) console.error('⚠️ ' + decision.reason)
return
}
console.log('🔄 ' + decision.reason + ': exiting ' + APPLY_EXIT_CODE + ' for systemd to restart through the launcher')
try {
fsSync.writeFileSync(autoApplyFile, JSON.stringify({ treeHash: readInstallManifest(INSTALL_DIR + '.next').treeHash, at: new Date().toISOString() }) + '\n')
} catch (err) {
console.error('⚠️ not applying: cannot record the attempt (' + err.message + ')')
return
}
await shutdown(APPLY_EXIT_CODE)
}
// Log stats periodically
setInterval(async () => {
if (policy) await policy.refresh().catch((err) => console.error('⚠️ registry refresh: ' + err.message))
await syncHosts()
reportStatus()
await maybeApplyStaged()
const tunStats = utunManager.getStats()
const routerStats = hyperswarmRouter.getStats()
console.log(`[${new Date().toISOString()}] Stats:`)
console.log(` TUN: ${tunStats.packetsReceived} rx, ${tunStats.packetsSent} tx, ${tunStats.packetsDropped} dropped`)
// utun's own counters (cumulative): packets the device dropped on read,
// and writes rejected because its 32-slot queue to the kernel was full
if (tunStats.tun) {
console.log(` TUN queue: ${tunStats.tun.rxDrop} rxDrop, ${tunStats.tun.txRejected} txRejected, ${tunStats.tun.txPackets} txPackets`)
}
console.log(` P2P: ${routerStats.packetsFromP2P} rx, ${routerStats.packetsToP2P} tx, ${routerStats.totalConnections} connections`)
console.log(` Routing: ${routerStats.routingTableSize} known IPs`)
if (policy) {
const c = policy.counters
const n = policy.snapshot()
console.log(` Registry (log-only) now: ${n.unknownPeers}/${n.connections} unknown peers, ${n.unownedAddresses} unowned addresses; total: ${c.unknownPeer} unknownPeer, ${c.unownedAnnounce} unownedAnnounce, ${c.spoofedSource} spoofedSource`)
}
}, 60000) // Every minute
}
/**
* Main entry point
*/
/**
* Scan the network for all active gateway IPs without starting the TUN.
* Useful for finding which IPs are taken before configuring a new gateway.
*/
async function scanIPs (config) {
console.log('🔍 Scanning Hyperident Gateway network for active IPs...')
console.log(` Subnet: ${config.gateway.subnet}`)
console.log()
// Need a minimal GatewayIdentity just for the constructor
const gatewayIdentity = new GatewayIdentity({
subnet: config.gateway.subnet,
gatewayIP: config.gateway.virtualIP,
staticMappings: config.staticMappings || {}
})
const hyperswarmRouter = new HyperswarmRouter({
gatewayIdentity,
networkTopic: config.gateway.networkTopic || 'hyperident-network',
swarmOptions: swarmOptionsFromConfig(config),
onError: () => {},
onConnection: () => {}
})
await hyperswarmRouter.start()
await hyperswarmRouter.joinNetwork()
console.log('⏳ Listening for announcements (10 seconds)...\n')
const peerIPs = await hyperswarmRouter.scanNetworkIPs(10000)
if (peerIPs.size === 0) {
console.log(' No peers found. The network may be empty or DHT discovery')
console.log(' may take longer. Try running again or increasing scan duration.')
} else {
console.log('Active IPs on the network:')
console.log('─────────────────────────')
for (const [ip, peers] of peerIPs) {
const own = ip === config.gateway.virtualIP ? ' ← (this gateway)' : ''
console.log(` ${ip} (${peers.length} connection(s))${own}`)
}
console.log()
console.log(`Total: ${peerIPs.size} IP(s) found`)
}
// Check if our configured IP is taken
const conflict = hyperswarmRouter.checkIPConflict(config.gateway.virtualIP)
if (conflict.taken) {
console.log(`\n⚠️ WARNING: Your configured IP ${config.gateway.virtualIP} is already in use!`)
} else {
console.log(`\n✅ Your configured IP ${config.gateway.virtualIP} is available.`)
}
// Suggest next available IPs
const takenIPs = new Set(peerIPs.keys())
const suggestions = []
for (let i = 1; i <= 10 && suggestions.length < 3; i++) {
const candidate = `10.22.0.${i}`
if (!takenIPs.has(candidate)) {
suggestions.push(candidate)
}
}
if (suggestions.length > 0) {
console.log(` Available IPs: ${suggestions.join(', ')}`)
}
await hyperswarmRouter.stop()
console.log()
}
/** A hostname as a registry label: [a-z0-9-], no leading/trailing '-', at most 63. */
function hostnameLabel () {
return os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 63)
}
const short = (h) => (h ? String(h).slice(0, 12) + '…' : '-')
function printRegistry (info) {
console.log(`Registry ${info.key}`)
console.log(` this node: writer ${short(info.writer)} ${info.writable ? 'writable' : 'NOT writable'}` +
(info.device ? `, device ${short(info.device.device)} of root ${short(info.device.root)}` : ', not bound to a device'))
console.log(` router swarm key: ${short(info.routerSwarmKey)}`)
if (!info.founded) { console.log(' not founded yet'); return }
console.log(` pool: ${info.founded.pool}`)
console.log(' members:')
for (const m of info.members) console.log(` ${short(m.root)} ${m.role}`)
console.log(' devices:')
for (const d of info.devices) console.log(` ${short(d.device)} root ${short(d.root)} swarm ${short(d.swarmKey)}`)
console.log(' addresses:')
for (const e of info.ips) {
const names = info.names.filter((n) => n.ip === e.ip).map((n) => n.label + '.hyperlocal')
console.log(` ${e.ip} → device ${short(e.device)} ${names.join(' ')}`)
}
if (info.rejected.length) {
console.log(' refused ops from this node:')
for (const r of info.rejected) console.log(` #${r.length} ${r.t}: ${r.reason}`)
}
if (info.checks) {
const { now: n, total: c } = info.checks
console.log(` checks (log-only) now: ${n.unknownPeers}/${n.connections} unknown peers, ${n.unownedAddresses} unowned addresses`)
console.log(` checks (log-only) total: ${c.unknownPeer} unknownPeer, ${c.unownedAnnounce} unownedAnnounce, ${c.spoofedSource} spoofedSource`)
}
}
/** `registry <command>`: talks to the running gateway over its control socket (lib/registry-node.js). */
async function registryCli (config) {
const home = os.homedir()
const state = stateDir(home)
const cmd = argv._[1]
// Sign "this device owns that writer core" here, with the device key; the daemon only gets the proof.
const signedBinding = async () => {
const cred = loadDeviceCred(home)
const { writer } = await registryRequest(state, { cmd: 'writer' })
return signAttested(b4a.from(writer, 'hex'), cred)
}
if (cmd === 'status') {
printRegistry(await registryRequest(state, { cmd: 'info' }))
} else if (cmd === 'found') {
const admins = [].concat(argv.admin || [])
printRegistry(await registryRequest(state, { cmd: 'found', bound: await signedBinding(), admins, pool: argv.pool || config.gateway.subnet }))
} else if (cmd === 'seed') {
const info = await registryRequest(state, { cmd: 'info' })
const bound = info.device ? null : await signedBinding()
printRegistry(await registryRequest(state, { cmd: 'seed', bound, ip: argv.ip || config.gateway.virtualIP, label: argv.label || hostnameLabel() }))
} else if (cmd === 'admit' || cmd === 'expel') {
const root = argv._[2]
if (!root) throw new Error(`registry ${cmd} needs a root public key (hex)`)
printRegistry(await registryRequest(state, { cmd, root: String(root), role: argv.role || 'member' }))
} else {
throw new Error('usage: registry status | found --admin <root> [--pool <cidr>] | seed [--ip <ip>] [--label <name>] | admit <root> [--role admin|member] | expel <root>')
}
}
// The directory this gateway runs from. Installed by pgh it is
// <store>/apps/hyperident-gateway, with .pgh-install.json beside gateway.js and
// a staged update (if any) in <dir>.next.
const INSTALL_DIR = decodeURIComponent(new URL('.', import.meta.url).pathname).replace(/\/$/, '')
function readInstallManifest (dir) {
try { return JSON.parse(fsSync.readFileSync(path.join(dir, '.pgh-install.json'), 'utf8')) } catch { return null }
}
// status.json for tools comparing installed vs running; written atomically
// (tmp + rename) so a reader never sees half a document. Best-effort: a
// failure is logged, never fatal.
function writeStatus (doc) {
const dir = stateDir(os.homedir())
const file = path.join(dir, 'status.json')
try {
fsSync.mkdirSync(dir, { recursive: true })
fsSync.writeFileSync(file + '.tmp', JSON.stringify(doc, null, 2) + '\n')
fsSync.renameSync(file + '.tmp', file)
} catch (err) {
console.error('⚠️ status.json not written: ' + err.message)
}
}
// This process's capability sets; '' where /proc is absent (not Linux), which
// capabilityRefusal treats as "cannot tell", never as "missing".
async function readProcStatus () {
try { return await fs.readFile('/proc/self/status', 'utf8') } catch { return '' }
}
async function main() {
try {
// Refuse to start where the gateway cannot work, before any TUN or swarm
// work (lib/safeguards.js). --scan-ips and `registry` create no TUN, so they run anywhere.
const isRegistryCli = argv._[0] === 'registry'
if (!argv['scan-ips'] && !isRegistryCli) {
const refusal = supervisorRefusal({ PGH_APP_NAME: os.getEnv('PGH_APP_NAME') }) ||
capabilityRefusal(await readProcStatus()) ||
tunnelInUseRefusal((p) => fsSync.existsSync(p))
if (refusal) {
console.error('\n❌ ' + refusal)
Bare.exitCode = 1
return
}
}
// Load configuration
const config = await loadConfig(argv.config)
if (isRegistryCli) {
await registryCli(config)
return
}
// Handle --scan-ips mode
if (argv['scan-ips']) {
await scanIPs(config)
return
}
// Start gateway
await startGateway(config)
} catch (error) {
console.error('\n❌ Fatal error:', error.message)
console.error(error.stack)
Bare.exitCode = 1
}
}
// Run
main()