feat: productionize paid modes and PvP capture

This commit is contained in:
jwinterm
2026-07-21 20:41:27 -04:00
parent 8fb284f8ed
commit 89398335c7
230 changed files with 31755 additions and 2777 deletions
@@ -0,0 +1,239 @@
# Parallel Monero stagenet wallet candidate
> **STATUS: DESIGN / NO-GO FOR HOST MUTATION OR PRODUCTION DEPLOYMENT.**
>
> The read-only preflight and disposable fixture test in this change are executable. The candidate
> unit is an inert, manual-only template. There is intentionally no password-capture, wallet-copy,
> credential-bundle, installer, activation, or cutover program yet. Do not improvise those steps
> from shell commands. No in-place destructive migration is shipped with this design; deleting an
> abandoned draft does not make the parallel candidate production-deployable.
This is the smaller alternative to replacing the current wallet in place. It is fixed to the
Monero **stagenet** wallet used by `monerogue.app`. It has no mainnet or Wownero mode.
The safety property is simple: the current wallet, unit, and application stay exactly as they are.
A stopped, consistent copy is opened under a new dedicated identity on a different loopback port.
Nothing can use that copy until one complete directory containing the wallet, protected
credentials, configuration, and readiness marker has been validated and atomically published.
## What is safe to run now
The local fixture uses only synthetic bytes below a temporary directory:
```bash
python3 scripts/deploy/monero-stagenet-parallel-fixture-test.py
```
The fixed-path preflight is read-only. It deliberately does not read unit text, process argv,
wallet bytes, keys, passwords, or mnemonics. After independent review, it can check the current
legacy RPC:
```bash
sudo python3 scripts/deploy/monero-stagenet-parallel-preflight.py legacy
```
Its `candidate-prereqs` scope additionally requires the new system identity, free candidate ports,
an unused candidate path, the root-owned candidate binary, and enough local space. Provisioning
that identity is a future approved change; the preflight does not create it.
Neither command proves that secret capture, bundle creation, activation, backup recovery, or an
application cutover is safe. The service template must not be installed yet.
## Fixed boundary
| Purpose | Fixed value |
| --- | --- |
| Legacy unit | `monero-wallet-rpc.service` |
| Legacy wallet | `/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test` + `.keys` |
| Legacy RPC | `127.0.0.1:38083` |
| Network/daemon | Monero stagenet / `127.0.0.1:38081` |
| Candidate identity | `monerogue-wallet-candidate:monerogue-wallet-candidate` |
| Staging generation | `/var/lib/.monerogue-stagenet-wallet-candidate.staging` |
| Published generation | `/var/lib/monerogue-stagenet-wallet-candidate` |
| Candidate RPC | `127.0.0.1:38084`, Digest authentication required |
| Offline verification RPC | `127.0.0.1:39084`, temporary only |
| Candidate unit | `monerogue-stagenet-wallet-candidate.service`, manual only |
The final bundle must be one directory on the same filesystem as its staging generation:
```text
/var/lib/monerogue-stagenet-wallet-candidate/ root:root 0711
├── READY root:root 0600, written last
├── STATE.json root:root 0600, non-secret phase/address metadata
├── expected-address root:root 0600, public address only
├── wallet/ candidate:candidate 0700
│ ├── stagenet candidate:candidate 0600
│ └── stagenet.keys candidate:candidate 0600
├── config/ root:candidate 0710
│ └── wallet-rpc.conf root:candidate 0640
├── secrets/ root:root 0711
│ ├── wallet-password root:candidate 0640
│ └── rpc-auth.netrc root:root 0600
└── integration/ root:root 0711
└── candidate-app.env root:monerogue 0640
```
Execute-only directory traversal permits each identity to open its one reviewed fixed path without
listing the directory. No file may be a symlink or have a link count other than one. The `READY`
marker contains only version, stagenet, `127.0.0.1:38084`, the public address, and a
`manual-only` flag—never a password, hash of a seed, private key, or wallet bytes.
## Required staged procedure
These are acceptance requirements for small future helpers, not commands to run today.
### 1. Read-only inventory
The preflight must pass immediately before a maintenance window. Separately record the public
wallet address without recording credentials. Confirm the current application and wallet remain
healthy and that `38084` and `39084` have no listener.
The preflight intentionally does not inspect `/proc/<pid>/cmdline`, because the current process
contains a password. That work belongs only in the bounded capture helper described next.
### 2. Capture the already-exposed wallet password
A future fixed-path helper may read the password only from the validated live process. It must:
- run as root with `RLIMIT_CORE=0`, `PR_SET_DUMPABLE=0`, `umask 077`, and no tracing/logging;
- obtain only `MainPID` from systemd, then validate PID start time, `/proc/<pid>/exe`, UID, cgroup,
and the sole PID-owned `127.0.0.1:38083` listener before and after capture;
- read NUL-delimited `/proc/<pid>/cmdline` directly, never systemd unit text or `ExecStart` output;
- accept exactly one bounded `--password VALUE` or `--password=VALUE`, reject duplicates,
`--password-file`, NUL/newline data, or a process change;
- write only a new `root:root 0600` file below the unpublished staging generation using
`openat(O_NOFOLLOW|O_CREAT|O_EXCL)`, `fsync`, and retained directory descriptors;
- print only success/refusal, never arguments, password length, content, hash, or exception data;
- zero mutable buffers before exit; and
- leave an incomplete stage inert and refuse automatic overwrite or cleanup after any failure.
The helper needs executable fixture coverage for both argv forms, duplicates, process replacement,
short writes, fsync failures, interruption, and restart with every possible partial file/state. A
static source test is not sufficient.
### 3. One bounded stopped-wallet copy window
Only a narrow wrapper may stop the legacy unit. It must acquire a fixed root-only lock and install
an `EXIT`, signal, and error trap that always attempts to restart the exact legacy unit. It must not
read or alter the unit.
After stop, require `MainPID=0`, no listener on `38083`, and no other process descriptor open on
either source inode. Open every source/path component with retained directory descriptors and
`O_NOFOLLOW`; require `jw:jw 0600`, a single link, and stable inode/size/timestamps. Copy wallet and
keys with `O_EXCL` temporary files, `fsync`, byte-compare through retained descriptors, publish each
inside the unpublished stage with `renameat2(RENAME_NOREPLACE)`, and fsync its directory.
The wrapper must restart the legacy unit whether the copy succeeds, fails, or is interrupted. A
successful window is not complete until the restarted RPC has the same PID ownership/listener
properties and returns the exact public address captured before stop. At no point may a helper
move, rename, chmod, chown, truncate, erase, or unlink the legacy wallet, keys, directory, or unit.
### 4. Build and verify one unused bundle
Generate a new RPC credential in a non-dumpable process. The wallet password and RPC credential
must be distinct. Keep all copies in the one unpublished generation; no `/etc` credential file or
application drop-in is needed.
The protected wallet config must contain exactly these non-secret semantics plus one Digest login:
```text
stagenet=1
wallet-file=/var/lib/monerogue-stagenet-wallet-candidate/wallet/stagenet
password-file=/var/lib/monerogue-stagenet-wallet-candidate/secrets/wallet-password
rpc-bind-port=38084
rpc-bind-ip=127.0.0.1
daemon-address=127.0.0.1:38081
rpc-login=monerogue-candidate:<generated-secret>
non-interactive=1
```
Before publication, open the copied wallet offline on temporary port `39084` as the candidate UID,
with supplementary groups and all capabilities cleared, `no_new_privs`, core dumps disabled, and
logs directed to `/dev/null`. Prove all of the following without printing response data:
- the verifier PID owns the sole exact loopback listener;
- unauthenticated JSON-RPC returns HTTP 401;
- Digest-authenticated `get_address` returns HTTP 200 and the captured stagenet address; and
- termination removes the verifier and listener cleanly.
Then verify every directory/file owner, mode, type, and link count. Write and fsync `STATE.json`,
write `READY` **last**, fsync every file and directory, atomically rename the whole staging
directory to the unused final name with `RENAME_NOREPLACE`, and fsync `/var/lib`. A crash before the
single directory rename leaves no final candidate. An existing final candidate is never replaced.
### 5. Manual candidate validation
Only after an independent reviewer accepts the helper and crash evidence may the inert service
template be installed as `/etc/systemd/system/monerogue-stagenet-wallet-candidate.service`. It has
no `[Install]` section, no boot target, no restart loop, no relationship to `monerogue.service`, and
requires the exact readiness marker.
Start it manually, then prove systemd MainPID/executable/UID/cgroup agreement, the sole
`127.0.0.1:38084` listener owned by that PID, HTTP 401 without auth, HTTP 200 with the protected
netrc, and the sealed public address. The legacy RPC and live app must remain healthy throughout.
### 6. Candidate application canary
The bundle may contain one `root:monerogue 0640` environment override with candidate endpoint and
Digest credentials. It must also force:
```text
HOST=127.0.0.1
PORT=3103
PAYMENTS_ENABLED=false
PAYOUTS_ENABLED=false
DIRECT_PAYMENT_ENABLED=false
DIRECT_PAYOUTS_ENABLED=false
CREDITS_PAYOUTS_ENABLED=false
MATCH_ENABLED=false
MATCH_CRYPTO_RACE_ENABLED=false
MATCH_PAYOUTS_ENABLED=false
```
Run the exact release only against a fresh, isolated canary database. Do not attach port 3103 to
the NPM/router, public ingress, production database, payment intake, payout dispatchers, or the
live service. Readiness and a non-money wallet query are the only allowed checks. Stop both canary
app and candidate wallet afterward.
Promotion of the production app from `38083` to `38084` is explicitly out of scope. It requires a
separate reviewed maintenance change and rollback plan.
## Rollback and partial failures
There is no destructive wallet rollback because this path never alters the legacy artifacts.
- During the copy window, always restart and validate the legacy unit from the trap.
- Before candidate activation, leave any partial staging generation root-only and inert. Do not
delete, merge, resume, or overwrite it automatically.
- After candidate activation, stop the candidate unit and prove port `38084` is closed. Restart and
validate the legacy unit if needed. The live application still points to `38083`.
- Preserve the unused candidate bundle for incident review; cleanup is a later exact-target,
separately approved operation.
If the legacy unit cannot restart or its address differs, keep the application stopped/ingress
held under the existing incident runbook. Do not attempt an app cutover to rescue the ceremony.
## GO criteria
All are mandatory before any host mutation beyond provisioning the unused candidate identity:
1. Small fixed-path capture, copy-window, bundle, and validation helpers exist; no general source,
destination, network, unit, port, user, or overwrite arguments exist.
2. Disposable tests inject failure before/after every create, write, chmod/chown, file fsync,
directory fsync, state write, readiness write, stop, start, address check, and final rename.
3. Kill/restart tests prove every partial stage remains inert and every stopped-wallet failure
makes a bounded restart attempt without touching legacy bytes or metadata.
4. Symlink, hardlink, inode replacement, open-writer, wrong UID/cgroup/PID, wildcard/IPv6 listener,
port collision, duplicate password option, and existing-final fixtures all fail closed.
5. A reviewer verifies secrets never enter argv, environment, stdout/stderr, journald, unit text,
tracebacks, Git, ordinary docs, or the non-secret readiness marker.
6. The restored wallet backup plus its password has been opened offline and address-matched, and
mnemonic/off-host recovery custody is resolved separately. A `.keys` file alone is not mnemonic
custody evidence.
7. The complete process succeeds, rolls back, and succeeds again on an isolated stagenet host with
synthetic/unfunded material before touching the funded stagenet wallet.
8. An independent reviewer records GO against an immutable release hash.
Until every item passes, the production-deployable answer is **no**. Only the read-only preflight
and disposable fixture test are approved by this document; even the candidate service template is
not approved for installation.
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Build a deterministic, runtime-only Wowngeon release artifact from one clean Git commit.
# This script deliberately does not install dependencies, read environment files, contact a
# deployment host, or activate a release. See --help and docs/DEPLOY.md for the operator gates.
umask 077
die() {
printf 'build-release-artifact: %s\n' "$*" >&2
exit 1
}
usage() {
cat <<'EOF'
Usage: scripts/deploy/build-release-artifact.sh [options]
Options:
--ref REF Git commit-ish to archive (default: HEAD)
--release-id ID Destination directory name inside the archive
(default: git-<12-character commit prefix>)
--output-dir DIR Existing or new non-symlink output directory
(default: <repository>/dist/releases)
-h, --help Show this help
The builder fails unless tracked files and non-ignored untracked files are clean. It archives only
the runtime allowlist (LICENSE, html/**, and the guarded runtime subset of src/**) from the selected
commit, rejects symlinks/gitlinks and non-example .env files anywhere in that commit, and refuses
to overwrite an artifact or checksum. Repeating the command for the same commit, release ID, and
toolchain produces byte-identical .tar.gz output.
EOF
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "required command is unavailable: $1"
}
for required_command in git tar gzip sha256sum mktemp install ln rm chmod cat dirname basename find sort xargs node; do
require_command "$required_command"
done
script_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
repo_root=$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null) \
|| die 'script is not inside a Git worktree'
repo_root=$(CDPATH= cd -- "$repo_root" && pwd -P)
ref=HEAD
release_id=''
output_dir="$repo_root/dist/releases"
while (($# > 0)); do
case "$1" in
--ref)
(($# >= 2)) || die '--ref requires a value'
ref=$2
shift 2
;;
--release-id)
(($# >= 2)) || die '--release-id requires a value'
release_id=$2
shift 2
;;
--output-dir)
(($# >= 2)) || die '--output-dir requires a value'
output_dir=$2
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
done
# A commit name cannot describe uncommitted candidate content. Refuse both tracked changes and
# non-ignored untracked additions instead of silently producing an artifact different from the
# reviewed tree. Ignored files such as local .env and node_modules are never read or archived.
if [[ -n "$(git -C "$repo_root" status --porcelain=v1 --untracked-files=all)" ]]; then
die 'worktree is dirty; commit or remove every tracked/untracked candidate change first'
fi
commit=$(git -C "$repo_root" rev-parse --verify "${ref}^{commit}" 2>/dev/null) \
|| die "ref does not resolve to a commit: $ref"
[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || die 'resolved commit is not a full SHA-1 object ID'
if [[ -z "$release_id" ]]; then
release_id="git-${commit:0:12}"
fi
[[ "$release_id" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$ ]] \
|| die 'release ID must be 1-96 safe characters and start with an alphanumeric character'
[[ "$release_id" != '.' && "$release_id" != '..' ]] || die 'release ID cannot be dot or dot-dot'
if [[ -e "$output_dir" || -L "$output_dir" ]]; then
[[ -d "$output_dir" && ! -L "$output_dir" ]] \
|| die 'output directory must be a real directory, not a symlink'
else
install -d -m 0700 -- "$output_dir"
fi
output_dir=$(CDPATH= cd -- "$output_dir" && pwd -P)
artifact="$output_dir/${release_id}.tar.gz"
checksum="$artifact.sha256"
[[ ! -e "$artifact" && ! -L "$artifact" ]] || die "refusing to overwrite: $artifact"
[[ ! -e "$checksum" && ! -L "$checksum" ]] || die "refusing to overwrite: $checksum"
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/wowngeon-release.XXXXXXXX")
artifact_tmp=$(mktemp "$output_dir/.${release_id}.artifact.XXXXXXXX")
checksum_tmp=$(mktemp "$output_dir/.${release_id}.checksum.XXXXXXXX")
published=0
cleanup() {
local status=$?
if [[ "$published" != '1' ]]; then
# Another same-ID builder may have won publication after both processes passed the
# initial existence check. Remove a destination only while it is still our own hard link;
# a losing builder must never unlink the winning builder's artifact or checksum.
if [[ -e "$artifact" && "$artifact" -ef "$artifact_tmp" ]]; then
rm -f -- "$artifact"
fi
if [[ -e "$checksum" && "$checksum" -ef "$checksum_tmp" ]]; then
rm -f -- "$checksum"
fi
fi
rm -f -- "$artifact_tmp" "$checksum_tmp"
rm -rf -- "$work_dir"
return "$status"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
source_tar="$work_dir/runtime-source.tar"
stage_root="$work_dir/stage"
release_root="$stage_root/$release_id"
install -d -m 0700 -- "$release_root"
# Git trees can encode regular files, executables, symlinks, and gitlinks. Releases accept only
# the first two. This also validates path policy before tar is allowed to extract anything.
while IFS= read -r -d '' tree_entry; do
metadata=${tree_entry%%$'\t'*}
path=${tree_entry#*$'\t'}
mode=${metadata%% *}
[[ "$metadata" != "$path" ]] || die 'malformed Git tree entry'
[[ "$mode" == '100644' || "$mode" == '100755' ]] \
|| die "tracked symlink, gitlink, or unsafe file mode is forbidden: $path"
[[ "$path" != *$'\n'* && "$path" != *$'\r'* ]] \
|| die 'tracked filenames containing newline or carriage return are forbidden'
[[ "$path" != /* && "$path" != ../* && "$path" != */../* && "$path" != */.. ]] \
|| die "unsafe tracked path: $path"
[[ "/$path/" != *'/.git/'* && "/$path/" != *'/node_modules/'* ]] \
|| die "tracked VCS/dependency state is forbidden: $path"
filename=$(basename -- "$path")
case "$filename" in
.env)
die "tracked runtime environment file is forbidden: $path"
;;
.env.*)
[[ "$filename" == *.example ]] \
|| die "tracked non-example environment file is forbidden: $path"
;;
.npmrc|.yarnrc|.netrc)
die "tracked credential-capable configuration file is forbidden: $path"
;;
esac
done < <(git -C "$repo_root" ls-tree -rz --full-tree "$commit")
# Production releases intentionally exclude source documentation, tests, root-level operator
# tooling/service units, and environment examples. Operational artifacts remain in the reviewed
# source/fleet repositories and must have their own pinned provenance; they are never made part of
# the application runtime merely because they share a Git commit.
runtime_pathspecs=(
'LICENSE'
'html'
'src'
':(exclude)src/.env*'
':(glob,exclude)src/**/.env*'
':(exclude)src/scripts/pvp-capture.js'
':(exclude)src/scripts/stagenet-financial-canary.js'
':(exclude)src/scripts/setup_db.js'
':(exclude)src/scripts/smoke.js'
':(exclude)src/jest.config.js'
':(exclude)src/sim'
)
git -C "$repo_root" archive --format=tar "$commit" -- "${runtime_pathspecs[@]}" >"$source_tar"
source_hash=$(sha256sum "$source_tar")
source_hash=${source_hash%% *}
[[ "$source_hash" =~ ^[0-9a-f]{64}$ ]] || die 'could not calculate source archive SHA-256'
tar --extract --file="$source_tar" --directory="$release_root" --no-same-owner --no-same-permissions
runtime_roots=$(find "$release_root" -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort)
[[ "$runtime_roots" == $'LICENSE\nhtml\nsrc' ]] \
|| die 'runtime archive contains an unexpected or missing top-level path'
[[ -z "$(find "$release_root/src" -type f -name '.env*' -print -quit)" ]] \
|| die 'runtime archive contains a forbidden environment file or example'
for required_file in LICENSE src/index.js src/package.json src/package-lock.json \
src/scripts/preflight.js src/scripts/migrate-disposable-clone.js html/index.html; do
[[ -f "$release_root/$required_file" && ! -L "$release_root/$required_file" ]] \
|| die "required release file is absent or unsafe: $required_file"
done
# Keep the source package manifest useful to developers while exposing only runnable commands in
# the runtime artifact. This deterministic metadata-only rewrite does not change dependencies or
# lockfile resolution; the final runtime bytes are covered by .release-files.sha256 below.
node - "$release_root/src/package.json" <<'NODE'
'use strict';
const fs = require('fs');
const manifestPath = process.argv[2];
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const sourceScripts = manifest.scripts && typeof manifest.scripts === 'object'
? manifest.scripts
: {};
const runtimeScripts = {};
for (const name of ['start', 'preflight', 'db:migrate:clone']) {
if (typeof sourceScripts[name] !== 'string' || sourceScripts[name].trim().length === 0) {
throw new Error(`required runtime package script is absent or empty: ${name}`);
}
runtimeScripts[name] = sourceScripts[name];
}
manifest.scripts = runtimeScripts;
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 });
NODE
lock_hash=$(sha256sum "$release_root/src/package-lock.json")
lock_hash=${lock_hash%% *}
commit_epoch=$(git -C "$repo_root" show -s --format=%ct "$commit")
[[ "$commit_epoch" =~ ^[0-9]+$ ]] || die 'commit timestamp is invalid'
printf '%s\n' "$source_hash" >"$release_root/.release-source-sha256"
printf '%s\n' "$commit" >"$release_root/.release-commit"
printf '%s\n' "$release_id" >"$release_root/.release-id"
printf '%s\n' "$lock_hash" >"$release_root/.release-package-lock-sha256"
(
cd "$release_root"
find . -type f ! -path './.release-files.sha256' -print0 \
| LC_ALL=C sort -z \
| xargs -0 -r sha256sum --
) >"$release_root/.release-files.sha256"
# Normalize ownership, modes, ordering, timestamps, and gzip headers. Metadata files deliberately
# identify the prefix-free, runtime-allowlisted `git archive` hash; the sidecar identifies the
# final blob.
tar \
--sort=name \
--format=posix \
--pax-option=delete=atime,delete=ctime \
--mtime="@$commit_epoch" \
--owner=0 --group=0 --numeric-owner \
--mode='u+rwX,go+rX,go-w' \
--directory="$stage_root" \
--create --file=- "$release_id" \
| gzip -n >"$artifact_tmp"
artifact_hash=$(sha256sum "$artifact_tmp")
artifact_hash=${artifact_hash%% *}
printf '%s %s\n' "$artifact_hash" "$(basename -- "$artifact")" >"$checksum_tmp"
# Hard-link publication is atomic and refuses an existing destination even under a concurrent run.
ln -- "$artifact_tmp" "$artifact" || die "could not publish artifact without overwrite: $artifact"
if ! ln -- "$checksum_tmp" "$checksum"; then
rm -f -- "$artifact"
die "could not publish checksum without overwrite: $checksum"
fi
chmod 0600 -- "$artifact" "$checksum"
published=1
printf 'release_id=%s\ncommit=%s\nsource_sha256=%s\nartifact_sha256=%s\nartifact=%s\nchecksum=%s\n' \
"$release_id" "$commit" "$source_hash" "$artifact_hash" "$artifact" "$checksum"
+103
View File
@@ -0,0 +1,103 @@
\set ON_ERROR_STOP on
\pset pager off
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT current_database() AS database,
COUNT(*) AS applied_migrations,
MAX(filename) AS latest_migration
FROM schema_migrations;
WITH audit(category, row_count, atomic_amount) AS (
SELECT 'payout_nonterminal', COUNT(*), COALESCE(SUM(amount), 0)::numeric
FROM payouts
WHERE status IS DISTINCT FROM 'completed'
UNION ALL
SELECT 'payout_completed_missing_tx', COUNT(*), COALESCE(SUM(amount), 0)::numeric
FROM payouts
WHERE status = 'completed' AND NULLIF(BTRIM(COALESCE(tx_hash, '')), '') IS NULL
UNION ALL
SELECT 'refund_unsettled', COUNT(*), COALESCE(SUM(amount), 0)::numeric
FROM payment_refunds
WHERE status IS DISTINCT FROM 'completed'
UNION ALL
SELECT 'solo_orphan', COUNT(*), 0::numeric
FROM games
WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired')
UNION ALL
SELECT 'match_orphan', COUNT(*), COALESCE(SUM(payout_liability_amount_atomic), 0)::numeric
FROM matches
WHERE status IS NULL OR status NOT IN ('finished', 'cancelled')
UNION ALL
SELECT 'match_queue_outstanding', COUNT(*), COALESCE(SUM(escrow_value_atomic), 0)::numeric
FROM match_queue_entries
WHERE status IS NULL OR status NOT IN ('consumed', 'cancelled')
UNION ALL
SELECT 'solo_liability_without_payout', COUNT(*),
COALESCE(SUM(CASE
WHEN treasure_found THEN payout_treasure_amount
ELSE payout_escape_amount
END), 0)::numeric
FROM games g
WHERE g.status = 'won'
AND g.payout_eligible = TRUE
AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.game_id = g.id)
UNION ALL
SELECT 'match_liability_without_payout', COUNT(*),
COALESCE(SUM(m.payout_liability_amount_atomic), 0)::numeric
FROM matches m
WHERE m.economy = 'crypto_race'
AND m.status = 'finished'
AND m.winner_user_id IS NOT NULL
AND m.payout_liability_accepted_at IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.match_id = m.id)
UNION ALL
SELECT 'late_payment_manual_review', COUNT(*), COALESCE(SUM(observed_amount), 0)::numeric
FROM payment_late_reviews
WHERE status IS NULL OR status NOT IN ('resolved', 'ignored')
)
SELECT category, row_count, atomic_amount
FROM audit
ORDER BY category;
WITH ledger AS (
SELECT COUNT(*) = 40
AND MAX(filename) = '040_paid_match_entropy_precommit.sql' AS valid
FROM schema_migrations
), audit(row_count) AS (
SELECT COUNT(*) FROM payouts WHERE status IS DISTINCT FROM 'completed'
UNION ALL
SELECT COUNT(*) FROM payouts
WHERE status = 'completed' AND NULLIF(BTRIM(COALESCE(tx_hash, '')), '') IS NULL
UNION ALL
SELECT COUNT(*) FROM payment_refunds
WHERE status IS DISTINCT FROM 'completed'
UNION ALL
SELECT COUNT(*) FROM games WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired')
UNION ALL
SELECT COUNT(*) FROM matches WHERE status IS NULL OR status NOT IN ('finished', 'cancelled')
UNION ALL
SELECT COUNT(*) FROM match_queue_entries WHERE status IS NULL OR status NOT IN ('consumed', 'cancelled')
UNION ALL
SELECT COUNT(*) FROM games g
WHERE g.status = 'won' AND g.payout_eligible = TRUE
AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.game_id = g.id)
UNION ALL
SELECT COUNT(*) FROM matches m
WHERE m.economy = 'crypto_race' AND m.status = 'finished'
AND m.winner_user_id IS NOT NULL
AND m.payout_liability_accepted_at IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.match_id = m.id)
UNION ALL
SELECT COUNT(*) FROM payment_late_reviews WHERE status IS NULL OR status NOT IN ('resolved', 'ignored')
)
SELECT ((SELECT valid FROM ledger) AND COALESCE(SUM(row_count), 0) = 0) AS financial_audit_clean FROM audit \gset
\if :financial_audit_clean
\echo 'PASS: no dispatchable/ambiguous financial state or orphaned gameplay state'
\else
\echo 'FAIL: reconcile every nonzero category before any payout-capable boot'
\quit 3
\endif
ROLLBACK;
@@ -0,0 +1,212 @@
-- Read-only release gate for every CHECK constraint introduced with NOT VALID in migrations.
--
-- Usage (connection arguments deliberately omitted):
-- psql -X -v ON_ERROR_STOP=1 \
-- --set=expected_database=EXACT_DATABASE_NAME \
-- --file=scripts/deploy/financial-constraint-audit.sql
--
-- This script is safe to run against a live database: it opens one repeatable-read, read-only
-- transaction, takes no explicit table lock, and makes no catalog or data change.
\set ON_ERROR_STOP on
\pset pager off
\if :{?expected_database}
\else
\echo 'FAIL: pass --set=expected_database=EXACT_DATABASE_NAME'
\quit 2
\endif
SELECT current_database() = :'expected_database' AS expected_database_matches \gset
\if :expected_database_matches
\else
\echo 'FAIL: connected database does not exactly match expected_database'
\quit 2
\endif
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
SET LOCAL statement_timeout = '10min';
SET LOCAL lock_timeout = '5s';
-- Names are intentionally explicit. A missing/renamed constraint is a release-gate failure even
-- if the equivalent predicate happens to be implemented elsewhere.
WITH expected(table_name, constraint_name) AS (
VALUES
('payments', 'payments_status_check'),
('payments', 'payments_payment_type_check'),
('payouts', 'payouts_status_check'),
('games', 'games_payout_commitment_complete'),
('games', 'games_layout_fingerprints_array_check'),
('race_entry_lots', 'race_entry_lots_remaining_within_original'),
('match_queue_entries', 'match_queue_entries_status_check'),
('match_queue_entries', 'match_queue_entries_escrow_nonnegative'),
('match_queue_entries', 'match_queue_committed_escrow_complete'),
('matches', 'matches_payout_liability_amount_nonnegative'),
('matches', 'matches_payout_liability_cap_positive'),
('matches', 'matches_payout_liability_complete'),
('matches', 'matches_payout_liability_economics_consistent'),
('race_entry_lots', 'race_entry_lots_refund_shape'),
('payment_refunds', 'payment_refunds_progress_nonnegative'),
('games', 'games_entry_consumption_shape'),
('payments', 'payments_fairness_binding_complete'),
('payouts', 'payouts_no_address_review_shape')
), actual AS (
SELECT classes.relname AS table_name,
constraints.conname AS constraint_name,
constraints.convalidated
FROM pg_catalog.pg_constraint AS constraints
JOIN pg_catalog.pg_class AS classes ON classes.oid = constraints.conrelid
JOIN pg_catalog.pg_namespace AS namespaces ON namespaces.oid = classes.relnamespace
WHERE namespaces.nspname = 'public'
AND constraints.contype = 'c'
)
SELECT COUNT(*) = 18
AND COUNT(actual.constraint_name) = 18 AS constraint_inventory_ok,
COUNT(*) FILTER (WHERE actual.convalidated IS FALSE) AS unvalidated_constraints,
COALESCE(string_agg(expected.table_name || '.' || expected.constraint_name, ', '
ORDER BY expected.table_name, expected.constraint_name)
FILTER (WHERE actual.constraint_name IS NULL), '') AS missing_constraints
FROM expected
LEFT JOIN actual USING (table_name, constraint_name)
\gset
\if :constraint_inventory_ok
\echo 'Constraint inventory present; currently unvalidated:' :unvalidated_constraints
\else
ROLLBACK;
\echo 'FAIL: required financial CHECK constraint inventory is incomplete:' :missing_constraints
\quit 3
\endif
-- These predicates mirror PostgreSQL CHECK semantics exactly: only FALSE is a violation; NULL is
-- accepted by CHECK. Counts diagnose historical rows that predate NOT VALID constraint creation.
WITH violations(constraint_name, row_count) AS (
SELECT 'payments_status_check', COUNT(*)::bigint
FROM payments
WHERE (status IN ('pending', 'confirmed', 'expired', 'refunded')) IS FALSE
UNION ALL
SELECT 'payments_payment_type_check', COUNT(*)::bigint
FROM payments
WHERE (payment_type IN ('single_game', 'credits_package', 'cosmetic_pack')) IS FALSE
UNION ALL
SELECT 'payouts_status_check', COUNT(*)::bigint
FROM payouts
WHERE (status IN ('pending', 'processing', 'completed', 'failed', 'needs_review',
'permanently_failed', 'batched')) IS FALSE
UNION ALL
SELECT 'games_payout_commitment_complete', COUNT(*)::bigint
FROM games
WHERE (payout_committed_at IS NULL
OR (payout_eligible IS NOT NULL AND payout_terms IS NOT NULL)) IS FALSE
UNION ALL
SELECT 'games_layout_fingerprints_array_check', COUNT(*)::bigint
FROM games
WHERE (layout_fingerprints IS NULL OR jsonb_typeof(layout_fingerprints) = 'array') IS FALSE
UNION ALL
SELECT 'race_entry_lots_remaining_within_original', COUNT(*)::bigint
FROM race_entry_lots
WHERE (remaining_entries >= 0 AND remaining_entries <= original_entries) IS FALSE
UNION ALL
SELECT 'match_queue_entries_status_check', COUNT(*)::bigint
FROM match_queue_entries
WHERE (status IN ('queued', 'matched', 'consumed', 'cancelled')) IS FALSE
UNION ALL
SELECT 'match_queue_entries_escrow_nonnegative', COUNT(*)::bigint
FROM match_queue_entries
WHERE (escrow_amount >= 0 AND escrow_value_atomic >= 0) IS FALSE
UNION ALL
SELECT 'match_queue_committed_escrow_complete', COUNT(*)::bigint
FROM match_queue_entries
WHERE (
status NOT IN ('matched', 'consumed')
OR economy = 'free'
OR (economy = 'credits_prestige' AND escrow_amount > 0)
OR (economy = 'crypto_race' AND escrow_amount = 1
AND escrow_value_atomic > 0 AND race_entry_lot_id IS NOT NULL)
) IS FALSE
UNION ALL
SELECT 'matches_payout_liability_amount_nonnegative', COUNT(*)::bigint
FROM matches
WHERE (payout_liability_amount_atomic IS NULL
OR payout_liability_amount_atomic >= 0) IS FALSE
UNION ALL
SELECT 'matches_payout_liability_cap_positive', COUNT(*)::bigint
FROM matches
WHERE (payout_liability_cap_atomic IS NULL OR payout_liability_cap_atomic > 0) IS FALSE
UNION ALL
SELECT 'matches_payout_liability_complete', COUNT(*)::bigint
FROM matches
WHERE (
payout_liability_accepted_at IS NULL
OR (economy = 'crypto_race'
AND payout_liability_amount_atomic IS NOT NULL
AND payout_liability_amount_atomic > 0
AND payout_liability_cap_atomic IS NOT NULL
AND payout_liability_amount_atomic <= payout_liability_cap_atomic
AND payout_liability_terms IS NOT NULL)
) IS FALSE
UNION ALL
SELECT 'matches_payout_liability_economics_consistent', COUNT(*)::bigint
FROM matches
WHERE (
payout_liability_accepted_at IS NULL
OR (entry_fee_atomic > 0
AND pot_atomic > 0
AND house_fee_atomic >= 0
AND house_fee_atomic < pot_atomic
AND payout_liability_amount_atomic = pot_atomic - house_fee_atomic)
) IS FALSE
UNION ALL
SELECT 'race_entry_lots_refund_shape', COUNT(*)::bigint
FROM race_entry_lots
WHERE (refunded_at IS NULL OR remaining_entries = 0) IS FALSE
UNION ALL
SELECT 'payment_refunds_progress_nonnegative', COUNT(*)::bigint
FROM payment_refunds
WHERE (purchase_progress_deducted >= 0) IS FALSE
UNION ALL
SELECT 'games_entry_consumption_shape', COUNT(*)::bigint
FROM games
WHERE (entry_credits_spent IS NULL OR entry_credits_spent > 0) IS FALSE
UNION ALL
SELECT 'payments_fairness_binding_complete', COUNT(*)::bigint
FROM payments
WHERE (
fairness_bound_at IS NULL
OR (payment_type = 'single_game'
AND fairness_proof_version = 2
AND fairness_offer_id IS NOT NULL
AND fairness_offer_issued_at IS NOT NULL
AND fairness_commitment ~ '^[0-9a-f]{64}$'
AND fairness_server_seed ~ '^[0-9a-f]{64}$'
AND fairness_client_seed IS NOT NULL)
) IS FALSE
UNION ALL
SELECT 'payouts_no_address_review_shape', COUNT(*)::bigint
FROM payouts
WHERE (
payout_address <> 'PENDING_NO_ADDRESS'
OR (status = 'needs_review'
AND reason IN (
'match_winner_no_address',
'solo_winner_no_address',
'solo_winner_identity_review'
)
AND tx_hash IS NULL)
) IS FALSE
)
SELECT COALESCE(SUM(row_count), 0) = 0 AS historical_constraints_clean,
jsonb_object_agg(constraint_name, row_count ORDER BY constraint_name)::text
AS violation_counts
FROM violations
\gset
\echo 'Historical NOT VALID constraint violation counts:' :violation_counts
\if :historical_constraints_clean
ROLLBACK;
\echo 'PASS: every historical row satisfies all 18 NOT VALID financial/fairness constraints'
\else
ROLLBACK;
\echo 'FAIL: reconcile every nonzero historical constraint category before release'
\quit 3
\endif
@@ -0,0 +1,103 @@
-- Prove PostgreSQL can VALIDATE every historical NOT VALID constraint on a disposable restore.
-- Every catalog update is rolled back, but ALTER TABLE VALIDATE CONSTRAINT still scans and locks;
-- this script therefore refuses databases whose names do not contain "restore" or "scratch".
-- Never run it against a live application database.
--
-- Usage (connection arguments deliberately omitted):
-- psql -X -v ON_ERROR_STOP=1 \
-- --set=expected_database=EXACT_RESTORE_DATABASE \
-- --set=confirm_disposable=I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE \
-- --file=scripts/deploy/financial-constraint-validate-restore.sql
\set ON_ERROR_STOP on
\pset pager off
\if :{?expected_database}
\else
\echo 'FAIL: pass --set=expected_database=EXACT_RESTORE_DATABASE'
\quit 2
\endif
\if :{?confirm_disposable}
\else
\echo 'FAIL: pass the documented confirm_disposable value'
\quit 2
\endif
SELECT current_database() = :'expected_database'
AND current_database() ~* '(restore|scratch)'
AND :'confirm_disposable' = 'I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE'
AS disposable_restore_confirmed
\gset
\if :disposable_restore_confirmed
\else
\echo 'FAIL: VALIDATE is restricted to the exact, explicitly confirmed restore/scratch database'
\quit 2
\endif
-- First provide named historical violation counts under a read-only snapshot. The included audit
-- exits nonzero before any ALTER if the inventory is incomplete or any old row violates a CHECK.
\ir financial-constraint-audit.sql
BEGIN;
SET LOCAL statement_timeout = '10min';
SET LOCAL lock_timeout = '5s';
ALTER TABLE public.payments VALIDATE CONSTRAINT payments_status_check;
ALTER TABLE public.payments VALIDATE CONSTRAINT payments_payment_type_check;
ALTER TABLE public.payouts VALIDATE CONSTRAINT payouts_status_check;
ALTER TABLE public.games VALIDATE CONSTRAINT games_payout_commitment_complete;
ALTER TABLE public.games VALIDATE CONSTRAINT games_layout_fingerprints_array_check;
ALTER TABLE public.race_entry_lots VALIDATE CONSTRAINT race_entry_lots_remaining_within_original;
ALTER TABLE public.match_queue_entries VALIDATE CONSTRAINT match_queue_entries_status_check;
ALTER TABLE public.match_queue_entries VALIDATE CONSTRAINT match_queue_entries_escrow_nonnegative;
ALTER TABLE public.match_queue_entries VALIDATE CONSTRAINT match_queue_committed_escrow_complete;
ALTER TABLE public.matches VALIDATE CONSTRAINT matches_payout_liability_amount_nonnegative;
ALTER TABLE public.matches VALIDATE CONSTRAINT matches_payout_liability_cap_positive;
ALTER TABLE public.matches VALIDATE CONSTRAINT matches_payout_liability_complete;
ALTER TABLE public.matches VALIDATE CONSTRAINT matches_payout_liability_economics_consistent;
ALTER TABLE public.race_entry_lots VALIDATE CONSTRAINT race_entry_lots_refund_shape;
ALTER TABLE public.payment_refunds VALIDATE CONSTRAINT payment_refunds_progress_nonnegative;
ALTER TABLE public.games VALIDATE CONSTRAINT games_entry_consumption_shape;
ALTER TABLE public.payments VALIDATE CONSTRAINT payments_fairness_binding_complete;
ALTER TABLE public.payouts VALIDATE CONSTRAINT payouts_no_address_review_shape;
WITH expected(table_name, constraint_name) AS (
VALUES
('payments', 'payments_status_check'),
('payments', 'payments_payment_type_check'),
('payouts', 'payouts_status_check'),
('games', 'games_payout_commitment_complete'),
('games', 'games_layout_fingerprints_array_check'),
('race_entry_lots', 'race_entry_lots_remaining_within_original'),
('match_queue_entries', 'match_queue_entries_status_check'),
('match_queue_entries', 'match_queue_entries_escrow_nonnegative'),
('match_queue_entries', 'match_queue_committed_escrow_complete'),
('matches', 'matches_payout_liability_amount_nonnegative'),
('matches', 'matches_payout_liability_cap_positive'),
('matches', 'matches_payout_liability_complete'),
('matches', 'matches_payout_liability_economics_consistent'),
('race_entry_lots', 'race_entry_lots_refund_shape'),
('payment_refunds', 'payment_refunds_progress_nonnegative'),
('games', 'games_entry_consumption_shape'),
('payments', 'payments_fairness_binding_complete'),
('payouts', 'payouts_no_address_review_shape')
)
SELECT COUNT(*) = 18 AND BOOL_AND(constraints.convalidated) AS all_constraints_validated
FROM expected
JOIN pg_catalog.pg_namespace AS namespaces ON namespaces.nspname = 'public'
JOIN pg_catalog.pg_class AS classes
ON classes.relnamespace = namespaces.oid AND classes.relname = expected.table_name
JOIN pg_catalog.pg_constraint AS constraints
ON constraints.conrelid = classes.oid
AND constraints.conname = expected.constraint_name
AND constraints.contype = 'c'
\gset
\if :all_constraints_validated
ROLLBACK;
\echo 'PASS: PostgreSQL validated all 18 constraints on the disposable restore; catalog changes rolled back'
\else
ROLLBACK;
\echo 'FAIL: explicit VALIDATE CONSTRAINT coverage was incomplete'
\quit 3
\endif
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""Disposable fixture proof for the parallel-wallet filesystem contract.
This test uses only synthetic bytes below a fresh TemporaryDirectory. It never imports or invokes
the host preflight, systemd, a wallet binary, a wallet file, or a credential. Run directly with:
python3 scripts/deploy/monero-stagenet-parallel-fixture-test.py
"""
from __future__ import annotations
import ctypes
import errno
import os
import pathlib
import secrets
import stat
import tempfile
import unittest
from collections.abc import Callable
O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
RENAME_NOREPLACE = 1
LIBC = ctypes.CDLL(None, use_errno=True)
class Refusal(RuntimeError):
pass
def rename_noreplace(source_fd: int, source: str, destination_fd: int, destination: str) -> None:
result = LIBC.renameat2(
ctypes.c_int(source_fd),
ctypes.c_char_p(os.fsencode(source)),
ctypes.c_int(destination_fd),
ctypes.c_char_p(os.fsencode(destination)),
ctypes.c_uint(RENAME_NOREPLACE),
)
if result != 0:
problem = ctypes.get_errno()
raise OSError(problem, os.strerror(problem), destination)
def open_directory_nofollow(path: pathlib.Path) -> int:
if not path.is_absolute() or pathlib.Path(os.path.normpath(path)) != path:
raise Refusal("path is not absolute and normalized")
descriptor = os.open("/", os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
try:
for component in path.parts[1:]:
following = os.open(
component,
os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW,
dir_fd=descriptor,
)
os.close(descriptor)
descriptor = following
return descriptor
except Exception:
os.close(descriptor)
raise
def file_identity(details: os.stat_result) -> tuple[int, ...]:
return (
details.st_dev,
details.st_ino,
details.st_uid,
details.st_gid,
stat.S_IMODE(details.st_mode),
details.st_nlink,
details.st_size,
details.st_mtime_ns,
details.st_ctime_ns,
)
def open_source(directory_fd: int, name: str) -> tuple[int, tuple[int, ...]]:
descriptor = os.open(name, os.O_RDONLY | O_NOFOLLOW, dir_fd=directory_fd)
details = os.fstat(descriptor)
if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1:
os.close(descriptor)
raise Refusal("source is not a single-linked regular file")
if stat.S_IMODE(details.st_mode) != 0o600:
os.close(descriptor)
raise Refusal("source mode differs from the contract")
return descriptor, file_identity(details)
def copy_one(
source_fd: int,
expected: tuple[int, ...],
destination_fd: int,
name: str,
after_copy: Callable[[], None] | None = None,
) -> None:
temporary = f".{name}.pending.{secrets.token_hex(8)}"
output = os.open(
temporary,
os.O_RDWR | os.O_CREAT | os.O_EXCL | O_NOFOLLOW,
0o600,
dir_fd=destination_fd,
)
published = False
try:
os.lseek(source_fd, 0, os.SEEK_SET)
while True:
block = os.read(source_fd, 1024 * 1024)
if not block:
break
view = memoryview(block)
while view:
written = os.write(output, view)
if written <= 0:
raise OSError("short synthetic copy write")
view = view[written:]
os.fsync(output)
if after_copy is not None:
after_copy()
if file_identity(os.fstat(source_fd)) != expected:
raise Refusal("source metadata changed during copy")
os.lseek(source_fd, 0, os.SEEK_SET)
os.lseek(output, 0, os.SEEK_SET)
while True:
source_block = os.read(source_fd, 1024 * 1024)
output_block = os.read(output, 1024 * 1024)
if source_block != output_block:
raise Refusal("copy differs from retained source")
if not source_block:
break
os.close(output)
output = -1
rename_noreplace(destination_fd, temporary, destination_fd, name)
published = True
os.fsync(destination_fd)
finally:
if output >= 0:
os.close(output)
if not published:
try:
os.unlink(temporary, dir_fd=destination_fd)
except FileNotFoundError:
pass
def copy_pair(source: pathlib.Path, wallet_destination: pathlib.Path) -> None:
source_fd = open_directory_nofollow(source)
destination_fd = open_directory_nofollow(wallet_destination)
try:
opened: list[tuple[int, tuple[int, ...], str]] = []
try:
for source_name, destination_name in (
("test", "stagenet"),
("test.keys", "stagenet.keys"),
):
descriptor, expected = open_source(source_fd, source_name)
opened.append((descriptor, expected, destination_name))
for descriptor, expected, destination_name in opened:
copy_one(descriptor, expected, destination_fd, destination_name)
finally:
for descriptor, _, _ in opened:
os.close(descriptor)
finally:
os.close(source_fd)
os.close(destination_fd)
def require_ready(stage: pathlib.Path) -> None:
descriptor = open_directory_nofollow(stage)
try:
marker = os.open("READY", os.O_RDONLY | O_NOFOLLOW, dir_fd=descriptor)
try:
details = os.fstat(marker)
if (
not stat.S_ISREG(details.st_mode)
or details.st_nlink != 1
or stat.S_IMODE(details.st_mode) != 0o600
):
raise Refusal("readiness marker metadata is invalid")
finally:
os.close(marker)
except FileNotFoundError:
raise Refusal("readiness marker is absent")
finally:
os.close(descriptor)
def publish(stage: pathlib.Path, final: pathlib.Path) -> None:
require_ready(stage)
if stage.parent != final.parent:
raise Refusal("publication must be one same-filesystem directory rename")
parent = open_directory_nofollow(stage.parent)
try:
rename_noreplace(parent, stage.name, parent, final.name)
os.fsync(parent)
finally:
os.close(parent)
class BundleFixture(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory(prefix="monero-parallel-fixture-")
self.root = pathlib.Path(self.temporary.name)
self.source = self.root / "legacy"
self.stage = self.root / ".candidate.staging"
self.final = self.root / "candidate"
self.source.mkdir(mode=0o700)
self.stage.mkdir(mode=0o700)
(self.stage / "wallet").mkdir(mode=0o700)
for name, content in (("test", b"synthetic-wallet\0bytes"), ("test.keys", b"synthetic-key\0bytes")):
path = self.source / name
path.write_bytes(content)
path.chmod(0o600)
def tearDown(self) -> None:
self.temporary.cleanup()
def ready(self) -> None:
marker = self.stage / "READY"
descriptor = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL | O_NOFOLLOW, 0o600)
try:
os.write(descriptor, b"network=stagenet\nrpc_bind=127.0.0.1:38084\n")
os.fchmod(descriptor, 0o600)
os.fsync(descriptor)
finally:
os.close(descriptor)
stage_fd = open_directory_nofollow(self.stage)
try:
os.fsync(stage_fd)
finally:
os.close(stage_fd)
def test_partial_stage_is_not_a_candidate(self) -> None:
copy_pair(self.source, self.stage / "wallet")
with self.assertRaises(Refusal):
publish(self.stage, self.final)
self.assertFalse(self.final.exists())
self.assertTrue(self.stage.exists())
def test_ready_bundle_is_one_atomic_nonreplacing_publish(self) -> None:
copy_pair(self.source, self.stage / "wallet")
self.ready()
publish(self.stage, self.final)
self.assertFalse(self.stage.exists())
self.assertEqual((self.final / "wallet" / "stagenet").read_bytes(), b"synthetic-wallet\0bytes")
self.assertEqual((self.final / "wallet" / "stagenet.keys").read_bytes(), b"synthetic-key\0bytes")
def test_existing_final_is_never_replaced(self) -> None:
self.ready()
self.final.mkdir(mode=0o700)
sentinel = self.final / "sentinel"
sentinel.write_text("older generation", encoding="ascii")
with self.assertRaises(OSError) as problem:
publish(self.stage, self.final)
self.assertEqual(problem.exception.errno, errno.EEXIST)
self.assertEqual(sentinel.read_text(encoding="ascii"), "older generation")
self.assertTrue(self.stage.exists())
def test_symlink_component_is_refused(self) -> None:
linked = self.root / "linked-source"
linked.symlink_to(self.source, target_is_directory=True)
with self.assertRaises(OSError):
copy_pair(linked, self.stage / "wallet")
def test_hard_linked_key_is_refused(self) -> None:
os.link(self.source / "test.keys", self.root / "second-link")
with self.assertRaises(Refusal):
copy_pair(self.source, self.stage / "wallet")
self.assertFalse((self.stage / "wallet" / "stagenet.keys").exists())
def test_source_change_is_detected_before_publication(self) -> None:
source_fd = open_directory_nofollow(self.source)
destination_fd = open_directory_nofollow(self.stage / "wallet")
descriptor, expected = open_source(source_fd, "test")
try:
def mutate() -> None:
with open(self.source / "test", "ab") as output:
output.write(b"changed")
with self.assertRaises(Refusal):
copy_one(descriptor, expected, destination_fd, "stagenet", mutate)
self.assertFalse((self.stage / "wallet" / "stagenet").exists())
finally:
os.close(descriptor)
os.close(source_fd)
os.close(destination_fd)
def test_marker_contains_no_synthetic_secret(self) -> None:
self.ready()
self.assertNotIn(b"synthetic-key", (self.stage / "READY").read_bytes())
self.assertNotIn(b"synthetic-wallet", (self.stage / "READY").read_bytes())
def test_linked_readiness_marker_is_refused(self) -> None:
external = self.root / "external-marker"
external.write_text("network=stagenet\n", encoding="ascii")
external.chmod(0o600)
os.link(external, self.stage / "READY")
with self.assertRaises(Refusal):
publish(self.stage, self.final)
self.assertFalse(self.final.exists())
if __name__ == "__main__":
os.umask(0o077)
unittest.main(verbosity=2)
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""Read-only preflight for a parallel Monero stagenet wallet-RPC candidate.
This script does not read systemd unit text, process arguments, wallet bytes, keys, passwords, or
mnemonics. It does not create, modify, move, or delete anything. It validates only public RPC
identity, process/listener identity, path metadata, capacity, and inert candidate prerequisites.
"""
from __future__ import annotations
import argparse
import ctypes
import grp
import http.client
import json
import os
import pathlib
import pwd
import resource
import socket
import stat
import subprocess
import sys
import urllib.error
import urllib.request
LEGACY_UNIT = "monero-wallet-rpc.service"
LEGACY_USER = "jw"
LEGACY_GROUP = "jw"
LEGACY_BINARY = pathlib.Path("/usr/bin/monero-wallet-rpc")
LEGACY_DIR = pathlib.Path("/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4")
LEGACY_WALLET = LEGACY_DIR / "test"
LEGACY_KEYS = LEGACY_DIR / "test.keys"
LEGACY_RPC_PORT = 38083
LEGACY_RPC_URL = "http://127.0.0.1:38083/json_rpc"
CANDIDATE_UNIT = "monerogue-stagenet-wallet-candidate.service"
CANDIDATE_USER = "monerogue-wallet-candidate"
CANDIDATE_GROUP = "monerogue-wallet-candidate"
CANDIDATE_HOME = pathlib.Path("/var/lib/monerogue-stagenet-wallet-candidate")
CANDIDATE_STAGE = pathlib.Path("/var/lib/.monerogue-stagenet-wallet-candidate.staging")
CANDIDATE_BINARY = pathlib.Path("/usr/bin/monero-wallet-rpc")
CANDIDATE_RPC_PORT = 38084
VERIFY_RPC_PORT = 39084
ADDRESS_REQUEST = {
"jsonrpc": "2.0",
"id": "parallel-preflight",
"method": "get_address",
"params": {"account_index": 0},
}
O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
LIBC = ctypes.CDLL(None, use_errno=True)
class Refusal(RuntimeError):
pass
def refuse(message: str) -> None:
raise Refusal(message)
def harden_process() -> None:
os.umask(0o077)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
try:
if LIBC.prctl(4, 0, 0, 0, 0) != 0: # PR_SET_DUMPABLE
refuse("could not disable process dumps")
except AttributeError:
refuse("could not disable process dumps")
def identity(user: str, group: str) -> tuple[int, int]:
try:
return pwd.getpwnam(user).pw_uid, grp.getgrnam(group).gr_gid
except KeyError:
refuse(f"required identity is missing: {user}:{group}")
def open_directory_nofollow(path: pathlib.Path) -> int:
if not path.is_absolute() or pathlib.Path(os.path.normpath(path)) != path:
refuse("path is not absolute and normalized")
descriptor = os.open("/", os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
try:
for component in path.parts[1:]:
following = os.open(
component,
os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW,
dir_fd=descriptor,
)
os.close(descriptor)
descriptor = following
if not stat.S_ISDIR(os.fstat(descriptor).st_mode):
refuse("opened path is not a directory")
return descriptor
except Exception:
os.close(descriptor)
raise
def require_directory(path: pathlib.Path, user: str, group: str, mode: int) -> os.stat_result:
try:
descriptor = open_directory_nofollow(path)
except (FileNotFoundError, NotADirectoryError, OSError):
refuse(f"required real directory is unavailable: {path}")
try:
details = os.fstat(descriptor)
expected = (*identity(user, group), mode)
actual = (details.st_uid, details.st_gid, stat.S_IMODE(details.st_mode))
if actual != expected:
refuse(f"directory metadata mismatch: {path}")
return details
finally:
os.close(descriptor)
def require_regular(path: pathlib.Path, user: str, group: str, mode: int) -> os.stat_result:
parent = open_directory_nofollow(path.parent)
try:
try:
descriptor = os.open(path.name, os.O_RDONLY | O_NOFOLLOW, dir_fd=parent)
except OSError:
refuse(f"required real file is unavailable: {path}")
try:
details = os.fstat(descriptor)
expected = (*identity(user, group), mode)
actual = (details.st_uid, details.st_gid, stat.S_IMODE(details.st_mode))
if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1 or actual != expected:
refuse(f"file metadata mismatch or hard link refused: {path}")
if details.st_size <= 0:
refuse(f"required file is empty: {path}")
return details
finally:
os.close(descriptor)
finally:
os.close(parent)
def systemctl(*arguments: str) -> subprocess.CompletedProcess[bytes]:
try:
return subprocess.run(
["/usr/bin/systemctl", *arguments],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
check=False,
timeout=20,
)
except (OSError, subprocess.TimeoutExpired):
refuse("systemd state check failed")
def main_pid(unit: str) -> int:
result = systemctl("show", unit, "--property=MainPID", "--value")
if result.returncode != 0:
refuse("systemd MainPID check failed")
try:
return int(result.stdout.strip())
except ValueError:
refuse("systemd returned an invalid MainPID")
def process_uid(pid: int) -> int:
try:
with open(f"/proc/{pid}/status", "r", encoding="ascii") as source:
for line in source:
if line.startswith("Uid:"):
values = line.split()[1:]
if len(values) == 4 and len(set(values)) == 1:
return int(values[0])
except (OSError, UnicodeDecodeError, ValueError):
pass
refuse("legacy process UID is unavailable")
def process_start_time(pid: int) -> str:
try:
raw = pathlib.Path(f"/proc/{pid}/stat").read_text(encoding="ascii")
fields = raw[raw.rfind(")") + 2 :].split()
return fields[19]
except (OSError, UnicodeDecodeError, IndexError):
refuse("legacy process start time is unavailable")
def process_has_unit_cgroup(pid: int, unit: str) -> bool:
try:
lines = pathlib.Path(f"/proc/{pid}/cgroup").read_text(encoding="ascii").splitlines()
except (OSError, UnicodeDecodeError):
refuse("legacy process cgroup is unavailable")
return any(line.rsplit(":", 1)[-1].endswith(f"/{unit}") for line in lines)
def parse_listener_table(path: str, port: int, ipv6: bool) -> list[tuple[str, int]]:
found: list[tuple[str, int]] = []
try:
with open(path, "r", encoding="ascii") as source:
next(source, None)
for line in source:
fields = line.split()
if len(fields) < 10 or fields[3] != "0A":
continue
address_hex, port_hex = fields[1].split(":")
if int(port_hex, 16) != port:
continue
if ipv6:
address = socket.inet_ntop(socket.AF_INET6, bytes.fromhex(address_hex))
else:
address = socket.inet_ntoa(bytes.fromhex(address_hex)[::-1])
found.append((address, int(fields[9])))
except (OSError, UnicodeDecodeError, ValueError, IndexError):
refuse("kernel listener state is unavailable")
return found
def listeners(port: int) -> list[tuple[str, int]]:
return parse_listener_table("/proc/net/tcp", port, False) + parse_listener_table(
"/proc/net/tcp6", port, True
)
def process_owns_socket(pid: int, inode: int) -> bool:
expected = f"socket:[{inode}]"
try:
for entry in os.scandir(f"/proc/{pid}/fd"):
try:
if os.readlink(entry.path) == expected:
return True
except OSError:
continue
except OSError:
refuse("legacy process descriptors are unavailable")
return False
def require_exact_listener(pid: int, port: int) -> None:
active = listeners(port)
if len(active) != 1 or active[0][0] != "127.0.0.1":
refuse(f"port {port} is not one exact IPv4 loopback listener")
if not process_owns_socket(pid, active[0][1]):
refuse(f"port {port} is not owned by the expected process")
def require_unused_port(port: int) -> None:
if listeners(port):
refuse(f"candidate port {port} is already in use")
def validate_public_address() -> None:
body = json.dumps(ADDRESS_REQUEST, separators=(",", ":")).encode("ascii")
request = urllib.request.Request(
LEGACY_RPC_URL,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
# Never honor ambient proxy settings for a loopback identity check.
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(request, timeout=5) as response:
if response.status != 200:
refuse("legacy wallet RPC returned an unexpected status")
raw = response.read(1024 * 1024 + 1)
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException):
refuse("legacy wallet RPC public address check failed")
if len(raw) > 1024 * 1024:
refuse("legacy wallet RPC response is oversized")
try:
payload = json.loads(raw.decode("utf-8"))
address = payload["result"]["address"]
except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError):
refuse("legacy wallet RPC returned an invalid public address response")
alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
if (
not isinstance(address, str)
or len(address) != 95
or not address.startswith("5")
or any(character not in alphabet for character in address)
):
refuse("legacy wallet RPC address is not a primary stagenet address")
def validate_legacy() -> tuple[os.stat_result, os.stat_result]:
if os.geteuid() != 0:
refuse("run as root so process ownership checks cannot be hidden")
if systemctl("is-active", "--quiet", LEGACY_UNIT).returncode != 0:
refuse("legacy wallet service must be active")
pid = main_pid(LEGACY_UNIT)
if pid <= 1:
refuse("legacy wallet service has no stable MainPID")
started = process_start_time(pid)
expected_uid, _ = identity(LEGACY_USER, LEGACY_GROUP)
if process_uid(pid) != expected_uid:
refuse("legacy wallet process UID is unexpected")
try:
executable = pathlib.Path(os.readlink(f"/proc/{pid}/exe"))
except OSError:
refuse("legacy wallet process executable is unavailable")
if executable != LEGACY_BINARY:
refuse("legacy wallet process executable is unexpected")
if not process_has_unit_cgroup(pid, LEGACY_UNIT):
refuse("legacy wallet process cgroup is unexpected")
require_exact_listener(pid, LEGACY_RPC_PORT)
require_directory(LEGACY_DIR, LEGACY_USER, LEGACY_GROUP, 0o775)
wallet = require_regular(LEGACY_WALLET, LEGACY_USER, LEGACY_GROUP, 0o600)
keys = require_regular(LEGACY_KEYS, LEGACY_USER, LEGACY_GROUP, 0o600)
validate_public_address()
if main_pid(LEGACY_UNIT) != pid or process_start_time(pid) != started:
refuse("legacy wallet process changed during preflight")
require_exact_listener(pid, LEGACY_RPC_PORT)
return wallet, keys
def validate_candidate_prerequisites(wallet: os.stat_result, keys: os.stat_result) -> None:
try:
record = pwd.getpwnam(CANDIDATE_USER)
group = grp.getgrnam(CANDIDATE_GROUP)
except KeyError:
refuse("parallel candidate service identity is not provisioned")
if (
record.pw_uid >= 1000
or record.pw_gid != group.gr_gid
or os.getgrouplist(CANDIDATE_USER, record.pw_gid) != [record.pw_gid]
or record.pw_dir != os.fspath(CANDIDATE_HOME)
or record.pw_shell not in ("/usr/sbin/nologin", "/bin/false")
):
refuse("parallel candidate identity is not a dedicated system account")
require_regular(CANDIDATE_BINARY, "root", "root", 0o755)
require_directory(CANDIDATE_HOME.parent, "root", "root", 0o755)
if os.path.lexists(CANDIDATE_STAGE) or os.path.lexists(CANDIDATE_HOME):
refuse("candidate staging/final path already exists; do not overwrite it")
if systemctl("is-active", "--quiet", CANDIDATE_UNIT).returncode == 0:
refuse("candidate unit must be inactive")
require_unused_port(CANDIDATE_RPC_PORT)
require_unused_port(VERIFY_RPC_PORT)
capacity = os.statvfs(CANDIDATE_HOME.parent)
available = capacity.f_bavail * capacity.f_frsize
required = wallet.st_size + keys.st_size + 64 * 1024 * 1024
if available < required:
refuse("insufficient space for a non-replacing candidate wallet copy")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Read-only Monero stagenet parallel preflight")
parser.add_argument(
"scope",
choices=("legacy", "candidate-prereqs"),
help="legacy checks the current RPC; candidate-prereqs also checks inert new-path gates",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
options = parse_args(sys.argv[1:] if argv is None else argv)
harden_process()
wallet, keys = validate_legacy()
if options.scope == "candidate-prereqs":
validate_candidate_prerequisites(wallet, keys)
print(f"PASSED: read-only {options.scope} preflight")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Refusal as problem:
print(f"REFUSED: {problem}", file=sys.stderr)
raise SystemExit(1)
except Exception:
# Do not render process or RPC data in an unexpected exception.
print("REFUSED: unexpected read-only preflight failure", file=sys.stderr)
raise SystemExit(1)
@@ -0,0 +1,11 @@
# Install as /etc/monerogue-wallet/wallet-rpc.conf, owned root:monerogue-wallet, mode 0640.
# Replace CHANGE_ME with an independently generated password. The same rpc-login credentials
# must be set as WALLET_RPC_USER/WALLET_RPC_PASSWORD in the app's protected .env file.
stagenet=1
wallet-file=/var/lib/monerogue-wallet/stagenet
password-file=/etc/monerogue-wallet/wallet-password
rpc-bind-port=38083
rpc-bind-ip=127.0.0.1
daemon-address=127.0.0.1:38081
rpc-login=monerogue:CHANGE_ME_STRONG_RPC_PASSWORD
non-interactive=1
@@ -0,0 +1,54 @@
# Manual-only, parallel Monero STAGENET wallet-RPC candidate.
#
# REVIEW STATUS: INERT TEMPLATE. Do not install until the bundle producer and its crash-injection
# evidence satisfy README-monero-stagenet-parallel-candidate.md. This unit intentionally has no
# installation stanza, so it cannot be enabled into a boot target.
[Unit]
Description=Monerogue parallel wallet RPC candidate (stagenet, manual only)
After=network-online.target monerod.service
Wants=network-online.target monerod.service
ConditionPathExists=/var/lib/monerogue-stagenet-wallet-candidate/READY
[Service]
Type=simple
User=monerogue-wallet-candidate
Group=monerogue-wallet-candidate
SupplementaryGroups=
WorkingDirectory=/var/lib/monerogue-stagenet-wallet-candidate/wallet
# The protected config fixes stagenet, wallet/password paths, daemon 127.0.0.1:38081,
# RPC 127.0.0.1:38084, and Digest rpc-login. No secret belongs in this unit or argv.
ExecStart=/usr/bin/monero-wallet-rpc --config-file /var/lib/monerogue-stagenet-wallet-candidate/config/wallet-rpc.conf --log-file /dev/null
# A candidate must be restarted deliberately and revalidated; it is not a production dependency.
Restart=no
TimeoutStopSec=60
UMask=0077
LimitCORE=0
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
PrivateUsers=no
RemoveIPC=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
RestrictRealtime=yes
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
MemoryDenyWriteExecute=yes
KeyringMode=private
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
ReadWritePaths=/var/lib/monerogue-stagenet-wallet-candidate/wallet
+30 -3
View File
@@ -5,18 +5,45 @@
# two are fully independent. NPM (Nginx Proxy Manager) already forwards monerogue.app -> :3001.
[Unit]
Description=Monerogue Game Server (Monero Stagenet)
After=network.target postgresql.service monero-wallet-rpc.service
After=network-online.target postgresql.service monero-wallet-rpc.service
Wants=network-online.target
Wants=monero-wallet-rpc.service
[Service]
Type=simple
User=monerogue
Group=monerogue
WorkingDirectory=/var/www/monerogue/app/src
EnvironmentFile=/var/www/monerogue/app/src/.env
WorkingDirectory=/var/www/monerogue/current/src
EnvironmentFile=/etc/monerogue/app.env
EnvironmentFile=/etc/monerogue/wallet-rpc.env
ExecStartPre=/usr/bin/node scripts/preflight.js
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=10
TimeoutStopSec=30
KillSignal=SIGTERM
UMask=0077
# Process/filesystem hardening. Application state lives in PostgreSQL, so the checkout is
# intentionally read-only; PrivateTmp provides the only disposable writable filesystem.
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
# Install as /etc/wownerogue/wallet-rpc.conf, owned root:wownerogue-wallet, mode 0640.
# Replace CHANGE_ME with an independently generated password. Configure the same credentials
# as WALLET_RPC_USER/WALLET_RPC_PASSWORD in the app's protected .env file.
wallet-file=/var/lib/wownerogue-wallet/mainnet
password-file=/etc/wownerogue/wallet-password
rpc-bind-port=34570
rpc-bind-ip=127.0.0.1
daemon-address=127.0.0.1:34568
rpc-login=wownerogue:CHANGE_ME_STRONG_RPC_PASSWORD
non-interactive=1
+42
View File
@@ -0,0 +1,42 @@
# Wownero MAINNET wallet RPC for play.wowne.ro.
# Install to /etc/systemd/system/wownero-wallet-rpc.service.
[Unit]
Description=Wownero Wallet RPC (Mainnet) - wownerogue
After=network-online.target wownerod.service
Wants=network-online.target
Wants=wownerod.service
[Service]
User=wownerogue-wallet
Group=wownerogue-wallet
Type=simple
ExecStart=/usr/bin/wownero-wallet-rpc --config-file /etc/wownerogue/wallet-rpc.conf
Restart=always
RestartSec=10
TimeoutStopSec=60
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
ReadWritePaths=/var/lib/wownerogue-wallet
[Install]
WantedBy=multi-user.target
+44
View File
@@ -0,0 +1,44 @@
# play.wowne.ro game instance (Wownero mainnet, free + paid prestige, no payouts).
# Install to /etc/systemd/system/wownerogue.service.
[Unit]
Description=Wownerogue Game Server (Wownero Mainnet)
After=network-online.target postgresql.service wownero-wallet-rpc.service
Wants=network-online.target
Wants=wownero-wallet-rpc.service
[Service]
Type=simple
User=wownerogue
Group=wownerogue
WorkingDirectory=/var/www/wownerogue/current/src
EnvironmentFile=/etc/wownerogue/app.env
EnvironmentFile=/etc/wownerogue/wallet-rpc.env
ExecStartPre=/usr/bin/node scripts/preflight.js
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=10
TimeoutStopSec=30
KillSignal=SIGTERM
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
+33
View File
@@ -0,0 +1,33 @@
[Unit]
Description=Validated PostgreSQL backups for Wowngeon instances
After=postgresql.service
Requires=postgresql.service
[Service]
Type=oneshot
User=postgres
Group=postgres
Environment=BACKUP_ROOT=/var/backups/wowngeon/daily
Environment=BACKUP_RETENTION_DAYS=14
ExecStart=/usr/local/sbin/wowngeon-db-backup
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX
SystemCallArchitectures=native
ReadWritePaths=/var/backups/wowngeon/daily
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
set -eu
# Atomic daily custom-format dumps for both production instances. Run as the postgres OS user;
# the service unit grants write access only to this dedicated directory.
backup_root=${BACKUP_ROOT:-/var/backups/wowngeon/daily}
retention_days=${BACKUP_RETENTION_DAYS:-14}
stamp=$(/usr/bin/date -u +%Y%m%dT%H%M%SZ)
[ "$backup_root" = /var/backups/wowngeon/daily ] || {
echo "Refusing an unapproved backup root: $backup_root" >&2
exit 64
}
case "$retention_days" in
''|*[!0-9]*) echo "BACKUP_RETENTION_DAYS must be a positive integer" >&2; exit 64 ;;
esac
[ "$retention_days" -gt 0 ] || { echo "BACKUP_RETENTION_DAYS must be positive" >&2; exit 64; }
[ -d "$backup_root" ] && [ ! -L "$backup_root" ] || {
echo "Backup root is missing or is a symlink" >&2
exit 66
}
for database_name in wownerogue monerogue; do
final_path="${backup_root}/${database_name}-${stamp}.dump"
temporary_path="${backup_root}/.${database_name}-${stamp}.dump.tmp"
checksum_path="${final_path}.sha256"
checksum_temporary="${checksum_path}.tmp"
/usr/bin/pg_dump --format=custom --no-owner --file="$temporary_path" "$database_name"
/usr/bin/pg_restore --list "$temporary_path" >/dev/null
/usr/bin/chmod 0600 "$temporary_path"
/usr/bin/mv "$temporary_path" "$final_path"
(
cd "$backup_root"
/usr/bin/sha256sum "$(/usr/bin/basename "$final_path")"
) >"$checksum_temporary"
/usr/bin/chmod 0600 "$checksum_temporary"
/usr/bin/mv "$checksum_temporary" "$checksum_path"
done
# Local copies are an operational convenience, not the retention archive. The independently
# encrypted restic vault keeps GFS history; bound this same-disk directory so it cannot fill `/`.
/usr/bin/find "$backup_root" -maxdepth 1 -type f \
\( -name 'monerogue-*.dump' -o -name 'monerogue-*.dump.sha256' \
-o -name 'wownerogue-*.dump' -o -name 'wownerogue-*.dump.sha256' \) \
-mtime "+$retention_days" -delete
/usr/bin/find "$backup_root" -maxdepth 1 -type f -name '.*.tmp' -mtime +1 -delete
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Daily Wowngeon PostgreSQL backups
[Timer]
OnCalendar=*-*-* 03:20:00
RandomizedDelaySec=20m
Persistent=true
Unit=wowngeon-db-backup.service
[Install]
WantedBy=timers.target
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=Wowngeon application and wallet RPC firewall
After=network.target
Before=wownerogue.service monerogue.service wownero-wallet-rpc.service monero-wallet-rpc.service
[Service]
Type=oneshot
EnvironmentFile=/etc/wowngeon/firewall.env
ExecStart=/usr/local/sbin/wowngeon-firewall
RemainAfterExit=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ProtectKernelTunables=no
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
LockPersonality=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
# Idempotent application/RPC isolation. Install mode 0755 at
# /usr/local/sbin/wowngeon-firewall and provide NPM_SOURCE_IPV4 through the unit's EnvironmentFile.
: "${NPM_SOURCE_IPV4:?Set NPM_SOURCE_IPV4 to the reverse proxy address}"
ensure_rule() {
command_name=$1
shift
if ! "$command_name" -C INPUT "$@" 2>/dev/null; then
"$command_name" -I INPUT 1 "$@"
fi
}
# Only the reverse proxy (and local probes) may reach either Node listener.
ensure_rule /usr/sbin/iptables ! -i lo ! -s "$NPM_SOURCE_IPV4" \
-p tcp -m multiport --dports 3000,3001 -j REJECT
ensure_rule /usr/sbin/ip6tables ! -i lo \
-p tcp -m multiport --dports 3000,3001 -j REJECT
# Wallet and daemon JSON-RPC are local control planes. P2P ports are intentionally untouched.
ensure_rule /usr/sbin/iptables ! -i lo \
-p tcp -m multiport --dports 34568,34570,38081,38083 -j REJECT
ensure_rule /usr/sbin/ip6tables ! -i lo \
-p tcp -m multiport --dports 34568,34570,38081,38083 -j REJECT