From 9994563cfcd61fd87298bba224ae37aedc2a22bf Mon Sep 17 00:00:00 2001 From: Eric P Date: Sat, 16 May 2026 10:13:59 -0500 Subject: [PATCH 1/5] Update everything for StartOS 0.4.0 --- .dockerignore | 3 +- .github/workflows/build.yml | 20 ++ .github/workflows/buildService.yml | 37 --- .github/workflows/release.yml | 20 ++ .github/workflows/releaseService.yml | 72 ------ .github/workflows/tagAndRelease.yml | 25 ++ .gitignore | 6 +- Dockerfile | 8 +- Makefile | 69 +----- backend-entrypoint.sh | 42 ++++ check-api.sh | 2 +- check-pushtx.sh | 2 +- check-synced.sh | 14 +- config.env | 46 ++-- db-entrypoint.sh | 87 +++++++ functions.sh | 8 +- manifest.yaml | 182 --------------- package.json | 28 +++ s9pk.mk | 132 +++++++++++ scripts/.gitignore | 1 - scripts/bundle.ts | 6 - scripts/deps.ts | 1 - scripts/embassy.ts | 5 - scripts/procedures/dependencies.ts | 131 ----------- scripts/procedures/getConfig.ts | 196 ---------------- scripts/procedures/migrations.ts | 34 --- scripts/procedures/properties.ts | 3 - scripts/procedures/setConfig.ts | 27 --- soroban-entrypoint.sh | 15 ++ startos/actions/config.ts.bak | 267 ++++++++++++++++++++++ startos/actions/configureDojo.ts | 150 ++++++++++++ startos/actions/index.ts | 12 + startos/actions/selectBitcoinNode.ts | 49 ++++ startos/actions/selectIndexer.ts | 49 ++++ startos/actions/viewPairingCode.ts | 40 ++++ startos/backups.ts | 5 + startos/daemons.ts | 165 +++++++++++++ startos/dependencies.ts | 69 ++++++ startos/fileModels/stats.json.ts | 17 ++ startos/fileModels/store.json.ts | 58 +++++ startos/i18n/dictionaries/default.ts | 68 ++++++ startos/i18n/dictionaries/translations.ts | 3 + startos/i18n/index.ts | 5 + startos/index.ts | 11 + startos/init/index.ts | 18 ++ startos/init/watchHosts.ts | 38 +++ startos/interfaces.ts | 25 ++ startos/main.ts | 24 ++ startos/manifest/i18n.ts | 26 +++ startos/manifest/index.ts | 58 +++++ startos/mounts.ts | 45 ++++ startos/sdk.ts | 9 + startos/utils.ts | 15 ++ startos/versions/index.ts | 7 + startos/versions/v1.29.2.0.ts | 96 ++++++++ tsconfig.json | 11 + 56 files changed, 1774 insertions(+), 788 deletions(-) create mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/buildService.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/releaseService.yml create mode 100644 .github/workflows/tagAndRelease.yml create mode 100644 backend-entrypoint.sh create mode 100644 db-entrypoint.sh delete mode 100644 manifest.yaml create mode 100644 package.json create mode 100644 s9pk.mk delete mode 100644 scripts/.gitignore delete mode 100644 scripts/bundle.ts delete mode 100644 scripts/deps.ts delete mode 100644 scripts/embassy.ts delete mode 100644 scripts/procedures/dependencies.ts delete mode 100644 scripts/procedures/getConfig.ts delete mode 100644 scripts/procedures/migrations.ts delete mode 100644 scripts/procedures/properties.ts delete mode 100644 scripts/procedures/setConfig.ts create mode 100644 soroban-entrypoint.sh create mode 100644 startos/actions/config.ts.bak create mode 100644 startos/actions/configureDojo.ts create mode 100644 startos/actions/index.ts create mode 100644 startos/actions/selectBitcoinNode.ts create mode 100644 startos/actions/selectIndexer.ts create mode 100644 startos/actions/viewPairingCode.ts create mode 100644 startos/backups.ts create mode 100644 startos/daemons.ts create mode 100644 startos/dependencies.ts create mode 100644 startos/fileModels/stats.json.ts create mode 100644 startos/fileModels/store.json.ts create mode 100644 startos/i18n/dictionaries/default.ts create mode 100644 startos/i18n/dictionaries/translations.ts create mode 100644 startos/i18n/index.ts create mode 100644 startos/index.ts create mode 100644 startos/init/index.ts create mode 100644 startos/init/watchHosts.ts create mode 100644 startos/interfaces.ts create mode 100644 startos/main.ts create mode 100644 startos/manifest/i18n.ts create mode 100644 startos/manifest/index.ts create mode 100644 startos/mounts.ts create mode 100644 startos/sdk.ts create mode 100644 startos/utils.ts create mode 100644 startos/versions/index.ts create mode 100644 startos/versions/v1.29.2.0.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore index f3b6411..1f1847c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,2 @@ -**/.git +.git +.gitmodules diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..dafe4e5 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: Build + +on: + workflow_dispatch: + pull_request: + paths-ignore: ['*.md'] + branches: ['master'] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + build: + if: github.event.pull_request.draft == false + uses: start9labs/shared-workflows/.github/workflows/build.yml@master + # with: + # FREE_DISK_SPACE: true + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} diff --git a/.github/workflows/buildService.yml b/.github/workflows/buildService.yml deleted file mode 100644 index ab6e729..0000000 --- a/.github/workflows/buildService.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Build Service - -on: - workflow_dispatch: - pull_request: - paths-ignore: ['*.md'] - branches: ['main', 'master'] - push: - paths-ignore: ['*.md'] - branches: ['main', 'master'] - -jobs: - BuildPackage: - runs-on: ubuntu-latest - steps: - - name: Prepare StartOS SDK - uses: Start9Labs/sdk@v1 - - - name: Checkout services repository - uses: actions/checkout@v4 - - - name: Build the service package - id: build - run: | - git submodule update --init --recursive - start-sdk init - make - PACKAGE_ID=$(yq -oy ".id" manifest.*) - echo "package_id=$PACKAGE_ID" >> $GITHUB_ENV - printf "\n SHA256SUM: $(sha256sum ${PACKAGE_ID}.s9pk) \n" - shell: bash - - - name: Upload .s9pk - uses: actions/upload-artifact@v4 - with: - name: ${{ env.package_id }}.s9pk - path: ./${{ env.package_id }}.s9pk diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3b83acf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,20 @@ +name: Release + +on: + push: + tags: + - 'v*.*' + +jobs: + release: + uses: start9labs/shared-workflows/.github/workflows/release.yml@master + with: + # FREE_DISK_SPACE: true + RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }} + S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }} + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + permissions: + contents: write diff --git a/.github/workflows/releaseService.yml b/.github/workflows/releaseService.yml deleted file mode 100644 index 6cf91f2..0000000 --- a/.github/workflows/releaseService.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Release Service - -on: - push: - tags: - - 'v*.*' - -jobs: - ReleasePackage: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Prepare StartOS SDK - uses: Start9Labs/sdk@v1 - - - name: Checkout services repository - uses: actions/checkout@v4 - - - name: Build the service package - run: | - git submodule update --init --recursive - start-sdk init - make - - - name: Setting package ID and title from the manifest - id: package - run: | - echo "package_id=$(yq -oy ".id" manifest.*)" >> $GITHUB_ENV - echo "package_title=$(yq -oy ".title" manifest.*)" >> $GITHUB_ENV - shell: bash - - - name: Generate sha256 checksum - run: | - PACKAGE_ID=${{ env.package_id }} - printf "\n SHA256SUM: $(sha256sum ${PACKAGE_ID}.s9pk) \n" - sha256sum ${PACKAGE_ID}.s9pk > ${PACKAGE_ID}.s9pk.sha256 - shell: bash - - - name: Generate changelog - run: | - PACKAGE_ID=${{ env.package_id }} - echo "## What's Changed" > change-log.txt - yq -oy '.release-notes' manifest.* >> change-log.txt - echo "## SHA256 Hash" >> change-log.txt - echo '```' >> change-log.txt - sha256sum ${PACKAGE_ID}.s9pk >> change-log.txt - echo '```' >> change-log.txt - shell: bash - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.ref_name }} - name: ${{ env.package_title }} ${{ github.ref_name }} - prerelease: true - body_path: change-log.txt - files: | - ./${{ env.package_id }}.s9pk - ./${{ env.package_id }}.s9pk.sha256 - - - name: Publish to Registry - env: - S9USER: ${{ secrets.S9USER }} - S9PASS: ${{ secrets.S9PASS }} - S9REGISTRY: ${{ secrets.S9REGISTRY }} - run: | - if [[ -z "$S9USER" || -z "$S9PASS" || -z "$S9REGISTRY" ]]; then - echo "Publish skipped: missing registry credentials." - else - start-sdk publish https://$S9USER:$S9PASS@$S9REGISTRY ${{ env.package_id }}.s9pk - fi diff --git a/.github/workflows/tagAndRelease.yml b/.github/workflows/tagAndRelease.yml new file mode 100644 index 0000000..5690d6b --- /dev/null +++ b/.github/workflows/tagAndRelease.yml @@ -0,0 +1,25 @@ +name: Tag and Release + +on: + push: + branches: ['master'] + paths-ignore: ['*.md'] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tag: + uses: start9labs/shared-workflows/.github/workflows/tagAndRelease.yml@master + with: + REFERENCE_REGISTRY: ${{ vars.REFERENCE_REGISTRY }} + # FREE_DISK_SPACE: true + RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }} + S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }} + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + permissions: + contents: write diff --git a/.gitignore b/.gitignore index b36f48e..5a9c5cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ *.s9pk -scripts/*.js +startos/*.js +node_modules/ .DS_Store .vscode/ docker-images +javascript +ncc-cache +package-lock.json diff --git a/Dockerfile b/Dockerfile index d0dcc07..b8b0671 100644 --- a/Dockerfile +++ b/Dockerfile @@ -137,10 +137,12 @@ COPY ./nginx/*.conf /etc/nginx/sites-available/ RUN mkdir /etc/nginx/sites-enabled && \ ln -sf /etc/nginx/sites-available/mainnet.conf /etc/nginx/sites-enabled/dojo.conf -### Docker entrypoint +### Daemon scripts COPY ./config.env /usr/local/bin/config.env -COPY --chmod=755 ./docker_entrypoint.sh /usr/local/bin/ +COPY --chmod=755 ./db-entrypoint.sh /usr/local/bin/ +COPY --chmod=755 ./soroban-entrypoint.sh /usr/local/bin/ +COPY --chmod=755 ./backend-entrypoint.sh /usr/local/bin/ COPY --chmod=755 ./check-synced.sh /usr/local/bin/ COPY --chmod=755 ./check-api.sh /usr/local/bin/ COPY --chmod=755 ./check-mysql.sh /usr/local/bin/ @@ -151,3 +153,5 @@ COPY --chmod=755 ./samourai-dojo/docker/my-dojo/soroban/restart.sh /usr/local/bi # Remove /tmp clean since it errors out nodejs 24 RUN sed -i 's|rm -rf /tmp/\*||' /usr/local/bin/soroban-restart.sh + +RUN mkdir -p /run/secrets diff --git a/Makefile b/Makefile index 44e22a3..397574e 100644 --- a/Makefile +++ b/Makefile @@ -1,63 +1,10 @@ -PKG_ID := $(shell yq e ".id" < manifest.yaml) -PKG_VERSION := $(shell yq e ".version" < manifest.yaml) -TS_FILES := $(shell find ./ -name \*.ts) +ARCHES := x86 arm -# delete the target of a rule if it has changed and its recipe exits with a nonzero exit status -.DELETE_ON_ERROR: +# overrides to s9pk.mk must precede the include statement +include s9pk.mk -all: verify - -verify: $(PKG_ID).s9pk - @start-sdk verify s9pk $(PKG_ID).s9pk - @echo " Done!" - @echo " Filesize: $(shell du -h $(PKG_ID).s9pk) is ready" - -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 - -clean: - rm -rf docker-images - rm -f $(PKG_ID).s9pk - rm -f scripts/*.js - -clean-manifest: - @sed -i '' '/^[[:blank:]]*#/d' manifest.yaml - @echo; echo "Comments successfully removed from manifest.yaml file."; echo - -scripts/embassy.js: $(TS_FILES) - deno run --allow-read --allow-write --allow-env --allow-net scripts/bundle.ts - -arm: - @rm -f docker-images/aarch64.tar - ARCH=aarch64 $(MAKE) - -x86: - @rm -f docker-images/x86_64.tar - ARCH=x86_64 $(MAKE) - -docker-images/aarch64.tar: Dockerfile docker_entrypoint.sh config.env nginx/mainnet.conf nginx/testnet.conf samourai-dojo check-api.sh check-mysql.sh check-pushtx.sh check-synced.sh check-soroban.sh functions.sh -ifeq ($(ARCH),x86_64) -else - mkdir -p docker-images - docker buildx build --tag start9/$(PKG_ID)/main:$(PKG_VERSION) --build-arg ARCH=aarch64 --platform=linux/arm64 -o type=docker,dest=docker-images/aarch64.tar . -endif - -docker-images/x86_64.tar: Dockerfile docker_entrypoint.sh config.env nginx/mainnet.conf nginx/testnet.conf samourai-dojo check-api.sh check-mysql.sh check-pushtx.sh check-synced.sh check-soroban.sh functions.sh -ifeq ($(ARCH),aarch64) -else - mkdir -p docker-images - docker buildx build --tag start9/$(PKG_ID)/main:$(PKG_VERSION) --build-arg ARCH=x86_64 --platform=linux/amd64 -o type=docker,dest=docker-images/x86_64.tar . -endif - -$(PKG_ID).s9pk: manifest.yaml instructions.md icon.png LICENSE.md scripts/embassy.js docker-images/aarch64.tar docker-images/x86_64.tar -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 +assets: icon.png instructions.md LICENSE.md + @mkdir -p assets + @cp icon.png assets/ + @cp instructions.md assets/ + @cp LICENSE.md assets/ diff --git a/backend-entrypoint.sh b/backend-entrypoint.sh new file mode 100644 index 0000000..0a2ae5e --- /dev/null +++ b/backend-entrypoint.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -ea + +source /usr/local/bin/config.env + +echo "[i] Dojo Tor address: $TOR_ADDRESS" +mkdir -p /var/lib/tor/hsv3dojo +echo "$TOR_ADDRESS" > /var/lib/tor/hsv3dojo/hostname + +if [ "$COMMON_BTC_NETWORK" = "testnet" ]; then + PAIRING_URL="http://$TOR_ADDRESS/test/v2" + EXPLORER_ENDPOINT="mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/testnet4" + echo "[i] Running on TESTNET" +else + PAIRING_URL="http://$TOR_ADDRESS/v2" + EXPLORER_ENDPOINT="mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion" + echo "[i] Running on MAINNET" +fi + +echo "[i] Pairing URL: $PAIRING_URL" + +# Set dojo config corresponding to current network +if [ "$COMMON_BTC_NETWORK" = "testnet" ]; then + cp /home/node/app/static/admin/conf/index-testnet.js /home/node/app/static/admin/conf/index.js + ln -sf /etc/nginx/sites-available/testnet.conf /etc/nginx/sites-enabled/dojo.conf +else + cp /home/node/app/static/admin/conf/index-mainnet.js /home/node/app/static/admin/conf/index.js + ln -sf /etc/nginx/sites-available/mainnet.conf /etc/nginx/sites-enabled/dojo.conf +fi + +mkdir -p /var/lib/tor/hsv3explorer +echo -n "$EXPLORER_ENDPOINT" > /var/lib/tor/hsv3explorer/hostname + +cat << EOF > /root/stats.json +{ + "pairingCode": '{"pairing":{"type":"dojo.api","version":"$DOJO_VERSION_TAG","apikey":"$NODE_API_KEY","url":"$PAIRING_URL"},"explorer":{"type":"explorer.btc_rpc_explorer","url":"http://$EXPLORER_ENDPOINT"}}', + "adminKey": "$NODE_ADMIN_KEY" +} +EOF + +# Start dojo +exec pm2-runtime -u node --raw /home/node/app/pm2.config.cjs \ No newline at end of file diff --git a/check-api.sh b/check-api.sh index 1c0ed01..f11be86 100755 --- a/check-api.sh +++ b/check-api.sh @@ -4,7 +4,7 @@ source /usr/local/bin/config.env source /usr/local/bin/functions.sh -admin_key=$(yq -e '.admin-key' /root/start9/config.yaml 2>/dev/null) +admin_key=$(jq -r '.dojo.adminKey' /root/store.json 2>/dev/null) access_token=$(cat /run/secrets/access_token 2>/dev/null) if [ -z "$access_token" ] || ! check_token "$access_token"; then diff --git a/check-pushtx.sh b/check-pushtx.sh index d32df37..8424707 100755 --- a/check-pushtx.sh +++ b/check-pushtx.sh @@ -4,7 +4,7 @@ source /usr/local/bin/config.env source /usr/local/bin/functions.sh -admin_key=$(yq -e '.admin-key' /root/start9/config.yaml 2>/dev/null) +admin_key=$(jq -r '.dojo.adminKey' /root/store.json 2>/dev/null) access_token=$(cat /run/secrets/access_token 2>/dev/null) if [ -z "$access_token" ] || ! check_token "$access_token"; then diff --git a/check-synced.sh b/check-synced.sh index c31a63d..40950f9 100755 --- a/check-synced.sh +++ b/check-synced.sh @@ -6,7 +6,7 @@ source /usr/local/bin/functions.sh bci_result=$(curl -sS --user "$BITCOIND_RPC_USER:$BITCOIND_RPC_PASSWORD" --data-binary '{"jsonrpc": "1.0", "id": "gbci", "method": "getblockchaininfo", "params": []}' -H 'content-type: text/plain;' http://$BITCOIND_IP:$BITCOIND_RPC_PORT/ 2>&1) bci_return=$? -bci_error=$(echo "$bci_result" | yq -e '.message' 2>/dev/null) +bci_error=$(echo "$bci_result" | jq -r '.message // "null"' 2>/dev/null) if [[ $bci_return -ne 0 ]]; then echo "Error contacting Bitcoin RPC: $bci_result" >&2 @@ -16,19 +16,19 @@ elif [ "$bci_error" != "null" ]; then exit 61 fi -bci_block_count=$(echo "$bci_result" | yq -e '.result.blocks' 2>/dev/null) -bci_block_ibd=$(echo "$bci_result" | yq -e '.result.initialblockdownload' 2>/dev/null) +bci_block_count=$(echo "$bci_result" | jq -r '.result.blocks // "null"' 2>/dev/null) +bci_block_ibd=$(echo "$bci_result" | jq -r '.result.initialblockdownload // "null"' 2>/dev/null) if [ "$bci_block_count" = "null" ]; then echo "Error ascertaining Bitcoin blockchain status: $bci_error" >&2 exit 61 elif [ "$bci_block_ibd" != "false" ] ; then - bci_block_headers=$(echo "$bci_result" | yq -e '.result.headers' 2>/dev/null) + bci_block_headers=$(echo "$bci_result" | jq -r '.result.headers // "null"' 2>/dev/null) echo -n "Bitcoin blockchain is not fully synced yet: $bci_block_count downloaded of $bci_block_headers blocks" >&2 echo " ($(expr ${bci_block_count}00 / $bci_block_headers)%)" >&2 exit 61 fi -admin_key=$(yq -e '.admin-key' /root/start9/config.yaml 2>/dev/null) +admin_key=$(jq -r '.dojo.adminKey' /root/store.json 2>/dev/null) access_token=$(cat /run/secrets/access_token 2>/dev/null) if [ -z "$access_token" ] || ! check_token "$access_token"; then @@ -42,8 +42,8 @@ fi account_status=$(get_account_status "$access_token") pushtx_status=$(get_pushtx_status "$access_token") -synced_blocks=$(echo "$account_status" | yq -e '.blocks' 2>/dev/null) -bitcoind_blocks=$(echo "$pushtx_status" | yq -e '.data.bitcoind.blocks' 2>/dev/null) +synced_blocks=$(echo "$account_status" | jq -r '.blocks // "null"' 2>/dev/null) +bitcoind_blocks=$(echo "$pushtx_status" | jq -r '.data.bitcoind.blocks // "null"' 2>/dev/null) if [[ $synced_blocks -eq $bitcoind_blocks ]]; then exit 0 else diff --git a/config.env b/config.env index db98469..1bc1d3a 100644 --- a/config.env +++ b/config.env @@ -6,10 +6,26 @@ source /home/node/app/docker/my-dojo/.env export HOST_IP=$(ip -4 route list match 0/0 | awk '{print $3}') -export BITCOIND_TYPE=$(yq e '.bitcoin-node.type' /root/start9/config.yaml) -export BITCOIND_IP="${BITCOIND_TYPE}.embassy" -export BITCOIND_RPC_USER=$(yq e '.bitcoin-node.username' /root/start9/config.yaml) -export BITCOIND_RPC_PASSWORD=$(yq e '.bitcoin-node.password' /root/start9/config.yaml) +# Config tor and explorer +TOR_ADDRESS=$(jq -r '.tor.announceAddrs[0]' /root/store.json) + +if [ -z "$TOR_ADDRESS" ] || [ "$TOR_ADDRESS" = "null" ]; then + echo "[!] Tor onion address not found, please create an onion address for the Dojo..." + exit 1 +fi + +COOKIE_FILE="/mnt/bitcoin/.cookie" +if [ -f "$COOKIE_FILE" ]; then + COOKIE=$(cat "$COOKIE_FILE") + export BITCOIND_RPC_USER="${COOKIE%%:*}" + export BITCOIND_RPC_PASSWORD="${COOKIE#*:}" + echo "[i] Loaded Bitcoin RPC credentials from .cookie (user: $BITCOIND_RPC_USER)" +else + echo "[w] Bitcoin .cookie file not found at $COOKIE_FILE" +fi + +export BITCOIND_TYPE=$(jq -r '.bitcoinNode.type' /root/store.json) +export BITCOIND_IP="${BITCOIND_TYPE}.startos" export BITCOIND_ZMQ_BLK_HASH=28332 export BITCOIND_ZMQ_RAWTXS=28333 @@ -31,34 +47,34 @@ export NET_DOJO_MYSQL_IPV4=127.0.0.1 # Keep this API key secret! # Provide a value with a high entropy! # Type: alphanumeric -export NODE_API_KEY=$(yq e '.api-key' /root/start9/config.yaml) +export NODE_API_KEY=$(jq -r '.dojo.apiKey' /root/store.json) # API key required for accessing the admin/maintenance services provided by the server # Keep this Admin key secret! # Provide a value with a high entropy! # Type: alphanumeric -export NODE_ADMIN_KEY=$(yq e '.admin-key' /root/start9/config.yaml) +export NODE_ADMIN_KEY=$(jq -r '.dojo.adminKey' /root/store.json) # BIP47 Payment Code used for admin authentication # Type: alphanumeric -export NODE_PAYMENT_CODE=$(yq e '.payment-code' /root/start9/config.yaml) +export NODE_PAYMENT_CODE=$(jq -r '.dojo.paymentCode' /root/store.json) # Secret used by the server for signing Json Web Token # Keep this value secret! # Provide a value with a high entropy! # Type: alphanumeric -export NODE_JWT_SECRET=$(yq e '.jwt-secret' /root/start9/config.yaml) +export NODE_JWT_SECRET=$(jq -r '.dojo.jwtSecret' /root/store.json) # FEE TYPE USED FOR FEES ESTIMATIONS BY BITCOIND # Allowed values are ECONOMICAL or CONSERVATIVE export NODE_FEE_TYPE=ECONOMICAL # Indexer or third-party service used for imports and rescans of addresses -export S9_INDEXER_TYPE=$(yq e '.indexer.type' /root/start9/config.yaml) +export S9_INDEXER_TYPE=$(jq -r '.indexer.type' /root/store.json) # Values: local_bitcoind | local_indexer | third_party_explorer export NODE_ACTIVE_INDEXER=local_indexer -export INDEXER_IP=${S9_INDEXER_TYPE}.embassy # fulcrum or electrs +export INDEXER_IP=${S9_INDEXER_TYPE}.startos # fulcrum or electrs export INDEXER_RPC_PORT=50001 export INDEXER_BATCH_SUPPORT=active export INDEXER_PROTOCOL=tcp @@ -72,7 +88,7 @@ export EXPLORER_INSTALL=on # Soroban configuration export SOROBAN_INSTALL=on -SOROBAN_ANNOUNCE_CONFIG=$(yq e '.soroban-announce.enabled // "disabled"' /root/start9/config.yaml) +SOROBAN_ANNOUNCE_CONFIG=$(jq -r '.dojo.sorobanAnnounce.enabled // "disabled"' /root/store.json) if [ "$SOROBAN_ANNOUNCE_CONFIG" = "enabled" ]; then export SOROBAN_ANNOUNCE=on else @@ -80,7 +96,7 @@ else fi # PandoTx Process is only available when Soroban announce is enabled -S9_PANDOTX_PROCESS=$(yq e '.soroban-announce.pandotx-process // false' /root/start9/config.yaml) +S9_PANDOTX_PROCESS=$(jq -r '.dojo.sorobanAnnounce.pandotxProcess // false' /root/store.json) if [ "$SOROBAN_ANNOUNCE_CONFIG" = "enabled" ] && [ "$S9_PANDOTX_PROCESS" = "true" ]; then export NODE_PANDOTX_PROCESS=on else @@ -88,7 +104,7 @@ else fi # Push transaction through PandoTx (Soroban network) -S9_PANDOTX_PUSH=$(yq e '.pandotx-push' /root/start9/config.yaml) +S9_PANDOTX_PUSH=$(jq -r '.dojo.pandotxPush' /root/store.json) if [ "$S9_PANDOTX_PUSH" = "true" ]; then export NODE_PANDOTX_PUSH=on else @@ -117,7 +133,7 @@ export SOROBAN_ONION_FILE=/var/lib/tor/hsv3soroban/hostname # Fallback mode # convenient: a push will ultimately be processed through the local node (soroban or bitcoind) # secure: it will fail if it can't be processed through a randomnly selected Soroban node -export NODE_PANDOTX_FALLBACK_MODE=$(yq e '.pandotx-fallback-mode' /root/start9/config.yaml) +export NODE_PANDOTX_FALLBACK_MODE=$(jq -r '.dojo.pandotxFallbackMode' /root/store.json) # Max number of retries in case of a failed push -export NODE_PANDOTX_NB_RETRIES=$(yq e '.pandotx-retries' /root/start9/config.yaml) +export NODE_PANDOTX_NB_RETRIES=$(jq -r '.dojo.pandotxRetries' /root/store.json) diff --git a/db-entrypoint.sh b/db-entrypoint.sh new file mode 100644 index 0000000..3db9d41 --- /dev/null +++ b/db-entrypoint.sh @@ -0,0 +1,87 @@ +#!/bin/bash +set -ea + +source /usr/local/bin/config.env + +# DATABASE SETUP +if [ -d "/run/mysqld" ]; then + # mysqld run directory already present, no need to create + chown -R mysql:mysql /run/mysqld +else + echo "[i] MySQL run directory not found, creating...." + mkdir -p /run/mysqld + chown -R mysql:mysql /run/mysqld +fi + +MYSQL_DATABASE=${MYSQL_DATABASE:-"samourai-main"} +MYSQL_USER=${MYSQL_USER:-"samourai"} +MYSQL_PASSWORD=${MYSQL_PASSWORD:-"samourai"} + +if [ ! -d /var/lib/mysql/mysql ]; then + echo "[i] MySQL data directory not found or not initialized, creating initial DBs" + + mkdir -p /var/lib/mysql + chown -R mysql:mysql /var/lib/mysql + touch /var/lib/mysql/.dojo_db_initialized + + mysql_install_db --user=mysql --ldata=/var/lib/mysql > /dev/null + + if [ "$MYSQL_ROOT_PASSWORD" = "" ]; then + MYSQL_ROOT_PASSWORD=$(pwgen 16 1) + echo "[i] MySQL root Password: $MYSQL_ROOT_PASSWORD" + export MYSQL_ROOT_PASSWORD + fi + + tfile=$(mktemp) + if [ ! -f "$tfile" ]; then + return 1 + fi + + cat << EOF > "$tfile" +USE mysql; +FLUSH PRIVILEGES ; +GRANT ALL ON *.* TO 'root'@'%' identified by '$MYSQL_ROOT_PASSWORD' WITH GRANT OPTION ; +GRANT ALL ON *.* TO 'root'@'localhost' identified by '$MYSQL_ROOT_PASSWORD' WITH GRANT OPTION ; +SET PASSWORD FOR 'root'@'localhost'=PASSWORD('${MYSQL_ROOT_PASSWORD}') ; +DROP DATABASE IF EXISTS test ; +FLUSH PRIVILEGES ; +EOF + + if [ "$MYSQL_DATABASE" != "" ]; then + echo "[i] Creating database: $MYSQL_DATABASE" + echo "[i] with character set: 'utf8' and collation: 'utf8_general_ci'" + echo "CREATE DATABASE IF NOT EXISTS \`$MYSQL_DATABASE\` CHARACTER SET utf8 COLLATE utf8_general_ci;" >> "$tfile" + + if [ "$MYSQL_USER" != "" ]; then + echo "[i] Creating user: $MYSQL_USER with password $MYSQL_PASSWORD" + + { + echo "GRANT ALL ON \`$MYSQL_DATABASE\`.* to '$MYSQL_USER'@'%' IDENTIFIED BY '$MYSQL_PASSWORD';" + echo "GRANT ALL ON \`$MYSQL_DATABASE\`.* to '$MYSQL_USER'@'localhost' IDENTIFIED BY '$MYSQL_PASSWORD';" + echo "FLUSH PRIVILEGES;" + } >> "$tfile" + fi + fi + + /usr/bin/mysqld --user=mysql --bootstrap --verbose=0 --skip-name-resolve --skip-networking=0 < "$tfile" + + rm -f "$tfile" + echo + echo 'MySQL init process done. Starting mysqld...' + echo +else + echo "[i] MySQL data directory already initialized, skipping initial DB creation." +fi + +# Migrate database tables +echo "[i] Running database migration..." +for f in /docker-entrypoint-initdb.d/*; do + case "$f" in + *.sql) echo "$0: running $f"; sed "1iUSE \`$MYSQL_DATABASE\`;" "$f" | /usr/bin/mysqld --user=mysql --bootstrap --verbose=0 --skip-name-resolve --skip-networking=0; echo ;; + *) echo "$0: ignoring or entrypoint initdb empty $f" ;; + esac + echo +done + +# Start mysql +exec /usr/bin/mysqld_safe --user=mysql --datadir='/var/lib/mysql' \ No newline at end of file diff --git a/functions.sh b/functions.sh index fb0a18d..3b63dda 100755 --- a/functions.sh +++ b/functions.sh @@ -4,7 +4,7 @@ do_authenticate () { local ak=$1 auth_result=$(curl -X POST -s --data-binary "apikey=$ak&at=null" -H "Content-Type: application/json" "http://localhost:8080/auth/login") - at=$(echo "$auth_result" | yq e ".authorizations.access_token" 2>/dev/null) + at=$(echo "$auth_result" | jq -r '.authorizations.access_token // empty' 2>/dev/null) if [ -n "$at" ] && [ "$at" != "null" ]; then echo "$at" > /run/secrets/access_token echo "$at" @@ -16,7 +16,7 @@ do_authenticate () { get_pushtx_status () { local access_token=$1 status=$(curl -s -H "Content-Type: application/json" "http://localhost:8081/status?at=$access_token") - maybe_error=$(echo "$status" | yq -e '.error' 2>/dev/null) + maybe_error=$(echo "$status" | jq -r '.error // empty' 2>/dev/null) if [ "$maybe_error" == 'Invalid JSON Web Token' ]; then return 1 else @@ -27,7 +27,7 @@ get_pushtx_status () { get_account_status () { local access_token=$1 status=$(curl -s -H "Content-Type: application/json" "http://localhost:8080/status?at=$access_token") - maybe_error=$(echo "$status" | yq -e '.error' 2>/dev/null) + maybe_error=$(echo "$status" | jq -r '.error // empty' 2>/dev/null) if [ "$maybe_error" == 'Invalid JSON Web Token' ]; then return 1 else @@ -38,7 +38,7 @@ get_account_status () { check_token () { local access_token=$1 status=$(curl -s -H "Content-Type: application/json" "http://localhost:8080/status?at=$access_token") - maybe_error=$(echo "$status" | yq -e '.error' 2>/dev/null) + maybe_error=$(echo "$status" | jq -r '.error // empty' 2>/dev/null) if [ "$maybe_error" == 'Invalid JSON Web Token' ]; then return 1 else diff --git a/manifest.yaml b/manifest.yaml deleted file mode 100644 index 54e935b..0000000 --- a/manifest.yaml +++ /dev/null @@ -1,182 +0,0 @@ -id: dojo -title: 'Dojo' -version: 1.29.2.0 -release-notes: Version v1.29.2 -license: AGPLv3 -wrapper-repo: 'https://github.com/ericpp/dojo-startos' -upstream-repo: 'https://github.com/Dojo-Open-Source-Project/samourai-dojo' -support-site: 'https://dojo-osp.org/' -marketing-site: 'https://dojo-osp.org/' -build: ['make'] -description: - short: Your private backend server for Ashigaru, Samourai Wallet and other light wallets. - long: - Dojo is the backend server for Ashigaru, Samourai Wallet and other light wallets. It provides HD account & loose addresses (BIP47) balances & transactions lists. - Provides unspent output lists to the wallet. PushTX endpoint broadcasts transactions through the backing bitcoind node. -assets: - license: LICENSE.md - icon: icon.png - instructions: instructions.md -main: - type: docker - image: main - entrypoint: 'docker_entrypoint.sh' - args: [] - mounts: - main: /root - db: /var/lib/mysql -hardware-requirements: - arch: - - x86_64 - - aarch64 -health-checks: - api: - name: API - success-message: Dojo API is online and ready for connections - type: docker - image: main - entrypoint: 'check-api.sh' - args: [] - inject: true - system: false - io-format: yaml - mysql: - name: MySQL - success-message: MySQL is online and ready for connections - type: docker - image: main - entrypoint: 'check-mysql.sh' - args: [] - inject: true - system: false - io-format: yaml - pushtx: - name: PushTx - success-message: Dojo PushTx API is online and ready for connections - type: docker - image: main - entrypoint: 'check-pushtx.sh' - args: [] - inject: true - system: false - io-format: yaml - synced: - name: Synced - success-message: Dojo is synced with the network - type: docker - image: main - entrypoint: 'check-synced.sh' - args: [] - inject: true - system: false - io-format: yaml - soroban: - name: Soroban - success-message: Soroban is running - type: docker - image: main - entrypoint: 'check-soroban.sh' - args: [] - inject: true - system: false - io-format: yaml -config: - get: - type: script - set: - type: script -properties: - type: script -volumes: - main: - type: data - db: - type: data -interfaces: - main: - name: Dojo Web UI - description: Specifies the interface to listen on for HTTP connections. - tor-config: - port-mapping: - 80: '9000' - ui: true - protocols: - - tcp - - http -dependencies: - bitcoind: - version: '>=0.21.1.2' - requirement: - type: 'opt-out' - how: Use the Bitcoin Core (default) - description: Used to subscribe to new block events from a full archival node - config: - check: - type: script - auto-configure: - type: script - requires-runtime-config: true - bitcoind-testnet: - version: '>=0.21.1.2' - requirement: - type: 'opt-in' - how: Use the Bitcoin Core Testnet4 - description: Used to subscribe to new block events from a full archival node (testnet) - config: - check: - type: script - auto-configure: - type: script - requires-runtime-config: true - fulcrum: - version: '>=2.0.0' - requirement: - type: 'opt-in' - how: Set Indexer to Fulcrum in the config - description: Used for fast scan of addresses and indexing for deep wallets. - electrs: - version: '>=0.10.7' - requirement: - type: 'opt-in' - how: Set Indexer to Electrs in the config - description: A more stable, but less performant indexer. -backup: - create: - type: docker - image: compat - system: true - entrypoint: compat - args: - - duplicity - - create - - /mnt/backup - - /root/data - mounts: - BACKUP: /mnt/backup - main: /root/data - db: /var/lib/mysql - io-format: yaml - restore: - type: docker - image: compat - system: true - entrypoint: compat - args: - - duplicity - - restore - - /mnt/backup - - /root/data - mounts: - BACKUP: /mnt/backup - main: /root/data - db: /var/lib/mysql - io-format: yaml -migrations: - from: - '*': - type: script - args: ['from'] - to: - '*': - type: script - args: ['to'] diff --git a/package.json b/package.json new file mode 100644 index 0000000..d38b9dc --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "dojo-startos", + "scripts": { + "build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", + "prettier": "prettier --write startos", + "check": "tsc --noEmit" + }, + "dependencies": { + "@start9labs/start-sdk": "1.5.3", + "bitcoin-core-startos": "github:Start9Labs/bitcoin-core-startos#v31.0_14", + "bitcoind-testnet4-startos": "github:remcoros/bitcoind-testnet4-startos#v31.0_1", + "fulcrum-startos": "github:Start9Labs/fulcrum-startos#v2.1.1_5", + "electrs-startos": "github:Start9-Community/electrs-startos#v0.11.1_8", + "tor-startos": "github:Start9Labs/tor-startos#v0.4.9.9_1" + }, + "devDependencies": { + "@types/node": "^22.19.0", + "@vercel/ncc": "^0.38.4", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + }, + "prettier": { + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true + } +} diff --git a/s9pk.mk b/s9pk.mk new file mode 100644 index 0000000..978a059 --- /dev/null +++ b/s9pk.mk @@ -0,0 +1,132 @@ +# ** Plumbing. DO NOT EDIT **. +# This file is imported by ./Makefile. Make edits there + +PACKAGE_ID := $(shell awk -F"'" '/id:/ {print $$2}' startos/manifest/index.ts) +INGREDIENTS := $(shell start-cli s9pk list-ingredients 2>/dev/null) +# Resolve the actual git dir so this works inside git worktrees, where .git +# is a file pointing at
/.git/worktrees/ rather than a directory. +GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null) +GIT_DEPS := $(if $(GIT_DIR),$(GIT_DIR)/HEAD $(GIT_DIR)/index) +ARCHES ?= x86 arm riscv +TARGETS ?= arches +ifdef VARIANT +BASE_NAME := $(PACKAGE_ID)_$(VARIANT) +else +BASE_NAME := $(PACKAGE_ID) +endif + +.PHONY: all arches aarch64 x86_64 riscv64 arm arm64 x86 riscv arch/* clean install check-deps check-init package ingredients +.DELETE_ON_ERROR: +.SECONDARY: + +define SUMMARY + @manifest=$$(start-cli s9pk inspect $(1) manifest); \ + size=$$(du -h $(1) | awk '{print $$1}'); \ + title=$$(printf '%s' "$$manifest" | jq -r .title); \ + version=$$(printf '%s' "$$manifest" | jq -r .version); \ + arches=$$(printf '%s' "$$manifest" | jq -r '[.images[].arch // []] | flatten | unique | join(", ")'); \ + sdkv=$$(printf '%s' "$$manifest" | jq -r .sdkVersion); \ + gitHash=$$(printf '%s' "$$manifest" | jq -r .gitHash | sed -E 's/(.*-modified)$$/\x1b[0;31m\1\x1b[0m/'); \ + printf "\n"; \ + printf "\033[1;32m✅ Build Complete!\033[0m\n"; \ + printf "\n"; \ + printf "\033[1;37m📦 $$title\033[0m \033[36mv$$version\033[0m\n"; \ + printf "───────────────────────────────\n"; \ + printf " \033[1;36mFilename:\033[0m %s\n" "$(1)"; \ + printf " \033[1;36mSize:\033[0m %s\n" "$$size"; \ + printf " \033[1;36mArch:\033[0m %s\n" "$$arches"; \ + printf " \033[1;36mSDK:\033[0m %s\n" "$$sdkv"; \ + printf " \033[1;36mGit:\033[0m %s\n" "$$gitHash"; \ + echo "" +endef + +all: $(TARGETS) + +arches: $(ARCHES) + +universal: $(BASE_NAME).s9pk + $(call SUMMARY,$<) + +arch/%: $(BASE_NAME)_%.s9pk + $(call SUMMARY,$<) + +x86 x86_64: arch/x86_64 +arm arm64 aarch64: arch/aarch64 +riscv riscv64: arch/riscv64 + +$(BASE_NAME).s9pk: $(INGREDIENTS) $(GIT_DEPS) + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack -o $@ + +$(BASE_NAME)_%.s9pk: $(INGREDIENTS) $(GIT_DEPS) + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack --arch=$* -o $@ + +ingredients: $(INGREDIENTS) + @echo " Re-evaluating ingredients..." + +install: | check-deps check-init + @HOST=$$(awk -F'/' '/^host:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$HOST" ]; then \ + echo "Error: You must define \"host: http://server-name.local\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S9PK=$$(ls -t *.s9pk 2>/dev/null | head -1); \ + if [ -z "$$S9PK" ]; then \ + echo "Error: No .s9pk file found. Run 'make' first."; \ + exit 1; \ + fi; \ + printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \ + start-cli package install -s "$$S9PK" + +publish: | all + @REGISTRY=$$(awk -F'/' '/^registry:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$REGISTRY" ]; then \ + echo "Error: You must define \"registry: https://my-registry.tld\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S3BASE=$$(awk -F'/' '/^s9pk-s3base:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$S3BASE" ]; then \ + echo "Error: You must define \"s3base: https://s9pks.my-s3-bucket.tld\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + command -v s3cmd >/dev/null || \ + (echo "Error: s3cmd not found. It must be installed to publish using s3." && exit 1); \ + printf "\n🚀 Publishing to %s; indexing on %s ...\n" "$$S3BASE" "$$REGISTRY"; \ + for s9pk in *.s9pk; do \ + age=$$(( $$(date +%s) - $$(stat -c %Y "$$s9pk") )); \ + if [ "$$age" -gt 3600 ]; then \ + printf "\033[1;33m⚠️ %s is %d minutes old. Publish anyway? [y/N] \033[0m" "$$s9pk" "$$((age / 60))"; \ + read -r ans; \ + case "$$ans" in [yY]*) ;; *) echo "Skipping $$s9pk"; continue ;; esac; \ + fi; \ + start-cli s9pk publish "$$s9pk"; \ + done + +check-deps: + @command -v start-cli >/dev/null || \ + (echo "Error: start-cli not found. Please see https://docs.start9.com/latest/developer-guide/sdk/installing-the-sdk" && exit 1) + @command -v npm >/dev/null || \ + (echo "Error: npm not found. Please install Node.js and npm." && exit 1) + +check-init: + @if [ ! -f ~/.startos/developer.key.pem ]; then \ + echo "Initializing StartOS developer environment..."; \ + start-cli init-key; \ + fi + +javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules + npm run check + npm run build + +node_modules: package-lock.json + npm ci + +package-lock.json: package.json + npm i + +clean: + @echo "Cleaning up build artifacts..." + @rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk $(PACKAGE_ID)_riscv64.s9pk javascript node_modules diff --git a/scripts/.gitignore b/scripts/.gitignore deleted file mode 100644 index 4c43fe6..0000000 --- a/scripts/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.js \ No newline at end of file diff --git a/scripts/bundle.ts b/scripts/bundle.ts deleted file mode 100644 index 07cbf3a..0000000 --- a/scripts/bundle.ts +++ /dev/null @@ -1,6 +0,0 @@ -// scripts/bundle.ts -import { bundle } from "https://deno.land/x/emit@0.40.0/mod.ts"; - -const result = await bundle("scripts/embassy.ts"); - -await Deno.writeTextFile("scripts/embassy.js", result.code); diff --git a/scripts/deps.ts b/scripts/deps.ts deleted file mode 100644 index 3105b54..0000000 --- a/scripts/deps.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "https://deno.land/x/embassyd_sdk@v0.3.3.0.11/mod.ts"; diff --git a/scripts/embassy.ts b/scripts/embassy.ts deleted file mode 100644 index 14d127a..0000000 --- a/scripts/embassy.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { setConfig } from "./procedures/setConfig.ts"; -export { getConfig } from "./procedures/getConfig.ts"; -export { properties } from "./procedures/properties.ts"; -export { migration } from "./procedures/migrations.ts"; -export { dependencies } from "./procedures/dependencies.ts"; diff --git a/scripts/procedures/dependencies.ts b/scripts/procedures/dependencies.ts deleted file mode 100644 index 4806b8c..0000000 --- a/scripts/procedures/dependencies.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { types as T, matches } from "../deps.ts"; - -const { shape, number, string, boolean } = matches; - -const matchBitcoindConfig = shape({ - rpc: shape({ - enable: boolean, - advanced: shape({ - threads: number, - }) - }), - advanced: shape({ - pruning: shape({ - mode: string - }) - }), - 'zmq-enabled': boolean -}); - -const matchIndexerConfig = shape({ - type: string, // "electrs" or "fulcrum" -}); - -export const dependencies: T.ExpectedExports.dependencies = { - bitcoind: { - // deno-lint-ignore require-await - async check(effects, config) { - effects.info("check bitcoind"); - - if (!matchBitcoindConfig.test(config)) { - return { error: "Bitcoind config is not the correct shape" } - } - - if (!config.rpc.enable) { - return { error: "Must have RPC enabled" }; - } - - if (!config['zmq-enabled']) { - return { error: "Must have ZeroMQ enabled" }; - } - - if (config.advanced.pruning.mode !== "disabled") { - return { error: "Pruning must be disabled (must be an archival node)" }; - } - - return { result: null }; - }, - // deno-lint-ignore require-await - async autoConfigure(effects, configInput) { - effects.info("autoconfigure bitcoind"); - - const config = matchBitcoindConfig.unsafeCast(configInput); - - config.rpc.enable = true; - - config['zmq-enabled'] = true; - - if (config.advanced.pruning.mode !== "disabled") { - config.advanced.pruning.mode = "disabled"; - } - - return { result: config }; - }, - }, - 'bitcoind-testnet': { - // deno-lint-ignore require-await - async check(effects, config) { - effects.info("check bitcoind-testnet"); - - if (!matchBitcoindConfig.test(config)) { - return { error: "Bitcoind-testnet config is not the correct shape" } - } - - if (!config.rpc.enable) { - return { error: "Must have RPC enabled" }; - } - - if (!config['zmq-enabled']) { - return { error: "Must have ZeroMQ enabled" }; - } - - if (config.advanced.pruning.mode !== "disabled") { - return { error: "Pruning must be disabled (must be an archival node)" }; - } - - return { result: null }; - }, - // deno-lint-ignore require-await - async autoConfigure(effects, configInput) { - effects.info("autoconfigure bitcoind"); - - const config = matchBitcoindConfig.unsafeCast(configInput); - - config.rpc.enable = true; - - config['zmq-enabled'] = true; - - if (config.advanced.pruning.mode !== "disabled") { - config.advanced.pruning.mode = "disabled"; - } - - return { result: config }; - }, - }, - indexer: { - // deno-lint-ignore require-await - async check(effects, config) { - effects.info("check indexer"); - if (!matchIndexerConfig.test(config)) { - return { error: "Indexer config is not the correct shape" }; - } - if (!["electrs", "fulcrum"].includes(config.type)) { - return { error: "Indexer type must be either 'electrs' or 'fulcrum'" }; - } - return { result: null }; - }, - - // deno-lint-ignore require-await - async autoConfigure(effects, configInput) { - effects.info("autoconfigure indexer"); - const config = matchIndexerConfig.unsafeCast(configInput); - - // Set default type if not specified - if (!config.type) { - config.type = "fulcrum"; // default to fulcrum - } - - return { result: config }; - }, - } -}; diff --git a/scripts/procedures/getConfig.ts b/scripts/procedures/getConfig.ts deleted file mode 100644 index 25ec492..0000000 --- a/scripts/procedures/getConfig.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { compat, types as T } from "../deps.ts"; - -export const getConfig: T.ExpectedExports.getConfig = compat.getConfig({ - "tor-address": { - "name": "Tor Address", - "description": "The Tor address of the network interface", - "type": "pointer", - "subtype": "package", - "package-id": "dojo", - "target": "tor-address", - "interface": "main", - }, - "bitcoin-node": { - "type": "union", - "name": "Bitcoin Node", - "description": - "The Bitcoin node type you would like to use for Dojo", - "tag": { - "id": "type", - "name": "Select Bitcoin Node", - "variant-names": { - "bitcoind": "Bitcoin Core", - "bitcoind-testnet": "Bitcoin Core (testnet4)", - }, - "description": - "The Bitcoin node type you would like to use for Dojo", - }, - "default": "bitcoind", - "variants": { - "bitcoind": { - "username": { - "type": "pointer", - "name": "RPC Username", - "description": "The username for Bitcoin Core's RPC interface", - "subtype": "package", - "package-id": "bitcoind", - "target": "config", - "multi": false, - "selector": "$.rpc.username", - }, - "password": { - "type": "pointer", - "name": "RPC Password", - "description": "The password for Bitcoin Core's RPC interface", - "subtype": "package", - "package-id": "bitcoind", - "target": "config", - "multi": false, - "selector": "$.rpc.password", - }, - }, - "bitcoind-testnet": { - "username": { - "type": "pointer", - "name": "RPC Username", - "description": "The username for Bitcoin Core Testnet RPC interface", - "subtype": "package", - "package-id": "bitcoind-testnet", - "target": "config", - "multi": false, - "selector": "$.rpc.username", - }, - "password": { - "type": "pointer", - "name": "RPC Password", - "description": "The password for Bitcoin Core Testnet RPC interface", - "subtype": "package", - "package-id": "bitcoind-testnet", - "target": "config", - "multi": false, - "selector": "$.rpc.password", - }, - }, - }, - }, - "indexer": { - "type": "union", - "name": "Indexer", - "description": - "The indexer you want to use for Dojo", - "tag": { - "id": "type", - "name": "Select Indexer", - "variant-names": { - "electrs": "electrs", - "fulcrum": "Fulcrum", - }, - "description": - "The indexer you want to use for Dojo", - }, - "default": "electrs", - "variants": { - "electrs": {}, - "fulcrum": {}, - }, - }, - "payment-code": { - "type": "string", - "name": "BIP47 Payment Code", - "description": "BIP47 Payment Code used for admin authentication", - "nullable": true, - "copyable": true, - "masked": false - }, - "admin-key": { - "type": "string", - "name": "Admin Key", - "description": "Key for accessing the admin/maintenance", - "nullable": false, - "copyable": true, - "masked": true, - "default": { - "charset": "a-z,A-Z,0-9", - "len": 22, - }, - }, - "api-key": { - "type": "string", - "name": "API Key", - "description": "Key for accessing the services", - "nullable": false, - "copyable": true, - "masked": true, - "default": { - "charset": "a-z,A-Z,0-9", - "len": 22, - }, - }, - "jwt-secret": { - "type": "string", - "name": "JWT Secret", - "description": "Secret used by the server for signing", - "nullable": false, - "copyable": true, - "masked": true, - "default": { - "charset": "a-z,A-Z,0-9", - "len": 22, - }, - }, - "soroban-announce": { - "type": "union", - "name": "Soroban Network Announce", - "description": "Configure Soroban network participation", - "tag": { - "id": "enabled", - "name": "Soroban Network Announce", - "variant-names": { - "disabled": "Disabled", - "enabled": "Enabled" - }, - "description": "Enable to participate in the Soroban network" - }, - "default": "disabled", - "variants": { - "disabled": {}, - "enabled": { - "pandotx-process": { - "type": "boolean", - "name": "PandoTx Process", - "description": "Process and relay transactions from other Soroban nodes", - "nullable": false, - "default": false - } - } - } - }, - "pandotx-push": { - "type": "boolean", - "name": "PandoTx Push", - "description": "Push your transactions through random Soroban nodes for enhanced privacy", - "nullable": false, - "default": true, - }, - "pandotx-retries": { - "type": "number", - "name": "PandoTx Retries", - "description": "Maximum retry attempts for failed transaction pushes", - "nullable": false, - "default": 2, - "range": "[0,10]", - "integral": true, - }, - "pandotx-fallback-mode": { - "type": "enum", - "name": "PandoTx Fallback Mode", - "description": "Behavior when Soroban push fails", - "nullable": false, - "default": "convenient", - "values": ["convenient", "secure"], - "value-names": { - "convenient": "Convenient (fallback to local node)", - "secure": "Secure (fail if Soroban unavailable)", - }, - }, -}); \ No newline at end of file diff --git a/scripts/procedures/migrations.ts b/scripts/procedures/migrations.ts deleted file mode 100644 index 00ca24a..0000000 --- a/scripts/procedures/migrations.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { compat, types as T } from '../deps.ts' - -export const migration: T.ExpectedExports.migration = compat.migrations.fromMapping( - { - '1.28.0.0': { - up: compat.migrations.updateConfig( - (config: any) => { - // Add default soroban and pandotx config for users upgrading from versions before 1.28.0 - config['soroban-announce'] = { - 'enabled': 'disabled' - } - config['pandotx-push'] = true - config['pandotx-retries'] = 2 - config['pandotx-fallback-mode'] = 'convenient' - return config - }, - true, - { version: '1.28.0.0', type: 'up' }, - ), - down: compat.migrations.updateConfig( - (config: any) => { - delete config['soroban-announce'] - delete config['pandotx-push'] - delete config['pandotx-retries'] - delete config['pandotx-fallback-mode'] - return config - }, - true, - { version: '1.28.0.0', type: 'down' }, - ), - }, - }, - '1.29.2.0', -) diff --git a/scripts/procedures/properties.ts b/scripts/procedures/properties.ts deleted file mode 100644 index dff99aa..0000000 --- a/scripts/procedures/properties.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { compat, types as T } from "../deps.ts"; - -export const properties: T.ExpectedExports.properties = compat.properties; diff --git a/scripts/procedures/setConfig.ts b/scripts/procedures/setConfig.ts deleted file mode 100644 index a385303..0000000 --- a/scripts/procedures/setConfig.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { compat, types as T } from "../deps.ts"; - -// deno-lint-ignore require-await -export const setConfig: T.ExpectedExports.setConfig = async ( - effects: T.Effects, - newConfig: T.Config, -) => { - const dependencies: { [key: string]: string[] } = {}; - - if ((newConfig as any)?.['bitcoin-node']?.type === 'bitcoind') { - dependencies['bitcoind'] = ['synced']; - } - - if ((newConfig as any)?.['bitcoin-node']?.type === 'bitcoind-testnet') { - dependencies['bitcoind-testnet'] = ['synced']; - } - - if ((newConfig as any)?.indexer?.type === 'fulcrum') { - dependencies['fulcrum'] = ['synced']; - } - - if ((newConfig as any)?.indexer?.type === 'electrs') { - dependencies['electrs'] = ['synced']; - } - - return compat.setConfig(effects, newConfig, dependencies); -}; \ No newline at end of file diff --git a/soroban-entrypoint.sh b/soroban-entrypoint.sh new file mode 100644 index 0000000..91e6351 --- /dev/null +++ b/soroban-entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -ea + +source /usr/local/bin/config.env + +# Start Soroban if enabled +echo "[i] Checking Soroban configuration..." +echo "[i] SOROBAN_INSTALL=$SOROBAN_INSTALL" +echo "[i] SOROBAN_ANNOUNCE=$SOROBAN_ANNOUNCE" + +echo "[i] Starting Soroban process as soroban user..." +mkdir -p $(dirname $SOROBAN_ONION_FILE) +chown -R soroban:soroban $(dirname $SOROBAN_ONION_FILE) + +exec runuser -u soroban -- /usr/local/bin/soroban-restart.sh \ No newline at end of file diff --git a/startos/actions/config.ts.bak b/startos/actions/config.ts.bak new file mode 100644 index 0000000..65c04a7 --- /dev/null +++ b/startos/actions/config.ts.bak @@ -0,0 +1,267 @@ +import { store } from '../fileModels/store.json' +import { sdk } from '../sdk' +import { i18n } from '../i18n' + +const { InputSpec, Value, Variants } = sdk + +const bitcoindSpec = InputSpec.of({ + username: Value.text({ + name: i18n('RPC Username'), + required: false, + default: null, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: false, + }), + password: Value.text({ + name: i18n('RPC Password'), + required: false, + default: null, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + }), +}) + +const bitcoinNodeVariants = Variants.of({ + bitcoind: { + name: i18n('Bitcoin Core'), + spec: bitcoindSpec, + }, + 'bitcoind-testnet': { + name: i18n('Bitcoin Core (testnet4)'), + spec: InputSpec.of({ + username: Value.text({ + name: i18n('RPC Username'), + required: false, + default: null, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: false, + }), + password: Value.text({ + name: i18n('RPC Password'), + required: false, + default: null, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + }), + }), + }, +}) + +const indexerVariants = Variants.of({ + electrs: { + name: i18n('electrs'), + spec: InputSpec.of({}), + }, + fulcrum: { + name: i18n('Fulcrum'), + spec: InputSpec.of({}), + }, +}) + +const sorobanVariants = Variants.of({ + disabled: { + name: i18n('Disabled'), + spec: InputSpec.of({}), + }, + enabled: { + name: i18n('Enabled'), + spec: InputSpec.of({ + 'pandotx-process': Value.toggle({ + name: i18n('PandoTx Process'), + description: i18n( + 'Process and relay transactions from other Soroban nodes', + ), + default: false, + }), + }), + }, +}) + +export const inputSpec = InputSpec.of({ + 'bitcoin-node': Value.union({ + name: i18n('Bitcoin Node'), + description: i18n( + 'The Bitcoin node type you would like to use for Dojo', + ), + warning: null, + variants: bitcoinNodeVariants, + default: 'bitcoind', + }), + indexer: Value.union({ + name: i18n('Indexer'), + description: i18n('The indexer you want to use for Dojo'), + warning: null, + variants: indexerVariants, + default: 'electrs', + }), + 'payment-code': Value.text({ + name: i18n('BIP47 Payment Code'), + required: false, + default: null, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: false, + }), + 'admin-key': Value.text({ + name: i18n('Admin Key'), + description: i18n('Key for accessing the admin/maintenance'), + required: true, + default: { charset: 'a-z,A-Z,0-9', len: 22 }, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + generate: { charset: 'a-z,A-Z,0-9', len: 22 }, + }), + 'api-key': Value.text({ + name: i18n('API Key'), + description: i18n('Key for accessing the services'), + required: true, + default: { charset: 'a-z,A-Z,0-9', len: 22 }, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + generate: { charset: 'a-z,A-Z,0-9', len: 22 }, + }), + 'jwt-secret': Value.text({ + name: i18n('JWT Secret'), + description: i18n('Secret used by the server for signing'), + required: true, + default: { charset: 'a-z,A-Z,0-9', len: 22 }, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + generate: { charset: 'a-z,A-Z,0-9', len: 22 }, + }), + 'soroban-announce': Value.union({ + name: i18n('Soroban Network Announce'), + description: i18n('Configure Soroban network participation'), + warning: null, + variants: sorobanVariants, + default: 'disabled', + }), + 'pandotx-push': Value.toggle({ + name: i18n('PandoTx Push'), + description: i18n( + 'Push your transactions through random Soroban nodes for enhanced privacy', + ), + default: true, + }), + 'pandotx-retries': Value.number({ + name: i18n('PandoTx Retries'), + description: i18n('Maximum retry attempts for failed transaction pushes'), + required: true, + default: 2, + min: 0, + max: 10, + integer: true, + units: null, + placeholder: null, + }), + 'pandotx-fallback-mode': Value.select({ + name: i18n('PandoTx Fallback Mode'), + description: i18n('Behavior when Soroban push fails'), + values: { + convenient: i18n('Convenient (fallback to local node)'), + secure: i18n('Secure (fail if Soroban unavailable)'), + }, + default: 'convenient', + }), +}) + +export const configAction = sdk.Action.withInput( + 'config', + + async ({ effects }) => ({ + name: i18n('Dojo Web UI'), + description: i18n( + 'The Bitcoin node type you would like to use for Dojo', + ), + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + inputSpec, + + async ({ effects }) => { + const current = await store.read().const(effects) + if (!current) return {} + return { + 'bitcoin-node': { + selection: current['bitcoin-node']?.type || 'bitcoind', + value: { + username: current['bitcoin-node']?.username || '', + password: current['bitcoin-node']?.password || '', + }, + }, + indexer: { + selection: current.indexer?.type || 'electrs', + value: {}, + }, + 'payment-code': current['payment-code'] || '', + 'admin-key': current['admin-key'] || '', + 'api-key': current['api-key'] || '', + 'jwt-secret': current['jwt-secret'] || '', + 'soroban-announce': { + selection: current['soroban-announce']?.enabled || 'disabled', + value: + current['soroban-announce']?.enabled === 'enabled' + ? { + 'pandotx-process': + current['soroban-announce']?.['pandotx-process'] || false, + } + : {}, + }, + 'pandotx-push': current['pandotx-push'] ?? true, + 'pandotx-retries': current['pandotx-retries'] ?? 2, + 'pandotx-fallback-mode': + current['pandotx-fallback-mode'] || 'convenient', + } + }, + + async ({ effects, input }) => { + const bitcoinNode = input['bitcoin-node'] + const indexer = input['indexer'] + const sorobanAnnounce = input['soroban-announce'] + + await store.write(effects, { + 'bitcoin-node': { + type: bitcoinNode.selection as 'bitcoind' | 'bitcoind-testnet', + username: (bitcoinNode.value as any)?.username, + password: (bitcoinNode.value as any)?.password, + }, + indexer: { + type: indexer.selection as 'electrs' | 'fulcrum', + }, + 'payment-code': input['payment-code'] || null, + 'admin-key': input['admin-key'] as string, + 'api-key': input['api-key'] as string, + 'jwt-secret': input['jwt-secret'] as string, + 'soroban-announce': { + enabled: sorobanAnnounce.selection as 'disabled' | 'enabled', + 'pandotx-process': + sorobanAnnounce.selection === 'enabled' + ? (sorobanAnnounce.value as any)?.['pandotx-process'] || false + : undefined, + }, + 'pandotx-push': input['pandotx-push'] as boolean, + 'pandotx-retries': input['pandotx-retries'] as number, + 'pandotx-fallback-mode': input['pandotx-fallback-mode'] as + | 'convenient' + | 'secure', + }) + }, +) diff --git a/startos/actions/configureDojo.ts b/startos/actions/configureDojo.ts new file mode 100644 index 0000000..1b21aab --- /dev/null +++ b/startos/actions/configureDojo.ts @@ -0,0 +1,150 @@ +import { storeJson } from '../fileModels/store.json' +import { sdk } from '../sdk' +import { i18n } from '../i18n' + +const { InputSpec, Value, Variants } = sdk + +const sorobanVariants = Variants.of({ + disabled: { + name: i18n('Disabled'), + spec: InputSpec.of({}), + }, + enabled: { + name: i18n('Enabled'), + spec: InputSpec.of({ + 'pandotx-process': Value.toggle({ + name: i18n('PandoTx Process'), + description: i18n( + 'Process and relay transactions from other Soroban nodes', + ), + default: false, + }), + }), + }, +}) + +export const inputSpec = InputSpec.of({ + paymentCode: Value.text({ + name: i18n('BIP47 Payment Code'), + required: false, + default: null, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: false, + }), + adminKey: Value.text({ + name: i18n('Admin Key'), + description: i18n('Key for accessing the admin/maintenance'), + required: true, + default: { charset: 'a-z,A-Z,0-9', len: 22 }, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + generate: { charset: 'a-z,A-Z,0-9', len: 22 }, + }), + apiKey: Value.text({ + name: i18n('API Key'), + description: i18n('Key for accessing the services'), + required: true, + default: { charset: 'a-z,A-Z,0-9', len: 22 }, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + generate: { charset: 'a-z,A-Z,0-9', len: 22 }, + }), + jwtSecret: Value.text({ + name: i18n('JWT Secret'), + description: i18n('Secret used by the server for signing'), + required: true, + default: { charset: 'a-z,A-Z,0-9', len: 22 }, + placeholder: null, + inputmode: 'text', + patterns: [], + masked: true, + generate: { charset: 'a-z,A-Z,0-9', len: 22 }, + }), + sorobanAnnounce: Value.union({ + name: i18n('Soroban Network Announce'), + description: i18n('Configure Soroban network participation'), + warning: null, + variants: sorobanVariants, + default: 'disabled', + }), + pandotxPush: Value.toggle({ + name: i18n('PandoTx Push'), + description: i18n( + 'Push your transactions through random Soroban nodes for enhanced privacy', + ), + default: true, + }), + pandotxRetries: Value.number({ + name: i18n('PandoTx Retries'), + description: i18n('Maximum retry attempts for failed transaction pushes'), + required: true, + default: 2, + min: 0, + max: 10, + integer: true, + units: null, + placeholder: null, + }), + pandotxFallbackMode: Value.select({ + name: i18n('PandoTx Fallback Mode'), + description: i18n('Behavior when Soroban push fails'), + values: { + convenient: i18n('Convenient (fallback to local node)'), + secure: i18n('Secure (fail if Soroban unavailable)'), + }, + default: 'convenient', + }), +}) + +export const configureDojoAction = sdk.Action.withInput( + 'configure-dojo', + + async ({ effects }) => ({ + name: i18n('Configure Dojo'), + description: i18n( + 'Customize your Dojo', + ), + warning: null, + allowedStatuses: 'any', + group: i18n('Configuration'), + visibility: 'enabled', + }), + + inputSpec, + + // pre-fill + async ({ effects }) => { + const dojo = await storeJson.read((s) => s.dojo).const(effects) + if (!dojo) return {} + return { + ...dojo, + sorobanAnnounce: + dojo.sorobanAnnounce.enabled === 'enabled' + ? { selection: 'enabled', value: { 'pandotx-process': dojo.sorobanAnnounce.pandotxProcess } } + : { selection: 'disabled', value: {} }, + } + }, + + // execution + async ({ effects, input }) => { + const announce = input.sorobanAnnounce + return storeJson.merge(effects, { + dojo: { + ...input, + sorobanAnnounce: { + enabled: announce.selection, + pandotxProcess: + announce.selection === 'enabled' + ? announce.value['pandotx-process'] + : false, + }, + }, + }) + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts new file mode 100644 index 0000000..ae7da6f --- /dev/null +++ b/startos/actions/index.ts @@ -0,0 +1,12 @@ +import { sdk } from '../sdk' +import { configureDojoAction } from './configureDojo' +import { selectBitcoinNodeAction } from './selectBitcoinNode' +import { selectIndexerAction } from './selectIndexer' +import { viewPairingCodeAction } from './viewPairingCode' + + +export const actions = sdk.Actions.of() + .addAction(selectBitcoinNodeAction) + .addAction(selectIndexerAction) + .addAction(configureDojoAction) + .addAction(viewPairingCodeAction) \ No newline at end of file diff --git a/startos/actions/selectBitcoinNode.ts b/startos/actions/selectBitcoinNode.ts new file mode 100644 index 0000000..f7d7a6e --- /dev/null +++ b/startos/actions/selectBitcoinNode.ts @@ -0,0 +1,49 @@ +import { storeJson } from '../fileModels/store.json' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +const { InputSpec, Value } = sdk + +const bitcoinNodeInputSpec = InputSpec.of({ + bitcoinNodeType: Value.select({ + name: i18n('Select Bitcoin Node'), + description: i18n('The Bitcoin node type you would like to use for Dojo'), + values: { + bitcoind: i18n('Bitcoin Core'), + 'bitcoind-testnet': i18n('Bitcoin Core (testnet4)'), + }, + default: 'bitcoind', + }), +}) + +export const selectBitcoinNodeAction = sdk.Action.withInput( + 'select-bitcoin-node', + + { + name: i18n('Select Bitcoin Node'), + description: i18n( + 'The Bitcoin node type you would like to use for Dojo', + ), + warning: null, + allowedStatuses: 'any', + group: i18n('Configuration'), + visibility: 'enabled', + }, + + // form input specification + bitcoinNodeInputSpec, + + // pre-fill the form + async ({ effects }) => { + const bitcoinNodeType = await storeJson.read((s) => s.bitcoinNode.type).const(effects) + return { bitcoinNodeType: bitcoinNodeType || 'bitcoind' } + }, + + // execution function + async ({ effects, input }) => { + await storeJson.merge(effects, { + bitcoinNode: { + type: input.bitcoinNodeType, + }, + }) + }, +) \ No newline at end of file diff --git a/startos/actions/selectIndexer.ts b/startos/actions/selectIndexer.ts new file mode 100644 index 0000000..7ef3a97 --- /dev/null +++ b/startos/actions/selectIndexer.ts @@ -0,0 +1,49 @@ +import { storeJson } from '../fileModels/store.json' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +const { InputSpec, Value } = sdk + +const indexerInputSpec = InputSpec.of({ + indexerType: Value.select({ + name: i18n('Select Indexer'), + description: i18n('The indexer you want to use for Dojo'), + values: { + fulcrum: i18n('Fulcrum'), + electrs: i18n('electrs'), + }, + default: 'fulcrum', + }), +}) + +export const selectIndexerAction = sdk.Action.withInput( + 'select-indexer', + + { + name: i18n('Select Indexer'), + description: i18n( + 'The indexer you want to use for Dojo', + ), + warning: null, + allowedStatuses: 'any', + group: i18n('Configuration'), + visibility: 'enabled', + }, + + // form input specification + indexerInputSpec, + + // pre-fill the form + async ({ effects }) => { + const indexerType = await storeJson.read((s) => s.indexer.type).const(effects) + return { indexerType: indexerType || 'fulcrum' } + }, + + // execution function + async ({ effects, input }) => { + await storeJson.merge(effects, { + indexer: { + type: input.indexerType, + }, + }) + }, +) \ No newline at end of file diff --git a/startos/actions/viewPairingCode.ts b/startos/actions/viewPairingCode.ts new file mode 100644 index 0000000..e97956f --- /dev/null +++ b/startos/actions/viewPairingCode.ts @@ -0,0 +1,40 @@ +import { storeJson } from '../fileModels/store.json' +import { statsJson } from '../fileModels/stats.json' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +const { InputSpec, Value } = sdk + +export const viewPairingCodeAction = sdk.Action.withoutInput( + // ID + "view-pairing-code", + + // Metadata + async ({ effects }) => ({ + name: i18n("View Pairing Code"), + description: i18n("View the pairing code for Dojo"), + warning: null, + allowedStatuses: "any", // 'any', 'only-running', 'only-stopped' + group: i18n('Properties'), + visibility: "enabled", // 'enabled', 'disabled', 'hidden' + }), + + // Handler + async ({ effects }) => { + const stats = await statsJson.read((s) => s).const(effects); + + return { + version: "1", + title: "Pairing Code", + message: "Your pairing code:", + result: { + type: 'single', + name: i18n('Pairing Code'), + description: i18n('Code for pairing your wallet with this Dojo'), + value: stats?.pairingCode ?? '', + masked: true, + copyable: true, + qr: true, + } + }; + }, +); diff --git a/startos/backups.ts b/startos/backups.ts new file mode 100644 index 0000000..3e53ce5 --- /dev/null +++ b/startos/backups.ts @@ -0,0 +1,5 @@ +import { sdk } from './sdk' + +export const { createBackup, restoreInit } = sdk.setupBackups( + async ({ effects }) => sdk.Backups.ofVolumes('main', 'db'), +) diff --git a/startos/daemons.ts b/startos/daemons.ts new file mode 100644 index 0000000..c42caa2 --- /dev/null +++ b/startos/daemons.ts @@ -0,0 +1,165 @@ +import { T, SubContainer, Daemons, healthFns } from '@start9labs/start-sdk' +import { sdk } from './sdk' +import { i18n } from './i18n' +import { sorobanPort, backendPort, uiPort } from './utils' +import { manifest } from './manifest' +import { StoreJson } from './fileModels/store.json' + +type HealthCheckResult = healthFns.HealthCheckResult + +async function runCheckScript( + sub: SubContainer, + script: string, + successMessage: string, + defaultLoadingMessage: string, +): Promise { + try { + const res = await sub.exec([script], {}, 30_000) + if (res.exitCode === 0) { + return { result: 'success', message: successMessage } + } + const stderrMsg = res.stderr?.toString().trim() + if (res.exitCode === 60) { + return { result: 'starting', message: stderrMsg || null } + } + return { result: 'loading', message: stderrMsg || defaultLoadingMessage } + } catch { + return { result: 'failure', message: defaultLoadingMessage } + } +} + +export async function getDaemons({ effects, config, sub }: { + effects: T.Effects, + config: StoreJson, + sub: SubContainer, +}) { + const torIp = await sdk.getContainerIp(effects, { packageId: 'tor' }).const() + + // track Tor running status dynamically for health check (no restart needed) + let torRunning = false + if (torIp) { + sdk.getStatus(effects, { packageId: 'tor' }).onChange((status) => { + torRunning = status?.desired.main === 'running' + return { cancel: false } + }) + } + + const hasTorAddress = config.tor.announceAddrs?.some((ip: string) => ip?.includes('.onion')) + + return sdk.Daemons.of(effects) + .addDaemon('mariadb', { + subcontainer: sub, + exec: { + command: ['db-entrypoint.sh'], + env: {}, + }, + ready: { + gracePeriod: 120_000, + display: null, + fn: async () => { + const res = await sub.exec([ + 'mysqladmin', + '-u', + 'root', + '--socket=/run/mysqld/mysqld.sock', + 'ping', + ]) + return res.exitCode === 0 + ? { result: 'success', message: null } + : { result: 'loading', message: null } + }, + }, + requires: [], + }) + .addDaemon('soroban', { + subcontainer: sub, + exec: { + command: ['soroban-entrypoint.sh'], + env: {}, + }, + ready: { + gracePeriod: 120_000, + display: null, + fn: () => + sdk.healthCheck.checkPortListening(effects, sorobanPort, { + successMessage: i18n('Soroban is ready'), + errorMessage: i18n('Soroban is not ready'), + }), + }, + requires: [], + }) + .addDaemon('backend', { + subcontainer: sub, + exec: { + command: ['backend-entrypoint.sh'], + env: {}, + }, + ready: { + gracePeriod: 720_000, + display: i18n('Dojo API'), + fn: () => + sdk.healthCheck.checkPortListening(effects, backendPort, { + successMessage: i18n('Dojo API is ready'), + errorMessage: i18n('Dojo API is not ready'), + }), + }, + requires: ['mariadb', 'soroban'], + }) + .addDaemon('frontend', { + subcontainer: sub, + exec: { + command: ['nginx'], + env: {}, + }, + ready: { + gracePeriod: 120_000, + display: i18n('Dojo Web UI'), + fn: () => + sdk.healthCheck.checkPortListening(effects, uiPort, { + successMessage: i18n('Dojo Web UI is ready'), + errorMessage: i18n('Dojo Web UI is not ready'), + }), + }, + requires: ['backend'], + }) + .addHealthCheck('api', { + ready: { + display: i18n('Dojo API'), + fn: () => + runCheckScript( + sub, + 'check-api.sh', + i18n('Dojo API is online and ready for connections'), + i18n('Dojo API is starting...'), + ), + }, + requires: ['backend'], + }) + .addHealthCheck('pushtx', { + ready: { + display: i18n('PushTx'), + fn: () => + runCheckScript( + sub, + 'check-pushtx.sh', + i18n('Dojo PushTx API is online and ready for connections'), + i18n('PushTx is starting...'), + ), + }, + requires: ['backend'], + }) + .addHealthCheck('synced', { + ready: { + display: i18n('Synced'), + gracePeriod: 720_000, + fn: () => + runCheckScript( + sub, + 'check-synced.sh', + i18n('Dojo is synced with the network'), + i18n('Dojo is syncing...'), + ), + }, + requires: ['backend'], + }) +} \ No newline at end of file diff --git a/startos/dependencies.ts b/startos/dependencies.ts new file mode 100644 index 0000000..699029f --- /dev/null +++ b/startos/dependencies.ts @@ -0,0 +1,69 @@ +import { T } from '@start9labs/start-sdk' +import { storeJson } from './fileModels/store.json' +import { i18n } from './i18n' +import { sdk } from './sdk' +import { configureDojoAction } from './actions/configureDojo' + +export const setDependencies = sdk.setupDependencies(async ({ effects }) => { + const config = await storeJson.read((s) => s).const(effects) + + // const torAddresses = await sdk.serviceInterface + // .getOwn(effects, 'ui', (i) => + // i?.addressInfo?.public + // .filter({ exclude: { kind: 'domain' } }) + // .filter({ + // predicate: ({ metadata }) => + // metadata.kind === 'plugin' && metadata.packageId === 'tor', + // }) + // .format(), + // ) + // .const() + + // if (!(torAddresses?.length ?? 0)) { + // await sdk.action.createOwnTask(effects, configureDojoAction, 'critical', { + // reason: i18n( + // 'Tor interface is not ready. Add an onion address to enable connectivity.', + // ), + // }) + // } + + const deps: T.CurrentDependenciesResult = {} + + if (config?.bitcoinNode?.type === 'bitcoind') { + deps['bitcoind'] = { + kind: 'running', + versionRange: '>=0.21.1.2', + healthChecks: ['synced'], + } + } + + if (config?.bitcoinNode?.type === 'bitcoind-testnet') { + deps['bitcoind-testnet'] = { + kind: 'running', + versionRange: '>=0.21.1.2', + healthChecks: ['synced'], + } + } + + if (config?.indexer?.type === 'fulcrum') { + deps['fulcrum'] = { + kind: 'exists', + versionRange: '>=2.0.0', + } + } + + if (config?.indexer?.type === 'electrs') { + deps['electrs'] = { + kind: 'exists', + versionRange: '>=0.10.7', + } + } + + deps['tor'] = { + kind: 'running', + versionRange: '>=0.4.9.5:0', + healthChecks: [], + } + + return deps +}) diff --git a/startos/fileModels/stats.json.ts b/startos/fileModels/stats.json.ts new file mode 100644 index 0000000..b9e9b38 --- /dev/null +++ b/startos/fileModels/stats.json.ts @@ -0,0 +1,17 @@ +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' + +const shape = z.object({ + pairingCode: z.string(), + adminKey: z.string(), +}) + +export type StatsJson = z.infer + +export const statsJson = FileHelper.json( + { + base: sdk.volumes.main, + subpath: 'stats.json', + }, + shape, +) \ No newline at end of file diff --git a/startos/fileModels/store.json.ts b/startos/fileModels/store.json.ts new file mode 100644 index 0000000..00924e5 --- /dev/null +++ b/startos/fileModels/store.json.ts @@ -0,0 +1,58 @@ +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' +import { generateKey } from '../utils' + +const shape = z.object({ + bitcoinNode: z + .object({ + type: z.enum(['bitcoind', 'bitcoind-testnet']).catch('bitcoind'), + }) + .catch({ type: 'bitcoind' }), + indexer: z + .object({ + type: z.enum(['fulcrum', 'electrs']).catch('electrs'), + }) + .catch({ type: 'electrs' }), + dojo: z + .object({ + paymentCode: z.string().nullable().catch(null), + adminKey: z.string().catch(''), + apiKey: z.string().catch(''), + jwtSecret: z.string().catch(''), + sorobanAnnounce: z + .object({ + enabled: z.enum(['disabled', 'enabled']).catch('disabled'), + pandotxProcess: z.boolean().catch(false), + }) + .catch({ enabled: 'disabled', pandotxProcess: false }), + pandotxPush: z.boolean().catch(true), + pandotxRetries: z.number().catch(2), + pandotxFallbackMode: z.enum(['convenient', 'secure']).catch('convenient'), + }) + .catch({ + paymentCode: null, + adminKey: generateKey(22), + apiKey: generateKey(22), + jwtSecret: generateKey(22), + sorobanAnnounce: { enabled: 'disabled', pandotxProcess: false }, + pandotxPush: true, + pandotxRetries: 2, + pandotxFallbackMode: 'convenient', + }), + tor: z + .object({ + proxy: z.string().nullable().catch(null), + announceAddrs: z.array(z.string()).nullable().catch([]), + }) + .catch({ proxy: null, announceAddrs: [] }), +}) + +export type StoreJson = z.infer + +export const storeJson = FileHelper.json( + { + base: sdk.volumes.main, + subpath: 'store.json', + }, + shape, +) \ No newline at end of file diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts new file mode 100644 index 0000000..d121b6f --- /dev/null +++ b/startos/i18n/dictionaries/default.ts @@ -0,0 +1,68 @@ +export const DEFAULT_LANG = 'en_US' + +const dict = { + 'Starting Dojo!': 0, + 'Dojo Web UI': 1, + 'The web interface of Dojo': 2, + 'Web UI': 3, + 'The Bitcoin node type you would like to use for Dojo': 4, + 'Bitcoin Core': 5, + 'Bitcoin Core (testnet4)': 6, + 'The indexer you want to use for Dojo': 7, + 'electrs': 8, + 'Fulcrum': 9, + 'BIP47 Payment Code': 10, + 'Admin Key': 11, + 'Key for accessing the admin/maintenance': 12, + 'API Key': 13, + 'Key for accessing the services': 14, + 'JWT Secret': 15, + 'Secret used by the server for signing': 16, + 'Soroban Network Announce': 17, + 'Configure Soroban network participation': 18, + 'Disabled': 19, + 'Enabled': 20, + 'PandoTx Process': 21, + 'Process and relay transactions from other Soroban nodes': 22, + 'PandoTx Push': 23, + 'Push your transactions through random Soroban nodes for enhanced privacy': 24, + 'PandoTx Retries': 25, + 'Maximum retry attempts for failed transaction pushes': 26, + 'PandoTx Fallback Mode': 27, + 'Behavior when Soroban push fails': 28, + 'Convenient (fallback to local node)': 29, + 'Secure (fail if Soroban unavailable)': 30, + 'Select Bitcoin Node': 31, + 'Select Indexer': 32, + 'Configuration': 33, + 'Soroban is ready': 34, + 'Soroban is not ready': 35, + 'Dojo API': 36, + 'Dojo API is ready': 37, + 'Dojo API is not ready': 38, + 'Dojo Web UI is ready': 39, + 'Dojo Web UI is not ready': 40, + 'Tor is not installed': 41, + 'Tor is not running': 42, + 'Tor interface is ready': 43, + 'Tor interface is not ready. Add an onion address to enable connectivity.': 44, + 'Configure Dojo': 45, + 'Customize your Dojo': 46, + 'Properties': 47, + 'View Pairing Code': 48, + 'View the pairing code for Dojo': 49, + 'Pairing Code': 50, + 'Code for pairing your wallet with this Dojo': 51, + 'Dojo API is online and ready for connections': 52, + 'Dojo API is starting...': 53, + 'PushTx': 54, + 'Dojo PushTx API is online and ready for connections': 55, + 'PushTx is starting...': 56, + 'Synced': 57, + 'Dojo is synced with the network': 58, + 'Dojo is syncing...': 59, +} as const + +export type I18nKey = keyof typeof dict +export type LangDict = Record<(typeof dict)[I18nKey], string> +export default dict diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts new file mode 100644 index 0000000..98f9348 --- /dev/null +++ b/startos/i18n/dictionaries/translations.ts @@ -0,0 +1,3 @@ +import { LangDict } from './default' + +export default {} satisfies Record diff --git a/startos/i18n/index.ts b/startos/i18n/index.ts new file mode 100644 index 0000000..1849d6d --- /dev/null +++ b/startos/i18n/index.ts @@ -0,0 +1,5 @@ +import { setupI18n } from '@start9labs/start-sdk' +import defaultDict, { DEFAULT_LANG } from './dictionaries/default' +import translations from './dictionaries/translations' + +export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG) diff --git a/startos/index.ts b/startos/index.ts new file mode 100644 index 0000000..7af589b --- /dev/null +++ b/startos/index.ts @@ -0,0 +1,11 @@ +/** + * Plumbing. DO NOT EDIT. + */ +export { createBackup } from './backups' +export { main } from './main' +export { init, uninit } from './init' +export { actions } from './actions' +import { buildManifest } from '@start9labs/start-sdk' +import { manifest as sdkManifest } from './manifest' +import { versionGraph } from './versions' +export const manifest = buildManifest(versionGraph, sdkManifest) diff --git a/startos/init/index.ts b/startos/init/index.ts new file mode 100644 index 0000000..8607f54 --- /dev/null +++ b/startos/init/index.ts @@ -0,0 +1,18 @@ +import { sdk } from '../sdk' +import { setDependencies } from '../dependencies' +import { setInterfaces } from '../interfaces' +import { versionGraph } from '../versions' +import { actions } from '../actions' +import { restoreInit } from '../backups' +import { watchHosts } from './watchHosts' + +export const init = sdk.setupInit( + restoreInit, + versionGraph, + setInterfaces, + setDependencies, + actions, + watchHosts, +) + +export const uninit = sdk.setupUninit(versionGraph) diff --git a/startos/init/watchHosts.ts b/startos/init/watchHosts.ts new file mode 100644 index 0000000..d05c7ea --- /dev/null +++ b/startos/init/watchHosts.ts @@ -0,0 +1,38 @@ +import { storeJson } from '../fileModels/store.json' +import { sdk } from '../sdk' + +const uiInterfaceId = 'ui' + +export const watchHosts = sdk.setupOnInit(async (effects, _) => { + const proxy = await sdk.getContainerIp(effects, { + packageId: 'tor' + }).const() + + const publicInfo = await sdk.serviceInterface + .getOwn(effects, uiInterfaceId, (i) => + i?.addressInfo?.public.filter({ + exclude: { kind: 'domain' }, + }), + ) + .const() +console.log('publicInfo', publicInfo) + if (!publicInfo) return + + const announceAddrs = publicInfo + .filter({ + predicate: ({ metadata }) => + metadata.kind === 'plugin' && metadata.packageId === 'tor', + }) + .format() + + await storeJson.merge( + effects, + { + tor: { + proxy, + announceAddrs, + }, + }, + { allowWriteAfterConst: true }, + ) +}) \ No newline at end of file diff --git a/startos/interfaces.ts b/startos/interfaces.ts new file mode 100644 index 0000000..c5fd6d1 --- /dev/null +++ b/startos/interfaces.ts @@ -0,0 +1,25 @@ +import { i18n } from './i18n' +import { sdk } from './sdk' +import { uiPort } from './utils' + +export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + const uiMulti = sdk.MultiHost.of(effects, 'main') + const uiMultiOrigin = await uiMulti.bindPort(uiPort, { + protocol: 'http', + }) + const ui = sdk.createInterface(effects, { + name: i18n('Web UI'), + id: 'ui', + description: i18n('The web interface of Dojo'), + type: 'ui', + masked: false, + schemeOverride: null, + username: null, + path: '', + query: {}, + }) + + const uiReceipt = await uiMultiOrigin.export([ui]) + + return [uiReceipt] +}) diff --git a/startos/main.ts b/startos/main.ts new file mode 100644 index 0000000..9c353c9 --- /dev/null +++ b/startos/main.ts @@ -0,0 +1,24 @@ +import { storeJson } from './fileModels/store.json' +import { i18n } from './i18n' +import { sdk } from './sdk' + +import { getMounts } from './mounts' +import { getDaemons } from './daemons' + +export const main = sdk.setupMain(async ({ effects }) => { + console.info(i18n('Starting Dojo!')) + + let config = await storeJson.read((s) => s).const(effects) + if (!config) { + throw new Error('store.json not found') + } + + const sub = await sdk.SubContainer.of( + effects, + { imageId: 'dojo' }, + getMounts({ config }), + 'dojo-sub', + ) + + return await getDaemons({ effects, config, sub }) +}) diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts new file mode 100644 index 0000000..57a1d6d --- /dev/null +++ b/startos/manifest/i18n.ts @@ -0,0 +1,26 @@ +export default { + description: { + short: { + en_US: + 'Your private backend server for Ashigaru, Samourai Wallet and other light wallets.', + }, + long: { + en_US: + 'Dojo is the backend server for Ashigaru, Samourai Wallet and other light wallets. It provides HD account & loose addresses (BIP47) balances & transactions lists. Provides unspent output lists to the wallet. PushTX endpoint broadcasts transactions through the backing bitcoind node.', + }, + }, + bitcoindDescription: { + en_US: + 'Used to subscribe to new block events from a full archival node', + }, + bitcoindTestnetDescription: { + en_US: + 'Used to subscribe to new block events from a full archival node (testnet)', + }, + fulcrumDescription: { + en_US: 'Used for fast scan of addresses and indexing for deep wallets', + }, + electrsDescription: { + en_US: 'A more stable, but less performant indexer', + }, +} diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts new file mode 100644 index 0000000..5849dce --- /dev/null +++ b/startos/manifest/index.ts @@ -0,0 +1,58 @@ +import { setupManifest } from '@start9labs/start-sdk' +import i18n from './i18n' + +export const manifest = setupManifest({ + id: 'dojo', + title: 'Dojo', + license: 'AGPL-3.0', + packageRepo: 'https://github.com/ericpp/dojo-startos', + upstreamRepo: 'https://github.com/Dojo-Open-Source-Project/samourai-dojo', + marketingUrl: 'https://dojo-osp.org/', + donationUrl: 'https://dojo-osp.org/donate/', + docsUrls: [ + 'https://dojo-osp.org/', + ], + description: i18n.description, + volumes: ['main', 'db'], + images: { + dojo: { + source: { + dockerBuild: { + dockerfile: './Dockerfile', + workdir: '.', + }, + }, + arch: ['x86_64', 'aarch64'], + }, + }, + alerts: { + install: null, + update: null, + uninstall: null, + restore: null, + start: null, + stop: null, + }, + dependencies: { + bitcoind: { + description: i18n.bitcoindDescription, + optional: true, + s9pk: null, + }, + 'bitcoind-testnet': { + description: i18n.bitcoindTestnetDescription, + optional: true, + s9pk: null, + }, + fulcrum: { + description: i18n.fulcrumDescription, + optional: true, + s9pk: null, + }, + electrs: { + description: i18n.electrsDescription, + optional: true, + s9pk: null, + }, + }, +}) diff --git a/startos/mounts.ts b/startos/mounts.ts new file mode 100644 index 0000000..2ff4880 --- /dev/null +++ b/startos/mounts.ts @@ -0,0 +1,45 @@ +import { sdk } from './sdk' +import { StoreJson } from './fileModels/store.json' +import { manifest as bitcoinManifest } from 'bitcoin-core-startos/startos/manifest' +import { manifest as bitcoindTestnetManifest } from 'bitcoind-testnet4-startos/startos/manifest' +import { btcMountpoint } from './utils' + +export function getMounts({ config }: { config: StoreJson }) { + + let mounts = sdk.Mounts.of() + .mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/root', + readonly: false, + }) + .mountVolume({ + volumeId: 'db', + subpath: null, + mountpoint: '/var/lib/mysql', + readonly: false, + }) + + if (config?.bitcoinNode?.type === 'bitcoind') { + // TODO: Add testnet somehow? + mounts = mounts.mountDependency({ + dependencyId: 'bitcoind', + volumeId: 'main', + subpath: null, + mountpoint: btcMountpoint, + readonly: true, + }) + } + + if (config?.bitcoinNode?.type === 'bitcoind-testnet') { + mounts = mounts.mountDependency({ + dependencyId: 'bitcoind-testnet', + volumeId: 'main', + subpath: 'testnet4', + mountpoint: btcMountpoint, + readonly: true, + }) + } + + return mounts +} \ No newline at end of file diff --git a/startos/sdk.ts b/startos/sdk.ts new file mode 100644 index 0000000..04ae4b1 --- /dev/null +++ b/startos/sdk.ts @@ -0,0 +1,9 @@ +import { StartSdk } from '@start9labs/start-sdk' +import { manifest } from './manifest' + +/** + * Plumbing. DO NOT EDIT. + * + * The exported "sdk" const is used throughout this package codebase. + */ +export const sdk = StartSdk.of().withManifest(manifest).build(true) diff --git a/startos/utils.ts b/startos/utils.ts new file mode 100644 index 0000000..c892647 --- /dev/null +++ b/startos/utils.ts @@ -0,0 +1,15 @@ +import { randomBytes } from 'crypto' + +const ALPHANUM = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +export function generateKey(len: number): string { + const bytes = randomBytes(len) + return Array.from(bytes, (b) => ALPHANUM[b % ALPHANUM.length]).join('') +} + +export const uiPort = 9000 +export const backendPort = 8080 +export const sorobanPort = 4242 +export const dbPort = 3306 + +export const btcMountpoint = '/mnt/bitcoin' \ No newline at end of file diff --git a/startos/versions/index.ts b/startos/versions/index.ts new file mode 100644 index 0000000..12fc78f --- /dev/null +++ b/startos/versions/index.ts @@ -0,0 +1,7 @@ +import { VersionGraph } from '@start9labs/start-sdk' +import { v_1_29_2_0 } from './v1.29.2.0' + +export const versionGraph = VersionGraph.of({ + current: v_1_29_2_0, + other: [], +}) diff --git a/startos/versions/v1.29.2.0.ts b/startos/versions/v1.29.2.0.ts new file mode 100644 index 0000000..7980470 --- /dev/null +++ b/startos/versions/v1.29.2.0.ts @@ -0,0 +1,96 @@ +import { IMPOSSIBLE, VersionInfo, YAML } from '@start9labs/start-sdk' +import { readFile, rm } from 'fs/promises' +import { storeJson } from '../fileModels/store.json' +import { generateKey } from '../utils' + +export const v_1_29_2_0 = VersionInfo.of({ + version: '1.29.2:0', + releaseNotes: { + en_US: 'Initial release on StartOS SDK 0.4.0', + }, + migrations: { + up: async ({ effects }) => { + const configYaml: + | { + 'tor-address'?: string + 'bitcoin-node'?: { + type: 'bitcoind' | 'bitcoind-testnet' + username?: string + password?: string + } + indexer?: { type: 'electrs' | 'fulcrum' } + 'payment-code'?: string | null + 'admin-key'?: string + 'api-key'?: string + 'jwt-secret'?: string + 'soroban-announce'?: { + enabled: 'disabled' | 'enabled' + 'pandotx-process'?: boolean + } + 'pandotx-push'?: boolean + 'pandotx-retries'?: number + 'pandotx-fallback-mode'?: 'convenient' | 'secure' + } + | undefined = await readFile( + '/media/startos/volumes/main/start9/config.yaml', + 'utf-8', + ).then(YAML.parse, () => undefined) + + if (configYaml) { + await storeJson.write(effects, { + bitcoinNode: configYaml['bitcoin-node'] || { + type: 'bitcoind', + }, + indexer: { + type: configYaml.indexer?.type || 'electrs', + }, + dojo: { + paymentCode: configYaml['payment-code'] || null, + adminKey: configYaml['admin-key'] || generateKey(22), + apiKey: configYaml['api-key'] || generateKey(22), + jwtSecret: configYaml['jwt-secret'] || generateKey(22), + sorobanAnnounce: { + enabled: configYaml['soroban-announce']?.enabled || 'disabled', + pandotxProcess: configYaml['soroban-announce']?.['pandotx-process'] || false, + }, + pandotxPush: configYaml['pandotx-push'] ?? true, + pandotxRetries: configYaml['pandotx-retries'] ?? 2, + pandotxFallbackMode: configYaml['pandotx-fallback-mode'] || 'convenient', + }, + tor: { + proxy: null, + announceAddrs: [], + }, + }) + + await rm('/media/startos/volumes/main/start9', { + recursive: true, + }).catch(console.error) + } else { + // Fresh install with no prior config to migrate — write defaults so + // docker_entrypoint.sh can source config.env without jq failing on a + // missing store.json file (jq exits 2 when the file doesn't exist, + // which kills the entrypoint immediately via `set -e`). + await storeJson.write(effects, { + bitcoinNode: { type: 'bitcoind' }, + indexer: { type: 'electrs' }, + dojo: { + paymentCode: null, + adminKey: generateKey(22), + apiKey: generateKey(22), + jwtSecret: generateKey(22), + sorobanAnnounce: { enabled: 'disabled', pandotxProcess: false }, + pandotxPush: true, + pandotxRetries: 2, + pandotxFallbackMode: 'convenient', + }, + tor: { + proxy: null, + announceAddrs: [], + }, + }) + } + }, + down: IMPOSSIBLE, + }, +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9a19690 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["startos/**/*.ts", "node_modules/**/startos"], + "compilerOptions": { + "target": "es2022", + "module": "None", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +} From b3727cc2531f0a4d932f5596088807c507eec461 Mon Sep 17 00:00:00 2001 From: Eric P Date: Tue, 19 May 2026 22:40:05 -0500 Subject: [PATCH 2/5] Fix pairing code, health checks, and tor address --- Dockerfile | 2 +- backend-entrypoint.sh | 4 +- check-synced.sh | 2 +- config.env | 2 + startos/actions/viewPairingCode.ts | 10 ++-- startos/daemons.ts | 84 ++++++++++++---------------- startos/dependencies.ts | 25 +-------- startos/fileModels/backend.json.ts | 17 ++++++ startos/fileModels/stats.json.ts | 17 ------ startos/i18n/dictionaries/default.ts | 11 +++- 10 files changed, 73 insertions(+), 101 deletions(-) create mode 100644 startos/fileModels/backend.json.ts delete mode 100644 startos/fileModels/stats.json.ts diff --git a/Dockerfile b/Dockerfile index b8b0671..c915bf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -77,7 +77,7 @@ ENV SOROBAN_HOME=/home/soroban RUN set -ex && \ apk --update --no-cache add ca-certificates bash && \ apk --no-cache add shadow && \ - apk --no-cache add mariadb mariadb-client pwgen nginx yq curl netcat-openbsd && \ + apk --no-cache add mariadb mariadb-client pwgen nginx jq curl netcat-openbsd && \ apk --no-cache add openssl libevent zlib runuser ### Node diff --git a/backend-entrypoint.sh b/backend-entrypoint.sh index 0a2ae5e..8e86dc6 100644 --- a/backend-entrypoint.sh +++ b/backend-entrypoint.sh @@ -31,9 +31,9 @@ fi mkdir -p /var/lib/tor/hsv3explorer echo -n "$EXPLORER_ENDPOINT" > /var/lib/tor/hsv3explorer/hostname -cat << EOF > /root/stats.json +cat << EOF > /root/backend.json { - "pairingCode": '{"pairing":{"type":"dojo.api","version":"$DOJO_VERSION_TAG","apikey":"$NODE_API_KEY","url":"$PAIRING_URL"},"explorer":{"type":"explorer.btc_rpc_explorer","url":"http://$EXPLORER_ENDPOINT"}}', + "pairingCode": "{\"pairing\":{\"type\":\"dojo.api\",\"version\":\"$DOJO_VERSION_TAG\",\"apikey\":\"$NODE_API_KEY\",\"url\":\"$PAIRING_URL\"},\"explorer\":{\"type\":\"explorer.btc_rpc_explorer\",\"url\":\"http://$EXPLORER_ENDPOINT\"}}", "adminKey": "$NODE_ADMIN_KEY" } EOF diff --git a/check-synced.sh b/check-synced.sh index 40950f9..2f16721 100755 --- a/check-synced.sh +++ b/check-synced.sh @@ -17,7 +17,7 @@ elif [ "$bci_error" != "null" ]; then fi bci_block_count=$(echo "$bci_result" | jq -r '.result.blocks // "null"' 2>/dev/null) -bci_block_ibd=$(echo "$bci_result" | jq -r '.result.initialblockdownload // "null"' 2>/dev/null) +bci_block_ibd=$(echo "$bci_result" | jq -r '.result.initialblockdownload' 2>/dev/null) if [ "$bci_block_count" = "null" ]; then echo "Error ascertaining Bitcoin blockchain status: $bci_error" >&2 exit 61 diff --git a/config.env b/config.env index 1bc1d3a..0f62bb8 100644 --- a/config.env +++ b/config.env @@ -14,6 +14,8 @@ if [ -z "$TOR_ADDRESS" ] || [ "$TOR_ADDRESS" = "null" ]; then exit 1 fi +TOR_ADDRESS=${TOR_ADDRESS#http://} + COOKIE_FILE="/mnt/bitcoin/.cookie" if [ -f "$COOKIE_FILE" ]; then COOKIE=$(cat "$COOKIE_FILE") diff --git a/startos/actions/viewPairingCode.ts b/startos/actions/viewPairingCode.ts index e97956f..dacc108 100644 --- a/startos/actions/viewPairingCode.ts +++ b/startos/actions/viewPairingCode.ts @@ -1,8 +1,6 @@ -import { storeJson } from '../fileModels/store.json' -import { statsJson } from '../fileModels/stats.json' +import { backendJson } from '../fileModels/backend.json' import { i18n } from '../i18n' import { sdk } from '../sdk' -const { InputSpec, Value } = sdk export const viewPairingCodeAction = sdk.Action.withoutInput( // ID @@ -13,14 +11,14 @@ export const viewPairingCodeAction = sdk.Action.withoutInput( name: i18n("View Pairing Code"), description: i18n("View the pairing code for Dojo"), warning: null, - allowedStatuses: "any", // 'any', 'only-running', 'only-stopped' + allowedStatuses: "only-running", // 'any', 'only-running', 'only-stopped' group: i18n('Properties'), visibility: "enabled", // 'enabled', 'disabled', 'hidden' }), // Handler async ({ effects }) => { - const stats = await statsJson.read((s) => s).const(effects); + const backend = await backendJson.read((s) => s).const(effects); return { version: "1", @@ -30,7 +28,7 @@ export const viewPairingCodeAction = sdk.Action.withoutInput( type: 'single', name: i18n('Pairing Code'), description: i18n('Code for pairing your wallet with this Dojo'), - value: stats?.pairingCode ?? '', + value: backend?.pairingCode ?? '', masked: true, copyable: true, qr: true, diff --git a/startos/daemons.ts b/startos/daemons.ts index c42caa2..e3617dd 100644 --- a/startos/daemons.ts +++ b/startos/daemons.ts @@ -33,17 +33,6 @@ export async function getDaemons({ effects, config, sub }: { config: StoreJson, sub: SubContainer, }) { - const torIp = await sdk.getContainerIp(effects, { packageId: 'tor' }).const() - - // track Tor running status dynamically for health check (no restart needed) - let torRunning = false - if (torIp) { - sdk.getStatus(effects, { packageId: 'tor' }).onChange((status) => { - torRunning = status?.desired.main === 'running' - return { cancel: false } - }) - } - const hasTorAddress = config.tor.announceAddrs?.some((ip: string) => ip?.includes('.onion')) return sdk.Daemons.of(effects) @@ -54,20 +43,14 @@ export async function getDaemons({ effects, config, sub }: { env: {}, }, ready: { - gracePeriod: 120_000, - display: null, - fn: async () => { - const res = await sub.exec([ - 'mysqladmin', - '-u', - 'root', - '--socket=/run/mysqld/mysqld.sock', - 'ping', - ]) - return res.exitCode === 0 - ? { result: 'success', message: null } - : { result: 'loading', message: null } - }, + display: i18n('MySQL'), + fn: () => + runCheckScript( + sub, + 'check-mysql.sh', + i18n('MySQL is online and ready for connections'), + i18n('MySQL is starting...'), + ), }, requires: [], }) @@ -78,13 +61,14 @@ export async function getDaemons({ effects, config, sub }: { env: {}, }, ready: { - gracePeriod: 120_000, - display: null, + display: i18n('Soroban'), fn: () => - sdk.healthCheck.checkPortListening(effects, sorobanPort, { - successMessage: i18n('Soroban is ready'), - errorMessage: i18n('Soroban is not ready'), - }), + runCheckScript( + sub, + 'check-soroban.sh', + i18n('Soroban is running'), + i18n('Soroban is starting...'), + ), }, requires: [], }) @@ -95,13 +79,14 @@ export async function getDaemons({ effects, config, sub }: { env: {}, }, ready: { - gracePeriod: 720_000, display: i18n('Dojo API'), fn: () => - sdk.healthCheck.checkPortListening(effects, backendPort, { - successMessage: i18n('Dojo API is ready'), - errorMessage: i18n('Dojo API is not ready'), - }), + runCheckScript( + sub, + 'check-api.sh', + i18n('Dojo API is online and ready for connections'), + i18n('Dojo API is starting...'), + ), }, requires: ['mariadb', 'soroban'], }) @@ -122,19 +107,6 @@ export async function getDaemons({ effects, config, sub }: { }, requires: ['backend'], }) - .addHealthCheck('api', { - ready: { - display: i18n('Dojo API'), - fn: () => - runCheckScript( - sub, - 'check-api.sh', - i18n('Dojo API is online and ready for connections'), - i18n('Dojo API is starting...'), - ), - }, - requires: ['backend'], - }) .addHealthCheck('pushtx', { ready: { display: i18n('PushTx'), @@ -162,4 +134,18 @@ export async function getDaemons({ effects, config, sub }: { }, requires: ['backend'], }) + .addHealthCheck('tor', { + ready: { + display: i18n('Tor'), + fn: () => + hasTorAddress ? { + result: 'success', + message: i18n('Web UI interface has Tor address configured'), + } : { + result: 'failure', + message: i18n('Web UI interface requires a Tor address to be configured'), + } + }, + requires: [], + }) } \ No newline at end of file diff --git a/startos/dependencies.ts b/startos/dependencies.ts index 699029f..308b5a9 100644 --- a/startos/dependencies.ts +++ b/startos/dependencies.ts @@ -6,34 +6,13 @@ import { configureDojoAction } from './actions/configureDojo' export const setDependencies = sdk.setupDependencies(async ({ effects }) => { const config = await storeJson.read((s) => s).const(effects) - - // const torAddresses = await sdk.serviceInterface - // .getOwn(effects, 'ui', (i) => - // i?.addressInfo?.public - // .filter({ exclude: { kind: 'domain' } }) - // .filter({ - // predicate: ({ metadata }) => - // metadata.kind === 'plugin' && metadata.packageId === 'tor', - // }) - // .format(), - // ) - // .const() - - // if (!(torAddresses?.length ?? 0)) { - // await sdk.action.createOwnTask(effects, configureDojoAction, 'critical', { - // reason: i18n( - // 'Tor interface is not ready. Add an onion address to enable connectivity.', - // ), - // }) - // } - const deps: T.CurrentDependenciesResult = {} if (config?.bitcoinNode?.type === 'bitcoind') { deps['bitcoind'] = { kind: 'running', versionRange: '>=0.21.1.2', - healthChecks: ['synced'], + healthChecks: ['bitcoind'], } } @@ -41,7 +20,7 @@ export const setDependencies = sdk.setupDependencies(async ({ effects }) => { deps['bitcoind-testnet'] = { kind: 'running', versionRange: '>=0.21.1.2', - healthChecks: ['synced'], + healthChecks: ['bitcoind'], } } diff --git a/startos/fileModels/backend.json.ts b/startos/fileModels/backend.json.ts new file mode 100644 index 0000000..06a6b41 --- /dev/null +++ b/startos/fileModels/backend.json.ts @@ -0,0 +1,17 @@ +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' + +const shape = z.object({ + pairingCode: z.string().catch(''), + adminKey: z.string().catch(''), +}) + +export type BackendJson = z.infer + +export const backendJson = FileHelper.json( + { + base: sdk.volumes.main, + subpath: 'backend.json', + }, + shape, +) \ No newline at end of file diff --git a/startos/fileModels/stats.json.ts b/startos/fileModels/stats.json.ts deleted file mode 100644 index b9e9b38..0000000 --- a/startos/fileModels/stats.json.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { FileHelper, z } from '@start9labs/start-sdk' -import { sdk } from '../sdk' - -const shape = z.object({ - pairingCode: z.string(), - adminKey: z.string(), -}) - -export type StatsJson = z.infer - -export const statsJson = FileHelper.json( - { - base: sdk.volumes.main, - subpath: 'stats.json', - }, - shape, -) \ No newline at end of file diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index d121b6f..ac57ddf 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -38,8 +38,6 @@ const dict = { 'Soroban is ready': 34, 'Soroban is not ready': 35, 'Dojo API': 36, - 'Dojo API is ready': 37, - 'Dojo API is not ready': 38, 'Dojo Web UI is ready': 39, 'Dojo Web UI is not ready': 40, 'Tor is not installed': 41, @@ -61,6 +59,15 @@ const dict = { 'Synced': 57, 'Dojo is synced with the network': 58, 'Dojo is syncing...': 59, + 'MySQL': 60, + 'MySQL is online and ready for connections': 61, + 'MySQL is starting...': 62, + 'Soroban': 63, + 'Soroban is running': 64, + 'Soroban is starting...': 65, + 'Tor': 66, + 'Web UI interface has Tor address configured': 67, + 'Web UI interface requires a Tor address to be configured': 68, } as const export type I18nKey = keyof typeof dict From b22df5d4b32caf40468b2f90f398b90fece59d1f Mon Sep 17 00:00:00 2001 From: Eric P Date: Sat, 13 Jun 2026 01:29:27 -0500 Subject: [PATCH 3/5] Rename watchHosts to writeTorAddress --- startos/init/index.ts | 4 ++-- startos/init/{watchHosts.ts => writeTorAddress.ts} | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename startos/init/{watchHosts.ts => writeTorAddress.ts} (87%) diff --git a/startos/init/index.ts b/startos/init/index.ts index 8607f54..ed7fa7d 100644 --- a/startos/init/index.ts +++ b/startos/init/index.ts @@ -4,7 +4,7 @@ import { setInterfaces } from '../interfaces' import { versionGraph } from '../versions' import { actions } from '../actions' import { restoreInit } from '../backups' -import { watchHosts } from './watchHosts' +import { writeTorAddress } from './writeTorAddress' export const init = sdk.setupInit( restoreInit, @@ -12,7 +12,7 @@ export const init = sdk.setupInit( setInterfaces, setDependencies, actions, - watchHosts, + writeTorAddress, ) export const uninit = sdk.setupUninit(versionGraph) diff --git a/startos/init/watchHosts.ts b/startos/init/writeTorAddress.ts similarity index 87% rename from startos/init/watchHosts.ts rename to startos/init/writeTorAddress.ts index d05c7ea..a6119b6 100644 --- a/startos/init/watchHosts.ts +++ b/startos/init/writeTorAddress.ts @@ -3,7 +3,7 @@ import { sdk } from '../sdk' const uiInterfaceId = 'ui' -export const watchHosts = sdk.setupOnInit(async (effects, _) => { +export const writeTorAddress = sdk.setupOnInit(async (effects, _) => { const proxy = await sdk.getContainerIp(effects, { packageId: 'tor' }).const() @@ -15,7 +15,7 @@ export const watchHosts = sdk.setupOnInit(async (effects, _) => { }), ) .const() -console.log('publicInfo', publicInfo) + if (!publicInfo) return const announceAddrs = publicInfo From 6be616bc194d9d147d04ad6dfdc51d54195695b8 Mon Sep 17 00:00:00 2001 From: Eric P Date: Sat, 20 Jun 2026 15:58:21 -0500 Subject: [PATCH 4/5] Fix broken Start9 link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fdd8299..dad4e7d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Dojo is self-hosted Bitcoin backend server that powers Ashigaru, Samourai Wallet ## Dependencies -Install the system dependencies below to build this project by following the instructions in the provided links. You can find instructions on how to set up the appropriate build environment in the [Developer Docs](https://docs.start9.com/0.3.5.x/developer-docs/packaging.html). +Install the system dependencies below to build this project by following the instructions in the provided links. You can find instructions on how to set up the appropriate build environment in the [Developer Docs](https://docs.start9.com/packaging/0.4.0.x/). - [docker](https://docs.docker.com/get-docker) - [docker-buildx](https://docs.docker.com/buildx/working-with-buildx/) From 149c87b751b25503b7998fc73b7bb66a8d8732e4 Mon Sep 17 00:00:00 2001 From: Eric P Date: Mon, 29 Jun 2026 23:26:09 -0500 Subject: [PATCH 5/5] Set Tor as requirement for everything --- startos/daemons.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/startos/daemons.ts b/startos/daemons.ts index e3617dd..143dcd3 100644 --- a/startos/daemons.ts +++ b/startos/daemons.ts @@ -54,6 +54,20 @@ export async function getDaemons({ effects, config, sub }: { }, requires: [], }) + .addHealthCheck('tor', { + ready: { + display: i18n('Tor'), + fn: () => + hasTorAddress ? { + result: 'success', + message: i18n('Dojo API interface has Tor address configured'), + } : { + result: 'failure', + message: i18n('Dojo API interface requires a Tor address to be configured'), + } + }, + requires: [], + }) .addDaemon('soroban', { subcontainer: sub, exec: { @@ -70,7 +84,7 @@ export async function getDaemons({ effects, config, sub }: { i18n('Soroban is starting...'), ), }, - requires: [], + requires: ['tor'], }) .addDaemon('backend', { subcontainer: sub, @@ -88,7 +102,7 @@ export async function getDaemons({ effects, config, sub }: { i18n('Dojo API is starting...'), ), }, - requires: ['mariadb', 'soroban'], + requires: ['mariadb', 'soroban', 'tor'], }) .addDaemon('frontend', { subcontainer: sub, @@ -134,18 +148,4 @@ export async function getDaemons({ effects, config, sub }: { }, requires: ['backend'], }) - .addHealthCheck('tor', { - ready: { - display: i18n('Tor'), - fn: () => - hasTorAddress ? { - result: 'success', - message: i18n('Web UI interface has Tor address configured'), - } : { - result: 'failure', - message: i18n('Web UI interface requires a Tor address to be configured'), - } - }, - requires: [], - }) } \ No newline at end of file