4 of 4

Application DebuggingLesson 4 of 4

Works in my shell, fails as a service

Reproduce a service's real environment, and separate unit, permission, SELinux, network and sandbox causes.

  • systemctl cat
  • systemctl show
  • systemd-run
  • systemd-run -t
  • systemd-analyze security
  • journalctl -u
  • ausearch -m avc
  • sealert
  • audit2why
  • systemctl edit
  • EnvironmentFile
  • ProtectSystem

55 min3 exercises

After this lesson you can

  • List the differences between an interactive shell and a systemd service, and test each one.
  • Run a command in an environment that matches a unit with systemd-run, to reproduce a failure.
  • Separate a unit problem, a file permission problem, an SELinux denial, a port problem and a sandbox restriction using evidence.

The idea

When a program works from your terminal but fails under systemctl start, the program is usually not the problem. The context changed. A terminal gives a program your identity, your environment, your working directory and a login session. A service gets none of that, unless the unit asks for it.

What differs

AspectInteractive shellsystemd service
UserYou (or root via sudo)User= in the unit, otherwise root
EnvironmentYour login environment, PATH, HOME, LANGVery small: PATH is basic; nothing from ~/.bashrc; add with Environment= / EnvironmentFile=
Working directoryWhere you are/, unless WorkingDirectory=
Input/outputA terminalNo terminal; output goes to the journal
Limitspam_limits from loginLimitNOFILE= and friends
SELinux domainunconfined_t for adminsThe domain defined by the executable’s label (for example httpd_t)
Filesystem viewThe whole systemMay be restricted: ProtectSystem=, ProtectHome=, PrivateTmp=, ReadWritePaths=
NetworkAnyMay be restricted: PrivateNetwork=, IPAddressDeny=
TimingYou start it when readyStarted at boot, perhaps before the network or a mount is ready

An administrator logged in as root is usually unconfined_t: SELinux does not block that user. The same program as a service is confined, which is the most common reason it “works for me as root and fails as a service”.

Reproducing the service context

Do not guess. Run the same command in the same context:

sudo systemd-run --wait --pipe --uid=svcapp /usr/local/bin/svcapp        # as the service user, minimal environment
sudo systemd-run -t --uid=svcapp --property=WorkingDirectory=/ /bin/bash  # an interactive shell inside a service-like context
sudo -u svcapp env -i /usr/local/bin/svcapp                              # simpler: other user, empty environment
systemctl show UNIT -p User,Group,WorkingDirectory,Environment,ExecStart # what the unit really has
systemctl cat UNIT                                                       # the unit plus drop-ins

systemd-run creates a transient unit, so it has the same cgroup and limit handling as a real service, and --property= lets you copy settings from the real unit (--property=ProtectSystem=strict reproduces a sandbox problem).

The four layers

When you have the same failure by hand, work through layers in order of cost:

  1. Unit and process: exit status (status=203/EXEC, 3/...), journalctl -u UNIT, systemctl show UNIT. Does the executable exist and is it executable, is User= valid, is the Type= right?
  2. File permissions and ownership: sudo -u USER ls -l PATH, namei -l /full/path/to/file (shows permissions of every component of a path: a directory on the way may deny access).
  3. SELinux: ls -lZ, ps -eZ | grep PROCESS, ausearch -m avc -ts recent. Never setenforce 0 as a fix.
  4. Network: ss -tlnp, firewall-cmd --list-all, semanage port -l for the port label.
  5. Sandbox directives in the unit: systemd-analyze security UNIT lists the restrictions and a score; ProtectSystem=strict makes the file system read-only for the service except for ReadWritePaths=.

Try it

The environment, side by side

A tiny script prints who it runs as, its directory, HOME, PATH and a variable.

MYAPP_MODE=dev /usr/local/bin/envdump
sudo systemd-run --wait --pipe /usr/local/bin/envdump
user=alex cwd=/home/alex HOME=/home/alex PATH=/home/alex/.local/bin:/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin
MYAPP_MODE=dev
Running as unit: run-r3b7e1a0e2b8c4cb9a8c0e2f2d3d4e5f6.service
user=root cwd=/ HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
MYAPP_MODE=unset

(Representative output.) Notice the working directory /, no personal PATH entries, and the missing variable. A script that calls a tool in ~/bin, reads a relative config.ini, or expects MYAPP_MODE fails under systemd and works for you. Fix it in the unit, not by relying on a shell: absolute paths, WorkingDirectory=, Environment= or EnvironmentFile=/etc/sysconfig/myapp.

Reproducing as the service user

sudo -u svcapp /usr/local/bin/svcapp; echo "rc=$?"
cat: /etc/svcapp/secret.conf: Permission denied
rc=3

Now the journal’s status=3 makes sense, and the fix is a narrow one: give group read access to the file for the service’s group, not world-read.

sudo chown root:svcapp /etc/svcapp/secret.conf
sudo chmod 640 /etc/svcapp/secret.conf
namei -l /etc/svcapp/secret.conf
f: /etc/svcapp/secret.conf
dr-xr-xr-x root root /
drwxr-xr-x root root etc
drwxr-xr-x root root svcapp
-rw-r----- root svcapp secret.conf

namei -l is a very good command for permission puzzles: it prints the permissions of each directory on the path, because a missing x bit on any of them blocks access to everything below it.

SELinux and sandbox evidence

Permissions fine, still Permission denied? Then ask SELinux and the sandbox:

sudo ausearch -m avc -ts recent | tail -4
systemctl cat webdemo | grep -E 'Protect|ReadWrite|PrivateTmp'
sudo systemd-analyze security webdemo --no-pager | tail -5
type=AVC msg=audit(1730718311.221:412): avc:  denied  { name_bind } for  pid=4455 comm="webdemo" src=8443 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:unreserved_port_t:s0 tclass=tcp_socket permissive=0
ProtectSystem=strict
→ Overall exposure level for webdemo.service: 4.2 OK 🙂

(Representative output.) The AVC line has the story in one place: who (scontext, domain httpd_t), what (tcontext, the port is unreserved_port_t), and which operation (name_bind on a tcp_socket). Allowing it means labelling the port: semanage port -a -t http_port_t -p tcp 8443. audit2why (or sealert -a /var/log/audit/audit.log) translates AVC lines into plain suggestions, but read them: do not pipe audit2allow into a policy module without understanding what it permits.

When the unit still fails

SymptomCauseFix
status=203/EXECPath wrong, no execute bit, bad label, missing interpreterls -lZ, file, first line of the script
status=217/USERUser= does not existCreate it or fix the name
status=200/CHDIRWorkingDirectory= missingCreate the directory
Starts, then exits immediately with code 0Type=simple but the program forks and exitsType=forking with PIDFile=, or run in foreground
Works after a manual start, fails at bootStarts before network or mountAfter=network-online.target, RequiresMountsFor=/data
Read-only file system for a path the user can writeProtectSystem=, ProtectHome=ReadWritePaths=
Cannot reach the networkPrivateNetwork=, firewall, SELinux connect denialCheck each; booleans such as httpd_can_network_connect
Cannot write to /tmp shared with another servicePrivateTmp=trueUse a real shared directory
Environment variable missingNot set in the unitEnvironment=, EnvironmentFile=

Going further

Reading the whole unit. systemctl cat and systemctl show are the first stop, and drop-ins in /etc/systemd/system/UNIT.d/ are where surprises hide. systemd-delta lists every unit that overrides a packaged one. Beyond the exam: systemd-analyze security scores a unit’s exposure and suggests directives to tighten, and for confined services, sandbox-style restrictions are cheaper than writing SELinux policy. Learn to read AVC denials fluently before ever writing policy.

Practice

0 of 3 done

Do these on your own system. They get harder. Check each result, then tick it off.

  1. Warm-up

    Same command, different result

    Setup:

    sudo tee /usr/local/bin/envdump >/dev/null <<'EOT'
    #!/bin/bash
    echo "user=$(id -un) cwd=$PWD HOME=$HOME PATH=$PATH"
    echo "MYAPP_MODE=${MYAPP_MODE:-unset}"
    EOT
    sudo chmod 755 /usr/local/bin/envdump

    Run it from your shell, then as a transient service with systemd-run --wait --pipe. List every difference and say which of them could break a real application.

    Check your result
    • The shell run shows your user, your HOME, a long PATH and your own MYAPP_MODE if you set it.
    • The service run shows user=root, cwd=/, HOME unset or /root, a short PATH and MYAPP_MODE=unset.
    Hint

    sudo systemd-run --wait --pipe /usr/local/bin/envdump runs the program as a service and shows its output. A service starts with a minimal environment and working directory /. Variables come only from Environment=, EnvironmentFile= and what systemd itself provides.

    Solution
    MYAPP_MODE=dev /usr/local/bin/envdump
    sudo systemd-run --wait --pipe /usr/local/bin/envdump
    sudo systemd-run --wait --pipe --setenv=MYAPP_MODE=prod --property=User=nobody /usr/local/bin/envdump
  2. Practice

    Reproduce a unit failure by hand

    Setup:

    sudo useradd -r -s /sbin/nologin svcapp 2>/dev/null
    sudo tee /usr/local/bin/svcapp >/dev/null <<'EOT'
    #!/bin/bash
    cat /etc/svcapp/secret.conf > /dev/null || exit 3
    echo "svcapp started"; sleep 3600
    EOT
    sudo chmod 755 /usr/local/bin/svcapp
    sudo mkdir -p /etc/svcapp && echo key=1 | sudo tee /etc/svcapp/secret.conf && sudo chmod 600 /etc/svcapp/secret.conf
    sudo tee /etc/systemd/system/svcapp.service <<'EOT'
    [Service]
    User=svcapp
    ExecStart=/usr/local/bin/svcapp
    EOT
    sudo systemctl daemon-reload; sudo systemctl start svcapp; systemctl status svcapp --no-pager | head -8

    The unit fails with status=3. Without changing the unit first, reproduce the failure as the service user from a shell, find the cause, and fix it with the least privilege (do not make the file world-readable).

    Check your result
    • sudo -u svcapp cat /etc/svcapp/secret.conf fails with Permission denied.
    • After chown root:svcapp /etc/svcapp/secret.conf; chmod 640 /etc/svcapp/secret.conf, systemctl is-active svcapp prints active.
    • ls -l /etc/svcapp/secret.conf shows mode 640, group svcapp.
    Hint

    sudo -u svcapp COMMAND runs a command as the service account. systemd-run -t --uid=svcapp COMMAND is closer to a real service (same cgroup rules, minimal environment).

    Solution
    sudo -u svcapp /usr/local/bin/svcapp; echo "rc=$?"
    sudo -u svcapp cat /etc/svcapp/secret.conf
    ls -l /etc/svcapp/secret.conf
    sudo chown root:svcapp /etc/svcapp/secret.conf && sudo chmod 640 /etc/svcapp/secret.conf
    sudo systemctl restart svcapp; systemctl is-active svcapp
  3. Challenge

    Permission, label, port, sandbox

    A service webdemo.service runs /usr/local/bin/webdemo as user webdemo. It must serve files from /srv/webdemo on TCP port 8443, and write a log to /var/log/webdemo/app.log. After a deployment it fails to start. For each of these four independent faults, write the evidence you would collect and the fix: (1) /srv/webdemo is owned by root with mode 700; (2) the files in /srv/webdemo have the SELinux type default_t; (3) port 8443 is not labelled for the web server type that the service domain may bind; (4) the unit has ProtectSystem=strict and does not list /var/log/webdemo under ReadWritePaths=. State which tool shows each: ls -l, ls -lZ, ausearch -m avc -ts recent, semanage port -l, journalctl -u, systemd-analyze security.

    Check your result
    • (1) Permission denied in the journal; sudo -u webdemo ls /srv/webdemo reproduces it; fix with chown/chmod.
    • (2) AVC denial in ausearch -m avc; ls -lZ shows default_t; fix semanage fcontext -a -t httpd_sys_content_t '/srv/webdemo(/.*)?' then restorecon -Rv.
    • (3) AVC denial for name_bind; semanage port -a -t http_port_t -p tcp 8443.
    • (4) Read-only file system when writing the log; add ReadWritePaths=/var/log/webdemo (and create the directory with the right owner).
    Hint

    Four layers, four tools. Permissions first (cheapest to test), then SELinux (ausearch), then the network layer (ss, firewall), then sandbox directives (systemctl cat, systemd-analyze security). Change one layer at a time and retry.

    Solution
    journalctl -u webdemo --no-pager | tail
    sudo -u webdemo ls /srv/webdemo; ls -ld /srv/webdemo
    sudo chown -R webdemo:webdemo /srv/webdemo && sudo chmod 750 /srv/webdemo
    ls -lZ /srv/webdemo | head -3
    sudo ausearch -m avc -ts recent | tail -5
    sudo semanage fcontext -a -t httpd_sys_content_t '/srv/webdemo(/.*)?' && sudo restorecon -Rv /srv/webdemo
    sudo semanage port -l | grep -w http_port_t
    sudo semanage port -a -t http_port_t -p tcp 8443
    systemctl cat webdemo | grep -E 'Protect|ReadWrite'
    sudo mkdir -p /var/log/webdemo && sudo chown webdemo: /var/log/webdemo
    sudo systemctl edit webdemo       # [Service]  ReadWritePaths=/var/log/webdemo
    sudo systemctl restart webdemo; systemctl is-active webdemo