Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
.*
!.htaccess
*.md
build.txt
docker-compose.dev.yml
modules/config.php
modules/config.backup-*.php
16 changes: 9 additions & 7 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ jobs:
DOCKERHUB_ENABLED: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- name: Select target registries
id: images
run: |
Expand All @@ -41,7 +43,7 @@ jobs:
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
# GitHub Container Registry is the primary image location;
# Docker Hub is included only when its secrets are configured
Expand All @@ -57,24 +59,24 @@ jobs:
type=semver,pattern={{major}}
type=sha
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
if: github.event_name != 'pull_request' && env.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v4
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/amd64,linux/arm64
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
modules/config.php
.idea/
nanote.txt
nanoNodeMonitor
nanoNodeMonitor
modules/config.backup-*.php
10 changes: 10 additions & 0 deletions .htaccess
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Hide repository metadata and docs on installs that git-clone the project
# into the webroot. Uses only mod_alias (AllowOverride FileInfo), answering
# 404 as if the files were not there. The page itself (index.php, api.php,
# templates/, static/) is unaffected.
<IfModule mod_alias.c>
RedirectMatch 404 "/\.(git|github)(/|$)"
RedirectMatch 404 "/\.(gitignore|dockerignore)$"
RedirectMatch 404 "(?i)\.(md|yml|yaml|sh|ps1)$"
RedirectMatch 404 "/Dockerfile$"
</IfModule>
38 changes: 36 additions & 2 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ modules/
templates/index.hbs Handlebars template rendered client-side with api.php data
static/ Vendored CSS/JS, themes, images (no build step)
Dockerfile, entry.sh Docker image; entry.sh symlinks /opt/nanoNodeMonitor/config.php
.htaccess (root, modules/, scripts/) Apache access rules (see Security conventions)
```

## Architecture / request flow
Expand All @@ -57,6 +58,10 @@ Dockerfile, entry.sh Docker image; entry.sh symlinks /opt/nanoNodeMonitor/con
node version, block counts, peers, confirmation-time percentiles, account
balance/representative/weight, host system load/memory/uptime, telemetry, and sync
percentage. Cache TTL (default 30 s) shields the node from request bursts.
`FileCache` and `ApcuCache` override `fetch()` single-flight: only one request
rebuilds an expired entry (flock on `<cachefile>.lock` / `apcu_add` lock), the
others wait up to 10 s and re-read; if locking fails they fall back to plain
read-callback-write.
4. **RPC layer** — `functions_rpc.php` has one function per RPC action; all go through
`postCurl()`, which POSTs JSON to `http://$nanoNodeRPCIP:$nanoNodeRPCPort` and dies
with an HTTP 503 (`myError()`) when the node is unreachable.
Expand All @@ -74,15 +79,23 @@ Configs carry a `$configVersion` schema marker (`0` for legacy files,
`CONFIG_VERSION` in `constants.php` is current). `scripts/migrate-config.php`
upgrades old configs (run automatically by `entry.sh` on container start): it
loads defaults + the user's config, applies sequential per-version migration
steps, backs up the old file, and writes a minimal config containing only
steps, backs up the old file (as `config.backup-<Ymd-His>.php` next to it, with
the original's permission bits, so a web server never serves it as text), and writes a minimal config containing only
non-default values. When a config change needs migration (renamed setting,
retired option value), bump `CONFIG_VERSION` and add a step to the
`$migrations` array keyed by the new version — steps run once per config, so
one-time rules (like the v1 dark→modern theme move) never re-fire.

The script token-scans the config source **before executing it** and skips
configs containing custom PHP logic (control flow, superglobals like
`$_SERVER`, `die`/`exit`, output, includes). Never weaken this: operators run
`$_SERVER`, `die`/`exit`, output, includes). After that denylist an
**allowlist** pass (`nnm_config_is_plain()`) accepts only assignments of
literals, arrays, `true`/`false`/`null`, defined constants, variables and
`= ; , ( ) [ ] . - +`; any other token (function calls such as `getenv()`,
backticks, `new`, `eval`, `?:`, `fn`, `match`, heredocs, `"$interpolation"`)
marks the config as scripted. `(` directly after a value is treated as a call.
This keeps runtime-computed values (e.g. secrets read from the environment)
from being evaluated as root and baked into the file. Never weaken this: operators run
hand-crafted configs with host-based switching and request guards, and
flattening one would bake in a single branch of that logic. Scripted configs
silence the startup notice by declaring a literal `$configVersion = N;` at the
Expand All @@ -95,6 +108,27 @@ These were deliberate hardening decisions — do not regress them:
- **Never use `unserialize()` on cache or request data.** `FileCache` stores JSON
(a malicious serialized payload in a predictable cache path would otherwise allow
PHP object injection). `RedisCache` also uses JSON.
- **`FileCache` only trusts a safe base directory**: a real directory (not a
symlink), owned by the effective user (`posix_geteuid()`) and not world-writable;
cache files that are symlinks are never read or written. Otherwise it behaves
like `NullCache` (logged via `error_log`), never fatal. Mode/owner checks are
skipped on Windows.
- **Web access rules**: `modules/.htaccess` and `scripts/.htaccess` deny all
requests, so nothing the browser needs may live there (the page loads only
`index.php`, `api.php`, `templates/` and `static/`). The root `.htaccess` 404s
`.git/`, `.github/`, dotfiles, `*.md/yml/yaml/sh/ps1` and `Dockerfile` using only
`mod_alias` (keep it to `AllowOverride FileInfo` directives so it cannot 500 the
page on restricted hosts). `.dockerignore` must keep `!.htaccess`.
- `index.php` sends `X-Content-Type-Options: nosniff` and
`Referrer-Policy: strict-origin-when-cross-origin`. No `X-Frame-Options` /
`frame-ancestors`: operators embed the page.
- Client-side errors are shown via `textContent` (`showError()` in `index.js`),
never by assigning response data to `innerHTML`.
- Docker: `entry.sh` only adds traverse (`o+x`) to `/opt` and makes the config dir
755; never chmod or broaden mounts beyond `/opt/nanoNodeMonitor` (legacy
`-v ~:/opt` mounts make `/opt` the operator's home directory).
- Workflow actions are pinned to full commit SHAs (`# vX.Y.Z` comment);
Dependabot updates them.
- **Escape all config/host-derived values in templates** with the `e()` helper
(`htmlspecialchars`, ENT_QUOTES). Account values embedded in third-party image URLs
are additionally `rawurlencode()`d. Values echoed into inline JS use `(int)` casts or
Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,18 @@ They are also pushed to Docker Hub as a secondary, legacy location:

#### Standalone

sudo docker run -d -p 80:80 -v ~:/opt --restart=unless-stopped ghcr.io/gigaionllc/nanonodemonitor
sudo docker run -d -p 80:80 -v ~/nanoNodeMonitor:/opt/nanoNodeMonitor --restart=unless-stopped ghcr.io/gigaionllc/nanonodemonitor

This will create a directory called _nanoNodeMonitor_ inside your home directory with the _config.php_ inside it.
Edit it according to your needs and you're good to go!

> **Upgrading from an older setup?** Earlier versions of this README mounted the whole
> home directory (`-v ~:/opt`). That still works, but it gives the monitor container
> access to everything in your home directory (in the Compose setup that includes the
> node's wallet files). Switch to the narrower mounts shown here; your config stays in
> the same place (`~/nanoNodeMonitor/config.php`), and in Compose the node's data stays
> in `~/Nano` as before.

#### Docker Compose

1. Create a directory called _nano_ and go inside it: `mkdir nano && cd nano`
Expand All @@ -49,15 +56,15 @@ services:
ports:
- "80:80"
volumes:
- "~:/opt"
- "~/nanoNodeMonitor:/opt/nanoNodeMonitor"
node:
image: "nanocurrency/nano:TAG"
restart: "unless-stopped"
ports:
- "7075:7075"
- "127.0.0.1:7076:7076"
volumes:
- "~:/root"
- "~/Nano:/root/Nano"
```
3. Nice! Now execute `sudo docker-compose up -d` to start everything.

Expand All @@ -67,6 +74,19 @@ services:

6. Done!

## Node RPC safety

The monitor only uses read-only RPC actions (`version`, `block_count`, `peers`,
`account_balance`, `telemetry`, ...). It never needs wallet or control actions, so:

- Keep `enable_control = false` in the node's `config-rpc.toml`.
- Bind RPC to localhost (`::1` / `127.0.0.1`) or to the Docker network only. Never
publish port 7076 on a public interface (the Compose example above uses
`127.0.0.1:7076:7076` for that reason).
- When the monitor reaches the node by container name, any other container on the same
Docker network can reach the RPC as well, which is one more reason to keep
`enable_control` off.

## Manual Installation

### Prerequisites
Expand Down
7 changes: 5 additions & 2 deletions entry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ ln -s $monitordir/config.php /var/www/html/modules/config.php
# the old file next to it on the volume). Never blocks the web server.
php /var/www/html/scripts/migrate-config.php || echo "WARNING: config migration failed, starting with existing config."

# change folder rights so www-data can read
chmod 755 /opt
# let www-data reach the config. /opt only needs traverse (o+x), not
# read/list: with the legacy `-v ~:/opt` mount /opt *is* the host home
# directory, which must not be made listable for other users.
chmod o+x /opt
chmod 755 "${monitordir}"

# start apache
apache2-foreground
5 changes: 5 additions & 0 deletions index.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
<?php
// security headers (sent before any output). No X-Frame-Options /
// frame-ancestors on purpose: operators may embed the status page.
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');

// include required files
require_once __DIR__.'/modules/includes.php';

Expand Down
10 changes: 10 additions & 0 deletions modules/.htaccess
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Nothing in this directory is meant to be requested by a browser: PHP files
# here are only included by index.php/api.php (or run from the CLI), and
# modules/ holds the operator's config.php and its backups.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
58 changes: 58 additions & 0 deletions modules/cache/ApcuCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
class ApcuCache extends Cache {
private $ttl;

// how long to wait for another request's rebuild before rebuilding anyway
const LOCK_WAIT_SECONDS = 10;
// safety expiry of the rebuild lock should its holder die unexpectedly
const LOCK_TTL = 30;

public function __construct(array $options = array()) {
$this->ttl = array_key_exists('ttl', $options) ? $options['ttl'] : 30;
}
Expand All @@ -16,4 +21,57 @@ public function read($key) {
public function write($key, $data) {
return apcu_store($key, $data, $this->ttl);
}

/**
* Single-flight fetch: apcu_add() on "<key>.lock" is an atomic lock, so
* only one request rebuilds an expired entry while the others wait
* (bounded) for its result. The lock is also released if the callback
* exits (e.g. node down -> HTTP 503). If the wait times out, this falls
* back to the plain read-callback-write behaviour.
*/
public function fetch($key, $callback) {
$data = $this->read($key);
if (!is_null($data)) return $data;

$lockKey = $key . '.lock';
$token = uniqid('', true);
$locked = apcu_add($lockKey, $token, self::LOCK_TTL);

if (!$locked) {
$deadline = microtime(true) + self::LOCK_WAIT_SECONDS;
while (!$locked && microtime(true) < $deadline) {
usleep(50000);
$data = $this->read($key);
if (!is_null($data)) return $data;
$locked = apcu_add($lockKey, $token, self::LOCK_TTL);
}
}

if (!$locked) {
$data = $callback();
$this->write($key, $data);
return $data;
}

$released = false;
$release = function () use ($lockKey, $token, &$released) {
if (!$released && apcu_fetch($lockKey) === $token) {
apcu_delete($lockKey);
}
$released = true;
};
register_shutdown_function($release);

try {
// another request may have rebuilt the entry just before we locked
$data = $this->read($key);
if (is_null($data)) {
$data = $callback();
$this->write($key, $data);
}
} finally {
$release();
}
return $data;
}
}
Loading
Loading