diff --git a/.gitignore b/.gitignore index b3002d5..5ade90c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ config/grafana/alerting js-libp2p-datastore config.json listening-addrs.txt +datastore diff --git a/README.md b/README.md index d03946b..5ee1f23 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,39 @@ Please note that the RPC API server only listens on the loopback interface (127. } ``` +### Config file example + +A config file will be generated for you automatically, and stored at `$HOME/.config/@libp2p/amino-dht-bootstrapper/config.json`. The default config we generate looks like this: + +```json +{ + "addresses": { + "announce": [], + "noAnnounce": [], + "listen": [ + "/ip4/0.0.0.0/tcp/4003/ws", + "/ip4/0.0.0.0/tcp/4001", + "/ip6/::/tcp/4004/ws", + "/ip6/::/tcp/4001", + "/ip4/0.0.0.0/udp/4002/webrtc" + ] + }, + "bootstrap": { + "list": [ + "/dns4/am6.bootstrap.libp2p.io/tcp/443/wss/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb", + "/dns4/sg1.bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt", + "/dns4/sv15.bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN" + ] + }, + "privateKey": "generated by `auto-config.ts`", + "connectionManager": { + "inboundConnectionThreshold": 100, + "maxIncomingPendingConnections": 100, + "maxConnections": 500 + } +} +``` + ## Building the Docker Image Building should be straightforward from the root of the repository: diff --git a/example-config.json b/example-config.json index 7d317c2..6cf951a 100644 --- a/example-config.json +++ b/example-config.json @@ -15,10 +15,7 @@ "/dns4/sg1.bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt", "/dns4/sv15.bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN" ], - "Identity": { - "PeerID": "Generate with `ipfs init` and then grab from ~/.ipfs/config", - "PrivKey": "Generate with `ipfs init` and then grab from ~/.ipfs/config" - }, + "privateKey": "generated automatically by `auto-config.ts`", "connectionManager": { "inboundConnectionThreshold": 100, "maxIncomingPendingConnections": 100, diff --git a/package-lock.json b/package-lock.json index e9e6e1e..e4abe7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,6 @@ "@libp2p/interface": "^2.1.2", "@libp2p/kad-dht": "^14.0.0", "@libp2p/logger": "^5.1.0", - "@libp2p/peer-id": "^5.0.4", "@libp2p/ping": "^2.0.7", "@libp2p/prometheus-metrics": "^4.2.1", "@libp2p/tcp": "^10.0.8", diff --git a/package.json b/package.json index 27ee00e..7d365c5 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,6 @@ "@libp2p/interface": "^2.1.2", "@libp2p/kad-dht": "^14.0.0", "@libp2p/logger": "^5.1.0", - "@libp2p/peer-id": "^5.0.4", "@libp2p/ping": "^2.0.7", "@libp2p/prometheus-metrics": "^4.2.1", "@libp2p/tcp": "^10.0.8", diff --git a/src/index.ts b/src/index.ts index 65beaf9..3c8948a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,18 +2,15 @@ import { writeFileSync } from 'node:fs' import { createServer } from 'node:http' -import { isAbsolute, join } from 'node:path' import { parseArgs } from 'node:util' import { noise } from '@chainsafe/libp2p-noise' import { yamux } from '@chainsafe/libp2p-yamux' import { autoNAT } from '@libp2p/autonat' import { bootstrap } from '@libp2p/bootstrap' import { circuitRelayServer } from '@libp2p/circuit-relay-v2' -import { privateKeyFromProtobuf } from '@libp2p/crypto/keys' import { dcutr } from '@libp2p/dcutr' import { identify, identifyPush } from '@libp2p/identify' import { kadDHT, removePrivateAddressesMapper } from '@libp2p/kad-dht' -import { peerIdFromPrivateKey, peerIdFromString } from '@libp2p/peer-id' import { ping } from '@libp2p/ping' import { prometheusMetrics } from '@libp2p/prometheus-metrics' import { tcp } from '@libp2p/tcp' @@ -23,14 +20,13 @@ import { webSockets } from '@libp2p/websockets' import { LevelDatastore } from 'datastore-level' import { createLibp2p } from 'libp2p' import { register } from 'prom-client' -import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { createRpcServer } from './create-rpc-server.js' import { connectionsByEncrypterMetrics } from './services/connections-by-encrypter-metrics.js' import { connectionsByMultiplexerMetrics } from './services/connections-by-multiplexer-metrics.js' import { connectionsByTransportMetrics } from './services/connections-by-transport-metrics.js' import { versionsMetrics } from './services/versions-metrics.js' -import { readConfig } from './utils/load-config.js' -import type { PrivateKey } from '@libp2p/interface' +import { autoConfig } from './utils/auto-config.js' +import { decodePrivateKey } from './utils/peer-id.js' import type { Multiaddr } from '@multiformats/multiaddr' import type { Libp2pOptions, ServiceFactoryMap } from 'libp2p' @@ -39,11 +35,6 @@ process.addListener('uncaughtException', (err) => { process.exit(1) }) -function decodePeerId (privkeyStr: string): PrivateKey { - const privkeyBytes = uint8ArrayFromString(privkeyStr, 'base64pad') - return privateKeyFromProtobuf(privkeyBytes) -} - function writeListeningAddrsToFile (maddrs: Multiaddr[]): void { const addrs = maddrs.map((ma) => ma.toString()) const addrsString = addrs.join('\n') @@ -105,7 +96,7 @@ const args = parseArgs({ }) const { - config: configFilename, + config: argConfigFilename, 'enable-kademlia': argEnableKademlia, 'enable-autonat': argEnableAutonat, 'enable-tls': argEnableTls, @@ -122,16 +113,9 @@ if (argHelp === true) { process.exit(0) } -if (configFilename == null) { - throw new Error('--config must be provided') -} -const configFilepath = isAbsolute(configFilename) ? configFilename : join(process.cwd(), configFilename) -const config = readConfig(configFilepath) +const config = await autoConfig(argConfigFilename) -const privateKey = decodePeerId(config.Identity.PrivKey) -if (!peerIdFromString(config.Identity.PeerID).equals(peerIdFromPrivateKey(privateKey))) { - throw new Error('Config Identity.PeerId doesn\'t match Identity.PrivKey') -} +const privateKey = decodePrivateKey(config.privateKey) const metricsServer = createServer((req, res) => { if (req.url === argMetricsPath && req.method === 'GET') { @@ -159,7 +143,7 @@ await createRpcServer({ apiPort: parseInt(argApiPort ?? options['api-port'].defa const services: ServiceFactoryMap = { circuitRelay: circuitRelayServer(), bootstrap: bootstrap({ - list: config.Bootstrap + ...config.bootstrap }), identify: identify({ agentVersion: 'js-libp2p-bootstrapper' @@ -179,16 +163,14 @@ const libp2pOptions: Libp2pOptions = { datastore: new LevelDatastore(argDatastore ?? options.datastore.default), privateKey, addresses: { + ...config.addresses, announceFilter: (addrs) => { // filter out private IP addresses return addrs.filter((addr) => { const nodeAddress = addr.nodeAddress() return isPrivateIp(nodeAddress.address) !== true }) - }, - listen: config.Addresses.Swarm, - announce: config.Addresses.Announce, - noAnnounce: config.Addresses.NoAnnounce + } }, connectionManager: config.connectionManager, connectionGater: { diff --git a/src/utils/auto-config.ts b/src/utils/auto-config.ts new file mode 100644 index 0000000..f5e0056 --- /dev/null +++ b/src/utils/auto-config.ts @@ -0,0 +1,69 @@ +/* eslint-disable no-console */ +/** + * If a config option isn't used, and a user doesn't already have a config file, + * we should automatically generate one by calling `ipfs init` or similar with + * the Kubo npm package. + */ +import { writeFile, mkdir, access, constants } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join, isAbsolute } from 'node:path' +import { generateKeyPair } from '@libp2p/crypto/keys' +import { defaultConfig, type BootstrapConfig } from './default-config.js' +import { readConfig } from './load-config.js' +import { encodePrivateKey } from './peer-id.js' + +export const CONFIG_FOLDER = join(homedir(), '.config', '@libp2p', 'amino-dht-bootstrapper') +export const DEFAULT_CONFIG_NAME = 'config.json' +export const CONFIG_PATH = join(CONFIG_FOLDER, DEFAULT_CONFIG_NAME) + +/** + * check if DEFAULT_CONFIG_NAME exists, if it does, use it automatically + * if not, copy `defaultConfig` to `${CONFIG_FOLDER}/config.json` + * create a private key and peer ID and add it to the config + */ +export async function autoConfig (configPathArg?: string): Promise { + if (configPathArg != null) { + console.info('Attempting to use config file from %s', configPathArg) + /** + * If a config path is provided, attempt to use it. + * If it's not an absolute path, assume it's relative to the current working directory. + */ + const configFilepath = isAbsolute(configPathArg) ? configPathArg : join(process.cwd(), configPathArg) + return readConfig(configFilepath) + } + + console.info('Checking for config file at %s', CONFIG_PATH) + try { + const config = readConfig(CONFIG_PATH) + console.info('Config file found') + return config + } catch { + console.info('No config file found, generating one automatically...') + } + + // check for config folder existence, if it doesn't exist, create it. + try { + console.info('Checking for config folder at %s', CONFIG_FOLDER) + await access(CONFIG_FOLDER, constants.R_OK | constants.W_OK) + } catch (e) { + console.info('Config folder not found, creating it...') + await mkdir(CONFIG_FOLDER, { recursive: true }) + } + + try { + console.info('Using default config object: %O', defaultConfig) + const configJson = { ...defaultConfig } + + console.info('Generating private key and peer ID...') + const libp2pGeneratedPrivateKey = await generateKeyPair('Ed25519') + configJson.privateKey = encodePrivateKey(libp2pGeneratedPrivateKey) + + console.info('Writing config file') + await writeFile(CONFIG_PATH, JSON.stringify(configJson, null, 2)) + console.info('Config file created successfully at %s', CONFIG_PATH) + return configJson + } catch (e) { + console.error('Error creating config', e) + throw e + } +} diff --git a/src/utils/default-config.ts b/src/utils/default-config.ts new file mode 100644 index 0000000..1df30f2 --- /dev/null +++ b/src/utils/default-config.ts @@ -0,0 +1,36 @@ +import type { BootstrapInit } from '@libp2p/bootstrap' +import type { AddressManagerInit, ConnectionManagerInit } from 'libp2p' + +export interface BootstrapConfig { + addresses: Omit + connectionManager: ConnectionManagerInit + bootstrap: BootstrapInit + privateKey: string +} + +export const defaultConfig: BootstrapConfig = { + addresses: { + announce: [], + noAnnounce: [], + listen: [ + '/ip4/0.0.0.0/tcp/4003/ws', + '/ip4/0.0.0.0/tcp/4001', + '/ip6/::/tcp/4004/ws', + '/ip6/::/tcp/4001', + '/ip4/0.0.0.0/udp/4002/webrtc' + ] + }, + bootstrap: { + list: [ + '/dns4/am6.bootstrap.libp2p.io/tcp/443/wss/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb', + '/dns4/sg1.bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt', + '/dns4/sv15.bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN' + ] + }, + privateKey: 'generated by `auto-config.ts`', + connectionManager: { + inboundConnectionThreshold: 100, + maxIncomingPendingConnections: 100, + maxConnections: 500 + } +} diff --git a/src/utils/load-config.ts b/src/utils/load-config.ts index 94d2ad4..c66da73 100644 --- a/src/utils/load-config.ts +++ b/src/utils/load-config.ts @@ -1,45 +1,24 @@ import { readFileSync } from 'node:fs' -import type { ConnectionManagerInit } from 'libp2p' +import type { BootstrapConfig } from './default-config.js' export function readConfig (filepath: string): BootstrapConfig { const configString = readFileSync(filepath, 'utf8') const config = JSON.parse(configString) - validateKuboConfig(config) + validateConfig(config) return config } -function validateKuboConfig (config: any): config is KuboConfig { - validateKey(config, 'Bootstrap', 'Bootstrap') - validateKey(config, 'Addresses', 'Addresses') - validateKey(config.Addresses, 'Swarm', 'Addresses.Swarm') - validateKey(config.Addresses, 'Announce', 'Addresses.Announce') - validateKey(config.Addresses, 'NoAnnounce', 'Addresses.NoAnnounce') - validateKey(config, 'Identity', 'Identity') - validateKey(config.Identity, 'PeerID', 'Identity.PeerID') - validateKey(config.Identity, 'PrivKey', 'Identity.PrivKey') +function validateConfig (config: any): config is BootstrapConfig { + validateKey(config, 'bootstrap', 'bootstrap') + validateKey(config.bootstrap, 'list', 'bootstrap.list') + validateKey(config, 'addresses', 'addresses') + validateKey(config.addresses, 'listen', 'addresses.listen') + validateKey(config.addresses, 'announce', 'addresses.announce') + validateKey(config.addresses, 'noAnnounce', 'addresses.noAnnounce') + validateKey(config, 'privateKey', 'privateKey') return true } -/** - * Subset of options that we care about from the kubo config - */ -interface KuboConfig { - Bootstrap: string[] - Addresses: { - Swarm: string[] - Announce: string[] - NoAnnounce: string[] - } - Identity: { - PeerID: string - PrivKey: string - } -} - -type BootstrapConfig = KuboConfig & { - connectionManager: ConnectionManagerInit -} - function validateKey (config: any, key: string, path: string): void { if (config[key] == null) { throw new Error(`Config key missing: ${path}`) diff --git a/src/utils/peer-id.ts b/src/utils/peer-id.ts new file mode 100644 index 0000000..fa6a1ac --- /dev/null +++ b/src/utils/peer-id.ts @@ -0,0 +1,25 @@ +import { privateKeyFromProtobuf, privateKeyToProtobuf } from '@libp2p/crypto/keys' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import type { PrivateKey } from '@libp2p/interface' + +/** + * Decode's a privateKey string representation into a PrivateKey object + * for use by js-libp2p. + * + * The string representation is a base64pad encoded protobuf representation of + * the private key. This aligns with the way Kubo stores private keys in + * `~/home/.ipfs/config`. + */ +export function decodePrivateKey (privkeyStr: string): PrivateKey { + const privkeyBytes = uint8ArrayFromString(privkeyStr, 'base64pad') + return privateKeyFromProtobuf(privkeyBytes) +} + +/** + * Inverse of `decodePrivateKey`. Encodes a PrivateKey object into a string + */ +export function encodePrivateKey (privkey: PrivateKey): string { + const privKeyBytes = privateKeyToProtobuf(privkey) + return uint8ArrayToString(privKeyBytes, 'base64pad') +}