mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 07:08:33 +02:00
chore: prepare the repository for public release
Removes operator-private material from the shipped tree, corrects documentation
that had drifted from the code, deletes dead code, and applies a consistent
prose style.
Private material moved out of the public tree (docs/private/, git-ignored):
- the wallet keyfile recovery runbook and its helper scripts, which together
published the mainnet wallet file path, the service account, backup snapshot
filenames, and the wallet RPC listener state. The helper duplicated the same
constants, so relocating the document alone would not have removed them.
- the stagenet parallel-candidate runbook and its fixtures.
- the dated production-state record, the internal remediation plan, and the
TODO list. None are reference documentation for an outside reader.
Redacted or genericised:
- operator home-directory paths in shipped scripts and units. The asset build
script now takes KENNEY_ASSETS_DIR instead of a hard-coded path.
- private LAN addresses in tests, env examples and deploy docs, replaced with
RFC 5737 documentation addresses.
- the default alert sender and the notification hub example.
- the Monero wallet unit now matches the Wownero one: a dedicated service
account and a config file, instead of the operator's login account with the
wallet password in argv where any local user can read it from /proc.
Ignore rules now cover every .env variant rather than the bare name, plus local
agent state. The deployment docs instruct operators to create .env.mainnet and
.env.stagenet, which the exact-name rule would have allowed to be committed.
Documentation corrected against the code: per-chain dungeon level counts, the
premium unlock rule, Tavern chat scope, backup retention, the chain-profile
fallback, the environment template location, and several sections describing
shipped work as pending.
Dead code removed: configPersistence, rpccalls, a duplicate vendored ROT.js
bundle, the unused tile-picker overlay, the unreachable legacy game-size path
and its vestigial parameter, spectator stubs that shadowed the real
SpectatorManager, and commented-out debugging. nip98 now imports the shared
nostr loader rather than duplicating it.
Prose style: em-dashes removed throughout in favour of colons, semicolons and
commas; comments restated as present-tense descriptions of the code rather than
narration of past changes.
127 suites / 1015 tests pass.
This commit is contained in:
@@ -6,15 +6,27 @@ Run with Blender, not plain Python:
|
||||
|
||||
blender --background --python scripts/build_kenney_3d_characters.py
|
||||
|
||||
The source FBX files remain in the local Kenney bundle. Outputs are written under
|
||||
html/assets/generated/ (gitignored) for CDN/local delivery.
|
||||
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 = Path("/home/jw/Drawings/assets/Kenney Game Assets All-in-1 1.1.0/3D assets/Animated Characters 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"
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# Parallel Monero stagenet wallet candidate
|
||||
|
||||
> **STATUS: HISTORICAL NON-MUTATING DESIGN RECORD.** It is not an installation procedure. Such
|
||||
> Software production wallet preparation/promotion is owned by the separately reviewed,
|
||||
> default-closed roles in `~/src/such-fleet`; this document and its sample unit authorize nothing.
|
||||
>
|
||||
> 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.
|
||||
@@ -1,306 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,374 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,35 +1,46 @@
|
||||
# Monero STAGENET wallet RPC for the monerogue.app instance.
|
||||
# Mirrors wownero-wallet-rpc.service. Install to /etc/systemd/system/monero-wallet-rpc.service
|
||||
# Monero STAGENET wallet RPC for the monerogue instance.
|
||||
# Install to /etc/systemd/system/monero-wallet-rpc.service.
|
||||
#
|
||||
# Wallet: /home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test (the stagenet wallet with coins)
|
||||
# Daemon: monerod --stagenet RPC on 127.0.0.1:38081 (already running)
|
||||
# RPC: binds :38083 (free)
|
||||
#
|
||||
# IMPORTANT: replace REPLACE_WITH_WALLET_PASSWORD below with the wallet's real password
|
||||
# (do NOT commit the real password). Consider --wallet-dir + --rpc-login for production,
|
||||
# but this mirrors the existing wownero setup for parity.
|
||||
# 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=jw
|
||||
Group=jw
|
||||
User=monerogue-wallet
|
||||
Group=monerogue-wallet
|
||||
Type=simple
|
||||
|
||||
ExecStart=/usr/bin/monero-wallet-rpc \
|
||||
--stagenet \
|
||||
--wallet-file /home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test \
|
||||
--password "REPLACE_WITH_WALLET_PASSWORD" \
|
||||
--rpc-bind-port 38083 \
|
||||
--daemon-address 127.0.0.1:38081 \
|
||||
--non-interactive \
|
||||
--rpc-bind-ip 127.0.0.1 \
|
||||
--disable-rpc-login
|
||||
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
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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
|
||||
@@ -1,372 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synthetic-only tests for the fixed wallet keyfile recovery proof primitives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).with_name("wallet-keyfile-recovery-proof.py")
|
||||
SPEC = importlib.util.spec_from_file_location("wallet_keyfile_recovery_proof", SCRIPT)
|
||||
if SPEC is None or SPEC.loader is None: # pragma: no cover
|
||||
raise RuntimeError("fixture import unavailable")
|
||||
proof = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = proof
|
||||
SPEC.loader.exec_module(proof)
|
||||
|
||||
|
||||
def synthetic_profile(stagenet: bool = True):
|
||||
return proof.WalletProfile(
|
||||
name="synthetic",
|
||||
unit="synthetic.service",
|
||||
user="synthetic",
|
||||
group="synthetic",
|
||||
binary=pathlib.Path("/fixed/bin/wallet-rpc"),
|
||||
live_wallet=pathlib.Path("/fixed/live/wallet"),
|
||||
live_rpc_port=38083,
|
||||
live_bind_address="127.0.0.1",
|
||||
snapshot_wallet=pathlib.Path("/fixed/snapshot.wallet"),
|
||||
snapshot_keys=pathlib.Path("/fixed/snapshot.keys"),
|
||||
proof_port=39084,
|
||||
stagenet=stagenet,
|
||||
address_kind="xmr-stagenet" if stagenet else "wow-mainnet",
|
||||
proof_directory=pathlib.Path("/fixed/proof"),
|
||||
)
|
||||
|
||||
|
||||
def cmdline(profile, *, wallet=None, passwords=(b"synthetic-wallet-password",), extra=()):
|
||||
arguments = [os.fsencode(profile.binary)]
|
||||
if profile.stagenet:
|
||||
arguments.append(b"--stagenet")
|
||||
arguments.extend((b"--wallet-file", os.fsencode(wallet or profile.live_wallet)))
|
||||
for password in passwords:
|
||||
arguments.extend((b"--password", password))
|
||||
arguments.extend((b"--rpc-bind-port", str(profile.live_rpc_port).encode("ascii")))
|
||||
arguments.extend(extra)
|
||||
raw = bytearray(b"\0".join(arguments) + b"\0")
|
||||
return raw, len(raw)
|
||||
|
||||
|
||||
class CmdlineContractFixture(unittest.TestCase):
|
||||
def test_exact_fixed_argv_extracts_only_the_inline_password_and_wipes_input(self):
|
||||
profile = synthetic_profile()
|
||||
raw, length = cmdline(profile)
|
||||
secret = proof.extract_inline_wallet_password(raw, length, profile)
|
||||
try:
|
||||
self.assertEqual(secret, b"synthetic-wallet-password")
|
||||
self.assertFalse(any(raw))
|
||||
finally:
|
||||
proof.wipe_buffer(secret)
|
||||
|
||||
def test_wrong_wallet_path_is_refused_and_input_is_wiped(self):
|
||||
profile = synthetic_profile()
|
||||
raw, length = cmdline(profile, wallet=pathlib.Path("/wrong/wallet"))
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.extract_inline_wallet_password(raw, length, profile)
|
||||
self.assertFalse(any(raw))
|
||||
|
||||
def test_duplicate_password_is_refused(self):
|
||||
profile = synthetic_profile()
|
||||
raw, length = cmdline(profile, passwords=(b"one", b"two"))
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.extract_inline_wallet_password(raw, length, profile)
|
||||
self.assertFalse(any(raw))
|
||||
|
||||
def test_password_file_or_wrong_network_is_refused(self):
|
||||
profile = synthetic_profile()
|
||||
for indirect in (
|
||||
(b"--password-file", b"/not-accepted"),
|
||||
(b"--password-file=/not-accepted",),
|
||||
(b"--wallet-dir=/not-accepted",),
|
||||
):
|
||||
raw, length = cmdline(profile, extra=indirect)
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.extract_inline_wallet_password(raw, length, profile)
|
||||
self.assertFalse(any(raw))
|
||||
|
||||
mainnet = synthetic_profile(stagenet=False)
|
||||
for wrong_network in ((b"--stagenet",), (b"--stagenet=1",), (b"--testnet=1",)):
|
||||
raw, length = cmdline(mainnet, extra=wrong_network)
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.extract_inline_wallet_password(raw, length, mainnet)
|
||||
self.assertFalse(any(raw))
|
||||
|
||||
|
||||
class SnapshotCopyFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="wallet-recovery-fixture-")
|
||||
self.root = pathlib.Path(self.temporary.name)
|
||||
self.source = self.root / "source"
|
||||
self.destination = self.root / "destination"
|
||||
self.source.mkdir(mode=0o700)
|
||||
self.destination.mkdir(mode=0o700)
|
||||
self.wallet = self.source / "snapshot.wallet"
|
||||
self.keys = self.source / "snapshot.keys"
|
||||
self.wallet.write_bytes(b"synthetic encrypted wallet cache\0bytes")
|
||||
self.keys.write_bytes(b"synthetic encrypted keyfile\0bytes")
|
||||
self.wallet.chmod(0o600)
|
||||
self.keys.chmod(0o600)
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def copy(self, after_first_copy=None):
|
||||
proof.copy_snapshot_pair(
|
||||
self.source,
|
||||
self.wallet.name,
|
||||
self.keys.name,
|
||||
self.destination,
|
||||
os.getuid(),
|
||||
os.getgid(),
|
||||
after_first_copy,
|
||||
)
|
||||
|
||||
def test_copy_is_byte_equal_private_and_source_is_unchanged(self):
|
||||
wallet_before = self.wallet.read_bytes()
|
||||
keys_before = self.keys.read_bytes()
|
||||
self.copy()
|
||||
self.assertEqual((self.destination / "wallet").read_bytes(), wallet_before)
|
||||
self.assertEqual((self.destination / "wallet.keys").read_bytes(), keys_before)
|
||||
self.assertEqual(self.wallet.read_bytes(), wallet_before)
|
||||
self.assertEqual(self.keys.read_bytes(), keys_before)
|
||||
self.assertEqual((self.destination / "wallet").stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual((self.destination / "wallet.keys").stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_hard_linked_source_is_refused(self):
|
||||
os.link(self.keys, self.root / "second-key-link")
|
||||
with self.assertRaises(proof.Refusal):
|
||||
self.copy()
|
||||
self.assertFalse((self.destination / "wallet").exists())
|
||||
|
||||
def test_symlink_source_component_is_refused(self):
|
||||
linked = self.root / "linked-source"
|
||||
linked.symlink_to(self.source, target_is_directory=True)
|
||||
with self.assertRaises((OSError, proof.Refusal)):
|
||||
proof.copy_snapshot_pair(
|
||||
linked,
|
||||
self.wallet.name,
|
||||
self.keys.name,
|
||||
self.destination,
|
||||
os.getuid(),
|
||||
os.getgid(),
|
||||
)
|
||||
|
||||
def test_source_change_during_copy_is_refused_and_partial_copy_removed(self):
|
||||
def mutate():
|
||||
with self.wallet.open("ab") as output:
|
||||
output.write(b"changed")
|
||||
|
||||
with self.assertRaises(proof.Refusal):
|
||||
self.copy(mutate)
|
||||
self.assertFalse((self.destination / "wallet").exists())
|
||||
self.assertFalse((self.destination / "wallet.keys").exists())
|
||||
|
||||
|
||||
class CleanupAndProtocolFixture(unittest.TestCase):
|
||||
def test_protection_failure_wipes_the_synthetic_secret(self):
|
||||
value = bytearray(b"synthetic-secret")
|
||||
with mock.patch.object(proof.LIBC, "mlock", return_value=-1):
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.protect_buffer(value)
|
||||
self.assertFalse(any(value))
|
||||
|
||||
def test_cleanup_never_follows_a_link_outside_its_exact_directory(self):
|
||||
with tempfile.TemporaryDirectory(prefix="wallet-cleanup-fixture-") as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
proof_dir = root / "proof"
|
||||
proof_dir.mkdir(mode=0o700)
|
||||
secret = proof_dir / "wallet-password"
|
||||
secret.write_bytes(b"synthetic secret")
|
||||
secret.chmod(0o600)
|
||||
external = root / "external"
|
||||
external.write_bytes(b"must survive")
|
||||
(proof_dir / "linked").symlink_to(external)
|
||||
details = proof_dir.stat()
|
||||
|
||||
self.assertFalse(
|
||||
proof.secure_remove_proof_directory(
|
||||
proof_dir, (details.st_dev, details.st_ino)
|
||||
)
|
||||
)
|
||||
self.assertFalse(proof_dir.exists())
|
||||
self.assertEqual(external.read_bytes(), b"must survive")
|
||||
|
||||
def test_cleanup_never_overwrites_an_external_hard_link(self):
|
||||
with tempfile.TemporaryDirectory(prefix="wallet-hardlink-fixture-") as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
proof_dir = root / "proof"
|
||||
proof_dir.mkdir(mode=0o700)
|
||||
external = root / "external"
|
||||
external.write_bytes(b"must survive")
|
||||
os.link(external, proof_dir / "linked")
|
||||
details = proof_dir.stat()
|
||||
|
||||
self.assertFalse(
|
||||
proof.secure_remove_proof_directory(
|
||||
proof_dir, (details.st_dev, details.st_ino)
|
||||
)
|
||||
)
|
||||
self.assertFalse(proof_dir.exists())
|
||||
self.assertEqual(external.read_bytes(), b"must survive")
|
||||
|
||||
def test_fixed_address_shapes_reject_cross_network_values(self):
|
||||
xmr = synthetic_profile()
|
||||
wow = synthetic_profile(stagenet=False)
|
||||
xmr_address = "5" + "1" * 94
|
||||
wow_address = "Wo" + "1" * 95
|
||||
self.assertTrue(proof.network_shaped_address(xmr_address, xmr))
|
||||
self.assertTrue(proof.network_shaped_address(wow_address, wow))
|
||||
self.assertFalse(proof.network_shaped_address(wow_address, xmr))
|
||||
self.assertFalse(proof.network_shaped_address(xmr_address, wow))
|
||||
|
||||
def test_digest_header_does_not_embed_the_ephemeral_rpc_password(self):
|
||||
challenge = 'Digest realm="wallet-rpc", nonce="abcdef", qop="auth", algorithm=MD5'
|
||||
rpc_secret = bytearray(b"a" * 64)
|
||||
authorization = proof.digest_authorization(challenge, rpc_secret)
|
||||
self.assertTrue(authorization.startswith("Digest "))
|
||||
self.assertNotIn(rpc_secret.decode("ascii"), authorization)
|
||||
|
||||
def test_digest_requires_auth_qop_but_accepts_a_spaced_offer(self):
|
||||
parsed = proof.parse_digest_challenge(
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth-int, auth"'
|
||||
)
|
||||
self.assertEqual(parsed["nonce"], "abcdef")
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.parse_digest_challenge(
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth-int"'
|
||||
)
|
||||
|
||||
def test_candidate_files_use_password_file_loopback_offline_contract(self):
|
||||
with tempfile.TemporaryDirectory(prefix="wallet-config-fixture-") as temporary:
|
||||
profile = dataclasses.replace(
|
||||
synthetic_profile(), proof_directory=pathlib.Path(temporary)
|
||||
)
|
||||
profile.candidate_directory.mkdir(mode=0o700)
|
||||
written = {}
|
||||
|
||||
def capture(_directory_fd, name, value):
|
||||
written[name] = bytes(value)
|
||||
|
||||
with mock.patch.object(proof, "write_new_file", side_effect=capture):
|
||||
rpc_secret = proof.prepare_candidate_files(
|
||||
profile, bytearray(b"synthetic-wallet-password")
|
||||
)
|
||||
try:
|
||||
config = written["wallet-rpc.conf"]
|
||||
self.assertEqual(
|
||||
written["wallet-password"], b"synthetic-wallet-password\n"
|
||||
)
|
||||
self.assertIn(
|
||||
b"wallet-file="
|
||||
+ os.fsencode(profile.candidate_directory / "wallet")
|
||||
+ b"\n",
|
||||
config,
|
||||
)
|
||||
self.assertIn(
|
||||
b"password-file="
|
||||
+ os.fsencode(profile.candidate_directory / "wallet-password")
|
||||
+ b"\n",
|
||||
config,
|
||||
)
|
||||
self.assertIn(b"rpc-bind-ip=127.0.0.1\n", config)
|
||||
self.assertIn(b"rpc-bind-port=39084\n", config)
|
||||
self.assertIn(b"non-interactive=1\n", config)
|
||||
self.assertIn(b"rpc-login=recovery-proof:" + bytes(rpc_secret), config)
|
||||
self.assertIn(b"stagenet=1\n", config)
|
||||
self.assertNotIn(b"daemon-address", config)
|
||||
finally:
|
||||
proof.wipe_buffer(rpc_secret, True)
|
||||
|
||||
def test_fixed_message_signature_must_verify_as_spend_capability(self):
|
||||
profile = synthetic_profile()
|
||||
requests = []
|
||||
signature = "SigV1" + "1" * 80
|
||||
|
||||
def answer(_profile, _challenge, _rpc_secret, request_body, nonce_count):
|
||||
request = json.loads(bytes(request_body).decode("ascii"))
|
||||
requests.append((request, nonce_count))
|
||||
if request["method"] == "sign":
|
||||
return json.dumps({"result": {"signature": signature}}).encode()
|
||||
return json.dumps(
|
||||
{"result": {"good": True, "signature_type": "spend"}}
|
||||
).encode()
|
||||
|
||||
with mock.patch.object(proof, "authenticated_candidate_rpc", side_effect=answer):
|
||||
proof.prove_spend_capability(
|
||||
profile,
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth"',
|
||||
bytearray(b"synthetic-rpc-secret"),
|
||||
"5" + "1" * 94,
|
||||
)
|
||||
|
||||
self.assertEqual([request["method"] for request, _ in requests], ["sign", "verify"])
|
||||
self.assertEqual([nonce_count for _, nonce_count in requests], [2, 3])
|
||||
self.assertEqual(requests[0][0]["params"], {"data": proof.SIGN_MESSAGE})
|
||||
self.assertEqual(requests[1][0]["params"]["signature"], signature)
|
||||
|
||||
def test_missing_spend_signature_is_refused(self):
|
||||
with mock.patch.object(
|
||||
proof,
|
||||
"authenticated_candidate_rpc",
|
||||
return_value=b'{"error":{"message":"watch-only"}}',
|
||||
):
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.prove_spend_capability(
|
||||
synthetic_profile(),
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth"',
|
||||
bytearray(b"synthetic-rpc-secret"),
|
||||
"5" + "1" * 94,
|
||||
)
|
||||
|
||||
def test_verified_view_signature_is_not_spend_capability(self):
|
||||
signature = "SigV1" + "1" * 80
|
||||
responses = [
|
||||
json.dumps({"result": {"signature": signature}}).encode(),
|
||||
b'{"result":{"good":true,"signature_type":"view"}}',
|
||||
]
|
||||
with mock.patch.object(
|
||||
proof, "authenticated_candidate_rpc", side_effect=responses
|
||||
):
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.prove_spend_capability(
|
||||
synthetic_profile(),
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth"',
|
||||
bytearray(b"synthetic-rpc-secret"),
|
||||
"5" + "1" * 94,
|
||||
)
|
||||
|
||||
def test_child_drops_to_exact_ids_with_no_supplementary_groups(self):
|
||||
with (
|
||||
mock.patch.object(proof.os, "setgroups") as setgroups,
|
||||
mock.patch.object(proof.os, "setgid") as setgid,
|
||||
mock.patch.object(proof.os, "setuid") as setuid,
|
||||
mock.patch.object(proof.os, "getresuid", return_value=(123, 123, 123)),
|
||||
mock.patch.object(proof.os, "getresgid", return_value=(456, 456, 456)),
|
||||
mock.patch.object(proof.os, "getgroups", return_value=[]),
|
||||
mock.patch.object(proof.os, "umask"),
|
||||
mock.patch.object(proof.resource, "setrlimit"),
|
||||
mock.patch.object(proof.LIBC, "prctl", return_value=0) as prctl,
|
||||
):
|
||||
proof.child_hardening(123, 456)
|
||||
setgroups.assert_called_once_with([])
|
||||
setgid.assert_called_once_with(456)
|
||||
setuid.assert_called_once_with(123)
|
||||
self.assertEqual(prctl.call_count, 3)
|
||||
|
||||
def test_listener_collision_is_refused(self):
|
||||
with mock.patch.object(proof, "listeners", return_value=[("127.0.0.1", 42)]):
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.require_unused_port(39084)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.umask(0o077)
|
||||
unittest.main(verbosity=2)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* splitOutputs.js — Split wallet outputs for concurrent payout capacity.
|
||||
* 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
|
||||
@@ -20,7 +20,7 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
|
||||
// Parse .env manually — no external dependencies needed
|
||||
// Parse .env manually so the script needs no external dependencies.
|
||||
function loadEnv() {
|
||||
const envPath = path.resolve(__dirname, '..', 'src', '.env');
|
||||
try {
|
||||
@@ -35,7 +35,7 @@ function loadEnv() {
|
||||
if (!process.env[key]) process.env[key] = val;
|
||||
}
|
||||
} catch (e) {
|
||||
// .env not found — that's fine, user can pass --endpoint
|
||||
// .env not found. The caller can pass --endpoint instead.
|
||||
}
|
||||
}
|
||||
loadEnv();
|
||||
@@ -76,7 +76,7 @@ function parseArgs() {
|
||||
case '--help':
|
||||
case '-h':
|
||||
console.log(`
|
||||
splitOutputs.js — Split wallet outputs for concurrent payout capacity.
|
||||
splitOutputs.js: split wallet outputs for concurrent payout capacity.
|
||||
|
||||
Usage: node scripts/splitOutputs.js [options]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user