Skip to content

Give a Service Only the File Access It Needs

A temperature logger needs to read its settings and write readings. It does not need to rewrite its settings or share temporary files with every other program. Raspberry Pi service isolation lets you test those requirements separately.

Outcome: observe a readable but non-writable settings file, a writable results directory and a private /tmp view. Complete the first restriction lab. You need Python 3, a working systemd user manager, XDG_RUNTIME_DIR and permitted unprivileged user/mount namespaces. Run the lab on the Pi as your ordinary user, without sudo.

Plan the file access before choosing settings

For this disposable logger, predict these results:

Operation Ordinary shell Restricted service
Read the sample settings Allowed Allowed
Append to the sample settings Allowed Denied
Create a sample result Allowed Allowed
Read the shell's temporary marker at its /tmp path Allowed Not visible at that path

ReadOnlyPaths changes write access in the service's filesystem view. PrivateTmp gives it separate temporary directories. Neither makes readable settings confidential. See the authoritative filesystem restriction reference for prerequisites and limitations.

Check the environment

These checks are read-only:

1
2
3
4
systemctl --version
systemctl --user is-system-running
printf 'Runtime directory: %s\n' "${XDG_RUNTIME_DIR:-not set}"
command -v python3

Stop if the user bus is unavailable or the runtime directory is unset. Some Ubuntu configurations restrict unprivileged user namespaces through security policy; another device may reject this lab even if the Pi example works. Do not disable AppArmor or change kernel policy to make it pass. A namespace/startup error is an unsupported or failed exercise, not evidence that file writes were blocked.

Run the disposable filesystem lab

Copy the whole block. It uses a new directory inside your user runtime directory, not your home or system configuration. The second temporary file is only a marker, not a real secret. Paths are passed as program arguments rather than inserted into Python source.

(
    set -eu
    umask 077
    test "$(id -u)" -ne 0
    test -n "${XDG_RUNTIME_DIR:-}"
    test -d "$XDG_RUNTIME_DIR"

    files_lab=$(mktemp -d "$XDG_RUNTIME_DIR/pi-security-files.XXXXXXXX")
    printf 'Practice directory: %s\n' "$files_lab"
    mkdir "$files_lab/config" "$files_lab/state"
    printf 'sample-rate=5\n' > "$files_lab/config/settings.txt"
    printf 'shell baseline works\n' >> "$files_lab/config/settings.txt"
    tmp_marker=$(mktemp /tmp/pi-security-marker.XXXXXXXX)
    printf 'Host marker: %s\n' "$tmp_marker"
    printf 'host-only marker\n' > "$tmp_marker"

    systemd-run --user --wait --pipe --collect --service-type=exec \
        -p RuntimeMaxSec=10s -p TimeoutStopSec=2s \
        -p NoNewPrivileges=yes -p PrivateTmp=yes \
        -p 'SystemCallFilter=~@mount' \
        -p "ReadOnlyPaths=$files_lab/config" \
        /usr/bin/python3 -c '
import errno
from pathlib import Path
import sys

root = Path(sys.argv[1])
settings = root / "config/settings.txt"
expected = "sample-rate=5\nshell baseline works\n"
if settings.read_text() != expected:
    raise SystemExit("Unexpected settings before the write test")
print("Allowed: read settings")

try:
    with settings.open("a") as output:
        output.write("unexpected service write\n")
except OSError as error:
    if error.errno not in (errno.EROFS, errno.EACCES, errno.EPERM):
        raise
    print("Blocked: append to settings (errno %s)" % error.errno)
else:
    raise SystemExit("Unexpected write access: restriction not demonstrated")

with (root / "state/result.txt").open("x") as output:
    output.write("sample result\n")
print("Allowed: create result")

marker = Path(sys.argv[2])
with marker.open("x") as output:
    output.write("private service marker\n")
if marker.read_text() != "private service marker\n":
    raise SystemExit("Private marker readback failed")
print("Separate: same /tmp path contains the service marker")
' "$files_lab" "$tmp_marker"

    # The shell still sees the original host file, not the service marker.
    test "$(cat "$tmp_marker")" = 'host-only marker'
    test "$(cat "$files_lab/state/result.txt")" = 'sample result'
    printf 'sample-rate=5\nshell baseline works\n' | \
        cmp - "$files_lab/config/settings.txt"
    printf 'shell write still works\n' >> "$files_lab/config/settings.txt"
    printf 'PASS: service restrictions did not change shell access\n'

    # Remove only this run's known sample files and now-empty directories.
    rm -- "$tmp_marker" "$files_lab/config/settings.txt" "$files_lab/state/result.txt"
    rmdir -- "$files_lab/config" "$files_lab/state" "$files_lab"
)

Expect settings read allowed, settings append blocked, result creation allowed, separate temporary contents, and the final PASS. All must occur with a zero exit status. The shell checks that no extra settings line appeared, then successfully appends its own line: this is not a host-wide chmod.

The exclusive open("x") on the marker is deliberate. If the service still sees the shell's existing file, creation fails instead of silently overwriting it. The same pathname can name different files in different mount namespaces.

Understand what this does not isolate

The lab protects only the selected settings path from writes in the service view; it does not make the rest of the filesystem read-only. The results directory remains writable because it is outside that path, not because ReadWritePaths grants new Unix permissions. The filesystem permissions lesson still applies.

Private temporary paths do not hide the rest of your home, revoke inherited file descriptors, or create a VM. A different program running directly as your user retains your user's access. This is a cooperative demonstration of specific restrictions, not a safe environment for running hostile code.

For a real logger, decide its dedicated account, required device access and data ownership as well. Settings such as ProtectSystem, ProtectHome and StateDirectory can help build a broader policy, but do not paste them into working services without checking their dependencies and testing recovery. Never mount private keys or a container-management socket merely to make a program start. The systemd service guide covers persistent service setup separately.

Failures and cleanup

226/NAMESPACE, permission errors during setup, or a missing user bus mean stop and inspect the exact error. The @mount syscall filter also requires kernel/systemd support. Do not remove restrictions just to obtain a green result.

The command creates no persistent unit. On success it removes its samples; on failure set -e stops before the final PASS, and the printed practice directory/host marker may remain. Inspect those exact paths, remove only the three known sample files when present, then remove their empty directories. Do not use recursive deletion of /tmp or your runtime directory. The service's private temporary files are cleaned up after the service stops; the host marker is a separate file.

Checkpoint: why is a readable settings file still unsuitable for a secret the service must not learn? Why can your shell write the file after the service was denied?

Next: limit a service's resource use. Return to Service Isolation.

The complete lab was checked on the Pi listed in verification scope. It does not test every filesystem path or resistance to namespace escapes.

Donate