This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+79
-34
@@ -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 || '<no output>').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 || '<empty>').slice(0, 160),
|
||||
stderr: (stderr || '<empty>').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'],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user