Files
TheWitnessandThomas Vincent 629971cc73 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>
2026-09-19 09:04:05 -04:00

421 lines
13 KiB
PHP

<?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/ |
+-------------------------------------------------------------------------+
*/
define('COPYRIGHT_YEARS', 'Copyright (C) 2004-' . date('Y') . ' The Cacti Group');
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', 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);
define('SEVERITY_LEVEL_CRITICAL', 3);
define('SEVERITY_LEVEL_ERROR', 4);
define('SEVERITY_LEVEL_WARNING', 5);
define('SEVERITY_LEVEL_NOTIFICATION', 6);
define('SEVERITY_LEVEL_INFORMATIONAL', 7);
define('SEVERITY_LEVEL_DEBUG', 8);
define('LOGGING_LOCATION_BUFFERED', 1);
define('LOGGING_LOCATION_SNMP', 2);
define('NOT_CONNECTED', 0);
define('INITIALISING', 1);
define('SYNCING', 2);
define('FULL_SYNCING', 3);
define('CONNECTED', 4);
define('BROKEN', 5);
define('NOT_REACHABLE', 6);
define('ANSI_ESCAPE', "\033[");
define('ANSI_RESET', ANSI_ESCAPE . '0m');
define('ANSI_BOLD', ANSI_ESCAPE . '1m');
define('ANSI_FAINT', ANSI_ESCAPE . '2m');
define('ANSI_ITALIC', ANSI_ESCAPE . '3m');
define('ANSI_UNDERLINE', ANSI_ESCAPE . '4m');
define('ANSI_BLINK', ANSI_ESCAPE . '5m');
define('ANSI_BLINK_FAST', ANSI_ESCAPE . '6m');
define('ANSI_CONCEAL', ANSI_ESCAPE . '7m');
define('ANSI_STRIKEOUT', ANSI_ESCAPE . '8m');
define('ANSI_FONT_DEFAULT', ANSI_ESCAPE . '9m');
define('ANSI_FONT_ALT1', ANSI_ESCAPE . '10m');
define('ANSI_FONT_ALT2', ANSI_ESCAPE . '11m');
define('ANSI_FONT_ALT3', ANSI_ESCAPE . '12m');
define('ANSI_FONT_ALT4', ANSI_ESCAPE . '13m');
define('ANSI_FONT_ALT5', ANSI_ESCAPE . '14m');
define('ANSI_FONT_ALT6', ANSI_ESCAPE . '15m');
define('ANSI_FONT_ALT7', ANSI_ESCAPE . '16m');
define('ANSI_FONT_ALT8', ANSI_ESCAPE . '17m');
define('ANSI_FONT_ALT9', ANSI_ESCAPE . '18m');
define('ANSI_FONT_ALT10', ANSI_ESCAPE . '19m');
define('ANSI_FONT_FRAKTUR', ANSI_ESCAPE . '20m');
define('ANSI_UNDERLINE_DBL', ANSI_ESCAPE . '21m');
define('ANSI_NORMAL', ANSI_ESCAPE . '22m');
define('ANSI_NO_ITL_FRAK', ANSI_ESCAPE . '23m');
define('ANSI_UNDERLINE_OFF', ANSI_ESCAPE . '24m');
define('ANSI_BLINK_OFF', ANSI_ESCAPE . '25m');
define('ANSI_INVERSE_OFF', ANSI_ESCAPE . '27m');
define('ANSI_REVEAL', ANSI_ESCAPE . '28m');
define('ANSI_STRIKEOUT_OFF', ANSI_ESCAPE . '29m');
define('ANSI_BLACK_FG', ANSI_ESCAPE . '30m');
define('ANSI_BLACK_BG', ANSI_ESCAPE . '40m');
define('ANSI_RED_FG', ANSI_ESCAPE . '31m');
define('ANSI_RED_BG', ANSI_ESCAPE . '41m');
define('ANSI_GREEN_FG', ANSI_ESCAPE . '32m');
define('ANSI_GREEN_BG', ANSI_ESCAPE . '42m');
define('ANSI_YELLOW_FG', ANSI_ESCAPE . '33m');
define('ANSI_YELLOW_BG', ANSI_ESCAPE . '43m');
define('ANSI_BLUE_FG', ANSI_ESCAPE . '34m');
define('ANSI_BLUE_BG', ANSI_ESCAPE . '44m');
define('ANSI_MAGENTA_FG', ANSI_ESCAPE . '35m');
define('ANSI_MAGENTA_BG', ANSI_ESCAPE . '45m');
define('ANSI_CYAN_FG', ANSI_ESCAPE . '36m');
define('ANSI_CYAN_BG', ANSI_ESCAPE . '46m');
define('ANSI_WHITE_FG', ANSI_ESCAPE . '37m');
define('ANSI_WHITE_BG', ANSI_ESCAPE . '47m');
define('ANSI_ERASE_TO_END', ANSI_ESCAPE . '0J');
define('ANSI_ERASE_TO_BEGIN', ANSI_ESCAPE . '1J');
define('ANSI_ERASE_SCREEN', ANSI_ESCAPE . '2J');
define('ANSI_ERASE_BUFFER', ANSI_ESCAPE . '3J');
define('ANSI_POS_TOP_LEFT', ANSI_ESCAPE . ';H');
$severity_levels = [
0 => 'none',
1 => 'emergency',
2 => 'alert',
3 => 'critical',
4 => 'error',
5 => 'warning',
6 => 'notification',
7 => 'informational',
8 => 'debug'
];
$logging_categories = [
'none' => 'No logging',
'all' => 'All categories',
'acl' => 'Access control',
'sys' => 'Internal system processes',
'slv' => 'Replication Slave processing',
'mstr' => 'Replication Master processing',
'ipc' => 'Client based inter process communication'
];
$process_status = [
'RUNNING',
'CLOSEDOWN_BY_SIGNAL',
'CLOSEDOWN_BY_TIMEOUT',
'CLOSEDOWN_BY_VIOLATION',
'CLOSEDOWN_BY_CLIENT_CMD',
'CLOSEDOWN_BY_CLIENT_DROP',
'CLOSEDOWN_BY_AUTHENTICATION_FAILURE',
];
$colors = [
'prompt' => ANSI_GREEN_FG, // green
'debug' => ANSI_CYAN_FG, // cyan
'normal' => ANSI_GREEN_FG, // green
'informational' => ANSI_BLUE_FG, // blue
'notification' => ANSI_WHITE_FG, // white
'warning' => ANSI_YELLOW_FG, // yellow
'error' => ANSI_RED_FG, // red
'critical' => ANSI_MAGENTA_FG, // magenta
'alert' => ANSI_MAGENTA_BG, // magenta background
'emergency' => ANSI_RED_BG, // red background
];
$rrdtool_env_vars = [
'RRD_DEFAULT_FONT',
'RRDCACHED_STRIPPATH',
];
$rrdtool_custom_cmds = [
'setenv',
'getenv',
'fc-list',
'setcnn',
'file_exists',
'filemtime',
'is_dir',
'mkdir',
'rmdir',
'unlink',
'rrd-list',
'archive',
'removespikes',
'version',
];
$rrdp_client_cnn_params = [
'timeout',
];
$rrdtool_cmds = [
'create',
'update',
'updatev',
'graph',
'graphv',
'dump',
'restore',
'last',
'lastupdate',
'first',
'info',
'fetch',
'tune',
'resize',
'xport',
'flushcached',
];
$rrdtool_msr_cmds = [
'create',
'update',
'updatev',
'restore',
'tune',
'resize',
'mkdir',
'rmdir',
'unlink',
'archive',
];
$rrdp_replicator_cmds = [
'REQUEST_SCANDIR',
'REQUEST_DUMP',
'REQUEST_LASTUPDATE',
'REQUEST_DIFF',
'RESPONSE_SCANDIR',
'RESPONSE_DUMP',
'RESPONSE_LASTUPDATE',
'RESPONSE_DIFF',
];
$rrdp_process_types = [
'S' => 'replication slave',
'M' => 'replication master',
'C' => 'client'
];
$rrdp_aliases = ['?' => 'list', 'help' => 'list', 'exit' => 'quit'];
$rrdp_help_messages = [
'?' => [
0 => [
'enable' => 'Turn on privileged commands',
'reset' => 'Reset terminal screen',
'show' => [
'info' => 'Show running system information',
'?' => [
'status' => 'Return system status',
'threads'=> 'Display currently open connections',
'version'=> 'System software status',
]
],
'quit'=> 'Close terminal session',
],
1 => [
'clear'=> [
'info'=> 'Clear functions for internal buffers',
'?' => [
'logging'=> [
'info'=> 'Clear logging buffer',
'?' => [
'buffered'=> 'Clear buffered log Cache',
'snmp' => 'Clear SNMP log Cache'
],
],
'counters' => 'Clear system counters',
],
],
'disable' => 'Turn off privileged commands',
'reset' => 'Reset terminal screen',
'set' => [
'info'=> 'Configure proxy settings',
'?' => [
'rsa'=> [
'info'=> 'Administrate encryption parameters',
'?' => [
'keys'=> 'Generate new RSA key pair'
],
],
'client'=> [
'info'=> 'Administrate RRDtool Proxy Server clients',
'?' => [
'add' => 'Add trusted client connection',
'remove'=> 'Delete trusted client connection entry',
],
],
'cluster'=> [
'info'=> 'Administrate RRDtool Proxy Server Cluster Peers',
'?' => [
'add' => 'Add a trusted peer',
'remove'=> 'Remove a trusted peer connection',
'update'=> 'Update IP address or fingerprint of a registered peer',
],
],
'logging'=> [
'info'=> 'Administrate logging locations and levels',
'?' => [
'buffered'=> [
'info' => 'Set GLOBAL buffered logging severity level (default:5)', '?' => array_slice($severity_levels,0,8)
],
'snmp'=> [
'info' => 'Set GLOBAL SNMP logging severity level (default:5)', '?' => array_slice($severity_levels,0,8)
],
'terminal'=> [
'info' => 'Set LOCAL terminal logging severity level (default:0) and categories (default: none)',
'?' => [
'level' => [
'info' => 'Numerical representation of the severity level [0-8]',
'?' => $severity_levels
],
'category' => [
'info'=> 'Set logging category (default: all)',
'?' => $logging_categories
],
],
],
'console'=> [
'info' => 'Set LOCAL terminal logging severity level (default:0) and categories',
'?' => [
'level' => [
'info' => 'Numerical representation of the severity level [0-8]',
'?' => $severity_levels
],
'category' => [
'info'=> 'Select one or more (comma separated) logging categories (default: none)',
'?' => $logging_categories
],
],
],
],
],
],
],
'show'=> [
'info'=> 'Show running system information',
'?' => [
'clients' => 'List all trusted client connections',
'counters'=> 'Return all system counters',
'cluster' => 'List all trusted cluster peers',
'logging' => [
'info'=> 'Display state of logging',
'?' => [
'status' => 'Show logging overview',
'buffered'=> 'List buffered log entries',
'snmp' => 'Display SNMP logs'
]
],
'msr'=> [
'info'=> 'Display replication state',
'?' => [
'buffer' => 'Display current replication buffer',
'health' => 'Health status',
'status' => 'Show current state of the MSR process',
],
],
'processes' => 'Display running child processes',
'rsa' => [
'info'=> 'Show Encryption setup',
'?' => [
'publickey' => 'Display current public key and fingerprint',
],
],
'threads' => 'Display currently open connections',
'variables' => 'Show Current Operating configuration',
'version' => 'System software status',
]
],
'shutdown' => 'Close all connections and shut down proxy',
'quit' => 'Close terminal session',
],
],
];
$rrdp_status = [
'aborted_clients' => 0,
'bytes_received' => 0,
'bytes_sent' => 0,
'connections' => 0,
'max_admin_connections' => 0,
'max_client_connections' => 0,
'memory_usage' => 'live',
'memory_peak_usage' => 'live',
'queries_rrdtool_total' => 0,
'queries_rrdtool_valid' => 0,
'queries_rrdtool_invalid' => 0,
'queries_system' => 0,
'threads_connected' => 'live',
'uptime' => 'live',
'connections_open' => 'live',
'connections_closed' => 0,
'connections_timeout' => 0,
'connections_broken' => 0,
'connections_aborted' => 0,
'connections_refused' => 0,
'msr_bytes_received' => 0,
'msr_bytes_sent' => 0,
'msr_connection_broken' => 0,
'rrd_pipe_broken' => 0,
'srv_bytes_received' => 0,
'srv_bytes_sent' => 0,
'srv_connection_broken' => 0,
'proc_closedown_by_signal' => 0,
'proc_closedown_by_timeout' => 0,
'proc_closedown_by_violation' => 0,
'proc_closedown_by_client_cmd' => 0,
'proc_closedown_by_client_drop' => 0,
'proc_closedown_by_decryption' => 0,
'proc_closedown_by_client_auth' => 0,
'rra_disk_size' => 0,
'rra_disk_used' => 0,
'rra_disk_avail' => 0,
'msr_disk_size' => 0,
'msr_disk_used' => 0,
'msr_disk_avail' => 0,
];
$rrdp_validate_filters = [
FILTER_VALIDATE_BOOLEAN,
FILTER_VALIDATE_EMAIL,
FILTER_VALIDATE_FLOAT,
FILTER_VALIDATE_INT,
FILTER_VALIDATE_IP,
FILTER_VALIDATE_MAC,
FILTER_VALIDATE_REGEXP,
FILTER_VALIDATE_URL
];