Running rsync from a systemd timer on Bazzite (SELinux rsync_t domain)
You'll probably never need this exact setup. The reason it's written down is the SELinux mechanics underneath: domain transitions, audit2allow's blind spots, dontaudit, label inheritance, and how to actually figure out why SELinux is blocking you instead of just flipping a boolean.
Likely you will never do this. But the SELinux gotchas I hit along the way are worth writing down even if the original task isn't. So treat this less as a recipe and more as a tour of how SELinux actually behaves when something it didn't expect tries to read or write under /home, and how to figure out why it's blocking you instead of just flipping a boolean and moving on.
For context: I don't normally run SELinux. On a desktop, why would you? ;-) The benefit-to-friction ratio just isn't there for me on a single-user box. But Bazzite ships it enforcing by default, and I'd rather work with the policy than turn it off, so when this hit I leaned in.
The setup that triggered it: two user accounts on the same Bazzite install, call them username1 and username2. Username2 already had a working backup. Username1 wanted their savegames included in it. The obvious move would be to set up backups on username1 too, but I already had this thing running on username2, so instead I wrote a systemd timer that runs rsync as root from username1's home into a folder under username2, and let username2's existing backup pick it up from there. I like a challenge but also don't want to reinvent the wheel. While writing this post I'm already regretting it. But hey, here we are.
And SELinux had thoughts.
A systemd timer that runs rsync as root does not behave like rsync from a sudo shell, even though the UID is the same. Fedora's SELinux policy labels /usr/bin/rsync as rsync_exec_t and ships a type_transition that moves the process into the confined rsync_t domain whenever it's launched from a system context (init_t, initrc_t, etc). rsync_t is intentionally narrow. It's the domain for the rsync server, not for ad-hoc file copies. Reading or writing anything under /home or /var/home is denied by default.
A sudo shell stays in unconfined_t and skips the transition. That's why "but it works when I run it by hand" is the usual entry point to this rabbit hole.
Below: the Bazzite-specific traps I hit, plus how I cut a small local SELinux module for it instead of flipping the rsync_full_access boolean.
# Confirm the transition is happening
sudo ausearch -m AVC -ts recent | grep rsync_t
If you see scontext=system_u:system_r:rsync_t:s0 paired with denied operations against user_home_t or data_home_t, that's the transition. Outside SELinux, a quick check is cat /proc/<pid>/attr/current on the running rsync.
# Two ways to fix it
# A. The blunt boolean
sudo setsebool -P rsync_full_access on
Persistent across reboots. Lets rsync_t access most non-security files anywhere. Fine for a personal box, too wide for anything shared.
# B. A scoped local policy module
What you want when rsync only needs to touch a specific subtree.
-
Put just the
rsync_tdomain into permissive so a full sync runs end-to-end and logs every denial it would have hit:sudo semanage permissive -a rsync_t -
Trigger the unit, ideally with a stale file in the destination so
--deleteactually fires:sudo touch /path/to/dest/STALE.tmp sudo systemctl start your-rsync.service -
Pull the AVCs and generate a module:
sudo ausearch -m AVC --start recent | audit2allow -M your_module sudo semodule -i your_module.pp -
Drop permissive and verify under enforcing:
sudo semanage permissive -d rsync_t sudo systemctl start your-rsync.service
For my use case (rsync mirror with --delete and --chown, source under one user's home, destination under another), the rules I needed were:
allow rsync_t data_home_t:dir { getattr open read remove_name search setattr write add_name };
allow rsync_t data_home_t:file { getattr setattr unlink read open create write };
allow rsync_t gconf_home_t:dir search;
allow rsync_t user_home_dir_t:dir search;
allow rsync_t user_home_t:dir search;
allow rsync_t self:capability { dac_override chown fowner fsetid };
One thing that bit me: data_home_t:file setattr. audit2allow only suggests it once rsync has actually tried to update mtimes on an existing destination file, so the first permissive pass usually misses it and you find out the next time you run.
# Debugging traps that ate hours
# audit2allow only sees AVCs that already happened
If rsync exits on the first denial, you only get that first denial in the audit log. Flip rsync_t permissive, let the run finish end to end, then collect. Otherwise the module ships incomplete and every enforcing run after that hands you one more rule, one denial at a time.
# dontaudit rules silently hide denials
The default policy contains dontaudit rules that suppress AVCs the policy author considered noise. When a denial is real but invisible:
sudo semodule -DB # disable all dontaudit, rebuild policy
# ... reproduce, debug ...
sudo semodule -B # restore
# Bazzite's default audit rule suppresses a class of events
/etc/audit/rules.d/audit.rules ships with -a never,task, which can hide events you'd expect to see. If AVCs are still missing after disabling dontaudit:
sudo auditctl -D # clear runtime audit rules
# ... reproduce, ausearch, debug ...
sudo augenrules --load # restore from rules.d
# Files inherit the label of whichever process created them
If your initial seed ran from a sudo shell (unconfined_t) and the later periodic runs come from the timer (rsync_t), the destination directory and the files inside can end up with different SELinux types. The dir-class denials in audit2allow won't match the file-class denials, which is confusing for about an hour. Fix with:
sudo chcon -R -t data_home_t /path/to/dest
# Authoring a .te file by hand
When you want to skip audit2allow and write the rules yourself:
sudo checkmodule -M -m -o your_module.mod your_module.te
sudo semodule_package -o your_module.pp -m your_module.mod
sudo semodule -i your_module.pp
sudo semodule -l | grep your_module confirms install. sudo semodule -r your_module removes it.
# Bazzite specifics worth flagging
- The
rsync_ttransition is Fedora policy, nothing Bazzite-specific. Same workflow applies on Silverblue, Kinoite, Bluefin, plain Fedora, and RHEL. /var/lib/selinux/targeted/active/modulesisn't where modules live on rpm-ostree systems; storage sits elsewhere under the ostree deployment. Usesemodule -landsemodule -E <name>to inspect rather thanfind.- Audit rules persist across
rpm-ostreeupgrades because/etcis a real directory, not part of the immutable image.
# Why bother with the module instead of the boolean
The boolean would have saved me hours. The module is safer: it lists exactly which types rsync_t is allowed to touch and nothing else. On a personal machine the boolean is probably fine. If you hit this on a server, now you know how to fix it. Although... if you really want to copy things between Username1 and Username2 on a server, I have other questions for you ;-)
# Related
- Bazzite: an immutable gaming-first Fedora variant
- Escaping the AWS SSM AppArmor profile with systemd-run
- rpm-ostree: rebase, pin, rollback