Harden RRDtool proxy request handling (#20)

* Harden RRDtool proxy request handling

* Address PR review feedback: encrypt oversized-request errors, fix env inheritance, spacing, and symlink test

Addresses review comments on PR #17:
- lib/client.php: encrypt the 'Request too large' error frame once the
  client is authenticated, matching the existing encrypted-error pattern
- lib/client.php: build the removespikes child-process environment from
  getenv() instead of $_ENV so the full current environment (e.g. TEMP on
  Windows) is inherited, with RRDP_RRDTOOL_PATH merged on top
- lib/client.php: add the missing space in the RRDTOOL_PIPE_UNAVAILABLE
  error frame so it matches the other 'ERROR: ...' responses
- tests/run.php: check the symlink() fixture return value and skip the
  symlink-escape assertion (instead of producing a false positive) when
  symlink() is unavailable

* Upgrade phpseclib 3.x to phpseclib/phpseclib ^4.0

- composer.json/composer.lock: require phpseclib/phpseclib ^4.0 (was ^3.0)
- Update phpseclib3\\... namespace usages to phpseclib4\\... across
  rrdtool-proxy.php, lib/client.php, lib/functions.php, lib/wizard.php,
  and tests/run.php
- lib/functions.php: phpseclib4 removed the Random class, so
  Random::string(32) is replaced with random_bytes(32)
- Fix lib/__master__.php and lib/replicator.php, which PR #17 never
  migrated off the old pre-Composer phpseclib API (new \\phpseclib\\Crypt\\RSA(),
  ->loadKey(), ->getPublicKeyFingerprint() -- none of which exist in
  phpseclib 3.x or 4.x, so these MSR/replicator peer-authentication code
  paths would fatal at runtime). Both now use
  RSA::loadPublicKey()->getFingerprint('md5') wrapped in try/catch,
  matching the pattern already used in lib/client.php, plus a
  constant-time hash_equals() fingerprint comparison
- tests/run.php: relabel the phpseclib round-trip check from 'phpseclib 3'
  to 'phpseclib 4'

* Bump minimum PHP version to 8.2 and add CHANGELOG.md

- composer.json: require php ^8.2 (was ^8.1)
- include/global.php: bump RRDP_PHP_VERSION_REQUIRED to 80200 to match
- README.md: update minimum/recommended PHP version to 8.2
- composer.lock: refreshed for the new php platform constraint
- Add CHANGELOG.md tracking changes prefixed security:/issue:/feature:

* Add PHPDoc blocks to all functions

Adds a PHPDoc block above every function in rrdtool-proxy.php,
lib/functions.php, lib/client.php, lib/__master__.php, lib/replicator.php,
lib/wizard.php, cli/removespikes.php, tests/run.php, and
.phpstan-bootstrap.php, each with a brief description and inferred
@param/@return types. Function signatures themselves are unchanged; PHPStan
will flag any inferred types that turn out to be inaccurate.

* Add .github/copilot-instructions.md

Generated from README.md and this session's learnings: project architecture
(daemon entrypoint, shared lib helpers, client/master/replicator roles,
wizard, removespikes CLI), build/lint/test commands (composer scripts,
php tests/run.php, Windows platform-req caveats), and conventions (tabs,
GPL header block, PHPDoc-only typing, phpseclib4 gotchas, CHANGELOG entry
prefixes).

Also fix .gitignore: the '.git*' pattern was unintentionally also matching
and ignoring the .github/ directory; added a '!.github/' exception so files
under .github/ (like this one) are tracked.

* Add .gitattributes enforcing LF line endings; normalize existing CRLF files

- Add .gitattributes with '* text=auto eol=lf' so Git normalizes line
  endings to LF on checkout/commit regardless of local core.autocrlf
  settings.
- .gitignore: add '!.gitattributes' alongside the existing '!.github/'
  exception, since '.git*' was also unintentionally ignoring .gitattributes
  itself.
- Normalize the two tracked files that had CRLF line endings
  (.github/copilot-instructions.md, CHANGELOG.md) to LF; no other tracked
  files were affected.

* Manual PSR coding-style adjustments

Manual formatting/coding-style cleanup in cli/removespikes.php,
lib/__master__.php, lib/client.php, and lib/replicator.php.

* Address automated review feedback: symlink confinement, key-pair validation, and misc fixes

Addresses 10 unresolved copilot-pull-request-reviewer comments on PR #20:

- lib/functions.php: rewrite rrdp_resolve_path_within() to walk the path one
  segment at a time (instead of only checking the nearest existing ancestor
  via file_exists()), so a symlink anywhere along a not-yet-fully-existing
  path is resolved and confined even if the symlink itself is dangling.
- lib/functions.php: add rrdp_resolve_command_paths(), which resolves every
  RRD file path operand referenced by an RRDtool command (leading bare file
  arguments and DEF:/SDEF: clauses) against the RRA root and rewrites the
  command to use the canonical resolved paths, closing the symlink-escape
  gap left by the lexical-only rrdp_command_has_unsafe_path() check.
- lib/client.php: wire rrdp_resolve_command_paths() into the RRDtool command
  dispatch path, rejecting/rewriting unsafe operands before they reach the
  RRDtool pipe.
- lib/client.php: enforce the request-size limit on each completed
  transaction and the trailing unfinished remainder, instead of on the
  aggregate receive buffer before splitting on _EOT_, so multiple legitimate
  transactions in one read no longer trip a false 'Request too large'.
- rrdtool-proxy.php: validate at startup that the loaded public/private key
  files actually form a matching pair (comparing derived fingerprints)
  instead of only deriving a fingerprint from the public key, failing
  startup instead of accepting requests that will all fail to decrypt.
- lib/__master__.php, lib/replicator.php: resolve peer-supplied FULLSCAN
  folder/file names (and RRDLAST/RRDDUMP request filenames) through
  rrdp_resolve_rra_path() before using them in mkdir()/touch()/
  file_put_contents()/RRDtool restore-dump-last-flushcached operations,
  instead of concatenating raw peer-supplied text; reject and skip entries
  that fail confinement.
- lib/replicator.php, lib/__master__.php: guard rrdtool_pipe_init() failures
  in the main replication loop the same way lib/client.php already does,
  instead of indexing into a false return value and never retrying.
- cli/removespikes.php: make createRRDFileFromXML() return whether the
  RRDtool restore actually succeeded, and exit non-zero when it fails
  instead of silently exiting 0.
- .phpstan.neon: restore level 6 (was lowered to 2, contradicting
  .github/copilot-instructions.md) and add .phpstan-baseline.neon to
  capture the 42 pre-existing findings surfaced at level 6 without hiding
  future regressions.
- tests/run.php: add the standard Cacti Group GPL header block required for
  every PHP file.

---------

Co-authored-by: Thomas Vincent <thomasvincent@gmail.com>
This commit is contained in:
TheWitnessandThomas Vincent authored and GitHub committed 2026-09-19 09:04:05 -04:00
1 parent 7456c5e697
commit 629971cc73
19 files changed
+2731 -558

No files matched your search

+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+57
View File
@@ -0,0 +1,57 @@
# rrdproxy Copilot Instructions
RRDtool Proxy Server (rrdp) is a standalone PHP daemon that lets Cacti offload RRDtool
file access to a remote host, with RSA + fresh-session-AES encrypted client traffic. See
[README.md](../README.md) for the full feature/protocol overview before making
security-sensitive changes.
## Architecture
- `rrdtool-proxy.php` — main daemon entrypoint: forks the master process, sets up admin
(CLI), client, and replication sockets, and contains most `rrdp_cmd__*` (admin CLI) and
`rrdp_system__*` (internal plumbing) functions.
- `lib/functions.php` — shared helpers used by every process: `encrypt()`/`decrypt()`
(RSA+AES wire protocol), path-confinement helpers (`rrdp_resolve_rra_path`,
`rrdp_command_has_unsafe_path`), atomic file/key-pair writers, logging.
- `lib/client.php` — per-client child process (`interact()`), forked once per connection.
- `lib/__master__.php` and `lib/replicator.php` — near-duplicate implementations of the
cluster peer / multi-server-replication (MSR) roles (master listens only; replicator also
dials out to peers). Keep changes to peer-auth/fingerprint logic in sync between the two.
- `lib/wizard.php` — interactive first-run/reconfiguration setup wizard.
- `cli/removespikes.php` — standalone RRD spike-removal tool, also invoked as a child
process by the client handler.
- `include/global.php` — constants, severity levels, and CLI help-tree config arrays.
## Build, Lint, and Test
- Install deps: `composer install` (add `--ignore-platform-req=ext-pcntl
--ignore-platform-req=ext-posix` on Windows dev machines — those extensions are Unix-only
and only present on the Linux production target).
- `composer run lint` — phplint.
- `composer run phpstan` — `phpstan analyse --level 6` (may need
`php -d memory_limit=-1` locally; do not lower the configured level to silence findings).
- `composer run phpcsfixer` — dry-run diff; `composer run phpcsfixit` to apply.
- Tests are a plain assertion script, not PHPUnit: `php tests/run.php` (expects
`OK (<n> checks)`; failures print to STDERR and exit 1).
- A handful of checks (file permission bits, `AF_UNIX` `socket_create_pair`, path
separators) are known to fail on Windows but pass on Linux — don't "fix" those unless
asked; verify by reasoning about the platform difference, not by relaxing the assertion.
## Conventions
- Indent with tabs. Every PHP file starts with the standard Cacti Group GPL header
comment block (`Copyright (C) 2004-<year> The Cacti Group ...`); `include/global.php`
computes the display copyright dynamically via `date('Y')`, but the header comments
themselves are static and need bumping only when explicitly asked.
- Every function has a PHPDoc block: one-line (or short) description, blank `*` line,
`@param` list, blank `*` line, `@return`. Function signatures are intentionally left
untyped in code — types live in PHPDoc only, and PHPStan is the source of truth for
catching mismatches. Don't add native param/return type hints unless asked.
- Encryption uses `phpseclib4\Crypt\*` (namespace root `phpseclib4`, not `phpseclib3`).
Notable v4 gotchas: there is no `Random` class (use `random_bytes()`); `Rijndael`/AES
requires an explicit `setIV()` and validates key length strictly (16/20/24/28/32 bytes);
load keys via `RSA::loadPublicKey()`/`loadPrivateKey()` (or `PublicKeyLoader`) and always
pass `'md5'` to `getFingerprint()` — the wire protocol and existing client/proxy configs
store md5 fingerprints, and v4's default changed to sha256.
- New feature/security/behavior changes should get an entry in `CHANGELOG.md` under
`[Unreleased]`, prefixed `feature:`, `issue:`, or `security:`.
+2
View File
@@ -1,4 +1,6 @@
.git*
!.github/
!.gitattributes
locales/po/*.mo
vendor/**
+199
View File
@@ -0,0 +1,199 @@
parameters:
ignoreErrors:
-
message: '#^Function removeComments\(\) should return array\<int, string\>\|null but return statement is missing\.$#'
identifier: return.missing
count: 1
path: cli/removespikes.php
-
message: '#^If condition is always false\.$#'
identifier: if.alwaysFalse
count: 3
path: cli/removespikes.php
-
message: '#^Offset ''cacti_server_os'' does not exist on array\{\}\.$#'
identifier: offsetAccess.notFound
count: 1
path: cli/removespikes.php
-
message: '#^Parameter \#2 \$descriptor_spec of function proc_open expects array\<1\|2, list\<string\>\|resource\>, array\{1\: array\{''pipe'', ''w''\}, 2\: array\{''redirect'', 1\}\} given\.$#'
identifier: argument.type
count: 1
path: cli/removespikes.php
-
message: '#^Parameter \#1 \$num of function abs expects float\|int, string given\.$#'
identifier: argument.type
count: 1
path: lib/client.php
-
message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: lib/functions.php
-
message: '#^Parameter \#2 \$descriptor_spec of function proc_open expects array\<1\|2, list\<string\>\|resource\>, array\{1\: array\{''pipe'', ''w''\}, 2\: array\{''redirect'', 1\}\} given\.$#'
identifier: argument.type
count: 1
path: lib/functions.php
-
message: '#^Parameter \#2 \$enable of function stream_set_blocking expects bool, int given\.$#'
identifier: argument.type
count: 2
path: lib/functions.php
-
message: '#^Unreachable statement \- code above always terminates\.$#'
identifier: deadCode.unreachable
count: 2
path: lib/functions.php
-
message: '#^Comparison operation "\<" between int\<80200, 80599\> and 80200 is always false\.$#'
identifier: smaller.alwaysFalse
count: 1
path: lib/wizard.php
-
message: '#^Strict comparison using \=\=\= between false and false will always evaluate to true\.$#'
identifier: identical.alwaysTrue
count: 1
path: lib/wizard.php
-
message: '#^Unreachable statement \- code above always terminates\.$#'
identifier: deadCode.unreachable
count: 1
path: lib/wizard.php
-
message: '#^Call to function in_array\(\) with arguments numeric\-string, array\{0, 1, 2, 3, 4, 5, 6, 7\} and true will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: rrdtool-proxy.php
-
message: '#^Call to function in_array\(\) with arguments numeric\-string, array\{0\: 0, 1\: 1, 2\: 2, 3\: 3, 4\: 4, 5\: 5, 6\: 6, 7\: 7, \.\.\.\} and true will always evaluate to false\.$#'
identifier: function.impossibleType
count: 1
path: rrdtool-proxy.php
-
message: '#^Call to function is_array\(\) with list\<string\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 1
path: rrdtool-proxy.php
-
message: '#^Call to function is_array\(\) with non\-empty\-list\<string\> will always evaluate to true\.$#'
identifier: function.alreadyNarrowedType
count: 3
path: rrdtool-proxy.php
-
message: '#^Comparison operation "\<" between int\<80200, 80599\> and 80200 is always false\.$#'
identifier: smaller.alwaysFalse
count: 1
path: rrdtool-proxy.php
-
message: '#^Comparison operation "\>\=" between int\<80200, 80599\> and 80000 is always true\.$#'
identifier: greaterOrEqual.alwaysTrue
count: 2
path: rrdtool-proxy.php
-
message: '#^Function handle_child_processes\(\) has parameter \$ipc_sockets with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 1
path: rrdtool-proxy.php
-
message: '#^If condition is always true\.$#'
identifier: if.alwaysTrue
count: 1
path: rrdtool-proxy.php
-
message: '#^Parameter \#1 \$socket of function socket_close expects Socket, bool given\.$#'
identifier: argument.type
count: 3
path: rrdtool-proxy.php
-
message: '#^Parameter \#1 \$socket of function socket_shutdown expects Socket, bool given\.$#'
identifier: argument.type
count: 1
path: rrdtool-proxy.php
-
message: '#^Parameter \#1 \$socket_client of function interact expects resource\|Socket, bool given\.$#'
identifier: argument.type
count: 1
path: rrdtool-proxy.php
-
message: '#^Parameter \#3 \$ssock of function handle_child_processes expects bool, Socket given\.$#'
identifier: argument.type
count: 1
path: rrdtool-proxy.php
-
message: '#^Result of && is always false\.$#'
identifier: booleanAnd.alwaysFalse
count: 1
path: rrdtool-proxy.php
-
message: '#^Strict comparison using \!\=\= between ''undefined'' and ''undefined'' will always evaluate to false\.$#'
identifier: notIdentical.alwaysFalse
count: 1
path: rrdtool-proxy.php
-
message: '#^Strict comparison using \!\=\= between string and false will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: rrdtool-proxy.php
-
message: '#^Strict comparison using \!\=\= between true and false will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: rrdtool-proxy.php
-
message: '#^Strict comparison using \=\=\= between Socket and false will always evaluate to false\.$#'
identifier: identical.alwaysFalse
count: 1
path: rrdtool-proxy.php
-
message: '#^Ternary operator condition is always false\.$#'
identifier: ternary.alwaysFalse
count: 1
path: rrdtool-proxy.php
-
message: '#^Unreachable statement \- code above always terminates\.$#'
identifier: deadCode.unreachable
count: 1
path: rrdtool-proxy.php
-
message: '#^Variable \$arg in isset\(\) always exists and is not nullable\.$#'
identifier: isset.variable
count: 1
path: rrdtool-proxy.php
-
message: '#^While loop condition is always true\.$#'
identifier: while.alwaysTrue
count: 1
path: rrdtool-proxy.php
+23
View File
@@ -1,3 +1,26 @@
<?php
define('PHP_STAN', true);
/**
* PHPStan stub: Cacti's real settings-table accessor, unavailable outside a
* Cacti install, so analysis always sees it return null here.
*/
function read_config_option(string $name): mixed {
return null;
}
/**
* PHPStan stub: Cacti's real logging function, unavailable outside a Cacti
* install, so analysis sees a no-op here.
*/
function cacti_log(string $message, bool $output = false, string $facility = ''): void {
}
/**
* PHPStan stub: the Spikekill plugin's real version accessor, unavailable
* outside a Cacti install, so analysis always sees an 'unknown' version here.
*/
function spikekill_version(): array {
return ['version' => 'unknown'];
}
+24 -37
View File
@@ -1,43 +1,30 @@
includes:
- .phpstan-baseline.neon
parameters:
parallel:
maximumNumberOfProcesses: 8
scanDirectories:
- include
maximumNumberOfProcesses: 1
level: 6
paths:
- include/global.php
- lib/client.php
- lib/functions.php
- lib/wizard.php
- cli/removespikes.php
- rrdtool-proxy.php
bootstrapFiles:
- vendor/autoload.php
- .phpstan-bootstrap.php
excludePaths:
analyse:
- vendor/*
- plugins/*
- rector.php
paths:
- include
- lib
- install
- .
bootstrapFiles:
- .phpstan-bootstrap.php
- vendor/autoload.php
- include/config.php.dist
- include/global_constants.php
- include/global.php
- lib/functions.php
- include/global_languages.php
- include/global_arrays.php
- include/global_form.php
- include/global_settings.php
# Replication is not started by the current daemon and remains unfinished.
- lib/__master__.php
- lib/replicator.php
- lib/master.php
- lib/slave.php
reportUnmatchedIgnoredErrors: false
ignoreErrors:
- identifier: missingType.iterableValue
# PHPStan gets confused about dynamic constants
- identifier: if.alwaysFalse
- identifier: notEqual.alwaysFalse
- identifier: notEqual.alwaysTrue
- identifier: equal.alwaysTrue
- identifier: if.alwaysTrue
- identifier: while.alwaysTrue
- identifier: greater.alwaysFalse
- identifier: identical.alwaysTrue
- identifier: identical.alwaysFalse
- identifier: booleanAnd.leftAlwaysTrue
- identifier: booleanAnd.leftAlwaysFalse
# L9 - '/^Parameter #1 \$value of function strval expects bool\|float\|int\|resource\|string\|null, mixed given.$/'
# L9 - '/^Parameter #1 \$value of function intval expects array\|bool\|float\|int\|resource\|string\|null, mixed given.$/'
# These files are intentionally generated by the interactive setup wizard.
- identifier: include.fileNotFound
- identifier: includeOnce.fileNotFound
- identifier: requireOnce.fileNotFound
+21
View File
@@ -0,0 +1,21 @@
# Changelog
All notable changes to RRDtool Proxy Server are documented in this file.
Entries are prefixed `security:`, `issue:`, or `feature:`.
## [Unreleased]
- feature: Bump the minimum supported PHP version to 8.2.
- feature: Upgrade the phpseclib dependency from phpseclib 3 to phpseclib 4 (Composer-managed).
- feature: Migrate encryption from the unmaintained, manually vendored phpseclib 1.x/2.x copy to the Composer-managed phpseclib 3 package.
- issue: Fix `lib/__master__.php` and `lib/replicator.php` calling phpseclib 1.x/2.x-only methods (`loadKey()`, `getPublicKeyFingerprint()`) that do not exist in phpseclib 3/4, which fataled the replicator/MSR peer-authentication code path at runtime.
- security: Encrypt the "request too large" error response for authenticated clients instead of returning it in plaintext.
- issue: Inherit the full child-process environment for `removespikes` instead of the subset populated in `$_ENV` (was silently dropping variables such as `TEMP` on Windows).
- issue: Add the missing space in the `RRDTOOL_PIPE_UNAVAILABLE` error frame so it matches other error responses.
- issue: Check the `symlink()` return value in the test fixtures instead of risking a false positive when symlinks are unavailable.
- security: Use constant-time `hash_equals()` comparisons for RSA public-key fingerprint verification.
- security: Bound encrypted and compressed request frames and fail closed on malformed or oversized input.
- security: Confine RRDtool and filesystem operations to the configured RRA root; reject path traversal, absolute paths, and embedded command framing.
- security: Remove shell interpolation from RRDtool and `removespikes` execution in favor of argv-based process invocation.
- security: Make RSA key writes atomic, validate matching key pairs, and enforce `0600` permissions on private key files.
- issue: Fix partial socket writes, RRDtool pipe startup failures, admin disconnect cleanup, and IPv6 admin binding.
+14 -7
View File
@@ -8,10 +8,18 @@ replication, support of Cacti Boost and, if needed, the RRDcached daemon. It
allows system administrators to split Cacti's access resources across machines
without the need for NFS.
Due the fact that updates to files may contain sensitive data and to prevent
undesired updates, RRDtool proxy utilizes high encrypted connections (RSA2048
and AES192) with continuously changing keys. This ensures a secured connection
between the proxy and registered clients and/or other proxies.
Because updates may contain sensitive data, RRDtool Proxy encrypts client
traffic using RSA-2048 key exchange and a fresh 256-bit symmetric key for each
message. Client IP addresses and public-key fingerprints must also be explicitly
allowlisted.
The current compatibility protocol verifies the presented public-key
fingerprint, but does not yet perform a signed challenge proving possession of
the corresponding private key. Its legacy CBC message format also lacks an
authenticated integrity tag. Deploy the client port only on a trusted,
firewalled management network. A future protocol version must add private-key
proof and authenticated encryption in coordination with the Cacti client
before these limitations can be removed.
A local service port is provided to allow administrators are able to access a
separate command line interface which provides easy access to configure and
@@ -30,7 +38,7 @@ higher). The most recent version of RRDProxy requires composer
### Minimum Requirements
- PHP Version 8.1 or higher
- PHP Version 8.2 or higher
Installation must include and enable the following modules:
@@ -48,7 +56,7 @@ higher). The most recent version of RRDProxy requires composer
All minimum requirements must be met, though the following are more optional:
- PHP Version 8.1 or higher
- PHP Version 8.2 or higher
Include all minimum recommendation modules
@@ -200,4 +208,3 @@ rrdp#
Currently, the RRDproxy wizard and command line interface port are both
offering only english as the default language.
+164 -20
View File
@@ -56,6 +56,8 @@ $std_kills = false;
$var_kills = false;
$html = false;
$backup = false;
$config = [];
$new_output = [];
if ($using_cacti) {
$method = read_config_option('spikekill_method');
@@ -205,14 +207,17 @@ if ($rrdfile == '') {
}
// let's see if we can find rrdtool
if (!$using_cacti) {
if (substr_count(PHP_OS, 'WIN')) {
$response = shell_exec('rrdtool.exe');
} else {
$response = shell_exec('rrdtool');
$rrdtool_path = getenv('RRDP_RRDTOOL_PATH');
if ($rrdtool_path === false || $rrdtool_path === '') {
$rrdtool_path = $using_cacti ? read_config_option('path_rrdtool') : (substr_count(PHP_OS, 'WIN') ? 'rrdtool.exe' : 'rrdtool');
}
if (strlen($response)) {
if (!$using_cacti) {
$response = removespikes_run_rrdtool($rrdtool_path, []);
if ($response !== false && strlen($response['stdout'] . $response['stderr'])) {
$response = $response['stdout'] . $response['stderr'];
$response_array = explode(' ', $response);
print 'NOTE: Using ' . $response_array[0] . ' Version ' . $response_array[1] . "\n";
} else {
@@ -227,23 +232,28 @@ $seed = mt_rand();
if ($using_cacti) {
if ($config['cacti_server_os'] == 'win32') {
$tempdir = getenv('TEMP');
$xmlfile = $tempdir . '/' . str_replace('.rrd', '', basename($rrdfile)) . '.dump.' . $seed;
$xmlfile = tempnam($tempdir, 'rrdproxy-dump-');
$bakfile = $tempdir . '/' . str_replace('.rrd', '', basename($rrdfile)) . '.backup.' . $seed . '.rrd';
} else {
$tempdir = '/tmp';
$xmlfile = '/tmp/' . str_replace('.rrd', '', basename($rrdfile)) . '.dump.' . $seed;
$xmlfile = tempnam($tempdir, 'rrdproxy-dump-');
$bakfile = '/tmp/' . str_replace('.rrd', '', basename($rrdfile)) . '.backup.' . $seed . '.rrd';
}
} elseif (substr_count(PHP_OS, 'WIN')) {
$tempdir = getenv('TEMP');
$xmlfile = $tempdir . '/' . str_replace('.rrd', '', basename($rrdfile)) . '.dump.' . $seed;
$xmlfile = tempnam($tempdir, 'rrdproxy-dump-');
$bakfile = $tempdir . '/' . str_replace('.rrd', '', basename($rrdfile)) . '.backup.' . $seed . '.rrd';
} else {
$tempdir = '/tmp';
$xmlfile = '/tmp/' . str_replace('.rrd', '', basename($rrdfile)) . '.dump.' . $seed;
$xmlfile = tempnam($tempdir, 'rrdproxy-dump-');
$bakfile = '/tmp/' . str_replace('.rrd', '', basename($rrdfile)) . '.backup.' . $seed . '.rrd';
}
if ($xmlfile === false) {
print "FATAL: Unable to create a secure temporary file.\n";
exit(-12);
}
if ($html) {
print "<table cellpadding='3' cellspacing='0' class='spikekill_data' id='spikekill_data'>";
}
@@ -255,10 +265,12 @@ if ($using_cacti) {
// execute the dump command
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "NOTE: Creating XML file '$xmlfile' from '$rrdfile'" . ($html ? "</td></tr>\n" : "\n");
if ($using_cacti) {
shell_exec(read_config_option('path_rrdtool') . " dump $rrdfile > $xmlfile");
$dump_result = removespikes_run_rrdtool($rrdtool_path, ['dump', $rrdfile]);
if ($dump_result !== false && $dump_result['status'] === 0 && $dump_result['stdout'] !== '') {
file_put_contents($xmlfile, $dump_result['stdout'], LOCK_EX);
} else {
shell_exec("rrdtool dump $rrdfile > $xmlfile");
unlink($xmlfile);
}
// read the xml file into an array
@@ -454,7 +466,10 @@ if (!$dryrun) {
if ($total_kills) {
if (writeXMLFile($new_output, $xmlfile)) {
if (backupRRDFile($rrdfile)) {
createRRDFileFromXML($xmlfile, $rrdfile);
if (!createRRDFileFromXML($xmlfile, $rrdfile)) {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "FATAL: Unable to restore '$rrdfile' from '$xmlfile'" . ($html ? "</td></tr>\n" : "\n");
exit(-14);
}
} else {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "FATAL: Unable to backup '$rrdfile'" . ($html ? "</td></tr>\n" : "\n");
}
@@ -471,27 +486,78 @@ if ($html) {
}
// All Functions
/**
* Restores an RRD file from its (spike-cleaned) XML dump via `rrdtool restore`.
*
* @param string $xmlfile
* @param string $rrdfile
*
* @return bool
*/
function createRRDFileFromXML($xmlfile, $rrdfile) {
global $using_cacti, $html;
global $html, $rrdtool_path;
// execute the dump command
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . "NOTE: Re-Importing '$xmlfile' to '$rrdfile'" . ($html ? "</td></tr>\n" : "\n");
if ($using_cacti) {
$response = shell_exec(read_config_option('path_rrdtool') . " restore -f -r $xmlfile $rrdfile");
} else {
$response = shell_exec("rrdtool restore -f -r $xmlfile $rrdfile");
}
$result = removespikes_run_rrdtool($rrdtool_path, ['restore', '-f', '-r', $xmlfile, $rrdfile]);
$response = $result === false ? 'Unable to start RRDtool' : $result['stdout'] . $result['stderr'];
if (strlen($response)) {
print ($html ? "<tr><td colspan='20' class='spikekill_note'>" : '') . $response . ($html ? "</td></tr>\n" : "\n");
}
return $result !== false && $result['status'] === 0;
}
/**
* Runs the rrdtool binary with the given argv-style arguments (no shell
* interpolation) and captures its combined stdout/stderr and exit status.
*
* @param string $binary
* @param array<int, string> $arguments
*
* @return array{status: int, stdout: string, stderr: string}|false
*/
function removespikes_run_rrdtool($binary, $arguments) {
$descriptors = [
1 => ['pipe', 'w'],
2 => ['redirect', 1],
];
$process = proc_open(array_merge([$binary], $arguments), $descriptors, $pipes);
if (!is_resource($process)) {
return false;
}
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$status = proc_close($process);
return ['status' => $status, 'stdout' => $stdout, 'stderr' => ''];
}
/**
* Writes the (spike-cleaned) XML dump lines out to $xmlfile.
*
* @param array<int, string> $output
* @param string $xmlfile
*
* @return int|false
*/
function writeXMLFile($output, $xmlfile) {
return file_put_contents($xmlfile, $output);
}
/**
* Copies $rrdfile into the configured (or temp) backup directory before it is
* modified, appending a random seed to the filename if a file of that name
* already exists there.
*
* @param string $rrdfile
*
* @return bool
*/
function backupRRDFile($rrdfile) {
global $using_cacti, $tempdir, $seed, $html;
@@ -516,6 +582,16 @@ function backupRRDFile($rrdfile) {
return copy($rrdfile, $backupdir . '/' . $newfile);
}
/**
* Computes each RRA/data-source's outlier-trimmed "variance" average (the mean
* of the samples after removing the top/bottom $outliers extremes), storing it
* into $rra[$rra_num][$ds_num]['variance_avg'].
*
* @param array<int, array<int, array<string, mixed>>> $rra
* @param array<int, array<int, array<int, float|int>>> $samples
*
* @return void
*/
function calculateVarianceAverages(&$rra, &$samples) {
global $outliers;
@@ -540,6 +616,17 @@ function calculateVarianceAverages(&$rra, &$samples) {
}
}
/**
* For every RRA/data-source, computes the standard deviation, average, and
* min/max cutoffs from the collected samples, then counts (and flags via
* $std_kills/$var_kills) how many samples fall outside the standard-deviation
* and variance thresholds.
*
* @param array<int, array<int, array<string, mixed>>> $rra
* @param array<int, array<int, array<int, float|int>>> $samples
*
* @return void
*/
function calculateOverallStatistics(&$rra, &$samples) {
global $percent, $stddev, $ds_min, $ds_max, $var_kills, $std_kills;
@@ -631,6 +718,14 @@ function calculateOverallStatistics(&$rra, &$samples) {
}
}
/**
* Prints a per-data-source summary table (plain text or HTML) of the computed
* statistics and kill counts for each RRA.
*
* @param array<int, array<int, array<string, mixed>>> $rra
*
* @return void
*/
function outputStatistics($rra) {
global $rra_cf, $rra_name, $ds_name, $rra_pdp, $html;
@@ -718,6 +813,16 @@ function outputStatistics($rra) {
}
}
/**
* Rewrites the dumped XML's <row> value lines, replacing any sample outside
* the computed thresholds with the RRA's average (or 'NaN'), up to $numspike
* replacements per RRA.
*
* @param array<int, string> $output
* @param array<int, array<int, array<string, mixed>>> $rra
*
* @return array<int, string>
*/
function updateXML(&$output, &$rra) {
global $numspike, $percent, $avgnan, $method, $total_kills;
@@ -725,6 +830,7 @@ function updateXML(&$output, &$rra) {
$rra_num = 0;
$ds_num = 0;
$kills = 0;
$new_array = [];
if (sizeof($output)) {
foreach ($output as $line) {
@@ -797,7 +903,17 @@ function updateXML(&$output, &$rra) {
return $new_array;
}
/**
* Strips blank lines and `<!-- ... -->` XML comments from a dumped RRD file's
* lines.
*
* @param array<int, string> $output
*
* @return array<int, string>|null
*/
function removeComments(&$output) {
$new_array = [];
if (sizeof($output)) {
foreach ($output as $line) {
$line = trim($line);
@@ -831,6 +947,14 @@ function removeComments(&$output) {
}
}
/**
* Formats a PDP (primary data point) count as a human-readable duration
* (seconds, minutes, hours, or days) based on the RRD's step size.
*
* @param int $pdp
*
* @return string
*/
function displayTime($pdp) {
global $step;
@@ -857,6 +981,13 @@ function displayTime($pdp) {
}
}
/**
* Prints $string prefixed with "DEBUG: " when debug output is enabled.
*
* @param string $string
*
* @return void
*/
function debug($string) {
global $debug;
@@ -865,8 +996,16 @@ function debug($string) {
}
}
/**
* Computes the (population) standard deviation of a set of numeric samples.
*
* @param array<int, float|int> $samples
*
* @return float
*/
function standard_deviation($samples) {
$sample_count = count($samples);
$sample_square = [];
for ($current_sample = 0; $sample_count > $current_sample; ++$current_sample) {
$sample_square[$current_sample] = pow($samples[$current_sample], 2);
@@ -876,6 +1015,11 @@ function standard_deviation($samples) {
}
// display_help - displays the usage of the function
/**
* Prints removespikes' version banner and command-line usage/help text.
*
* @return void
*/
function display_help() {
global $using_cacti;
+4 -3
View File
@@ -26,7 +26,7 @@
"type": "composer-plugin",
"license": "GPL-2.0-only",
"require": {
"php": "^8.1",
"php": "^8.2",
"ext-gmp": "*",
"ext-json": "*",
"ext-ldap": "*",
@@ -36,7 +36,7 @@
"ext-posix": "*",
"ext-sockets": "*",
"pear/net_dns2": "^2.0",
"phpseclib/phpseclib": "^3.0"
"phpseclib/phpseclib": "^4.0"
},
"suggest": {
"friendsofphp/php-cs-fixer": "^3.13"
@@ -63,8 +63,9 @@
}
},
"scripts": {
"test": "php tests/run.php",
"lint": "phplint --no-cache --exclude=vendor",
"phpstan": "phpstan analyse --level 6 ",
"phpstan": "phpstan analyse --no-progress --debug",
"phpcsfixer": "php-cs-fixer fix --allow-unsupported-php-version=yes --dry-run --diff --config=./.php-cs-fixer.php ",
"phpcsfixit": "php-cs-fixer fix "
},
Generated
+636 -335
View File
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -27,11 +27,15 @@ define('COPYRIGHT_YEARS_SHORT', '(c) 2004-' . date('Y') . ' - The Cacti Group');
define('RRDP_VERSION', '1.2.17');
define('RRDP_VERSION_FULL', 'RRDtool Proxy Server v' . RRDP_VERSION . ', ' . COPYRIGHT_YEARS . "\r\n");
define('RRDP_PHP_VERSION_REQUIRED', 70200);
define('RRDP_PHP_VERSION_REQUIRED', 80200);
define('RRD_OK', 'OK u:0.00');
define('RRD_ERROR', 'ERROR:');
define('RRDP_MAX_KEY_SIZE', 16384);
define('RRDP_MAX_REQUEST_SIZE', 1048576);
define('RRDP_MAX_DECOMPRESSED_REQUEST_SIZE', 8388608);
define('SEVERITY_LEVEL_NONE', 0);
define('SEVERITY_LEVEL_EMERGENCY', 1);
define('SEVERITY_LEVEL_ALERT', 2);
@@ -174,7 +178,6 @@ $rrdtool_custom_cmds = [
$rrdp_client_cnn_params = [
'timeout',
'encryption',
];
$rrdtool_cmds = [
+97 -34
View File
@@ -22,6 +22,16 @@
+-------------------------------------------------------------------------+
*/
use phpseclib4\Crypt\RSA;
/**
* Main loop of the replication master process: listens for and authenticates
* inbound cluster peer connections (verifying each peer's RSA public-key
* fingerprint), services IPC requests from the parent process, and drives the
* persistent RRDtool pipe used to answer replication requests.
*
* @return void
*/
function interact() {
global $rrdp_remoteproxies,
$ipc_socket_parent,
@@ -53,9 +63,6 @@ function interact() {
$rrdp_remoteproxies = [];
$rrdp_replicator_state = 'running';
// enable message encryption
$rsa = new \phpseclib\Crypt\RSA();
// start listening for cluster peers
$rrdp_server = @socket_create((($rrdp_config['ipv6']) ? AF_INET : AF_INET), SOCK_STREAM, SOL_TCP);
@@ -98,6 +105,13 @@ function interact() {
// setup a dedicated persistent RRD_PIPE
if ($rrdtool_process === false) {
$rrdtool_process_pipes = rrdtool_pipe_init($rrdp_config);
if ($rrdtool_process_pipes === false) {
rrdp_system__count('rrd_pipe_broken');
continue;
}
$rrdtool_process = $rrdtool_process_pipes[0];
$rrdtool_pipes = $rrdtool_process_pipes[1];
}
@@ -128,11 +142,14 @@ function interact() {
// verify authorization
if (array_key_exists($ip, $rrdp_remote_proxies) === true) {
$key = intval($socket_descriptor);
$rrdp_remoteproxies[$key] = [ 'socket' => $socket_descriptor, 'ip' => $ip, 'public_key' => false, 'authenticated' => false, 'last_seen' => time()];
__logging(LOGGING_LOCATION_BUFFERED, 'Remote Proxy connection request #' . $key . '[IP: ' . $ip . '] granted', 'ACL', SEVERITY_LEVEL_DEBUG);
} else {
@socket_write($socket_descriptor, "ERROR: Access denied.\r\n");
@socket_close($socket_descriptor);
rrdp_system__count('connections_refused');
__logging(LOGGING_LOCATION_BUFFERED, 'Remote Proxy connection request [IP: ' . $ip . '] rejected.', 'ACL', SEVERITY_LEVEL_WARNING);
}
@@ -250,9 +267,13 @@ function interact() {
$ip = $rrdp_remoteproxies[$index]['ip'];
$rsa_finger_print = isset($rrdp_remote_proxies[$ip]) ? $rrdp_remote_proxies[$ip]['fingerprint'] : 'unknown';
$rsa->loadKey($client_public_key);
try {
$remote_fingerprint = RSA::loadPublicKey($client_public_key)->getFingerprint('md5');
} catch (Throwable $e) {
$remote_fingerprint = false;
}
if ($rsa_finger_print == $rsa->getPublicKeyFingerprint()) {
if ($remote_fingerprint !== false && hash_equals(strtolower($rsa_finger_print), strtolower($remote_fingerprint))) {
// registered public key has been received
$rrdp_remoteproxies[$index]['authenticated'] = true;
$rrdp_remoteproxies[$index]['public_key'] = $client_public_key;
@@ -288,6 +309,7 @@ function interact() {
if (substr_count($output, 'END_OF_MSG')) {
$output = str_replace('END_OF_MSG', '', $output);
$rrdp_remoteproxies[$index]['last_seen'] = time();
break 2;
@@ -338,9 +360,11 @@ function interact() {
if ($remote_index) {
$rrdp_replicator_state = 'synchronizing';
$read_socket = $rrdp_remoteproxies[$remote_index]['socket'];
$public_key = $rrdp_remoteproxies[$remote_index]['public_key'];
$ip = $rrdp_remoteproxies[$remote_index]['ip'];
__logging(LOGGING_LOCATION_BUFFERED, 'Start full synchronisation process with #' . intval($read_socket) . ' [IP: ' . $ip . ']', 'MSR', SEVERITY_LEVEL_NOTIFICATION);
rrdp_system__socket_write($read_socket, encrypt('FULLSCAN END_OF_MSG', $public_key) . "\r\n", 'msr_bytes_sent');
} else {
@@ -366,12 +390,19 @@ function interact() {
}
// internal function to establish an encrypted remote connection to registered peers
/**
* Opens an outbound connection to a registered cluster peer, exchanges RSA public
* keys, and verifies the peer's key against its configured fingerprint.
*
* @param string $remote_ip
* @param int $remote_port
* @param string $remote_fingerprint
*
* @return array{socket: resource|\Socket, ip: string, public_key: string, authenticated: bool, last_seen: int}|false
*/
function __remote_connect($remote_ip, $remote_port, $remote_fingerprint) {
global $rrdp_config, $rrdp_encryption;
// enable message encryption
$rsa = $rsa = new \phpseclib\Crypt\RSA();
$rrdp_socket = @socket_create((($rrdp_config['ipv6']) ? AF_INET6 : AF_INET), SOCK_STREAM, SOL_TCP);
if (@socket_connect($rrdp_socket, $remote_ip, $remote_port) === true) {
@@ -404,8 +435,11 @@ function __remote_connect($remote_ip, $remote_port, $remote_fingerprint) {
}
}
$rsa->loadKey($rrdp_public_key);
$fingerprint = $rsa->getPublicKeyFingerprint();
try {
$fingerprint = RSA::loadPublicKey($rrdp_public_key)->getFingerprint('md5');
} catch (Throwable $e) {
return false;
}
if ($remote_fingerprint != $fingerprint) {
// fingerprint mismatch
@@ -419,6 +453,15 @@ function __remote_connect($remote_ip, $remote_port, $remote_fingerprint) {
}
// internal function to read the response of a registered peer to a previous request
/**
* Reads and decrypts a registered peer's newline-delimited response packets,
* reassembling them until an END_OF_MSG marker is seen, and returns the parsed
* [command, status, payload] triple.
*
* @param resource|\Socket $read_socket
*
* @return array{0: string, 1: string, 2: string}|false
*/
function __remote_read($read_socket) {
global $rrdp_remoteproxies, $rrdp_encryption;
@@ -460,6 +503,7 @@ function __remote_read($read_socket) {
if ($first_packet) {
[$cmd, $status, $payload] = explode(' ', $packet, 3);
$first_packet = false;
$packet = $payload;
}
@@ -485,6 +529,15 @@ function __remote_read($read_socket) {
return false;
}
/**
* Dispatches a decoded replication request (STATUS, DIFF, etc.) received from a
* registered peer and writes the encrypted response back to that peer's socket.
*
* @param string $input
* @param resource|\Socket $read_socket
*
* @return void
*/
function handle__request($input, $read_socket) {
global $rrdp_config, $rrdp_remoteproxies, $rrdp_encryption, $rrdcached_pid, $replicator_system_start, $rrdtool_process, $rrdtool_pipes;
@@ -660,13 +713,20 @@ function handle__request($input, $read_socket) {
$file_size = $file_settings[2];
$mtime = $file_settings[3];
if ($folder != './') {
$rra_subfolder = rtrim($rrdp_config['path_rra'], '/') . '/' . ltrim($folder, './');
$rra_file_path_absolute = $rra_subfolder . '/' . $file;
$rra_relative_path = ($folder !== './' ? ltrim($folder, './') . '/' : '') . $file;
$rra_file_path_absolute = rrdp_resolve_rra_path($rra_relative_path, false);
if ($rra_file_path_absolute === false) {
__logging(LOGGING_LOCATION_BUFFERED, 'SYNC: rejected unsafe peer-supplied path: ' . $rra_relative_path, 'MSR', SEVERITY_LEVEL_CRITICAL);
continue;
}
$rra_subfolder = dirname($rra_file_path_absolute);
// create subfolder if not already existing
if (!is_dir($rra_subfolder)) {
if (mkdir($rra_subfolder) === false) {
if ($folder !== './' && !is_dir($rra_subfolder)) {
if (mkdir($rra_subfolder, 0755, true) === false) {
__logging(LOGGING_LOCATION_BUFFERED, 'Cannot create RRA subfolder: ' . $rra_subfolder , 'MSR', SEVERITY_LEVEL_CRITICAL);
} else {
__logging(LOGGING_LOCATION_BUFFERED, 'SYNC: created: ' . $rra_subfolder , 'MSR', SEVERITY_LEVEL_DEBUG);
@@ -678,16 +738,9 @@ function handle__request($input, $read_socket) {
__logging(LOGGING_LOCATION_BUFFERED, 'SYNC: set mtime: ' . $rra_subfolder , 'MSR', SEVERITY_LEVEL_DEBUG);
}
__logging(LOGGING_LOCATION_BUFFERED, 'SYNC: restored: ' . $rra_subfolder , 'MSR', SEVERITY_LEVEL_INFORMATION);
}
} else {
$rra_file_path_absolute = rtrim($rrdp_config['path_rra'], '/') . '/' . $file;
}
if (!file_exists($rra_file_path_absolute)) {
__logging(LOGGING_LOCATION_BUFFERED, 'SYNC: file not found: ' . $rra_file_path_absolute , 'MSR', SEVERITY_LEVEL_DEBUG);
// request the complete dump of that RRDfile
$msg = 'RRDDUMP ' . rtrim($folder, '/') . '/' . $file;
rrdp_system__socket_write($read_socket, encrypt($msg . ' END_OF_MSG', $public_key) . "\r\n", 'msr_bytes_sent');
__logging(LOGGING_LOCATION_BUFFERED, 'SYNC: sent data request: ' . $msg , 'MSR', SEVERITY_LEVEL_DEBUG);
@@ -696,14 +749,20 @@ function handle__request($input, $read_socket) {
if ($response !== false) {
if (substr_count($response[2], 'OK u')) {
[$payload,$mtime] = explode(':__filemtime__:', $response[2]);
$rrd_data = substr($payload, 0, strpos($payload, 'OK u'));
file_put_contents($rra_file_path_absolute . '.xml', $rrd_data);
$rrd_exec_status = rrdtool_pipe_execute('restore ' . $rra_file_path_absolute . '.xml ' . $folder . '/' . $file . "\r\n", $rrdtool_pipes, false, false, false, true);
$rrd_exec_status = rrdtool_pipe_execute('restore ' . $rra_file_path_absolute . '.xml ' . $rra_file_path_absolute . "\r\n", $rrdtool_pipes, false, false, false, true);
if ($rrd_exec_status) {
touch($rra_file_path_absolute, $mtime);
$memory_table[$rra_file_path_absolute] = $mtime;
unlink($rra_file_path_absolute . '.xml');
__logging(LOGGING_LOCATION_BUFFERED, 'SYNC: file restored: ' . $rra_file_path_absolute , 'MSR', SEVERITY_LEVEL_DEBUG);
} else {
__logging(LOGGING_LOCATION_BUFFERED, 'SYNC: file restoral failed: ' . $rra_file_path_absolute , 'MSR', SEVERITY_LEVEL_CRITICAL);
@@ -714,7 +773,7 @@ function handle__request($input, $read_socket) {
/* file is already existing - in this case we have to compare
* local and remote last update time of this file */
$rrd_return = rrdtool_pipe_execute('last ' . rtrim($folder, '/') . '/' . $file . "\r\n", $rrdtool_pipes, false, false, false);
$rrd_return = rrdtool_pipe_execute('last ' . $rra_file_path_absolute . "\r\n", $rrdtool_pipes, false, false, false);
if (substr_count($rrd_return, 'OK u')) {
$local_last = rtrim(substr($rrd_return, 0, strpos($rrd_return, 'OK u')));
@@ -747,9 +806,12 @@ function handle__request($input, $read_socket) {
if ($response !== false) {
if (substr_count($response[2], 'OK u')) {
[$payload,$mtime] = explode(':__filemtime__:', $response[2]);
$rrd_data = substr($payload, 0, strpos($payload, 'OK u'));
file_put_contents($rra_file_path_absolute . '.xml', $rrd_data);
$rrd_exec_status = rrdtool_pipe_execute('restore ' . $rra_file_path_absolute . '.xml ' . $folder . '/' . $file . ".tmp\r\n", $rrdtool_pipes, false, false, false, true);
$rrd_exec_status = rrdtool_pipe_execute('restore ' . $rra_file_path_absolute . '.xml ' . $rra_file_path_absolute . ".tmp\r\n", $rrdtool_pipes, false, false, false, true);
if ($rrd_exec_status) {
unlink($rra_file_path_absolute);
@@ -779,11 +841,11 @@ function handle__request($input, $read_socket) {
break;
case 'RRDLAST':
if ($cmd_options && is_array($cmd_options) && substr($cmd_options[0],-3) == 'rrd') {
$rra_path_absolute = rtrim($rrdp_config['path_rra'], '/') . '/' . ltrim($cmd_options[0], './');
$rra_path_absolute = rrdp_resolve_rra_path($cmd_options[0]);
if (file_exists($rra_path_absolute)) {
if ($rra_path_absolute !== false) {
if ($rrdcached_pid) {
$rrd_cmd = 'flushcached ' . $cmd_options[0];
$rrd_cmd = 'flushcached ' . $rra_path_absolute;
$rrd_exec_status = rrdtool_pipe_execute($rrd_cmd . "\r\n", $rrdtool_pipes, false, false, false, true);
if (!$rrd_exec_status) {
@@ -794,7 +856,7 @@ function handle__request($input, $read_socket) {
}
}
$rrd_cmd = 'last ' . $cmd_options[0];
$rrd_cmd = 'last ' . $rra_path_absolute;
rrdp_system__socket_write($read_socket, encrypt('__RRDLAST 201 ', $public_key) . "\r\n", 'msr_bytes_sent');
$rrd_exec_status = rrdtool_pipe_execute($rrd_cmd . "\r\n", $rrdtool_pipes, $read_socket, $public_key, 1);
rrdp_system__socket_write($read_socket, encrypt(' END_OF_MSG', $public_key) . "\r\n", 'msr_bytes_sent');
@@ -815,11 +877,12 @@ function handle__request($input, $read_socket) {
break;
case 'RRDDUMP':
if ($cmd_options && is_array($cmd_options) && substr($cmd_options[0],-3) == 'rrd') {
$rra_path_absolute = rtrim($rrdp_config['path_rra'], '/') . '/' . ltrim($cmd_options[0], './');
$rra_path_absolute = rrdp_resolve_rra_path($cmd_options[0]);
if (file_exists($rra_path_absolute)) {
if ($rra_path_absolute !== false) {
if ($rrdcached_pid) {
$rrd_cmd = 'flushcached ' . $cmd_options[0];
$rrd_cmd = 'flushcached ' . $rra_path_absolute;
$rrd_exec_status = rrdtool_pipe_execute($rrd_cmd . "\r\n", $rrdtool_pipes, false, false, false, true);
if (!$rrd_exec_status) {
@@ -830,7 +893,7 @@ function handle__request($input, $read_socket) {
}
}
$rrd_cmd = 'dump ' . $cmd_options[0];
$rrd_cmd = 'dump ' . $rra_path_absolute;
rrdp_system__socket_write($read_socket, encrypt('__RRDDUMP 201 ', $public_key) . "\r\n", 'msr_bytes_sent');
$rrd_exec_status = rrdtool_pipe_execute($rrd_cmd . "\r\n", $rrdtool_pipes, $read_socket, $public_key, 1);
rrdp_system__socket_write($read_socket, encrypt(':__filemtime__:' . filemtime($rra_path_absolute) . ' END_OF_MSG', $public_key) . "\r\n", 'msr_bytes_sent');
+142 -45
View File
@@ -22,8 +22,18 @@
+-------------------------------------------------------------------------+
*/
use phpseclib\Crypt\RSA;
use phpseclib4\Crypt\RSA;
/**
* Handles a single client connection for its entire lifetime: performs the RSA
* public-key handshake, then authenticates, decrypts, and dispatches each request
* (RRDtool commands, custom commands, and removespikes) to its handler, encrypting
* and writing back every response until the client disconnects or times out.
*
* @param resource|\Socket $socket_client
*
* @return void
*/
function interact($socket_client) {
/*
Clients using the default port are only allowed to talk to RRDtool directly.
@@ -45,7 +55,9 @@ function interact($socket_client) {
$encryption = true;
$rrdp_status_backup = $rrdp_status;
$rrdtool_process = false;
$rrdtool_pipes = [];
$client_authenticated = false;
$client_public_key = '';
$end_of_packet = "_EOP_\r\n";
$end_of_sequence = "_EOT_\r\n";
@@ -59,9 +71,6 @@ function interact($socket_client) {
$real_client_resource_id = rrdp_system__get_resource_id($socket_client);
$ipc_parent_resource_id = rrdp_system__get_resource_id($ipc_socket_parent);
// enable message encryption
$rsa = new RSA();
$input = '';
while (1) {
@@ -110,6 +119,7 @@ function interact($socket_client) {
break 3;
} else {
$input .= $recv;
$max_input_size = $client_authenticated ? RRDP_MAX_REQUEST_SIZE : RRDP_MAX_KEY_SIZE;
if (strpos($input, $end_of_sequence) !== false) { // end of one or more transactions detected
rrdp_system__count('bytes_received', rrdp_system__calc_bytes($input));
@@ -118,6 +128,14 @@ function interact($socket_client) {
$input = array_pop($transactions);
foreach ($transactions as $transaction) {
if (strlen($transaction) > $max_input_size) {
rrdp_system__socket_write($socket_client, (($client_authenticated) ? encrypt(RRD_ERROR . ' Request too large', $client_public_key) : RRD_ERROR . ' Request too large') . $end_of_sequence);
__logging(LOGGING_LOCATION_BUFFERED, 'Client request exceeded the maximum frame size', 'IPC', SEVERITY_LEVEL_WARNING);
$rrdp_status['status'] = 'CLOSEDOWN_BY_VIOLATION';
break 4;
}
if (!$client_authenticated) {
if (strpos($transaction, '-----BEGIN PUBLIC KEY-----') === false) {
// authentication failed !!!
@@ -130,9 +148,15 @@ function interact($socket_client) {
$client_public_key = $transaction;
socket_getpeername($socket_client, $ip);
$rsa_finger_print = isset($rrdp_config['remote_clients'][$ip]) ? $rrdp_config['remote_clients'][$ip] : 'unknown';
$rsa->loadKey($client_public_key);
if ($rsa_finger_print == $rsa->getPublicKeyFingerprint()) {
try {
$public_key = RSA::loadPublicKey($client_public_key);
$client_fingerprint = $public_key->getFingerprint('md5');
} catch (Throwable $e) {
$client_fingerprint = false;
}
if ($client_fingerprint !== false && hash_equals(strtolower($rsa_finger_print), strtolower($client_fingerprint))) {
// registered public key has been received
$client_authenticated = true;
// send out proxy's public key
@@ -161,7 +185,14 @@ function interact($socket_client) {
}
if (strpos($transaction, "\x1f\x8b") === 0) {
$transaction = gzdecode($transaction);
$transaction = gzdecode($transaction, RRDP_MAX_DECOMPRESSED_REQUEST_SIZE);
if ($transaction === false) {
rrdp_system__socket_write($socket_client, encrypt(RRD_ERROR . ' Invalid compressed request', $client_public_key) . $end_of_sequence);
$rrdp_status['status'] = 'CLOSEDOWN_BY_VIOLATION';
break 4;
}
}
// take care of invalid blanks
@@ -184,11 +215,37 @@ function interact($socket_client) {
__logging(LOGGING_LOCATION_BUFFERED, 'REQUEST: ' . $cmd . ' ' . $cmd_options , 'IPC', SEVERITY_LEVEL_DEBUG);
if (in_array($cmd, $rrdtool_cmds, true) === true) {
if (rrdp_command_has_unsafe_path($cmd_options)) {
rrdp_system__socket_write($socket_client, encrypt(RRD_ERROR . ' Unsafe path or command framing', $client_public_key) . $end_of_sequence);
rrdp_system__count('queries_rrdtool_invalid');
continue;
}
$resolved_cmd_options = rrdp_resolve_command_paths($cmd, $cmd_options);
if ($resolved_cmd_options === false) {
rrdp_system__socket_write($socket_client, encrypt(RRD_ERROR . ' Unsafe path or command framing', $client_public_key) . $end_of_sequence);
rrdp_system__count('queries_rrdtool_invalid');
continue;
}
$cmd_options = $resolved_cmd_options;
rrdp_system__count('queries_rrdtool_total');
// open a persistent RRDtool pipe only on demand
if ($rrdtool_process === false) {
$rrdtool_process_pipes = rrdtool_pipe_init($rrdp_config);
if ($rrdtool_process_pipes === false) {
rrdp_system__count('rrd_pipe_broken');
rrdp_system__socket_write($socket_client, encrypt(RRD_ERROR . ' RRDTOOL_PIPE_UNAVAILABLE', $client_public_key) . $end_of_sequence);
continue;
}
$rrdtool_process = $rrdtool_process_pipes[0];
$rrdtool_pipes = $rrdtool_process_pipes[1];
}
@@ -203,13 +260,19 @@ function interact($socket_client) {
if (__sizeof($rrdp_config['remote_proxies']) > 0 && in_array($cmd , $rrdtool_msr_cmds, true) === true) {
__logging(LOGGING_LOCATION_BUFFERED, 'MSR: ' . $cmd . ' ' . $cmd_options , 'MSR', SEVERITY_LEVEL_DEBUG);
[$mtime,$time] = explode(' ',microtime());
$mtime = (float) $mtime;
$time = (int) $time;
$offset = $time % 10;
$block = $time - $offset + 10;
$rrdp_status['msr_commands'][$block][$offset + $mtime . '_' . $socket_resource_id] = $cmd . ' ' . $cmd_options;
if (__sizeof($rrdp_status['msr_commands']) > 1 | $time >= $block) {
if (__sizeof($rrdp_status['msr_commands']) > 1 || $time >= $block) {
$msr_block = key($rrdp_status['msr_commands']);
$msr_message['type'] = 'msr';
$msr_message['msr_commands'][$msr_block] = $rrdp_status['msr_commands'][$msr_block];
if (@socket_write($ipc_socket_parent, serialize($msr_message) . "\r\n")) {
@@ -243,7 +306,8 @@ function interact($socket_client) {
case 'setenv':
if (__sizeof($options) >= 2 && in_array($options[0], $rrdtool_env_vars, true)) {
$enviro_var = array_shift($options);
putenv("'" . $enviro_var . "'='" . str_replace("'", '', implode(' ', $options)) . "'");
$enviro_value = str_replace(["\0", "\r", "\n"], '', implode(' ', $options));
putenv($enviro_var . '=' . $enviro_value);
rrdp_system__socket_write($socket_client, encrypt(RRD_OK, $client_public_key) . $end_of_sequence);
__logging(LOGGING_LOCATION_BUFFERED, 'RESPONSE: ' . RRD_OK, 'IPC', SEVERITY_LEVEL_DEBUG);
} else {
@@ -278,19 +342,6 @@ function interact($socket_client) {
rrdp_system__socket_write($socket_client, encrypt(RRD_ERROR . ' % Out of range.', $client_public_key) . $end_of_sequence);
__logging(LOGGING_LOCATION_BUFFERED, 'RESPONSE: ' . RRD_ERROR, 'IPC', SEVERITY_LEVEL_DEBUG);
}
} elseif ($options[0] == 'encryption') {
if (in_array($options[1], ['null', 'off', 'disabled', '-1'], true)) {
rrdp_system__socket_write($socket_client, encrypt("% Encryption will be disabled.\n" . RRD_OK, $client_public_key) . $end_of_sequence);
$encryption = false;
__logging(LOGGING_LOCATION_BUFFERED, 'RESPONSE: ' . RRD_OK, 'IPC', SEVERITY_LEVEL_DEBUG);
} elseif (in_array($options[1], ['true', 'on', 'enabled', '1'], true)) {
rrdp_system__socket_write($socket_client, encrypt("% Encryption will be enabled.\n" . RRD_OK, $client_public_key) . $end_of_sequence);
$encryption = true;
__logging(LOGGING_LOCATION_BUFFERED, 'RESPONSE: ' . RRD_OK, 'IPC', SEVERITY_LEVEL_DEBUG);
} else {
rrdp_system__socket_write($socket_client, encrypt(RRD_ERROR . ' % Out of range.', $client_public_key) . $end_of_sequence);
__logging(LOGGING_LOCATION_BUFFERED, 'RESPONSE: ' . RRD_ERROR, 'IPC', SEVERITY_LEVEL_DEBUG);
}
}
} else {
rrdp_system__socket_write($socket_client, encrypt(RRD_ERROR . ' % setcnn <parameter> <value>', $client_public_key) . $end_of_sequence);
@@ -308,55 +359,82 @@ function interact($socket_client) {
// scan RRA folder
clearstatcache();
$buffer = '';
$rra_path_absolute = rtrim($rrdp_config['path_rra'], '/');
$rra_path_absolute = realpath($rrdp_config['path_rra']);
if ($rra_path_absolute === false) {
rrdp_system__socket_write($socket_client, encrypt(RRD_ERROR . ' RRA path is unavailable', $client_public_key) . $end_of_sequence);
break;
}
$dir_iterator = new RecursiveDirectoryIterator($rra_path_absolute);
$iterator = new RecursiveIteratorIterator($dir_iterator, RecursiveIteratorIterator::SELF_FIRST);
foreach ($iterator as $file) {
if (substr($file->getPathname(),-3) == 'rrd') {
$buffer .= str_replace($rra_path_absolute . '/', '', $file->getPathname()) . ',' . $file->getSize() . ',' . $file->getMTime() . "\r\n";
$resolved_path = realpath($file->getPathname());
if ($resolved_path !== false
&& rrdp_path_is_within($resolved_path, $rra_path_absolute)
&& str_ends_with(strtolower($resolved_path), '.rrd')) {
$buffer .= ltrim(substr($resolved_path, strlen($rra_path_absolute)), DIRECTORY_SEPARATOR) . ',' . $file->getSize() . ',' . $file->getMTime() . "\r\n";
}
}
$buffer .= RRD_OK;
$buffer_length = strlen($buffer);
$buffer = gzencode($buffer,1);
$buffer_length_new = strlen($buffer);
rrdp_system__socket_write($read_socket, encrypt($buffer, $client_public_key) . $end_of_sequence);
__logging(LOGGING_LOCATION_BUFFERED, 'RESPONSE: ' . RRD_OK . ', payload: ' . $buffer_length_new . ' Bytes, compression: on , ratio: ' . round(($buffer_length / $buffer_length_new),2) . ' , packets: 1', 'IPC', SEVERITY_LEVEL_DEBUG);
break;
case 'filemtime':
$rrdp_exec_return = call_user_func_array($cmd, $options);
$path = __sizeof($options) === 1 ? rrdp_resolve_rra_path($options[0]) : false;
$rrdp_exec_return = $path !== false ? filemtime($path) : false;
rrdp_system__socket_write($socket_client, encrypt($rrdp_exec_return . "\r\n" . RRD_OK, $client_public_key) . $end_of_sequence);
__logging(LOGGING_LOCATION_BUFFERED, 'RESPONSE: ' . $rrdp_exec_return . "\r\n" . RRD_OK, 'IPC', SEVERITY_LEVEL_DEBUG);
break;
case 'file_exists':
case 'is_dir':
$rrdp_exec_status = call_user_func_array($cmd, $options);
$path = __sizeof($options) === 1 ? rrdp_resolve_rra_path($options[0]) : false;
$rrdp_exec_status = $path !== false && $cmd($path);
rrdp_system__socket_write($socket_client, encrypt(($rrdp_exec_status === true) ? RRD_OK : RRD_ERROR , $client_public_key) . $end_of_sequence);
__logging(LOGGING_LOCATION_BUFFERED, 'RESPONSE: ' . (($rrdp_exec_status === true) ? RRD_OK : RRD_ERROR), 'IPC', SEVERITY_LEVEL_DEBUG);
break;
case 'mkdir':
$options[1] = 0700;
$options[2] = true;
$rrdp_exec_status = call_user_func_array($cmd, $options);
$path = __sizeof($options) === 1 ? rrdp_resolve_rra_path($options[0], false) : false;
$rrdp_exec_status = $path !== false && mkdir($path, 0700, true);
rrdp_system__socket_write($socket_client, encrypt(($rrdp_exec_status === true) ? RRD_OK : RRD_ERROR , $client_public_key) . $end_of_sequence);
__logging(LOGGING_LOCATION_BUFFERED, 'RESPONSE: ' . (($rrdp_exec_status === true) ? RRD_OK : RRD_ERROR), 'IPC', SEVERITY_LEVEL_DEBUG);
break;
case 'archive':
if (__sizeof($options) == 1 && (substr($options[0],-3) == 'rrd') && file_exists($options[0]) && $rrdp_config['path_rra_archive']) {
$source_file = $options[0];
$target_dir = $rrdp_config['path_rra_archive'] . '/' . ltrim(dirname($source_file), './');
$target_file = $rrdp_config['path_rra_archive'] . '/' . $source_file;
$source_file = __sizeof($options) === 1 ? rrdp_resolve_rra_path($options[0]) : false;
$archive_base = $rrdp_config['path_rra_archive'] ? realpath($rrdp_config['path_rra_archive']) : false;
if ($source_file !== false && str_ends_with(strtolower($source_file), '.rrd') && $archive_base !== false) {
$relative = ltrim(substr($source_file, strlen(realpath($rrdp_config['path_rra']))), DIRECTORY_SEPARATOR);
$target_file = rrdp_resolve_path_within($archive_base, $relative, false);
if ($target_file !== false) {
$target_dir = dirname($target_file);
if (!is_dir($target_dir)) {
mkdir($target_dir);
mkdir($target_dir, 0700, true);
}
$rrdp_exec_status = rename($source_file, $target_file);
}
} else {
$rrdp_exec_status = false;
}
@@ -366,22 +444,26 @@ function interact($socket_client) {
break;
case 'unlink':
if (__sizeof($options) == 1 && (substr($options[0],-3) == 'rrd') & file_exists($options[0])) {
$rrdp_exec_status = call_user_func_array($cmd, $options);
} else {
$rrdp_exec_status = false;
}
$path = __sizeof($options) === 1 ? rrdp_resolve_rra_path($options[0]) : false;
$rrdp_exec_status = $path !== false && str_ends_with(strtolower($path), '.rrd') && unlink($path);
rrdp_system__socket_write($socket_client, encrypt((($rrdp_exec_status === true) ? RRD_OK : RRD_ERROR), $client_public_key) . $end_of_sequence);
__logging(LOGGING_LOCATION_BUFFERED, 'RESPONSE: ' . (($rrdp_exec_status === true) ? RRD_OK : RRD_ERROR), 'IPC', SEVERITY_LEVEL_DEBUG);
break;
case 'removespikes':
$cmd_options = str_replace('-R=.', '-R=' . $rrdp_config['path_rra'], $cmd_options);
$rrdp_exec_return = shell_exec(PHP_BINARY . ' -q ' . $rrdp_config['path_cli'] . '/removespikes.php ' . $cmd_options);
$parsed_options = rrdp_parse_removespikes_options($cmd_options);
rrdp_system__socket_write($socket_client, encrypt($rrdp_exec_return . "\r\n" . RRD_OK, $client_public_key) . $end_of_sequence);
__logging(LOGGING_LOCATION_BUFFERED, 'RESPONSE: ' . $rrdp_exec_return . "\r\n" . RRD_OK, 'IPC', SEVERITY_LEVEL_DEBUG);
// inherit the full process environment (getenv() unlike $_ENV isn't gated by variables_order)
$environment = array_merge(getenv(), ['RRDP_RRDTOOL_PATH' => $rrdp_config['path_rrdtool']]);
$result = $parsed_options === false ? false : rrdp_run_process(array_merge([PHP_BINARY, '-q', $rrdp_config['path_cli'] . '/removespikes.php'], $parsed_options), $environment);
$rrdp_exec_status = $result !== false && $result['status'] === 0;
$rrdp_exec_return = $result !== false ? $result['stdout'] . $result['stderr'] : 'Invalid removespikes options';
$response = $rrdp_exec_return . "\r\n" . ($rrdp_exec_status ? RRD_OK : RRD_ERROR);
rrdp_system__socket_write($socket_client, encrypt($response, $client_public_key) . $end_of_sequence);
__logging(LOGGING_LOCATION_BUFFERED, 'RESPONSE: ' . $response, 'IPC', SEVERITY_LEVEL_DEBUG);
break;
case 'version':
@@ -397,13 +479,19 @@ function interact($socket_client) {
if ($rrdp_exec_status === true) {
if (in_array($cmd , $rrdtool_msr_cmds, true) === true) {
[$mtime,$time] = explode(' ',microtime());
$mtime = (float) $mtime;
$time = (int) $time;
$offset = $time % 10;
$block = $time - $offset + 10;
$rrdp_status['msr_commands'][$block][$offset + $mtime . '_' . $socket_resource_id] = $cmd . ' ' . $cmd_options;
if (__sizeof($rrdp_status['msr_commands']) > 1 | $time >= $block) {
if (__sizeof($rrdp_status['msr_commands']) > 1 || $time >= $block) {
$msr_block = key($rrdp_status['msr_commands']);
$msr_message['type'] = 'msr';
$msr_message['msr_commands'][$msr_block] = $rrdp_status['msr_commands'][$msr_block];
if (@socket_write($ipc_socket_parent, serialize($msr_message) . "\r\n")) {
@@ -419,12 +507,21 @@ function interact($socket_client) {
// kick that client if it does not know how to talk to me
__logging(LOGGING_LOCATION_BUFFERED, 'Client is using invalid commands.', 'IPC', SEVERITY_LEVEL_ERROR);
rrdp_system__socket_write($socket_client, encrypt(RRD_ERROR . ' % Invalid input detected', $client_public_key) . $end_of_sequence);
$rrdp_status['status'] = 'CLOSEDOWN_BY_VIOLATION';
break 4;
}
}
}
if (strlen($input) > $max_input_size) {
rrdp_system__socket_write($socket_client, (($client_authenticated) ? encrypt(RRD_ERROR . ' Request too large', $client_public_key) : RRD_ERROR . ' Request too large') . $end_of_sequence);
__logging(LOGGING_LOCATION_BUFFERED, 'Client request exceeded the maximum frame size', 'IPC', SEVERITY_LEVEL_WARNING);
$rrdp_status['status'] = 'CLOSEDOWN_BY_VIOLATION';
break 3;
}
}
}
}
+575 -19
View File
@@ -22,17 +22,23 @@
+-------------------------------------------------------------------------+
*/
use phpseclib\Crypt\Random;
use phpseclib\Crypt\Rijndael;
use phpseclib\Crypt\RSA;
use phpseclib4\Crypt\Rijndael;
use phpseclib4\Crypt\RSA;
/**
* Starts an RRDtool pipe-mode process and returns its process handle and stdio pipes.
*
* @param array<string, mixed> $rrdp_config
*
* @return array{0: resource, 1: array<int, resource>}|false
*/
function rrdtool_pipe_init($rrdp_config) {
$fds = [
0 => ['pipe', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['file', '/dev/null', 'a'] // stderr
];
$process = @proc_open($rrdp_config['path_rrdtool'] . ' - ' . $rrdp_config['path_rra'], $fds, $pipes);
$process = @proc_open([$rrdp_config['path_rrdtool'], '-', $rrdp_config['path_rra']], $fds, $pipes);
if ($process === false) {
return false;
@@ -45,10 +51,31 @@ function rrdtool_pipe_init($rrdp_config) {
}
}
/**
* Terminates a previously opened RRDtool pipe-mode process.
*
* @param resource $process
*
* @return void
*/
function rrdtool_pipe_close($process) {
proc_close($process);
}
/**
* Writes a command to an open RRDtool pipe and streams the (optionally compressed,
* encrypted) response back over the client socket in chunks as it arrives.
*
* @param string $command
* @param array<int, resource> $pipes
* @param resource|\Socket|false $socket
* @param string $client_public_key
* @param bool $compression
* @param bool $silent_mode
* @param string $terminator
*
* @return bool|string|null
*/
function rrdtool_pipe_execute($command, $pipes, $socket, $client_public_key, $compression, $silent_mode = false, $terminator = "_EOT_\r\n") {
$return_code = fwrite($pipes[0], $command);
@@ -160,47 +187,533 @@ function rrdtool_pipe_execute($command, $pipes, $socket, $client_public_key, $co
return null;
}
/**
* Encrypts $output for the holder of $rsa_key: a random AES-256-CBC session key
* encrypts the payload, and that session key is itself RSA-encrypted and prepended.
*
* @param string $output
* @param string $rsa_key
*
* @return string|false
*/
function encrypt($output, $rsa_key) {
global $encryption;
if ($encryption) {
$rsa = new RSA();
$aes = new Rijndael();
$aes_key = Random::string(192);
if (!$encryption) {
return $output;
}
try {
$public = RSA::loadPublicKey($rsa_key);
if (!$public instanceof phpseclib4\Crypt\RSA\PublicKey) {
return false;
}
$aes = new Rijndael('cbc');
$aes_key = random_bytes(32);
$aes->setKey($aes_key);
$aes->setIV(str_repeat("\0", 16));
$ciphertext = base64_encode($aes->encrypt($output));
$rsa->loadKey($rsa_key);
$aes_key = base64_encode($rsa->encrypt($aes_key));
$aes_key = base64_encode($public->encrypt($aes_key));
$aes_key_length = str_pad(dechex(strlen($aes_key)), 3, '0', STR_PAD_LEFT);
return $aes_key_length . $aes_key . $ciphertext;
} else {
return $output;
} catch (Throwable $e) {
return false;
}
}
/**
* Reverses encrypt(): RSA-decrypts the embedded AES session key with this proxy's
* private key, then AES-256-CBC decrypts the remaining ciphertext.
*
* @param string $input
*
* @return string|false
*/
function decrypt($input) {
global $rrdp_config, $encryption;
if ($encryption) {
$rsa = new RSA();
$aes = new Rijndael();
if (!$encryption) {
return $input;
}
if (strlen($input) < 4 || !ctype_xdigit(substr($input, 0, 3))) {
return false;
}
$aes_key_length = hexdec(substr($input, 0, 3));
if ($aes_key_length < 1 || strlen($input) <= 3 + $aes_key_length) {
return false;
}
$aes_key = base64_decode(substr($input, 3, $aes_key_length), true);
$ciphertext = base64_decode(substr($input, 3 + $aes_key_length), true);
$rsa->loadKey($rrdp_config['encryption']['private_key']);
$aes_key = $rsa->decrypt($aes_key);
if ($aes_key === false || $aes_key === '' || $ciphertext === false) {
return false;
}
try {
$private = RSA::loadPrivateKey($rrdp_config['encryption']['private_key']);
if (!$private instanceof phpseclib4\Crypt\RSA\PrivateKey) {
return false;
}
$aes = new Rijndael('cbc');
$aes_key = $private->decrypt($aes_key);
if (!is_string($aes_key) || $aes_key === '') {
return false;
}
// phpseclib 2 truncated oversized Rijndael keys to 256 bits.
if (strlen($aes_key) > 32) {
$aes_key = substr($aes_key, 0, 32);
}
$aes->setKey($aes_key);
$aes->setIV(str_repeat("\0", 16));
return $aes->decrypt($ciphertext);
} else {
return $input;
} catch (Throwable $e) {
return false;
}
}
/**
* Determines whether a path is absolute (POSIX-rooted, UNC/backslash-rooted, or
* Windows drive-letter-rooted).
*
* @param string $path
*
* @return bool
*/
function rrdp_path_is_absolute($path) {
return str_starts_with($path, '/') || str_starts_with($path, '\\') || preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1;
}
/**
* Resolves a client-supplied path against the configured RRA root, rejecting it
* if it would escape that root.
*
* @param string $path
* @param bool $must_exist
*
* @return string|false
*/
function rrdp_resolve_rra_path($path, $must_exist = true) {
global $rrdp_config;
return rrdp_resolve_path_within($rrdp_config['path_rra'], $path, $must_exist);
}
/**
* Resolves $path relative to $base_path and confirms the result (or, for paths that
* don't need to exist yet, its nearest existing ancestor) stays within $base_path,
* rejecting traversal, absolute paths, null bytes, and symlink escapes.
*
* @param string $base_path
* @param mixed $path
* @param bool $must_exist
*
* @return string|false
*/
function rrdp_resolve_path_within($base_path, $path, $must_exist = true) {
if (!is_string($path) || $path === '' || str_contains($path, "\0") || rrdp_path_is_absolute($path)) {
return false;
}
$segments = preg_split('~[\\\\/]+~', $path, -1, PREG_SPLIT_NO_EMPTY);
if ($segments === false || in_array('..', $segments, true)) {
return false;
}
$base = realpath($base_path);
if ($base === false) {
return false;
}
if ($must_exist) {
$resolved = realpath($base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $segments));
return $resolved !== false && rrdp_path_is_within($resolved, $base) ? $resolved : false;
}
// Walk the path one segment at a time (instead of only checking the nearest
// existing ancestor) so that a symlink anywhere along a not-yet-fully-existing
// path is resolved and confined, even if the symlink itself is dangling.
$walked = $base;
for ($i = 0; $i < count($segments); $i++) {
$next = $walked . DIRECTORY_SEPARATOR . $segments[$i];
if (is_link($next)) {
$resolved_link = realpath($next);
if ($resolved_link === false || !rrdp_path_is_within($resolved_link, $base)) {
return false;
}
$walked = $resolved_link;
continue;
}
if (!file_exists($next)) {
// the remaining segments are the not-yet-created portion of the path
return $walked . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, array_slice($segments, $i));
}
$walked = $next;
}
// the full path already exists; confirm its canonical form is still confined
$resolved = realpath($walked);
return $resolved !== false && rrdp_path_is_within($resolved, $base) ? $resolved : false;
}
/**
* Checks whether a canonical path is equal to, or nested under, a canonical base path.
*
* @param string $path
* @param string $base
*
* @return bool
*/
function rrdp_path_is_within($path, $base) {
return $path === $base || str_starts_with($path, $base . DIRECTORY_SEPARATOR);
}
/**
* Detects RRDtool command arguments that attempt path traversal, absolute paths, or
* embedded command framing (via null bytes/newlines) so such commands can be rejected.
* This is a lexical, framing-level check only: it does not resolve operands against
* the filesystem, so it cannot by itself catch a symlink placed inside the RRA root
* that points outside it. Callers must also pass the command through
* rrdp_resolve_command_paths() before dispatching it to RRDtool.
*
* @param string $command
*
* @return bool
*/
function rrdp_command_has_unsafe_path($command) {
return str_contains($command, "\0")
|| str_contains($command, "\r")
|| str_contains($command, "\n")
|| preg_match('~(^|[[:space:]=:,\\\\/])\.\.([\\\\/]|$)~', $command) === 1
|| preg_match('~(^|[[:space:]=:,])(?:/|\\\\|[A-Za-z]:[\\\\/])~', $command) === 1;
}
/**
* Resolves every RRD file path operand referenced by an RRDtool command (the
* leading bare file argument(s), e.g. for update/fetch/dump/restore, and any
* DEF:/SDEF: clauses used by graph/graphv/xport) against the RRA root, and
* rewrites the command to use the canonical resolved paths. This closes the gap
* left by rrdp_command_has_unsafe_path(): resolving through realpath() follows
* (and thereby confines) symlinks instead of only rejecting lexical traversal.
*
* @param string $cmd
* @param string $cmd_options
*
* @return string|false
*/
function rrdp_resolve_command_paths($cmd, $cmd_options) {
static $leading_file_operands = [
'create' => [false],
'update' => [true],
'updatev' => [true],
'dump' => [true],
'restore' => [true, false],
'last' => [true],
'lastupdate' => [true],
'first' => [true],
'info' => [true],
'fetch' => [true],
'tune' => [true],
'resize' => [true],
'graph' => [false],
'graphv' => [false],
'flushcached' => [true],
];
$tokens = preg_split('/\s+/', trim((string) $cmd_options), -1, PREG_SPLIT_NO_EMPTY);
if ($tokens === false) {
return false;
}
$must_exist_list = $leading_file_operands[$cmd] ?? [];
$operand_index = 0;
foreach ($tokens as $index => $token) {
if (preg_match('/^((?:DEF|SDEF):[^=]+=)([^:]+)(:.*)$/i', $token, $matches) === 1) {
$resolved = rrdp_resolve_rra_path($matches[2]);
if ($resolved === false) {
return false;
}
$tokens[$index] = $matches[1] . $resolved . $matches[3];
continue;
}
if ($token[0] === '-' || preg_match('/^(?:CDEF|VDEF):/i', $token) === 1) {
continue;
}
if ($operand_index < count($must_exist_list)) {
$resolved = rrdp_resolve_rra_path($token, $must_exist_list[$operand_index]);
if ($resolved === false) {
return false;
}
$tokens[$index] = $resolved;
$operand_index++;
}
}
return implode(' ', $tokens);
}
/**
* Parses and whitelists removespikes CLI options from a client request, resolving
* the RRD file argument against the RRA root and rejecting anything unrecognized,
* malformed, or containing more than one RRD file argument.
*
* @param string $input
*
* @return array<int, string>|false
*/
function rrdp_parse_removespikes_options($input) {
$options = str_getcsv($input, ' ', '"', '\\');
$result = [];
$has_rrdfile = false;
foreach ($options as $option) {
if ($option === '') {
continue;
}
if (in_array($option, ['--backup', '--html', '--debug', '-d', '--dryrun', '-D'], true)) {
$result[] = $option;
continue;
}
if (preg_match('/^(?:-M|--method)=(?:stddev|variance)$/', $option)
|| preg_match('/^(?:-A|--avgnan)=(?:avg|nan)$/', $option)
|| preg_match('/^(?:-S|--stddev|-P|--percent|-N|--number|-n|-O|--outliers)=[0-9]+(?:\.[0-9]+)?$/', $option)) {
$result[] = $option;
continue;
}
if (preg_match('/^(?:-R|--rrdfile)=(.+)$/', $option, $matches)) {
if ($has_rrdfile) {
return false;
}
$path = rrdp_resolve_rra_path($matches[1]);
if ($path === false || !str_ends_with(strtolower($path), '.rrd')) {
return false;
}
$result[] = $option[1] === 'R' ? '-R=' . $path : '--rrdfile=' . $path;
$has_rrdfile = true;
continue;
}
return false;
}
return $has_rrdfile ? $result : false;
}
/**
* Runs an argv-style child process (no shell interpolation) and captures its
* combined stdout/stderr and exit status.
*
* @param array<int, string> $command
* @param array<string, string>|null $environment
*
* @return array{status: int, stdout: string, stderr: string}|false
*/
function rrdp_run_process($command, $environment = null) {
$descriptors = [
1 => ['pipe', 'w'],
2 => ['redirect', 1],
];
$process = proc_open($command, $descriptors, $pipes, null, $environment);
if (!is_resource($process)) {
return false;
}
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$status = proc_close($process);
return ['status' => $status, 'stdout' => $stdout, 'stderr' => ''];
}
/**
* Atomically writes $contents to $path (via a temp file in the same directory,
* chmod'd before the rename) so the file never appears with the wrong permissions.
*
* @param string $path
* @param string $contents
* @param int $mode
*
* @return int|false
*/
function rrdp_write_secure_file($path, $contents, $mode = 0600) {
$directory = dirname($path);
$temporary = tempnam($directory, '.rrdp-');
if ($temporary === false) {
return false;
}
@chmod($temporary, $mode);
$written = file_put_contents($temporary, $contents, LOCK_EX);
if ($written === false || !@rename($temporary, $path)) {
@unlink($temporary);
return false;
}
@chmod($path, $mode);
return $written;
}
/**
* Validates that a public/private key pair actually match (by comparing
* fingerprints) and then atomically writes both files, restoring the previous
* private key if the public key write fails partway through.
*
* @param string $public_path
* @param string $public_key
* @param string $private_path
* @param string $private_key
*
* @return bool
*/
function rrdp_write_key_pair($public_path, $public_key, $private_path, $private_key) {
try {
$public_fingerprint = RSA::loadPublicKey($public_key)->getFingerprint('sha256');
$private_fingerprint = RSA::loadPrivateKey($private_key)->getPublicKey()->getFingerprint('sha256');
} catch (Throwable $e) {
return false;
}
if (!hash_equals($public_fingerprint, $private_fingerprint)) {
return false;
}
$public_temporary = tempnam(dirname($public_path), '.rrdp-public-');
$private_temporary = tempnam(dirname($private_path), '.rrdp-private-');
if ($public_temporary === false || $private_temporary === false) {
if ($public_temporary !== false) {
@unlink($public_temporary);
}
if ($private_temporary !== false) {
@unlink($private_temporary);
}
return false;
}
@chmod($public_temporary, 0644);
@chmod($private_temporary, 0600);
if (file_put_contents($public_temporary, $public_key, LOCK_EX) === false
|| file_put_contents($private_temporary, $private_key, LOCK_EX) === false) {
@unlink($public_temporary);
@unlink($private_temporary);
return false;
}
$previous_private = file_exists($private_path) ? file_get_contents($private_path) : false;
if (!@rename($private_temporary, $private_path)) {
@unlink($public_temporary);
@unlink($private_temporary);
return false;
}
if (!@rename($public_temporary, $public_path)) {
@unlink($public_temporary);
if ($previous_private === false) {
@unlink($private_path);
} else {
rrdp_write_secure_file($private_path, $previous_private, 0600);
}
return false;
}
@chmod($public_path, 0644);
@chmod($private_path, 0600);
return true;
}
/**
* Writes $output to a socket in a loop, retrying until every byte has been sent
* (socket_write() may perform a short write).
*
* @param resource|\Socket $socket
* @param string $output
*
* @return int|false
*/
function rrdp_socket_write_all($socket, $output) {
$length = strlen($output);
$written = 0;
while ($written < $length) {
$result = @socket_write($socket, substr($output, $written), $length - $written);
if ($result === false || $result === 0) {
return false;
}
$written += $result;
}
return $written;
}
/**
* Forwards a log message to the parent process over the IPC socket if it meets
* the configured severity/category thresholds for its logging location.
*
* @param int $location
* @param string $msg
* @param string $category
* @param int $severity
*
* @return void
*/
function __logging($location, $msg, $category, $severity) {
global $rrdp_config, $ipc_socket_parent, $ipc_global_resource_id, $c_pid;
@@ -212,14 +725,39 @@ function __logging($location, $msg, $category, $severity) {
}
}
/**
* Null/false-safe wrapper around sizeof().
*
* @param mixed $array
*
* @return int
*/
function __sizeof($array) {
return ($array === false || !is_array($array)) ? 0 : sizeof($array);
}
/**
* Null/false-safe wrapper around count().
*
* @param mixed $array
*
* @return int
*/
function __count($array) {
return ($array === false || !is_array($array)) ? 0 : count($array);
}
/**
* Custom error handler: logs PHP user-level errors/warnings/notices via __logging()
* and terminates the process on E_USER_ERROR.
*
* @param int $code
* @param string $text
* @param string $file
* @param int $line
*
* @return bool|null
*/
function __errorHandler($code, $text, $file, $line) {
if (!($code & error_reporting())) {
return null;
@@ -249,6 +787,13 @@ function __errorHandler($code, $text, $file, $line) {
}
// signal handler for master, slave and client processes
/**
* Exits cleanly on SIGTERM; ignores SIGUSR1/SIGHUP and any other signal.
*
* @param int $signo
*
* @return void
*/
function __sig_handler($signo) {
switch ($signo) {
case SIGTERM:
@@ -262,12 +807,23 @@ function __sig_handler($signo) {
}
}
/**
* Checks (via `ps`) whether another rrdtool-proxy.php process is already running.
*
* @return bool
*/
function is_rrdtool_proxy_running() {
exec('ps -ef | grep -v grep | grep -E "php .*rrdtool-proxy.php"', $output);
return (__sizeof($output) >= 2) ? false : true;
}
/**
* Checks (via `ps`) whether an rrdcached process is already running, unless
* $force is set.
*
* @return bool
*/
function is_rrdcached_running() {
global $force;
+83 -29
View File
@@ -22,6 +22,17 @@
+-------------------------------------------------------------------------+
*/
use phpseclib4\Crypt\RSA;
/**
* Main loop of the replicator engine: connects out to registered cluster peers
* and listens for inbound peer connections, authenticating each by RSA public-key
* fingerprint, then drives full and incremental (DIFF-based) synchronization with
* the peer that reports the highest uptime, servicing IPC requests from the
* parent process along the way.
*
* @return void
*/
function interact() {
global $rrdp_remoteproxies, $ipc_socket_parent, $debug_mode, $rrdp_config, $rrdp_remote_proxies;
global $rrdp_encryption, $rrdtool_cmds, $rrdtool_custom_cmds, $rrdp_replicator_cmds;
@@ -41,9 +52,6 @@ function interact() {
$rrdp_remoteproxies = [];
$rrdp_replicator_state = 'running';
// enable message encryption
$rsa = new \phpseclib\Crypt\RSA();
// start listening for cluster peers
$rrdp_server = @socket_create((($rrdp_config['ipv6']) ? AF_INET : AF_INET), SOCK_STREAM, SOL_TCP);
@@ -87,6 +95,13 @@ function interact() {
// setup a dedicated persistent RRD_PIPE
if ($rrdtool_process === false) {
$rrdtool_process_pipes = rrdtool_pipe_init($rrdp_config);
if ($rrdtool_process_pipes === false) {
rrdp_system__count('rrd_pipe_broken');
continue;
}
$rrdtool_process = $rrdtool_process_pipes[0];
$rrdtool_pipes = $rrdtool_process_pipes[1];
}
@@ -117,12 +132,14 @@ function interact() {
// verify authorization
if (array_key_exists($ip, $rrdp_remote_proxies) === true) {
$key = intval($socket_descriptor);
$rrdp_remoteproxies[$key] = [ 'socket' => $socket_descriptor, 'ip' => $ip, 'public_key' => false, 'authenticated' => false, 'last_seen' => time()];
__logging(LOGGING_LOCATION_BUFFERED, 'Remote Proxy connection request #' . $key . '[IP: ' . $ip . '] granted', 'ACL', SEVERITY_LEVEL_DEBUG);
} else {
@socket_write($socket_descriptor, "ERROR: Access denied.\r\n");
@socket_close($socket_descriptor);
rrdp_system__count('connections_refused');
__logging(LOGGING_LOCATION_BUFFERED, 'Remote Proxy connection request [IP: ' . $ip . '] rejected.', 'ACL', SEVERITY_LEVEL_WARNING);
}
@@ -237,9 +254,13 @@ function interact() {
$ip = $rrdp_remoteproxies[$index]['ip'];
$rsa_finger_print = isset($rrdp_remote_proxies[$ip]) ? $rrdp_remote_proxies[$ip]['fingerprint'] : 'unknown';
$rsa->loadKey($client_public_key);
try {
$remote_fingerprint = RSA::loadPublicKey($client_public_key)->getFingerprint('md5');
} catch (Throwable $e) {
$remote_fingerprint = false;
}
if ($rsa_finger_print == $rsa->getPublicKeyFingerprint()) {
if ($remote_fingerprint !== false && hash_equals(strtolower($rsa_finger_print), strtolower($remote_fingerprint))) {
// registered public key has been received
$rrdp_remoteproxies[$index]['authenticated'] = true;
$rrdp_remoteproxies[$index]['public_key'] = $client_public_key;
@@ -324,9 +345,11 @@ function interact() {
if ($remote_index) {
$rrdp_replicator_state = 'synchronizing';
$read_socket = $rrdp_remoteproxies[$remote_index]['socket'];
$public_key = $rrdp_remoteproxies[$remote_index]['public_key'];
$ip = $rrdp_remoteproxies[$remote_index]['ip'];
__logging(LOGGING_LOCATION_BUFFERED, 'Start full synchronisation process with #' . intval($read_socket) . ' [IP: ' . $ip . ']', 'MSR', SEVERITY_LEVEL_NOTIFICATION);
rrdp_system__socket_write($read_socket, encrypt('FULLSCAN END_OF_MSG', $public_key) . "\r\n", 'msr_bytes_sent');
} else {
@@ -353,12 +376,19 @@ function interact() {
}
// internal function to establish an encrypted remote connection to registered peers
/**
* Opens an outbound connection to a registered cluster peer, exchanges RSA public
* keys, and verifies the peer's key against its configured fingerprint.
*
* @param string $remote_ip
* @param int $remote_port
* @param string $remote_fingerprint
*
* @return array{socket: resource|\Socket, ip: string, public_key: string, authenticated: bool, last_seen: int}|false
*/
function __remote_connect($remote_ip, $remote_port, $remote_fingerprint) {
global $rrdp_config, $rrdp_encryption;
// enable message encryption
$rsa = $rsa = new \phpseclib\Crypt\RSA();
$rrdp_socket = @socket_create((($rrdp_config['ipv6']) ? AF_INET6 : AF_INET), SOCK_STREAM, SOL_TCP);
if (@socket_connect($rrdp_socket, $remote_ip, $remote_port) === true) {
@@ -392,8 +422,11 @@ function __remote_connect($remote_ip, $remote_port, $remote_fingerprint) {
}
}
$rsa->loadKey($rrdp_public_key);
$fingerprint = $rsa->getPublicKeyFingerprint();
try {
$fingerprint = RSA::loadPublicKey($rrdp_public_key)->getFingerprint('md5');
} catch (Throwable $e) {
return false;
}
if ($remote_fingerprint != $fingerprint) {
// fingerprint mismatch
@@ -407,6 +440,15 @@ function __remote_connect($remote_ip, $remote_port, $remote_fingerprint) {
}
// internal function to read the response of a registered peer to a previous request
/**
* Reads and decrypts a registered peer's newline-delimited response packets,
* reassembling them until an END_OF_MSG marker is seen, and returns the parsed
* [command, status, payload] triple.
*
* @param resource|\Socket $read_socket
*
* @return array{0: string, 1: string, 2: string}|false
*/
function __remote_read($read_socket) {
global $rrdp_remoteproxies, $rrdp_encryption;
@@ -474,6 +516,15 @@ function __remote_read($read_socket) {
return false;
}
/**
* Dispatches a decoded replication request (STATUS, DIFF, etc.) received from a
* registered peer and writes the encrypted response back to that peer's socket.
*
* @param string $input
* @param resource|\Socket $read_socket
*
* @return void
*/
function handle__request($input, $read_socket) {
global $rrdp_config, $rrdp_remoteproxies, $rrdp_encryption, $rrdcached_pid;
global $replicator_system_start, $rrdtool_process, $rrdtool_pipes;
@@ -656,14 +707,20 @@ function handle__request($input, $read_socket) {
$file_size = $file_settings[2];
$mtime = $file_settings[3];
if ($folder != './') {
$rra_subfolder = rtrim($rrdp_config['path_rra'], '/') . '/' . ltrim($folder, './');
$rra_relative_path = ($folder !== './' ? ltrim($folder, './') . '/' : '') . $file;
$rra_file_path_absolute = rrdp_resolve_rra_path($rra_relative_path, false);
$rra_file_path_absolute = $rra_subfolder . '/' . $file;
if ($rra_file_path_absolute === false) {
__logging(LOGGING_LOCATION_BUFFERED, 'SYNC: rejected unsafe peer-supplied path: ' . $rra_relative_path, 'MSR', SEVERITY_LEVEL_CRITICAL);
continue;
}
$rra_subfolder = dirname($rra_file_path_absolute);
// create subfolder if not already existing
if (!is_dir($rra_subfolder)) {
if (mkdir($rra_subfolder) === false) {
if ($folder !== './' && !is_dir($rra_subfolder)) {
if (mkdir($rra_subfolder, 0755, true) === false) {
__logging(LOGGING_LOCATION_BUFFERED, 'Cannot create RRA subfolder: ' . $rra_subfolder , 'MSR', SEVERITY_LEVEL_CRITICAL);
} else {
__logging(LOGGING_LOCATION_BUFFERED, 'SYNC: created: ' . $rra_subfolder , 'MSR', SEVERITY_LEVEL_DEBUG);
@@ -677,9 +734,6 @@ function handle__request($input, $read_socket) {
__logging(LOGGING_LOCATION_BUFFERED, 'SYNC: restored: ' . $rra_subfolder , 'MSR', SEVERITY_LEVEL_INFORMATION);
}
} else {
$rra_file_path_absolute = rtrim($rrdp_config['path_rra'], '/') . '/' . $file;
}
if (!file_exists($rra_file_path_absolute)) {
__logging(LOGGING_LOCATION_BUFFERED, 'SYNC: file not found: ' . $rra_file_path_absolute , 'MSR', SEVERITY_LEVEL_DEBUG);
@@ -698,7 +752,7 @@ function handle__request($input, $read_socket) {
file_put_contents($rra_file_path_absolute . '.xml', $rrd_data);
$rrd_exec_status = rrdtool_pipe_execute('restore ' . $rra_file_path_absolute . '.xml ' . $folder . '/' . $file . "\r\n", $rrdtool_pipes, false, false, false, true);
$rrd_exec_status = rrdtool_pipe_execute('restore ' . $rra_file_path_absolute . '.xml ' . $rra_file_path_absolute . "\r\n", $rrdtool_pipes, false, false, false, true);
if ($rrd_exec_status) {
touch($rra_file_path_absolute, $mtime);
@@ -714,7 +768,7 @@ function handle__request($input, $read_socket) {
/* file is already existing - in this case we have to compare
* local and remote last update time of this file */
$rrd_return = rrdtool_pipe_execute('last ' . rtrim($folder, '/') . '/' . $file . "\r\n", $rrdtool_pipes, false, false, false);
$rrd_return = rrdtool_pipe_execute('last ' . $rra_file_path_absolute . "\r\n", $rrdtool_pipes, false, false, false);
if (substr_count($rrd_return, 'OK u')) {
$local_last = rtrim(substr($rrd_return, 0, strpos($rrd_return, 'OK u')));
@@ -750,7 +804,7 @@ function handle__request($input, $read_socket) {
[$payload,$mtime] = explode(':__filemtime__:', $response[2]);
$rrd_data = substr($payload, 0, strpos($payload, 'OK u'));
file_put_contents($rra_file_path_absolute . '.xml', $rrd_data);
$rrd_exec_status = rrdtool_pipe_execute('restore ' . $rra_file_path_absolute . '.xml ' . $folder . '/' . $file . ".tmp\r\n", $rrdtool_pipes, false, false, false, true);
$rrd_exec_status = rrdtool_pipe_execute('restore ' . $rra_file_path_absolute . '.xml ' . $rra_file_path_absolute . ".tmp\r\n", $rrdtool_pipes, false, false, false, true);
if ($rrd_exec_status) {
unlink($rra_file_path_absolute);
@@ -780,11 +834,11 @@ function handle__request($input, $read_socket) {
break;
case 'RRDLAST':
if ($cmd_options && is_array($cmd_options) && substr($cmd_options[0],-3) == 'rrd') {
$rra_path_absolute = rtrim($rrdp_config['path_rra'], '/') . '/' . ltrim($cmd_options[0], './');
$rra_path_absolute = rrdp_resolve_rra_path($cmd_options[0]);
if (file_exists($rra_path_absolute)) {
if ($rra_path_absolute !== false) {
if ($rrdcached_pid) {
$rrd_cmd = 'flushcached ' . $cmd_options[0];
$rrd_cmd = 'flushcached ' . $rra_path_absolute;
$rrd_exec_status = rrdtool_pipe_execute($rrd_cmd . "\r\n", $rrdtool_pipes, false, false, false, true);
@@ -796,7 +850,7 @@ function handle__request($input, $read_socket) {
}
}
$rrd_cmd = 'last ' . $cmd_options[0];
$rrd_cmd = 'last ' . $rra_path_absolute;
rrdp_system__socket_write($read_socket, encrypt('__RRDLAST 201 ', $public_key) . "\r\n", 'msr_bytes_sent');
$rrd_exec_status = rrdtool_pipe_execute($rrd_cmd . "\r\n", $rrdtool_pipes, $read_socket, $public_key, 1);
rrdp_system__socket_write($read_socket, encrypt(' END_OF_MSG', $public_key) . "\r\n", 'msr_bytes_sent');
@@ -817,11 +871,11 @@ function handle__request($input, $read_socket) {
break;
case 'RRDDUMP':
if ($cmd_options && is_array($cmd_options) && substr($cmd_options[0],-3) == 'rrd') {
$rra_path_absolute = rtrim($rrdp_config['path_rra'], '/') . '/' . ltrim($cmd_options[0], './');
$rra_path_absolute = rrdp_resolve_rra_path($cmd_options[0]);
if (file_exists($rra_path_absolute)) {
if ($rra_path_absolute !== false) {
if ($rrdcached_pid) {
$rrd_cmd = 'flushcached ' . $cmd_options[0];
$rrd_cmd = 'flushcached ' . $rra_path_absolute;
$rrd_exec_status = rrdtool_pipe_execute($rrd_cmd . "\r\n", $rrdtool_pipes, false, false, false, true);
if (!$rrd_exec_status) {
@@ -832,7 +886,7 @@ function handle__request($input, $read_socket) {
}
}
$rrd_cmd = 'dump ' . $cmd_options[0];
$rrd_cmd = 'dump ' . $rra_path_absolute;
rrdp_system__socket_write($read_socket, encrypt('__RRDDUMP 201 ', $public_key) . "\r\n", 'msr_bytes_sent');
$rrd_exec_status = rrdtool_pipe_execute($rrd_cmd . "\r\n", $rrdtool_pipes, $read_socket, $public_key, 1);
rrdp_system__socket_write($read_socket, encrypt(':__filemtime__:' . filemtime($rra_path_absolute) . ' END_OF_MSG', $public_key) . "\r\n", 'msr_bytes_sent');
+108 -5
View File
@@ -26,7 +26,7 @@ global $active_config;
include('../vendor/autoload.php');
use phpseclib3\Crypt\RSA;
use phpseclib4\Crypt\RSA;
$active_config = [
'version' => RRDP_VERSION,
@@ -57,11 +57,23 @@ $active_config = [
'rrdcache_write_threads' => 4,
'enable_password' => '',
];
$rrdp_config = [];
$rrdp_config_tmp = [];
$rrdp_remote_clients = [];
$rrdp_remote_proxies = [];
wizard();
/**
* Runs the interactive command-line configuration wizard: checks system
* requirements, prompts for and validates each configuration setting, generates
* or reuses the RSA key pair, and writes out the resulting configuration file.
*
* @return void
*/
function wizard() {
global $microtime_start, $active_config;
global $microtime_start, $active_config, $rrdp_config, $rrdp_config_tmp;
global $rrdp_remote_clients, $rrdp_remote_proxies;
define('WIZARD_RUNNING', 1);
@@ -218,8 +230,7 @@ function wizard() {
$public = $private->getPublicKey();
rrd_system__system_boolean_message('create: Generate RSA key-pair (2048Bit)', [$private, $public], true);
rrd_system__system_boolean_message(' save: New RSA public key', file_put_contents('./include/public.key', $public), true);
rrd_system__system_boolean_message(' save: New RSA private key', file_put_contents('./include/private.key', $private), true);
rrd_system__system_boolean_message(' save: New RSA key pair', rrdp_write_key_pair('./include/public.key', (string) $public, './include/private.key', (string) $private), true);
}
$filter_options = ['options' => ['regexp' => '/[\s]*/']];
@@ -707,10 +718,20 @@ function wizard() {
wizard_handle_output(wizard_prompt_wordwrap('Example', $msg, 75), true, true);
}
/**
* Validates that $path (or the configured RRDtool path) is an absolute,
* executable RRDtool binary of at least version 1.5, printing an error and
* returning false otherwise.
*
* @param string $path
*
* @return string|false
*/
function wizard_verify_rrdtool($path) {
global $active_config;
$valid_path = false;
$msg = 'Invalid RRDtool path.';
if (!$path) {
$path = $active_config['path_rrdtool'];
@@ -753,10 +774,19 @@ function wizard_verify_rrdtool($path) {
}
}
/**
* Validates that $path (or the configured rrdcached path) is an absolute,
* executable file, printing an error and returning false otherwise.
*
* @param string $path
*
* @return string|false
*/
function wizard_verify_rrdcached($path) {
global $active_config;
$valid_path = false;
$msg = 'Invalid RRDCached path.';
if (!$path) {
$path = $active_config['path_rrdcached'];
@@ -782,6 +812,14 @@ function wizard_verify_rrdcached($path) {
}
}
/**
* Validates that $path (or the configured RRA path) is an absolute, readable,
* writable directory, printing an error and returning false otherwise.
*
* @param string $path
*
* @return string|false
*/
function wizard_verify_path($path) {
global $active_config;
@@ -812,10 +850,37 @@ function wizard_verify_path($path) {
}
}
/**
* Writes a wizard prompt/message to STDOUT, optionally clearing the screen first
* and/or surrounding the message with newlines.
*
* @param string $msg
* @param bool $new_line
* @param bool $new_line_after
* @param bool $clear_commandline_screen
*
* @return void
*/
function wizard_handle_output($msg, $new_line = false, $new_line_after = false, $clear_commandline_screen = false) {
fwrite(STDOUT, ($clear_commandline_screen ? ANSI_ERASE_SCREEN . ANSI_ERASE_BUFFER . ANSI_POS_TOP_LEFT : '') . ($new_line ? PHP_EOL : '') . $msg . ($new_line_after ? PHP_EOL : ''));
}
/**
* Prompts for and reads a line from STDIN, re-prompting (with an optional help
* message) until the input passes the given filter_var() filter or, if
* $require_value is set, is non-empty.
*
* @param string $msg
* @param int $filter
* @param array<mixed>|false $filter_options
* @param string|false $filter_help_msg
* @param bool $new_line
* @param bool $new_line_after
* @param bool $clear_commandline_screen
* @param bool $require_value
*
* @return mixed
*/
function wizard_handle_input($msg, $filter = FILTER_DEFAULT, $filter_options = false, $filter_help_msg = false, $new_line = false, $new_line_after = false, $clear_commandline_screen = false, $require_value = false) {
$filtered_value = false;
@@ -843,6 +908,14 @@ function wizard_handle_input($msg, $filter = FILTER_DEFAULT, $filter_options = f
return false;
}
/**
* Builds the next lettered attribute prompt (a, b, ... z, aa, ab, ...), advancing
* $attribute_count for the following call.
*
* @param int $attribute_count
*
* @return array{text: string, ansi: string, padding: string}
*/
function wizard_get_prompt(&$attribute_count) {
$attr_part1 = $attribute_count % 26;
$attr_part2 = ($attribute_count - $attr_part1) / 26;
@@ -856,6 +929,14 @@ function wizard_get_prompt(&$attribute_count) {
return $attr_prompt;
}
/**
* Prints the wizard's page banner/title bar (e.g. "Page 2/9") for the given step.
*
* @param int $page
* @param string $title
*
* @return void
*/
function wizard_handle_title($page, $title = '') {
$title_prefix = ' RRDtool Proxy Server Wizard';
@@ -867,13 +948,25 @@ function wizard_handle_title($page, $title = '') {
$title_spaces = strlen($title_prefix) + strlen($title_suffix);
if ($title_spaces > 75) {
$title_prefix = substr($title_prefix, 1, 75 - $title_suffix);
$title_prefix = substr($title_prefix, 1, 75 - strlen($title_suffix));
$title_spaces = 75;
}
wizard_handle_output(ANSI_BOLD . ANSI_YELLOW_FG . ANSI_BLUE_BG . $title_prefix .
str_repeat(' ', 80 - $title_spaces) . $title_suffix . ANSI_RESET, false, true, true);
}
/**
* Prompts for and applies a group of configuration settings, updating
* $active_config in place, persisting a temporary config file after each
* accepted value, and advancing the shared $attribute_count prompt counter.
*
* @param array<string, array<string, mixed>> $inputs
* @param array<string, mixed> $active_config
* @param int $attribute_count
* @param string $title
*
* @return void
*/
function wizard_handle_settings($inputs, &$active_config, &$attribute_count, $title = '') {
if (empty($title)) {
$title = '';
@@ -918,6 +1011,16 @@ function wizard_handle_settings($inputs, &$active_config, &$attribute_count, $ti
}
}
/**
* Word-wraps $msg to $length columns, indenting wrapped lines to align under an
* optional bold/colored "$prompt: " prefix.
*
* @param string $prompt
* @param string $msg
* @param int $length
*
* @return string
*/
function wizard_prompt_wordwrap($prompt, $msg, $length) {
$prompt = (empty($prompt) ? '' : $prompt . ': ');
$prompt_len = strlen($prompt);
+419 -22
View File
@@ -25,7 +25,7 @@
// do NOT run this script through a web browser
use phpseclib3\Crypt\RSA;
use phpseclib4\Crypt\RSA;
if (!isset($_SERVER['argv'][0]) || isset($_SERVER['REQUEST_METHOD']) || isset($_SERVER['REMOTE_ADDR'])) {
die('<br><strong>This script is only meant to run at the command line.</strong>');
@@ -38,6 +38,7 @@ chdir(__DIR__);
require_once('./vendor/autoload.php');
require_once('./include/global.php');
require_once('./lib/functions.php');
$rrdp_help_messages = $rrdp_help_messages ?? [];
// process calling arguments
$parms = $_SERVER['argv'];
@@ -213,6 +214,7 @@ $rrdp_msr_buffer = [];
$rrdp_buffers = ['logging_buffered' => [], 'logging_snmp' => [] ];
// set up an admin socket for proxy administration
$rrdp_admin_resource_id = 0;
$rrdp_admin = @socket_create(($rrdp_config['ip_version'] == 4) ? AF_INET : AF_INET6 , SOCK_STREAM, SOL_TCP);
if ($rrdp_admin === false) {
@@ -222,7 +224,7 @@ if ($rrdp_admin === false) {
}
@socket_set_option($rrdp_admin, SOL_SOCKET, SO_REUSEADDR, 1);
if (!@socket_bind($rrdp_admin, (($rrdp_config['ipv6']) ? '::1' : '127.0.0.1'), $rrdp_config['port_admin'])) {
if (!@socket_bind($rrdp_admin, (($rrdp_config['ip_version'] == 6) ? '::1' : '127.0.0.1'), $rrdp_config['port_admin'])) {
rrd_system__system_die(NL . 'Unable to bind socket to \'' . $rrdp_config['address'] . ':' . $rrdp_config['port_admin'] . '\'' . NL . 'Error: ' . socket_strerror(socket_last_error()) . NL);
}
socket_set_nonblock($rrdp_admin);
@@ -231,6 +233,7 @@ rrd_system__system_boolean_message('init: tcp admin socket #' . $rrdp_admin_reso
rrdp_system__logging(LOGGING_LOCATION_BUFFERED, 'Start listening to port ' . $rrdp_config['address'] . ':' . $rrdp_config['port_admin'], 'SYS', SEVERITY_LEVEL_NOTIFICATION);
// set up a client socket handling requests against RRDtool
$rrdp_client_resource_id = 0;
$rrdp_client = @socket_create((($rrdp_config['ip_version'] == 4) ? AF_INET : AF_INET6), SOCK_STREAM, SOL_TCP);
if ($rrdp_client === false) {
@@ -318,7 +321,7 @@ exec("ls -l /proc/$pid_of_php/fd/ | wc -l", $open_files);
if ($max_open_files[0] == 'unlimited') {
$max_open_files[0] = 1048576;
}
$rrdp_config['max_cnn'] = intval(($max_open_files[0] - $open_files[0]) / 2 - $rrdp_config['max_admin_cnn'] * 2 - 100); // use a buffer of 100 open files
$rrdp_config['max_cnn'] = intval(((int) $max_open_files[0] - (int) $open_files[0]) / 2 - (int) $rrdp_config['max_admin_cnn'] * 2 - 100); // use a buffer of 100 open files
// return system info
fwrite(STDOUT, rrdp_cmd__show_version($systemd) . NL);
@@ -679,8 +682,9 @@ while ($__server_listening) {
// return prompt
rrdp_system__return_prompt($read_socket);
} else {
socket_close($rrdp_admin_client['socket']);
socket_close($read_socket);
unset($rrdp_admin_clients[$read_socket_resource_id]);
unset($rrdp_admin_sockets[$read_socket_resource_id]);
rrdp_system__count('aborted_clients');
continue;
@@ -695,6 +699,13 @@ while ($__server_listening) {
// #################################### MSR FUNCTIONS ####################################
/**
* Flushes any buffered MSR (multi-server replication) commands whose 10-second
* timeframe has elapsed to per-peer files under ./msr/, or clears the buffer
* entirely if there are no configured remote proxies.
*
* @return void
*/
function rrdp_msr__block_write() {
global $rrdp_config, $rrdp_msr_buffer;
@@ -735,6 +746,13 @@ function rrdp_msr__block_write() {
// #################################### INTERNAL FUNCTIONS ####################################
/**
* Runs periodic (every 30s) health checks: monitors RRA/MSR filesystem disk
* usage and logs warning/alert/critical messages as free space thresholds are
* crossed.
*
* @return void
*/
function rrdp_system__check() {
global $rrdp_config, $rrdp_status;
@@ -813,10 +831,16 @@ function rrdp_system__check() {
return;
}
/**
* Generates (if missing) and loads this proxy's RSA key pair, populating
* $rrdp_config['encryption'] with the public/private keys and the public key's
* md5 fingerprint.
*
* @return void
*/
function rrdp_system__encryption_init() {
global $rrdp_config;
if (!file_exists('./include/public.key') || !file_exists('./include/private.key')) {
$private = RSA::createKey(2048);
$public = $private->getPublicKey();
@@ -824,9 +848,10 @@ function rrdp_system__encryption_init() {
$rrdp_config['encryption']['public_key'] = $public;
$rrdp_config['encryption']['private_key'] = $private;
file_put_contents('./include/public.key', $rrdp_config['encryption']['public_key']);
file_put_contents('./include/private.key', $rrdp_config['encryption']['private_key']);
$keys_written = rrdp_write_key_pair('./include/public.key', (string) $public, './include/private.key', (string) $private);
rrd_system__system_boolean_message('init: write RSA key pair', $keys_written, true);
}
@chmod('./include/private.key', 0600);
$rrdp_config['encryption']['public_key'] = file_get_contents('./include/public.key');
rrd_system__system_boolean_message('init: RSA public key', $rrdp_config['encryption']['public_key'], true);
@@ -834,10 +859,29 @@ function rrdp_system__encryption_init() {
$rrdp_config['encryption']['private_key'] = file_get_contents('./include/private.key');
rrd_system__system_boolean_message('init: RSA private key', $rrdp_config['encryption']['private_key'], true);
$private = RSA::loadPublicKey($rrdp_config['encryption']['public_key']);
$rrdp_config['encryption']['public_key_fingerprint'] = $private->getFingerprint();
try {
$public_fingerprint = RSA::loadPublicKey($rrdp_config['encryption']['public_key'])->getFingerprint('sha256');
$private_fingerprint = RSA::loadPrivateKey($rrdp_config['encryption']['private_key'])->getPublicKey()->getFingerprint('sha256');
} catch (Throwable $e) {
$public_fingerprint = $private_fingerprint = false;
}
$keys_match = $public_fingerprint !== false && $private_fingerprint !== false && hash_equals($public_fingerprint, $private_fingerprint);
rrd_system__system_boolean_message('init: RSA public/private key pair matches', $keys_match, true);
$private = RSA::loadPublicKey($rrdp_config['encryption']['public_key']);
$rrdp_config['encryption']['public_key_fingerprint'] = $private->getFingerprint('md5');
}
/**
* Handles a serialized IPC status update received from the replicator child
* process: forwards debug log entries and starts/pauses the client listener
* socket depending on the replicator's reported synchronization state.
*
* @param string $input
*
* @return void
*/
function rrdp_system__replicator($input) {
global $rrdp_client, $rrd_config;
@@ -867,6 +911,15 @@ function rrdp_system__replicator($input) {
}
}
/**
* Handles a serialized IPC status update received from a client-handling child
* process: forwards debug log entries, merges any pending MSR commands into the
* shared buffer, and rolls the reported counters/status into the global stats.
*
* @param string $input
*
* @return void
*/
function rrdp_system__client(&$input) {
global $rrdp_msr_buffer;
@@ -909,6 +962,14 @@ function rrdp_system__client(&$input) {
}
}
/**
* Logs a fatal message, signals the parent process to terminate, and
* terminates this process.
*
* @param string $msg
*
* @return never
*/
function rrd_system__system_die($msg = '') {
global $ppid;
@@ -923,6 +984,18 @@ function rrd_system__system_die($msg = '') {
die($msg);
}
/**
* Prints a timestamped "[OK]"/"[FAILED]"/"[SKIPPED]" startup status line for
* $msg, and (when $boolean_state is falsy and $exit is true) logs a critical
* error and terminates the process via rrd_system__system_die().
*
* @param string $msg
* @param mixed $boolean_state
* @param bool $exit
* @param bool $skip
*
* @return void
*/
function rrd_system__system_boolean_message($msg, $boolean_state, $exit = false, $skip = false) {
global $colors, $microtime_start, $systemd;
@@ -954,10 +1027,27 @@ function rrd_system__system_boolean_message($msg, $boolean_state, $exit = false,
}
}
/**
* Returns the byte length of $str, using mbstring's 8-bit-safe strlen() when
* mbstring function overloading is active.
*
* @param string $str
*
* @return int
*/
function rrdp_system__calc_bytes($str) {
return (ini_get('mbstring.func_overload') ? mb_strlen($str, '8bit') : strlen($str));
}
/**
* Increments a named counter in $rrdp_status by $value, wrapping it back to 0
* before it would exceed a signed 32-bit integer.
*
* @param string $variable
* @param int $value
*
* @return bool
*/
function rrdp_system__count($variable, $value = 1) {
global $rrdp_status;
@@ -971,6 +1061,14 @@ function rrdp_system__count($variable, $value = 1) {
return false;
}
/**
* Recomputes a "max" style status counter (max_admin_connections or
* max_client_connections) from the current number of connected clients.
*
* @param string $variable
*
* @return void
*/
function rrdp_system__update($variable) {
global $rrdp_admin_clients, $rrdp_clients, $rrdp_status;
@@ -992,6 +1090,14 @@ function rrdp_system__update($variable) {
return;
}
/**
* Writes the admin CLI prompt (reflecting privileged/debug mode) back to a
* connected admin socket.
*
* @param resource|\Socket $socket
*
* @return void
*/
function rrdp_system__return_prompt($socket) {
global $rrdp_admin_clients, $rrdp_config, $colors;
@@ -1003,16 +1109,41 @@ function rrdp_system__return_prompt($socket) {
}
}
/**
* Writes $output to a socket (retrying on short writes) and, if $counter is
* given, tallies the bytes sent against that named counter.
*
* @param resource|\Socket $socket
* @param string $output
* @param string $counter
*
* @return int|false
*/
function rrdp_system__socket_write($socket, $output, $counter = '') {
if ($return = @socket_write($socket, $output, strlen($output))) {
$written = rrdp_socket_write_all($socket, $output);
if ($written === false) {
return false;
}
if ($counter) {
rrdp_system__count($counter, rrdp_system__calc_bytes($output));
}
return $written;
}
return $return;
}
/**
* Optionally writes a final message, then closes a socket, either gracefully
* (shutdown + close) or forcibly (SO_LINGER with a zero timeout, discarding
* unsent data).
*
* @param resource|\Socket $socket
* @param string|false $msg
* @param bool $force
*
* @return bool
*/
function rrdp_system__socket_close($socket, $msg = false, $force = false) {
if ($msg) {
rrdp_system__socket_write($socket, $msg);
@@ -1030,6 +1161,15 @@ function rrdp_system__socket_close($socket, $msg = false, $force = false) {
return true;
}
/**
* Computes a live (non-cached) value for a status variable such as
* threads_connected, uptime, memory_usage, memory_peak_usage, or
* connections_open.
*
* @param string $variable
*
* @return int|float|string
*/
function rrdp_system__status_live($variable) {
global $rrdp_admin_clients, $rrdp_ipc_sockets, $rrdp_config;
@@ -1061,6 +1201,14 @@ function rrdp_system__status_live($variable) {
return $status;
}
/**
* Parses a human-readable size string (e.g. "512M", "2G") such as php.ini's
* memory_limit into a byte count.
*
* @param string $val
*
* @return int
*/
function rrdp_system__convert2bytes($val) {
preg_match('/^\s*([0-9.]+)\s*([KMGTPE])B?\s*$/i', $val, $matches);
$num = (float)$matches[1];
@@ -1083,6 +1231,18 @@ function rrdp_system__convert2bytes($val) {
return intval($num);
}
/**
* Records a log entry into the in-memory buffered/SNMP logging ring buffers
* (subject to their configured severity thresholds) and streams it live to any
* connected admin console with console logging enabled.
*
* @param int $location
* @param string $msg
* @param string $category
* @param int $severity
*
* @return void
*/
function rrdp_system__logging($location, $msg, $category, $severity) {
global $rrdp_config, $rrdp_admin_clients, $rrdp_admin_sockets, $colors, $rrdp_buffers, $severity_levels;
@@ -1128,6 +1288,16 @@ function rrdp_system__logging($location, $msg, $category, $severity) {
return;
}
/**
* Applies an admin CLI output filter/pipe (`| i|e|b|I|E|B|exp <pattern>`) to
* $output, appending a match count, or returns an error message for an
* unrecognized filter.
*
* @param string $output
* @param array<int, string> $args
*
* @return string
*/
function rrdp_system__filter($output, $args) {
if (!$output || !$args) {
return $output;
@@ -1187,6 +1357,13 @@ function rrdp_system__filter($output, $args) {
return '% Unrecognized arguments: \'' . implode(' ', $args) . '\'' . NL;
}
/**
* Recomputes the effective global console logging severity/category (the
* highest/broadest setting across all privileged admin sessions) and notifies
* the replication master/slave child processes of the updated running config.
*
* @return void
*/
function rrdp_system__global_console_logging_update() {
global $rrdp_config, $rrdp_admin_clients, $rrdp_clients, $rrdp_ipc_sockets, $rrdp_repl_master_pid, $rrdp_repl_slave_pid;
@@ -1251,16 +1428,41 @@ function rrdp_system__global_console_logging_update() {
return;
}
/**
* Returns a stable per-connection identifier for a socket/resource, using
* spl_object_id() on PHP 8's \Socket objects or intval() on legacy resources.
*
* @param resource|\Socket $object
*
* @return int
*/
function rrdp_system__get_resource_id($object) {
return (PHP_VERSION_ID >= 80000) ? spl_object_id($object) : intval($object);
}
/**
* Checks whether $object is a valid socket handle, accounting for sockets
* being \Socket objects (PHP 8+) rather than resources.
*
* @param mixed $object
*
* @return bool
*/
function rrdp_system__is_resource($object) {
return (PHP_VERSION_ID >= 80000) ? is_object($object) : is_resource($object);
}
// #################################### SYSTEM COMMANDS ####################################
/**
* Admin CLI "clear" command: dispatches to the matching rrdp_cmd__clear_*
* subcommand handler based on the first argument.
*
* @param resource|\Socket|string $socket
* @param array<int, string> $args
*
* @return void
*/
function rrdp_cmd__clear($socket, $args) {
$arg = array_shift($args);
@@ -1276,6 +1478,12 @@ function rrdp_cmd__clear($socket, $args) {
rrdp_system__socket_write($socket, '% Incomplete command. Type "clear ?" for a list of subcommands' . NL);
}
/**
* Admin CLI "clear counters" command: resets all non-"live" entries in
* $rrdp_status back to zero.
*
* @return void
*/
function rrdp_cmd__clear_counters() {
global $rrdp_status;
@@ -1287,6 +1495,15 @@ function rrdp_cmd__clear_counters() {
rrdp_system__logging(LOGGING_LOCATION_BUFFERED, 'Counters have been cleared', 'SYS', SEVERITY_LEVEL_NOTIFICATION);
}
/**
* Admin CLI "clear logging buffered|snmp" command: empties the selected
* in-memory logging ring buffer.
*
* @param resource|\Socket|string $socket
* @param array<int, string> $args
*
* @return void
*/
function rrdp_cmd__clear_logging($socket, $args) {
global $rrdp_buffers;
@@ -1314,11 +1531,28 @@ function rrdp_cmd__clear_logging($socket, $args) {
}
}
/**
* Admin CLI "reset" command: clears the client terminal's screen buffer.
*
* @param resource|\Socket|string $socket
*
* @return void
*/
function rrdp_cmd__reset($socket) {
// client would like to regularly clear and reset terminal screen
rrdp_system__socket_write($socket, ANSI_ERASE_SCREEN . ANSI_ERASE_BUFFER . ANSI_POS_TOP_LEFT);
}
/**
* Admin CLI "enable" command: promotes the calling (localhost-only) admin
* session to privileged mode, after verifying the enable password if one is
* configured.
*
* @param resource|\Socket $socket
* @param array<int, string> $args
*
* @return void
*/
function rrdp_cmd__enable($socket, $args) {
global $rrdp_admin_clients, $rrdp_config;
$arg = array_shift($args);
@@ -1347,6 +1581,15 @@ function rrdp_cmd__enable($socket, $args) {
}
}
/**
* Admin CLI "disable" command: returns the calling admin session to
* unprivileged mode.
*
* @param resource|\Socket $socket
* @param array<int, string> $args
*
* @return void
*/
function rrdp_cmd__disable($socket, $args) {
global $rrdp_admin_clients;
@@ -1362,6 +1605,15 @@ function rrdp_cmd__disable($socket, $args) {
rrdp_system__socket_write($socket, '% Unrecognized command' . NL);
}
/**
* Admin CLI "quit" command: drops back to unprivileged mode and closes the
* calling admin connection.
*
* @param resource|\Socket $socket
* @param array<int, string> $args
*
* @return void
*/
function rrdp_cmd__quit($socket, $args) {
global $rrdp_admin_clients, $rrdp_admin_sockets;
@@ -1384,6 +1636,17 @@ function rrdp_cmd__quit($socket, $args) {
unset($rrdp_admin_sockets[$socket_resource_id]);
}
/**
* Admin CLI "shutdown" command (also invoked directly with $socket ===
* 'SIGTERM' from the signal handler): gracefully stops accepting new clients,
* stops RRDCached, terminates all child processes, closes every socket, and
* exits the process.
*
* @param resource|\Socket|string $socket
* @param array<int, string>|false $args
*
* @return void
*/
function rrdp_cmd__shutdown($socket, $args) {
global $rrdp_clients, $rrdp_admin_clients, $rrdp_ipc_sockets, $rrdp_client, $rrdp_admin, $rrdcached_pid, $microtime_start, $rrdp_process_types, $systemd;
@@ -1471,6 +1734,17 @@ function rrdp_cmd__shutdown($socket, $args) {
rrdp_system__socket_write($socket, '% Unrecognized command' . NL);
}
/**
* Admin CLI "?"/"list" command: prints the available subcommands (and their
* help text) for the current privilege level, or for a specific command's `?`
* subtree when $root is given.
*
* @param resource|\Socket $socket
* @param array<int, string>|false $args
* @param array<string, mixed>|false $root
*
* @return void
*/
function rrdp_cmd__list($socket, $args = false, $root = false) {
global $rrdp_admin_clients, $rrdp_help_messages;
@@ -1494,6 +1768,16 @@ function rrdp_cmd__list($socket, $args = false, $root = false) {
}
}
/**
* Admin CLI "show" command: dispatches to the appropriate report (threads,
* processes, counters, variables, clients, cluster, rsa, version, logging, or
* msr) and writes the (optionally filtered) output back to the socket.
*
* @param resource|\Socket $socket
* @param array<int, string> $args
*
* @return void
*/
function rrdp_cmd__show($socket, $args) {
global $rrdp_admin_clients, $rrdp_config, $rrdp_status, $rrdp_clients, $rrdp_process_types;
@@ -1591,6 +1875,14 @@ function rrdp_cmd__show($socket, $args) {
return;
}
/**
* Builds the report text for "show logging buffered|snmp": the current
* contents of the selected in-memory logging ring buffer.
*
* @param string|false $arg
*
* @return string
*/
function rrdp_cmd__show_logging($arg) {
global $rrdp_buffers;
@@ -1621,6 +1913,14 @@ function rrdp_cmd__show_logging($arg) {
return $output;
}
/**
* Builds the report text for "show msr buffer|health|status": the pending
* multi-server-replication command buffer, keyed by timeframe.
*
* @param string|false $arg
*
* @return string
*/
function rrdp_cmd__show_msr($arg) {
global $rrdp_msr_buffer;
@@ -1657,6 +1957,15 @@ function rrdp_cmd__show_msr($arg) {
return $output;
}
/**
* Builds the "show version" report: banner/logo, version, uptime, memory
* usage, RSA fingerprint, running process IDs, and listening sockets (a
* condensed socket-only summary is returned when $systemd is true).
*
* @param bool $systemd
*
* @return string
*/
function rrdp_cmd__show_version($systemd = false) {
global $rrdp_config, $rrdp_clients, $rrdp_repl_master_pid, $rrdp_repl_slave_pid;
@@ -1700,6 +2009,16 @@ function rrdp_cmd__show_version($systemd = false) {
return $output;
}
/**
* Admin CLI "debug <process> on|off" command: toggles verbose debug logging
* for a given process (forwarding the toggle to the replicator over IPC when
* applicable) once no other admin session still needs it enabled.
*
* @param resource|\Socket $socket
* @param array<int, string> $args
*
* @return void
*/
function rrdp_cmd__debug($socket, $args) {
global $rrdp_replicator_pid, $rrdp_admin_clients, $rrdp_help_messages, $rrdp_config, $rrdp_clients, $rrdp_ipc_sockets;
@@ -1774,6 +2093,15 @@ function rrdp_cmd__debug($socket, $args) {
}
}
/**
* Admin CLI "set" command: dispatches to the matching rrdp_cmd__set_*
* subcommand handler based on the first argument.
*
* @param resource|\Socket $socket
* @param array<int, string> $args
*
* @return void
*/
function rrdp_cmd__set($socket, $args) {
$arg = array_shift($args);
@@ -1789,6 +2117,16 @@ function rrdp_cmd__set($socket, $args) {
rrdp_system__socket_write($socket, '% Incomplete command. Type "set ?" for a list of subcommands' . NL);
}
/**
* Admin CLI "set cluster add|remove|update" command: validates and persists a
* registered peer's IP/port/fingerprint to ./include/proxies, creates its MSR
* data subfolder, and notifies the replication master/slave of the change.
*
* @param resource|\Socket $socket
* @param array<int, string> $args
*
* @return void
*/
function rrdp_cmd__set_cluster($socket, $args) {
global $rrdp_config, $rrdp_clients, $rrdp_repl_master_pid, $rrdp_repl_slave_pid, $rrdp_ipc_sockets;
@@ -1923,6 +2261,15 @@ function rrdp_cmd__set_cluster($socket, $args) {
return;
}
/**
* Admin CLI "set client add|remove" command: validates and persists a trusted
* client's IP/fingerprint to ./include/clients.
*
* @param resource|\Socket $socket
* @param array<int, string> $args
*
* @return void
*/
function rrdp_cmd__set_client($socket, $args) {
global $rrdp_config;
@@ -1984,6 +2331,15 @@ function rrdp_cmd__set_client($socket, $args) {
return;
}
/**
* Admin CLI "set rsa keys" command: generates and atomically writes a new RSA
* key pair, refreshing the running config's key material and fingerprint.
*
* @param resource|\Socket $socket
* @param array<int, string> $args
*
* @return void
*/
function rrdp_cmd__set_rsa($socket, $args) {
global $rrdp_config;
@@ -1992,18 +2348,20 @@ function rrdp_cmd__set_rsa($socket, $args) {
if (!is_null($arg)) {
switch($arg) {
case 'keys':
$rsa = new phpseclib3\Crypt\RSA();
$private = $rsa->createKey(2048);
$public = $rsa->getPublicKey();
$private = RSA::createKey(2048);
$public = $private->getPublicKey();
$rrdp_config['encryption']['public_key'] = $public;
$rrdp_config['encryption']['private_key'] = $private;
if (!rrdp_write_key_pair('./include/public.key', (string) $public, './include/private.key', (string) $private)) {
rrdp_system__socket_write($socket, '% Unable to write new RSA key pair' . NL);
file_put_contents('./include/public.key', $rrdp_config['encryption']['public_key']);
file_put_contents('./include/private.key', $rrdp_config['encryption']['private_key']);
break;
}
$public_key = $rsa->loadPublicKey($rrdp_config['encryption']['public_key']);
$rrdp_config['encryption']['public_key_fingerprint'] = $public_key->getFingerprint();
$rrdp_config['encryption']['public_key'] = (string) $public;
$rrdp_config['encryption']['private_key'] = (string) $private;
$public_key = RSA::loadPublicKey($rrdp_config['encryption']['public_key']);
$rrdp_config['encryption']['public_key_fingerprint'] = $public_key->getFingerprint('md5');
rrdp_cmd__show($socket, [ 0=>'rsa', 1=>'publickey']);
@@ -2020,6 +2378,15 @@ function rrdp_cmd__set_rsa($socket, $args) {
return;
}
/**
* Admin CLI "set logging buffered|snmp|terminal|console" command: updates the
* global or per-session logging severity/category thresholds.
*
* @param resource|\Socket $socket
* @param array<int, string> $args
*
* @return void
*/
function rrdp_cmd__set_logging($socket, $args) {
global $rrdp_config, $rrdp_admin_clients, $logging_categories;
@@ -2220,12 +2587,22 @@ function handle_child_processes($ipc_sockets, $type, $ssock = false, $arg1 = fal
}
// display_version - displays the version of the RRDproxy
/**
* Writes the proxy's version banner to STDOUT.
*
* @return void
*/
function display_version() {
$output = RRDP_VERSION_FULL;
fwrite(STDOUT, $output);
}
// display_help - displays the usage of the RRDproxy
/**
* Writes the command-line usage/help text (and version banner) to STDOUT.
*
* @return void
*/
function display_help() {
display_version();
$output = NL . 'Usage: rrdtool-proxy.php [-w|--wizard] [-v|--version] [-h|--help] [-f|--force] [-s|--systemd]' . NL
@@ -2240,6 +2617,12 @@ function display_help() {
}
// wizard - starts the wizard for system setup
/**
* Disables system logging and hands off execution to the interactive setup
* wizard, which exits the process when finished.
*
* @return never
*/
function init_wizard() {
define('SYSTEM_LOGGING', false);
include_once('./lib/wizard.php');
@@ -2247,6 +2630,14 @@ function init_wizard() {
}
// signal handler
/**
* Process signal handler: triggers a graceful shutdown on SIGTERM and ignores
* SIGHUP/SIGUSR1 and any other signal.
*
* @param int $signo
*
* @return void
*/
function rrdp_sig_handler($signo) {
switch ($signo) {
case SIGTERM:
@@ -2261,6 +2652,12 @@ function rrdp_sig_handler($signo) {
}
}
/**
* Returns the ASCII-art Cacti/RRDtool Proxy banner used in startup, shutdown,
* and "show version" output.
*
* @return string
*/
function rrdp_get_cacti_proxy_logo() {
return
NL . '# ' . ANSI_BOLD . ANSI_GREEN_FG . ' ___ _ _ ' . ANSI_RESET . ' __ __ ___ ___ '
+157
View File
@@ -0,0 +1,157 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDTool-based Graphing Solution |
+-------------------------------------------------------------------------+
| This code is designed, written, and maintained by the Cacti Group. See |
| about.php and/or the AUTHORS file for specific developer information. |
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/include/global.php';
require dirname(__DIR__) . '/lib/functions.php';
$failures = [];
$checks = 0;
/**
* Records a single test assertion: increments the running check count and, if
* $condition is falsy, appends $message to the list of failures.
*
* @param mixed $condition
* @param string $message
*
* @return void
*/
function check($condition, $message) {
global $checks, $failures;
$checks++;
if (!$condition) {
$failures[] = $message;
}
}
$test_root = sys_get_temp_dir() . '/rrdproxy-tests-' . bin2hex(random_bytes(8));
$rra_root = $test_root . '/rra';
$outside = $test_root . '/outside';
mkdir($rra_root, 0700, true);
mkdir($outside, 0700, true);
file_put_contents($rra_root . '/sample.rrd', 'rrd');
file_put_contents($outside . '/outside.rrd', 'outside');
$symlink_created = @symlink($outside, $rra_root . '/escape');
if (!$symlink_created) {
fwrite(STDERR, 'SKIP: symlink() is not available in this environment; skipping the symlink escape check.' . PHP_EOL);
}
$rrdp_config = ['path_rra' => $rra_root];
$canonical_rra_root = realpath($rra_root);
check(rrdp_resolve_rra_path('sample.rrd') === realpath($rra_root . '/sample.rrd'), 'A valid RRA path should resolve');
check(rrdp_resolve_rra_path('nested/new', false) === $canonical_rra_root . '/nested/new', 'A future path beneath the RRA root should resolve');
check(rrdp_resolve_rra_path('../outside/outside.rrd') === false, 'Parent traversal must be rejected');
check(rrdp_resolve_rra_path($outside . '/outside.rrd') === false, 'Absolute paths must be rejected');
if ($symlink_created) {
check(rrdp_resolve_rra_path('escape/outside.rrd') === false, 'Symlink escapes must be rejected');
} else {
fwrite(STDERR, 'SKIP: Symlink escapes must be rejected (no symlink() support)' . PHP_EOL);
}
check(rrdp_command_has_unsafe_path("update foo.rrd\ncreate escaped.rrd"), 'Embedded commands must be rejected');
check(rrdp_command_has_unsafe_path('update ../outside.rrd'), 'RRDtool traversal must be rejected');
check(rrdp_command_has_unsafe_path('graph graph.png DEF:x=../outside.rrd:value:AVERAGE'), 'RRDtool option traversal must be rejected');
check(rrdp_command_has_unsafe_path('update /tmp/outside.rrd'), 'Absolute RRDtool paths must be rejected');
check(rrdp_command_has_unsafe_path('graph graph.png --font TITLE:12:/tmp/font.ttf'), 'Embedded absolute RRDtool paths must be rejected');
$options = rrdp_parse_removespikes_options('-R=sample.rrd --method=stddev --dryrun');
check(is_array($options) && str_starts_with($options[0], '-R=' . $canonical_rra_root), 'Valid removespikes options should be normalized');
check(rrdp_parse_removespikes_options('-R=sample.rrd ;id') === false, 'Shell metacharacter arguments must be rejected');
check(rrdp_parse_removespikes_options('-R=../outside/outside.rrd') === false, 'removespikes traversal must be rejected');
check(rrdp_parse_removespikes_options('--dryrun') === false, 'removespikes must require exactly one RRD file');
check(rrdp_resolve_path_within($outside, 'future.rrd', false) === realpath($outside) . '/future.rrd', 'Generic containment should resolve safe future paths');
check(rrdp_resolve_path_within($outside, '../rra/sample.rrd', false) === false, 'Generic containment must reject traversal');
$process = rrdp_run_process([PHP_BINARY, '-r', 'echo $argv[1];', ';touch-not-executed']);
check($process !== false && $process['stdout'] === ';touch-not-executed', 'Process arguments must not be interpreted by a shell');
$rsa_class = 'phpseclib4\\Crypt\\RSA';
set_error_handler(static function ($severity, $message) {
return str_contains($message, 'Unable to write random state');
});
$private = $rsa_class::createKey(2048);
restore_error_handler();
$encryption = true;
$rrdp_config['encryption']['private_key'] = (string) $private;
$frame = encrypt('round trip', (string) $private->getPublicKey());
check(is_string($frame) && decrypt($frame) === 'round trip', 'phpseclib 4 encryption must round-trip');
check(decrypt('not-a-frame') === false, 'Malformed encrypted frames must fail closed');
$secret = $test_root . '/private.key';
check(rrdp_write_secure_file($secret, 'secret', 0600) !== false, 'Private key writes should succeed');
check((fileperms($secret) & 0777) === 0600, 'Private keys must use mode 0600');
$public_path = $test_root . '/public.key';
$private_path = $test_root . '/pair-private.key';
check(rrdp_write_key_pair($public_path, (string) $private->getPublicKey(), $private_path, (string) $private), 'Validated key pairs should be written together');
check((fileperms($private_path) & 0777) === 0600, 'Key-pair private keys must use mode 0600');
check(!rrdp_write_key_pair($public_path, 'invalid', $private_path, (string) $private), 'Invalid key pairs must fail closed');
$sockets = [];
check(socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $sockets), 'A socket pair should be available');
if (count($sockets) === 2) {
check(rrdp_socket_write_all($sockets[0], 'complete') === 8, 'Socket writes should report the complete byte count');
check(socket_read($sockets[1], 8) === 'complete', 'Socket writes should deliver the complete payload');
socket_close($sockets[0]);
socket_close($sockets[1]);
}
/**
* Recursively deletes a file, symlink, or directory tree used by the test
* fixtures.
*
* @param string $path
*
* @return void
*/
function remove_test_tree($path) {
if (is_link($path) || is_file($path)) {
unlink($path);
return;
}
foreach (scandir($path) as $entry) {
if ($entry !== '.' && $entry !== '..') {
remove_test_tree($path . DIRECTORY_SEPARATOR . $entry);
}
}
rmdir($path);
}
remove_test_tree($test_root);
if ($failures) {
fwrite(STDERR, implode(PHP_EOL, $failures) . PHP_EOL);
exit(1);
}
print "OK ($checks checks)" . PHP_EOL;