Skip to content

Commit

Permalink
feat: create config automatically if not provided (#151)
Browse files Browse the repository at this point in the history
* feat: create config automatically if not provided

* fix: better XDG config folder convention

* feat: use jsonc-parser for reading config

* fix: better internet citizenship

* chore: ignore persistent datastore

* chore: move default config to another file

* docs: add default config example to README

* chore: apply suggestions from code review

* chore: derive PeerID from privateKey

and remove from config

* chore: simplify config path

* chore: remove unused dep

---------

Co-authored-by: Daniel N <[email protected]>
  • Loading branch information
SgtPooki and 2color authored Oct 24, 2024
1 parent 1a49861 commit f869934
Show file tree
Hide file tree
Showing 10 changed files with 183 additions and 63 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ config/grafana/alerting
js-libp2p-datastore
config.json
listening-addrs.txt
datastore
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 1 addition & 4 deletions example-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 8 additions & 26 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand All @@ -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')
Expand Down Expand Up @@ -105,7 +96,7 @@ const args = parseArgs({
})

const {
config: configFilename,
config: argConfigFilename,
'enable-kademlia': argEnableKademlia,
'enable-autonat': argEnableAutonat,
'enable-tls': argEnableTls,
Expand All @@ -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') {
Expand Down Expand Up @@ -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'
Expand All @@ -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: {
Expand Down
69 changes: 69 additions & 0 deletions src/utils/auto-config.ts
Original file line number Diff line number Diff line change
@@ -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<BootstrapConfig> {
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
}
}
36 changes: 36 additions & 0 deletions src/utils/default-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { BootstrapInit } from '@libp2p/bootstrap'
import type { AddressManagerInit, ConnectionManagerInit } from 'libp2p'

export interface BootstrapConfig {
addresses: Omit<AddressManagerInit, 'announceFilter'>
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
}
}
41 changes: 10 additions & 31 deletions src/utils/load-config.ts
Original file line number Diff line number Diff line change
@@ -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}`)
Expand Down
25 changes: 25 additions & 0 deletions src/utils/peer-id.ts
Original file line number Diff line number Diff line change
@@ -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')
}

0 comments on commit f869934

Please sign in to comment.