Wownerogue

A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
This commit is contained in:
jwinterm
2026-07-27 08:23:01 -04:00
commit fad2a695bd
578 changed files with 102281 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# PostgreSQL backup script for Wownerogue
# Reads configuration from environment variables (set in .env or systemd service)
set -e
# Configuration with defaults
DB_NAME="${DB_NAME:-wownerogue}"
DB_USER="${DB_USER:-postgres}"
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
BACKUP_DIR="${BACKUP_DIR:-/var/backups/wownerogue}"
RETENTION_DAYS="${RETENTION_DAYS:-30}"
# Create backup directory if it doesn't exist
mkdir -p "$BACKUP_DIR"
# Timestamp for filename
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/${DB_NAME}_${TIMESTAMP}.sql.gz"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
log "Starting backup of database '$DB_NAME' on $DB_HOST:$DB_PORT..."
# Perform backup with gzip compression
if pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" | gzip > "$BACKUP_FILE"; then
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
log "Backup completed successfully: $BACKUP_FILE ($BACKUP_SIZE)"
else
log "ERROR: Backup failed!"
rm -f "$BACKUP_FILE" # Remove partial backup
exit 1
fi
# Create a symlink to latest backup for easy access
ln -sf "$BACKUP_FILE" "$BACKUP_DIR/${DB_NAME}_LATEST.sql.gz"
# Delete backups older than retention period
log "Cleaning up backups older than $RETENTION_DAYS days..."
DELETED_COUNT=$(find "$BACKUP_DIR" -name "${DB_NAME}_[0-9]*.sql.gz" -mtime +$RETENTION_DAYS -delete -print | wc -l)
log "Deleted $DELETED_COUNT old backup(s)"
# Show disk usage
TOTAL_SIZE=$(du -sh "$BACKUP_DIR" | cut -f1)
BACKUP_COUNT=$(find "$BACKUP_DIR" -name "${DB_NAME}_[0-9]*.sql.gz" | wc -l)
log "Backup directory: $BACKUP_DIR ($TOTAL_SIZE, $BACKUP_COUNT backups)"
log "Backup process complete."
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Build runtime GLBs for Kenney "Animated Characters 1".
Run with Blender, not plain Python:
blender --background --python scripts/build_kenney_3d_characters.py
The source FBX files remain in your local Kenney bundle. Outputs are written under
html/assets/generated/ (gitignored) for local delivery.
Point KENNEY_ASSETS_DIR at the "Animated Characters 1" directory of your own copy of the
Kenney Game Assets bundle, for example:
KENNEY_ASSETS_DIR="$HOME/assets/Kenney Game Assets All-in-1 1.1.0/3D assets/Animated Characters 1" \
blender --background --python scripts/build_kenney_3d_characters.py
"""
import os
from pathlib import Path
import bpy
ROOT = Path(__file__).resolve().parents[1]
_kenney_dir = os.environ.get("KENNEY_ASSETS_DIR")
if not _kenney_dir:
raise SystemExit(
"Set KENNEY_ASSETS_DIR to the 'Animated Characters 1' directory of your Kenney bundle."
)
KENNEY = Path(_kenney_dir).expanduser()
OUT = ROOT / "html/assets/generated/3d/kenney-animated-characters"
MODEL = KENNEY / "Model/characterMedium.fbx"
ANIMS = {
"idle": KENNEY / "Animations/idle.fbx",
"run": KENNEY / "Animations/run.fbx",
"jump": KENNEY / "Animations/jump.fbx",
}
SKINS = {
"survivorMaleB": KENNEY / "Skins/survivorMaleB.png",
"survivorFemaleA": KENNEY / "Skins/survivorFemaleA.png",
}
def clear_scene():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
def import_fbx(path):
before = set(bpy.data.objects)
bpy.ops.import_scene.fbx(filepath=str(path), automatic_bone_orientation=True)
return [obj for obj in bpy.data.objects if obj not in before]
def find_armature(objects):
for obj in objects:
if obj.type == "ARMATURE":
return obj
return None
def assign_skin(meshes, texture_path):
image = bpy.data.images.load(str(texture_path), check_existing=True)
mat = bpy.data.materials.new(texture_path.stem)
mat.use_nodes = True
nodes = mat.node_tree.nodes
bsdf = nodes.get("Principled BSDF")
tex = nodes.new("ShaderNodeTexImage")
tex.image = image
mat.node_tree.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
if "Alpha" in tex.outputs and "Alpha" in bsdf.inputs:
mat.node_tree.links.new(tex.outputs["Alpha"], bsdf.inputs["Alpha"])
mat.blend_method = "BLEND"
for mesh in meshes:
mesh.data.materials.clear()
mesh.data.materials.append(mat)
def append_animation(main_armature, anim_name, anim_path):
imported = import_fbx(anim_path)
anim_armature = find_armature(imported)
action = anim_armature.animation_data.action if anim_armature and anim_armature.animation_data else None
if action:
copied = action.copy()
copied.name = anim_name
main_armature.animation_data_create()
main_armature.animation_data.action = copied
track = main_armature.animation_data.nla_tracks.new()
track.name = anim_name
strip = track.strips.new(anim_name, int(copied.frame_range[0]), copied)
strip.name = anim_name
main_armature.animation_data.action = None
for obj in imported:
bpy.data.objects.remove(obj, do_unlink=True)
def build_skin(name, texture_path):
clear_scene()
imported = import_fbx(MODEL)
armature = find_armature(imported)
if not armature:
raise RuntimeError("No armature found in characterMedium.fbx")
meshes = [obj for obj in imported if obj.type == "MESH"]
assign_skin(meshes, texture_path)
for anim_name, anim_path in ANIMS.items():
append_animation(armature, anim_name, anim_path)
bpy.ops.object.select_all(action="DESELECT")
for obj in imported:
obj.select_set(True)
bpy.context.view_layer.objects.active = armature
OUT.mkdir(parents=True, exist_ok=True)
out_path = OUT / f"{name}.glb"
bpy.ops.export_scene.gltf(
filepath=str(out_path),
export_format="GLB",
use_selection=True,
export_animations=True,
export_nla_strips=True,
export_force_sampling=True,
)
print(f"Wrote {out_path}")
def main():
for name, skin in SKINS.items():
build_skin(name, skin)
if __name__ == "__main__":
main()
+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 NULL OR status NOT IN ('recorded', '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(*) = 43
AND MAX(filename) = '043_durable_solo_restart_snapshots.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 NULL OR status NOT IN ('recorded', '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
@@ -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
+46
View File
@@ -0,0 +1,46 @@
# Monero STAGENET wallet RPC for the monerogue instance.
# Install to /etc/systemd/system/monero-wallet-rpc.service.
#
# Wallet credentials belong in /etc/monerogue/wallet-rpc.conf (root:monerogue-wallet 0640), never in
# this unit: anything passed on the command line is readable from /proc by any local user. See
# monero-wallet-rpc.conf.example.
[Unit]
Description=Monero Wallet RPC (Stagenet) - monerogue
After=network-online.target monerod.service
Wants=network-online.target
Wants=monerod.service
[Service]
User=monerogue-wallet
Group=monerogue-wallet
Type=simple
ExecStart=/usr/bin/monero-wallet-rpc --config-file /etc/monerogue/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/monerogue-wallet
[Install]
WantedBy=multi-user.target
+43
View File
@@ -0,0 +1,43 @@
[Unit]
Description=Monerogue Game Server (Monero Stagenet)
After=network-online.target postgresql.service monerogue-wallet.service
Wants=network-online.target
Requires=monerogue-wallet.service
[Service]
Type=simple
User=monerogue
Group=monerogue
WorkingDirectory=/var/www/monerogue/current/src
EnvironmentFile=/etc/monerogue/app.env
EnvironmentFile=/etc/monerogue/wallet-rpc.env
UnsetEnvironment=BASH_ENV ENV IFS PATH SHELL HOME LD_PRELOAD LD_LIBRARY_PATH LD_AUDIT GLIBC_TUNABLES NODE_OPTIONS NODE_PATH NODE_EXTRA_CA_CERTS PYTHONHOME PYTHONPATH OPENSSL_CONF SSL_CERT_FILE SSL_CERT_DIR DOTENV_CONFIG_PATH DOTENV_KEY DATABASE_URL HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy PGHOST PGHOSTADDR PGPORT PGDATABASE PGUSER PGPASSWORD PGPASSFILE PGSERVICE PGSERVICEFILE PGOPTIONS PGSSLMODE PGSSLROOTCERT PGSSLCRL PGSSLCERT PGSSLKEY PGCHANNELBINDING PGTARGETSESSIONATTRS PGCONNECT_TIMEOUT NPM_CONFIG_PREFIX NPM_CONFIG_CACHE NPM_CONFIG_USERCONFIG NPM_CONFIG_GLOBALCONFIG NPM_CONFIG_REGISTRY NPM_CONFIG_NODE_OPTIONS NPM_CONFIG_SCRIPT_SHELL NPM_CONFIG_SHELL NPM_CONFIG_PROXY NPM_CONFIG_HTTPS_PROXY NPM_CONFIG_CAFILE NPM_CONFIG_CERT NPM_CONFIG_KEY npm_config_prefix npm_config_cache npm_config_userconfig npm_config_globalconfig npm_config_registry npm_config_node_options npm_config_script_shell npm_config_shell npm_config_proxy npm_config_https_proxy npm_config_cafile npm_config_cert npm_config_key
ExecStartPre=/usr/bin/node scripts/preflight.js
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=10
TimeoutStopSec=30
KillSignal=SIGTERM
UMask=0077
LimitCORE=0
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
+47
View File
@@ -0,0 +1,47 @@
# play.wowne.ro: Such Software operated Wownero mainnet free + pay-for-credits prestige.
# No direct paid entry, payouts, cash-out, or crypto racing; not marketed as gambling.
# Install to /etc/systemd/system/wownerogue.service.
[Unit]
Description=Wownerogue Game Server (Wownero Mainnet)
After=network-online.target postgresql.service wownerogue-wallet.service
Wants=network-online.target
Requires=wownerogue-wallet.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
UnsetEnvironment=BASH_ENV ENV IFS PATH SHELL HOME LD_PRELOAD LD_LIBRARY_PATH LD_AUDIT GLIBC_TUNABLES NODE_OPTIONS NODE_PATH NODE_EXTRA_CA_CERTS PYTHONHOME PYTHONPATH OPENSSL_CONF SSL_CERT_FILE SSL_CERT_DIR DOTENV_CONFIG_PATH DOTENV_KEY DATABASE_URL HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy PGHOST PGHOSTADDR PGPORT PGDATABASE PGUSER PGPASSWORD PGPASSFILE PGSERVICE PGSERVICEFILE PGOPTIONS PGSSLMODE PGSSLROOTCERT PGSSLCRL PGSSLCERT PGSSLKEY PGCHANNELBINDING PGTARGETSESSIONATTRS PGCONNECT_TIMEOUT NPM_CONFIG_PREFIX NPM_CONFIG_CACHE NPM_CONFIG_USERCONFIG NPM_CONFIG_GLOBALCONFIG NPM_CONFIG_REGISTRY NPM_CONFIG_NODE_OPTIONS NPM_CONFIG_SCRIPT_SHELL NPM_CONFIG_SHELL NPM_CONFIG_PROXY NPM_CONFIG_HTTPS_PROXY NPM_CONFIG_CAFILE NPM_CONFIG_CERT NPM_CONFIG_KEY npm_config_prefix npm_config_cache npm_config_userconfig npm_config_globalconfig npm_config_registry npm_config_node_options npm_config_script_shell npm_config_shell npm_config_proxy npm_config_https_proxy npm_config_cafile npm_config_cert npm_config_key
ExecStartPre=/usr/bin/node scripts/preflight.js
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=10
TimeoutStopSec=30
KillSignal=SIGTERM
UMask=0077
LimitCORE=0
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 wownerogue-wallet.service monerogue-wallet.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
+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env node
/**
* splitOutputs.js: split wallet outputs for concurrent payout capacity.
*
* Wownero/Monero lock change outputs for several blocks after spending.
* Pre-splitting one large output into many smaller ones allows the wallet
* to handle multiple concurrent payouts without "not enough unlocked balance".
*
* Usage:
* node scripts/splitOutputs.js [options]
*
* Options:
* --amount <WOW> Target output size in WOW (default: 10)
* --count <N> Number of outputs to create (default: 20)
* --status Show current output breakdown and exit
* --endpoint <url> Wallet RPC endpoint (overrides PRIMARY_WALLET_ENDPOINT)
*/
const path = require('path');
const fs = require('fs');
const http = require('http');
// Parse .env manually so the script needs no external dependencies.
function loadEnv() {
const envPath = path.resolve(__dirname, '..', 'src', '.env');
try {
const lines = fs.readFileSync(envPath, 'utf8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.substring(0, eq).trim();
const val = trimmed.substring(eq + 1).trim().replace(/^['"]|['"]$/g, '');
if (!process.env[key]) process.env[key] = val;
}
} catch (e) {
// .env not found. The caller can pass --endpoint instead.
}
}
loadEnv();
const ATOMIC_DIVISOR = 1e11; // Wownero: 11 decimal places
function parseArgs() {
const args = process.argv.slice(2);
const opts = {
amount: 10,
count: 20,
status: false,
endpoint: process.env.PRIMARY_WALLET_ENDPOINT || 'http://127.0.0.1:34570'
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--amount':
opts.amount = parseFloat(args[++i]);
if (!opts.amount || opts.amount <= 0) {
console.error('Error: --amount must be a positive number');
process.exit(1);
}
break;
case '--count':
opts.count = parseInt(args[++i], 10);
if (!opts.count || opts.count <= 0) {
console.error('Error: --count must be a positive integer');
process.exit(1);
}
break;
case '--status':
opts.status = true;
break;
case '--endpoint':
opts.endpoint = args[++i];
break;
case '--help':
case '-h':
console.log(`
splitOutputs.js: split wallet outputs for concurrent payout capacity.
Usage: node scripts/splitOutputs.js [options]
Commands:
--status Show current output breakdown and exit
(default) Split outputs
Options:
--amount <WOW> Target output size in WOW (default: 10)
--count <N> Number of outputs to create (default: 20)
--endpoint <url> Wallet RPC endpoint (default: PRIMARY_WALLET_ENDPOINT env)
--help Show this help message
Examples:
node scripts/splitOutputs.js --status
Show all spendable outputs with locked/unlocked status.
node scripts/splitOutputs.js --amount 10 --count 30
Creates 30 outputs of 10 WOW each (requires 300+ WOW unlocked balance).
`);
process.exit(0);
break;
default:
console.error(`Unknown option: ${args[i]}`);
process.exit(1);
}
}
return opts;
}
function rpcCall(endpoint, method, params = {}) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ jsonrpc: '2.0', id: '0', method, params });
const parsed = new URL(`${endpoint}/json_rpc`);
const options = {
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try {
const json = JSON.parse(data);
if (json.error) {
reject(new Error(`RPC error (${method}): ${json.error.message}`));
} else {
resolve(json);
}
} catch (e) {
reject(new Error(`Invalid JSON response from ${method}: ${data.substring(0, 200)}`));
}
});
});
req.on('error', (err) => reject(new Error(`RPC connection error (${method}): ${err.message}`)));
req.write(body);
req.end();
});
}
function formatWow(atomic) {
return (atomic / ATOMIC_DIVISOR).toFixed(4);
}
async function showStatus(endpoint) {
console.log('=== Wallet Output Status ===');
console.log(`Endpoint: ${endpoint}`);
console.log('');
// Get balance summary
const balResp = await rpcCall(endpoint, 'get_balance', { account_index: 0 });
const bal = balResp.result;
console.log(`Total balance: ${formatWow(bal.balance)} WOW`);
console.log(`Unlocked balance: ${formatWow(bal.unlocked_balance)} WOW`);
const locked = bal.balance - bal.unlocked_balance;
if (locked > 0) {
console.log(`Locked balance: ${formatWow(locked)} WOW`);
}
console.log('');
// Get all transfers to build output picture
const transfersResp = await rpcCall(endpoint, 'incoming_transfers', {
transfer_type: 'available',
account_index: 0
});
const outputs = transfersResp.result?.transfers || [];
if (outputs.length === 0) {
console.log('No spendable outputs found.');
return;
}
// Separate unlocked vs locked
const unlocked = outputs.filter(o => !o.frozen && o.unlocked !== false);
const lockedOutputs = outputs.filter(o => o.frozen || o.unlocked === false);
// Sort by amount descending
unlocked.sort((a, b) => b.amount - a.amount);
lockedOutputs.sort((a, b) => b.amount - a.amount);
console.log(`Spendable outputs: ${unlocked.length}`);
if (unlocked.length > 0) {
for (const o of unlocked) {
const age = o.block_height ? `block ${o.block_height}` : 'mempool';
console.log(` ${formatWow(o.amount).padStart(12)} WOW (${age})`);
}
}
if (lockedOutputs.length > 0) {
console.log('');
console.log(`Locked outputs: ${lockedOutputs.length}`);
for (const o of lockedOutputs) {
const age = o.block_height ? `block ${o.block_height}` : 'mempool';
const reason = o.frozen ? 'frozen' : 'locked';
console.log(` ${formatWow(o.amount).padStart(12)} WOW (${age}, ${reason})`);
}
}
console.log('');
const totalSpendable = unlocked.reduce((s, o) => s + o.amount, 0);
console.log(`Total spendable: ${unlocked.length} outputs, ${formatWow(totalSpendable)} WOW`);
if (lockedOutputs.length > 0) {
console.log(`Total locked: ${lockedOutputs.length} outputs, ${formatWow(lockedOutputs.reduce((s, o) => s + o.amount, 0))} WOW`);
}
// If amount/count provided, simulate which outputs would likely be consumed
if (opts.amount && opts.count) {
const amountAtomic = Math.round(opts.amount * ATOMIC_DIVISOR);
const totalNeeded = amountAtomic * opts.count;
const estimatedFees = Math.round(0.01 * ATOMIC_DIVISOR * opts.count);
const totalWithFees = totalNeeded + estimatedFees;
console.log('');
console.log(`--- Split Simulation: ${opts.count} x ${opts.amount} WOW ---`);
console.log(`Need: ~${formatWow(totalWithFees)} WOW (${formatWow(totalNeeded)} + ~${formatWow(estimatedFees)} fees)`);
if (totalWithFees > totalSpendable) {
console.log(`INSUFFICIENT: only ${formatWow(totalSpendable)} WOW spendable`);
return;
}
// Simulate output selection: wallet typically picks smallest sufficient
// combination to minimize change. Sort ascending and accumulate.
const candidates = [...unlocked].sort((a, b) => a.amount - b.amount);
const selected = [];
let accumulated = 0;
for (const o of candidates) {
selected.push(o);
accumulated += o.amount;
if (accumulated >= totalWithFees) break;
}
console.log(`Likely inputs (${selected.length} output${selected.length > 1 ? 's' : ''}):`);
for (const o of selected) {
const age = o.block_height ? `block ${o.block_height}` : 'mempool';
console.log(` ${formatWow(o.amount).padStart(12)} WOW (${age})`);
}
const change = accumulated - totalWithFees;
console.log(`Change output: ~${formatWow(change)} WOW`);
// Show what remains untouched
const remaining = unlocked.filter(o => !selected.includes(o));
console.log(`Untouched outputs: ${remaining.length} (${formatWow(remaining.reduce((s, o) => s + o.amount, 0))} WOW, still spendable)`);
console.log('');
console.log(`After split: ${remaining.length} existing + ${opts.count} new + 1 change = ${remaining.length + opts.count + 1} total outputs`);
}
}
async function splitOutputs(endpoint, opts) {
const amountAtomic = Math.round(opts.amount * ATOMIC_DIVISOR);
const totalNeeded = amountAtomic * opts.count;
console.log('=== Wownero Output Splitter ===');
console.log(`Endpoint: ${endpoint}`);
console.log(`Output size: ${opts.amount} WOW (${amountAtomic} atomic)`);
console.log(`Count: ${opts.count} outputs`);
console.log(`Total: ${formatWow(totalNeeded)} WOW (+ fees)`);
console.log('');
// Get wallet address
let ownAddress;
try {
const addrResp = await rpcCall(endpoint, 'get_address', {
account_index: 0,
address_index: [0]
});
ownAddress = addrResp.result.address;
console.log(`Wallet: ${ownAddress.substring(0, 12)}...${ownAddress.substring(ownAddress.length - 6)}`);
} catch (err) {
console.error(`Failed to get wallet address: ${err.message}`);
console.error('Make sure wownero-wallet-rpc is running on', endpoint);
process.exit(1);
}
// Check balance
let balance;
try {
const balResp = await rpcCall(endpoint, 'get_balance', { account_index: 0 });
balance = balResp.result;
console.log(`Balance: ${formatWow(balance.balance)} WOW`);
console.log(`Unlocked: ${formatWow(balance.unlocked_balance)} WOW`);
console.log('');
} catch (err) {
console.error(`Failed to get balance: ${err.message}`);
process.exit(1);
}
// Rough fee estimate: ~0.01 WOW per output
const estimatedFees = Math.round(0.01 * ATOMIC_DIVISOR * opts.count);
const totalWithFees = totalNeeded + estimatedFees;
if (balance.unlocked_balance < totalWithFees) {
console.error(`Insufficient unlocked balance!`);
console.error(` Need: ~${formatWow(totalWithFees)} WOW (including estimated fees)`);
console.error(` Have: ${formatWow(balance.unlocked_balance)} WOW unlocked`);
process.exit(1);
}
// Build destinations (all to own address)
const destinations = [];
for (let i = 0; i < opts.count; i++) {
destinations.push({ amount: amountAtomic, address: ownAddress });
}
console.log(`Splitting into ${opts.count} outputs of ${opts.amount} WOW each...`);
// Send transfer_split to self
try {
const txResp = await rpcCall(endpoint, 'transfer_split', {
destinations,
account_index: 0,
priority: 1,
get_tx_key: true
});
const result = txResp.result;
const totalFee = (result.fee_list || []).reduce((a, b) => a + b, 0);
console.log('');
console.log('=== Split Complete ===');
console.log(`Transactions: ${result.tx_hash_list.length}`);
for (const txHash of result.tx_hash_list) {
console.log(` tx: ${txHash}`);
}
console.log(`Total fee: ${(totalFee / ATOMIC_DIVISOR).toFixed(6)} WOW`);
console.log(`Outputs created: ${opts.count}`);
console.log('');
console.log('Outputs will be spendable after 4 block confirmations (~8 minutes).');
} catch (err) {
console.error(`Failed to split outputs: ${err.message}`);
process.exit(1);
}
}
async function main() {
const opts = parseArgs();
if (opts.status) {
await showStatus(opts.endpoint);
} else {
await splitOutputs(opts.endpoint, opts);
}
}
main().catch(err => {
console.error('Unexpected error:', err.message);
process.exit(1);
});
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=Wownerogue PostgreSQL Backup
After=postgresql.service
[Service]
Type=oneshot
# Run as the user who owns the application (adjust as needed)
# User=wownerogue
# Load environment from the application's .env file
# Update this path to match your installation
EnvironmentFile=/var/www/wownerogue/src/.env
# Override backup-specific settings if needed
Environment="BACKUP_DIR=/var/backups/wownerogue"
Environment="RETENTION_DAYS=30"
# Run the backup script
# Update this path to match your installation
ExecStart=/var/www/wownerogue/scripts/backup_db.sh
# Working directory
WorkingDirectory=/var/www/wownerogue/src
[Install]
WantedBy=multi-user.target
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Daily Wownerogue database backup
[Timer]
# Run daily at 3 AM
OnCalendar=*-*-* 03:00:00
# Run immediately if a scheduled run was missed (e.g., system was powered off)
Persistent=true
# Add random delay up to 15 minutes to avoid exact timing
RandomizedDelaySec=900
[Install]
WantedBy=timers.target