import { storeJson } from './fileModels/storeJson' import { i18n } from './i18n' import { sdk } from './sdk' import { APP_USER, BITCOIND_RPC_HOSTNAME, BITCOIND_RPC_PORT, CERTRELAY_ANCHOR_REFRESH, CERTRELAY_BIN, CERTRELAY_BIND, CERTRELAY_CHAIN, CERTRELAY_DATA_DIR, CERTRELAY_DEFAULT_BOOTSTRAP, CERTRELAY_DEFAULT_HEALTHCHECK_HANDLE, CERTRELAY_DEFAULT_SELF_URL, CERTRELAY_DIR, CERTRELAY_FABRIC_BIN, CERTRELAY_FABRIC_DEST, CERTRELAY_PORT, CERTRELAY_REMOTE_IP_HEADER, dataDir, pgDataDir, POSTGRES_PORT, SPACED_CHAIN, spacedRpcPort, SUBSPACES_DATA_DIR, SUBSPACES_DIR, SUBSPACES_PROVER_BIN, SUBSPACES_PROVER_DIR, SUBSPACES_PROVER_PORT, SUBSPACES_REGISTRY_BIN, SUBSPACES_REGISTRY_DIR, SUBSPACES_REGISTRY_PORT, SUBSPACES_SUBS_BIN, SUBSPACES_UI_PORT, SUBSPACES_WALLET, uiPort, } from './utils' export const main = sdk.setupMain(async ({ effects }) => { console.info(i18n('Starting Spaces!')) const store = await storeJson.read().const(effects) if ( !store?.password || !store?.btcAuth || !store?.dbAuth || !store?.spacedAuth ) { // taskSetPassword + taskBtcAuth + taskSeedDb + taskSeedSpacedAuth all seed // these in init; if they aren't populated yet, init hasn't finished — let // StartOS restart us. throw new Error('Spaces store.json is not yet populated.') } const { password: APP_PASSWORD, btcAuth, dbAuth, spacedAuth } = store const enableExplorer = store.enableExplorer === true const enableSubspaces = store.enableSubspaces === true // subs-prover is independently gated and defaults OFF (null/undefined => false). const enableSubsProver = store.enableSubsProver === true const spacedEnv = { SPACED_CHAIN, SPACED_DATA_DIR: dataDir, // Listen on all interfaces so the spaced RPC can be exposed externally via // the "Spaces API" StartOS interface. Internal clients (subs, certrelay, // indexer, space-cli, health checks) still connect over loopback below. SPACED_RPC_BIND: '0.0.0.0', SPACED_RPC_PORT: String(spacedRpcPort), SPACED_RPC_URL: `http://127.0.0.1:${spacedRpcPort}`, SPACED_BLOCK_INDEX: 'true', SPACED_BITCOIN_RPC_URL: `http://${BITCOIND_RPC_HOSTNAME}:${BITCOIND_RPC_PORT}`, SPACED_BITCOIN_RPC_USER: btcAuth.username, SPACED_BITCOIN_RPC_PASSWORD: btcAuth.password, SPACED_RPC_USER: spacedAuth.username, SPACED_RPC_PASSWORD: spacedAuth.password, // legacy aliases for `bitcoin-cli` / shell helpers that read these names BTC_RPC_HOST: BITCOIND_RPC_HOSTNAME, BTC_RPC_PORT: String(BITCOIND_RPC_PORT), BTC_RPC_USER: btcAuth.username, BTC_RPC_PASSWORD: btcAuth.password, APP_USER, APP_PASSWORD, DB_URL: `postgres://${dbAuth.username}:${dbAuth.password}@127.0.0.1:${POSTGRES_PORT}/${dbAuth.database}`, } // Certrelay config — user-tunable bits live in store.json (set via the // "Configure Certrelay" action); the rest are fixed package defaults. const certrelaySelfUrl = store.certrelaySelfUrl ?? CERTRELAY_DEFAULT_SELF_URL const certrelayBootstrap = store.certrelayBootstrap ?? CERTRELAY_DEFAULT_BOOTSTRAP const certrelayHealthcheckHandle = store.certrelayHealthcheckHandle ?? CERTRELAY_DEFAULT_HEALTHCHECK_HANDLE const mounts = sdk.Mounts.of().mountVolume({ volumeId: 'main', subpath: null, mountpoint: dataDir, readonly: false, }) const spacedSub = await sdk.SubContainer.of( effects, { imageId: 'spaces' }, mounts, 'spaced-sub', ) const termSub = await sdk.SubContainer.of( effects, { imageId: 'spaces' }, mounts, 'terminal-sub', ) const postgresSub = await sdk.SubContainer.of( effects, { imageId: 'postgres' }, mounts, 'postgres-sub', ) const postgresEnv = { POSTGRES_USER: dbAuth.username, POSTGRES_PASSWORD: dbAuth.password, POSTGRES_DB: dbAuth.database, PGDATA: pgDataDir, } // TODO(prebuilt-indexer / prebuilt-explorer): the Go indexer and SvelteKit // explorer used to be built from source in dedicated builder images // (golang:1.23-alpine, node:20-alpine). Those images + their build chains // were removed to shrink the .s9pk and cut first-enable latency. When the // prebuilt images exist, re-add their SubContainers + env here, e.g.: // const indexerSub = await sdk.SubContainer.of( // effects, { imageId: '' }, mounts, 'indexer-sub') // const explorerSub = await sdk.SubContainer.of( // effects, { imageId: '' }, mounts, 'explorer-sub') // const indexerEnv = { POSTGRES_URI, BITCOIN_NODE_URI/USER/PASSWORD, // SPACES_NODE_URI, RPC_USER/PASSWORD, ACTIVATION_BLOCK_HEIGHT, // FAST_SYNC_BLOCK_HEIGHT, UPDATE_DB_INTERVAL, MEMPOOL_CHUNK_SIZE } // const explorerEnv = { DB_URL (?sslmode=disable), PUBLIC_BTC_NETWORK, PORT } // Also add the image ids to startos/manifest/index.ts and re-add the daemon // definitions in the explorer branch below. See git history for prior shapes. const subspacesSub = await sdk.SubContainer.of( effects, { imageId: 'subspaces' }, mounts, 'subspaces-sub', ) // subs reads all of these from the environment (see subs/src/main.rs #[arg(env=...)]). const subsEnv = { SUBS_DATA_DIR: SUBSPACES_DATA_DIR, SUBS_PORT: String(SUBSPACES_UI_PORT), SUBS_WALLET: SUBSPACES_WALLET, SUBS_SPACED_RPC_URL: `http://127.0.0.1:${spacedRpcPort}`, SUBS_SPACED_RPC_USER: spacedAuth.username, SUBS_SPACED_RPC_PASSWORD: spacedAuth.password, SUBS_PROVER_ENDPOINT: `http://127.0.0.1:${SUBSPACES_PROVER_PORT}`, SUBS_REGISTRY_ENDPOINT: `http://127.0.0.1:${SUBSPACES_REGISTRY_PORT}`, HOME: SUBSPACES_DATA_DIR, RUST_LOG: 'subs=info,subs_prover=info,registry_server=info', } const proverEnv = { SUBS_PROVER_SERVER: '1', SUBS_PROVER_PORT: String(SUBSPACES_PROVER_PORT), HOME: SUBSPACES_PROVER_DIR, RUST_LOG: 'subs_prover=info', } const registryEnv = { REGISTRY_SERVER_PORT: String(SUBSPACES_REGISTRY_PORT), HOME: SUBSPACES_REGISTRY_DIR, RUST_LOG: 'registry_server=info', } const certrelaySub = await sdk.SubContainer.of( effects, { imageId: 'certrelay' }, mounts, 'certrelay-sub', ) const certrelayEnv = { CERTRELAY_CHAIN, CERTRELAY_DATA_DIR, CERTRELAY_BIND, CERTRELAY_PORT: String(CERTRELAY_PORT), CERTRELAY_SELF_URL: certrelaySelfUrl, // Credentials come from the local spaced server (store.spacedAuth). CERTRELAY_SPACED_RPC_URL: `http://${spacedAuth.username}:${spacedAuth.password}@127.0.0.1:${spacedRpcPort}`, CERTRELAY_REMOTE_IP_HEADER, CERTRELAY_BOOTSTRAP: certrelayBootstrap ? 'true' : 'false', CERTRELAY_ANCHOR_REFRESH, CERTRELAY_HEALTHCHECK_HANDLE: certrelayHealthcheckHandle, HOME: CERTRELAY_DATA_DIR, RUST_LOG: 'info', } const bashrc = [ 'export PATH=/root/.cargo/bin:/data/bin:/usr/local/bin:/usr/bin:/bin', "export PS1='spaces:\\w$ '", `alias spaces='space-cli --chain ${SPACED_CHAIN} --rpc-user "$SPACED_RPC_USER" --rpc-password "$SPACED_RPC_PASSWORD" '`, 'cat < chain .addOneshot('certrelay-setup', { subcontainer: certrelaySub, exec: { command: [ 'sh', '-c', `set -eu; \ mkdir -p ${CERTRELAY_DATA_DIR} /data/bin; \ echo "certrelay-setup: installing fabric CLI to ${CERTRELAY_FABRIC_DEST}..."; \ cp -f ${CERTRELAY_FABRIC_BIN} ${CERTRELAY_FABRIC_DEST}; \ chmod +x ${CERTRELAY_FABRIC_DEST}; \ echo "certrelay-setup: done."`, ], user: 'root', }, requires: [], }) .addDaemon('certrelay', { subcontainer: certrelaySub, exec: { command: [CERTRELAY_BIN], env: certrelayEnv, // /data is root-owned; the image's USER certrelay can't write it. user: 'root', }, ready: { display: i18n('Certrelay'), fn: () => sdk.healthCheck.checkPortListening(effects, CERTRELAY_PORT, { successMessage: i18n('certrelay is ready'), errorMessage: i18n('certrelay is not ready'), }), // Match the image's 120s start-period: anchor-refresh/checkpoint work // on first start can delay the listener. gracePeriod: 120_000, trigger: sdk.trigger.cooldownTrigger(30_000), }, requires: ['certrelay-setup', 'spaced'], }) // Appends the Subspaces chain (fetch → build → wallet ensure → subs-prover → // subs daemon) to any existing chain. `as any` because the chain's TS type // depends on prior IDs and we can't easily express the union here. const withSubspaces = (chain: any): any => { let c = chain .addOneshot('subspaces-dirs', { subcontainer: subspacesSub, exec: { command: [ 'sh', '-c', `set -eu; \ mkdir -p ${SUBSPACES_DATA_DIR} ${SUBSPACES_PROVER_DIR} ${SUBSPACES_REGISTRY_DIR}; \ echo "subspaces-dirs: ensured data/prover/registry dirs under ${SUBSPACES_DIR}."`, ], user: 'root', }, requires: [], }) .addDaemon('subs-registry', { subcontainer: subspacesSub, exec: { command: [ 'sh', '-c', `cd ${SUBSPACES_REGISTRY_DIR} && exec ${SUBSPACES_REGISTRY_BIN} --port ${SUBSPACES_REGISTRY_PORT}`, ], env: registryEnv, user: 'root', }, ready: { display: i18n('Subspaces Registry'), fn: () => sdk.healthCheck.checkPortListening(effects, SUBSPACES_REGISTRY_PORT, { successMessage: i18n('registry-server is ready'), errorMessage: i18n('registry-server is not ready'), }), gracePeriod: 60_000, }, requires: ['subspaces-dirs'], }) .addDaemon('subs', { subcontainer: subspacesSub, exec: { command: [SUBSPACES_SUBS_BIN], env: subsEnv, user: 'root', }, ready: { display: i18n('Subspaces Web UI'), fn: () => sdk.healthCheck.checkPortListening(effects, SUBSPACES_UI_PORT, { successMessage: i18n('subspaces UI is ready'), errorMessage: i18n('subspaces UI is not ready'), }), gracePeriod: 60_000, }, // Intentionally NOT contingent on subs-prover: the RISC Zero prover can // take a while to become ready, and subs only needs it on-demand (proof // generation), not at startup. subs reaches the prover via // SUBS_PROVER_ENDPOINT once the prover is up. requires: ['subspaces-dirs', 'spaced', 'subs-registry'], }) // subs-prover is opt-in and DISABLED BY DEFAULT (toggle via the "Enable // Subspaces Prover" action). Its interface (8888) stays registered either // way (see interfaces.ts), but the daemon only starts when enabled. It's // added LAST and depends on every other subspaces daemon; it runs lengthy // timing tests on boot and NOTHING depends on it, so its slow startup never // blocks anything else. if (enableSubsProver) { c = c.addDaemon('subs-prover', { subcontainer: subspacesSub, exec: { command: [ 'sh', '-c', `cd ${SUBSPACES_PROVER_DIR} && exec ${SUBSPACES_PROVER_BIN} --server --server-port ${SUBSPACES_PROVER_PORT}`, ], env: proverEnv, // Run as root: the horologger/subs image declares USER subs, but our // /data volume + subdirs are root-owned, so the non-root user can't // traverse/write them. Root sidesteps the ownership mismatch. user: 'root', }, ready: { display: i18n('Subspaces Prover'), fn: () => sdk.healthCheck.checkPortListening(effects, SUBSPACES_PROVER_PORT, { successMessage: i18n('subs-prover is ready'), errorMessage: i18n('subs-prover is not ready'), }), gracePeriod: 60_000, trigger: sdk.trigger.cooldownTrigger(30_000), }, requires: ['subspaces-dirs', 'subs-registry', 'subs'], }) } return c } if (!enableExplorer) { // Spaces-only mode: spaced + gotty terminal. No PostgreSQL, no indexer. // User can flip the toggle via the Enable Embedded Explorer action; // store.json.enableExplorer is read via .const() so the merge triggers // an automatic service restart and the full chain takes over. let chain: any = sdk.Daemons.of(effects) .addOneshot('bashrc', { subcontainer: termSub, exec: { command: ['bash', '-c', `cat > /root/.bashrc <<'SPACES_BASHRC_EOF' ${bashrc} SPACES_BASHRC_EOF`], user: 'root', }, requires: [], }) .addDaemon('spaced', { subcontainer: spacedSub, exec: { command: ['/root/.cargo/bin/spaced'], env: spacedEnv, }, ready: { display: i18n('Spaced RPC'), fn: () => sdk.healthCheck.checkPortListening(effects, spacedRpcPort, { successMessage: i18n('spaced RPC is ready'), errorMessage: i18n('spaced RPC is not ready'), }), gracePeriod: 120_000, }, requires: [], }) .addDaemon('web-terminal', { subcontainer: termSub, exec: { command: [ 'gotty', '--port', String(uiPort), '-c', `${APP_USER}:${APP_PASSWORD}`, '--permit-write', '--reconnect', '/bin/bash', ], env: spacedEnv, }, ready: { display: i18n('Web Interface'), fn: () => sdk.healthCheck.checkPortListening(effects, uiPort, { successMessage: i18n('The web terminal is ready'), errorMessage: i18n('The web terminal is not ready'), }), }, requires: ['bashrc'], }) .addHealthCheck('sync', { ready: { display: i18n('Spaced Sync'), fn: async () => { try { const probe = await spacedSub.exec( [ '/root/.cargo/bin/space-cli', '--chain', SPACED_CHAIN, '--rpc-user', spacedAuth.username, '--rpc-password', spacedAuth.password, '--output-format', 'json', 'getserverinfo', ], {}, ) const stderr = (probe.stderr ?? '').toString().trim() const stdoutText = (probe.stdout ?? '').toString() const stdoutTrimmed = stdoutText.trim() if (probe.exitCode !== 0) { return { result: 'failure', message: i18n('space-cli exited ${code}: ${error}', { code: String(probe.exitCode), error: (stderr || stdoutTrimmed || '').slice(0, 200), }), } } let parsed: { ready?: boolean progress?: number chain?: { blocks?: number; headers?: number } } try { parsed = JSON.parse(stdoutText) } catch { return { result: 'failure', message: i18n( 'getserverinfo non-JSON. stdout=${stdout} stderr=${stderr}', { stdout: (stdoutTrimmed || '').slice(0, 160), stderr: (stderr || '').slice(0, 160), }, ), } } const progress = Math.min( 100, Math.max(0, Math.round((parsed.progress ?? 0) * 100)), ) const blocks = parsed.chain?.blocks ?? 0 const headers = parsed.chain?.headers ?? 0 if (parsed.ready === true && progress >= 100) { return { result: 'success', message: i18n( 'spaced is fully synced (blocks ${blocks} / headers ${headers}).', { blocks: String(blocks), headers: String(headers) }, ), } } return { result: 'loading', message: i18n( 'spaced is indexing: ${pct}% (blocks ${blocks} / headers ${headers}).', { pct: String(progress), blocks: String(blocks), headers: String(headers), }, ), } } catch (e) { return { result: 'failure', message: i18n('Spaced Sync health check crashed: ${error}', { error: (e as Error)?.message ?? String(e), }), } } }, gracePeriod: 30_000, trigger: sdk.trigger.cooldownTrigger(30_000), }, requires: ['spaced'], }) chain = withCertrelay(chain) if (enableSubspaces) chain = withSubspaces(chain) return chain } // enableExplorer = true: full chain — spaced + terminal + embedded // PostgreSQL + Go indexer + indexer-sync health check. let chain: any = sdk.Daemons.of(effects) .addOneshot('bashrc', { subcontainer: termSub, exec: { command: ['bash', '-c', `cat > /root/.bashrc <<'SPACES_BASHRC_EOF' ${bashrc} SPACES_BASHRC_EOF`], user: 'root', }, requires: [], }) .addOneshot('postgres-chown', { subcontainer: postgresSub, exec: { command: [ 'bash', '-c', `mkdir -p ${pgDataDir} && chown -R postgres:postgres ${pgDataDir} && chmod 700 ${pgDataDir}`, ], user: 'root', }, requires: [], }) .addDaemon('postgres', { subcontainer: postgresSub, exec: { command: [ 'docker-entrypoint.sh', 'postgres', '-c', 'listen_addresses=127.0.0.1', ], env: postgresEnv, }, ready: { display: i18n('Database'), fn: () => sdk.healthCheck.checkPortListening(effects, POSTGRES_PORT, { successMessage: i18n('postgres is ready'), errorMessage: i18n('postgres is not ready'), }), gracePeriod: 60_000, }, requires: ['postgres-chown'], }) .addDaemon('spaced', { subcontainer: spacedSub, exec: { command: ['/root/.cargo/bin/spaced'], env: spacedEnv, }, ready: { display: i18n('Spaced RPC'), fn: () => sdk.healthCheck.checkPortListening(effects, spacedRpcPort, { successMessage: i18n('spaced RPC is ready'), errorMessage: i18n('spaced RPC is not ready'), }), gracePeriod: 120_000, }, requires: [], }) // TODO(prebuilt-indexer): the Go indexer build-from-source chain // (indexer-fetch + indexer-build + indexer-cleanup-legacy + // indexer-migrate + `indexer` daemon) was removed to shrink the .s9pk // and cut first-enable latency. Re-add an `indexer` daemon here that runs // the prebuilt `sync` binary from the future indexer image (image id + // binary path TBD) against PostgreSQL + spaced. Env it needs is the old // indexerEnv (POSTGRES_URI, BITCOIN_NODE_*, SPACES_NODE_URI, RPC_USER/ // PASSWORD, ACTIVATION_BLOCK_HEIGHT, FAST_SYNC_BLOCK_HEIGHT, // UPDATE_DB_INTERVAL, MEMPOOL_CHUNK_SIZE). A goose migration step must // run before it. See git history (build-from-source) for the prior shape. // // TODO(prebuilt-explorer): the SvelteKit explorer build chain // (explorer-fetch + explorer-install + `explorer-ui` daemon) was removed // for the same reason. Re-add an `explorer-ui` daemon that runs the // prebuilt explorer server from the provided explorer image (image id + // command TBD) on port 3000, reading DB_URL + PUBLIC_BTC_NETWORK. .addDaemon('web-terminal', { subcontainer: termSub, exec: { command: [ 'gotty', '--port', String(uiPort), '-c', `${APP_USER}:${APP_PASSWORD}`, '--permit-write', '--reconnect', '/bin/bash', ], env: spacedEnv, }, ready: { display: i18n('Web Interface'), fn: () => sdk.healthCheck.checkPortListening(effects, uiPort, { successMessage: i18n('The web terminal is ready'), errorMessage: i18n('The web terminal is not ready'), }), }, requires: ['bashrc'], }) .addHealthCheck('sync', { ready: { display: i18n('Spaced Sync'), fn: async () => { try { const probe = await spacedSub.exec( [ '/root/.cargo/bin/space-cli', '--chain', SPACED_CHAIN, '--rpc-user', spacedAuth.username, '--rpc-password', spacedAuth.password, '--output-format', 'json', 'getserverinfo', ], {}, ) const stderr = (probe.stderr ?? '').toString().trim() const stdoutText = (probe.stdout ?? '').toString() const stdoutTrimmed = stdoutText.trim() if (probe.exitCode !== 0) { return { result: 'failure', message: i18n('space-cli exited ${code}: ${error}', { code: String(probe.exitCode), error: (stderr || stdoutTrimmed || '').slice(0, 200), }), } } let parsed: { ready?: boolean progress?: number chain?: { blocks?: number; headers?: number } } try { parsed = JSON.parse(stdoutText) } catch { return { result: 'failure', message: i18n( 'getserverinfo non-JSON. stdout=${stdout} stderr=${stderr}', { stdout: (stdoutTrimmed || '').slice(0, 160), stderr: (stderr || '').slice(0, 160), }, ), } } const progress = Math.min( 100, Math.max(0, Math.round((parsed.progress ?? 0) * 100)), ) const blocks = parsed.chain?.blocks ?? 0 const headers = parsed.chain?.headers ?? 0 if (parsed.ready === true && progress >= 100) { return { result: 'success', message: i18n( 'spaced is fully synced (blocks ${blocks} / headers ${headers}).', { blocks: String(blocks), headers: String(headers) }, ), } } return { result: 'loading', message: i18n( 'spaced is indexing: ${pct}% (blocks ${blocks} / headers ${headers}).', { pct: String(progress), blocks: String(blocks), headers: String(headers), }, ), } } catch (e) { return { result: 'failure', message: i18n( 'Spaced Sync health check crashed: ${error}', { error: (e as Error)?.message ?? String(e) }, ), } } }, gracePeriod: 30_000, trigger: sdk.trigger.cooldownTrigger(30_000), }, requires: ['spaced'], }) // TODO(prebuilt-indexer): re-add the `indexer-sync` standalone health // check once the prebuilt indexer daemon is wired back in. It queried // MAX(blocks.height) (non-orphan) via psql and compared against spaced's // tip from `space-cli getserverinfo`. See git history for the prior body. chain = withCertrelay(chain) if (enableSubspaces) chain = withSubspaces(chain) return chain })