Servers Security

Running SELinux in Enforcing Mode: Troubleshooting and Writing Custom Policy

The single most common SELinux “fix” on the internet is setenforce 0, and it is almost always wrong. SELinux is not a misbehaving guest on your server; it is a mandatory access control system that contains a compromised daemon to the handful of things that daemon legitimately needs to do. This guide is about operating it properly: reading denials, fixing them with the right tool, and writing a clean, minimal policy module when a service genuinely has no upstream policy.

If you take one thing away: a denial is data, not an obstacle. The audit log is telling you exactly what the kernel refused and why. Disabling SELinux throws that information away and leaves you with an unconfined, un-auditable process.

1. How type enforcement actually works

SELinux is primarily a type enforcement (TE) system. Every process and every object (file, socket, port, IPC) carries a security context with four fields:

user:role:type:level
system_u:system_r:httpd_t:s0

The field that does the work is the type. For files this is sometimes called a context or label; for processes it is called a domain. The policy is a giant allowlist of rules of the form “domain X may perform action Y on type Z”. Everything not explicitly allowed is denied — that is the default-deny posture that makes SELinux valuable.

Two mechanisms tie it together:

Inspect contexts with the -Z flag, which almost every core utility understands:

ps -eZ | grep httpd                 # process domains
ls -Z /var/www/html                 # file types
id -Z                               # your own context
ss -Z | grep :443                   # socket contexts

Confirm your enforcing state before troubleshooting anything:

getenforce                          # Enforcing | Permissive | Disabled
sestatus                            # full status incl. policy name and mode

Do not edit /etc/selinux/config to “fix” a problem. Set SELINUX=enforcing and leave it. Use setenforce 0 only as a momentary diagnostic, and set it straight back.

2. Reading AVC denials

When the kernel refuses an action, it emits an AVC (Access Vector Cache) denial to the audit subsystem. On a default RHEL/Rocky/Alma box these land in /var/log/audit/audit.log. Do not grep that file raw — use ausearch, which parses and timestamps records:

ausearch -m AVC,USER_AVC -ts recent          # last 10 minutes of denials
ausearch -m AVC -ts today | tail -n 40       # today's denials

A raw denial looks like this:

type=AVC msg=audit(1715520000.123:456): avc:  denied  { name_connect } for
  pid=2310 comm="nginx" dest=8080 scontext=system_u:system_r:httpd_t:s0
  tcontext=system_u:object_r:http_cache_port_t:s0 tclass=tcp_socket permissive=0

Read it field by field — this is the whole skill:

Field Meaning
denied { name_connect } the permission that was refused
comm / pid the process that tried it
scontext the source domain (the actor — here httpd_t)
tcontext the target context (here a TCP port type)
tclass the object class (tcp_socket, file, dir, …)
permissive=0 it was actually blocked (1 = logged only)

For a plain-English explanation and, crucially, a suggested remedy, install setroubleshoot-server and use sealert. It correlates denials and often names the exact setsebool or semanage command to run:

sealert -a /var/log/audit/audit.log

audit2why does the lighter-weight version — it tells you why a specific denial happened (wrong label? missing boolean? needs a rule?):

ausearch -m AVC -ts recent | audit2why

Watch for dontaudit rules. Some noisy-but-harmless denials are silenced by policy and never hit the log. If a service misbehaves and you see nothing, temporarily disable dontaudit rules with semodule -DB (rebuild), reproduce, then re-enable with semodule -B. Always turn them back on.

3. Fix the common cases first

The overwhelming majority of denials are not policy bugs. They are one of three fixable conditions: a wrong file context, an unregistered port, or a boolean that is off. Reach for a custom module only after you have ruled these out.

Wrong file context

This is the number-one cause. You put content somewhere policy does not expect, or you moved a file with mv (which preserves the old label) instead of cp. The denial will show a tcontext type that does not match what the domain expects (e.g. default_t or user_home_t where httpd_sys_content_t belongs).

Two distinct operations — get the difference straight:

# restorecon: reset a file to the label policy ALREADY says it should have
restorecon -Rv /var/www/html

# chcon: change a label imperatively (NOT persistent — lost on relabel)
chcon -t httpd_sys_content_t /var/www/html/index.html

Use restorecon whenever the path is already covered by policy. Reserve chcon for quick experiments; never put it in automation, because a filesystem relabel will silently undo it.

Ports

Network types are guarded too. If you run nginx on 8443, policy must know 8443/tcp is an http_port_t. Inspect and add:

semanage port -l | grep http_port_t            # what's already allowed
semanage port -a -t http_port_t -p tcp 8443    # add a new port
semanage port -m -t http_port_t -p tcp 8443    # modify if it already exists

If -a errors with “already defined”, the port belongs to another type and you must use -m. One protocol/port pair maps to exactly one type.

Booleans

Booleans are policy-author-provided switches for common variations — “may httpd make outbound network connections”, “may it read user home directories”, and so on. List, query, and set them. The -P flag makes the change persist across reboots — forget it and your fix evaporates on the next restart.

getsebool -a | grep httpd                       # all httpd booleans + state
semanage boolean -l | grep httpd_can_network    # includes descriptions
setsebool -P httpd_can_network_connect on       # persistent

The triage order that resolves most tickets: context, then port, then boolean. Run restorecon first, check semanage port -l, then getsebool -a. Only if all three are correct do you have a real policy gap.

4. Confined vs. unconfined, and why your daemon won’t start

Whether SELinux confines a service at all depends on labelling and a transition rule. Out of the box:

So when you drop in a third-party daemon, one of two things happens. Either its binary is labelled bin_t, it stays in init_t, and it “just works” but gains no protection — the worst outcome, because you think you are protected and you are not. Or it picks up a transition into a confined domain (sometimes the generic bin_t -> unconfined_service_t path) and is denied operations the stock policy never anticipated, so it fails to start.

Diagnose with the process domain. If your custom daemon shows init_t or unconfined_service_t, that is your starting point:

ps -eZ | grep my-daemon
journalctl -u my-daemon -b | tail -n 50          # the OS-level failure
ausearch -m AVC -c my-daemon -ts recent          # the SELinux reason

For a true daemon you actually want a dedicated confined domain — which means writing policy. That is the rest of this article.

5. Generate a starter with audit2allow (then stop)

audit2allow reads denials and emits allow rules that would have permitted them. It is the fastest way to understand what a service needs and to scaffold a module. It is also the fastest way to punch a hole through your security posture if you ship its output unreviewed.

Generate a candidate module straight from the audit log. -M builds a named module: it produces a human-readable mydaemon.te and a compiled, loadable mydaemon.pp in one step:

ausearch -m AVC -c my-daemon --raw | audit2allow -M mydaemon
# produces: mydaemon.te (source) and mydaemon.pp (compiled package)

Now read mydaemon.te before doing anything else. This is non-negotiable. audit2allow is mechanical — it will happily generate rules that defeat the entire point of confinement. Treat these as red flags:

The mental model: audit2allow answers “what would unblock this?” — never “what should this service be allowed to do?”. You answer the second question. Use the generated .te as a checklist of needs, not as the artifact you load.

6. Write a clean .te module with proper interfaces

A maintainable module uses the reference policy interfaces and macros instead of raw allow rules wherever possible. Interfaces (defined in .if files under /usr/share/selinux/devel/include) encapsulate the correct, complete set of rules for an operation, so you do not hand-roll fragile permission lists. Find them with sepolicy:

sepolicy interface -l | grep -i 'read.*log'      # discover available interfaces
sepolicy interface -v logging_read_generic_logs  # show what one expands to

Here is a minimal but correct module for a hypothetical daemon mydaemon that listens on a TCP port, reads its config under /etc/mydaemon, and writes logs and a pidfile. Note the structure: a private domain, a type_transition from init, and named interfaces instead of bare rules.

policy_module(mydaemon, 1.0.0)

########################################
# Declarations
require {
    type init_t;
}

# The daemon's own domain, its executable label, and its data labels
type mydaemon_t;
type mydaemon_exec_t;
init_daemon_domain(mydaemon_t, mydaemon_exec_t)   # binary -> domain transition

type mydaemon_conf_t;
files_config_file(mydaemon_conf_t)                 # private config type

type mydaemon_log_t;
logging_log_file(mydaemon_log_t)                   # marks it as a log file

type mydaemon_var_run_t;
files_pid_file(mydaemon_var_run_t)                 # marks it as a runtime/pid file

########################################
# Local policy
allow mydaemon_t self:capability { setgid setuid };
allow mydaemon_t self:tcp_socket { create_stream_socket_perms };

# Read its own configuration
read_files_pattern(mydaemon_t, mydaemon_conf_t, mydaemon_conf_t)
list_dirs_pattern(mydaemon_t, mydaemon_conf_t, mydaemon_conf_t)

# Manage its own logs (file transition so new logs get the right label)
manage_files_pattern(mydaemon_t, mydaemon_log_t, mydaemon_log_t)
logging_log_filetrans(mydaemon_t, mydaemon_log_t, file)

# Manage its own pidfile under /run
manage_files_pattern(mydaemon_t, mydaemon_var_run_t, mydaemon_var_run_t)
files_pid_filetrans(mydaemon_t, mydaemon_var_run_t, file)

# Standard plumbing every daemon needs (resolver, syslog socket, etc.)
sysnet_dns_name_resolve(mydaemon_t)
logging_send_syslog_msg(mydaemon_t)

Pair it with a file context (.fc) file so the labels are applied by path. This is what makes restorecon do the right thing forever:

/usr/sbin/mydaemon          --      gen_context(system_u:object_r:mydaemon_exec_t,s0)
/etc/mydaemon(/.*)?                 gen_context(system_u:object_r:mydaemon_conf_t,s0)
/var/log/mydaemon(/.*)?            gen_context(system_u:object_r:mydaemon_log_t,s0)
/run/mydaemon(/.*)?                gen_context(system_u:object_r:mydaemon_var_run_t,s0)

Why this beats the raw audit2allow dump: the daemon writes to its own private types, not to shared system types like var_log_t. A compromise of mydaemon cannot touch other services’ logs or config. That isolation is the entire value proposition, and you only get it by declaring private types and using file transitions.

7. Build, load, and test safely

You have two compilation paths. The devel Makefile is the clean one for a real .te/.fc/.if set — it knows where the interfaces live:

# requires selinux-policy-devel
make -f /usr/share/selinux/devel/Makefile mydaemon.pp

The manual path is useful when you only have a .te (no interface dependencies) and want to understand each step:

checkmodule -M -m -o mydaemon.mod mydaemon.te     # compile module
semodule_package -o mydaemon.pp -m mydaemon.mod   # package it

Now the safe rollout. Never load a fresh module straight into enforcing on a production node. Test in permissive for that domain onlypermissive_domains lets one type run permissive while the rest of the system stays enforcing:

semodule -i mydaemon.pp                            # install the module
semanage permissive -a mydaemon_t                  # make ONLY this domain permissive
restorecon -Rv /usr/sbin/mydaemon /etc/mydaemon /var/log/mydaemon
systemctl restart mydaemon

Exercise every code path — start, reload, rotate logs, hit every endpoint — then harvest any residual denials the domain logged while permissive:

ausearch -m AVC -c mydaemon -ts recent | audit2allow -R   # -R suggests interfaces

Fold legitimate gaps back into the .te (using the suggested interfaces, after review), rebuild, reinstall, and only then flip the domain to enforcing:

semanage permissive -d mydaemon_t                  # remove the permissive exception
ausearch -m AVC -c mydaemon -ts recent             # should now be empty

To remove a module entirely, reference it by name, not filename (no .pp):

semodule -r mydaemon

Enterprise scenario

A payments platform ran a fleet of RHEL 8 hosts behind a hard PCI-DSS requirement: SELinux stays enforcing, no exceptions, audited quarterly. A new vendor agent for log shipping (a third-party binary dropped at /opt/vendor/bin/shipper) refused to start under systemd. journalctl showed a generic exit, and ps -eZ revealed the real problem — it was running in init_t, completely unconfined, because its binary was labelled bin_t. The vendor’s “fix” in their docs was, predictably, setenforce 0, which would have failed the next audit on the spot.

The constraint that made this tricky: the agent needed to read every other service’s logs under /var/log to ship them, but the security team would not grant a blanket logging_read_all_logs() to an unconfined domain sitting in init_t. The gotcha is that an unconfined daemon both bypasses confinement and still trips denials on MCS-categorised files — worst of both worlds.

The fix was to give it a real domain and one tightly scoped read interface, never setenforce:

policy_module(shipper, 1.0.0)

require { type init_t; }
type shipper_t;
type shipper_exec_t;
init_daemon_domain(shipper_t, shipper_exec_t)

# read existing logs only — no write, no create
logging_read_all_logs(shipper_t)
allow shipper_t self:capability { dac_read_search };

They relabelled the binary (semanage fcontext -a -t shipper_exec_t '/opt/vendor/bin/shipper'; restorecon -v ...), rolled out in semanage permissive -a shipper_t first, harvested the residual denials, and only then went enforcing. Audit closed without a finding.

Verify

Confirm the end state explicitly rather than assuming the restart “worked”:

getenforce                                  # Enforcing
ps -eZ | grep mydaemon                      # runs in mydaemon_t, NOT init_t/unconfined
semodule -l | grep mydaemon                 # module is loaded
semanage permissive -l | grep mydaemon      # NO output = enforcing for this domain
ausearch -m AVC -c mydaemon -ts today       # no denials after a full exercise
sesearch -A -s mydaemon_t | head            # inspect the live allow rules for the domain
seinfo -t | grep mydaemon                   # your private types exist in the loaded policy

The decisive signals: the process is in your dedicated domain, the permissive list does not mention it, and ausearch is clean after you have driven real traffic through it.

Checklist

Pitfalls and next steps

A few traps that bite even experienced operators:

For deeper coverage, read the upstream reference policy interfaces under /usr/share/selinux/devel/include — they are the canonical examples of well-written policy. From here, the natural next steps are MLS/MCS categories for multi-tenant isolation, confining containers with container-selinux, and wiring make -f /usr/share/selinux/devel/Makefile into CI so every policy change is built, diffed with sesearch, and reviewed like any other code.

SELinuxLinuxSecurityRHELPolicyHardening

Comments

Keep Reading