From 2cdc4a5f34d41ece7155b7e72797c246f7b761e5 Mon Sep 17 00:00:00 2001 From: spacesops Date: Thu, 14 May 2026 18:27:20 -0400 Subject: [PATCH] imp/exp/show/cookie --- README.md | 7 ++ instructions.md | 10 ++ startos/actions/exportWallet.ts | 92 ++++++++++++++++++ startos/actions/importWallet.ts | 133 +++++++++++++++++++++++++++ startos/actions/index.ts | 6 ++ startos/actions/showPassword.ts | 57 ++++++++++++ startos/actions/syncStatus.ts | 2 +- startos/i18n/dictionaries/default.ts | 27 +++++- startos/main.ts | 113 ++++++++++++++++------- 9 files changed, 409 insertions(+), 38 deletions(-) create mode 100644 startos/actions/exportWallet.ts create mode 100644 startos/actions/importWallet.ts create mode 100644 startos/actions/showPassword.ts diff --git a/README.md b/README.md index d5bf9a1..ceb8a46 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,12 @@ mainnet-only. | --- | --- | --- | --- | --- | | `reset-password` | Reset Web UI Password | Enabled | Any | Regenerates the web-UI password and restarts the terminal daemon | | `show-credentials` | Show Web UI Credentials | Hidden | Any | Surfaces the current `admin` username + masked password (launched by the first-install task) | +| `show-password` | Show Web UI Password | Enabled | Any | Same as `show-credentials` but visible in the actions list, for routine re-display of the current admin credentials | | `set-bitcoin-rpc` | Set up Bitcoin RPC | Enabled | Any | Re-invokes bitcoind's `generate-rpc-dependent` with the stored credentials. Safe to call repeatedly. | | `sync-status` | Sync Status | Enabled | Only running | Runs `space-cli getserverinfo` inside the daemon container and returns the JSON output | | `reset-spaced-state` | Reset Spaced State | Enabled | Any | Deletes `/data/mainnet/` so spaced resyncs its index from spaces' anchor. Preserves `store.json` (passwords + RPC credentials). Use when spaced crash-loops on a stale or corrupt index. | +| `export-wallet` | Export Wallet | Enabled | Only running | Runs `space-cli exportwallet /data/mainnet/wallets_backup/default.json` and surfaces the resulting JSON as a masked/copyable result. The file is also persisted inside the volume at that path. | +| `import-wallet` | Import Wallet | Enabled | Only running | Accepts a pasted JSON payload (textarea), writes it to `/data/mainnet/wallets_backup/default.json` (rotating the existing file to `.bakNNN`), rotates `/data/mainnet/wallets/default` to `.bakNNN`, then runs `space-cli importwallet` + `loadwallet`. | ## Backups and Restore @@ -177,6 +180,7 @@ startos_managed_env_vars: - SPACED_BITCOIN_RPC_URL - SPACED_BITCOIN_RPC_USER - SPACED_BITCOIN_RPC_PASSWORD + - SPACED_RPC_COOKIE - BTC_RPC_HOST - BTC_RPC_PORT - BTC_RPC_USER @@ -186,7 +190,10 @@ startos_managed_env_vars: actions: - reset-password - show-credentials + - show-password - set-bitcoin-rpc - sync-status - reset-spaced-state + - export-wallet + - import-wallet ``` diff --git a/instructions.md b/instructions.md index 3a6ea7e..7837724 100644 --- a/instructions.md +++ b/instructions.md @@ -47,6 +47,16 @@ spaces walletbalance default - **Reset Spaced State** — wipes `/data/mainnet/` so spaced resyncs its index from spaces' anchor. Use this if spaced is crash-looping on a stale or corrupt index. `store.json` (passwords + RPC credentials) is preserved. +- **Export Wallet** — runs `space-cli exportwallet` on the `default` wallet, + writes the JSON to `/data/mainnet/wallets_backup/default.json` inside the + container, and surfaces the same JSON as a copyable result so you can save + it locally. Requires the service to be running. +- **Import Wallet** — paste a previously-exported `default.json` payload to + restore it. The action rotates any existing + `/data/mainnet/wallets_backup/default.json` and + `/data/mainnet/wallets/default` to `.bakNNN` suffixes so no prior state is + lost, then calls `space-cli importwallet` and `space-cli loadwallet`. + Requires the service to be running. ## Limitations diff --git a/startos/actions/exportWallet.ts b/startos/actions/exportWallet.ts new file mode 100644 index 0000000..ffee668 --- /dev/null +++ b/startos/actions/exportWallet.ts @@ -0,0 +1,92 @@ +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { dataDir, SPACED_CHAIN } from '../utils' + +const BACKUP_DIR = `${dataDir}/${SPACED_CHAIN}/wallets_backup` +const BACKUP_PATH = `${BACKUP_DIR}/default.json` + +export const exportWallet = sdk.Action.withoutInput( + // id + 'export-wallet', + + // metadata + async ({ effects }) => ({ + name: i18n('Export Wallet'), + description: i18n( + 'Export the `default` spaces wallet to /data/mainnet/wallets_backup/default.json and surface the JSON below.', + ), + warning: null, + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }), + + // run + async ({ effects }) => { + const mounts = sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: dataDir, + readonly: false, + }) + + const cmd = [ + `mkdir -p ${BACKUP_DIR}`, + `/root/.cargo/bin/space-cli --chain ${SPACED_CHAIN} --rpc-cookie ${dataDir}/${SPACED_CHAIN}/.cookie exportwallet ${BACKUP_PATH}`, + ].join(' && ') + + const result = await sdk.SubContainer.withTemp( + effects, + { imageId: 'spaces' }, + mounts, + 'spaces-export-wallet', + async (subc) => { + const exportRes = await subc.exec(['bash', '-c', cmd], { user: 'root' }) + if (exportRes.exitCode !== 0) { + return { + ok: false as const, + stderr: (exportRes.stderr ?? '').toString(), + exitCode: exportRes.exitCode, + } + } + const catRes = await subc.exec(['cat', BACKUP_PATH], { user: 'root' }) + if (catRes.exitCode !== 0) { + return { + ok: false as const, + stderr: (catRes.stderr ?? '').toString(), + exitCode: catRes.exitCode, + } + } + return { ok: true as const, body: (catRes.stdout ?? '').toString() } + }, + ) + + if (!result.ok) { + return { + version: '1', + title: i18n('Failure'), + message: i18n('Could not export wallet: ${error}', { + error: result.stderr || `exit ${result.exitCode}`, + }), + result: null, + } + } + + return { + version: '1', + title: i18n('Wallet exported.'), + message: i18n( + 'A copy of this JSON is saved inside the container at /data/mainnet/wallets_backup/default.json.', + ), + result: { + type: 'single', + name: i18n('Wallet JSON'), + description: null, + value: result.body.trim(), + masked: true, + copyable: true, + qr: false, + }, + } + }, +) diff --git a/startos/actions/importWallet.ts b/startos/actions/importWallet.ts new file mode 100644 index 0000000..f07e938 --- /dev/null +++ b/startos/actions/importWallet.ts @@ -0,0 +1,133 @@ +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { dataDir, SPACED_CHAIN } from '../utils' + +const { InputSpec, Value } = sdk + +const BACKUP_DIR = `${dataDir}/${SPACED_CHAIN}/wallets_backup` +const BACKUP_PATH = `${BACKUP_DIR}/default.json` +const WALLET_DIR = `${dataDir}/${SPACED_CHAIN}/wallets/default` +const COOKIE_PATH = `${dataDir}/${SPACED_CHAIN}/.cookie` + +const inputSpec = InputSpec.of({ + walletJson: Value.textarea({ + name: i18n('Wallet JSON'), + description: i18n('Paste the JSON contents of a default.json wallet export.'), + warning: null, + footnote: null, + default: null, + required: true, + minLength: 1, + maxLength: null, + placeholder: '{...}', + }), +}) + +export const importWallet = sdk.Action.withInput( + // id + 'import-wallet', + + // metadata + async ({ effects }) => ({ + name: i18n('Import Wallet'), + description: i18n( + 'Restore a previously-exported default.json wallet into spaced.', + ), + warning: i18n( + 'Existing /data/mainnet/wallets_backup/default.json and /data/mainnet/wallets/default will be renamed with .bakNNN suffixes before the import. The active spaced daemon will load the imported wallet on success.', + ), + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }), + + // input + inputSpec, + + // prefill + async ({ effects }) => {}, + + // run + async ({ effects, input }) => { + // Validate JSON before touching disk. + try { + JSON.parse(input.walletJson) + } catch (e) { + return { + version: '1', + title: i18n('Failure'), + message: i18n('Could not parse wallet JSON: ${error}', { + error: (e as Error).message, + }), + result: null, + } + } + + // base64-encode so shell quoting can never break on user content. + const b64 = Buffer.from(input.walletJson, 'utf8').toString('base64') + + const script = `set -e +mkdir -p '${BACKUP_DIR}' + +# Rotate /data/mainnet/wallets_backup/default.json -> .bakNNN if exists. +BACKUP='${BACKUP_PATH}' +if [ -e "$BACKUP" ]; then + i=0 + while [ -e "$(printf '%s.bak%03d' "$BACKUP" "$i")" ]; do + i=$((i+1)) + done + mv "$BACKUP" "$(printf '%s.bak%03d' "$BACKUP" "$i")" +fi + +# Write fresh default.json from base64 payload. +printf '%s' '${b64}' | base64 -d > "$BACKUP" + +# Rotate /data/mainnet/wallets/default folder -> .bakNNN if exists. +WDIR='${WALLET_DIR}' +if [ -d "$WDIR" ]; then + j=0 + while [ -d "$(printf '%s.bak%03d' "$WDIR" "$j")" ]; do + j=$((j+1)) + done + mv "$WDIR" "$(printf '%s.bak%03d' "$WDIR" "$j")" +fi + +# Import + load via spaced RPC. +/root/.cargo/bin/space-cli --chain ${SPACED_CHAIN} --rpc-cookie '${COOKIE_PATH}' importwallet "$BACKUP" +/root/.cargo/bin/space-cli --chain ${SPACED_CHAIN} --rpc-cookie '${COOKIE_PATH}' loadwallet +` + + const res = await sdk.SubContainer.withTemp( + effects, + { imageId: 'spaces' }, + sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: dataDir, + readonly: false, + }), + 'spaces-import-wallet', + (subc) => subc.exec(['bash', '-c', script], { user: 'root' }), + ) + + if (res.exitCode !== 0) { + const stderr = (res.stderr ?? '').toString() + const stdout = (res.stdout ?? '').toString() + return { + version: '1', + title: i18n('Failure'), + message: i18n('Could not import wallet: ${error}', { + error: stderr || stdout || `exit ${res.exitCode}`, + }), + result: null, + } + } + + return { + version: '1', + title: i18n('Success'), + message: i18n('Wallet imported and loaded.'), + result: null, + } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts index 7c4c832..3e31ce6 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -1,13 +1,19 @@ import { sdk } from '../sdk' +import { exportWallet } from './exportWallet' +import { importWallet } from './importWallet' import { resetPassword } from './resetPassword' import { resetSpacedState } from './resetSpacedState' import { setBitcoinRpc } from './setBitcoinRpc' import { showCredentials } from './showCredentials' +import { showPassword } from './showPassword' import { syncStatus } from './syncStatus' export const actions = sdk.Actions.of() .addAction(resetPassword) .addAction(showCredentials) + .addAction(showPassword) .addAction(setBitcoinRpc) .addAction(syncStatus) .addAction(resetSpacedState) + .addAction(exportWallet) + .addAction(importWallet) diff --git a/startos/actions/showPassword.ts b/startos/actions/showPassword.ts new file mode 100644 index 0000000..d09cc2a --- /dev/null +++ b/startos/actions/showPassword.ts @@ -0,0 +1,57 @@ +import { storeJson } from '../fileModels/storeJson' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { APP_USER } from '../utils' + +export const showPassword = sdk.Action.withoutInput( + // id + 'show-password', + + // metadata + async ({ effects }) => ({ + name: i18n('Show Web UI Password'), + description: i18n( + 'Display the existing username and password for the Spaces web terminal.', + ), + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // run + async ({ effects }) => { + const password = await storeJson.read((s) => s.password).once() + + return { + version: '1', + title: i18n('Show Web UI Password'), + message: i18n( + 'Use these credentials to log in to the Spaces web terminal.', + ), + result: { + type: 'group', + value: [ + { + type: 'single', + name: i18n('Username'), + description: null, + value: APP_USER, + masked: false, + copyable: true, + qr: false, + }, + { + type: 'single', + name: i18n('Password'), + description: null, + value: password ?? '', + masked: true, + copyable: true, + qr: false, + }, + ], + }, + } + }, +) diff --git a/startos/actions/syncStatus.ts b/startos/actions/syncStatus.ts index c256a93..566e895 100644 --- a/startos/actions/syncStatus.ts +++ b/startos/actions/syncStatus.ts @@ -54,7 +54,7 @@ export const syncStatus = sdk.Action.withoutInput( return { version: '1', title: i18n('Sync Status'), - message: stdout || i18n('spaced is fully synced.'), + message: stdout || i18n('Sync Status'), result: { type: 'single', name: 'getserverinfo', diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index 83a9abf..b7bfe88 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -12,9 +12,14 @@ const dict = { 'Spaced Sync': 7, 'spaced is querying Bitcoin and indexing — this can take a while on first run.': 8, - 'spaced is fully synced.': 9, - 'spaced is indexing. Progress: ${pct}%': 10, - 'spaced RPC did not respond.': 11, + 'spaced is fully synced (blocks ${blocks} / headers ${headers}).': 9, + 'spaced is indexing: ${pct}% (blocks ${blocks} / headers ${headers}).': 10, + 'getserverinfo non-JSON. stdout=${stdout} stderr=${stderr}': 11, + 'Spaced Sync health check crashed: ${error}': 53, + 'space-cli exited ${code}: ${error}': 54, + 'Show Web UI Password': 55, + 'Display the existing username and password for the Spaces web terminal.': + 56, // interfaces.ts 'Web UI': 12, @@ -53,6 +58,22 @@ const dict = { 36, 'Spaced state has been wiped. Start (or restart) the service to resync.': 37, 'Could not wipe spaced state: ${error}': 38, + 'Export Wallet': 39, + "Export the `default` spaces wallet to /data/mainnet/wallets_backup/default.json and surface the JSON below.": + 40, + 'Could not export wallet: ${error}': 41, + 'Wallet exported.': 42, + 'Wallet JSON': 43, + 'A copy of this JSON is saved inside the container at /data/mainnet/wallets_backup/default.json.': + 44, + 'Import Wallet': 45, + 'Restore a previously-exported default.json wallet into spaced.': 46, + 'Existing /data/mainnet/wallets_backup/default.json and /data/mainnet/wallets/default will be renamed with .bakNNN suffixes before the import. The active spaced daemon will load the imported wallet on success.': + 47, + 'Paste the JSON contents of a default.json wallet export.': 48, + 'Could not parse wallet JSON: ${error}': 49, + 'Wallet imported and loaded.': 50, + 'Could not import wallet: ${error}': 51, } as const /** diff --git a/startos/main.ts b/startos/main.ts index 94a3cc4..ea58102 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -33,6 +33,7 @@ export const main = sdk.setupMain(async ({ effects }) => { 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_COOKIE: `/data/mainnet/.cookie`, // legacy aliases for `bitcoin-cli` / shell helpers that read these names BTC_RPC_HOST: BITCOIND_RPC_HOSTNAME, BTC_RPC_PORT: String(BITCOIND_RPC_PORT), @@ -136,51 +137,95 @@ SPACES_BASHRC_EOF`], ready: { display: i18n('Spaced Sync'), fn: async () => { - const probe = await spacedSub.exec( - [ - 'bash', - '-c', - `/root/.cargo/bin/space-cli --chain ${SPACED_CHAIN} --rpc-cookie ${dataDir}/${SPACED_CHAIN}/.cookie getserverinfo`, - ], - {}, - ) - - if (probe.exitCode !== 0) { - return { - result: 'starting', - message: i18n( - 'spaced is querying Bitcoin and indexing — this can take a while on first run.', - ), - } - } - - const stdout = (probe.stdout ?? '').toString() - let progress = 0 try { - const parsed = JSON.parse(stdout) as { + const probe = await spacedSub.exec( + [ + '/root/.cargo/bin/space-cli', + '--chain', + SPACED_CHAIN, + '--rpc-cookie', + `${dataDir}/${SPACED_CHAIN}/.cookie`, + '--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 } } - if (parsed.ready === true) { + try { + parsed = JSON.parse(stdoutText) + } catch { return { - result: 'success', - message: i18n('spaced is fully synced.'), + result: 'failure', + message: i18n( + 'getserverinfo non-JSON. stdout=${stdout} stderr=${stderr}', + { + stdout: (stdoutTrimmed || '').slice(0, 160), + stderr: (stderr || '').slice(0, 160), + }, + ), } } - progress = Math.floor((parsed.progress ?? 0) * 100) - } catch { - // fall through to loading - } - return { - result: 'loading', - message: i18n('spaced is indexing. Progress: ${pct}%', { - pct: String(progress), - }), + 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: 300_000, + gracePeriod: 30_000, + trigger: sdk.trigger.cooldownTrigger(30_000), }, requires: ['spaced'], })