monerod get_info

This commit is contained in:
2026-10-22 15:22:23 -04:00
parent 3f04c3afa9
commit d8780701e6
9 changed files with 315 additions and 10 deletions
+1
View File
@@ -46,6 +46,7 @@ COPY scripts/custody-db.ts ./scripts/custody-db.ts
COPY scripts/monero-observer-worker.ts ./scripts/monero-observer-worker.ts
COPY scripts/custody-deployment-init.ts ./scripts/custody-deployment-init.ts
COPY scripts/custody-deployment-check.ts ./scripts/custody-deployment-check.ts
COPY scripts/monero-daemon-check.ts ./scripts/monero-daemon-check.ts
COPY --from=randomx-build /native/build/wikideck-randomx ./native/wikideck-randomx
RUN mkdir -p /app/data && chown node:node /app/data
USER node
+23
View File
@@ -106,6 +106,29 @@ services:
pids_limit: 128
mem_limit: 1g
stop_grace_period: 30s
daemon-check:
image: wikideck:private-pilot
profiles: ["monero-check"]
build:
context: .
target: runtime
args:
WIKIDECK_NETWORK: pilot
entrypoint: [node, --experimental-strip-types, scripts/monero-daemon-check.ts]
environment:
WIKIDECK_MONERO_DAEMON_CONFIG_FILE: /run/monero/daemon.json
volumes:
- type: bind
source: "${WIKIDECK_MONERO_DAEMON_CONFIG_HOST:-./private/monero-daemon.json}"
target: /run/monero/daemon.json
read_only: true
networks: [web]
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
pids_limit: 32
mem_limit: 128m
restart: "no"
networks:
financial: {internal: true}
web: {}
+27 -3
View File
@@ -1,6 +1,6 @@
# Private pilot deployment: mock-only foundation
Status: **application and isolated PostgreSQL sandbox only**. This is not a real-XMR stack, a public node, or an approval to accept deposits. The first deployment increment intentionally has no Monero daemon, Wallet-RPC gateway, observer, deposit enrollment, or withdrawal execution.
Status: **application and isolated PostgreSQL sandbox only**. This is not a real-XMR stack, a public node, or an approval to accept deposits. The operator already hosts a Monero full node on the network and intends the pilot to reuse it; this Compose profile neither starts nor connects to that daemon yet. Wallet-RPC, observer, deposit enrollment, and withdrawal execution are not included.
## Start the isolated profile
@@ -16,8 +16,32 @@ To stop while preserving pilot data, run `docker compose -p wikideck-xmr-pilot -
The independent private chain uses a fixed pilot genesis time and its own deterministic chain/genesis identity. The app can run with the current sandbox features and mock balances while test cards and UI flows are exercised. Testnet and mainnet candidate identities and launch gates remain unchanged.
This stack does **not** create real Monero deposit addresses, scan a wallet, approve real credits, construct or relay withdrawals, or settle NFT sales against active consensus. Deposit enrollment and withdrawal request flags remain rejected outside the mainnet profile, and mainnet remains separately gated. Do not expose the service publicly or solicit funds. Wallet integration requires the later observer/gateway and schema/role work, then actual withdrawal execution and activated sale settlement described in [the implementation plan](real-xmr-pilot-plan.md).
This stack does **not** connect to the operator's Monero daemon, create real Monero deposit addresses, scan a wallet, approve real credits, construct or relay withdrawals, or settle NFT sales against active consensus. Having a full node supplies only the blockchain daemon; it is not a Wallet-RPC service, watch-only deposit wallet, or spending wallet. Deposit enrollment and withdrawal request flags remain rejected outside the mainnet profile, and mainnet remains separately gated. Do not expose the service publicly or solicit funds.
### Reusing the operator-hosted daemon
The next integration should point the observer/gateway to the existing daemon rather than deploy or synchronize another one. An operator-triggered, read-only `get_info` probe is now available as a first connectivity check. It checks the expected Monero network, synchronized/local height, and daemon status; it does not open a wallet or provide a financial proof. The probe is opt-in and does not run on app startup.
Create a protected JSON config file outside the repository with `network` set to `mainnet` (or `stagenet` for a rehearsal), `url` set to the daemon's private literal-IP `/json_rpc` endpoint with its explicit port, and `allowPrivateHttp` set to `true` only when deliberately using HTTP on a trusted private network. For example:
```json
{
"network": "mainnet",
"url": "http://192.168.1.20:18081/json_rpc",
"allowPrivateHttp": true
}
```
Replace the example address with the operator's actual daemon address. Protect the file with owner-only permissions/ACLs. No credentials belong in this file. The endpoint is deliberately restricted to a private literal IP; public hosts and DNS names are rejected.
From the pilot Compose project, run `docker compose -p wikideck-xmr-pilot -f compose.custody-pilot.yaml --profile monero-check run --rm daemon-check`; set `WIKIDECK_MONERO_DAEMON_CONFIG_HOST` to the config file path if it is not at the documented default. This runs from the Compose network, making it a better reachability check than testing only from the host. It emits the network, height, version and result, but not the endpoint. The current probe does not implement Monero's Digest `--rpc-login`; if the daemon requires it, do not disable auth or publish RPC to make this check work. Put a separately reviewed, authenticated TLS proxy on the private path or defer the check until a secure supported transport is configured.
Before wiring it into observer/gateway services, also verify the daemon's software version and operation policy. Mainnet is required for the real-XMR run; Stagenet rehearsal needs a separate Stagenet daemon endpoint. A daemon that is reachable on the operator's LAN is not automatically reachable from Docker containers.
Keep daemon RPC private to the pilot/operator network, bound to an appropriate private interface, and firewall-allowlisted to the pilot host. Prefer a supported authenticated TLS boundary; do not publish daemon RPC or Wallet-RPC to the Internet. The probe's HTTP option has no credentials and relies on private-network isolation; it is only for an explicitly trusted/firewalled route. Store any later gateway credentials/certificates in protected secret files, never in source, Compose literals, or browser settings. The observer should use a watch-only wallet/RPC boundary; transaction signing and spend keys remain separate and out of the app/observer. The eventual flow still needs a private Wallet-RPC gateway, observer, schema/role integration, reviewed credits, actual withdrawal execution, recovery, and active sale settlement, as described in [the implementation plan](real-xmr-pilot-plan.md).
This probe does not establish that the node is honest or independently canonical, and `synchronized: true` is a point-in-time daemon report—not a finality guarantee or credit authorization. No deposit addresses are issued and no financial API is enabled by a successful result.
## Initial checks
The network manifest reports `profile: pilot`, `network: wikideck-randomx-pilot-1`, and `launchStatus: private-pilot-mock-only`. Verify startup refuses a missing pilot-mode opt-in and any non-mock payment setting. Deployment tests check loopback binding, distinct profile selection, database secret isolation, and absence of Monero services. A successful mock deployment is only evidence for this first slice, not for real-XMR readiness.
The network manifest reports `profile: pilot`, `network: wikideck-randomx-pilot-1`, and `launchStatus: private-pilot-mock-only`. Verify startup refuses a missing pilot-mode opt-in and any non-mock payment setting. Deployment tests check loopback binding, distinct profile selection, database secret isolation, and absence of an in-stack Monero daemon. A successful mock deployment is only evidence for this first slice, not for daemon connectivity or real-XMR readiness.
+9 -7
View File
@@ -5,7 +5,9 @@ Updated: 2026-10-22. Implementation plan for a private, operator-supervised func
## Progress checkpoint — 2026-10-22
- **Step 1 started; mock-only foundation implemented.** Added a distinct `pilot` profile with its own network/genesis identity, explicit private-mock-only startup opt-in, separate local ledger/market/custody paths and browser wallet database, and a loopback-bound PostgreSQL sandbox stack. Setup and limits are in [the pilot deployment guide](real-xmr-pilot-deployment.md). Testnet v6 and mainnet candidate identities/gates were left intact.
- **Step 1 is not complete.** This first stack has no Monero daemon, Wallet-RPC gateway, observer, real-fund deposit or withdrawal path. It is useful for isolated UI/mock-flow work only and must not receive funds.
- **Operator infrastructure update:** a Monero full node is hosted on the operator's network and is intended to be reused; the pilot does not need to deploy or synchronize another daemon. Its network mode, RPC reachability from the pilot host/container, authentication/transport boundary and sync health still need verification. This does not provide Wallet-RPC, deposit observation, address allocation or a spending wallet.
- **Next integration slice implemented:** added an operator-triggered, read-only `monerod get_info` probe, opt-in Compose check service, and mocked regression coverage. The Linux test image builds and focused probe/deployment tests pass (6/6). The probe validates expected Mainnet/Stagenet identity and synchronization, but has not been run against the operator's node because its private endpoint/configuration has not been provided. It does not enable wallet access, addresses, deposits, or credits. See [the probe setup and security boundary](real-xmr-pilot-deployment.md).
- **Step 1 is not complete.** The current pilot stack does not connect to that external daemon and still has no Wallet-RPC gateway, observer, real-fund deposit or withdrawal path. It is useful for isolated UI/mock-flow work only and must not receive funds.
- **Validation:** pilot-specific network/startup/storage/Compose tests pass (10/10); pilot runtime Docker image builds. The attempted serial full offline suite had one native RandomX HTTP peer-gossip convergence failure in `tests/api.test.ts`; the other reported tests passed. Treat full-suite status as not yet clean and rerun after the next increment.
- **Steps 2–5:** not started. Existing deposit/withdrawal APIs still require the separately gated mainnet profile; sale-lock transactions remain candidate-only and are not accepted by active consensus.
@@ -38,21 +40,21 @@ One Linux host, one authoritative market writer, one treasury tool/worker, fixed
- Add an explicitly identified private pilot profile for the sale protocol, distinct from existing v6 and the final mainnet candidate.
- Bind signed market/payment requests to the pilot chain and custodian; select the Monero network explicitly. Real XMR requires Monero mainnet, not a public Wikideck launch.
- Give the pilot its own explicit genesis/startup configuration rather than inheriting the candidate's January 1, 2027 delay and public-peer requirements. Leave existing mainnet protections unchanged; do not fake review acknowledgements or alter v6 in place.
- Add one pilot deployment definition using existing Docker patterns: app, PostgreSQL, observer and private Wallet-RPC gateway, with explicit connections to the daemon/watch-only wallet and separate spending wallet.
- Add one pilot deployment definition using existing Docker patterns: app, PostgreSQL, observer and private Wallet-RPC gateway. Reuse the operator-hosted full node over a verified private network connection; do not add a duplicate daemon service. Connect the observer/watch-only wallet and separate spending wallet explicitly. Keep daemon and Wallet-RPC endpoints private, authenticated and unavailable from the public Internet; supply credentials through protected files/secrets, not checked-in Compose values.
- Include schema initialization, role grants and required configuration in a short setup procedure. Keep spending keys outside the web app and Wallet RPC off the public Internet.
**Progress:** A separate mock-only pilot network/profile, launch opt-in, profile-isolated local storage, PostgreSQL Compose deployment and setup guide now exist. This establishes the private application base only; the planned Monero daemon/observer/gateway and any real-fund integrations remain outstanding. See [pilot deployment status and startup](real-xmr-pilot-deployment.md).
**Progress:** A separate mock-only pilot network/profile, launch opt-in, profile-isolated local storage, PostgreSQL Compose deployment and setup guide now exist. An opt-in read-only daemon probe is implemented to verify private connectivity and `get_info` health from the Compose network; it still needs the operator's protected endpoint config and a successful real-node run. It does not connect the observer or Wallet-RPC. Wallet roles and all real-fund integrations remain outstanding. See [pilot deployment status and startup](real-xmr-pilot-deployment.md).
**Result:** start the stack, open the UI, create two wallets and obtain a test card. Start preparing/syncing the Monero daemon and wallet while application development proceeds.
**Result:** start the stack, open the UI, create two wallets and obtain a test card; verify the hosted daemon's intended network, synchronization and private RPC reachability from the pilot runtime. Do not start a duplicate daemon. Prepare the watch-only/observer and separately controlled spending-wallet services while application development proceeds. A Stagenet rehearsal still requires a Stagenet daemon endpoint; an operator's Mainnet daemon cannot substitute for it.
### 2. Connect deposits to spendable balances
- Add a deposit panel that signs the existing enrollment request and displays the allocated address.
- Run the observer against the configured wallet; show pending and available balances.
- Run the observer against the configured watch-only wallet and operator-hosted daemon; show pending and available balances. Explicitly bind the Monero network and refuse a daemon/wallet network mismatch or stale/unsynchronized RPC state.
- Support the pilot identity in the existing prepare/stage/release workflow. Keep approvals operator-driven rather than adding automation.
- Report capabilities truthfully as the flow becomes available; keep mock-credit controls unreachable in real-fund mode.
**Result:** a real Stagenet deposit is observed and approved into the buyer's available balance through the running application. This is private development progress, not a reason to solicit real deposits before cash-out works.
**Result:** a real Stagenet deposit is observed and approved into the buyer's available balance through the running application, using a Stagenet daemon endpoint. For the eventual real-XMR run, use the operator-hosted Monero Mainnet node only after network and synchronization checks pass. This is private development progress, not a reason to solicit real deposits before cash-out works.
### 3. Finish withdrawals with an operator tool
@@ -116,4 +118,4 @@ Keep the functional essentials: signed authorization, correct network binding, p
## Next coding task
**Finish step 1's operational checks, then design the pilot's separate Wikideck-chain/Monero-mainnet financial identity and schema/role contract before connecting deposits.** Existing observer/deposit contracts are mainnet-specific, so do not simply enable their flags on the pilot. Keep the next deployment mock-only until network binding, reviewed credit, actual withdrawal execution, recovery, and sale settlement are connected. Continue with the operator treasury tool and active sale-lock settlement as parallel implementation tracks where the identity contract permits. Completion means a buyer purchasing a card and the seller receiving withdrawable XMR—not another scaffold or release checklist.
**Run the read-only daemon check from the pilot Compose network once its protected endpoint config is available, then design the pilot's separate Wikideck-chain/Monero-mainnet financial identity and schema/role contract before connecting deposits.** No duplicate daemon is planned. Existing observer/deposit contracts are mainnet-specific, so do not simply enable their flags on the pilot. Keep the deployment mock-only until network binding, reviewed credit, actual withdrawal execution, recovery, and sale settlement are connected. Continue with the operator treasury tool and active sale-lock settlement as parallel implementation tracks where the identity contract permits. Completion means a buyer purchasing a card and the seller receiving withdrawable XMR—not another scaffold or release checklist.
+1
View File
@@ -15,6 +15,7 @@
"benchmark:market": "node --experimental-strip-types scripts/benchmark-market-reconciliation.ts",
"custody:db": "node --experimental-strip-types scripts/custody-db.ts",
"custody:backup": "node --experimental-strip-types scripts/custody-pair-backup.ts",
"monero:daemon-check": "node --experimental-strip-types scripts/monero-daemon-check.ts",
"node:backup": "node --experimental-strip-types scripts/node-backup.ts",
"ops:check": "node --experimental-strip-types scripts/ops-check.ts",
"miner": "tsx server/testnet/miner.ts",
+11
View File
@@ -0,0 +1,11 @@
import { loadMoneroDaemonProbeConfig, probeMoneroDaemon } from '../server/market/monero-daemon-probe.ts';
try {
const config = await loadMoneroDaemonProbeConfig();
const result = await probeMoneroDaemon(config);
console.log(JSON.stringify({ component: 'monero-daemon-probe', ready: true, ...result }));
} catch (error) {
console.error(JSON.stringify({ component: 'monero-daemon-probe', ready: false,
reason: error instanceof Error ? error.message : 'Probe failed' }));
process.exitCode = 1;
}
+146
View File
@@ -0,0 +1,146 @@
import { lstat, readFile } from 'node:fs/promises';
import { isIP } from 'node:net';
import { randomUUID } from 'node:crypto';
import { z } from 'zod';
const MAX_CONFIG_BYTES = 4096;
const MAX_RESPONSE_BYTES = 64 * 1024;
const RPC_TIMEOUT_MS = 5000;
const configSchema = z.object({
network: z.enum(['mainnet', 'stagenet']),
url: z.string().min(1).max(2048),
allowPrivateHttp: z.boolean(),
}).strict();
const infoSchema = z.object({
status: z.literal('OK'),
nettype: z.enum(['mainnet', 'stagenet', 'testnet']),
mainnet: z.boolean(), stagenet: z.boolean(), testnet: z.boolean(),
synchronized: z.boolean(), busy_syncing: z.boolean(), offline: z.boolean(), untrusted: z.boolean(),
height: z.number().int().nonnegative().safe(),
height_without_bootstrap: z.number().int().nonnegative().safe(),
target_height: z.number().int().nonnegative().safe(),
version: z.string().min(1).max(80),
bootstrap_daemon_address: z.string().max(512),
}).passthrough();
const envelopeSchema = z.object({ jsonrpc: z.literal('2.0'), id: z.string().uuid(),
result: z.record(z.string(), z.unknown()), error: z.never().optional() }).strict();
export type MoneroDaemonProbeConfig = z.infer<typeof configSchema>;
export type MoneroDaemonProbeTransport = (url: string, init: RequestInit) => Promise<Response>;
export type MoneroDaemonProbeResult = {
network: 'mainnet' | 'stagenet';
height: number;
version: string;
synchronized: true;
bootstrapProxyUsed: false;
checkedAt: string;
};
function isPrivateIp(host: string): boolean {
const version = isIP(host);
if (version === 4) {
const [a, b] = host.split('.').map(Number);
return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) || (a === 169 && b === 254);
}
if (version === 6) {
const normalized = host.toLowerCase();
return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8') ||
normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb');
}
return false;
}
function validateEndpoint(config: MoneroDaemonProbeConfig): URL {
let url: URL;
try { url = new URL(config.url); } catch { throw new Error('Invalid Monero daemon RPC endpoint'); }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash ||
url.pathname !== '/json_rpc' || !url.port) {
throw new Error('Monero daemon endpoint must be a bare /json_rpc URL with an explicit port');
}
const host = url.hostname.replace(/^\[|\]$/g, '');
if (!isPrivateIp(host)) throw new Error('Monero daemon endpoint must use a private literal IP address');
if (url.protocol === 'http:' && !config.allowPrivateHttp) {
throw new Error('Plain HTTP daemon RPC requires explicit private-network acknowledgement');
}
return url;
}
export async function loadMoneroDaemonProbeConfig(env: NodeJS.ProcessEnv = process.env): Promise<MoneroDaemonProbeConfig> {
const path = env.WIKIDECK_MONERO_DAEMON_CONFIG_FILE;
if (!path) throw new Error('Protected Monero daemon probe config file is required');
try {
const info = await lstat(path);
if (!info.isFile() || info.isSymbolicLink() || info.size < 1 || info.size > MAX_CONFIG_BYTES) throw new Error();
if (process.platform !== 'win32' && (info.mode & 0o077) !== 0) throw new Error();
const config = configSchema.parse(JSON.parse(await readFile(path, 'utf8')));
validateEndpoint(config);
return config;
} catch {
throw new Error('Invalid or inaccessible protected Monero daemon probe configuration');
}
}
async function readBounded(response: Response): Promise<string> {
if (!response.body) throw new Error('Monero daemon RPC response body is missing');
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const part = await reader.read();
if (part.done) break;
size += part.value.byteLength;
if (size > MAX_RESPONSE_BYTES) throw new Error('Monero daemon RPC response exceeds size limit');
chunks.push(part.value);
}
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
return Buffer.concat(chunks).toString('utf8');
}
/** Calls only monerod's read-only get_info method; no Wallet-RPC or transaction methods exist here. */
export async function probeMoneroDaemon(configInput: unknown, transport: MoneroDaemonProbeTransport = fetch,
now = Date.now()): Promise<MoneroDaemonProbeResult> {
const config = configSchema.parse(configInput);
const endpoint = validateEndpoint(config);
if (!Number.isSafeInteger(now) || now < 0) throw new Error('Invalid daemon probe time');
const id = randomUUID();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS);
timeout.unref();
try {
let response: Response;
try {
response = await transport(endpoint.href, { method: 'POST', redirect: 'error', signal: controller.signal,
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id, method: 'get_info' }) });
} catch {
throw new Error('Monero daemon RPC probe failed or timed out');
}
if (response.status !== 200 || response.redirected ||
!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '')) {
await response.body?.cancel();
throw new Error('Monero daemon RPC response rejected');
}
let envelope: z.infer<typeof envelopeSchema>;
try { envelope = envelopeSchema.parse(JSON.parse(await readBounded(response))); }
catch { throw new Error('Invalid Monero daemon RPC response'); }
if (envelope.id !== id) throw new Error('Monero daemon RPC response ID mismatch');
let info: z.infer<typeof infoSchema>;
try { info = infoSchema.parse(envelope.result); }
catch { throw new Error('Invalid Monero daemon RPC response'); }
if (info.nettype !== config.network || info.mainnet !== (config.network === 'mainnet') ||
info.stagenet !== (config.network === 'stagenet') || info.testnet ||
info.height < 1 || info.height_without_bootstrap !== info.height || info.untrusted ||
info.offline || !info.synchronized || info.busy_syncing || info.bootstrap_daemon_address !== '') {
throw new Error('Monero daemon network or synchronization state is not acceptable');
}
return { network: config.network, height: info.height, version: info.version,
synchronized: true, bootstrapProxyUsed: false, checkedAt: new Date(now).toISOString() };
} finally {
clearTimeout(timeout);
}
}
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadMoneroDaemonProbeConfig, probeMoneroDaemon, type MoneroDaemonProbeTransport } from '../server/market/monero-daemon-probe.ts';
const config = { network: 'mainnet' as const, url: 'http://10.1.2.3:18081/json_rpc', allowPrivateHttp: true };
function info(overrides: Record<string, unknown> = {}) {
return { status: 'OK', nettype: 'mainnet', mainnet: true, stagenet: false, testnet: false,
synchronized: true, busy_syncing: false, offline: false, untrusted: false,
height: 3_000_000, height_without_bootstrap: 3_000_000, target_height: 3_000_000,
version: '0.18.4.2-release', bootstrap_daemon_address: '', ...overrides };
}
function transportFor(result: Record<string, unknown>, options: { status?: number; type?: string; redirected?: boolean } = {}) {
let request: { method?: string; redirect?: RequestRedirect; headers?: HeadersInit; body?: string } | undefined;
const transport: MoneroDaemonProbeTransport = async (url, init) => {
assert.equal(url, config.url);
request = { method: init.method, redirect: init.redirect, headers: init.headers, body: init.body as string };
const body = JSON.parse(init.body as string) as { id: string };
return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result }), {
status: options.status ?? 200, headers: { 'content-type': options.type ?? 'application/json' },
...((options.redirected === undefined) ? {} : { statusText: options.redirected ? 'Moved' : 'OK' }),
});
};
return { transport, getRequest: () => request };
}
test('daemon probe calls only read-only get_info and accepts a synchronized local Mainnet chain', async () => {
const fixture = transportFor(info());
const result = await probeMoneroDaemon(config, fixture.transport, Date.UTC(2026, 9, 22));
assert.deepEqual(result, { network: 'mainnet', height: 3_000_000, version: '0.18.4.2-release',
synchronized: true, bootstrapProxyUsed: false, checkedAt: '2026-10-22T00:00:00.000Z' });
const request = fixture.getRequest()!;
assert.equal(request.method, 'POST');
assert.equal(request.redirect, 'error');
assert.equal(JSON.parse(request.body!).method, 'get_info');
assert.equal(new Headers(request.headers).has('authorization'), false);
});
test('daemon probe rejects network mismatch, syncing, offline, bootstrap and untrusted replies', async () => {
for (const override of [
{ nettype: 'stagenet', mainnet: false, stagenet: true },
{ synchronized: false }, { busy_syncing: true }, { offline: true }, { untrusted: true },
{ height_without_bootstrap: 2_999_999 }, { bootstrap_daemon_address: 'node.example:18081' },
{ testnet: true },
]) {
const fixture = transportFor(info(override));
await assert.rejects(probeMoneroDaemon(config, fixture.transport), /network or synchronization/);
}
});
test('daemon probe rejects malformed, oversized, redirected and non-JSON responses', async () => {
const badJson: MoneroDaemonProbeTransport = async (_url, init) => {
const body = JSON.parse(init.body as string) as { id: string };
return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { ...info(), height: 'bad' } }),
{ headers: { 'content-type': 'application/json' } });
};
await assert.rejects(probeMoneroDaemon(config, badJson), /Invalid Monero daemon RPC response/);
const tooLarge: MoneroDaemonProbeTransport = async () => new Response('x'.repeat(70 * 1024),
{ headers: { 'content-type': 'application/json' } });
await assert.rejects(probeMoneroDaemon(config, tooLarge), /Invalid Monero daemon RPC response/);
const redirected = transportFor(info(), { status: 302 });
await assert.rejects(probeMoneroDaemon(config, redirected.transport), /response rejected/);
const wrongType = transportFor(info(), { type: 'text/html' });
await assert.rejects(probeMoneroDaemon(config, wrongType.transport), /response rejected/);
const mismatchedId: MoneroDaemonProbeTransport = async () => new Response(JSON.stringify({
jsonrpc: '2.0', id: '00000000-0000-4000-8000-000000000000', result: info(),
}), { headers: { 'content-type': 'application/json' } });
await assert.rejects(probeMoneroDaemon(config, mismatchedId), /response ID mismatch/);
});
test('daemon probe requires explicit private literal endpoint and HTTP boundary acknowledgement', async () => {
await assert.rejects(probeMoneroDaemon({ ...config, allowPrivateHttp: false }, async () => new Response()), /explicit private-network acknowledgement/);
await assert.rejects(probeMoneroDaemon({ ...config, url: 'http://daemon.example:18081/json_rpc' }, async () => new Response()), /private literal IP/);
await assert.rejects(probeMoneroDaemon({ ...config, url: 'http://8.8.8.8:18081/json_rpc' }, async () => new Response()), /private literal IP/);
await assert.rejects(probeMoneroDaemon({ ...config, url: 'https://user:password@10.1.2.3:18081/json_rpc' }, async () => new Response()), /bare \/json_rpc/);
await assert.rejects(probeMoneroDaemon({ ...config, url: 'http://10.1.2.3:18081/' }, async () => new Response()), /bare \/json_rpc/);
});
test('daemon probe config loader requires a protected file and rejects unsafe configuration', async () => {
await assert.rejects(loadMoneroDaemonProbeConfig({}), /config file is required/);
const directory = await mkdtemp(join(tmpdir(), 'wikideck-monero-daemon-'));
const path = join(directory, 'daemon.json');
try {
await writeFile(path, JSON.stringify(config), { mode: 0o600 });
if (process.platform !== 'win32') await chmod(path, 0o600);
const loaded = await loadMoneroDaemonProbeConfig({ WIKIDECK_MONERO_DAEMON_CONFIG_FILE: path });
assert.deepEqual(loaded, config);
await writeFile(path, JSON.stringify({ ...config, url: 'http://daemon.example:18081/json_rpc' }), { mode: 0o600 });
await assert.rejects(loadMoneroDaemonProbeConfig({ WIKIDECK_MONERO_DAEMON_CONFIG_FILE: path }), /Invalid or inaccessible/);
} finally { await rm(directory, { recursive: true, force: true }); }
});
+4
View File
@@ -15,6 +15,10 @@ test('private pilot Compose stack is loopback-bound, isolated, and mock-only', a
assert.match(compose, /pg-runtime:\/secrets\/runtime:ro/);
assert.doesNotMatch(compose, /POSTGRES_PASSWORD:\s|5432:5432/i);
assert.doesNotMatch(compose, /^\s+(?:monero|wallet-rpc|observer):/im);
assert.match(compose, /daemon-check:\n\s+image: wikideck:private-pilot/);
assert.match(compose, /profiles: \["monero-check"\]/);
assert.match(compose, /scripts\/monero-daemon-check\.ts/);
assert.match(compose, /target: \/run\/monero\/daemon\.json\n\s+read_only: true/);
const database = compose.split(' database:')[1].split(' migrate:')[0];
assert.doesNotMatch(database, /ports:/);
const app = compose.split(' wikideck:')[1].split('networks:\n financial:')[0];