OwlCyberSecurity - MANAGER
Edit File: comet_pkgacct_stream_guard
#!/usr/local/cpanel/3rdparty/bin/perl # Copyright 2026 WebPros International, LLC # All rights reserved. # copyright@cpanel.net http://cpanel.net # This code is subject to the cPanel license. Unauthorized copying is prohibited. package scripts::comet_pkgacct_stream_guard; use cPstrict; =encoding utf-8 =head1 NAME comet_pkgacct_stream_guard - fail a Comet backup rather than vault a corrupted C<pkgacct --stdout-archive> stream (CPANEL-55666) =head1 SYNOPSIS comet_pkgacct_stream_guard <cpanel_user> [ pkgacct option ... ] =head1 DESCRIPTION The legacy C<databases_program_output> and C<pkgacct_program_output> Protected Items are C<engine1/stdout> sources: the Comet C<backup-tool> agent forks the item's C<EngineProps.COMMAND> and streams the command's STDOUT straight into the Storage Vault. Under C<--stdout-archive>, B<STDOUT IS THE ARCHIVE>. That makes any core code path which prints to STDOUT during a C<pkgacct> run a silent data-loss hazard, and there is one: C<Cpanel::Pkgacct::Components::Mysql> hardcodes C<< ERRORS_TO_STDOUT => 1 >> (Mysql.pm:673) and C<Cpanel::DBAdmin> raw-prints its error text to STDOUT (DBAdmin.pm:201). A single stale C<dbmap> entry therefore PREPENDS a few hundred bytes of human-readable error text to the gzip stream. C<pkgacct> still exits B<0> with an empty STDERR, so the agent records a perfectly successful backup of an archive that C<gzip> cannot open and C<restorepkg> cannot extract. The core half of that is a separate case; this guard is the plugin's half. C<run()> forks C<pkgacct> with its STDOUT on a pipe and inspects the FIRST TWO BYTES (after any leading blank bytes) before anything reaches our own STDOUT: =over 4 =item * B<leading blank bytes> — up to C<$MAX_LEADING_WHITESPACE_BYTES> of C<\n>, C<\r>, space and tab ahead of the gzip magic are skipped and NOT written (CPANEL-57691: core C<pkgacct> prints blank lines before the archive for accounts with Mailman lists). How many were skipped is logged to C</var/log/comet_backup.log>, NOT to STDERR: the agent grades any STDERR output as a warning (Comet 7001), which fails the backup. The checks below apply to the first two bytes after them. =item * B<gzip magic (C<\x1f\x8b>)> — the two bytes are written through and the rest of the stream is pumped verbatim to STDOUT until EOF. On the happy path this is a pure passthrough, so the vaulted CONTENT is byte-identical to what the un-wrapped C<pkgacct> COMMAND produced. C<pkgacct>'s own exit status is then propagated. =item * B<anything else> — this is the corruption case, and the leading bytes ARE the error message the administrator needs. Up to C<$MAX_CAPTURED_GARBAGE_BYTES> of them are captured and printed to STDERR (which the agent files into the Comet job log), the rest of the stream is DRAINED AND DISCARDED, and the guard exits non-zero having written B<ZERO BYTES> to STDOUT. =item * B<EOF before two bytes> — an empty stream. Nothing is written, a diagnostic naming CPANEL-55666 goes to STDERR, and the guard exits non-zero. =back B<Zero bytes on STDOUT is load-bearing, not tidiness.> Comet grades a C<engine1/stdout> job that exits non-zero having produced SOME output as a FAILED_WARNING rather than a hard failure, and this plugin's own C<_warning_completion_is_success> treats several warning shapes as success. A non-zero exit with no output is what makes the job unambiguously failed, so a corrupted snapshot is never recorded as a restore point. B<There is deliberately NO full-stream gunzip validation, and that is a design decision rather than an omission.> C<pkgacct> dumps every database into its working directory B<before> it streams the tar, so the whole demonstrated error class — C<DBAdmin> printing to STDOUT from the C<Mysql> component — lands B<ahead> of the archive bytes. A two-byte head check therefore covers it at zero streaming cost, whereas decompressing every byte of a multi-gigabyte account archive in-line would double the CPU of every legacy backup to catch a class of corruption nobody has observed. The C<pkgacct> path lives in the package variable C<$PKGACCT_BIN> purely so the unit suite can point it at a fixture after C<do>-loading this file. It is B<deliberately not> settable from the environment or the command line: this program is exec'd by the backup agent from a string stored on the Comet server, so an override would be runtime attack surface for no operational gain. =cut # Path to the real pkgacct. A package variable ONLY so t/small can retarget it # after do-loading this file; never read from %ENV or @ARGV (see the DESCRIPTION). our $PKGACCT_BIN = '/usr/local/cpanel/scripts/pkgacct'; # gzip's two-byte file magic. pkgacct --stdout-archive always emits a gzip # stream (it is a .tar.gz), so these are the first two bytes of every good run. our $GZIP_MAGIC = "\x1f\x8b"; # How much of a corrupted stream's leading garbage is echoed to STDERR. The # DBAdmin error text this exists to surface is a few hundred bytes; the cap # keeps a pathological stream from flooding the Comet job log. our $MAX_CAPTURED_GARBAGE_BYTES = 4096; our $READ_CHUNK_BYTES = 65_536; # CPANEL-57691: core pkgacct can print a few blank lines to STDOUT before the # archive (seen with Mailman lists on 138: "\n\n\n" then the gzip data). Up to # this many leading whitespace bytes (\n, \r, space, tab) are skipped before the # gzip magic is required. The cap keeps a stream of nothing but whitespace from # being read forever. our $MAX_LEADING_WHITESPACE_BYTES = 4096; exit run(@ARGV) unless caller; =head1 FUNCTIONS =head2 run( @argv ) Runs C<$PKGACCT_BIN @argv> behind the head check described above. Returns the process exit code: 0 only when the stream carried gzip magic, was pumped through in full, and C<pkgacct> itself exited 0. =cut sub run (@argv) { # A vanished consumer must not kill us mid-write: with SIGPIPE ignored the # syswrite below fails EPIPE instead, so the child is still reaped and an # exit code is still chosen deliberately. local $SIG{'PIPE'} = 'IGNORE'; binmode STDOUT; my $out_fh = _spawn_pkgacct(@argv) or return 1; my ( $skipped, $head ) = _read_head($out_fh); return _refuse_truncated_stream( $out_fh, $head ) if !defined $head || length($head) < length $GZIP_MAGIC; return _refuse_corrupt_stream( $out_fh, $head, $skipped ) if $head ne $GZIP_MAGIC; _note( 'Skipped blank bytes that pkgacct printed before the account backup archive', { user => $argv[0] // '', bytes => $skipped } ) if $skipped; return _pass_stream_through( $out_fh, $head ); } =head2 _read_head( $fh ) Reads the stream's head: skips up to C<$MAX_LEADING_WHITESPACE_BYTES> leading whitespace bytes (C<\n>, C<\r>, space, tab), then reads the next two bytes. Returns C<( $skipped_count, $head )>. C<$head> is C<undef> on a read error and shorter than two bytes at EOF. Once the cap is reached a further whitespace byte is NOT skipped, so it becomes part of C<$head> and the stream is refused. The skipped bytes are dropped; everything from the gzip magic on is passed through unchanged, so the stored archive is exactly the gzip data. =cut sub _read_head ($fh) { my $skipped = 0; my $byte; while (1) { $byte = _read_exactly( $fh, 1 ); return ( $skipped, $byte ) if !defined $byte || !length $byte; last if $byte !~ m{\A[\x09\x0a\x0d\x20]\z} || $skipped >= $MAX_LEADING_WHITESPACE_BYTES; $skipped++; } my $rest = _read_exactly( $fh, length($GZIP_MAGIC) - 1 ); return ( $skipped, defined $rest ? $byte . $rest : undef ); } # Forks $PKGACCT_BIN with its STDOUT on a pipe we own. STDERR is inherited on # purpose: pkgacct's own diagnostics belong in the Comet job log unchanged. sub _spawn_pkgacct (@argv) { my $fh; my $pid = open( $fh, '-|', $PKGACCT_BIN, @argv ); if ( !$pid ) { my $err = $!; _diag("could not run $PKGACCT_BIN: $err"); _diag('No backup data was produced.'); return; } binmode $fh; return $fh; } # EOF (or a read error) before even the two magic bytes could be read. Reported # separately from the wrong-magic case because the operator's next step differs: # there is no leading garbage to read here, only an archive that was not produced. sub _refuse_truncated_stream ( $out_fh, $head ) { my $got = defined $head ? length $head : 0; my $status = _reap($out_fh); _diag("the account backup produced no data (it ended after $got byte(s))."); _diag( sprintf( 'pkgacct exited with status %d. The backup failed and nothing was stored.', _exit_code_from_status($status) ) ); return 1; } # Wrong magic: the leading bytes are error TEXT that core printed into the # archive stream. Surface them, discard the stream, write nothing. sub _refuse_corrupt_stream ( $out_fh, $head, $skipped = 0 ) { my $captured = $head . _drain( $out_fh, $MAX_CAPTURED_GARBAGE_BYTES - length $head ); my $status = _reap($out_fh); my $hex = join( ' ', map { sprintf( '0x%02x', ord ) } split( //, $head ) ); _diag('the account backup data is not a valid archive and was discarded. The backup failed.'); _diag( $skipped ? "After $skipped blank byte(s), the next two bytes were $hex, expected 0x1f 0x8b." : "First two bytes were $hex, expected 0x1f 0x8b." ); _diag( sprintf( 'pkgacct exited with status %d.', _exit_code_from_status($status) ) ); _diag('The error text pkgacct printed is shown below:'); _diag_block($captured); return 1; } # Happy path: write the magic we consumed, then pump the rest verbatim. sub _pass_stream_through ( $out_fh, $head ) { my $pump = _write_all($head) ? _pump($out_fh) : 'write_error'; my $status = _reap($out_fh); if ( $pump eq 'write_error' ) { # The vault side went away. Quiet on purpose: this is not a corrupted # snapshot, it is a consumer that stopped reading, and the agent already # knows why. _diag('the backup destination stopped reading before the backup finished; aborting.'); return 1; } if ( $pump eq 'read_error' ) { _diag('could not read all of the account backup data. The backup failed.'); return 1; } my $exit_code = _exit_code_from_status($status); if ($exit_code) { _diag("pkgacct exited with status $exit_code. The backup failed."); return $exit_code; } return 0; } =head2 _read_exactly( $fh, $want ) Reads exactly C<$want> bytes, looping over short C<sysread>s. Returns the bytes read (possibly fewer than C<$want> at EOF), or C<undef> on a read error. =cut sub _read_exactly ( $fh, $want ) { my $buf = ''; while ( length($buf) < $want ) { my $got = sysread( $fh, my $chunk, $want - length($buf) ); return undef if !defined $got; last if $got == 0; $buf .= $chunk; } return $buf; } # Reads the rest of the stream to EOF, keeping at most $limit bytes of it. The # child is NEVER killed: it is left to finish writing into a pipe we are # draining, which is what keeps this a clean reap instead of a SIGPIPE race. sub _drain ( $fh, $limit ) { my $captured = ''; while (1) { my $got = sysread( $fh, my $chunk, $READ_CHUNK_BYTES ); last if !defined $got || $got == 0; my $room = $limit - length $captured; $captured .= substr( $chunk, 0, $room ) if $room > 0; } return $captured; } # Copies $fh to STDOUT until EOF. Returns 'ok', 'read_error' or 'write_error'. sub _pump ($fh) { while (1) { my $got = sysread( $fh, my $chunk, $READ_CHUNK_BYTES ); return 'read_error' if !defined $got; last if $got == 0; return 'write_error' if !_write_all($chunk); } return 'ok'; } # syswrite can be short; loop until the whole buffer is out. Returns 0 on any # write failure, EPIPE included (SIGPIPE is ignored by run()). sub _write_all ($data) { my $len = length $data; my $written = 0; while ( $written < $len ) { my $wrote = syswrite( STDOUT, $data, $len - $written, $written ); return 0 if !defined $wrote; $written += $wrote; } return 1; } # Closing a piped filehandle waits for the child and puts its wait status in $?. sub _reap ($fh) { close $fh; return $?; } sub _exit_code_from_status ($status) { return 0 if !$status; my $code = $status >> 8; return $code if $code > 0 && $code < 256; # Death by signal, or a status we cannot express as an exit code. return 1; } # CPANEL-57691: a note for /var/log/comet_backup.log, never STDERR. The agent grades an # engine1 job that wrote anything to STDERR as a warning (Comet status 7001), and the # plugin fails a warning, so a line on STDERR would fail the very backup it describes. # Fail-soft and silent: a log that cannot be written must not change the result. sub _note ( $message, $context = {} ) { local $@; local $SIG{'__WARN__'} = sub { }; eval { require Whostmgr::CometBackup::Logger; Whostmgr::CometBackup::Logger->new()->info( $message, $context ); 1; }; return; } sub _diag ($message) { print {*STDERR} "comet_pkgacct_stream_guard: $message\n"; return; } # Echoes captured stream bytes, rendering anything that is not printable ASCII # (or tab/newline) as an escape so a binary fragment cannot mangle the job log. sub _diag_block ($bytes) { my $rendered = $bytes; $rendered =~ s{([^\x09\x0a\x20-\x7e])}{sprintf( '\\x%02x', ord $1 )}ge; print {*STDERR} "--- begin captured stream head ---\n$rendered\n--- end captured stream head ---\n"; return; } 1;