#!/usr/bin/perl

# Written 2003-01-14 by Jim Lippard.
# Modified 2003-02-13 by Jim Lippard to add "both" function.
# Modified 2003-05-25 by Jim Lippard to use rsync user identity even
#    when client is root (sudo).
# Modified 2003-05-26 by Jim Lippard to add setup/cleanup for source/dest.
# Modified 2003-06-15 by Jim Lippard to make source and destination sudo
#    per-directory.  (Currently, if there are multiple rsyncs to the same
#    directory, they need to have the same settings on the server side, as
#    it will always match the first occurrence.)  Did the same for setup/cleanup.
# Modified 2003-11-05 by Jim Lippard to fix bug where cleanup didn't work on the
#    server side.
# Modified 2004-11-15 by Jim Lippard to allow use of --relative (which changes
#    the options required on the server side), which is needed so that
#    --no-implied-dirs can be used in config file.
# Modified 2008-11-10 by Jim Lippard to support new options in rsync 3.0.  It
#    might be nice to use rrsync as a base for the server side.
# Modified 2009-12-31 by Jim Lippard to use ssh -vv when $DEBUG=1.
# Modified 2011-12-20 by Jim Lippard to use _rsyncu user to avoid
#    conflict with OpenBSD's name for the _rsync daemon.
# Modified 2012-01-03 by Jim Lippard to support ECDSA and RSA
#    identities as well as DSA (priority: ECDSA, DSA, RSA).
# Modified 2014-05-10 by Jim Lippard to support option change in rsync 3.1.
# Modified 2014-11-22 by Jim Lippard to support option change in rsync 3.1.1.
# Modified 2015-10-18 by Jim Lippard to support doas and sudo.
# Modified 2015-10-23 by Jim Lippard to support ED25519 identities.
# Modified 2016-01-30 by Jim Lippard to support option change in rsync 3.1.2.
# Modified 2021-03-26 by Jim Lippard to add a configuration option to specify
#    ssh-identity per setup (since restricting allowed commands allows only
#    a single command per remote host per identity used).
# Modified 2021-07-18 by Jim Lippard to fix path through code that led to
#    uninitialized ssh_identity.
# Modified 2021-10-12 by Jim Lippard to move config file to /etc in preparation
#    for making this a package. Also clean up configuration processing logic.
# Modified 2022-02-14 by Jim Lippard to finally do the server side correctly
#    and not run twice with "both" in one invocation, just do what the client
#    expects the first time.
# Modified 2023-02-22 by Jim Lippard to die if the server side is invoked
#    and there is no SSH_ORIGINAL_COMMAND.
# Modified 2023-12-02 by Jim Lippard to use pledge and unveil on OpenBSD.
# Modified 2023-12-30 by Jim Lippard to call pledge correctly.
# Modified 2023-12-31 by Jim Lippard to expand unveil to cover
#    /usr/share/zoneinfo.
# Modified 2024-01-03 by Jim Lippard to use new perl open format.
# Modified 2024-02-10 by Jim Lippard to unveil setup/cleanup commands
#    properly (ignore ""/0).
# Modified 2024-07-09 by Jim Lippard to unveil /bin/sh if there's an *
#    in a dirlist, and to unveil containing dirs.
# Modified 2024-07-10 by Jim Lippard to add some minimal validation
#    on dirlists, restricting * to filenames (not directories) and
#    stopping unobfuscated directory traversal. The local config file
#    is still highly trusted and access should be restricted. Fixed bug
#    in client unveiling of paths and locking of unveil.
# Modified 2024-08-29 by Jim Lippard for error checking on all pledge calls.
# Modified 2024-10-08 by Jim Lippard to support new rsync 3.3.0 "s" option.
# Modified 2025-09-15 by Jim Lippard to support Linux rsync path.
# Modified 2025-09-25 by Jim Lippard to abort and complain if can't open
#    log file for writing. Removed DSA key option.
# Modified 2026-01-04 by Jim Lippard to remove @ from subroutine calls.
# Modified 2026-01-10 by Jim Lippard to break up setup/cleanup commands to
#    avoid shell.
# Modified 2026-01-11 by Jim Lippard to allow setup/cleanup commands to
#    contain multiple commands separated by semicolons.
# Modified 2026-02-06 by Jim Lippard to require absolute paths for dirs.
# Modified 2026-04-08 by Jim Lippard to fix cleanup command regex typo,
#    error in OS check for pledge/unveil for exec_server. Use File::Glob
#    instead of using shell. Simplify rsync execution logic for client and
#    server.
# Modified 2026-04-17 by Jim Lippard to validate config file ownership and
#    permissions and log failures, create log file with 0600 permissions.
# Modified 2026-04-19 by Jim Lippard to validate config file dir before
#    config file (since insufficient access on dir can prevent determination
#    if config file even exists).
# Modified 2026-04-29 by Jim Lippard to reject globs in sources where source-sudo
#    is 'yes' since those can't be expanded without privilege (which would either
#    require either using shell expansion or adding some helper cruft).
# Modified 2026-05-02 by Jim Lippard to clean up some minor errors: really remove
#    RSA SSH ID support this time, fix error message, make security_die calls
#    consistent, fix typo in error message, remove unused variable declarations
#    and some dead, commented-out code, document a couple limitations (no spaces in
#    command arguments, dir paths must be exactly consistent between client and server.
# Modified 2026-05-17 by Jim Lippard after Gemini security assessment which
#    claimed to find five security issues, all of which were bogus. Updated
#    comments to explain unveiling on setup-command/cleanup-command.
#    (Gemini claimed issues were (1) post-unveil traversal, based on
#    wrong assumption paths were from SSH_ORIGINAL_COMMAND instead of
#    config, (2) setgid denial of service on config directory, but
#    fail-closed is the intended design for a permissions error,
#    (3) server-side glob won't work, it does work and the suggested
#    change would break it, (4) confabulated stray brace at the end of
#    the script, (5) multiple commands in setup/cleanup unveil
#    "blind spot" based on false assumption that unveil environments
#    are propagated over system calls. No actual issues to fix.
# Modified 2026-05-31 by Jim Lippard to replace $RSYNC_IDENTITY constant with
#    $rsync_identity variable.

# To Do:  Add "label" distinct from hostname, because there may be hosts behind
#   firewalls with different external names (or no external name at all) rsyncing
#   with hosts outside the firewall.  This may also allow a solution to the
#   problem with issuing multiple server commands... but maybe not (since we want
#   to have only a single command from a single host in the authorized_keys2 file).
#   (This can be done either by using different SSH keys for different commands
#   or issuing the commands via a filtering script that allows the different
#   desired options.)
# This functionality already exists, but only by sacrificing the design goal of
#   being able to use one and the same config file for all hosts (or at least for
#   both sides of a connection).

# Future enhancement:  Config file place restrictions on push/pull.  Accidents
#    are already avoided if you use the command= setting in the authorized_keys2
#    file, but another layer of protection is worthwhile.  The command= setting
#    may not be an option in some cases--e.g., where some things are being synched
#    in each direction between the same two machines.
#  Allow keywords to choose which sets of things to sync.
#  "both" attempts each direction for the same directories on the server side...
#      this is an error; it needs to make decisions based on the SSH command received.

# Script to allow rsync between machines using a non-privileged rsync user.
# This is a genericized version of a script originally written 2002-08-16 (same author).

# The current version requires local hostnames only, with no domain names
# in the config file.  To change, modify this script so that $HOSTNAME retains
# the full hostname rather than omitting the domain portion.

# Security:
# 1. Use an unprivileged rsync user with a passwordless SSH DSA key as the
#    initiator (client side) of the rsync, when possible.
#
# 2. Use an authorized_keys2 file on the server side which specifies a
#    specific command (calling this script with the appropriate arguments)
#    and a specific host (the host expected to be on the other side).
#    Set permissions and ownership on the authorized_keys2 file so that
#    the rsync user cannot modify it or anything else in its .ssh directory.
#    (Manually update its known_hosts file.)
#
# 3. Put the specific needed rsync commands into your sudoers file, which
#    specify the particular dirs or files being rsynced.  These commands
#    are of the following form on the source (server) side when using "pull":
#      $RSYNC_PATH --server --sender -vlogDtprze.[iLf] <rsync options> . <source dir>
#    and of the following form on the destination (server) side when using "push":
#      $RSYNC_PATH --server -vlogDtprze.[iLf] <rsync options> . <dest dir>
#    There is a command of these forms for each source/dest directory; it is recommended
#    that they be put into a command alias such as
#       Cmnd_Alias RSYNC_CMDS = <comma-separated list of commands>
#
#    The format of the commands on the source (client) side when using "push":
#        $RSYNC_PATH -avz <rsync options> <source dir> $RSYNC_USER@<dest host>\:<dest dir>
#    The backslash must appear in front of the colon; that character has special meaning
#    in the sudoers file syntax.
#
#    Finally, the format on the destination (client) side when using "pull":
#        $RSYNC_PATH -avz <rsync options> $SOURCE_HOST\:<source dir> <dest dir>
#
#    In previous versions, for the latter two cases the SSH identity used was root's
#    rather than the rsync user, but now the rsync user is used in all cases.
#
# 4. The server side script will only permit rsync for pathnames that are in the appropriate
#    entry in the config file.  Make sure the ownership and permissions on the config file
#    are restricted to read-only for the rsync user.
#
# It can be helpful to use separate SSH keys for push and pull operations,
# splitting them out in the authorized_keys file accordingly and specifying
# the identity to use in the ssh-identity field of the config file.
#
# NOTE: As of sudo version 1.6.9p4 (the version included with OpenBSD 4.2), sudo filters
# environment variables.  If you do any rsyncs with this script that use sudo, you
# will need to permit the RSYNC_RSH environment variable to be passed by adding
# a line to your sudoers file that says:
#  Default:_rsyncu env_keep +="RSYNC_RSH"

# Usage:
#   On client side: 
#   rsync-client.pl [push|pull|both] server-hostname
#   On server side:
#   rsync-server.pl [push|pull|both] client-hostname

# "push" means that the client is the source and the server is the destination.
# "pull" means that the client is the destination and the server is the source.
# "both" means that all pushes, then all pulls will be performed as appropriate.
# If you use "push" on the client side you must use "push" on the server side.

# Configuration file format:
#
# # Comment.
# source: sourcehost
# destination: destinationhost
# source-dirlist: list,of,dirs,or,files
# destination-dirlist: list,of,dirs,or,files
# rsync-options: --delete --delete-after --exclude file --exclude file, --exclude file, ""
# [optional] ssh-identity: [ssh-identity-file]
# source-setup: commandline, commandline, commandline
# source-cleanup: commandline, commandline, commandline
# destination-setup: commandline, commandline, commandline
# destination-cleanup: commandline, commandline, commandline
# source-sudo: [yes|no], [yes|no], [yes|no]
# destination-sudo: [yes|no], [yes|no], [yes|no]

# All files will be rsynced between the two hosts.  The first matching config file entry
# is used, any further settings for the same pair of hosts in the same slots (source/destination)
# will be ignored.

# The number of comma-separated items in each source-sudo, destination-sudo, source-dirlist,
# destination-dirlist, source- and dest- setup and cleanup, and rsync-options list must be the same.
# If a given directory or file requires no rsync options, use "".  If there are files  to be
# excluded for a given directory, use "--exclude file [--exclude file [...]]" for each file in that
# directory; commas separate the exclude file lists for each directory.  Other common options
# include --delete, --delete-after, --delete-excluded, --include, and --ignore-existing.  See
# rsync's man page for more info.

# If source-sudo is yes, the rsync command on the source will be issued with sudo.
# If destination-sudo is yes, the rsync command on the destination will be issued with sudo.
# This is necessary where the rsync user lacks read permission on the source or write permission
# on the destination.

# Setup commands will be executed before rsync, cleanup commands will be
# executed after the rsync.

# Note on OpenBSD: Setup/cleanup commands and all commands in
# semicolon-separated chains (e.g., "cmd1; cmd2; cmd3") have their binary
# paths automatically unveiled with rx permissions. Once executed, these
# commands run without unveil restrictions inherited from rsync-client.pl,
# so they can freely access any files their normal Unix permissions allow.
# rx is used instead of x alone to avoid mysterious failures when the
# commands themselves are being rsynced (e.g., as part of a backup).

### Required packages.

use strict;
use warnings;
use Fcntl qw(:DEFAULT :flock); # for logging
use File::Basename;
use File::Glob ':glob';
use Sys::Hostname;
use if $^O eq "openbsd", "OpenBSD::Pledge";
use if $^O eq "openbsd", "OpenBSD::Unveil";

### Global constants.

my $DEBUG = 0;
my $USE_SUDO = 0;

my $RSYNC_USER = '_rsyncu';
my $RSYNC_USER_HOME = "/home/$RSYNC_USER";
my $RSYNC_USER_SSHDIR = "$RSYNC_USER_HOME/.ssh";
my $RSYNC_ED25519_IDENTITY = "$RSYNC_USER_SSHDIR/id_ed25519"; # preferred
my $RSYNC_ECDSA_IDENTITY = "$RSYNC_USER_SSHDIR/id_ecdsa";

my $CONFIG_FILE = '/etc/rsync/rsync.conf';
my $LOG_FILE = "$RSYNC_USER_HOME/rsync.out";

my $DOAS = '/usr/bin/doas';
my $RSYNC = '/usr/local/bin/rsync';
$RSYNC = '/usr/bin/rsync' if ($^O eq 'linux');
my $SUDO = '/usr/bin/sudo';
my $SSH = '/usr/bin/ssh';
my $ZONEINFO_DIR = '/usr/share/zoneinfo';

my $ALLOW_MULTIPLE = 1;

my $HOSTNAME = hostname();
my $DOMAIN = '';
($HOSTNAME, $DOMAIN) = split (/\./, $HOSTNAME, 2);

if (-e $SUDO) {
    $USE_SUDO = 1;
}

my $CLIENT = 0;

if ($0 =~ /rsync-client.pl$/) {
    $CLIENT = 1;
}
elsif ($0 =~ /rsync-server.pl$/) {
    $CLIENT = 0;
}

my @POSSIBLE_SERVER_OPTIONS = ('-vlogDtprz',
			       '-vlogDtpRz',
			       '-vlogDtprze.',
			       '-vlogDtprze.i',
			       '-vlogDtprze.f',
			       '-vlogDtprze.if',
			       '-vlogDtprze.iLf',
			       '-vlogDtprze.iL',
			       '-vlogDtprze.iLfx',
			       '-vlogDtprze.iLfxC',
			       '-vlogDtprze.iLfxCIvu',
			       '-vlogDtprze.iLsfxCIvu');
# The "R" option is for --relative; the e.[i] options only appear after rsync 3.0.; .f only in rsync 3.0.7+
# I probably haven't made this work for 3.0 uses of --relative.
# The "L" option appeared with rsync 3.0.8.
# The "f" disappears in rsync 3.1.0.
# The "C" option appeared with rsync 3.1.2.
# the "s" option after the dot appeared with rsync 3.3.0.

# For dirlist subroutine; could add other forms of validation in
# the future for rsync options, commands, etc.
my $VALIDATE_DIRLIST = 1;

### Variables.

my ($push, $both, $other_host, $source, $destination,
    @source_dirlist, @destination_dirlist, @rsync_options, $rsync_identity,
    $ssh_identity,
    @source_sudo, @destination_sudo,
    @source_setup, @source_cleanup, @dest_setup, @dest_cleanup,
    @setup_command, @cleanup_command, $idx);
my (@this_command);

# Client variables.
my ($source_info, $destination_info, @rsync_command);

# Server variables.
my (@allowed_prefix, @allowed_paths, $options, $need_sudo, $command, $time,
    $path, $allowed_this_path);

### Main program.

if ($#ARGV != 1) {
    if ($CLIENT) {
	die "Usage: $0 [push|pull] server-hostname\n";
    }
    else {
	die "Usage: $0 [push|pull] client-hostname\n";
    }
}

if ($ARGV[0] eq 'push') {
    $push = 1;
}
elsif ($ARGV[0] eq 'pull') {
    $push = 0;
}
elsif ($ARGV[0] eq 'both') {
    $push = 0;
    $both = 1;
}
else {
    die "Invalid argument \"$ARGV[0]\".  Must be \"push\" or \"pull\" or \"both\".\n";
}

$other_host = $ARGV[1];

# Validate config file ownership and permissions. There's a TOCTOU window between this
# check and parse_config, but config file should be owned by root (and immutable) if
# following best practices.
validate_config_file_security();

# Parse the config file, then execute client or server functions
# as necessary. If "both" on the client side, execute push, then
# parse the config file again and execute pull. If "both" on the
# server side, look in the SSH_ORIGINAL_COMMAND for --sender -- if
# present, do a pull, if not present, do a push.
if ($CLIENT) {
    # This could be a push or a pull, but if it's "both" it's a pull.
    # (Reversed the order on both in order to allow pledge to add
    # further restrictions after the pull.)
    parse_config();
    exec_client();
    # And so, with both, we now need to do a push.
    if ($both) {
	$push = 1;
	parse_config();
	exec_client();
    }
}
# server gets invoked twice if "both" is used, need to do what the
# client expects. [Should no longer be the case.]
else {
    $command = $ENV{'SSH_ORIGINAL_COMMAND'};
    if (defined ($command) && $command =~ /--sender/) {
	$push = 0;
    }
    else {
	$push = 1;
    }
    parse_config();
    exec_server();
}

### Subroutines.

# Find appropriate entry in configuration file.
# The logic here was horrible, such that optional items (e.g. cleanup) for
# an entry, if they came after all required items, would be ignored and
# processed as part of the next entry. The workaround was to make sure the
# last item for any entry was a required item. The better fix might have
# been to process all entries before finding the match, but we went with
# removing the "check_arg" code for the "source:" field and considering
# an entry to be complete once we hit the next "source:" field.
sub parse_config {
    my ($found_match, $have_source, $have_destination, $have_source_dirlist,
	$have_destination_dirlist, $have_rsync_options,$have_ssh_identity,
	$have_source_sudo, $have_destination_sudo,
	$have_source_setup, $have_source_cleanup,
	$have_dest_setup, $have_dest_cleanup, $next_source, $have_next_source);

    $found_match = 0;
    $have_source = 0;
    $next_source = "";
    $have_next_source = 0;
    $have_destination = 0;
    $have_source_dirlist = 0;
    $have_destination_dirlist = 0;
    $have_rsync_options = 0;
    $have_ssh_identity = 0;
    $ssh_identity = '';
    $have_source_sudo = 0;
    $have_destination_sudo = 0;
    $have_source_setup = 0;
    $have_source_cleanup = 0;
    $have_dest_setup = 0;
    $have_dest_cleanup = 0;

    open (CONFIG, '<', $CONFIG_FILE) || die "Cannot open config file. $CONFIG_FILE $!\n";
    while (<CONFIG>) {
	if (/^\s*#|^$/) {
	}
	elsif (/^\s*source:\s+(.*)$/) {
	    if (!$have_source) {
		$source = $1;
		$have_source = 1;
	    }
	    else {
		$next_source = $1;
		$have_next_source = 1;
	    }
	}
	elsif (/^\s*destination:\s+(.*)$/) {
	    check_arg ('destination', $1, $have_destination);
	    $destination = $1;
	    $have_destination = 1;
	}
	elsif (/^\s*source-dirlist:\s+(.*)$/) {
	    check_arg ('source-dirlist', $1, $have_source_dirlist);
	    @source_dirlist = &dirlist ($1, $VALIDATE_DIRLIST);
	    $have_source_dirlist = 1;
	}
	elsif (/^\s*destination-dirlist:\s+(.*)$/) {
	    check_arg ('destination-dirlist', $1, $have_destination_dirlist);
	    @destination_dirlist = &dirlist ($1, $VALIDATE_DIRLIST);
	    $have_destination_dirlist = 1;
	}
	elsif (/^\s*rsync-options:\s+(.*)$/) {
	    check_arg ('rsync-options', $1, $have_rsync_options);
	    @rsync_options = &dirlist ($1);
	    $have_rsync_options = 1;
	}
	elsif (/^\s*ssh-identity:\s+(.*)$/) {
	    check_arg ('ssh-identity', $1, $have_ssh_identity);
	    $ssh_identity = $1;
	    $have_ssh_identity = 1;
	}
	elsif (/^\s*source-setup:\s+(.*)$/) {
	    check_arg ('source-setup', $1, $have_source_setup);
	    @source_setup = &dirlist ($1);
	    $have_source_setup = 1;
	}
	elsif (/^\s*source-cleanup:\s+(.*)$/) {
	    check_arg ('source-cleanup', $1, $have_source_cleanup);
	    @source_cleanup = &dirlist ($1);
	    $have_source_cleanup = 1;
	}
	elsif (/^\s*destination-setup:\s+(.*)$/) {
	    check_arg ('destination-setup', $1, $have_dest_setup);
	    @dest_setup = &dirlist ($1);
	    $have_dest_setup = 1;
	}
	elsif (/^\s*destination-cleanup:\s+(.*)$/) {
	    check_arg ('destination-cleanup', $1, $have_dest_cleanup);
	    @dest_cleanup = &dirlist ($1);
	    $have_dest_cleanup = 1;
	}
	elsif (/^\s*source-sudo:\s+(.*)$/) {
	    check_arg ('source-sudo', $1, $have_source_sudo);
	    @source_sudo = &yes_or_no_list ('source-sudo', $1);
	    $have_source_sudo = 1;
	}
	elsif (/^\s*destination-sudo:\s+(.*)$/) {
	    check_arg ('destination-sudo', $1, $have_destination_sudo);
	    @destination_sudo = &yes_or_no_list ('destination-sudo', $1);
	    $have_destination_sudo = 1;
	}
	else {
	    die "Invalid configuration statement \"$_\" in config file.\n";
	}

	if (($have_source && $have_next_source) || eof (CONFIG)) {

	    # Do we have all required items for an entry?
	    if ($have_source && $have_destination &&
		$have_source_dirlist && $have_destination_dirlist &&
		$have_rsync_options && $have_source_sudo && $have_destination_sudo) {
	    }
	    else {
		if ($have_source) {
		    if ($have_destination) {
			die "Configuration file does not have all required fields for entry with source $source and destination $destination.\n";
		    }
		    else {
			die "Configuration file does not have all required fields for antry with source $source and no destination.\n";
		    }
		}
		die "Configuration file does not have all required fields for an entry with no source or destination.\n";
	    }

	    # Validate that all dirlists have the same number of args.
	    if ($#source_dirlist != $#destination_dirlist) {
		die "Configuration file entry for source $source and destination $destination has differing numbers of source directories and destination directories.\n";
	    }
	    if ($#source_dirlist != $#rsync_options) {
		die "Configuration file entry for source $source and destination $destination has differing numbers of source directories and rsync options.\n";
	    }
	    if ($#source_dirlist != $#source_sudo) {
		die "Configuration file entry for source $source and destination $destination has differing numbers of source directories and source-sudo entries.\n";
	    }
	    if ($#source_dirlist != $#destination_sudo) {
		die "Configuration file entry for source $source and destination $destination has differing numbers of source directories and destination-sudo entries.\n";
	    }
	    if ($have_source_setup && $#source_dirlist != $#source_setup) {
		die "Configuration file entry for source $source and destination $destination has differing numbers of source directories and source-setup entries.\n";
	    }
	    if ($have_source_cleanup && $#source_dirlist != $#source_cleanup) {
		die "Configuration file entry for source $source and destination $destination has differing numbers of source directories and source-cleanup entries.\n";
	    }
	    if ($have_dest_setup && $#source_dirlist != $#dest_setup) {
		die "Configuration file entry for source $source and destination $destination has differing numbers of source directories and dest-setup entries.\n";
	    }
	    if ($have_dest_cleanup && $#source_dirlist != $#dest_cleanup) {
		die "Configuration file entry for source $source and destination $destination has differing numbers of source directories and dest-cleanup entries.\n";
	    }

	    # Validate glob + sudo combination
	    if ($have_source_sudo) {
		for (my $idx = 0; $idx <= $#source_dirlist; $idx++) {
		    if ($source_sudo[$idx] eq 'yes' && $source_dirlist[$idx] =~ /\*/) {
			die "Configuration error: source-dirlist entry \"$source_dirlist[$idx]\" contains glob pattern (*) but source-sudo is 'yes'.\n" .
			    "Glob expansion happens before privilege escalation, so the directory must be readable by $RSYNC_USER.\n" .
			    "Either: (1) make directory readable by $RSYNC_USER and set source-sudo to 'no', or (2) use explicit file paths instead of globs.\n";
		    }
		}
	    }

	    if (((($CLIENT && $push) || (!$CLIENT && !$push)) &&
		 ($HOSTNAME eq $source && $other_host eq $destination)) ||
		((($CLIENT && !$push) || (!$CLIENT && $push)) &&
		 ($HOSTNAME eq $destination && $other_host eq $source))) {
		$found_match = 1;
		last;
	    }
	    else {
		$have_source = 0;
		if ($have_next_source) {
		    $have_source = 1;
		    $source = $next_source;
		    $have_next_source = 0;
		    $next_source = "";
		}
		$have_destination = 0;
		$have_source_dirlist = 0;
		undef @source_dirlist;
		$have_destination_dirlist = 0;
		undef @destination_dirlist;
		$have_rsync_options = 0;
		undef @rsync_options;
		$have_ssh_identity = 0;
		$ssh_identity = '';
		$have_source_sudo = 0;
		undef @source_sudo;
		$have_destination_sudo = 0;
		undef @destination_sudo;
		$have_source_setup = 0;
		undef @source_setup;
		$have_source_cleanup = 0;
		undef @source_cleanup;
		$have_dest_setup = 0;
		undef @dest_setup;
		$have_dest_cleanup = 0;
		undef @dest_cleanup;
	    }
	}
    }
    close (CONFIG);

    if (!$found_match) {
	if ($DEBUG) {
	    if ($CLIENT) {
		print "DEBUG: hostname=$HOSTNAME, other_host=$other_host, CLIENT=$CLIENT, push=$push, both=$both\n";
	    }
	    else {
		$time = time();
		$time = localtime ($time);

		my $LOG = open_log_file_secure();

		print $LOG "$time $0 hostname=$HOSTNAME, other_host=$other_host, CLIENT=$CLIENT, push=$push, both=$both, No matching entry in config file.\n";
		close ($LOG);
	    }
	}
	die "No matching entry in config file. $CONFIG_FILE\n";
    }
}

# Client function.
sub exec_client {
    if ($ssh_identity ne '') {
	if (-e "$ssh_identity") {
	    $rsync_identity = $ssh_identity;
	}
	else {
	    die "Cannot find specified ssh-identity. $ssh_identity\n";
	}
    }
    elsif (-e $RSYNC_ED25519_IDENTITY) {
	$rsync_identity = $RSYNC_ED25519_IDENTITY;
    }
    elsif (-e $RSYNC_ECDSA_IDENTITY) {
	$rsync_identity = $RSYNC_ECDSA_IDENTITY;
    }
    else {
	die "Cannot find .ssh/(ed25519 ecdsa)_id. RSA and DSA keys are no longer supported.\n"
    }
    $ENV{'RSYNC_RSH'} = "$SSH -i $rsync_identity";
    if ($DEBUG) {
	$ENV{'RSYNC_RSH'} = "$SSH -vv -i $rsync_identity";
    }

    @rsync_command = ($RSYNC, '-avz');

    if ($push) {
	$source_info = "";
	$destination_info = "$RSYNC_USER\@$other_host:"; 
	@setup_command = @source_setup;
	@cleanup_command = @source_cleanup;
	@allowed_paths = @source_dirlist;
    }
    else {
	$source_info = "$RSYNC_USER\@$other_host:";
	$destination_info = "";
	@setup_command = @dest_setup;
	@cleanup_command = @dest_cleanup;
	@allowed_paths = @destination_dirlist;
    }

    # Use pledge and unveil to restrict access for client. stdio already included.
    if ($^O eq 'openbsd') {
	my ($path, $command);
	
	pledge ('rpath', 'wpath', 'cpath', 'exec', 'unveil', 'proc') || die "Cannot pledge promises. $!\n";

	# Unveil dir paths.
	foreach $path (@allowed_paths) {
	    my $unveil_path = dirname ($path) if ($path !~ /\/$/);
	    $unveil_path //= $path;
	    if ($push) {
		unveil ($unveil_path, 'r');

	    }
	    else {
		unveil ($unveil_path, 'rwc');	
	    }
	}

	# Unveil commands.
	unveil ($RSYNC, 'rx');
	unveil ($SSH, 'rx');
	unveil ($RSYNC_USER_SSHDIR, 'r');
	unveil ($CONFIG_FILE, 'r');
	unveil ($LOG_FILE, 'rwc');
	unveil ($ZONEINFO_DIR, 'r');

	foreach $command (@setup_command) {
	    if ($command) {
		foreach my $cmd (split(/\s*;\s*/, $command)) {
		    my $cmd_path = $cmd;
		    $cmd_path =~ s/^(\S+).*$/$1/;
		    unveil ($cmd_path, 'rx');
		}
	    }
	}
	foreach $command (@cleanup_command) {
	    if ($command) {
		foreach my $cmd (split(/\s*;\s*/, $command)) {
		    my $cmd_path = $cmd;
		    $cmd_path =~ s/^(\S+).*$/$1/;
		    unveil ($cmd_path, 'rx');
		}
	    }
	}
	
	# Would be better to check for need first.
	if ($USE_SUDO) {
	    unveil ($SUDO, 'rx');
	}
	else {
	    unveil ($DOAS, 'rx');
	}

	# If both, only lock down AFTER the pull has completed, since
	# pull requires more access on client side. (This could also be
	# a bit better by intentionally removing unneeded paths after
	# the pull is complete.)
	unveil () if (!$both || $push);
    }

    for ($idx = 0; $idx <= $#source_dirlist; $idx++) {
	if ($setup_command[$idx]) {
	    print "setup: $setup_command[$idx]\n" if ($DEBUG);
	    exec_command ($setup_command[$idx], $ALLOW_MULTIPLE);
	}
	if (($push && $source_sudo[$idx]) || (!$push && $destination_sudo[$idx])) {
	    if ($USE_SUDO) {
		@this_command = ($SUDO, @rsync_command);
	    }
	    else {
		@this_command = ($DOAS, @rsync_command);
	    }
	}
	else {
	    @this_command = (@rsync_command);
	}

	# Do any glob expansion required on client side, don't rely on shell.
	my @sources = ($source_info eq '' && $source_dirlist[$idx] =~ /\*/)
	    ? bsd_glob("$source_info$source_dirlist[$idx]", GLOB_LIMIT|GLOB_NOCHECK|GLOB_BRACE|GLOB_QUOTE)
	    : ("$source_info$source_dirlist[$idx]");
	
	if ($rsync_options[$idx]) {
	    print "@this_command $rsync_options[$idx] @sources $destination_info$destination_dirlist[$idx]\n" if ($DEBUG);
	    system (@this_command, split (/\s+/, $rsync_options[$idx]), @sources, "$destination_info$destination_dirlist[$idx]");
	}
	else {
	    print "@this_command @sources $destination_info$destination_dirlist[$idx]\n" if ($DEBUG);
	    system (@this_command, @sources, "$destination_info$destination_dirlist[$idx]");
	}

        if ($cleanup_command[$idx]) {
            print "cleanup: $cleanup_command[$idx]\n" if ($DEBUG);
	    exec_command ($cleanup_command[$idx], $ALLOW_MULTIPLE);
        }
    }
}

# Server function.
sub exec_server {
    # Push.
    if ($push) {
	@allowed_paths = @destination_dirlist;
	@setup_command = @dest_setup;
	@cleanup_command = @dest_cleanup;
    }
    # Pull.
    else {
	@allowed_paths = @source_dirlist;
	@setup_command = @source_setup;
	@cleanup_command = @source_cleanup;
    }

    my $allowed_prefix_re = ($push) ? '--server' : '--server --sender'; # used in regexp
    @allowed_prefix = ($push) ? ('--server') : ('--server', '--sender');

    # Use pledge and unveil to restrict access for server. stdio already included.
    if ($^O eq 'openbsd') {
	my ($path, $command);

	pledge ('rpath' , 'wpath', 'cpath', 'unveil', 'exec', 'proc') || die "Cannot pledge promises. $!\n";;

	unveil ($RSYNC, 'rx');
	unveil ($CONFIG_FILE, 'r');
	unveil ($LOG_FILE, 'rwc');
	unveil ($ZONEINFO_DIR, 'r');
	foreach $path (@allowed_paths) {
	    my $unveil_path = dirname ($path) if ($path !~ /\/$/);
	    $unveil_path //= $path;
	    if ($push) {
		unveil ($unveil_path, 'rwc');
	    }
	    else {
		unveil ($unveil_path, 'r');
	    }
	}

	foreach $command (@setup_command) {
	    if ($command) {
		foreach my $cmd (split(/\s*;\s*/, $command)) {
		    my $cmd_path = $cmd;
		    $cmd_path =~ s/^(\S+).*$/$1/;
		    unveil ($cmd_path, 'rx');
		}
	    }
	}
	foreach $command (@cleanup_command) {
	    if ($command) {
		foreach my $cmd (split(/\s*;\s*/, $command)) {
		    my $cmd_path = $cmd;
		    $cmd_path =~ s/^(\S+).*$/$1/;
		    unveil ($cmd_path, 'rx');
		}
	    }
	}

	# Would be better to check for need first.
	if ($USE_SUDO) {
	    unveil ($SUDO, 'rx');
	}
	else {
	    unveil ($DOAS, 'rx');
	}

	unveil ();
    }

    $command = $ENV{'SSH_ORIGINAL_COMMAND'};
    die "Uninitialized environment variable SSH_ORIGINAL_COMMAND.\n" if (!defined ($command));
    $time = time();
    $time = localtime ($time);

    my $LOG = open_log_file_secure();

    print $LOG "$time $0 $ENV{'SSH_CONNECTION'} ***New command issued: $command\n";

    if ($command =~ /^rsync $allowed_prefix_re\s*([\s\w\.-]*)\s+\.\s+(.*)$/) {
	$options = $1;
	$path = $2;

	$allowed_this_path = 0;
	for ($idx = 0; $idx <= $#allowed_paths; $idx++) {
	    if ($push) {
		$need_sudo = $destination_sudo[$idx];
	    }
	    else {
		$need_sudo = $source_sudo[$idx];
	    }
	    # Requires strict matching (e.g., can't have dir with terminal slash in client config but not in server config).
	    if ($path eq $allowed_paths[$idx]) {
		$allowed_this_path = 1;
		if (server_options_match ($LOG, $options, $rsync_options[$idx])) {
		    if ($setup_command[$idx]) {
			print $LOG "$time Setup command: $setup_command[$idx]\n";
			exec_command ($setup_command[$idx], $ALLOW_MULTIPLE);
		    }

		    $time = time();
		    $time = localtime ($time);
		    if ($need_sudo) {
			@this_command = ($USE_SUDO ? $SUDO : $DOAS, $RSYNC);
		    }
		    else {
			@this_command = ($RSYNC);
		    }

		    my @paths = ($path =~ /\*/)
			? bsd_glob($path, GLOB_LIMIT|GLOB_NOCHECK|GLOB_BRACE|GLOB_QUOTE)
			: ($path);
		    
		    print $LOG "$time Command issued: @this_command @allowed_prefix $options . @paths\n";
		    system (@this_command, @allowed_prefix, split (/\s+/, $options), '.', @paths);
		    print $LOG "$time Command executed: @this_command @allowed_prefix $options . @paths\n";

		    if ($cleanup_command[$idx]) {
			print $LOG "$time Cleanup command: $cleanup_command[$idx]\n";
			exec_command ($cleanup_command[$idx], $ALLOW_MULTIPLE);
		    }

		}
		else {
		    print $LOG "$time Disallowed options: $options\n";
		}
		last;
	    }
	}

	$time = time();
	$time = localtime ($time);
	print $LOG "$time Disallowed path: $path\n" if (!$allowed_this_path);
    }
    else { # Doesn't match regexp.
	print $LOG "$time Disallowed command: $command\n";
        print $LOG "    Permitted:   rsync @allowed_prefix <options> . <dir>\n";
	if ($push) {
	    print $LOG "    Running as push.\n";
	}
	else {
	    print $LOG "    Running as pull.\n";
	}
    }

    close ($LOG);
}

### Utility subroutines.

# Subroutine to die with security error message and log it.
sub security_die {
    my ($message) = @_;
    
    # Try to log the security event
    eval {
	my $LOG = open_log_file_secure();
	my $timestamp = localtime (time());
	my $remote = $ENV{'SSH_CONNECTION'} || 'local';
	print $LOG "$timestamp SECURITY [$remote]: $message\n";
	close($LOG);
    };
    # Ignore errors during logging - still die even if logging fails
    
    die "SECURITY: $message\n";
}

# Subroutine to verify config file ownership and permissions.
sub validate_config_file_security {
    my ($config_dir, $dir_mode, $dir_uid, $dir_gid, $dir_perms);
    my ($uid, $gid, $mode, $perms);
    my ($rsync_gid);

    # Get directory path for config file.
    $config_dir = dirname ($CONFIG_FILE);

    # Directory must exist and be accessible.
    if (!-e $config_dir) {
	security_die ("Config directory $config_dir does not exist.");
    }

    if (!-d $config_dir) {
	security_die ("Config directory path $config_dir is not a directory.");
    }

    # Get rsync user's GID for validation.
    $rsync_gid = getgrnam ($RSYNC_USER);
    if (!defined ($rsync_gid)) {
	security_die ("Cannot determine GID for group $RSYNC_USER.");
    }

    # Check directory ownership and permissions.
    ($dir_mode, $dir_uid, $dir_gid) = (stat ($config_dir))[2,4,5];

    if (!defined ($dir_mode)) {
	security_die ("Cannot stat config directory $config_dir. $!");
    }

    # Config file must be owned by root (uid 0)
    if ($dir_uid != 0) {
        security_die ("Config directory $config_dir must be owned by root (currently uid $dir_uid)");
    }
    
    if ($dir_gid != $rsync_gid) {
        security_die ("Config directory $config_dir must be group-owned by $RSYNC_USER (expected gid $rsync_gid, found $dir_gid)");
    }
    
    $dir_perms = $dir_mode & 07777;
    if ($dir_perms != 0750) {
        security_die (sprintf("Config directory $config_dir has wrong permissions (expected 0750, found %04o)", $dir_perms));
    }

    # Directory validated, now check file
    if (!-e $CONFIG_FILE) {
        security_die ("Config file $CONFIG_FILE does not exist.");
    }

    if (!-f $CONFIG_FILE) {
        security_die ("Config file path $CONFIG_FILE is not a regular file");
    }

    # Check file ownership and permissions.
    ($mode, $uid, $gid) = (stat($CONFIG_FILE))[2,4,5];

    if (!defined($mode)) {
        security_die ("Cannot stat config file $CONFIG_FILE: $!");
    }

    # Config file must be owned by root.
    if ($uid != 0) {
        security_die ("Config file $CONFIG_FILE must be owned by root (currently uid $uid)");
    }
    
    # Config file must be group-owned by rsync group
    if ($gid != $rsync_gid) {
        security_die ("Config file $CONFIG_FILE must be group-owned by $RSYNC_USER (expected gid $rsync_gid, found $gid).");
    }
    
    # Extract permission bits
    $perms = $mode & 07777;
    
    # Must be 0640 (rw-r-----)
    if ($perms != 0640) {
        security_die (sprintf("Config file must have permissions 0640 (currently %04o).", $perms));
    }

}

# Subrouting to open log file and create with 0600 permissions.
sub open_log_file_secure {
    my $log_fh;
    # Create with 0600 permissions (rw-------)
    if (!sysopen($log_fh, $LOG_FILE, O_WRONLY|O_APPEND|O_CREAT, 0600)) {
        die "Cannot open log file $LOG_FILE: $!\n";
    }
    return $log_fh;
}

# Subroutine to complain if we already have one of these.
sub check_arg {
    my ($entry, $arg, $have) = @_;

    if ($have) {
	die "Configuration file has multiple \"$entry\" fields. $arg\n";
    }
}

# Subroutine to return array of directories from a list.
# Also used by command lists, so only validate when requested.
# "" becomes 0.
sub dirlist {
    my ($arg, $validate) = @_;
    my (@dirlist, $item);

    @dirlist = split (/,\s+/, $arg);
    foreach $item (@dirlist) {
	if ($item eq "\"\"") {
	    $item = 0;
	}
	elsif ($validate && !valid_dir ($item)) {
	    die "Configuration file has invalid dir \"$item\" in dirlist \"$arg\".\n";
	}
    }
    return (@dirlist);
}

# Subroutine to do some minimal validation on dirs in dirlist.
sub valid_dir {
    my ($path) = @_;
    my ($dir, $file);

    # No directory traversal.
    if ($path =~ /\.\./) {
	return 0;
    }

    # Must be absolute path.
    if ($path !~ /^\//) {
	return 0;
    }

    $dir = dirname ($path);
    $file = basename ($path);

    # Check dir portion.
    if ($dir !~ /^[A-Za-z0-9\._\-\/]+$/) {
	return 0;
    }

    # If path ends in /, treat file like dir.
    if ($path =~ /\/$/) {
	if ($file !~ /^[A-Za-z0-9\._\-\/]+$/) {
	    return 0;
	}
	return 1;
    }

    # Otherwise, allow *.
    elsif ($file !~ /^[A-Za-z0-9\._\-\/\*]+$/) {
	return 0;
    }

    return 1;
}

# Subroutine to return array of options from rsync_options.
sub optionlist {
    my ($arg) = @_;
    my (@wordlist, $word, @optionlist);

    @wordlist = split (/\s+/, $arg);
    foreach $word (@wordlist) {
	if (substr ($word, 0, 1) eq '-') {
	    push (@optionlist, $word);
	}
	elsif ($#optionlist > -1) {
	    $optionlist[$#optionlist] .= " $word";
	}
	else {
	    push (@optionlist, "$word");
	}
    }
    return (@optionlist);
}

# Subroutine to return "yes" or "no".
sub yes_or_no {
    my ($entry, $arg) = @_;

    return (1) if ($arg eq 'yes');
    return (0) if ($arg eq 'no');

    die "Invalid $entry field in config file.  Must be \"yes\" or \"no\", not \"$arg\".\n";
}

# Subroutine to return array of "yes" or "no" options.
sub yes_or_no_list {
    my ($entry, $arg) = @_;
    my (@in_list, $in, @yn_list);

    @in_list = split (/,\s+/, $arg);
    foreach $in (@in_list) {
	push (@yn_list, yes_or_no ($entry, $in));
    }

    return (@yn_list);
}

# Subroutine to return 1 if server options match.
# All supplied options must be from list of rsync_options, but not all
# rsync_options need be in the supplied options.
# If --relative is in the options, then we allow $RELATIVE_SERVER_OPTIONS,
# otherwise we allow $STANDARD_SERVER_OPTIONS.
sub server_options_match {
    my ($log_fh, $supplied_options, $avail_options) = @_;
    my (@split_supplied_options, @split_avail_options, $option);

    @split_supplied_options = optionlist ($supplied_options);

    @split_avail_options = @POSSIBLE_SERVER_OPTIONS;

    push (@split_avail_options, &optionlist ($avail_options)) if (defined ($avail_options));

    foreach $option (@split_supplied_options) {
	if (!grep (/^$option$/, @split_avail_options)) {
	    print $log_fh "$time Disallowed individual option: $option\n";
	    return 0;
	}
    }

    return 1;
}

# Subroutine to execute a command without invoking shell.
# Note: Arguments cannot contain whitespace. Use scripts for complex commands.
sub exec_command {
    my ($command, $allow_multiple) = @_;
    my (@commands, @command);

    if ($allow_multiple && $command =~ /;/) {
	@commands = split (/\s*;\s*/, $command);
    }
    else {
	push (@commands, $command);
    }

    foreach $command (@commands) {
	@command = split (/\s+/, $command);
	system (@command);
    }
}
