diff --git a/.gitignore b/.gitignore index 810e8544..2603f704 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -manager/target/ -**/*.rs.bk *.s9pk +startos/*.js +node_modules/ .DS_Store .vscode/ -scripts/embassy.js -docker-images/ +docker-images +javascript \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index fabdfe50..94204a97 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "bitcoin"] path = bitcoin - url = https://github.com/bitcoin/bitcoin + url = https://github.com/bitcoin/bitcoin/ diff --git a/Makefile b/Makefile index bb7a18de..56a71c58 100644 --- a/Makefile +++ b/Makefile @@ -1,67 +1,33 @@ -PKG_VERSION := $(shell yq e ".version" manifest.yaml) -PKG_ID := $(shell yq e ".id" manifest.yaml) -MANAGER_SRC := $(shell find ./manager -name '*.rs') manager/Cargo.toml manager/Cargo.lock -VERSION_CORE := $(shell (cd bitcoin && git describe) | sed 's/^v//') +PACKAGE_ID := bitcoind -.DELETE_ON_ERROR: +# Phony targets +.PHONY: all clean install -all: verify +# Default target +all: ${PACKAGE_ID}.s9pk -clean: - rm -f $(PKG_ID).s9pk - rm -f docker-images/*.tar - rm -f scripts/*.js +# Build targets +${PACKAGE_ID}.s9pk: $(shell start-cli s9pk list-ingredients) + start-cli s9pk pack -verify: $(PKG_ID).s9pk - @start-sdk verify s9pk $(PKG_ID).s9pk - @echo " Done!" - @echo " Filesize: $(shell du -h $(PKG_ID).s9pk) is ready" +./javascript/index.js: $(shell git ls-files startos) tsconfig.json node_modules package.json + npm run build -# for rebuilding just the arm image. -arm: - @rm -f docker-images/x86_64.tar - @ARCH=aarch64 $(MAKE) -s +node_modules: package.json package-lock.json + npm ci -# for rebuilding just the x86 image. -x86: - @rm -f docker-images/aarch64.tar - @ARCH=x86_64 $(MAKE) -s +package-lock.json: package.json + npm i -$(PKG_ID).s9pk: manifest.yaml assets/compat/* docker-images/aarch64.tar docker-images/x86_64.tar instructions.md scripts/embassy.js -ifeq ($(ARCH),aarch64) - @echo "start-sdk: Preparing aarch64 package ..." -else ifeq ($(ARCH),x86_64) - @echo "start-sdk: Preparing x86_64 package ..." -else - @echo "start-sdk: Preparing Universal Package ..." -endif - @start-sdk pack +# Clean target +clean: + rm -rf ${PACKAGE_ID}.s9pk + rm -rf javascript + rm -rf node_modules +# Install target install: - @if [ ! -f ~/.embassy/config.yaml ]; then echo "You must define \"host: http://server-name.local\" in ~/.embassy/config.yaml config file first."; exit 1; fi - @echo "\nInstalling to $$(grep -v '^#' ~/.embassy/config.yaml | cut -d'/' -f3) ...\n" - @[ -f $(PKG_ID).s9pk ] || ( $(MAKE) && echo "\nInstalling to $$(grep -v '^#' ~/.embassy/config.yaml | cut -d'/' -f3) ...\n" ) - @start-cli package install $(PKG_ID).s9pk - -docker-images/aarch64.tar: Dockerfile docker_entrypoint.sh manager/target/aarch64-unknown-linux-musl/release/bitcoind-manager manifest.yaml check-rpc.sh check-synced.sh actions/* -ifeq ($(ARCH),x86_64) -else - mkdir -p docker-images - docker buildx build --tag start9/$(PKG_ID)/main:$(PKG_VERSION) --build-arg ARCH=aarch64 --build-arg PLATFORM=arm64 --platform=linux/arm64 -o type=docker,dest=docker-images/aarch64.tar . -endif - -docker-images/x86_64.tar: Dockerfile docker_entrypoint.sh manager/target/x86_64-unknown-linux-musl/release/bitcoind-manager manifest.yaml check-rpc.sh check-synced.sh actions/* -ifeq ($(ARCH),aarch64) -else - mkdir -p docker-images - docker buildx build --tag start9/$(PKG_ID)/main:$(PKG_VERSION) --build-arg ARCH=x86_64 --build-arg PLATFORM=amd64 --platform=linux/amd64 -o type=docker,dest=docker-images/x86_64.tar . -endif - -manager/target/aarch64-unknown-linux-musl/release/bitcoind-manager: $(MANAGER_SRC) - docker run --rm -v ~/.cargo/registry:/root/.cargo/registry -v "$(shell pwd)"/manager:/home/rust/src messense/rust-musl-cross:aarch64-musl cargo build --release - -manager/target/x86_64-unknown-linux-musl/release/bitcoind-manager: $(MANAGER_SRC) - docker run --rm -v ~/.cargo/registry:/root/.cargo/registry -v "$(shell pwd)"/manager:/home/rust/src messense/rust-musl-cross:x86_64-musl cargo build --release - -scripts/embassy.js: scripts/**/*.ts - deno bundle scripts/embassy.ts scripts/embassy.js + @if [ ! -f ~/.startos/config.yaml ]; then echo "You must define \"host: http://server-name.local\" in ~/.startos/config.yaml config file first."; exit 1; fi + @echo "\nInstalling to $$(grep -v '^#' ~/.startos/config.yaml | cut -d'/' -f3) ...\n" + @[ -f $(PACKAGE_ID).s9pk ] || ( $(MAKE) && echo "\nInstalling to $$(grep -v '^#' ~/.startos/config.yaml | cut -d'/' -f3) ...\n" ) + @start-cli package install -s $(PACKAGE_ID).s9pk diff --git a/.github/workflows/buildService.yml b/Old/.github/workflows/buildService.yml similarity index 100% rename from .github/workflows/buildService.yml rename to Old/.github/workflows/buildService.yml diff --git a/.github/workflows/releaseService.yml b/Old/.github/workflows/releaseService.yml similarity index 100% rename from .github/workflows/releaseService.yml rename to Old/.github/workflows/releaseService.yml diff --git a/Old/.gitignore b/Old/.gitignore new file mode 100644 index 00000000..810e8544 --- /dev/null +++ b/Old/.gitignore @@ -0,0 +1,7 @@ +manager/target/ +**/*.rs.bk +*.s9pk +.DS_Store +.vscode/ +scripts/embassy.js +docker-images/ diff --git a/Old/.gitmodules b/Old/.gitmodules new file mode 100644 index 00000000..fabdfe50 --- /dev/null +++ b/Old/.gitmodules @@ -0,0 +1,3 @@ +[submodule "bitcoin"] + path = bitcoin + url = https://github.com/bitcoin/bitcoin diff --git a/Dockerfile b/Old/Dockerfile similarity index 100% rename from Dockerfile rename to Old/Dockerfile diff --git a/actions/reindex.sh b/Old/actions/reindex.sh similarity index 100% rename from actions/reindex.sh rename to Old/actions/reindex.sh diff --git a/actions/reindex_chainstate.sh b/Old/actions/reindex_chainstate.sh similarity index 100% rename from actions/reindex_chainstate.sh rename to Old/actions/reindex_chainstate.sh diff --git a/assets/compat/bitcoin.conf.template b/Old/assets/compat/bitcoin.conf.template similarity index 100% rename from assets/compat/bitcoin.conf.template rename to Old/assets/compat/bitcoin.conf.template diff --git a/check-rpc.sh b/Old/check-rpc.sh similarity index 100% rename from check-rpc.sh rename to Old/check-rpc.sh diff --git a/check-synced.sh b/Old/check-synced.sh similarity index 100% rename from check-synced.sh rename to Old/check-synced.sh diff --git a/docker_entrypoint.sh b/Old/docker_entrypoint.sh similarity index 100% rename from docker_entrypoint.sh rename to Old/docker_entrypoint.sh diff --git a/manager/.gitignore b/Old/manager/.gitignore similarity index 100% rename from manager/.gitignore rename to Old/manager/.gitignore diff --git a/manager/Cargo.lock b/Old/manager/Cargo.lock similarity index 100% rename from manager/Cargo.lock rename to Old/manager/Cargo.lock diff --git a/manager/Cargo.toml b/Old/manager/Cargo.toml similarity index 100% rename from manager/Cargo.toml rename to Old/manager/Cargo.toml diff --git a/manager/src/main.rs b/Old/manager/src/main.rs similarity index 100% rename from manager/src/main.rs rename to Old/manager/src/main.rs diff --git a/manifest.yaml b/Old/manifest.yaml similarity index 100% rename from manifest.yaml rename to Old/manifest.yaml diff --git a/scripts/dependencies.ts b/Old/scripts/dependencies.ts similarity index 100% rename from scripts/dependencies.ts rename to Old/scripts/dependencies.ts diff --git a/scripts/embassy.ts b/Old/scripts/embassy.ts similarity index 100% rename from scripts/embassy.ts rename to Old/scripts/embassy.ts diff --git a/scripts/services/action.ts b/Old/scripts/services/action.ts similarity index 100% rename from scripts/services/action.ts rename to Old/scripts/services/action.ts diff --git a/scripts/services/getConfig.ts b/Old/scripts/services/getConfig.ts similarity index 98% rename from scripts/services/getConfig.ts rename to Old/scripts/services/getConfig.ts index 3bcc08c2..618db90f 100644 --- a/scripts/services/getConfig.ts +++ b/Old/scripts/services/getConfig.ts @@ -123,13 +123,13 @@ export const getConfig: T.ExpectedExports.getConfig = async (effects) => { "zmq-enabled": { type: "boolean", name: "ZeroMQ Enabled", - description: "The ZeroMQ interface is useful for some applications which might require data related to block and transaction events from Bitcoin Core. For example, LND requires ZeroMQ be enabled for LND to get the latest block data", + description: "The ZeroMQ interface is useful for some applications which might require data related to block and transaction events from Bitcoin. For example, LND requires ZeroMQ be enabled for LND to get the latest block data", default: true, }, txindex: { type: "boolean", name: "Transaction Index", - description: "By enabling Transaction Index (txindex) Bitcoin Core will build a complete transaction index. This allows Bitcoin Core to access any transaction with commands like `gettransaction`.", + description: "By enabling Transaction Index (txindex) Bitcoin will build a complete transaction index. This allows Bitcoin to access any transaction with commands like `gettransaction`.", default: allowUnpruned, }, coinstatsindex: { @@ -302,7 +302,7 @@ export const getConfig: T.ExpectedExports.getConfig = async (effects) => { }, }, }, - pruning: { + prune: { type: "union", name: "Pruning Settings", description: diff --git a/scripts/services/migrations.ts b/Old/scripts/services/migrations.ts similarity index 100% rename from scripts/services/migrations.ts rename to Old/scripts/services/migrations.ts diff --git a/scripts/services/properties.ts b/Old/scripts/services/properties.ts similarity index 100% rename from scripts/services/properties.ts rename to Old/scripts/services/properties.ts diff --git a/scripts/services/setConfig.ts b/Old/scripts/services/setConfig.ts similarity index 100% rename from scripts/services/setConfig.ts rename to Old/scripts/services/setConfig.ts diff --git a/assets/rpcauth/rpcauth.py b/assets/rpcauth/rpcauth.py new file mode 100755 index 00000000..cc7bba1f --- /dev/null +++ b/assets/rpcauth/rpcauth.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# Copyright (c) 2015-2021 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +from argparse import ArgumentParser +from getpass import getpass +from secrets import token_hex, token_urlsafe +import hmac + +def generate_salt(size): + """Create size byte hex salt""" + return token_hex(size) + +def generate_password(): + """Create 32 byte b64 password""" + return token_urlsafe(32) + +def password_to_hmac(salt, password): + m = hmac.new(salt.encode('utf-8'), password.encode('utf-8'), 'SHA256') + return m.hexdigest() + +def main(): + parser = ArgumentParser(description='Create login credentials for a JSON-RPC user') + parser.add_argument('username', help='the username for authentication') + parser.add_argument('password', help='leave empty to generate a random password or specify "-" to prompt for password', nargs='?') + args = parser.parse_args() + + if not args.password: + args.password = generate_password() + elif args.password == '-': + args.password = getpass() + + # Create 16 byte hex salt + salt = generate_salt(16) + password_hmac = password_to_hmac(salt, args.password) + + print('String to be appended to bitcoin.conf:') + print(f'rpcauth={args.username}:{salt}${password_hmac}') + print(f'Your password:\n{args.password}') + +if __name__ == '__main__': + main() diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..26600907 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,425 @@ +{ + "name": "bitcoind", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "bitcoind", + "dependencies": { + "@start9labs/start-sdk": "0.3.6-beta.3", + "diskusage": "^1.2.0", + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.5", + "@types/node": "^20.11.30", + "@vercel/ncc": "^0.38.1", + "prettier": "^3.2.5", + "typescript": "^5.4.3" + } + }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==" + }, + "node_modules/@noble/curves": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.2.tgz", + "integrity": "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==", + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@start9labs/start-sdk": { + "version": "0.3.6-beta.3", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.3.6-beta.3.tgz", + "integrity": "sha512-362OC7XBwpIGz5vqwhkC2n0azia+cOSMoRBv9W+Sm/aEWgHCAm7+Jms2CmiVm77lZyk7bF1gbsQD3GZYR2va/Q==", + "dependencies": { + "@iarna/toml": "^2.2.5", + "@noble/curves": "^1.4.0", + "@noble/hashes": "^1.4.0", + "isomorphic-fetch": "^3.0.0", + "lodash.merge": "^4.6.2", + "mime-types": "^2.1.35", + "ts-matches": "^6.2.1", + "yaml": "^2.2.2" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.14.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", + "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@vercel/ncc": { + "version": "0.38.1", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.1.tgz", + "integrity": "sha512-IBBb+iI2NLu4VQn3Vwldyi2QwaXt5+hTyh58ggAMoCGE6DJmPvwL3KPBWcJl1m9LYPChBLE980Jw+CS4Wokqxw==", + "dev": true, + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/diskusage": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/diskusage/-/diskusage-1.2.0.tgz", + "integrity": "sha512-2u3OG3xuf5MFyzc4MctNRUKjjwK+UkovRYdD2ed/NZNZPrt0lqHnLKxGhlFVvAb4/oufIgQG3nWgwmeTbHOvXA==", + "hasInstallScript": true, + "dependencies": { + "es6-promise": "^4.2.8", + "nan": "^2.18.0" + } + }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nan": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", + "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/ts-matches": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-6.2.1.tgz", + "integrity": "sha512-qdnMgTHsGCEGGK6QiaNMY2vD9eQtRp2Q+pAxcOAzxHJKDKTBYsc1ISTg1zp8H2+EmtCB0eko/1TwYUA5/mUGug==" + }, + "node_modules/typescript": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/yaml": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", + "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + } + }, + "dependencies": { + "@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==" + }, + "@noble/curves": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.2.tgz", + "integrity": "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==", + "requires": { + "@noble/hashes": "1.4.0" + } + }, + "@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==" + }, + "@start9labs/start-sdk": { + "version": "0.3.6-beta.3", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.3.6-beta.3.tgz", + "integrity": "sha512-362OC7XBwpIGz5vqwhkC2n0azia+cOSMoRBv9W+Sm/aEWgHCAm7+Jms2CmiVm77lZyk7bF1gbsQD3GZYR2va/Q==", + "requires": { + "@iarna/toml": "^2.2.5", + "@noble/curves": "^1.4.0", + "@noble/hashes": "^1.4.0", + "isomorphic-fetch": "^3.0.0", + "lodash.merge": "^4.6.2", + "mime-types": "^2.1.35", + "ts-matches": "^6.2.1", + "yaml": "^2.2.2" + } + }, + "@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true + }, + "@types/node": { + "version": "20.14.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", + "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } + }, + "@vercel/ncc": { + "version": "0.38.1", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.1.tgz", + "integrity": "sha512-IBBb+iI2NLu4VQn3Vwldyi2QwaXt5+hTyh58ggAMoCGE6DJmPvwL3KPBWcJl1m9LYPChBLE980Jw+CS4Wokqxw==", + "dev": true + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "diskusage": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/diskusage/-/diskusage-1.2.0.tgz", + "integrity": "sha512-2u3OG3xuf5MFyzc4MctNRUKjjwK+UkovRYdD2ed/NZNZPrt0lqHnLKxGhlFVvAb4/oufIgQG3nWgwmeTbHOvXA==", + "requires": { + "es6-promise": "^4.2.8", + "nan": "^2.18.0" + } + }, + "es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, + "isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "requires": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "requires": { + "argparse": "^2.0.1" + } + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "nan": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", + "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==" + }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "ts-matches": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-6.2.1.tgz", + "integrity": "sha512-qdnMgTHsGCEGGK6QiaNMY2vD9eQtRp2Q+pAxcOAzxHJKDKTBYsc1ISTg1zp8H2+EmtCB0eko/1TwYUA5/mUGug==" + }, + "typescript": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "dev": true + }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "yaml": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", + "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..cc41e4f8 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "bitcoind", + "scripts": { + "build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", + "prettier": "prettier --write startos", + "check": "tsc --noEmit" + }, + "dependencies": { + "@start9labs/start-sdk": "0.3.6-beta.3", + "diskusage": "^1.2.0", + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.5", + "@types/node": "^20.11.30", + "@vercel/ncc": "^0.38.1", + "prettier": "^3.2.5", + "typescript": "^5.4.3" + }, + "prettier": { + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true + } +} diff --git a/startos/actions/config/config.ts b/startos/actions/config/config.ts new file mode 100644 index 00000000..84292dc9 --- /dev/null +++ b/startos/actions/config/config.ts @@ -0,0 +1,28 @@ +import { sdk } from '../../sdk' +import { read } from './read' +import { write } from './write' +import { configSpec } from './spec' + +export const config = sdk.Action.withInput( + // id + 'config', + + // metadata + async ({ effects }) => ({ + name: 'Customize Bitcoin', + description: 'Edit the bitcoin.conf configuration file', + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // form input specification + configSpec, + + // optionally pre-fill the input form + ({ effects }) => read(effects), + + // the execution function + ({ effects, input }) => write(input), +) diff --git a/startos/actions/config/mempool.ts b/startos/actions/config/mempool.ts new file mode 100644 index 00000000..eb250f89 --- /dev/null +++ b/startos/actions/config/mempool.ts @@ -0,0 +1,145 @@ +import { bitcoinConfFile, shape } from '../../file-models/bitcoin.conf' +import { sdk } from '../../sdk' + +const { Value } = sdk + +const mempoolSpec = sdk.InputSpec.of({ + persistmempool: Value.toggle({ + name: 'Persist Mempool', + default: true, + description: 'Save the mempool on shutdown and load on restart.', + }), + maxmempool: Value.number({ + name: 'Max Mempool Size', + description: 'Keep the transaction memory pool below megabytes.', + required: false, + default: null, + min: 1, + integer: true, + units: 'MiB', + placeholder: '300', + }), + mempoolexpiry: Value.number({ + name: 'Mempool Expiration', + description: + 'Do not keep transactions in the mempool longer than hours.', + required: false, + default: null, + min: 1, + integer: true, + units: 'Hr', + placeholder: '336', + }), + mempoolfullrbf: Value.toggle({ + name: 'Enable Full RBF', + default: true, + description: + 'Policy for your node to use for relaying and mining unconfirmed transactions. For details, see https://github.com/bitcoin/bitcoin/blob/master/doc/release-notes/release-notes-24.0.1.md#notice-of-new-option-for-transaction-replacement-policies', + }), + permitbaremultisig: Value.toggle({ + name: 'Permit Bare Multisig', + default: true, + description: 'Relay non-P2SH multisig transactions', + }), + datacarrier: Value.toggle({ + name: 'Relay OP_RETURN Transactions', + default: true, + description: 'Relay transactions with OP_RETURN outputs', + }), + datacarriersize: Value.number({ + name: 'Max OP_RETURN Size', + description: 'Maximum size of data in OP_RETURN outputs to relay', + required: false, + default: null, + min: 0, + max: 10_000, + integer: true, + units: 'bytes', + placeholder: '83', + }), +}) + +export const mempoolConfig = sdk.Action.withInput( + // id + 'mempool-config', + + // metadata + async ({ effects }) => ({ + name: 'Mempool Settings', + description: 'Edit the Mempool settings in bitcoin.conf', + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // form input specification + mempoolSpec, + + // optionally pre-fill the input form + ({ effects }) => read(effects), + + // the execution function + ({ effects, input }) => write(input), +) + +async function read(effects: any): Promise { + const bitcoinConf = await bitcoinConfFile.read.const(effects) + if (!bitcoinConf) return {} + const { + mempoolfullrbf, + persistmempool, + maxmempool, + mempoolexpiry, + datacarrier, + datacarriersize, + permitbaremultisig, + } = bitcoinConf + + const mempoolSettings: PartialMempoolSpec = { + maxmempool, + mempoolexpiry, + datacarriersize, + } + mempoolfullrbf == 1 + ? (mempoolSettings.mempoolfullrbf = true) + : (mempoolSettings.mempoolfullrbf = false) + persistmempool == 1 + ? (mempoolSettings.persistmempool = true) + : (mempoolSettings.persistmempool = false) + datacarrier == 1 + ? (mempoolSettings.datacarrier = true) + : (mempoolSettings.datacarrier = false) + permitbaremultisig == 1 + ? (mempoolSettings.permitbaremultisig = true) + : (mempoolSettings.permitbaremultisig = false) + + return mempoolSettings +} + +async function write(input: MempoolSpec) { + const { + mempoolfullrbf, + persistmempool, + maxmempool, + mempoolexpiry, + datacarrier, + datacarriersize, + permitbaremultisig, + } = input + + const mempoolSettings: typeof shape._TYPE = { + mempoolfullrbf: mempoolfullrbf == true ? 1 : 0, + persistmempool: persistmempool == true ? 1 : 0, + datacarrier: datacarrier == true ? 1 : 0, + permitbaremultisig: permitbaremultisig == true ? 1 : 0, + } + if (maxmempool) mempoolSettings.maxmempool = maxmempool + if (mempoolexpiry) mempoolSettings.mempoolexpiry = mempoolexpiry + if (datacarriersize) mempoolSettings.datacarriersize = datacarriersize + + await bitcoinConfFile.merge(mempoolSettings) +} + +type MempoolSpec = typeof mempoolSpec._TYPE +type PartialMempoolSpec = typeof mempoolSpec._PARTIAL diff --git a/startos/actions/config/peers.ts b/startos/actions/config/peers.ts new file mode 100644 index 00000000..2647b5f1 --- /dev/null +++ b/startos/actions/config/peers.ts @@ -0,0 +1,245 @@ +import { bitcoinConfFile, shape } from '../../file-models/bitcoin.conf' +import { sdk } from '../../sdk' + +const { Value, Variants, List, InputSpec } = sdk + +const peerSpec = sdk.InputSpec.of({ + /* + Spec defaults seem to be disregarded when reading a bitcoin.conf where the value in question is undefined. i.e. listen and v2transport default to false when the values are undefined in bitcoin.conf - presumably because undefined is falsy which then overrides spec defaults. + + We could get around this behavior by seeding a bitcoin.conf with the desired defaults, but if that is the solution, do the InputSpec defaults serve a purpose? + */ + listen: Value.toggle({ + name: 'Make Public', + default: true, + description: 'Allow other nodes to find your server on the network.', + }), + onlyonion: Value.toggle({ + name: 'Disable Clearnet', + default: false, + description: 'Only connect to peers over Tor.', + }), + v2transport: Value.toggle({ + name: 'Use V2 P2P Transport Protocol', + default: true, + description: + 'Enable or disable the use of BIP324 V2 P2P transport protocol.', + }), + peers: Value.list( + List.text( + { + name: 'Connect Nodes List Test', + minLength: 1, + description: + 'Add addresses of nodes for Bitcoin to EXCLUSIVELY connect to.', + }, + { + patterns: [ + { + regex: + '(^s*((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?:[0-9]{1,5}))s*$)|(^s*((?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*.?:[0-9]{1,5})s*$)|(^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?:[0-9]{1,5}s*$)', + description: + "Must be either a domain name, or an IPv4 or IPv6 address. Be sure to include the port number, but do not include protocol scheme (eg 'http://').", + }, + ], + }, + ), + ), + connectpeer: Value.union( + { + name: 'Connect Peer', + default: 'addnode', + }, + Variants.of({ + connect: { + name: 'Connect', + spec: InputSpec.of({ + peer: Value.text({ + name: 'Connect Union test', + default: null, + required: true, + }) + }), + }, + addnode: { + name: 'Add Node', + spec: InputSpec.of({ + peer: Value.text({ + name: 'Add Node Union test', + default: null, + required: false, + }), + }), + }, + }), + ), + // connectpeer: Value.union( + // { + // name: 'Connect Peer', + // default: 'addnode', + // }, + // Variants.of({ + // connect: { + // name: 'Connect', + // spec: InputSpec.of({ + // peers: Value.list( + // List.text( + // { + // name: 'Connect Nodes', + // minLength: 1, + // description: + // 'Add addresses of nodes for Bitcoin to EXCLUSIVELY connect to.', + // }, + // { + // patterns: [ + // { + // regex: + // '(^s*((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?:[0-9]{1,5}))s*$)|(^s*((?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*.?:[0-9]{1,5})s*$)|(^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?:[0-9]{1,5}s*$)', + // description: + // "Must be either a domain name, or an IPv4 or IPv6 address. Be sure to include the port number, but do not include protocol scheme (eg 'http://').", + // }, + // ], + // }, + // ), + // ), + // }), + // }, + // addnode: { + // name: 'Add Node', + // spec: InputSpec.of({ + // peers: Value.list( + // List.text( + // { + // name: 'Add Nodes', + // description: + // 'Add addresses of nodes for Bitcoin to connect with in addition to default nodes.', + // }, + // { + // inputmode: 'text', + // patterns: [ + // { + // regex: + // '(^s*((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?:[0-9]{1,5}))s*$)|(^s*((?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?(?:.[0-9A-Za-z](?:(?:[0-9A-Za-z]|\b-){0,61}[0-9A-Za-z])?)*.?:[0-9]{1,5})s*$)|(^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?:[0-9]{1,5}s*$)', + // description: + // "Must be either a domain name, or an IPv4 or IPv6 address. Be sure to include the port number, but do not include protocol scheme (eg 'http://').", + // }, + // ], + // }, + // ), + // ), + // }), + // }, + // }), + // ), +}) + +export const peerConfig = sdk.Action.withInput( + // id + 'peers-config', + + // metadata + async ({ effects }) => ({ + name: 'Peer Settings', + description: 'Edit the Peer settings in bitcoin.conf', + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // form input specification + peerSpec, + + // optionally pre-fill the input form + ({ effects }) => read(effects), + + // the execution function + ({ effects, input }) => write(input), +) + +async function read(effects: any): Promise { + const bitcoinConf = await bitcoinConfFile.read.const(effects) + if (!bitcoinConf) return {} + const { listen, connect, addnode, onlynet, v2transport, peers } = bitcoinConf + console.log('Connect: ', connect) + console.log('Addnode: ', addnode) + + const peerSettings: PartialPeerSpec = { + listen: listen == undefined ? undefined : listen == 1 ? true : false, + connectpeer: { + selection: connect !== undefined ? 'connect' : 'addnode', + value: { + peer: connect !== undefined ? connect : addnode !== undefined ? addnode : 'not specified' + // peers: + // connect !== undefined + // ? [connect].flat().filter((x): x is string => x !== undefined) + // : [addnode].flat().filter((x): x is string => x !== undefined), + }, + }, + peers: peers !== undefined ? peers : [], + onlyonion: onlynet == ('onion' as const), + v2transport: v2transport == 1, + } + console.log('peerSettings: ', peerSettings) + /* + connect array seems properly persisted and read from bitcoin.conf but the peers do not appear as pre-filled in the action list. Instead the list appears to be empty. + + 2025-02-03T13:35:13-07:00 Connect: [ + 2025-02-03T13:35:13-07:00 'abcxyz.onion:8334', + 2025-02-03T13:35:13-07:00 'abcxyz.onion:8333' + 2025-02-03T13:35:13-07:00 ] + 2025-02-03T13:35:13-07:00 Addnode: undefined + 2025-02-03T13:35:13-07:00 peerSettings: { + 2025-02-03T13:35:13-07:00 listen: false, + 2025-02-03T13:35:13-07:00 connectpeer: { selection: 'connect', value: { peers: [Array] } }, + 2025-02-03T13:35:13-07:00 onlyonion: false, + 2025-02-03T13:35:13-07:00 v2transport: false + 2025-02-03T13:35:13-07:00 } + */ + + return peerSettings +} + +async function write(input: peerSpec) { + const { connectpeer, listen, onlyonion, v2transport, peers } = input + console.log('Write input: ', input) + console.log('ConnectPeer Input: ', connectpeer.selection) + // console.log('Peers Input: ', connectpeer.value.peers) + console.log('Peers Input: ', connectpeer.value) + + const peerSettings: typeof shape._TYPE = {} + peerSettings.whitelist = '172.18.0.0/16' + if (listen) peerSettings.bind = '0.0.0.0:8333' + + if (listen) { + peerSettings.listen = 1 + } else { + peerSettings.listen = undefined + } + if (onlyonion) { + peerSettings.onlynet = 'onion' + } else { + peerSettings.onlynet = undefined + } + if (v2transport) peerSettings.v2transport = 1 + if (connectpeer.selection == ('connect')) { + console.log('In connect block') + // peerSettings.connect = connectpeer.value.peers as string[] + peerSettings.connect = connectpeer.value.peer + peerSettings.addnode = undefined + } else if (connectpeer.selection == 'addnode') { + console.log('In addnode block') + // peerSettings.addnode = (connectpeer.value.peers as string[]) + peerSettings.addnode = connectpeer.value.peer! + // peerSettings.connect = undefined + peerSettings.peers = peers + } + console.log('Connect: ', peerSettings.connect) + console.log('Addnode: ', peerSettings.addnode) + console.log('peerSettings passeed to merge: ', peerSettings) + + await bitcoinConfFile.merge(peerSettings) +} + +type peerSpec = typeof peerSpec._TYPE +type PartialPeerSpec = typeof peerSpec._PARTIAL diff --git a/startos/actions/config/read.ts b/startos/actions/config/read.ts new file mode 100644 index 00000000..7bb1c426 --- /dev/null +++ b/startos/actions/config/read.ts @@ -0,0 +1,28 @@ +import { bitcoinConfFile } from '../../file-models/bitcoin.conf' +import { PartialConfigSpec } from './spec' + +export async function read(effects: any): Promise { + const bitcoinConf = await bitcoinConfFile.read.const(effects) + if (!bitcoinConf) return {} + + return { + zmqEnabled: Object.keys(bitcoinConf).includes('zmqpubrawblock'), + txindex: bitcoinConf.txindex === 1, + coinstatsindex: bitcoinConf.coinstatsindex === 1, + testnet: bitcoinConf.testnet === 1, + wallet: { + enable: bitcoinConf.disablewallet === 0, + avoidpartialspends: bitcoinConf.avoidpartialspends === 1, + discardfee: bitcoinConf.discardfee ? bitcoinConf.discardfee : null, + }, + prune: bitcoinConf.prune, + dbcache: bitcoinConf.dbcache, + blockfilters: { + blockfilterindex: bitcoinConf.blockfilterindex === 'basic', + peerblockfilters: bitcoinConf.peerblockfilters === 1, + }, + bloomfilters: { + peerbloomfilters: bitcoinConf.peerbloomfilters === 1, + }, + } +} diff --git a/startos/actions/config/rpc.ts b/startos/actions/config/rpc.ts new file mode 100644 index 00000000..b97aa1c4 --- /dev/null +++ b/startos/actions/config/rpc.ts @@ -0,0 +1,96 @@ +import { bitcoinConfFile, shape } from '../../file-models/bitcoin.conf' +import { sdk } from '../../sdk' + +const { Value } = sdk + +const rpcSpec = sdk.InputSpec.of({ + servertimeout: Value.number({ + name: 'Rpc Server Timeout', + description: + 'Number of seconds after which an uncompleted RPC call will time out.', + required: false, + default: null, + min: 5, + max: 300, + integer: true, + units: 'seconds', + placeholder: '30', + }), + threads: Value.number({ + name: 'Threads', + description: + 'Set the number of threads for handling RPC calls. You may wish to increase this if you are making lots of calls via an integration.', + + required: false, + default: null, + min: 4, + max: 64, + step: null, + integer: true, + units: null, + placeholder: '16', + }), + workqueue: Value.number({ + name: 'Work Queue', + description: + 'Set the depth of the work queue to service RPC calls. Determines how long the backlog of RPC requests can get before it just rejects new ones.', + + required: false, + default: null, + min: 8, + max: 256, + step: null, + integer: true, + units: 'requests', + placeholder: '128', + }), +}) + +export const rpcConfig = sdk.Action.withInput( + // id + 'rpc-config', + + // metadata + async ({ effects }) => ({ + name: 'RPC Settings', + description: 'Edit the RPC settings in bitcoin.conf', + warning: null, + allowedStatuses: 'any', + group: 'RPC', + visibility: 'enabled', + }), + + // form input specification + rpcSpec, + + // optionally pre-fill the input form + ({ effects }) => read(effects), + + // the execution function + ({ effects, input }) => write(input), +) + +async function read(effects: any): Promise { + const bitcoinConf = await bitcoinConfFile.read.const(effects) + if (!bitcoinConf) return {} + + return { + servertimeout: bitcoinConf.rpcservertimeout, + threads: bitcoinConf.rpcthreads, + workqueue: bitcoinConf.rpcworkqueue, + } +} + +async function write(input: RpcSpec) { + const { servertimeout, threads, workqueue } = input + + const rpcSettings: typeof shape._TYPE = {} + if (servertimeout) rpcSettings.rpcservertimeout = servertimeout + if (threads) rpcSettings.rpcthreads = threads + if (workqueue) rpcSettings.rpcworkqueue = workqueue + + await bitcoinConfFile.merge(rpcSettings) +} + +type RpcSpec = typeof rpcSpec._TYPE +type PartialRpcSpec = typeof rpcSpec._PARTIAL diff --git a/startos/actions/config/spec.ts b/startos/actions/config/spec.ts new file mode 100644 index 00000000..5e5d3579 --- /dev/null +++ b/startos/actions/config/spec.ts @@ -0,0 +1,137 @@ +import { utils } from '@start9labs/start-sdk' +import { sdk } from '../../sdk' +import * as diskusage from 'diskusage' + +const { InputSpec, Value, List, Variants } = sdk +const diskUsage = utils.once(() => diskusage.check('/')) +const archivalMin = 900_000_000_000 + +export const configSpec = sdk.InputSpec.of({ + zmqEnabled: Value.toggle({ + name: 'ZeroMQ Enabled', + default: true, + description: + 'The ZeroMQ interface is useful for some applications which might require data related to block and transaction events from Bitcoin Core. For example, LND requires ZeroMQ be enabled for LND to get the latest block data', + }), + txindex: Value.dynamicToggle(async ({ effects }) => { + const disk = await diskUsage() + return { + name: 'Transaction Index', + default: disk.total >= archivalMin, + description: + 'By enabling Transaction Index (txindex) Bitcoin Core will build a complete transaction index. This allows Bitcoin Core to access any transaction with commands like `getrawtransaction`.', + disabled: disk.total < archivalMin ? 'Not enough disk space' : false, + } + }), + coinstatsindex: Value.toggle({ + name: 'Coinstats Index', + default: false, + description: + 'Enabling Coinstats Index reduces the time for the gettxoutsetinfo RPC to complete at the cost of using additional disk space', + }), + testnet: Value.toggle({ + name: 'Testnet', + default: false, + description: + 'Testnet is an alternative Bitcoin block chain to be used for testing. Testnet coins are separate and distinct from actual bitcoins, and are never supposed to have any value. This allows application developers or bitcoin testers to experiment, without having to use real bitcoins or worrying about breaking the main bitcoin chain.', + }), + wallet: Value.object( + { + name: 'Wallet', + description: 'Wallet Settings', + }, + InputSpec.of({ + enable: Value.toggle({ + name: 'Enable Wallet', + default: true, + description: 'Load the wallet and enable wallet RPC calls.', + }), + avoidpartialspends: Value.toggle({ + name: 'Avoid Partial Spends', + default: true, + description: + 'Group outputs by address, selecting all or none, instead of selecting on a per-output basis. This improves privacy at the expense of higher transaction fees.', + }), + discardfee: Value.number({ + name: 'Discard Change Tolerance', + description: + 'The fee rate (in BTC/kB) that indicates your tolerance for discarding change by adding it to the fee.', + required: false, + default: null, + min: 0, + max: 0.01, + integer: false, + units: 'BTC/kB', + placeholder: '.0001', + }), + }), + ), + prune: Value.dynamicNumber(async ({ effects }) => { + const disk = await diskUsage() + + return { + name: 'Pruning', + description: + 'Set the maximum size of the blockchain you wish to store on disk.', + warning: 'Increasing this value will require re-syncing your node.', + placeholder: 'Enter max blockchain size', + required: disk.total < archivalMin, + default: disk.total < archivalMin ? 550 : null, + integer: true, + units: 'MiB', + min: 550, + } + }), + dbcache: Value.number({ + name: 'Database Cache', + description: + "How much RAM to allocate for caching the TXO set. Higher values improve syncing performance, but increase your chance of using up all your system's memory or corrupting your database in the event of an ungraceful shutdown. Set this high but comfortably below your system's total RAM during IBD, then turn down to 450 (or leave blank) once the sync completes.", + warning: + 'WARNING: Increasing this value results in a higher chance of ungraceful shutdowns, which can leave your node unusable if it happens during the initial block download. Use this setting with caution. Be sure to set this back to the default (450 or leave blank) once your node is synced. DO NOT press the STOP button if your dbcache is large. Instead, set this number back to the default, hit save, and wait for bitcoind to restart on its own.', + required: false, + default: null, + min: 0, + integer: true, + units: 'MiB', + placeholder: '450', + }), + blockfilters: Value.object( + { + name: 'Block Filters', + description: 'Settings for storing and serving compact block filters', + }, + InputSpec.of({ + blockfilterindex: Value.toggle({ + name: 'Compute Compact Block Filters (BIP158)', + default: true, + description: + "Generate Compact Block Filters during initial sync (IBD) to enable 'getblockfilter' RPC. This is useful if dependent services need block filters to efficiently scan for addresses/transactions etc.", + }), + peerblockfilters: Value.toggle({ + name: 'Serve Compact Block Filters to Peers (BIP157)', + default: false, + description: + "Serve Compact Block Filters as a peer service to other nodes on the network. This is useful if you wish to connect an SPV client to your node to make it efficient to scan transactions without having to download all block data. 'Compute Compact Block Filters (BIP158)' is required.", + }), + }), + ), + bloomfilters: Value.object( + { + name: 'Bloom Filters (BIP37)', + description: 'Setting for serving Bloom Filters', + }, + InputSpec.of({ + peerbloomfilters: Value.toggle({ + name: 'Serve Bloom Filters to Peers', + default: false, + description: + 'Peers have the option of setting filters on each connection they make after the version handshake has completed. Bloom filters are for clients implementing SPV (Simplified Payment Verification) that want to check that block headers connect together correctly, without needing to verify the full blockchain. The client must trust that the transactions in the chain are in fact valid. It is highly recommended AGAINST using for anything except Bisq integration.', + warning: + 'This is ONLY for use with Bisq integration, please use Block Filters for all other applications.', + }), + }), + ), +}) + +export type ConfigSpec = typeof configSpec._TYPE +export type PartialConfigSpec = typeof configSpec._PARTIAL diff --git a/startos/actions/config/write.ts b/startos/actions/config/write.ts new file mode 100644 index 00000000..cdb1f038 --- /dev/null +++ b/startos/actions/config/write.ts @@ -0,0 +1,59 @@ +import { bitcoinConfFile, shape } from '../../file-models/bitcoin.conf' +import { ConfigSpec } from './spec' + +export async function write(input: ConfigSpec) { + const { + wallet, + txindex, + coinstatsindex, + testnet, + prune, + dbcache, + bloomfilters, + blockfilters, + } = input + + const shaped: typeof shape._TYPE = { + // RPC + rpcbind: prune ? '127.0.0.1:18332' : '0.0.0.0:8332', + rpcallowip: prune ? '127.0.0.1/32' : '0.0.0.0/0', + + // Wallet + disablewallet: wallet.enable ? 0 : 1, + avoidpartialspends: wallet.avoidpartialspends ? 1 : 0, + discardfee: wallet.discardfee || undefined, + + testnet: testnet ? 1 : 0, + } + + if (prune) shaped.prune = prune + + if (dbcache) shaped.dbcache = dbcache + + if (wallet.enable) shaped.deprecatedrpc = 'create_bdb' + + // Zero MQ + if (input.zmqEnabled) { + shaped.zmqpubrawblock = 'tcp://0.0.0.0:28332' + shaped.zmqpubhashblock = 'tcp://0.0.0.0:28332' + shaped.zmqpubrawtx = 'tcp://0.0.0.0:28333' + shaped.zmqpubhashtx = 'tcp://0.0.0.0:28333' + shaped.zmqpubsequence = 'tcp://0.0.0.0:28333' + } + + // TxIndex + if (txindex) shaped.txindex = 1 + + // CoinStatsIndex + if (coinstatsindex) shaped.coinstatsindex = 1 + + // BIP37 + if (bloomfilters) shaped.peerbloomfilters = 1 + + // BIP157 + if (blockfilters.blockfilterindex) shaped.blockfilterindex = 'basic' + + if (blockfilters.peerblockfilters) shaped.peerblockfilters = 1 + + await bitcoinConfFile.merge(shaped) +} diff --git a/startos/actions/deleteCoinstatsIndex.ts b/startos/actions/deleteCoinstatsIndex.ts new file mode 100644 index 00000000..537a9b83 --- /dev/null +++ b/startos/actions/deleteCoinstatsIndex.ts @@ -0,0 +1,31 @@ +import { sdk } from '../sdk' +import * as fs from 'fs/promises' + +export const deleteCoinstatsIndex = sdk.Action.withoutInput( + // id + 'delete-coinstats-index', + + // metadata + async ({ effects }) => ({ + name: 'Delete Coinstats Index', + description: + 'Deletes the Coinstats Index (coinstatsindex) in case it gets corrupted.', + warning: + "The Coinstats Index will be rebuilt once Bitcoin Core is started again, unless 'Transaction Index' is disabled in the config settings. Please don't do this unless you fully understand what you are doing.", + allowedStatuses: 'only-stopped', + group: 'Delete Corrupted Files', + visibility: 'enabled', + }), + + // execution function + async ({ effects }) => { + await fs.rmdir('/root/.bitcoin/indexes/coinstats', { recursive: true }) + + return { + version: '1', + title: 'Success', + message: 'Successfully deleted coinstats index', + result: null, + } + }, +) diff --git a/startos/actions/deletePeers.ts b/startos/actions/deletePeers.ts new file mode 100644 index 00000000..8a99613a --- /dev/null +++ b/startos/actions/deletePeers.ts @@ -0,0 +1,29 @@ +import { sdk } from '../sdk' +import * as fs from 'fs/promises' + +export const deletePeers = sdk.Action.withoutInput( + // id + 'delete-peers', + + // metadata + async ({ effects }) => ({ + name: 'Delete Peer List', + description: 'Deletes the Peer List (peers.dat) in case it gets corrupted.', + warning: null, + allowedStatuses: 'only-stopped', + group: 'Delete Corrupted Files', + visibility: 'enabled', + }), + + // execution function + async ({ effects }) => { + await fs.rm('/root/.bitcoin/peers.dat') + + return { + version: '1', + title: 'Success', + message: 'Successfully deleted peers.dat', + result: null, + } + }, +) diff --git a/startos/actions/deleteRpcAuth.ts b/startos/actions/deleteRpcAuth.ts new file mode 100644 index 00000000..847a1853 --- /dev/null +++ b/startos/actions/deleteRpcAuth.ts @@ -0,0 +1,57 @@ +import { bitcoinConfFile } from '../file-models/bitcoin.conf' +import { sdk } from '../sdk' +import { getRpcAuth, getRpcUsers } from '../utils' +const { InputSpec, Value } = sdk + +export const inputSpec = InputSpec.of({ + deletedRpcUsers: Value.dynamicMultiselect(async ({ effects }) => { + const existingUsernames = (await getRpcUsers(effects)) || [] + + return { + name: 'Existing RPC Users', + default: [], + values: existingUsernames.reduce( + (obj, curr) => ({ + ...obj, + [curr]: curr, + }), + {}, + ), + } + }), +}) + +export const deleteRpcAuth = sdk.Action.withInput( + // id + 'delete-rpcauth', + + // metadata + async ({ effects }) => ({ + name: 'Delete RPC Users', + description: + 'Delete RPC users from Bitcoin.conf. You may want to run this action if the RPC Auth entry is no longer needed or if the password is lost.', + warning: null, + allowedStatuses: 'any', + group: 'RPC', + visibility: (await getRpcUsers(effects)) + ? 'enabled' + : { disabled: 'There are no RPC users' }, + }), + + // input spec + inputSpec, + + // optionally pre-fill form + async ({ effects }) => {}, + + // execution function + async ({ effects, input }) => { + const rpcauth = await getRpcAuth(effects) + const filtered = rpcauth?.filter( + (auth) => !input.deletedRpcUsers.includes(auth.split(':', 2)[0]), + ) + await bitcoinConfFile.merge({ + rpcauth: filtered, + }) + }, +) diff --git a/startos/actions/deleteTxIndex.ts b/startos/actions/deleteTxIndex.ts new file mode 100644 index 00000000..710afc0e --- /dev/null +++ b/startos/actions/deleteTxIndex.ts @@ -0,0 +1,31 @@ +import { sdk } from '../sdk' +import * as fs from 'fs/promises' + +export const deleteTxIndex = sdk.Action.withoutInput( + // id + 'delete-txindex', + + // metadata + async ({ effects }) => ({ + name: 'Delete Transaction Index', + description: + 'Deletes the Transaction Index (txindex) in the event it gets corrupted.', + warning: + "The Transaction Index will be rebuilt once Bitcoin Core is started again, unless 'Coinstats Index' is disabled in the config settings. Please don't do this unless you fully understand what you are doing.", + allowedStatuses: 'only-stopped', + group: 'Delete Corrupted Files', + visibility: 'enabled', + }), + + // execution function + async ({ effects }) => { + await fs.rmdir('/root/.bitcoin/indexes/txindex', { recursive: true }) + + return { + version: '1', + title: 'Success', + message: 'Successfully deleted txindex', + result: null, + } + }, +) diff --git a/startos/actions/generateRpcAuth.ts b/startos/actions/generateRpcAuth.ts new file mode 100644 index 00000000..b1043c31 --- /dev/null +++ b/startos/actions/generateRpcAuth.ts @@ -0,0 +1,87 @@ +import { bitcoinConfFile } from '../file-models/bitcoin.conf' +import { sdk } from '../sdk' +import { getRpcAuth, getRpcUsers } from '../utils' +const { InputSpec, Value } = sdk + +export const inputSpec = InputSpec.of({ + username: Value.text({ + name: 'Username', + description: 'RPC Auth Username', + required: true, + default: null, + patterns: [ + { + regex: '^[a-zA-Z0-9_]+$', + description: 'Must be alphanumeric (can contain underscore).', + }, + ], + }), +}) + +export const generateRpcUser = sdk.Action.withInput( + // id + 'generate-rpcuser', + + // metadata + async ({ effects }) => ({ + name: 'Generate RPC User Credentials', + description: + 'Generate RPC User Credentials for remote connections i.e. Sparrow. rpcauth.py will randomly generate a secure password. The username and hashed password will be persisted in Bitcoin.conf', + warning: null, + allowedStatuses: 'any', + group: 'RPC', + visibility: 'enabled', + }), + + // input spec + inputSpec, + + // optionally pre-fill form + async ({ effects }) => {}, + + // execution function + async ({ effects, input }) => { + const existingUsernames = await getRpcUsers(effects) + + if (existingUsernames?.includes(input.username)) { + return { + version: '1', + title: 'Error creating RPC Auth User', + message: 'RPCAuth entry with this username already exists.', + result: null, + } + } + + const res = await sdk.runCommand( + effects, + { id: 'bitcoind' }, + ['python3', '/root/.bitcoin/rpcauth.py', `${input.username}`], + {}, + 'genRpcAuth', + ) + + if (typeof res.stdout === 'string') { + const password = res.stdout.split('\n')[3].trim() + const newRpcAuth = res.stdout.split('\n')[1].trim().split('=')[1].trim() + + bitcoinConfFile.merge({ + rpcauth: [newRpcAuth], + }) + + return { + version: '1', + title: 'RPC user successfully created', + message: `RPC password created for ${input.username}. Store this password in a secure place. If lost, a new RPC user will need to be created as Bitcoin.conf only stores a hash of the password`, + result: { + name: 'RPC Password', + type: 'single', + value: password, + description: `${input.username} RPC Password`, + copyable: true, + masked: true, + qr: false, + }, + } + } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts new file mode 100644 index 00000000..f4262d1b --- /dev/null +++ b/startos/actions/index.ts @@ -0,0 +1,27 @@ +import { sdk } from '../sdk' +import { config } from './config/config' +import { mempoolConfig } from './config/mempool' +import { peerConfig } from './config/peers' +import { rpcConfig } from './config/rpc' +import { deleteCoinstatsIndex } from './deleteCoinstatsIndex' +import { deletePeers } from './deletePeers' +import { deleteRpcAuth } from './deleteRpcAuth' +import { deleteTxIndex } from './deleteTxIndex' +import { generateRpcUser } from './generateRpcAuth' +import { reindexBlockchain } from './reindexBlockchain' +import { reindexChainstate } from './reindexChainstate' +import { runtimeInfo } from './runtimeInfo' + +export const actions = sdk.Actions.of() + .addAction(runtimeInfo) + .addAction(deleteCoinstatsIndex) + .addAction(deletePeers) + .addAction(deleteTxIndex) + .addAction(reindexBlockchain) + .addAction(reindexChainstate) + .addAction(config) + .addAction(rpcConfig) + .addAction(generateRpcUser) + .addAction(deleteRpcAuth) + .addAction(mempoolConfig) + .addAction(peerConfig) diff --git a/startos/actions/reindexBlockchain.ts b/startos/actions/reindexBlockchain.ts new file mode 100644 index 00000000..4d106cad --- /dev/null +++ b/startos/actions/reindexBlockchain.ts @@ -0,0 +1,30 @@ +import { sdk } from '../sdk' + +export const reindexBlockchain = sdk.Action.withoutInput( + // id + 'reindex-blockchain', + + // metadata + async ({ effects }) => ({ + name: 'Reindex Blockchain', + description: + 'Rebuilds the block and chainstate databases starting from genesis. If blocks already exist on disk, these are used rather than being re-downloaded. For pruned nodes, this means downloading the entire blockchain over again.', + warning: + 'Blocks not stored on disk will be re-downloaded in order to rebuild the database. If your node is pruned, this action is equivalent to syncing the node from scratch, so this process could take weeks on low-end hardware.', + allowedStatuses: 'any', + group: 'Reindex', + visibility: 'enabled', + }), + + // execution function + async ({ effects }) => { + await sdk.store.setOwn(effects, sdk.StorePath.reindexBlockchain, true) + return { + version: '1', + title: 'Success', + message: + 'Blockchain will be reindexed on next startup. If Bitcoin is already running, it will be automatically restarted now.', + result: null, + } + }, +) diff --git a/startos/actions/reindexChainstate.ts b/startos/actions/reindexChainstate.ts new file mode 100644 index 00000000..eca319b1 --- /dev/null +++ b/startos/actions/reindexChainstate.ts @@ -0,0 +1,34 @@ +import { bitcoinConfFile } from '../file-models/bitcoin.conf' +import { sdk } from '../sdk' + +export const reindexChainstate = sdk.Action.withoutInput( + // id + 'reindex-chainstate', + + // metadata + async ({ effects }) => ({ + name: 'Reindex Chainstate', + description: + "Rebuilds the chainstate database using existing block index data; as the block index is not rebuilt, 'reindex_chainstate' should be strictly faster than 'reindex'. This action should only be used in the case of chainstate corruption; if the blocks stored on disk are corrupted, the 'reindex' action will need to be run instead.", + warning: + "While faster than 'Reindex', 'Reindex Chainstate' can still take several days or more to complete.", + allowedStatuses: 'any', + group: 'Reindex', + visibility: + (await bitcoinConfFile.read.const(effects))?.prune + ? 'hidden' + : 'enabled', + }), + + // execution function + async ({ effects }) => { + await sdk.store.setOwn(effects, sdk.StorePath.reindexChainstate, true) + return { + version: '1', + title: 'Success', + message: + 'Chainstate will be reindexed on next startup. If Bitcoin was already running, it will be automatically restarted now.', + result: null, + } + }, +) diff --git a/startos/actions/runtimeInfo.ts b/startos/actions/runtimeInfo.ts new file mode 100644 index 00000000..f9df54a2 --- /dev/null +++ b/startos/actions/runtimeInfo.ts @@ -0,0 +1,316 @@ +import { T } from '@start9labs/start-sdk' +import { sdk } from '../sdk' +import { GetBlockchainInfo, GetNetworkInfo } from '../utils' + +export const runtimeInfo = sdk.Action.withoutInput( + // id + 'runtime-info', + + // metadata + async ({ effects }) => ({ + name: 'Runtime Information', + description: + 'Network and other runtime information about this Bitcoin node', + warning: null, + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }), + + // execution function + async ({ effects }) => { + // getnetowrkinfo + + const networkInfoRes = await sdk.runCommand( + effects, + { id: 'bitcoind' }, + ['bitcoin-cli', '-conf=/root/.bitcoin/bitcoin.conf', 'getnetworkinfo'], + {}, + 'getnetworkinfo', + ) + + const networkInfoRaw: GetNetworkInfo = JSON.parse( + networkInfoRes.stdout as string, + ) + + // getblockchaininfo + + const blockchainInfoRes = await sdk.runCommand( + effects, + { id: 'bitcoind' }, + ['bitcoin-cli', '-conf=/root/.bitcoin/bitcoin.conf', 'getblockchaininfo'], + {}, + 'getblockchaininfo', + ) + + const blockchainInfoRaw: GetBlockchainInfo = JSON.parse( + blockchainInfoRes.stdout as string, + ) + + // return + + return { + version: '1', + title: 'Node Runtime Info', + message: null, + result: { + type: 'group', + value: [ + getConnections(networkInfoRaw), + getBlockchainInfo(blockchainInfoRaw), + getSoftforkInfo(blockchainInfoRaw), + ], + }, + } + }, +) + +function getConnections(networkInfoRaw: GetNetworkInfo): T.ActionResultMember { + return { + type: 'single', + name: 'Connections', + description: 'The number of peers connected (inbound and outbound)', + value: `${networkInfoRaw.connections} (${networkInfoRaw.connectionsIn} in / ${networkInfoRaw.connectionsOut} out)`, + copyable: false, + masked: false, + qr: false, + } +} + +function getBlockchainInfo( + blockchainInfoRaw: GetBlockchainInfo, +): T.ActionResultMember { + return { + type: 'group', + name: 'Blockchain Info', + description: null, + value: [ + { + type: 'single', + name: 'Block Height', + value: String(blockchainInfoRaw.headers), + description: 'The current block height for the network', + copyable: false, + masked: false, + qr: false, + }, + { + type: 'single', + name: 'Synced Block Height', + value: String(blockchainInfoRaw.blocks), + description: 'The number of blocks the node has verified', + copyable: false, + masked: false, + qr: false, + }, + { + type: 'single', + name: 'Sync Progress', + value: + blockchainInfoRaw.blocks < blockchainInfoRaw.headers + ? `${(blockchainInfoRaw.verificationprogress * 100).toFixed(2)}%` + : '100%', + description: 'The percentage of the blockchain that has been verified', + copyable: false, + masked: false, + qr: false, + }, + ], + } +} + +function getSoftforkInfo( + blockchainInfoRaw: GetBlockchainInfo, +): T.ActionResultMember { + return { + type: 'group', + name: 'Softfork Info', + description: null, + value: [ + { + type: 'group', + name: 'Softforks', + description: null, + value: getSoftforks(blockchainInfoRaw), + }, + ], + } +} + +function getSoftforks( + blockchainInfoRaw: GetBlockchainInfo, +): T.ActionResultMember[] { + return Object.entries(blockchainInfoRaw.softforks).map(([key, val]) => { + const value: T.ActionResultMember[] = [ + { + type: 'single', + name: 'Type', + value: val.type, + description: 'Either "buried", "bip9"', + copyable: false, + masked: false, + qr: false, + }, + { + type: 'single', + name: 'Height', + value: val.height ? String(val.height) : 'N/A', + description: + 'height of the first block which the rules are or will be enforced (only for "buried" type, or "bip9" type with "active" status)', + copyable: false, + masked: false, + qr: false, + }, + { + type: 'single', + name: 'Active', + value: String(val.active), + description: + 'true if the rules are enforced for the mempool and the next block', + copyable: false, + masked: false, + qr: false, + }, + ] + + if (val.bip9) { + value.push(getBip9Info(val.bip9)) + + if (val.bip9.statistics) { + value.push(getBip9Statistics(val.bip9.statistics)) + } + } + + return { + type: 'group', + name: key, + description: null, + value, + } + }) +} + +function getBip9Info(bip9: Bip9): T.ActionResultMember { + const { status, bit, start_time, timeout, since } = bip9 + + return { + type: 'group', + name: 'Bip9', + description: null, + value: [ + { + type: 'single', + name: 'Status', + value: status, + description: + 'One of "defined", "started", "locked_in", "active", "failed"', + copyable: false, + masked: false, + qr: false, + }, + { + type: 'single', + name: 'Bit', + value: bit ? String(bit) : 'N/A', + description: + 'The bit (0-28) in the block version field used to signal this softfork (only for "started" status)', + copyable: false, + masked: false, + qr: false, + }, + { + type: 'single', + name: 'Start Time', + value: String(start_time), + description: + 'The minimum median time past of a block at which the bit gains its meaning', + copyable: false, + masked: false, + qr: false, + }, + { + type: 'single', + name: 'Timeout', + value: String(timeout), + description: + 'The median time past of a block at which the deployment is considered failed if not yet locked in', + copyable: false, + masked: false, + qr: false, + }, + { + type: 'single', + name: 'Since', + value: String(since), + description: 'height of the first block to which the status applies', + copyable: false, + masked: false, + qr: false, + }, + ], + } +} + +function getBip9Statistics(statistics: Bip9Stats): T.ActionResultMember { + const { period, threshold, elapsed, count, possible } = statistics + + return { + type: 'group', + name: 'Statistics', + description: null, + value: [ + { + type: 'single', + name: 'Period', + value: String(period), + description: 'The length in blocks of the BIP9 signalling period', + copyable: false, + masked: false, + qr: false, + }, + { + type: 'single', + name: 'Threshold', + value: String(threshold), + description: + 'The number of blocks with the version bit set required to activate the feature', + copyable: false, + masked: false, + qr: false, + }, + { + type: 'single', + name: 'Elapsed', + value: String(elapsed), + description: + 'The number of blocks elapsed since the beginning of the current period', + copyable: false, + masked: false, + qr: false, + }, + { + type: 'single', + name: 'Count', + value: String(count), + description: + 'The number of blocks with the version bit set in the current period', + copyable: false, + masked: false, + qr: false, + }, + { + type: 'single', + name: 'Possible', + value: String(possible), + description: + 'returns false if there are not enough blocks left in this period to pass activation threshold', + copyable: false, + masked: false, + qr: false, + }, + ], + } +} + +type Bip9 = NonNullable +type Bip9Stats = NonNullable diff --git a/startos/backups.ts b/startos/backups.ts new file mode 100644 index 00000000..45dd7fad --- /dev/null +++ b/startos/backups.ts @@ -0,0 +1,7 @@ +import { sdk } from './sdk' + +export const { createBackup, restoreBackup } = sdk.setupBackups(async () => + sdk.Backups.volumes('main').setOptions({ + exclude: ['blocks/', 'chainstate/', 'indexes/', 'testnet3/'], + }), +) diff --git a/startos/dependencies.ts b/startos/dependencies.ts new file mode 100644 index 00000000..7221c4bd --- /dev/null +++ b/startos/dependencies.ts @@ -0,0 +1,5 @@ +import { sdk } from './sdk' + +export const setDependencies = sdk.setupDependencies( + async ({ effects }) => ({}), +) diff --git a/startos/file-models/bitcoin.conf.ts b/startos/file-models/bitcoin.conf.ts new file mode 100644 index 00000000..932918c1 --- /dev/null +++ b/startos/file-models/bitcoin.conf.ts @@ -0,0 +1,148 @@ +import { FileHelper, matches } from '@start9labs/start-sdk' + +const { anyOf } = matches +const object = matches.object +const stringArray = matches.array(matches.string) +const string = stringArray.map(([a]) => a).orParser(matches.string) +const number = stringArray.map(([a]) => Number(a)).orParser(matches.number) +const numLiteral = (val: any) => { + return stringArray.map(([val]) => Number(val)).orParser(matches.literal(val)) +} +const literal = (val: string) => { + return stringArray.map(([val]) => matches.literal(val)).orParser(matches.literal(val)) +} + +export const shape = object({ + // RPC + rpcbind: matches.string.optional().onMismatch(undefined), + rpcallowip: matches.string.optional().onMismatch(undefined), + rpcauth: stringArray.optional().onMismatch(undefined), + rpcservertimeout: number.optional().onMismatch(undefined), + rpcthreads: number.optional().onMismatch(undefined), + rpcworkqueue: number.optional().onMismatch(undefined), + + // Mempool + mempoolfullrbf: anyOf(numLiteral(0), numLiteral(1)) + .optional() + .onMismatch(undefined), + persistmempool: anyOf(numLiteral(0), numLiteral(1)) + .optional() + .onMismatch(undefined), + maxmempool: number.optional().onMismatch(undefined), + mempoolexpiry: number.optional().onMismatch(undefined), + datacarrier: anyOf(numLiteral(0), numLiteral(1)).optional().onMismatch(undefined), + datacarriersize: number.optional().onMismatch(undefined), + permitbaremultisig: anyOf(numLiteral(0), numLiteral(1)) + .optional() + .onMismatch(undefined), + + // Peers + listen: anyOf(numLiteral(0), numLiteral(1)).optional().onMismatch(undefined), + bind: string.optional().onMismatch(undefined), + connect: string.optional(),//.onMismatch(undefined), + addnode: string.optional(),//.onMismatch(undefined), + peers: stringArray.optional(), + // connect: stringArray.optional(),//.onMismatch(undefined), + // addnode: stringArray.optional(),//.onMismatch(undefined), + onlynet: string.optional().onMismatch(undefined), + /* + It seeems an existing value in bitcoin.conf cannot be overwrittern with 'undefined' - is there a different way to delete a key/values from bitcoin.conf? + + 2025-01-31T14:35:43-07:00 peerSettings passed to merge: { listen: 1, onlynet: undefined, connect: undefined } + 2025-01-31T14:35:43-07:00 early exit fromBitcoinConf: { listen: [ '1' ], onlynet: [ 'onion' ] } + 2025-01-31T14:35:43-07:00 Arg received by toBitcoinConf: { listen: 1, onlynet: 'onion', connect: undefined } + 2025-01-31T14:35:43-07:00 toBitcoinConf: listen=1 + 2025-01-31T14:35:43-07:00 onlynet=onion + + */ + v2transport: anyOf(numLiteral(0), numLiteral(1)).optional().onMismatch(undefined), + + // Whitelist + whitelist: string.optional().onMismatch(undefined), + + // Pruning + prune: number.optional().onMismatch(undefined), + + // Performance Tuning + dbcache: number.optional().onMismatch(undefined), + + // Wallet + disablewallet: anyOf(numLiteral(0), numLiteral(1)).optional().onMismatch(undefined), + deprecatedrpc: string.optional().onMismatch(undefined), + avoidpartialspends: anyOf(numLiteral(0), numLiteral(1)) + .optional() + .onMismatch(undefined), + discardfee: number.optional().onMismatch(undefined), + + // Zero MQ + zmqpubrawblock: string.optional().onMismatch(undefined), + zmqpubhashblock: string.optional().onMismatch(undefined), + zmqpubrawtx: string.optional().onMismatch(undefined), + zmqpubhashtx: string.optional().onMismatch(undefined), + zmqpubsequence: string.optional().onMismatch(undefined), + + // TxIndex + txindex: numLiteral(1).optional().onMismatch(undefined), + + // CoinstatsIndex + coinstatsindex: numLiteral(1).optional().onMismatch(undefined), + + // BIP37 + peerbloomfilters: numLiteral(1).optional().onMismatch(undefined), + + // BIP157 + blockfilterindex: literal('basic').optional().onMismatch(undefined), + peerblockfilters: numLiteral(1).optional().onMismatch(undefined), + + // Testnet + testnet: anyOf(numLiteral(0), numLiteral(1)).optional().onMismatch(undefined), +}) + +export function fromBitcoinConf(text: string): Record { + const lines = text.split('\n') + const dictionary = {} as Record + + for (const line of lines) { + const [key, value] = line.split('=', 2) + if (key === '') { + console.log("early exit fromBitcoinConf: ", dictionary) + return dictionary + } + const trimmedKey = key.trim() + const trimmedValue = value.trim() + + if (!dictionary[trimmedKey]) { + dictionary[trimmedKey] = [] + } + + dictionary[trimmedKey].push(trimmedValue) + } + console.log("Normal exit fromBitcoinConf: ", dictionary) + + return dictionary +} + +function toBitcoinConf(conf: typeof shape._TYPE): string { + let bitcoinConfStr = '' + console.log("Arg received by toBitcoinConf: ", conf) + + Object.entries(conf).forEach(([key, value]) => { + if (Array.isArray(value)) { + for (const subValue of value) { + bitcoinConfStr += `${key}=${subValue}\n` + } + } else if (value !== undefined) { + bitcoinConfStr += `${key}=${value}\n` + } + }) + + console.log("toBitcoinConf: ", bitcoinConfStr) + return bitcoinConfStr +} + +export const bitcoinConfFile = FileHelper.raw( + '/media/startos/volumes/main/bitcoin.conf', + (obj: typeof shape._TYPE) => toBitcoinConf(obj), + (str) => fromBitcoinConf(str), + (obj) => shape.unsafeCast(obj), +) diff --git a/startos/index.ts b/startos/index.ts new file mode 100644 index 00000000..8d32c10c --- /dev/null +++ b/startos/index.ts @@ -0,0 +1,11 @@ +/** + * Plumbing. DO NOT EDIT. + */ +export { createBackup, restoreBackup } from './backups' +export { main } from './main' +export { packageInit, packageUninit, containerInit } from './init' +export { actions } from './actions' +import { buildManifest } from '@start9labs/start-sdk' +import { manifest as sdkManifest } from './manifest' +import { versions } from './versions' +export const manifest = buildManifest(versions, sdkManifest) diff --git a/startos/init.ts b/startos/init.ts new file mode 100644 index 00000000..1aca0a51 --- /dev/null +++ b/startos/init.ts @@ -0,0 +1,27 @@ +import { sdk } from './sdk' +import { exposedStore } from './store' +import { setDependencies } from './dependencies' +import { setInterfaces } from './interfaces' +import { versions } from './versions' +import { actions } from './actions' +import { bitcoinConfFile } from './file-models/bitcoin.conf' + +const install = sdk.setupInstall(async ({ effects }) => { + await bitcoinConfFile.write({}) + // @TODO call initial setup action and set store flag +}) + +const uninstall = sdk.setupUninstall(async ({ effects }) => {}) + +/** + * Plumbing. DO NOT EDIT. + */ +export const { packageInit, packageUninit, containerInit } = sdk.setupInit( + versions, + install, + uninstall, + setInterfaces, + setDependencies, + actions, + exposedStore, +) diff --git a/startos/interfaces.ts b/startos/interfaces.ts new file mode 100644 index 00000000..bd5535df --- /dev/null +++ b/startos/interfaces.ts @@ -0,0 +1,85 @@ +import { BindOptions } from '@start9labs/start-sdk/base/lib/osBindings' +import { bitcoinConfFile } from './file-models/bitcoin.conf' +import { getPeerPort, getRpcPort } from './utils' +import { sdk } from './sdk' + +export const rpcInterfaceId = 'rpc' +export const peerInterfaceId = 'peer' +export const zmqInterfaceId = 'zmq' +export const zmqPort = 28332 + +export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + let config = await bitcoinConfFile.read.const(effects) + + if (!config) return [] + + // RPC + const rpcPort = getRpcPort(config.testnet || 0) + const rpcMulti = sdk.host.multi(effects, 'rpc') + const rpcMultiOrigin = await rpcMulti.bindPort(rpcPort, { + protocol: 'grpc', + preferredExternalPort: rpcPort, + }) + const rpc = sdk.createInterface(effects, { + name: 'RPC Interface', + id: rpcInterfaceId, + description: 'Listens for JSON-RPC commands', + type: 'api', + masked: false, + schemeOverride: null, + username: null, + path: '', + search: {}, + }) + const rpcReceipt = await rpcMultiOrigin.export([rpc]) + + const receipts = [rpcReceipt] + + // PEER + const peerPort = getPeerPort(config.testnet || 0) + const peerMulti = sdk.host.multi(effects, 'peer') + const peerMultiOrigin = await peerMulti.bindPort(peerPort, { + protocol: 'bitcoin', + preferredExternalPort: peerPort, + }) + const peer = sdk.createInterface(effects, { + name: 'Peer Interface', + id: peerInterfaceId, + description: + 'Listens for incoming connections from peers on the bitcoin network', + type: 'p2p', + masked: false, + schemeOverride: null, + username: null, + path: '', + search: {}, + }) + const peerReceipt = await peerMultiOrigin.export([peer]) + + receipts.push(peerReceipt) + + // ZMQ (conditional) + if (config.zmqpubhashblock) { + const zmqMulti = sdk.host.multi(effects, 'zmq') + const zmqMultiOrigin = await zmqMulti.bindPort(zmqPort, { + preferredExternalPort: zmqPort, + } as BindOptions) + const zmq = sdk.createInterface(effects, { + name: 'ZeroMQ Interface', + id: zmqInterfaceId, + description: + 'Listens for incoming connections from peers on the bitcoin network', + type: 'api', + masked: false, + schemeOverride: null, + username: null, + path: '', + search: {}, + }) + const zmqReceipt = await zmqMultiOrigin.export([zmq]) + + receipts.push(zmqReceipt) + } + + return receipts +}) diff --git a/startos/main.ts b/startos/main.ts new file mode 100644 index 00000000..0356276b --- /dev/null +++ b/startos/main.ts @@ -0,0 +1,135 @@ +import { sdk } from './sdk' +import { T } from '@start9labs/start-sdk' +import { peerInterfaceId } from './interfaces' +import { GetBlockchainInfo, getRpcPort } from './utils' +import { bitcoinConfFile } from './file-models/bitcoin.conf' + +export const main = sdk.setupMain(async ({ effects, started }) => { + /** + * ======================== Setup (optional) ======================== + */ + + const conf = (await bitcoinConfFile.read.const(effects))! + + const rpcPort = getRpcPort(conf.testnet || 0) + const containerIp = await effects.getContainerIp() + // @TODO take into account possibilioty of multiple/no .onions and also clearnet domains + const peerAddr = ( + await sdk.serviceInterface.getOwn(effects, peerInterfaceId).const() + )?.addressInfo?.onionUrls[0] + + const bitcoinArgs: string[] = [] + + bitcoinArgs.push(`-onion=${containerIp}:9050`) + bitcoinArgs.push(`-externalip=${peerAddr}`) + bitcoinArgs.push('-datadir=/root/.bitcoin"') + bitcoinArgs.push('-conf=/root/.bitcoin/bitcoin.conf') + + for await (const reindexBlockchain of sdk.store + .getOwn(effects, sdk.StorePath.reindexBlockchain) + .watch()) { + if (reindexBlockchain) { + bitcoinArgs.push('-reindex') + await sdk.store.setOwn(effects, sdk.StorePath.reindexBlockchain, false) + await sdk.restart(effects) + } + } + + for await (const reindexChainstate of sdk.store + .getOwn(effects, sdk.StorePath.reindexChainstate) + .watch()) { + if (reindexChainstate) { + bitcoinArgs.push('-reindex-chainstate') + await sdk.store.setOwn(effects, sdk.StorePath.reindexChainstate, false) + await sdk.restart(effects) + } + } + + /** + * ======================== Additional Health Checks (optional) ======================== + */ + + const syncCheck = sdk.HealthCheck.of(effects, { + name: 'Blockchain Sync Progress', + fn: async () => { + const res = await sdk.runCommand( + effects, + { id: 'bitcoind' }, + [ + 'bitcoin-cli', + '-conf=/root/.bitcoin/bitcoin.conf', + 'getblockchaininfo', + ], + {}, + 'getblockchaininfo', + ) + + if (res.stdout && typeof res.stdout === 'string') { + const info: GetBlockchainInfo = JSON.parse(res.stdout) + + if (info.initialblockdownload) { + const percentage = (info.blocks / info.headers).toFixed(2) + return { + message: `Syncing blocks...${percentage}%`, + result: 'loading', + } + } + + return { + message: 'Bitcoin is fully synced', + result: 'success', + } + } + + return { + message: null, + result: + typeof res.stderr === 'string' && JSON.parse(res.stderr).code === 28 + ? 'starting' + : 'failure', + } + }, + }) + + const healthReceipts: T.HealthReceipt[] = [syncCheck] + + /** + * ======================== Daemons ======================== + */ + + const daemons = sdk.Daemons.of(effects, started, healthReceipts).addDaemon( + 'primary', + { + subcontainer: { id: 'bitcoind' }, + command: ['bitcoind', ...bitcoinArgs], + mounts: sdk.Mounts.of().addVolume('main', null, '/data', false), + ready: { + display: 'RPC Interface', + fn: () => + sdk.healthCheck.checkPortListening(effects, rpcPort, { + successMessage: 'The Bitcoin RPC interface is ready', + errorMessage: 'The Bitcoin RPC interface is not ready', + }), + }, + requires: [], + }, + ) + + if (conf.prune == 1) { + daemons.addDaemon('proxy', { + subcontainer: { id: 'proxy' }, + command: ['btc-rpc-proxy'], + mounts: sdk.Mounts.of().addVolume('proxy', null, '/data', false), // @TODO add mount for toml file + ready: { + display: 'RPC Proxy', + fn: () => + sdk.healthCheck.checkPortListening(effects, 28332, { + successMessage: 'The Bitcoin RPC Proxy is ready', + errorMessage: 'The Bitcoin RPC Proxy is not ready', + }), + }, + requires: [], + }) + } + return daemons +}) diff --git a/startos/manifest.ts b/startos/manifest.ts new file mode 100644 index 00000000..ecbeca8e --- /dev/null +++ b/startos/manifest.ts @@ -0,0 +1,42 @@ +import { setupManifest } from '@start9labs/start-sdk' + +export const manifest = setupManifest({ + id: 'bitcoind', + title: 'Bitcoin Core', + license: 'MIT', + donationUrl: null, + wrapperRepo: 'https://github.com/Start9Labs/bitcoind-startos', + upstreamRepo: 'https://github.com/bitcoin/bitcoin', + supportSite: 'https://github.com/bitcoin/bitcoin/issues', + marketingSite: 'https://bitcoincore.org/', + description: { + short: 'A Bitcoin Full Node by Bitcoin Core', + long: 'Bitcoin is an innovative payment network and a new kind of money. Bitcoin uses peer-to-peer technology to operate with no central authority or banks; managing transactions and the issuing of bitcoins is carried out collectively by the network. Bitcoin is open-source; its design is public, nobody owns or controls Bitcoin and everyone can take part. Through many of its unique properties, Bitcoin allows exciting uses that could not be covered by any previous payment system.', + }, + assets: ['rpcauth'], + volumes: ['main', 'proxy'], + images: { + bitcoind: { + source: { + dockerTag: 'bitcoin/bitcoin:27.1', + }, + }, + proxy: { + source: { + dockerTag: 'ghcr.io/start9labs/btc-rpc-proxy', + }, + }, + }, + hardwareRequirements: {}, + alerts: { + install: null, + update: null, + uninstall: + "Uninstalling Bitcoin Core will result in permanent loss of data. Without a backup, any funds stored on your node's default hot wallet will be lost forever. If you are unsure, we recommend making a backup, just to be safe.", + restore: + 'Restoring Bitcoin Core will overwrite its current data. You will lose any transactions recorded in watch-only wallets, and any funds you have received to the hot wallet, since the last backup.', + start: null, + stop: null, + }, + dependencies: {}, +}) diff --git a/startos/sdk.ts b/startos/sdk.ts new file mode 100644 index 00000000..5146d32a --- /dev/null +++ b/startos/sdk.ts @@ -0,0 +1,13 @@ +import { StartSdk } from '@start9labs/start-sdk' +import { manifest } from './manifest' +import { Store } from './store' + +/** + * Plumbing. DO NOT EDIT. + * + * The exported "sdk" const will be imported and used throughout the package codebase. + */ +export const sdk = StartSdk.of() + .withManifest(manifest) + .withStore() + .build(true) diff --git a/startos/store.ts b/startos/store.ts new file mode 100644 index 00000000..39d96f43 --- /dev/null +++ b/startos/store.ts @@ -0,0 +1,8 @@ +import { setupExposeStore } from '@start9labs/start-sdk' + +export type Store = { + reindexBlockchain: boolean + reindexChainstate: boolean +} + +export const exposedStore = setupExposeStore((_pathBuilder) => []) diff --git a/startos/utils.ts b/startos/utils.ts new file mode 100644 index 00000000..cef56bb1 --- /dev/null +++ b/startos/utils.ts @@ -0,0 +1,65 @@ +import { Effects } from '@start9labs/start-sdk/base/lib/Effects' +import { bitcoinConfFile } from './file-models/bitcoin.conf' + +export type GetNetworkInfo = { + connections: number + connectionsIn: number + connectionsOut: number +} + +export type GetBlockchainInfo = { + chain: string + blocks: number + headers: number + bestblockhash: string + difficulty: number + mediantime: number + verificationprogress: number + initialblockdownload: boolean + chainwork: string + size_on_disk: number + pruned: boolean + pruneheight?: number + automatic_pruning?: boolean + prune_target_size?: number + softforks: Record< + string, + { + type: string + bip9?: { + status: string + bit?: number + start_time: number + timeout: number + since: number + statistics?: { + period: number + threshold: number + elapsed: number + count: number + possible: boolean + } + } + height?: number + active: boolean + } + > + warnings: string +} + +export function getRpcPort(testnet: 0 | 1) { + return testnet ? 18332 : 8332 +} + +export function getPeerPort(testnet: 0 | 1) { + return testnet ? 18333 : 8333 +} + +export async function getRpcUsers(effects: Effects) { + const rpcauth = await getRpcAuth(effects) + return rpcauth?.map((e) => e.split(':', 2)[0]) +} + +export async function getRpcAuth(effects: Effects) { + return (await bitcoinConfFile.read.const(effects))?.rpcauth +} diff --git a/startos/versions/index.ts b/startos/versions/index.ts new file mode 100644 index 00000000..3a794d4f --- /dev/null +++ b/startos/versions/index.ts @@ -0,0 +1,4 @@ +import { VersionGraph } from '@start9labs/start-sdk' +import { v28_1_1 } from './v28_1_1' + +export const versions = VersionGraph.of(v28_1_1) diff --git a/startos/versions/v28_1_1.ts b/startos/versions/v28_1_1.ts new file mode 100644 index 00000000..bc0aca9b --- /dev/null +++ b/startos/versions/v28_1_1.ts @@ -0,0 +1,18 @@ +import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' +import { bitcoinConfFile } from '../file-models/bitcoin.conf' +import { sdk } from '../sdk' + +export const v28_1_1 = VersionInfo.of({ + version: '28.1:1', + releaseNotes: 'Revamped for StartOS 0.3.6', + migrations: { + up: async ({ effects }) => { + await bitcoinConfFile.merge({ testnet: 0 }) + await sdk.store.setOwn(effects, sdk.StorePath, { + reindexBlockchain: false, + reindexChainstate: false, + }) + }, + down: IMPOSSIBLE, + }, +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..a2945a5b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["startos/**/*.ts", "node_modules/**/startos"], + "compilerOptions": { + "target": "ES2018", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +}