Root-owned deployment trees should not become writable by the account used for file transfer. A restricted remote receiver lets rsync update one approved directory while the login account remains unable to run arbitrary commands as root.
The rrsync helper validates server-mode requests and anchors every accepted path under a fixed directory. A root-owned wrapper supplies the remote command to rrsync after rejecting unexpected argument characters, while write-only mode, deletion blocking, and symlink munging narrow what the receiver can do.
Use a dedicated SSH key for the deployment account and prepare the protected directory before applying the elevation rule. The client addresses the receiver's restricted root rather than the server filesystem root, and both traversal outside that root and direct sudo access to the rsync binary must fail.
$ ssh deploy@server.example.com 'command -v rrsync' /usr/bin/rrsync
The wrapper below uses this exact path. Adjust /usr/bin/rrsync in the wrapper only when the remote package installs it elsewhere.
$ ssh deploy@server.example.com 'stat -c "%A %U:%G %n" /srv/www/app' drwxr-xr-x root:root /srv/www/app
The deploy account has no write bit on this root-owned directory.
#!/usr/bin/python3 import os import re import sys RESTRICTED_DIR = "/srv/www/app" SAFE_ARGUMENT = re.compile(r"^[A-Za-z0-9_.,=+:/@%*~-]+$") def fail(message: str) -> None: print(f"rsync-deploy: {message}", file=sys.stderr) raise SystemExit(64) remote_args = sys.argv[1:] if os.geteuid() != 0: fail("must run through sudo") if not remote_args or remote_args[0] != "--server": fail("receiver mode is required") if any(not SAFE_ARGUMENT.fullmatch(arg) for arg in remote_args): fail("unsupported remote argument") os.environ["SSH_ORIGINAL_COMMAND"] = "rsync " + " ".join(remote_args) os.execv( "/usr/bin/rrsync", ["rrsync", "-wo", "-no-del", "-munge", RESTRICTED_DIR], )
Keep this wrapper owned by root and non-writable by deploy. A deployment account that can replace the wrapper can replace the program that sudo runs as root.
$ sudo chmod 0755 /usr/local/sbin/rsync-deploy
deploy ALL=(root) NOPASSWD: /usr/local/sbin/rsync-deploy
Do not grant deploy passwordless access to /usr/bin/rsync. The wrapper and rrsync path checks are the boundary that prevents an arbitrary root receiver.
$ sudo visudo -cf /etc/sudoers.d/rsync-deploy /etc/sudoers.d/rsync-deploy: parsed OK
$ ssh deploy@server.example.com 'sudo -n /usr/bin/rsync --version' sudo: a password is required
The nonzero result is expected. Only /usr/local/sbin/rsync-deploy appears in the passwordless rule.
$ ssh deploy@server.example.com 'sudo -n /usr/local/sbin/rsync-deploy --server . /../forbidden/' /usr/bin/rrsync error: do not use .. in arg (anchor the path at the root of your restricted dir)
The deliberate traversal request must fail before a receiver starts.
$ rsync --archive --dry-run --itemize-changes --chown=root:root --rsync-path='sudo -n /usr/local/sbin/rsync-deploy' ./release/ deploy@server.example.com:/ .d..t...... ./ <f+++++++++ app.txt cd+++++++++ assets/ <f+++++++++ assets/index.html
The remote slash is the root of the rrsync restriction, not the server filesystem root. Review every itemized path before removing --dry-run.
Related: How to preview rsync changes before syncing
$ rsync --archive --itemize-changes --chown=root:root --rsync-path='sudo -n /usr/local/sbin/rsync-deploy' ./release/ deploy@server.example.com:/ .d..t...... ./ <f+++++++++ app.txt cd+++++++++ assets/ <f+++++++++ assets/index.html
$ ssh deploy@server.example.com 'stat -c "%U:%G %n" /srv/www/app/app.txt /srv/www/app/assets/index.html' root:root /srv/www/app/app.txt root:root /srv/www/app/assets/index.html
$ rsync --archive --checksum --dry-run --itemize-changes --chown=root:root --rsync-path='sudo -n /usr/local/sbin/rsync-deploy' ./release/ deploy@server.example.com:/
No output means rsync found no content or requested metadata changes, while the wrapper still confines the remote receiver to /srv/www/app.