mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 22:58:32 +02:00
feat: productionize paid modes and PvP capture
This commit is contained in:
@@ -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.
|
||||
Executable
+275
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user