diff --git a/Dockerfile b/Dockerfile index 3de2a22..007243c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,8 @@ COPY package.json ./ COPY --from=build /app/dist ./dist COPY server ./server COPY shared ./shared +COPY scripts/node-backup.ts ./scripts/node-backup.ts +COPY scripts/ops-check.ts ./scripts/ops-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 diff --git a/README.md b/README.md index a6ac961..52bc850 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ This mines three real blocks using disposable identities. It changes the testnet ## Network and safety limits +[Deployment and recovery preparation](docs/operations-recovery.md) includes explicit trusted-proxy configuration, opt-in loopback-only diagnostics and `ops:check`, plus stopped-node paired `node:backup` create/verify/new-directory restore tooling. Backups contain sensitive market data and require external encryption/access control. Tools do not configure TLS/alerts or authorize production launch/restore. + [Stage-1 node resource controls](docs/node-resources.md) bound global RPC admission, response bytes and canonical page sizes. The UI reuses paged history; legacy full-state responses stop beyond 128 blocks. A consent-gated loopback-only smoke-load script is available as `test:rpc-load`. This does not resolve long-chain/DAG storage limits or establish production capacity. Nodes use only explicitly configured peers; there is no discovery service or public bootstrap network. The peer, storage, and HTTP configuration is documented in [docs/testnet-consensus.md](docs/testnet-consensus.md). The implementation is experimental: it has no consensus transaction fees or established economic security (market fees are off-chain simulations), and remains vulnerable to resource exhaustion, isolation/eclipsing, censorship, majority-hashpower attacks, and reorganizations. The ledger now uses transactional SQLite/WAL storage and sparse per-block NFT-state deltas, but nodes still retain DAG block records in memory, rebuild state roots over the full NFT set, and revalidate all proofs at startup. It is not yet an archival-scale production design. HTTPS, container hardening, and passing tests do not constitute a security audit. diff --git a/compose.mainnet.yaml b/compose.mainnet.yaml index c526ee6..6455582 100755 --- a/compose.mainnet.yaml +++ b/compose.mainnet.yaml @@ -18,6 +18,9 @@ services: WIKIDECK_MAINNET_LAUNCH: "${WIKIDECK_MAINNET_LAUNCH:-}" WIKIDECK_MAINNET_REVIEW_ACK: "${WIKIDECK_MAINNET_REVIEW_ACK:-}" WIKIDECK_ALLOW_PRIVATE_PEERS: "0" + # Empty by default; set only the actual edge-to-container source IP/CIDR after topology review. + WIKIDECK_TRUSTED_PROXIES: "${WIKIDECK_TRUSTED_PROXIES:-}" + # Diagnostics are deliberately disabled here. Never publish an operations port. WIKIDECK_MARKET_FEE_BPS: "${WIKIDECK_MARKET_FEE_BPS:-250}" WIKIDECK_MARKET_CONFIRMATIONS: "${WIKIDECK_MARKET_CONFIRMATIONS:-10}" volumes: diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100755 index 0000000..e8b601f --- /dev/null +++ b/deploy/nginx.conf.example @@ -0,0 +1,41 @@ +# Example for nginx on the SAME host as loopback-bound node :3201. +# Not installed by this repository. Replace host/cert paths and validate real topology. +# Node: WIKIDECK_TRUSTED_PROXIES=127.0.0.1,::1 (never use this for a container bridge without adjustment). +limit_req_zone $binary_remote_addr zone=wikideck_ip:10m rate=5r/s; +limit_conn_zone $binary_remote_addr zone=wikideck_conn:10m; + +server { + listen 80; + server_name wikideck.example.invalid; + return 308 https://$host$request_uri; +} +server { + listen 443 ssl; + server_name wikideck.example.invalid; + ssl_certificate /etc/ssl/wikideck/fullchain.pem; + ssl_certificate_key /etc/ssl/wikideck/private.key; + ssl_protocols TLSv1.2 TLSv1.3; + server_tokens off; + client_max_body_size 68k; + client_body_timeout 15s; + client_header_timeout 10s; + keepalive_timeout 10s; + send_timeout 30s; + limit_conn wikideck_conn 16; + limit_req_status 429; + # Do not proxy private operations listener. Its port must never be publicly published. + location / { + limit_req zone=wikideck_ip burst=20 nodelay; + proxy_pass http://127.0.0.1:3201; + proxy_http_version 1.1; + proxy_set_header Host $host; + # Overwrite, do NOT append an untrusted client-supplied X-Forwarded-For chain. + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Forwarded ""; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_send_timeout 15s; + proxy_read_timeout 30s; + } +} \ No newline at end of file diff --git a/docs/consensus-calibration.md b/docs/consensus-calibration.md index 3d1d447..805dd76 100755 --- a/docs/consensus-calibration.md +++ b/docs/consensus-calibration.md @@ -59,4 +59,4 @@ Before production calibration can be approved: - Freeze reviewed rules and actual genesis date with a new identity where required. Publish the manifest/artifact hashes and obtain independent review and explicit operator sign-off. Do not patch an active chain's rules in place. - Decide payment holds/reserves/disputes independently; ten confirmations cannot be sold as absolute finality on an unbootstrapped PoW chain. Real payments remain blocked. -See [security review](security-review.md), [launch evidence register](operator-launch.md) and the read-only preflight. The calibration blocker remains open. \ No newline at end of file +See [security review](security-review.md), [launch evidence register](operator-launch.md) and the read-only preflight. The calibration blocker remains open. diff --git a/docs/operations-recovery.md b/docs/operations-recovery.md new file mode 100755 index 0000000..b578d17 --- /dev/null +++ b/docs/operations-recovery.md @@ -0,0 +1,70 @@ +# Deployment, private monitoring and recovery — stage 4 + +**Production remains NO-GO.** This stage provides tools and a local recovery rehearsal, not an installed public edge, configured alerts or independent operational sign-off. No mainnet approval, node launch, real payment, production backup/restore or existing data modification was performed. Tools are available on Node 24 and included in the runtime image where noted. + +## Explicit reverse-proxy trust + +`WIKIDECK_TRUSTED_PROXIES` is empty/untrusted by default. It accepts up to 16 comma-separated **literal IPs or CIDRs** (IPv4 prefix 8–32; IPv6 32–128). It rejects booleans, hop counts, names and universal /0 ranges. Set only the actual source address/subnet of the trusted edge connection. A large permitted subnet can still be unsafe: do not include unrelated tenants/containers. The candidate Compose file forwards the setting but leaves it empty. + +The [nginx example](../deploy/nginx.conf.example) is **not installed or deployment-validated**. It illustrates TLS, request/body/connection/time limits and overwriting `X-Forwarded-For` with the actual remote client address rather than appending attacker input. Replace the reserved invalid hostname and certificate paths; review TLS/HSTS/ciphers, renewals, compression, static caching and per-IP/global admission for your edge. Validate nginx configuration on the intended version with real certificates before use. No actual TLS/certificate test was performed here. + +**Topology matters:** nginx on the host forwarding to a container's published loopback port may appear inside the container as a Docker gateway, not `127.0.0.1`. Determine the source and use a reviewed dedicated network; do not blindly copy a loopback trust list. Firewall direct backend access. Anyone who can connect from a trusted source can forge forwarded IPs unless the edge overwrites headers and isolates the backend. Application trust settings do not configure the firewall. Existing global RPC budgets remain independent of forwarded client IPs. + +## Private diagnostics + +Set `WIKIDECK_OPS_PORT` to an unused integer port (1–65535) to enable a **separate HTTP listener bound to `127.0.0.1` only**. Unset/empty disables it. Do not reuse the main RPC port. The default/candidate Compose definitions do not enable or publish this port. For container monitoring, execute the checker inside that container/network namespace; publishing a container port does not make a loopback listener safely accessible. + +Only `GET /status` is supported. Responses are no-store JSON capped at 64 KiB; connection count/timeouts are bounded. No password/token is required because access is confined to the local-process trust boundary. Therefore **all users/processes with loopback access can read it**; it is not suitable for an untrusted multi-user host without additional isolation. Never reverse proxy, tunnel broadly, add it to public routing, or publish it to the Internet. The listener has no administrative/mutation operations. + +Reported aggregates: + +- Node uptime and Node-process RSS; **RSS excludes the RandomX subprocess**, so monitor container/cgroup/host totals externally. +- Event-loop mean/max since listener startup (not rolling percentiles). +- Canonical height, DAG block count, pending transactions, ledger mutation queue, process-local reorg count, tip timestamp/age. Tip age is miner-timestamp-based, not proof of local receipt freshness or synchronization. +- Native subprocess failure/active/queue signals. `available` means no recorded failure/exit; it is **not** a startup known-vector test or proof of successful current hashing. +- Configured/connected peer counts, API/P2P active/rejected counts. Peer count is connectivity, not independently administered hashpower or consensus security. Counters reset on restart. +- Market mutation queue, last successful reconciliation timestamp and counts grouped by order state. No addresses, order IDs, balances or signed transactions are included. Diagnostics still reveal operational activity and must remain private. +- Disk bytes available and ledger/market DB/WAL sizes, sampled asynchronously every 30 seconds. Missing sample/failure is explicit; absence of `disk` is not healthy. First startup may precede the first successful sample. Sampling does not impose a quota or prevent exhaustion. + +`ops:check -- --port ` reads only literal local loopback with a five-second deadline and 64 KiB streaming cap, emitting JSON plus a nonzero exit for warning conditions. No remote target or credentials are accepted. Built-in **starting-point** warning thresholds: observation clock/freshness >60s; no peers; empty/stale chain (>1h by tip timestamp); native unavailable; queue pressure (native/market >=48 or ledger >=192); mean event-loop delay >250ms; stale market reconciliation >60s; `review`/`refund-required` orders; disk sample >90s old/unavailable or <512 MiB free. A fresh empty isolated testnet correctly warns. These thresholds are not production SLOs and must be reviewed against real workload/chain timing. + +Run the checker with your restricted local scheduler/monitoring agent and route warning codes to the **actual on-call system**. This repository does not create an alert account, install a scheduler or verify delivery. Externally monitor cumulative counter changes (reorgs/rejections), process/container totals, disk growth rate, peer/tip freshness, native failures and checker availability itself. A failed/unreachable operations listener must alert, not silently pass. Keep `/healthz` as liveness only; HTTP 200 is not launch/readiness evidence. + +## Stopped-node pair backup + +The `node:backup` script supports `create`, `verify`, `restore`. It deliberately does **not** offer online pair backup or overwrite/force/reset. Choose `WIKIDECK_NETWORK` explicitly to match the data; it validates the derived chain/network identity without enabling launch. The runtime includes the script and server helpers; it does not require dev dependencies. + +### Create + +1. Stop the node cleanly; verify all processes/writers using that data are stopped. Do not run `Ledger`/`Market` library writers directly while maintenance is active. Do not rely solely on a lock file for an arbitrary unmanaged process. +2. Invoke `node:backup` with action `create`, explicit `--source `, **new** `--destination ` and `--consent`. Source is the parent of the selected testnet/mainnet folder, not the individual SQLite file. Destination must be outside that data directory and must not exist. Its parent must already exist. +3. The tool acquires **the same exclusive network node lock** as normal startup. Any lock, even stale, refuses maintenance. The tool never deletes a preexisting lock. Inspect stale ownership/process status manually before separately authorized removal. +4. Validate SQLite quick checks and network/market metadata, then use SQLite `VACUUM INTO` for **both ledger and market** to produce standalone snapshot files (including committed source WAL contents, not a naive live-file copy). A symlinked network directory or database file is refused. Pair consistency relies on all writers staying stopped for the entire operation. +5. Database copies are checked, hashed with SHA-256 and flushed. A strict versioned manifest records names/sizes/hashes/network/profile/chain and creation time. The manifest completion marker is written/flushed **last**. POSIX directory entries are flushed; Windows/remote filesystem and storage-controller power-loss behavior requires real environment validation. Partial output is deliberately retained for investigation, not automatically trusted or deleted. + +Only the two databases and manifest are included: **not** browser wallets, backup passwords, deployment configuration, secrets, certificates, peer settings, native binaries or logs. Keep each category independently backed up with versioned recovery instructions. Backups contain sensitive marketplace participant/order information and are **not encrypted by this tool**. Store in restricted, encrypted, access-audited off-host storage with tested retention; do not put them in Git/build contexts. Unix modes are restrictive where supported; Windows ACLs and network-share permissions must be enforced by the operator. + +### Verify + +Action `verify` needs `--source ` only. It reads a bounded strict manifest, requires exactly one ledger and one market filename, checks regular files/sizes/hashes and refuses unexpected SQLite WAL/SHM/journal sidecars, then checks SQLite and metadata. It does not launch or initialize a new node. Verification opens SQLite read-only, but OS access timestamps and filesystem-specific read behavior are not a forensic immutability guarantee. + +**Hashes detect corruption, not authenticity:** an attacker able to replace both manifest and files can recompute hashes. Sign/protect the backup package externally, keep trusted manifests off-host and restrict directory ownership. No wallet-control or signature proof is inferred from a checksum. This tool assumes trusted local filesystem administration; it does not defend against a privileged concurrent file-replacement attacker or hostile mounted storage. + +### Restore to a new isolated directory + +1. Action `restore` requires explicit `--source `, a **nonexistent** `--destination `, and `--consent`. Existing destinations are refused even when empty. There is no in-place reset or deletion. +2. Verify the package before creating output, copy only fixed database names exclusively, recheck hashes/SQLite metadata and flush. No stale lock, user secrets or deployment approval variables are restored. A partial failed restore is retained; do not try to start it or merge it into a working node. +3. The tool reports `proofsRevalidated: false`: database/hash verification is not chain verification. Reopen/replay with the trusted reference RandomX release in an **isolated reviewed rehearsal** before declaring it usable. Preserve market instance identity, orders, fees, payout markers and nonce receipts together. Compare the expected tip/work/ownership/order state against the trusted recovery point. +4. Reconcile later chain events, payment state and any external processors before any future resumption of valuable activity. Restoring stale databases does not roll back on-chain transfers or real payouts. Live payments remain disabled and have no reviewed disaster recovery process in this release. + +An operator must choose recovery point/retention/RPO/RTO, practice off-host recovery and approve any production restore explicitly. Never remove a lock or replace a running volume just to make a recovery test pass. + +## Evidence and remaining hold points + +Automated tests cover default proxy distrust/spoofed-header rejection, explicit loopback proxy recognition, invalid trust/port settings, private listener and threshold checks, paired backup/restore identity preservation, refusal to overwrite/ignore locks/back up within data, and rejection of tampering, wrong network, incomplete packages and unexpected sidecars. + +The native HTTP integration now cleanly stops its disposable nodes, backs up their **real three-block RandomX chain and market journal**, restores to a fresh disposable directory, opens/revalidates copied proofs with the actual reference engine, and checks canonical ownership, market instance, order buyer and nonce receipt history. It then restarts the original isolated fixture. This is a local automated drill, not an off-host operational recovery or crash/power-loss certification. + +Final Node 24 TypeScript/Vite and production runtime builds passed. The suite passed **98 tests, 0 failed, 0 skipped**. A separate packaged-runtime smoke drill ran with no external network, read-only root, dropped capabilities and a disposable 64 MiB tmpfs: monitoring correctly returned nonzero for isolation, empty chain, not-yet-reconciled market and low disk; clean stopped-node backup, verification and new-directory restore each exited zero. That empty-chain CLI drill reported proof revalidation as false, as designed; nonempty real-proof replay is covered by the integration drill above. No host volume or production wallet was used. + +SEC-07/SEC-09 are only partially addressed: actual edge/source-IP/TLS checks, a production monitoring agent and verified alert delivery, off-host backup storage, long-history restore timing, filesystem/power-loss testing, independent peer/miner topology and named operators remain unverified. See [operator launch register](operator-launch.md) and [security review](security-review.md). No startup or payment approval is granted by these tools. \ No newline at end of file diff --git a/docs/operator-launch.md b/docs/operator-launch.md index 1d6f37a..f9065d6 100755 --- a/docs/operator-launch.md +++ b/docs/operator-launch.md @@ -23,6 +23,8 @@ The runtime image can execute the entry point with `--launch-preflight` as well; ## 2. Evidence register — all required before production sign-off +[Stage-4 deployment and recovery tooling](operations-recovery.md) adds explicit proxy trust, private loopback diagnostics/checks and stopped-node pair backup/verification/non-overwriting restore. A local native proof-replay restore drill is automated, but actual-host TLS/proxy verification, off-host recovery, alert delivery and operational ownership still require external evidence. + [Stage-3 consensus calibration evidence](consensus-calibration.md) contains a bounded local native measurement and deterministic timestamp/retarget scenarios. It is not the missing representative network-hashpower or independent consensus review evidence. Do not populate launch approvals from its illustrative targets. For each item record **owner, artifact/release ID, date, evidence location, reviewer and disposition**. Empty means unverified, not passed. diff --git a/docs/security-review.md b/docs/security-review.md index 114a147..4a73dbc 100755 --- a/docs/security-review.md +++ b/docs/security-review.md @@ -12,6 +12,8 @@ Scope: current consensus/ledger/P2P/RandomX integration, HTTP entry point, brows ## Findings that block production +**Stage-4 follow-up:** [Operations/recovery tooling](operations-recovery.md) provides explicit trusted proxy IP/CIDRs, opt-in separate loopback diagnostics and aggregate monitoring checks, a proxy example (not installed), stopped-node SQLite pair snapshots/verification and new-directory restore. Native integration replays restored reference proofs and preserves marketplace identity/orders/receipts. SEC-07 and SEC-09 remain open pending intended-host deployment, off-host recovery and real alert/on-call evidence. + **Stage-3 follow-up:** [Consensus calibration tools and evidence](consensus-calibration.md) add an offline native benchmark and deterministic actual-helper scenarios plus validator timestamp regressions. Initial target is 16 expected hashes; a short quota-limited sample is not a network calibration. SEC-02 remains open pending representative hashpower, adversarial/multi-host evidence and independent review; no consensus/genesis change was made. | ID | Priority / scope | Evidence and impact | Required disposition | diff --git a/package.json b/package.json index f0fcd8c..5aa738a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "test:rpc-load": "node --experimental-strip-types scripts/rpc-load.ts", "analyze:consensus": "node --experimental-strip-types scripts/consensus-analysis.ts", "benchmark:randomx": "node --experimental-strip-types scripts/benchmark-randomx.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", "native:configure": "cmake -S native -B native/build -DCMAKE_BUILD_TYPE=Release -DWIKIDECK_RANDOMX_TESTS=ON", "native:build": "cmake --build native/build --config Release --parallel 2", diff --git a/scripts/benchmark-randomx.ts b/scripts/benchmark-randomx.ts index 75a2d0a..5b8f17d 100755 --- a/scripts/benchmark-randomx.ts +++ b/scripts/benchmark-randomx.ts @@ -45,4 +45,4 @@ try { assumedNetworkHashesPerSecond:networkHps,illustrativeTargetForAssumedNetwork:targetForHashrate(networkHps), note:'Local single-worker IPC-inclusive warm hash benchmark, not a network/miner-throughput estimate. Cold includes initialization; epoch switch is one cache change. Network H/s is an explicit assumption, not extrapolated from this machine.', consensusChanged:false,productionReady:false},null,2)); -} finally {clearTimeout(deadline);engine.close();process.off('SIGINT',stop);process.off('SIGTERM',stop);} \ No newline at end of file +} finally {clearTimeout(deadline);engine.close();process.off('SIGINT',stop);process.off('SIGTERM',stop);} diff --git a/scripts/consensus-analysis.ts b/scripts/consensus-analysis.ts index f7d90b0..2e9abe1 100755 --- a/scripts/consensus-analysis.ts +++ b/scripts/consensus-analysis.ts @@ -1,4 +1,4 @@ import { calibrationReport } from '../server/analysis/calibration.ts'; if (process.argv.slice(2).length) throw new Error('No options accepted. This deterministic analysis never selects launch parameters.'); -console.log(JSON.stringify(calibrationReport(),null,2)); \ No newline at end of file +console.log(JSON.stringify(calibrationReport(),null,2)); diff --git a/scripts/node-backup.ts b/scripts/node-backup.ts new file mode 100755 index 0000000..a196c2f --- /dev/null +++ b/scripts/node-backup.ts @@ -0,0 +1,12 @@ +import { createBackup, verifyBackup, restoreBackup } from '../server/recovery.ts'; + +const [action,...args]=process.argv.slice(2); +if(!['create','verify','restore'].includes(action))throw new Error('Action must be create, verify or restore'); +const allowed=new Set(['--source','--destination','--consent']); +for(let i=0;i{const i=args.indexOf(key);return i<0?undefined:args[i+1];}; +const source=arg('--source'),destination=arg('--destination'); +if(!source||source.startsWith('--'))throw new Error('Explicit --source required'); +if(action!=='verify'&&(!args.includes('--consent')||!destination||destination.startsWith('--')))throw new Error('Explicit --consent and new --destination required; stop the node first'); +const result=action==='create'?await createBackup(source,destination!):action==='restore'?await restoreBackup(source,destination!):await verifyBackup(source); +console.log(JSON.stringify(result,null,2)); \ No newline at end of file diff --git a/scripts/ops-check.ts b/scripts/ops-check.ts new file mode 100755 index 0000000..14d12ab --- /dev/null +++ b/scripts/ops-check.ts @@ -0,0 +1,11 @@ +import { checkOperations } from '../server/operations-check.ts'; +const args=process.argv.slice(2); +if(args.length!==2||args[0]!=='--port'||!/^\d{1,5}$/.test(args[1])||Number(args[1])<1||Number(args[1])>65535)throw new Error('Explicit --port 1..65535 required; loopback diagnostics only.'); +const response=await fetch(`http://127.0.0.1:${Number(args[1])}/status`,{redirect:'error',signal:AbortSignal.timeout(5000)}); +if(!response.ok||!response.body||!/^application\/json(?:;|$)/i.test(response.headers.get('content-type')??''))throw new Error('Local diagnostics unavailable'); +const reader=response.body.getReader(),chunks:Uint8Array[]=[];let bytes=0; +try{for(;;){const part=await reader.read();if(part.done)break;bytes+=part.value.byteLength;if(bytes>65536)throw new Error('Diagnostics too large');chunks.push(part.value);}} +finally{await reader.cancel();reader.releaseLock();} +const joined=new Uint8Array(bytes);let offset=0;for(const chunk of chunks){joined.set(chunk,offset);offset+=chunk.length;} +const report=checkOperations(JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(joined))); +console.log(JSON.stringify(report,null,2));if(!report.healthy)process.exitCode=1; \ No newline at end of file diff --git a/server/analysis/calibration.ts b/server/analysis/calibration.ts index 2229e53..cec7209 100755 --- a/server/analysis/calibration.ts +++ b/server/analysis/calibration.ts @@ -63,4 +63,4 @@ export function calibrationReport() { knownDeficitCatchup:[.1,.25,.4,.49].map(share=>({attackerShare:share,probability:idealCatchup(share,10),currentDeficit:10})), candidateTargets:[1,10,100,1000,10000].map(hps=>({assumedNetworkHashesPerSecond:hps,target:targetForHashrate(hps)})), decision:'NO-GO: measurement, independent analysis, multi-host rehearsal and explicit operator approval still required.' }; -} \ No newline at end of file +} diff --git a/server/market/store.ts b/server/market/store.ts index e265bb1..d457a8a 100755 --- a/server/market/store.ts +++ b/server/market/store.ts @@ -73,6 +73,10 @@ export class Market { } catch (error) { db.close(); throw error; } } close() { this.db.close(); } + diagnostics() { + const states=this.db.prepare("SELECT json_extract(data,'$.status') AS status,COUNT(*) AS count FROM orders GROUP BY json_extract(data,'$.status')").all(); + return { queued:this.queued,lastReconciledAt:this.reconciled?.at??null,orderStates:states }; + } info(): MarketInfo { return { network: NETWORK, chainId: CHAIN_ID, marketId: this.id, paymentMode: 'mock', feeBps: this.config.feeBps, additionalFee: { ...ADDITIONAL_MARKET_FEE }, diff --git a/server/operations-check.ts b/server/operations-check.ts new file mode 100755 index 0000000..f565c6c --- /dev/null +++ b/server/operations-check.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +const nonnegative=z.number().finite().nonnegative(); +const schema=z.object({schema:z.literal('wikideck-operations-v1'),observedAt:z.string().datetime(), + native:z.object({available:z.boolean(),queued:nonnegative}), + ledger:z.object({height:nonnegative,mutationQueue:nonnegative,tipAgeSeconds:nonnegative.nullable()}), + peers:z.object({configured:nonnegative,connected:nonnegative}), + eventLoop:z.object({meanMs:nonnegative}),disk:z.unknown(),diskSampledAt:nonnegative.nullable(), + market:z.object({queued:nonnegative,lastReconciledAt:nonnegative.nullable(), + orderStates:z.array(z.object({status:z.string(),count:nonnegative})).max(20)})}); +export function checkOperations(raw:unknown,now=Date.now()){ + const s=schema.parse(raw),warnings:string[]=[]; + const observation=Date.parse(s.observedAt); + if(Math.abs(now-observation)>60000)warnings.push('diagnostics-clock-or-freshness'); + if(!s.native.available)warnings.push('native-engine-unavailable'); + if(s.native.queued>=48||s.ledger.mutationQueue>=192||s.market.queued>=48)warnings.push('queue-pressure'); + if(!s.peers.configured||!s.peers.connected)warnings.push('peer-isolation'); + if(s.ledger.height===0||s.ledger.tipAgeSeconds===null||s.ledger.tipAgeSeconds>3600)warnings.push('empty-or-stale-chain'); + if(s.eventLoop.meanMs>250)warnings.push('event-loop-delay'); + if(!s.market.lastReconciledAt||now-s.market.lastReconciledAt>60000)warnings.push('market-reconciliation-stale'); + if(s.market.orderStates.some(r=>(r.status==='review'||r.status==='refund-required')&&r.count>0))warnings.push('market-operator-review'); + const disk=z.object({availableBytes:nonnegative}).safeParse(s.disk); + if(!disk.success||!s.diskSampledAt||now-s.diskSampledAt>90000)warnings.push('disk-sample-unavailable-or-stale'); + else if(disk.data.availableBytes<512*1024*1024)warnings.push('disk-low'); + return {healthy:warnings.length===0,warnings, + note:'Local threshold hints only, not consensus readiness or launch approval. Reorg/rejection counters and native RSS need external time-series monitoring.'}; +} \ No newline at end of file diff --git a/server/operations.ts b/server/operations.ts new file mode 100755 index 0000000..bc4a07c --- /dev/null +++ b/server/operations.ts @@ -0,0 +1,63 @@ +import { createServer } from 'node:http'; +import { isIP } from 'node:net'; +import { monitorEventLoopDelay } from 'node:perf_hooks'; +import { stat,statfs } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { AddressInfo } from 'node:net'; + +/** No hop-count/boolean trust. Match only explicit proxy addresses/subnets. The edge must + * overwrite forwarded headers and prevent direct backend access from trusted addresses. */ +export function trustedProxies(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env.WIKIDECK_TRUSTED_PROXIES; + if (!raw) return []; + const values=raw.split(',').map(s=>s.trim()); + if(values.length>16)throw new Error('At most 16 trusted proxy addresses/subnets'); + for(const value of values){ + const [ip,prefix,...extra]=value.split('/'),family=isIP(ip); + if(!family||extra.length||(prefix!==undefined&&(!/^\d{1,3}$/.test(prefix)||Number(prefix)<(family===4?8:32)||Number(prefix)>(family===4?32:128)))) + throw new Error('Trusted proxies must be literal IPs or bounded CIDRs, never booleans, names or hop counts'); + } + return [...new Set(values)]; +} +export function operationsPort(env: NodeJS.ProcessEnv = process.env): number | undefined { + if(env.WIKIDECK_OPS_PORT===undefined||env.WIKIDECK_OPS_PORT==='')return undefined; + const n=Number(env.WIKIDECK_OPS_PORT); + if(!/^\d+$/.test(env.WIKIDECK_OPS_PORT)||!Number.isInteger(n)||n<1||n>65535)throw new Error('Invalid WIKIDECK_OPS_PORT'); + return n; +} +/** Separate loopback listener, disabled by default. No bearer tokens or details on public RPC. + * Local users/processes share this trust boundary; never forward/publish this port. */ +export function startOperations(port: number | undefined, snapshot: ()=>unknown, directory?:string) { + if(port===undefined)return {close() {}}; + const delay=monitorEventLoopDelay({resolution:20});delay.enable(); + let disk:unknown=null,lastDiskSample=0; + const sampleDisk=async()=>{ + if(!directory)return; + try { + const fs=await statfs(directory); + const sizes=await Promise.all(['ledger.sqlite','ledger.sqlite-wal','market.sqlite','market.sqlite-wal'].map(async name=>{ + try{return {name,bytes:(await stat(join(directory,name))).size};} + catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return {name,bytes:0};throw error;} + })); + disk={availableBytes:fs.bavail*fs.bsize,files:sizes};lastDiskSample=Date.now(); + }catch{disk={available:false,error:'Disk sample failed; inspect local filesystem and permissions.'};lastDiskSample=Date.now();} + }; + void sampleDisk();const diskTimer=setInterval(()=>{void sampleDisk();},30000);diskTimer.unref(); + const server=createServer((req,res)=>{ + res.setHeader('Cache-Control','no-store');res.setHeader('Content-Type','application/json'); + if(req.method!=='GET'||req.url!=='/status'){res.writeHead(404).end('{"error":"Not found"}');return;} + try { + const data=JSON.stringify({schema:'wikideck-operations-v1',observedAt:new Date().toISOString(), + uptimeSeconds:process.uptime(),nodeRssBytes:process.memoryUsage().rss, + eventLoop:{meanMs:Number.isFinite(delay.mean)?delay.mean/1e6:0,maxMs:delay.max/1e6}, + disk,diskSampledAt:lastDiskSample||null,...snapshot() as object}); + if(Buffer.byteLength(data)>64*1024)throw new Error('Diagnostics size'); + res.end(data); + }catch{res.writeHead(503).end('{"error":"Diagnostics unavailable"}');} + }); + server.maxConnections=4;server.headersTimeout=5000;server.requestTimeout=5000;server.keepAliveTimeout=1000; + server.setTimeout(5000,socket=>socket.destroy()); + server.on('error',()=>{clearInterval(diskTimer);delay.disable();console.error('Private operations listener failed; check local monitoring configuration.');}); + server.listen(port,'127.0.0.1'); + return {close(){clearInterval(diskTimer);delay.disable();server.closeAllConnections();server.close();},address:()=>server.address() as AddressInfo|null}; +} \ No newline at end of file diff --git a/server/preflight.ts b/server/preflight.ts index b44b7f5..b74439a 100755 --- a/server/preflight.ts +++ b/server/preflight.ts @@ -29,7 +29,7 @@ export function launchPreflight(env: NodeJS.ProcessEnv = process.env, now = Date { id: 'wallet-key-protection', status: 'blocked', detail: 'Stage-2 encrypted vault storage, explicit migration/unlock and lock-aware signing exist. Legacy plaintext copies, unlocked-page compromise, migration/lifecycle coverage and independent production wallet/recovery review remain unresolved.' }, { id: 'live-market-settlement', status: 'blocked', detail: 'No live SHKeeper adapter or reviewed payout/refund/reorg system exists. Mock payment cannot constitute real payment for valuable cards.' }, { id: 'release-security-review', status: 'unverified', detail: 'Independent consensus/application review and release-specific dependency/native/container scans require external evidence; an operator environment flag is not evidence.' }, - { id: 'deployment-rehearsal', status: 'unverified', detail: 'Verify real HTTPS endpoints, proxy/IP limits, independent peers and miners, alerts, incident response and backup/restore on the intended hosts.' }, + { id: 'deployment-rehearsal', status: 'unverified', detail: 'Stage-4 explicit proxy trust, private diagnostics/checker and stopped-node paired backup/restore with local native replay tests exist. Actual HTTPS/proxy topology, independent hosts/miners, alert delivery, off-host recovery and on-call procedures remain unverified.' }, { id: 'fee-recipient-control', status: 'unverified', detail: 'Confirm the fixed public XMR fee address, checksum and recipient control out of band before any future live payout. No keys or proof are requested here.' }, ); return { ...launchManifest(), assessedAt: new Date(now).toISOString(), productionReady: false, diff --git a/server/recovery.ts b/server/recovery.ts new file mode 100755 index 0000000..605a88a --- /dev/null +++ b/server/recovery.ts @@ -0,0 +1,104 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { mkdir, open, unlink, lstat, readFile, writeFile, chmod, copyFile, realpath } from 'node:fs/promises'; +import { join, resolve, relative, isAbsolute } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { z } from 'zod'; +import { NETWORK, PROFILE } from '../shared/network.ts'; +import { CHAIN_ID } from './testnet/consensus.ts'; + +const files=['ledger.sqlite','market.sqlite'] as const; +const manifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(1), + network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID),profile:z.literal(PROFILE),createdAt:z.string().datetime(), + files:z.array(z.object({name:z.enum(files),bytes:z.number().int().positive().max(64*1024**3),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()).length(2)}).strict(); +async function regular(path:string){const s=await lstat(path);if(!s.isFile()||s.isSymbolicLink())throw new Error('Expected regular non-symlink database file');return s;} +async function digest(path:string){const hash=createHash('sha256');for await(const chunk of createReadStream(path))hash.update(chunk);return hash.digest('hex');} +async function flush(path:string){const handle=await open(path,'r+');try{await handle.sync();}finally{await handle.close();}} +async function flushDirectory(path:string){ + if(process.platform==='win32')return; // Windows directory durability requires filesystem-specific operational validation. + const handle=await open(path,'r');try{await handle.sync();}finally{await handle.close();} +} +function inspect(file:string,name:typeof files[number]) { + const db=new DatabaseSync(file,{readOnly:true}); + try { + const result=db.prepare('PRAGMA quick_check').all(); + if(result.length!==1||Object.values(result[0])[0]!=='ok')throw new Error('SQLite integrity check failed'); + if(name==='ledger.sqlite'){ + const meta=(key:string)=>(db.prepare('SELECT value FROM metadata WHERE key=?').get(key) as {value:string}|undefined)?.value; + if(meta('schema_version')!=='1'||meta('network')!==NETWORK||meta('chain_id')!==CHAIN_ID)throw new Error('Ledger network/schema mismatch'); + }else{ + const row=db.prepare("SELECT value FROM market_meta WHERE key='identity'").get() as {value:string}|undefined; + const identity=row?JSON.parse(row.value):undefined; + if(identity?.version!==1||identity.network!==NETWORK||identity.chainId!==CHAIN_ID||!z.string().uuid().safeParse(identity.id).success)throw new Error('Market identity mismatch'); + } + }finally{db.close();} +} +/** Share the exact node startup lock. A stale lock is never removed automatically. */ +async function offlineLock(dataDir:string,fn:()=>Promise):Promise{ + const lockPath=join(dataDir,`${PROFILE}-node.lock`); + const handle=await open(lockPath,'wx',0o600).catch(()=>{throw new Error('Node directory is locked; stop the node and review stale locks manually.');}); + try{await handle.writeFile(`maintenance:${process.pid}`);return await fn();} + finally{await handle.close();await unlink(lockPath);} +} +export async function createBackup(dataDir:string,destination:string){ + const source=await realpath(dataDir),dest=resolve(destination); + const rel=relative(source,dest); + if(!rel||(!rel.startsWith('..')&&!isAbsolute(rel)))throw new Error('Backup destination must be outside the node data directory'); + return offlineLock(source,async()=>{ + if(await realpath(join(source,PROFILE))!==join(source,PROFILE))throw new Error('Network data directory must not be symlinked'); + await mkdir(dest,{mode:0o700}); // Exclusive: never overwrite or recursively create a surprising destination. + const entries=[]; + for(const name of files){ + const src=join(source,PROFILE,name),target=join(dest,name); + await regular(src);inspect(src,name); + const db=new DatabaseSync(src,{readOnly:true}); + try{db.prepare('VACUUM INTO ?').run(target);}finally{db.close();} + if(process.platform!=='win32')await chmod(target,0o600); + await flush(target); + inspect(target,name);const info=await regular(target); + entries.push({name,bytes:info.size,sha256:await digest(target)}); + } + const manifest=manifestSchema.parse({format:'wikideck-stopped-backup',version:1,network:NETWORK,chainId:CHAIN_ID,profile:PROFILE, + createdAt:new Date().toISOString(),files:entries}); + // Completion marker written last. Partial output is preserved for operator inspection, never treated as usable. + await writeFile(join(dest,'manifest.json'),JSON.stringify(manifest,null,2),{flag:'wx',mode:0o600}); + await flush(join(dest,'manifest.json')); + await flushDirectory(dest); + return manifest; + }); +} +export async function verifyBackup(directory:string){ + const root=await realpath(directory),manifestFile=join(root,'manifest.json'); + if((await regular(manifestFile)).size>16384)throw new Error('Backup manifest too large'); + const manifest=manifestSchema.parse(JSON.parse(await readFile(manifestFile,'utf8'))); + if(new Set(manifest.files.map(f=>f.name)).size!==2)throw new Error('Duplicate backup database'); + for(const file of manifest.files){ + const path=join(root,file.name),info=await regular(path); + if(info.size!==file.bytes||await digest(path)!==file.sha256)throw new Error('Backup database hash/size mismatch'); + // A completed package has standalone DBs. Sidecars would change what SQLite reads versus the hashed file. + for(const suffix of ['-wal','-shm','-journal']){ + try{await lstat(path+suffix);}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')continue;throw e;} + throw new Error('Backup database has unexpected SQLite sidecar'); + } + inspect(path,file.name); + } + return manifest; +} +export async function restoreBackup(directory:string,destination:string){ + const manifest=await verifyBackup(directory),dest=resolve(destination); + await mkdir(dest,{mode:0o700}); // Must not exist, even if empty. Never reset a node in place. + return offlineLock(dest,async()=>{ + const folder=join(dest,PROFILE);await mkdir(folder,{mode:0o700}); + for(const file of manifest.files){ + const target=join(folder,file.name); + await copyFile(join(directory,file.name),target,1); // COPYFILE_EXCL + if(process.platform!=='win32')await chmod(target,0o600); + await flush(target); + if(await digest(target)!==file.sha256)throw new Error('Restored copy hash mismatch'); + inspect(target,file.name); + } + await flushDirectory(folder);await flushDirectory(dest); + return {network:NETWORK,chainId:CHAIN_ID,restored:true,proofsRevalidated:false, + warning:'SQLite/hash checks only. Replay with the reference engine in an isolated reviewed rehearsal before production use.'}; + }); +} \ No newline at end of file diff --git a/server/testnet/http.ts b/server/testnet/http.ts index 8586fd7..38927a7 100644 --- a/server/testnet/http.ts +++ b/server/testnet/http.ts @@ -19,11 +19,14 @@ import { marketCommandSchema } from '../../shared/market.ts'; import { IS_MAINNET, PROFILE } from '../../shared/network.ts'; import { assertLaunchAllowed, launchManifest } from '../launch.ts'; import { resourceConfig, RpcBudget, RpcError } from '../resources.ts'; +import { trustedProxies, operationsPort, startOperations } from '../operations.ts'; const root = fileURLToPath(new URL('../../', import.meta.url)); assertLaunchAllowed(); const paymentConfig = marketConfig(); // Fail before opening node resources if live payments are requested. const limits = resourceConfig(); +const proxyTrust=trustedProxies(); +const opsPort=operationsPort(); const apiBudget = new RpcBudget(limits); const peerBudget = new RpcBudget({ ...limits, requestsPerSecond: 40, writesPerSecond: 10, active: 16 }); const dataDir = process.env.WIKIDECK_DATA_DIR ?? resolve(root,'data'); @@ -47,6 +50,7 @@ try { market = await Market.open(dataDir, ledger, paymentConfig); } catch(e) {peers.close();ledger.close();engine.close();await lock.close();await unlink(lockPath);throw e;} const app = express(); app.disable('x-powered-by'); +if(proxyTrust.length)app.set('trust proxy',proxyTrust); app.use(helmet({contentSecurityPolicy:{directives:{defaultSrc:["'self'"],scriptSrc:["'self'"], imgSrc:["'self'",'data:','https://upload.wikimedia.org','https://thumb.wikimedia.org'],styleSrc:["'self'","'unsafe-inline'"], connectSrc:["'self'"],upgradeInsecureRequests:null}},crossOriginEmbedderPolicy:false, @@ -176,6 +180,10 @@ const server=app.listen(port,host,()=>{ const a=server.address();console.log(`Wikideck RandomX ${IS_MAINNET?'mainnet candidate':'testnet'}: http://${host}:${typeof a==='object'&&a?a.port:port} · chain ${CHAIN_ID} · ${ledger.snapshot().blocks.length} blocks`); peers.start(); }); +const operations=startOperations(opsPort,()=>({network:NETWORK,paymentMode:'mock', + ledger:ledger.diagnostics(),native:engine.diagnostics(), + peers:{configured:peers.status().length,connected:peers.status().filter(p=>p.connected).length}, + rpc:{api:apiBudget.status(),p2p:peerBudget.status()},market:market.diagnostics()}),resolve(dataDir,PROFILE)); server.maxConnections=256; server.headersTimeout=10000; server.requestTimeout=15000; @@ -186,8 +194,8 @@ const marketTimer=setInterval(()=>{try {market.browse();}catch{console.error('Ma marketTimer.unref(); let stopping=false; for(const signal of ['SIGTERM','SIGINT']as const)process.once(signal,()=>{ - if(stopping)return;stopping=true;peers.close();clearInterval(marketTimer); + if(stopping)return;stopping=true;peers.close();operations.close();clearInterval(marketTimer); const timeout=setTimeout(()=>process.exit(1),25000);timeout.unref(); server.close(async()=>{market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});clearTimeout(timeout);process.exit(0);}); }); -server.on('error',async()=>{clearInterval(marketTimer);peers.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);}); +server.on('error',async()=>{clearInterval(marketTimer);operations.close();peers.close();market.close();ledger.close();engine.close();await lock.close();await unlink(lockPath).catch(()=>{});process.exit(1);}); diff --git a/server/testnet/ledger.ts b/server/testnet/ledger.ts index 3da99a0..8f1b00e 100644 --- a/server/testnet/ledger.ts +++ b/server/testnet/ledger.ts @@ -41,6 +41,7 @@ export class Ledger { private readonly pow: PowEngine; private closed = false; private revision = 0; + private reorgs = 0; private canonicalCache: { tip: string; blocks: TestnetBlock[] } | undefined; private constructor(file: string, db: DatabaseSync, pow: PowEngine) { this.file = file; this.db = db; this.pow = pow; } @@ -148,6 +149,9 @@ export class Ledger { } get size(): number { return this.nodes.size; } + diagnostics() { const createdAt=this.nodes.get(this.tip)?.block.createdAt??null; + return { height:this.height(), dagBlocks:this.size, pending:this.pending.length, mutationQueue:this.queued, + reorgsSinceStartup:this.reorgs,tipCreatedAt:createdAt,tipAgeSeconds:createdAt===null?null:Math.max(0,Date.now()/1000-createdAt) }; } get allBlocks(): TestnetBlock[] { const rows = this.db.prepare('SELECT block_json FROM blocks ORDER BY sequence').all() as { block_json: string }[]; return rows.map(row => blockSchema.parse(JSON.parse(row.block_json))); @@ -191,6 +195,7 @@ export class Ledger { await this.persist(node.block, pending); this.nodes.set(node.block.hash, node); if (wins) this.tip = node.block.hash; + if (disconnected.length) this.reorgs++; this.pending = pending; if (filtered) this.pendingState = filtered.state; this.revision++; diff --git a/server/testnet/randomx.ts b/server/testnet/randomx.ts index 55bf2a8..0e734e8 100644 --- a/server/testnet/randomx.ts +++ b/server/testnet/randomx.ts @@ -87,6 +87,7 @@ export class RandomX implements PowEngine { return line; }); } + diagnostics() { return { available: !this.failure && !this.exited, active: !!this.active, queued: this.queue.length }; } async mine( key: string, blob: Uint8Array, target: string, nonceOffset: number, maxAttempts: number, diff --git a/tests/api.test.ts b/tests/api.test.ts index 3600e44..ba43b24 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -17,6 +17,9 @@ import { NETWORK, CONSENSUS, blockClaimMessage, nftTransferMessage, import { marketMessage, type MarketAction, type MarketInfo, type MarketListing, type MarketOrder, type MarketActivity } from '../shared/market.ts'; import { StateClient } from '../shared/rpc-client.ts'; +import { createBackup,restoreBackup } from '../server/recovery.ts'; +import { Ledger } from '../server/testnet/ledger.ts'; +import { Market } from '../server/market/store.ts'; const root = fileURLToPath(new URL('../', import.meta.url)); const wikipediaMock = resolve(root, 'tests', 'mock-wikipedia.mjs'); @@ -361,6 +364,20 @@ test('native RandomX HTTP: three blocks, mined transfer, peer gossip/sync and du assert.equal(first.child.exitCode, 0, first.output()); assert.equal(existsSync(join(firstDir, 'testnet-node.lock')), false); } + const backupDir=join(directory,'stopped-pair-backup'),drillDir=join(directory,'isolated-restore-drill'); + await createBackup(firstDir,backupDir);await restoreBackup(backupDir,drillDir); + const drillLedger=await Ledger.open(drillDir,engine!); // Revalidate real RandomX proofs from the copied backup. + try { + assert.deepEqual(drillLedger.snapshot(),{...canonical,peers:0}); + const drillMarket=await Market.open(drillDir,drillLedger); + try {assert.equal(drillMarket.id,market.info.marketId);}finally{drillMarket.close();} + const db=new DatabaseSync(join(drillDir,'testnet','market.sqlite'),{readOnly:true}); + try { + const row=db.prepare('SELECT data FROM orders WHERE id=?').get(order.id) as {data:string}; + assert.equal(JSON.parse(row.data).buyer,bob.owner); + assert.ok((db.prepare('SELECT COUNT(*) AS n FROM receipts').get() as {n:number}).n>0); + }finally{db.close();} + }finally{drillLedger.close();} first = await start(firstDir); // Ledger.open revalidates every persisted block using native RandomX. const restored = await snapshot(first.base); assert.deepEqual(await pagedClient.load(),restored); diff --git a/tests/calibration.test.ts b/tests/calibration.test.ts index 31e81c5..3ffe011 100755 --- a/tests/calibration.test.ts +++ b/tests/calibration.test.ts @@ -53,4 +53,4 @@ test('analysis CLI is offline and benchmark refuses CPU work without consent',() const bad=spawnSync(process.execPath,['--experimental-strip-types','scripts/benchmark-randomx.ts','--consent','--hashes','1000000'],{ encoding:'utf8',timeout:30000,env:{...process.env,RANDOMX_BINARY:'/nonexistent'}}); assert.notEqual(bad.status,0);assert.match(bad.stderr,/Invalid --hashes/); -}); \ No newline at end of file +}); diff --git a/tests/operations.test.ts b/tests/operations.test.ts new file mode 100755 index 0000000..4ca193c --- /dev/null +++ b/tests/operations.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { trustedProxies, operationsPort, startOperations } from '../server/operations.ts'; +import express from 'express'; +import { once } from 'node:events'; +import { createServer } from 'node:net'; +import { checkOperations } from '../server/operations-check.ts'; + +test('proxy trust is explicit literal IP/CIDR only; defaults disabled',()=>{ + assert.deepEqual(trustedProxies({}),[]); + assert.deepEqual(trustedProxies({WIKIDECK_TRUSTED_PROXIES:'127.0.0.1,::1,172.20.1.0/24'}),['127.0.0.1','::1','172.20.1.0/24']); + for(const value of ['true','1','loopback','localhost','0.0.0.0/0','::/0','127.0.0.1/33','127.0.0.1,','http://proxy'])assert.throws(()=>trustedProxies({WIKIDECK_TRUSTED_PROXIES:value})); + assert.equal(operationsPort({}),undefined); + for(const value of ['0','-1','65536','x'])assert.throws(()=>operationsPort({WIKIDECK_OPS_PORT:value})); +}); +test('untrusted forwarded headers do not alter client IP; explicit loopback proxy is recognized',async t=>{ + const app=express();app.get('/ip',(req,res)=>{res.json({ip:req.ip});}); + const server=app.listen(0,'127.0.0.1');await once(server,'listening'); + t.after(()=>{server.closeAllConnections();server.close();}); + const a=server.address();assert.ok(a&&typeof a==='object');const url=`http://127.0.0.1:${a.port}/ip`; + const request=()=>fetch(url,{headers:{'X-Forwarded-For':'203.0.113.12'}}).then(r=>r.json()); + assert.equal((await request()).ip,'127.0.0.1'); + app.set('trust proxy',trustedProxies({WIKIDECK_TRUSTED_PROXIES:'127.0.0.1'})); + assert.equal((await request()).ip,'203.0.113.12'); +}); +test('private operations listener exposes bounded aggregates only on separate loopback port',async t=>{ + const reserve=createServer();reserve.listen(0,'127.0.0.1');await once(reserve,'listening'); + const a=reserve.address();assert.ok(a&&typeof a==='object');await new Promise(resolve=>reserve.close(()=>resolve())); + const ops=startOperations(a.port,()=>({network:'test',native:{available:true}}));t.after(()=>ops.close()); + const r=await fetch(`http://127.0.0.1:${a.port}/status`,{signal:AbortSignal.timeout(5000)}); + assert.equal(r.status,200);assert.equal(r.headers.get('cache-control'),'no-store'); + const data=await r.json();assert.equal(data.network,'test');assert.equal(data.native.available,true); + assert.ok(data.nodeRssBytes>0);assert.equal('privateKey' in data,false); + assert.equal((await fetch(`http://127.0.0.1:${a.port}/metrics`)).status,404); + assert.equal(ops.address?.()?.address,'127.0.0.1'); +}); +test('operations checks identify isolation, stale reconciliation, disk pressure and market reviews',()=>{ + const now=Date.now(); + const status={schema:'wikideck-operations-v1',observedAt:new Date(now).toISOString(), + native:{available:true,queued:0},ledger:{height:10,mutationQueue:0,tipAgeSeconds:60}, + peers:{configured:2,connected:2},eventLoop:{meanMs:20},disk:{availableBytes:1024**3},diskSampledAt:now, + market:{queued:0,lastReconciledAt:now,orderStates:[] as {status:string;count:number}[]}}; + assert.equal(checkOperations(status,now).healthy,true); + const bad={...status,native:{available:false,queued:50},peers:{configured:2,connected:0},disk:null, + market:{queued:0,lastReconciledAt:now-120000,orderStates:[{status:'review',count:1}]}}; + const report=checkOperations(bad,now); + for(const warning of ['native-engine-unavailable','queue-pressure','peer-isolation','market-reconciliation-stale','market-operator-review','disk-sample-unavailable-or-stale'])assert.ok(report.warnings.includes(warning)); + assert.throws(()=>checkOperations({})); +}); \ No newline at end of file diff --git a/tests/recovery.test.ts b/tests/recovery.test.ts new file mode 100755 index 0000000..b0109eb --- /dev/null +++ b/tests/recovery.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp,rm,writeFile,readFile,mkdir,access } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { DatabaseSync } from 'node:sqlite'; +import { Ledger } from '../server/testnet/ledger.ts'; +import { Market } from '../server/market/store.ts'; +import { createBackup,verifyBackup,restoreBackup } from '../server/recovery.ts'; + +test('stopped-node pair backup restores journal identity and replays ledger in isolated destination',async t=>{ + const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-'));t.after(()=>rm(root,{recursive:true,force:true})); + const source=join(root,'source'); + const pow={hash:async()=>{throw new Error('Empty ledger should require no proof work');},close(){}}; + const ledger=await Ledger.open(source,pow),market=await Market.open(source,ledger); + const id=market.id,snapshot=ledger.snapshot();market.close();ledger.close(); + const backup=join(root,'backup');const manifest=await createBackup(source,backup); + assert.equal(manifest.files.length,2);assert.deepEqual(await verifyBackup(backup),manifest); + const dest=join(root,'restore');assert.equal((await restoreBackup(backup,dest)).proofsRevalidated,false); + const restored=await Ledger.open(dest,pow),journal=await Market.open(dest,restored); + assert.deepEqual(restored.snapshot(),snapshot);assert.equal(journal.id,id); + journal.close();restored.close(); + await assert.rejects(restoreBackup(backup,dest),/EEXIST/); + await assert.rejects(createBackup(source,backup),/EEXIST/); + await assert.rejects(createBackup(source,join(source,'inside')),/outside/); + await writeFile(join(source,'testnet-node.lock'),'simulated-running'); + await assert.rejects(createBackup(source,join(root,'locked-backup')),/locked/); + assert.equal(await readFile(join(source,'testnet-node.lock'),'utf8'),'simulated-running'); +}); + +test('backup verifier rejects tampering, sidecars, network mismatch and incomplete packages',async t=>{ + const root=await mkdtemp(join(tmpdir(),'wikideck-recovery-bad-'));t.after(()=>rm(root,{recursive:true,force:true})); + const source=join(root,'source'),ledger=await Ledger.open(source,{hash:async()=>'',close(){}}),market=await Market.open(source,ledger); + market.close();ledger.close();const backup=join(root,'backup');await createBackup(source,backup); + const original=await readFile(join(backup,'manifest.json'),'utf8'); + await writeFile(join(backup,'ledger.sqlite-wal'),'unexpected');await assert.rejects(verifyBackup(backup),/sidecar/); + await rm(join(backup,'ledger.sqlite-wal')); + await writeFile(join(backup,'manifest.json'),original.replace('wikideck-randomx-testnet-6','wrong-network'));await assert.rejects(verifyBackup(backup)); + await writeFile(join(backup,'manifest.json'),original); + const db=new DatabaseSync(join(backup,'market.sqlite'));db.exec("UPDATE market_meta SET value='tampered' WHERE key='identity'");db.close(); + await assert.rejects(verifyBackup(backup),/hash\/size/); + const missing=join(root,'incomplete');await mkdir(missing);await assert.rejects(verifyBackup(missing)); + await assert.rejects(access(join(source,'testnet-node.lock'))); // maintenance lock released, not silently left behind +}); \ No newline at end of file