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 (MAC) system that contains a compromised daemon to the handful of operations that daemon legitimately needs — so that a remote-code-execution bug in your web server cannot read /etc/shadow, write cron jobs, or pivot to the database socket. When a service fails and SELinux is involved, the kernel has already told you exactly what it refused and why. Disabling it throws that diagnostic away and leaves you with an unconfined, un-auditable process running with the full discretionary permissions of its UID.

This is the operator’s playbook for running SELinux enforcing in production and never reaching for setenforce 0 under pressure. You will learn to read an AVC (Access Vector Cache) denial field by field from /var/log/audit/audit.log, to triage the three fixable causes that account for the overwhelming majority of denials — a wrong file context, an unregistered port, or a boolean that is off — with semanage, restorecon and setsebool -P, and only then, when a service genuinely has no upstream policy, to author a clean, minimal custom policy module from .te, .if and .fc sources using reference-policy interfaces rather than a raw audit2allow dump. The centerpiece is a symptom-to-fix denial playbook you keep open at 02:00 when a daemon won’t start and journalctl is being unhelpful.

By the end you will treat a denial the way a senior engineer does: as data, not an obstacle. When httpd returns 503, when a container can’t bind its port, when a moved file 404s, when a third-party agent silently runs unconfined in init_t, you will know within ninety seconds whether you are looking at a mislabelled file, a missing port registration, an off boolean, or a real policy gap that warrants a module — and you will fix the right one, persistently, without weakening the machine.

What problem this solves

SELinux failures are maddening precisely because the surface symptom lies. A daemon exits with a generic error, systemctl status shows code=exited status=1 with no useful detail, and the application’s own logs say “permission denied” on a path the Unix mode bits clearly allow. The ls -l output shows rw-r--r-- root root on a file the root process cannot read — because the type label is wrong and discretionary permissions never entered into it. Nothing in the ordinary Linux toolchain (ls -l, strace at a glance, the app log) points at SELinux, so engineers who do not know to check ausearch conclude the platform is broken and disable the one control that was doing its job.

What breaks without this knowledge is worse than a single outage. An on-call engineer runs setenforce 0, the daemon starts, the incident closes, and the machine now runs permissive until the next reboot re-reads /etc/selinux/config — or, if someone “made it permanent,” forever. A compromised nginx on that box can now do everything nginx’s UID can do: read every world-readable secret, connect anywhere, write to any directory the mode bits allow. In a regulated environment (PCI-DSS, FedRAMP, CIS Level 2, many DoD STIGs) enforcing SELinux is a hard control; setenforce 0 is an audit finding on the spot. Meanwhile the actual cause — a file relabelled by mv, a service moved to a non-standard port, a boolean nobody flipped — sat there, perfectly diagnosable, ignored.

Who hits this: anyone running RHEL, Rocky, AlmaLinux, CentOS Stream, Fedora, or Oracle Linux with the default targeted policy; anyone shipping a third-party or in-house daemon that has no upstream policy module; anyone moving web or app content between directories; anyone running containers under Podman or CRI-O where MCS categories isolate each container; and anyone who has ever “fixed” an SELinux problem with chcon in a config-management role and been bitten weeks later by a routine relabel that silently reverted it. The fix is almost never to weaken SELinux — it is to read the denial, apply the right tool, and (when warranted) write twenty lines of policy that give the service its own confined domain.

To frame the whole field before the deep dive, here is every denial class this article covers, the question it forces, and the first place to look:

Symptom class What the kernel is telling you First question to ask First command to run Most common single cause
Daemon won’t start A domain was denied an operation at startup Is it labelled/transitioned into a confined domain at all? ps -eZ | grep <daemon>; ausearch -m AVC -ts recent Wrong file context, or missing port registration
File access denied (mode bits allow it) tcontext type is not what the domain expects Was the file moved, restored, or created in the wrong place? ls -Z <path>; restorecon -nv <path> mv preserved the old label; run restorecon
Port bind/connect refused A tcp_socket/udp_socket name_bind/name_connect denied Is the port registered to the domain’s port type? semanage port -l | grep <port> Non-standard port not registered with semanage port
Feature works only with a flag flipped A boolean gates the operation Is there a boolean for this? getsebool -a | grep <service> Boolean off (e.g. httpd_can_network_connect)
Service runs but is unconfined Binary is bin_t, no transition, stays in init_t Does it have an *_exec_t label + a transition? ps -eZ | grep <daemon> shows init_t No policy module → write one
Container denied on a mounted volume MCS category mismatch (s0:c123,c456) Is the host path relabelled for container access? ls -Z <hostpath>; podman inspect label Volume not mounted with :z/:Z
Nothing in the log, but it still fails A dontaudit rule is silencing the denial Are dontaudit rules hiding it? semodule -DB, reproduce, then semodule -B Silenced denial; disable dontaudit to see it

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable on an enterprise Linux host: systemd service management (systemctl, journalctl), the Unix permission model (owners, groups, mode bits — SELinux is checked after these pass), reading /var/log, and installing packages with dnf. You should know what a daemon is, what a TCP port is, and roughly how nginx/httpd/postgresql are laid out on disk. No prior SELinux knowledge is assumed, but this is an Expert-level treatment: it moves fast through the model and spends its length on troubleshooting and policy authoring.

This sits in the Linux security hardening track. SELinux is one MAC implementation; the sibling article on Authoring AppArmor Profiles: Confining Services on Ubuntu and Debian covers the path-based alternative you meet on Ubuntu/Debian, and reading both makes the type-enforcement-versus-path-based trade-off concrete. Denials land in the audit subsystem, so it pairs tightly with Building a Linux Audit Trail with auditd and eBPF Runtime Visibilityaudit.log is the same log both articles read. Because systemd is what transitions a daemon into its domain, Mastering systemd: Units, Timers, Resource Control, and Service Hardening is upstream of the confined-vs-unconfined discussion. And the container isolation section connects directly to Running Rootless Containers in Production with Podman and Quadlet, where MCS categories keep containers apart.

A quick map of who confirms what during an SELinux incident, so you reach for the right tool fast:

Layer What lives here The tool that tells the truth Failure classes it explains
Kernel LSM hook The allow/deny decision itself audit.log via ausearch Every denial; permissive= tells you if it blocked
File labels security.selinux xattr on each inode ls -Z, restorecon -nv Wrong-context denials (the #1 cause)
Port types Port→type mapping in the policy store semanage port -l name_bind / name_connect on sockets
Booleans Policy-author switches getsebool -a, semanage boolean -l “works only with a flag on”
Policy modules Loaded .pp modules in the store semodule -l, sesearch, seinfo Missing domain, missing rule → write policy
Process domains The scontext of running processes ps -eZ, id -Z Unconfined-in-init_t, wrong domain
The .te/.if/.fc sources Your custom policy under version control sepolicy, the devel Makefile Gaps you fix by editing and rebuilding

Core concepts

Five mental models make every later diagnosis obvious.

SELinux is default-deny type enforcement, checked after DAC. Traditional Unix permission (mode bits, owners, groups) is discretionary access control (DAC) — the file’s owner decides who may read it. SELinux adds mandatory access control on top: even if DAC allows an operation, the kernel consults the policy, and if no rule explicitly permits it, the operation is denied. Crucially, DAC is checked first — a denial you see in audit.log means DAC already passed and SELinux is the one saying no. Every process and object carries a security context of four fields — user:role:type:level — and the field that does the enforcement work is the type. The policy is one enormous allowlist of rules of the form “the domain (a process type) X may perform action Y on objects of type Z”. Everything not written is refused.

Files have types; processes have domains; a transition ties them together. A file’s type — sometimes called its label or context — comes from policy rules keyed on its path: /var/www/html is httpd_sys_content_t, /etc/shadow is shadow_t. That label lives in the security.selinux extended attribute on the inode, so it travels with the file on the same filesystem and is why mv (which preserves the xattr) behaves differently from cp (which lets the destination policy assign a fresh one). A process type is called a domain: httpd_t, sshd_t, postgresql_t. When systemd (itself running in init_t) executes a binary labelled httpd_exec_t, the policy contains a type_transition rule that moves the new process into httpd_t. This is why a confined daemon becomes confined automatically — the transition is declared in policy, not configured by you, and it is the single mechanism you must reproduce when you confine your own daemon.

A denial names the actor, the target, and the operation — that is the whole skill. When the kernel refuses, it emits an AVC record to the audit subsystem. Read three fields and you know the fix: scontext is the source domain (the actor — who tried), tcontext is the target context (what it tried to touch), and tclass plus the bracketed permission ({ read }, { name_bind }, { name_connect }) is the operation. A tcontext of default_t where you expected httpd_sys_content_t is a labelling problem. A tclass=tcp_socket with { name_bind } and a port in the record is a port-registration problem. A denial that a boolean would have permitted is a boolean problem. The permission and the target type route you to one of exactly three fixes before you ever consider writing policy.

Modes are global, but permissiveness can be per-domain. SELinux runs in one of three global modes: enforcing (denials block and log), permissive (denials log but do not block — invaluable for discovery), or disabled (the LSM is off; no labels maintained — avoid, and note that going from disabled back to enforcing needs a full relabel). But the sharp tool is per-domain permissive: semanage permissive -a <domain> makes a single type run permissive while the entire rest of the system stays enforcing. That is how you safely bring up a new confined daemon in production — the new domain logs what it needs without blocking, everything else stays protected — then you harvest the denials, fold them into policy, and remove the exception.

Most denials are not policy bugs — they are one of three fixable conditions. Before you ever write a module, three cheap checks resolve the overwhelming majority of tickets: the file is in the wrong place or was moved (fix with restorecon), the service listens on a non-standard port the policy doesn’t know about (fix with semanage port), or a boolean that gates the behaviour is off (fix with setsebool -P). The triage order is context, then port, then boolean. Only when all three are correct — the labels match, the port is registered, the relevant booleans are on — do you have a genuine policy gap that a custom module addresses.

The vocabulary in one table

Before the deep sections, pin down every moving part. The glossary at the end repeats these for lookup; this table is the model side by side:

Concept One-line definition Where it lives Why it matters to a denial
Security context The user:role:type:level 4-tuple on every process/object xattr (files) / kernel (processes) The thing compared against policy
Type The field enforcement is based on 3rd field of the context Wrong type on a file = denial
Domain A process type scontext of a running process The actor in every denial
Type enforcement (TE) The rule model: domain→action→type The loaded policy The whole allowlist
type_transition Rule that moves a new process into a domain Policy module Why daemons auto-confine
Label / file context The type stored on a file security.selinux xattr restorecon sets it from policy
AVC denial Kernel record of a refused access /var/log/audit/audit.log Your primary diagnostic
Boolean Policy-author on/off switch Policy + /sys/fs/selinux/booleans Off → common denials; setsebool -P
Port type Type mapping a port to a domain’s allowed ports Policy store (semanage port) Non-standard port → name_bind denial
Confined domain A tightly boxed process type httpd_t, postgresql_t, … The goal state
Unconfined domain Barely restricted (unconfined_t, init_t) Default for unlabelled binaries The trap: “works” but not protected
Policy module A loadable .pp package of rules The policy store (semodule -l) What you write to fill a gap
MCS category The level field’s categories (c0,c1,…) 4th field of the context Container/multi-tenant isolation
dontaudit Rule that silences a harmless denial Policy Why a denial can be invisible

To ground the abstractions, here are the confined domains you meet most on a real server, the binary that enters each, and the private types they own — this is the shape your custom module should imitate:

Service Domain Executable type Key private data types
Apache HTTP httpd_t httpd_exec_t httpd_sys_content_t, httpd_log_t, httpd_sys_rw_content_t
nginx httpd_t (shares Apache’s) httpd_exec_t same as httpd (nginx uses the httpd domain)
PostgreSQL postgresql_t postgresql_exec_t postgresql_db_t, postgresql_log_t, postgresql_var_run_t
MariaDB/MySQL mysqld_t mysqld_exec_t mysqld_db_t, mysqld_log_t
OpenSSH server sshd_t sshd_exec_t sshd_key_t, sshd_var_run_t
systemd (PID 1) init_t init_exec_t — (the untransitioned default for stray daemons)
Podman/CRI-O container container_t container_runtime_exec_t container_file_t (+ MCS categories)
BIND DNS named_t named_exec_t named_zone_t, named_cache_t
chronyd chronyd_t chronyd_exec_t chronyd_var_run_t

How type enforcement actually works

Start with the context. Inspect any process or object with the -Z flag, which nearly every core utility understands, and you will see the four fields:

id -Z
# unconfined_u:unconfined_r:unconfined_t:s0

ps -eZ | grep -E 'httpd|nginx'
# system_u:system_r:httpd_t:s0   1234 ?  00:00:01 httpd

ls -Z /var/www/html/index.html
# unconfined_u:object_r:httpd_sys_content_t:s0 index.html

ss -ltnpZ | grep :443
# LISTEN 0 511 *:443 users:(("httpd",pid=1234)) ... system_u:system_r:httpd_t:s0

The four fields, and how much each matters in the targeted policy that every mainstream distribution ships by default:

Field Name Example Role in targeted policy
1 SELinux user system_u, unconfined_u Maps to a set of allowed roles; mostly background
2 Role system_r, object_r Gates which types a user may enter; object_r for all files
3 Type / domain httpd_t, httpd_sys_content_t The enforcement field — this is what you troubleshoot
4 Level (sensitivity:categories) s0, s0:c1,c2 MLS sensitivity / MCS categories; container & multi-tenant isolation

In targeted policy — the default on RHEL, Rocky, Alma, Fedora, Oracle Linux — the first two fields are largely fixed (system_u/system_r for system processes, object_r for files) and the type is where all the action is. Do not be distracted by user and role when troubleshooting a standard box; the denial you are chasing is almost always about the type.

The -Z flag is your window into contexts, and nearly every core utility accepts it. Commit these to reflex — you run them constantly during triage:

Command Shows the context of Use during triage to answer
id -Z Your current shell/user What domain you are in (often unconfined_t)
ps -eZ / ps -eZ | grep <d> Every process / one process Is the daemon confined, or stuck in init_t?
ls -Z <path> Files and directories Is the target labelled the way policy expects?
ls -Zd <dir> A directory itself (not its contents) Is the parent dir’s type blocking traversal?
ss -ltnpZ / netstat -Z Listening sockets and their processes Which domain owns the socket on a port?
stat -c '%C' <path> One file’s context, scriptably Grab just the context for a comparison
matchpathcon <path> What a path should be labelled Diff expected vs actual before restorecon
find / -context '*httpd*' Files matching a context pattern Locate everything a domain can touch
runcon -t <type> <cmd> (runs a command in a given context) Test what a domain can do, deliberately

Labelling: how a file gets its type

A file’s type is not stored in the policy — it is stored on the file, in the security.selinux xattr — but where it comes from is a policy rule keyed on the path. The semanage fcontext database maps path regexes to types; restorecon reads that database and stamps the correct label onto the inode. This split is the source of the single most common class of bug, so hold it clearly:

Operation What it does to the label Persistent across relabel? When to use
restorecon <path> Sets the label policy says the path should have Yes (reads the fcontext DB) The default fix for a mislabelled file
chcon -t <type> <path> Sets a label imperatively, right now No — a relabel reverts it Quick experiments only; never in automation
semanage fcontext -a -t <type> '<regex>' Adds a rule to the fcontext DB (does not touch files) Yes (it is the DB) Register a path so restorecon is authoritative
cp <src> <dst> Destination gets a fresh label from policy N/A Prefer over mv when placing content
mv <src> <dst> Preserves the source’s existing label N/A The trap — moved content keeps its old type
matchpathcon <path> / selabel_lookup Shows what label a path should have N/A Diff expected vs actual before restoring

The workflow that makes labels correct forever: register the path with semanage fcontext -a, then apply with restorecon. chcon is for a ten-second experiment and nothing else, because a routine restorecon -R / or an /.autorelabel boot will silently undo it.

Domain transition: how a process gets confined

When you start systemctl start httpd, the sequence is: systemd runs in init_t; it execs /usr/sbin/httpd, which policy has labelled httpd_exec_t; a type_transition init_t httpd_exec_t : process httpd_t; rule fires; the new process lands in httpd_t. Three ingredients must all be present — the binary’s *_exec_t label, an entrypoint relationship (httpd_exec_t is declared the entrypoint of httpd_t), and the transition rule. Break any one and the daemon does not enter its domain:

Ingredient What provides it Symptom if missing
*_exec_t label on the binary .fc file + restorecon, or semanage fcontext Binary is bin_t → process stays in init_t (unconfined)
Entrypoint declaration init_daemon_domain() / domain_entry_file() in the .te Transition can’t fire even if the label is right
type_transition rule init_daemon_domain() macro No transition → runs in the caller’s domain

Confirm the outcome in one command — this is the first thing to run when a daemon misbehaves:

ps -eZ | grep my-daemon
# GOOD:  system_u:system_r:my_daemon_t:s0   ... my-daemon
# TRAP:  system_u:system_r:init_t:s0        ... my-daemon   <- unconfined

If your daemon shows init_t or unconfined_service_t, it has no real confinement and no policy module — that is your starting point for the authoring sections below.

Confirm your enforcing state first

Never begin troubleshooting without knowing the mode. A “denial” you cannot reproduce is often because the box is already permissive:

getenforce          # Enforcing | Permissive | Disabled
sestatus            # full status: policy name, store, mode, MLS/MCS status

sestatus output on a healthy targeted box reads SELinux status: enabled, Current mode: enforcing, Loaded policy name: targeted. If Current mode says permissive you are seeing logged-not-blocked denials and the app is not actually being stopped — a common source of “it works on staging” confusion.

Modes: enforcing, permissive, disabled — and per-domain permissive

The global mode is set at runtime with setenforce and at boot in /etc/selinux/config. Understand every combination and, critically, why disabling is the one you almost never want:

Mode Denials blocked? Denials logged? Labels maintained? Return path Use it for
Enforcing Yes Yes Yes Production. The only correct steady state
Permissive No Yes Yes Instant Discovery: bring-up, harvesting a new domain’s needs
Disabled No No No Needs a full relabel on re-enable Almost never — you lose auditing and xattrs drift
Per-domain permissive No (that domain) / Yes (rest) Yes Yes semanage permissive -d Safely bringing up ONE new daemon in prod

Runtime toggles and the persistent setting:

setenforce 0        # Permissive  (momentary diagnostic ONLY — set it straight back)
setenforce 1        # Enforcing
getenforce          # confirm

# Persistent mode lives here — set enforcing and LEAVE IT:
grep '^SELINUX=' /etc/selinux/config     # SELINUX=enforcing

The rule that saves you an audit finding: do not edit /etc/selinux/config to “fix” a problem. Keep SELINUX=enforcing permanently. setenforce 0 is acceptable for a few seconds to prove SELinux is the cause of a symptom (if it starts working permissive, SELinux is involved), but you flip it straight back and then diagnose properly. The reason disabling is genuinely dangerous, not merely frowned upon: while disabled, new and modified files never get labelled, so the on-disk labels drift out of sync with reality; when you eventually re-enable, everything is mislabelled and you must trigger a full filesystem relabel (fixfiles onboot or touch /.autorelabel; reboot), which on a large host is a long maintenance window.

Per-domain permissive is the professional’s tool and deserves emphasis. It lets you observe exactly what one new type needs without exposing the machine:

semanage permissive -a my_daemon_t     # ONLY my_daemon_t runs permissive
semanage permissive -l                 # list current permissive domains
semanage permissive -d my_daemon_t     # remove the exception → back to enforcing for it

There is also a related distinction worth knowing: a type can be declared permissive in a module (permissive my_daemon_t; in a .te) — the same effect, shipped as policy. Prefer semanage permissive for temporary bring-up because it is trivially reversible without a rebuild.

Reading AVC denials — the core skill

When the kernel refuses an action it writes an AVC (and sometimes USER_AVC) record to /var/log/audit/audit.log. Do not grep that file raw. Use ausearch, which parses records, resolves timestamps from epoch, and lets you filter by message type, time, and process. audit.log is line-noise; ausearch is readable:

ausearch -m AVC,USER_AVC -ts recent            # AVCs in the last ~10 minutes
ausearch -m AVC -ts today | tail -n 40         # today's denials
ausearch -m AVC -c httpd -ts recent            # only comm=httpd
ausearch -m AVC -su httpd_t -ts today          # only scontext user/type httpd_t
ausearch -m AVC --start 09:00 --end 09:30      # a specific window

The ausearch flags you will actually use, so you scope a denial fast instead of scrolling audit.log:

Flag Meaning Example
-m AVC,USER_AVC Filter by message type (AVC denials) ausearch -m AVC -ts recent
-ts recent / -ts today Start time: last ~10 min / since midnight ausearch -m AVC -ts today
--start / --end An explicit time window ausearch -m AVC --start 09:00 --end 09:30
-c <comm> Filter by process command name ausearch -m AVC -c httpd
-su <type> Filter by source (scontext) user/type ausearch -m AVC -su httpd_t
-tc <class> Filter by target object class ausearch -m AVC -tc tcp_socket
--raw Unformatted output (feed to audit2allow) ausearch -m AVC -c X --raw | audit2allow -M X
-i Interpret numeric fields (nicer output) ausearch -m AVC -ts recent -i
-if <file> Read a specific log file, not the live one ausearch -if /var/log/audit/audit.log.1 -m AVC

A raw denial — the thing you are learning to read:

type=AVC msg=audit(1715520000.123:456): avc:  denied  { name_connect } for
  pid=2310 comm="httpd" 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 table is the diagnostic method — internalise it and you can decode any denial:

Field Value in the example Meaning What it tells you to check
avc: denied { ... } { name_connect } The permission refused Which operation to allow (or which fix maps to it)
pid / comm 2310 / httpd The process that tried Which service; correlate with journalctl
scontext ...:httpd_t:s0 Source domain (the actor) The domain that needs the access
tcontext ...:http_cache_port_t:s0 Target context The type being touched — often the whole clue
tclass tcp_socket The object class file / dir / tcp_socket / udp_socket / etc.
dest / name / path dest=8080 Class-specific detail Port number, file name, or path
permissive 0 Was it actually blocked? 0 = blocked (real), 1 = logged only

The permissive= field is the one people miss. permissive=1 means the record was logged but the operation was allowed — you are on a permissive box or a per-domain-permissive type, and this “denial” did not actually stop anything. permissive=0 is a real block. When someone says “SELinux is denying X but disabling it doesn’t help,” check whether they were reading permissive=1 records the whole time.

Common permissions and the object classes they appear on, so a bracketed verb immediately suggests a category of fix:

Permission Typical tclass Usually means First-line fix to consider
read, open, getattr file, dir, lnk_file Wrong file label restorecon / semanage fcontext
write, append, create file, dir Wrong label, or genuinely needs write to a private type restorecon; or private type in a module
name_bind tcp_socket, udp_socket Listening on an unregistered port semanage port -a
name_connect tcp_socket Outbound connect to an unregistered/blocked port semanage port; often a boolean too
execute, execute_no_trans file Executing something in the wrong domain Label as *_exec_t; add a transition
execmem, execstack, execheap process App maps writable+executable memory Almost always fix the app, not policy
connectto unix_stream_socket Talking to another domain’s socket A named interface between the two domains
signal, sigkill process Sending signals across domains An interface; or run in the same domain
search, add_name dir Traversing/adding into a directory Label the directory; list_dirs_pattern

Explaining a denial: audit2why, sealert, and setroubleshoot

ausearch shows you the denial; audit2why tells you why it happened and often what class of fix it needs — wrong label, missing boolean, or a genuine gap:

ausearch -m AVC -ts recent | audit2why
# ... prints, per denial, one of:
#   "Was caused by:  Missing type enforcement (TE) allow rule."      -> real gap
#   "... one of the following booleans: httpd_can_network_connect"   -> flip a boolean
#   "... you may need to relabel ... restorecon -R -v /path"          -> a labelling fix

For a plain-English explanation with a suggested exact command, install setroubleshoot-server and use sealert. It correlates related denials and frequently names the precise setsebool or semanage line to run — and it is what fires the desktop/journal “SELinux is preventing…” notifications:

dnf install -y setroubleshoot-server
sealert -a /var/log/audit/audit.log          # analyse the whole log
journalctl -t setroubleshoot --since "10 min ago"   # the notifications it raised

The three-tool division of labour, so you pick the right one:

Tool What it gives you Speed When to reach for it
ausearch The raw, filtered, timestamped denials Instant Always first — find and scope the denials
audit2why Why each denial happened (label/boolean/gap) Instant To classify: is this a boolean, a label, or a real gap?
sealert (setroubleshoot) Human explanation + a suggested exact command Seconds To get a concrete remediation, and for the notifications
audit2allow allow rules / a loadable module that would permit it Instant To scaffold a module — then read it, never ship blind

The dontaudit trap: when a denial is invisible

Some denials are noisy but harmless — a daemon probing for a capability it does not really need, a library stat-ing paths it may not read. Policy authors silence these with dontaudit rules so they never reach the log. That is usually a kindness, but it becomes a trap when a service genuinely misbehaves and you see nothing in ausearch. If a daemon fails and the audit log is silent, temporarily disable all dontaudit rules, reproduce, and re-enable:

semodule -DB          # -D disable dontaudit, -B rebuild/reload the policy
# ... reproduce the failure now; the previously-silenced denials will appear ...
ausearch -m AVC -ts recent
semodule -B           # rebuild WITHOUT -D → dontaudit rules back on (ALWAYS do this)

Leaving dontaudit disabled floods the log with harmless noise and can hide real signal, so the semodule -B at the end is not optional. This is the answer to “SELinux is definitely the problem but there are no denials.”

Fix the common cases first — context, then port, then boolean

The overwhelming majority of denials are one of three fixable conditions. Rule them out in this order before you consider a module; a custom policy for what is actually a mislabelled file is a self-inflicted maintenance burden.

Order Cause Tell-tale in the denial The fix Persistence
1 Wrong file context tcontext file type is default_t/user_home_t/mismatched; tclass=file/dir restorecon; register with semanage fcontext semanage fcontext + restorecon survives relabel
2 Unregistered port { name_bind }/{ name_connect }, tclass=*_socket, a dest= port semanage port -a -t <type> -p tcp <port> Persistent (stored in policy store)
3 Boolean off audit2why names a boolean; behaviour is a known variation setsebool -P <bool> on -P makes it persistent

Cause 1 — Wrong file context (the number-one cause)

You put content where policy does not expect it, or — far more often — you mvd a file (which preserves its old label) instead of cping it. The denial shows a tcontext type that does not match what the domain wants: default_t or user_home_t where httpd_sys_content_t belongs.

Confirm what the label is versus what it should be, without changing anything:

ls -Z /var/www/html/index.html
# unconfined_u:object_r:user_home_t:s0 index.html    <- wrong; came from a home dir via mv

restorecon -nv /var/www/html/index.html
# would relabel .../index.html from user_home_t to httpd_sys_content_t   <- -n = dry run

Fix. If the path is already covered by policy (as /var/www/html is), just restore it:

restorecon -Rv /var/www/html          # -R recursive, -v verbose (shows every change)

If the path is not a standard one — say you serve content from /srv/webdata — register a rule first, then restore. This is what makes the label authoritative forever:

semanage fcontext -a -t httpd_sys_content_t '/srv/webdata(/.*)?'   # add the rule
restorecon -Rv /srv/webdata                                        # apply it
semanage fcontext -l | grep webdata                               # verify the rule exists

chcon exists and works, but reserve it for a ten-second experiment. Putting chcon in Ansible/Puppet/a systemd ExecStartPre is a latent outage: the next restorecon -R /, fixfiles, or /.autorelabel reverts it and the service breaks weeks later with no obvious cause. The equivalence between mv/cp and label behaviour is worth committing to muscle memory:

You did Resulting label Result under enforcing
cp /home/u/site.html /var/www/html/ Fresh httpd_sys_content_t from policy Works
mv /home/u/site.html /var/www/html/ Kept user_home_t Denied — run restorecon
tar xf preserving xattrs (--selinux) Whatever the archive stored Depends — often wrong; restorecon after
rsync -X (preserve xattrs) Source labels copied Often wrong on the target; restorecon after
Wrote the file in place with the app Inherits parent dir’s transition/type Usually correct

Cause 2 — Unregistered port

Network types are guarded too. Policy knows which port types a domain may bind or connect to; if you run a service on a non-standard port, the policy must be told that port belongs to the right type. Run nginx on 8443 and you get { name_bind } denied on tcp_socket because 8443 is not (by default) an http_port_t.

Confirm and fix:

semanage port -l | grep -w http_port_t          # what ports http_port_t already covers
# http_port_t   tcp   80, 81, 443, 488, 8008, 8009, 8443, 9000

semanage port -a -t http_port_t -p tcp 8443     # ADD a port to a type
semanage port -m -t http_port_t -p tcp 8443     # MODIFY if the port already maps elsewhere
semanage port -d -t http_port_t -p tcp 8443     # DELETE the mapping

If -a errors with “port already defined,” the port belongs to a different type — one protocol/port pair maps to exactly one type — and you must move it with -m. Common services and their port types (so you register the right one, not a generic one):

Service / domain Port type to add to Default ports policy already allows
Apache / nginx (httpd_t) http_port_t 80, 81, 443, 488, 8008, 8009, 8443, 9000
SSH (sshd_t) ssh_port_t 22
PostgreSQL (postgresql_t) postgresql_port_t 5432
MySQL/MariaDB (mysqld_t) mysqld_port_t 1186, 3306, 63132–63164
Node/app servers behind a proxy http_port_t (if httpd talks to it) as above
Custom TCP daemon (your own domain) your own *_port_t (declare in .te) none — you define it

Cause 3 — Boolean off

Booleans are switches the policy author exposes for common variations that would otherwise require different policies: “may httpd make outbound network connections,” “may it read user home directories,” “may Samba share home directories.” When audit2why names a boolean, flipping it is the fix — and the -P flag makes it persist across reboots. Omit -P and the change evaporates on the next restart, producing the classic “it worked until we rebooted” bug.

getsebool -a | grep httpd                       # every httpd boolean and its current state
semanage boolean -l | grep httpd_can_network    # includes the human-readable description
setsebool -P httpd_can_network_connect on        # PERSISTENT (writes to policy store)
setsebool httpd_can_network_connect on           # runtime-only — reverts on reboot (avoid)
getsebool httpd_can_network_connect              # confirm: ... --> on

High-value booleans you will actually reach for, and exactly what each unlocks:

Boolean Default Turning it on allows Typical trigger
httpd_can_network_connect off httpd to make any outbound TCP connection App calls an API/backend over the network
httpd_can_network_connect_db off httpd to connect to database ports specifically PHP/app talking to a DB (narrower than the above)
httpd_can_network_relay off httpd to act as a forward/reverse proxy nginx/httpd reverse-proxying upstreams
httpd_read_user_content off httpd to read user_home_t content Serving ~user/public_html
httpd_enable_homedirs off httpd to access home directories at all UserDir setups
httpd_use_nfs / httpd_use_cifs off httpd to serve content from NFS/CIFS mounts Web root on a network share
httpd_execmem off httpd to map writable+executable memory Certain PHP/JIT modules (weakens W^X — avoid)
samba_enable_home_dirs off Samba to share home directories Home-share file servers
nfs_export_all_rw off NFS to export filesystems read-write NFS servers
ftpd_full_access off vsftpd broad filesystem access FTP servers needing more than the default
container_manage_cgroup off Containers to manage cgroups Some nested/systemd-in-container setups
selinuxuser_execmod on/off (varies) User content with execmod Certain interpreters

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

Confined vs. unconfined — and why your daemon won’t start (or worse, “just works”)

Whether SELinux confines a service at all depends on labelling and a transition rule, and the failure modes here are the ones that trip up experienced admins.

So when you drop in a third-party daemon, one of two things happens, and the worse outcome is the one that looks like success:

Outcome What you observe Reality Why it’s dangerous or annoying
Binary is bin_t, stays in init_t Daemon “just works” Unconfined — SELinux protects nothing You think you’re protected; a compromise runs wild
Picks up a transition to a confined domain Daemon fails to start with denials Stock policy never anticipated its needs Annoying but honest — you write policy to fix it
Lands in unconfined_service_t Works but loosely confined Minimal restriction Better than init_t, still not a real domain

The “just works because it’s unconfined” case is the trap. You gain a false sense of security and, in an MCS/MLS environment, the daemon can both bypass confinement and still trip category denials on labelled files — worst of both worlds. Diagnose with the process domain, then read both the OS-level failure and the SELinux reason:

ps -eZ | grep my-daemon                          # init_t/unconfined_service_t = your red flag
journalctl -u my-daemon -b | tail -n 50          # the OS-level failure (exit code, stderr)
ausearch -m AVC -c my-daemon -ts recent          # the SELinux reason, if any

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

Generate a starter with audit2allow — then stop and read it

audit2allow reads denials and emits the 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 clean through your security posture if you ship its output unreviewed. Treat it as a checklist of needs, never as the artifact you load.

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

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

# or pipe from a saved log:
audit2allow -a -M mydaemon                        # -a reads all of audit.log

Now read mydaemon.te before doing anything else. audit2allow is mechanical; it will happily generate rules that defeat confinement entirely. Treat these as red flags and, wherever you see one, ask “what is the right fix?” — which is almost always a private type or a proper interface, not the raw rule:

Red flag in generated .te What it really means The right fix instead
allow X self:capability { sys_admin }; Daemon wants near-root kernel powers Almost never needed — find the specific operation; drop the capability
allow X self:capability { dac_override, dac_read_search }; Bypass Unix permissions entirely Fix the file ownership/labels so DAC/SELinux both pass
allow X etc_t:file write; Wants to rewrite /etc broadly Relabel the daemon’s own config to a private type and write to that
allow X var_log_t:file write; Wants to write the shared log type Give it a private *_log_t and a log file transition
allow X ... execmem; / execstack; Maps writable+executable memory Fix the app (a memory bug/JIT); granting it weakens W^X
One rule allowing a whole class vs a broad type A relabelling problem in disguise Relabel the target to a specific type, then allow narrowly
allow X unconfined_t:... Interacting with unconfined processes Rethink the design; interface to a specific confined domain

The mental model: audit2allow answers “what would unblock this?” — it never answers “what should this service be allowed to do?” You answer the second question. Use the generated .te as a list of the operations the daemon attempted, then express each with the right construct (a private type, a named interface, a narrow allow) in a module you actually maintain.

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 that break on the next policy update. Discover them with sepolicy:

dnf install -y selinux-policy-devel policycoreutils-devel   # provides the interfaces + Makefile

sepolicy interface -l | grep -i 'read.*log'         # discover interfaces by keyword
sepolicy interface -v logging_read_generic_logs     # show exactly what one expands to
sepolicy generate --init /usr/sbin/mydaemon         # scaffold a full .te/.if/.fc skeleton for a daemon

sepolicy generate --init is the right scaffolder (as opposed to audit2allow): it produces a .te, .if, and .fc skeleton with the domain declaration, the init_daemon_domain transition, and common interface calls already wired — a far better starting point than a pile of raw allows. The three source-file types you will write:

File Purpose Key contents
.te (Type Enforcement) The policy logic policy_module(), type declarations, allow rules, interface calls, transitions
.fc (File Contexts) Path→label mapping Regex-to-gen_context() lines that restorecon and the module install honour
.if (Interface) Reusable interfaces your module exports interface(...) definitions so other modules can call into your types

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 transition from init, private data types, 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 the init transition
type mydaemon_t;
type mydaemon_exec_t;
init_daemon_domain(mydaemon_t, mydaemon_exec_t)   # binary -> domain transition + entrypoint

# A PRIVATE port type this daemon (and nothing else) may bind
type mydaemon_port_t;
corenet_port(mydaemon_port_t)

# PRIVATE data types — the whole point of confinement
type mydaemon_conf_t;
files_config_file(mydaemon_conf_t)                 # marks it a config file type

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

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

########################################
# Local policy

# Minimal capabilities — setgid/setuid to drop privileges after binding
allow mydaemon_t self:capability { setgid setuid };
allow mydaemon_t self:tcp_socket create_stream_socket_perms;

# Bind ONLY its own port type
allow mydaemon_t mydaemon_port_t:tcp_socket name_bind;

# Read its own configuration (pattern macros pull in dir search + file read)
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, with a file transition so NEW logs get the right label
manage_files_pattern(mydaemon_t, mydaemon_log_t, mydaemon_log_t)
manage_dirs_pattern(mydaemon_t, mydaemon_log_t, mydaemon_log_t)
logging_log_filetrans(mydaemon_t, mydaemon_log_t, { file dir })

# Manage its own pidfile under /run, with a file transition
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 almost every daemon needs
sysnet_dns_name_resolve(mydaemon_t)                # resolve hostnames
logging_send_syslog_msg(mydaemon_t)                # write to the syslog socket

Pair it with a file context (.fc) file so the labels are applied by path — this is what makes restorecon (and the module install) 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 or etc_t. A compromise of mydaemon cannot touch other services’ logs or config, cannot bind other services’ ports, cannot read arbitrary /etc. That isolation is the entire value proposition, and you only get it by declaring private types and using file transitions. The macros doing the heavy lifting, decoded:

Macro / interface What it does Why you use it over raw rules
policy_module(name, ver) Declares the module and pulls in base requires Boilerplate every module needs
init_daemon_domain(t, exec_t) Domain + entrypoint + init_t→domain transition The one call that confines a systemd daemon correctly
files_config_file(t) Marks a type as a config file Gets the right attributes for /etc-style files
logging_log_file(t) Marks a type as a log file Correct attributes for /var/log files
files_pid_file(t) Marks a type as a runtime/pid file Correct attributes for /run files
corenet_port(t) Declares a bindable port type Lets you own a private port type
read_files_pattern(d, t, t) Domain d reads files of type t in dirs of t Bundles dir-search + file-read correctly
manage_files_pattern(d, t, t) Full create/read/write/delete on type t One call for complete file management
logging_log_filetrans(d, t, cls) New files the domain makes in log dirs get type t So rotated/new logs are labelled automatically
files_pid_filetrans(d, t, file) Pidfiles the domain creates get type t So /run/mydaemon/… is labelled on creation
sysnet_dns_name_resolve(d) Everything needed to resolve DNS Dozens of correct rules in one line
logging_send_syslog_msg(d) Write to the syslog/journal socket Correct socket access without hand-rolling

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 and links them:

# requires selinux-policy-devel; run from the directory holding mydaemon.{te,fc,if}
make -f /usr/share/selinux/devel/Makefile mydaemon.pp

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

checkmodule -M -m -o mydaemon.mod mydaemon.te     # compile the module (-M enables MLS/MCS)
semodule_package -o mydaemon.pp -m mydaemon.mod \
    -f mydaemon.fc                                 # package .mod + .fc into a loadable .pp

Now the safe rollout. Never load a fresh module straight into enforcing on a production node. Install it, make only its domain permissive, relabel its files, and start it:

semodule -i mydaemon.pp                            # install (upgrade in place if re-run)
semanage permissive -a mydaemon_t                  # make ONLY this domain permissive
restorecon -Rv /usr/sbin/mydaemon /etc/mydaemon /var/log/mydaemon /run/mydaemon
systemctl restart mydaemon

Exercise every code path — start, reload, rotate logs, hit every endpoint, trigger error paths — then harvest any residual denials the domain logged while permissive. audit2allow -R suggests interfaces (the -R = reference policy) rather than raw allows, which is exactly what you want to fold back in:

ausearch -m AVC -c mydaemon -ts recent | audit2allow -R
# review the suggested interfaces, add the legitimate ones to mydaemon.te, rebuild

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

make -f /usr/share/selinux/devel/Makefile mydaemon.pp
semodule -i mydaemon.pp
semanage permissive -d mydaemon_t                  # remove the exception → enforcing for it
systemctl restart mydaemon
ausearch -m AVC -c mydaemon -ts recent             # should now be EMPTY

The full lifecycle of module commands, so you never guess the flag:

Command Action Note
semodule -i mydaemon.pp Install (or upgrade) a module Idempotent; re-run to update
semodule -l List installed modules Confirm yours is loaded
semodule -l | grep mydaemon Check one module Should print its name
semodule -r mydaemon Remove a module Reference by name, not .pp
semodule -B Rebuild/reload the active policy After -D, re-enables dontaudit
semodule -DB Rebuild with dontaudit disabled For seeing silenced denials; undo with -B
semodule -e mydaemon / -d mydaemon Enable / disable a module without removing Toggle without uninstalling
sesearch -A -s mydaemon_t Show the live allow rules for the domain Verify what actually loaded
seinfo -t | grep mydaemon Confirm your private types exist Sanity-check declarations

To audit the loaded policy — verifying containment, diffing before/after a change, answering “can this domain really do X?” — sesearch and seinfo (from setools-console) are the query tools. Keep this reference for policy review and CI diffs:

Command Answers the question Typical use
sesearch -A -s <domain> What may this domain do? Prove containment: “it can do only this”
sesearch -A -t <type> Who may act on this type? Find every domain that can touch a resource
sesearch -A -s <d> -t <t> -c file May domain d do anything to files of type t? Confirm a specific allow exists
sesearch -T -s <domain> What type_transitions apply? Verify the daemon transitions correctly
sesearch --neverallow Any neverallow rules relevant? Catch a rule that will fail the build
seinfo -t | grep <x> Does this type exist in loaded policy? Sanity-check your declarations loaded
seinfo -aportcon / seinfo --portcon Which ports map to which types? Cross-check semanage port -l
seinfo -b | grep <x> Which booleans exist? Discover a gating boolean
seinfo --stats How big is the loaded policy? Baseline; spot a huge unexpected change
sediff base.pp new.pp What changed between two policies? CI diff / review a policy change like code

MCS, MLS, and multi-level: the fourth field earns its keep

The level field — s0, s0:c1,c2, s0-s3:c0.c1023 — is the sensitivity-and-categories part of the context, and two schemes use it:

The scheme comparison:

Scheme Field usage Where you meet it Enforcement rule
Plain targeted s0 only Every default RHEL/Fedora box Type enforcement only; level is inert
MCS s0:c<n>,c<m> categories Containers (Podman/CRI-O), some multi-tenant Category set must dominate to access
MLS s0s15 sensitivities + categories Defence/government (mls policy) Bell-LaPadula: no read-up, no write-down

For containers, the practical knowledge is volume relabelling. When you bind-mount a host directory into a container, the host path has a normal type (say default_t or user_home_t) that container_t cannot read. Podman/Docker relabel on request:

# :z  -> relabel the volume as SHARED among containers (type svirt_sandbox_file_t / container_file_t)
# :Z  -> relabel as PRIVATE to THIS container (adds its unique MCS categories)
podman run -v /srv/appdata:/data:Z  myimage      # private, per-container categories
podman run -v /srv/shared:/data:z   myimage      # shared across containers

ls -Z /srv/appdata          # after :Z -> ...:container_file_t:s0:c123,c456

The :z/:Z distinction and its gotcha:

Volume flag Relabels to Category behaviour Use when Gotcha
:z (lowercase) container_file_t s0 (no unique categories) Multiple containers share the data Any container can read it — less isolation
:Z (uppercase) container_file_t The container’s unique c<n>,c<m> One container owns the data Only that container instance reads it
(no flag) unchanged host type kept The path is already container-labelled container_t likely can’t read it → denial

The classic container SELinux denial is a bind mount with no :z/:Z — the container gets Permission denied on /data even though the host mode bits are 0777, because the type is wrong. ls -Z on the host path plus ausearch -m AVC -ts recent (look for scontext=...container_t... and tcontext=...default_t...) confirms it, and adding :Z fixes it. There is also a boolean, container_use_cephfs and friends, and for the rare case where you must let containers touch host files broadly, setsebool -P container_manage_cgroup on and related switches — but reach for :Z first.

Architecture at a glance

There is no diagram for this topic — the model is a decision flow you run in your head, so picture it as a pipeline from symptom to fix. A request arrives at a confined daemon; the kernel’s LSM hook checks the DAC bits first, and only if those pass does it consult the SELinux policy. If the policy has an allow rule for scontext → operation → tcontext, the access proceeds silently. If not, the kernel writes an AVC record to audit.log and — in enforcing mode — returns EACCES to the process, which surfaces as a failed daemon, a 503, a “permission denied,” or a container that cannot read its volume.

Now trace the diagnostic path the way you would walk a flowchart. Every incident starts at one question: did SELinux actually block this? Run getenforce (are we even enforcing?) and ausearch -m AVC -ts recent (is there a denial, and is permissive=0?). A real denial routes down one of four branches by reading three fields. If the tcontext is a file type that looks wrong (default_t, user_home_t), you are on the labelling branch — restorecon, and register with semanage fcontext if the path is non-standard. If the permission is { name_bind }/{ name_connect } on a *_socket with a dest= port, you are on the port branch — semanage port -a. If audit2why names a boolean, you are on the boolean branch — setsebool -P. Only if all three are ruled out — labels correct, port registered, booleans on — do you reach the fourth branch: a genuine policy gap, where you scaffold with sepolicy generate/audit2allow -M, rewrite it cleanly with private types and interfaces, and roll it out under semanage permissive before flipping to enforcing.

The convergence point of every branch is the same instrument set: ps -eZ (is the actor in the domain you think?), ls -Z (is the target labelled the way you think?), ausearch/audit2why (what exactly was refused and why?), and sesearch/seinfo (what does the loaded policy actually say?). That is the whole method — localise the symptom to a branch by reading scontext, tcontext, and the permission; apply the branch’s fix; confirm with a clean ausearch. The mental picture is not a network diagram; it is this four-way fork, and the fastest engineers are the ones who read the three fields and pick the branch in seconds.

Real-world scenario

Meridian Pay, a fictional but representative payments processor, ran a fleet of about 40 RHEL 8 hosts under a hard PCI-DSS requirement: SELinux stays enforcing, no exceptions, audited quarterly by an external QSA. The platform team was six engineers; the constraint on the wall read setenforce 0 is a reportable incident.”

The trouble started when a vendor shipped a new log-shipping agent — a closed-source Go binary dropped at /opt/vendor/bin/shipper, started by a systemd unit — that refused to work correctly after install. systemctl status shipper showed the process running, so the on-call engineer nearly closed the ticket, but downstream the central log platform showed no data arriving. journalctl -u shipper was unhelpful (a generic “failed to open source” line). The vendor’s documentation, predictably, said: “If logs are not shipping, run setenforce 0.” On this fleet that instruction was a non-starter.

The breakthrough came from asking the right first question. ps -eZ | grep shipper revealed the real problem: the agent was running in init_t — completely unconfined — because its binary was labelled bin_t and there was no policy module to transition it. So SELinux was not confining it at all, yet it was still failing, which seemed contradictory. ausearch -m AVC -c shipper -ts recent explained it: the agent needed to read every other service’s logs under /var/log to ship them, and even an unconfined process in init_t trips category and type denials when it reaches into files labelled for other confined domains (httpd_log_t, postgresql_log_t, and MCS-categorised files). The agent was getting { read } denied on those precise files — the worst-of-both-worlds case: unconfined enough to be a security hole, still confined enough to fail.

The team refused both the vendor’s setenforce 0 and the lazy audit2allow -a -M shipper dump (which would have granted dac_read_search and broad var_log_t writes — a QSA red flag). Instead they gave the agent a real domain and exactly one tightly scoped read interface:

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, no delete
logging_read_all_logs(shipper_t)
allow shipper_t self:capability { dac_read_search };   # to traverse other services' log dirs
sysnet_dns_name_resolve(shipper_t)                     # resolve the log platform's hostname
allow shipper_t self:tcp_socket create_stream_socket_perms;

They labelled the binary and rolled out safely rather than straight to enforcing:

semanage fcontext -a -t shipper_exec_t '/opt/vendor/bin/shipper'
restorecon -v /opt/vendor/bin/shipper
make -f /usr/share/selinux/devel/Makefile shipper.pp
semodule -i shipper.pp
semanage permissive -a shipper_t          # observe first, block nothing
systemctl restart shipper                 # now runs in shipper_t, confined
# ... exercise a full shipping cycle across all log sources, harvest residual denials ...
ausearch -m AVC -c shipper -ts recent | audit2allow -R
# ... one extra interface for the syslog socket added and reviewed ...
semanage permissive -d shipper_t          # flip shipper_t to enforcing

The outcome, as numbers: the agent moved from init_t (unconfined) to shipper_t (confined to read-only log access plus DNS and one outbound socket), logs flowed to the platform, and the residual ausearch -m AVC -c shipper was clean. The quarterly QSA review passed with no finding — and, tellingly, the security team could now point at sesearch -A -s shipper_t and prove the agent could read logs but not write anywhere, connect only outbound, and touch nothing else. The lesson written on the runbook: “A denial on an unconfined daemon means it needs a domain, not setenforce 0. Give it the smallest domain that works.”

The incident as a timeline, because the order of moves is the lesson:

Step Observation Action taken Effect What it should have been
1 systemctl status = running (nearly closed the ticket) Check the data, not just the process
2 No logs at the platform journalctl -u shipper Generic “failed to open source” Correct next step
3 Cause unclear ps -eZ | grep shipperinit_t Found it runs unconfined The breakthrough
4 Why fail if unconfined? ausearch -m AVC -c shipper { read } denied on other domains’ logs
5 Vendor says disable SELinux (refused) Never on a PCI fleet
6 Tempting shortcut (rejected audit2allow -a -M) Would grant broad caps/writes Read the .te; write it clean
7 Real fix Dedicated domain + logging_read_all_logs Confined, read-only, working The actual fix
8 Safe rollout semanage permissive -a, harvest, then enforce Clean ausearch; QSA passes

Advantages and disadvantages

Type enforcement is powerful precisely because it is mandatory and default-deny, and those same properties are what make it feel obstructive until you learn to read it. Weigh it honestly:

Advantages (why enforcing SELinux is worth it) Disadvantages (why it bites)
Contains a compromised daemon to only what it legitimately needs — an RCE in httpd can’t read /etc/shadow or pivot The surface symptom lies: a generic app error, not “SELinux denied X”; you must know to check ausearch
Default-deny: anything not written is refused, so unforeseen operations are blocked, not silently allowed Labelling is a second permission system layered on DAC; mv vs cp and relabels create non-obvious failures
Every denial is logged with the exact actor, target, and operation — precise, machine-readable diagnostics The dontaudit mechanism can make a real denial invisible until you semodule -DB
Booleans let you tune common variations without writing policy — one persistent flag flips a whole behaviour Forgetting -P on setsebool gives the classic “works until reboot” bug
Per-domain permissive lets you bring up a new daemon in prod without exposing the machine Third-party daemons often ship no policy and run unconfined in init_t — a false sense of security
Private types + interfaces give a compromised service zero reach into other services’ data Writing good policy is a real skill; raw audit2allow output actively teaches bad habits
MCS isolates containers on a shared host for free — two containers can’t read each other’s files Volume mounts without :z/:Z produce confusing “permission denied” on 0777 paths
Meets hard compliance controls (PCI-DSS, STIG, CIS L2, FedRAMP) that mandate MAC setenforce 0 under pressure is a real, common, and reportable failure mode

The model is right for any internet-facing or multi-tenant host, any regulated workload, and anywhere a single daemon compromise must not become a full host compromise. It bites hardest on teams that treat every denial as an obstacle rather than data, on shops shipping in-house daemons with no policy, and on anyone who has normalised chcon-in-automation or setenforce 0-in-incidents. Every one of the disadvantages is manageable — but only if you know it exists, which is the entire point of this article.

Hands-on lab

Reproduce three classic denials (a mislabelled file, an unregistered port, an off boolean), then build and confine a tiny custom daemon end to end. Run on a RHEL 9 / Rocky 9 / AlmaLinux 9 VM you can throw away. Everything is reversible; teardown is at the end.

Step 0 — Confirm enforcing and install the tooling.

getenforce                                   # must print: Enforcing
sudo dnf install -y policycoreutils policycoreutils-devel \
    setroubleshoot-server selinux-policy-devel httpd

Expected: Enforcing, and the packages install (they provide semanage, sepolicy, audit2allow, sealert, and the devel Makefile).

Step 1 — Reproduce a wrong-file-context denial with httpd. Serve content from a non-standard directory and watch it fail:

sudo mkdir -p /srv/webdemo
echo '<h1>hello</h1>' | sudo tee /srv/webdemo/index.html
sudo sed -i 's#^DocumentRoot .*#DocumentRoot "/srv/webdemo"#' /etc/httpd/conf/httpd.conf
sudo systemctl restart httpd
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/index.html   # expect 403

Confirm it is SELinux, not Apache config:

sudo ausearch -m AVC -c httpd -ts recent | sudo audit2why | tail -n 8
# ... "you may need to relabel ..." OR a name_bind/read denial on default_t
ls -Z /srv/webdemo/index.html               # ...default_t... (wrong for httpd)

Step 2 — Fix it the right way: register the path, then restore.

sudo semanage fcontext -a -t httpd_sys_content_t '/srv/webdemo(/.*)?'
sudo restorecon -Rv /srv/webdemo
ls -Z /srv/webdemo/index.html               # now ...httpd_sys_content_t...
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/index.html   # now 200

Validation: the curl returns 200, ls -Z shows httpd_sys_content_t, and a fresh ausearch -m AVC -c httpd -ts recent is empty.

Step 3 — Reproduce and fix an unregistered-port denial. Make httpd listen on 8443 (not in http_port_t by default? it is — so use 8888 which is not):

sudo sed -i 's/^Listen 80$/Listen 8888/' /etc/httpd/conf/httpd.conf
sudo systemctl restart httpd                # FAILS to bind
sudo ausearch -m AVC -c httpd -ts recent | tail -n 6
# ... avc: denied { name_bind } ... tclass=tcp_socket ... dest=8888 ...

sudo semanage port -a -t http_port_t -p tcp 8888   # register the port
sudo systemctl restart httpd                       # now starts
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8888/index.html   # 200

Validation: the service starts after semanage port -a, and semanage port -l | grep 8888 shows http_port_t tcp ... 8888.

Step 4 — Reproduce and fix a boolean denial. Have httpd make an outbound connection (simulate with a reverse-proxy or a script); the boolean httpd_can_network_connect gates it. Demonstrate the boolean directly:

getsebool httpd_can_network_connect          # httpd_can_network_connect --> off
sudo setsebool -P httpd_can_network_connect on
getsebool httpd_can_network_connect          # --> on   (and PERSISTENT thanks to -P)

Validation: the boolean reads on, and because you used -P it survives sudo reboot (the -P-less form would revert).

Step 5 — Build and confine a tiny custom daemon. Create a trivial daemon, give it a domain, and confine it. First the daemon and its unit:

sudo tee /usr/local/sbin/mydaemon >/dev/null <<'EOF'
#!/usr/bin/env bash
# a trivial "daemon": listen on a TCP port, log to its own log dir
mkdir -p /var/log/mydaemon
exec 2>>/var/log/mydaemon/mydaemon.log
echo "$(date) starting on 9099" >>/var/log/mydaemon/mydaemon.log
exec /usr/bin/ncat -lk -p 9099 -c 'echo ok'
EOF
sudo chmod 0755 /usr/local/sbin/mydaemon
sudo dnf install -y nmap-ncat

sudo tee /etc/systemd/system/mydaemon.service >/dev/null <<'EOF'
[Unit]
Description=mydaemon lab
[Service]
ExecStart=/usr/local/sbin/mydaemon
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl start mydaemon
ps -eZ | grep mydaemon        # note: runs in init_t (UNCONFINED) — the trap

Now author the policy. Create mydaemon.te and mydaemon.fc:

cat > /tmp/mydaemon.te <<'EOF'
policy_module(mydaemon, 1.0.0)

require { type init_t; }

type mydaemon_t;
type mydaemon_exec_t;
init_daemon_domain(mydaemon_t, mydaemon_exec_t)

type mydaemon_port_t;
corenet_port(mydaemon_port_t)

type mydaemon_log_t;
logging_log_file(mydaemon_log_t)

allow mydaemon_t self:capability { setgid setuid };
allow mydaemon_t self:tcp_socket create_stream_socket_perms;
allow mydaemon_t mydaemon_port_t:tcp_socket name_bind;

manage_files_pattern(mydaemon_t, mydaemon_log_t, mydaemon_log_t)
manage_dirs_pattern(mydaemon_t, mydaemon_log_t, mydaemon_log_t)
logging_log_filetrans(mydaemon_t, mydaemon_log_t, { file dir })
EOF

cat > /tmp/mydaemon.fc <<'EOF'
/usr/local/sbin/mydaemon    --      gen_context(system_u:object_r:mydaemon_exec_t,s0)
/var/log/mydaemon(/.*)?             gen_context(system_u:object_r:mydaemon_log_t,s0)
EOF

Build, register the port and labels, install, and roll out permissive-first:

cd /tmp && make -f /usr/share/selinux/devel/Makefile mydaemon.pp
sudo semodule -i mydaemon.pp
sudo semanage port -a -t mydaemon_port_t -p tcp 9099
sudo restorecon -Rv /usr/local/sbin/mydaemon /var/log/mydaemon
sudo semanage permissive -a mydaemon_t        # bring up permissive, block nothing
sudo systemctl restart mydaemon
ps -eZ | grep mydaemon                        # now in mydaemon_t (confined)

Exercise it, harvest residual denials, then go enforcing:

printf '' | ncat localhost 9099               # hit the port a few times
sudo ausearch -m AVC -c mydaemon -ts recent | audit2allow -R   # review any gaps
sudo semanage permissive -d mydaemon_t        # flip mydaemon_t to enforcing
sudo systemctl restart mydaemon
sudo ausearch -m AVC -c mydaemon -ts recent   # should be empty

Step 6 — Validate the end state.

getenforce                                    # Enforcing
ps -eZ | grep mydaemon                        # mydaemon_t (NOT init_t)
sudo semodule -l | grep mydaemon              # module loaded
sudo semanage permissive -l | grep mydaemon   # NO output = enforcing for this domain
sudo sesearch -A -s mydaemon_t | head         # inspect the live allow rules
sudo seinfo -t | grep mydaemon                # your private types exist

Validation checklist: the process is in mydaemon_t, the module is listed, the permissive list does not mention mydaemon_t, and ausearch is clean.

Step 7 — Teardown. Reverse everything so the box is pristine:

sudo systemctl disable --now mydaemon
sudo rm -f /etc/systemd/system/mydaemon.service /usr/local/sbin/mydaemon
sudo systemctl daemon-reload
sudo semodule -r mydaemon                      # remove by NAME, not .pp
sudo semanage port -d -t mydaemon_port_t -p tcp 9099 2>/dev/null || true
sudo semanage port -d -t http_port_t -p tcp 8888 2>/dev/null || true
sudo semanage fcontext -d '/srv/webdemo(/.*)?' 2>/dev/null || true
sudo rm -rf /srv/webdemo /var/log/mydaemon
sudo setsebool -P httpd_can_network_connect off
sudo dnf remove -y httpd nmap-ncat 2>/dev/null || true
rm -f /tmp/mydaemon.*

Common mistakes & troubleshooting — the AVC-denial playbook

This is the reference you keep open at 02:00. Each row is a symptom you actually see, the root cause, the exact command to confirm it, and the fix. Read the prose once; keep the table open during the incident. The columns are deliberately in confirm-then-fix order — never fix before you have confirmed with ausearch/ls -Z/ps -eZ.

# Symptom Root cause Confirm (exact command) Fix
1 httpd 403s on content it should serve File has wrong type after mv ls -Z /path; restorecon -nv /path restorecon -Rv /path
2 Content in a non-standard dir won’t serve Path not in fcontext DB semanage fcontext -l | grep /srv/... (empty) semanage fcontext -a -t httpd_sys_content_t '/srv/x(/.*)?'; restorecon -Rv /srv/x
3 Daemon won’t bind a non-standard port Port not registered to the domain’s port type ausearch -m AVC -ts recent{ name_bind } ... dest=<port> semanage port -a -t <type>_port_t -p tcp <port>
4 “already defined” when adding a port Port belongs to another type semanage port -l | grep <port> semanage port -m -t <type> -p tcp <port> (modify, not add)
5 Feature works only after setenforce 0 A boolean gates it ausearch -m AVC -ts recent | audit2why (names the boolean) setsebool -P <boolean> on
6 Fix reverts after reboot setsebool/chcon used without persistence getsebool <b> shows off again; ls -Z shows old label Use setsebool -P / semanage fcontext + restorecon
7 Third-party daemon “runs” but isn’t protected Binary bin_t, no transition → init_t ps -eZ | grep <daemon> shows init_t Write a module: *_exec_t + init_daemon_domain()
8 Daemon fails at startup, generic exit Confined domain denied a startup op journalctl -u X; ausearch -m AVC -c X -ts recent Read the denial; apply the matching branch (label/port/boolean/module)
9 Denial mentions execmem/execstack App maps writable+executable memory ausearch -m AVC -ts recent{ execmem } Fix the app (or, last resort, the specific *_execmem boolean)
10 Container gets “permission denied” on a 0777 mount Volume not relabelled for containers ls -Z /hostpath (host type, not container_file_t) Mount with :Z (private) or :z (shared)
11 Two containers can/can’t see each other’s files unexpectedly MCS categories (:Z isolates, :z shares) ls -Z /hostpaths0 vs s0:c<n>,c<m> Choose :z for shared, :Z for isolated
12 Service definitely broken by SELinux, but no denials logged A dontaudit rule silences it semodule -DB; reproduce; ausearch -m AVC -ts recent; semodule -B Read the now-visible denial; then fix; re-enable dontaudit
13 “Denial” appears but disabling SELinux doesn’t help You’re reading permissive=1 records ausearch -m AVC -ts recent → check permissive= field It wasn’t blocking — look elsewhere for the real cause
14 Denials on a mount that has correct labels on local disk NFS/CIFS mount; xattrs not used ls -Z /mnt shows one generic type (nfs_t) Use the mount context or the httpd_use_nfs/use_nfs_home_dirs boolean
15 Log rotation breaks the service Rotated logs got the wrong type ls -Z /var/log/app/* (mismatch on new files) Add a logging_log_filetrans (or restorecon post-rotate)
16 App reads/writes an unexpected /tmp path denied Private tmp (PrivateTmp=yes) or wrong tmp type ausearchtcontext=...tmp_t; check the unit Use manage_files_pattern on the domain’s tmp type / files_tmp_filetrans
17 restorecon “works” but the label reverts on next boot chcon in automation racing restorecon grep -r chcon /etc/ansible /etc/puppet ... Replace chcon with semanage fcontext -a + restorecon
18 Whole app breaks after enabling SELinux from Disabled On-disk labels drifted while disabled getenforce history; mislabels everywhere Full relabel: fixfiles onboot; reboot (or touch /.autorelabel)
19 Sudden mass denials after a package update Vendor policy changed a type/interface rpm -q --changelog selinux-policy | head; ausearch Rebuild/adjust your module against the new interfaces
20 Cron/systemd job denied though the command works by hand Different domain when run by cron/systemd vs your shell ps -eZ of the job vs id -Z in your shell Add rules for the service domain, not unconfined_t
21 sealert suggests a module — is it safe to run it? The suggestion is raw audit2allow, may be too broad Read the proposed .te it prints Rewrite with private types/interfaces; don’t blind-load
22 Denial { connectto } unix_stream_socket between two daemons One domain talks to another’s socket ausearchtcontext is the other domain Use the correct <other>_stream_connect() interface

A few deserve a sentence beyond the table. Row 12 (invisible denials) is the one that makes people think SELinux breaks things “randomly”: the denial is real but dontaudit-silenced, and until you semodule -DB you debug blind — always try this when a service is clearly SELinux-affected yet ausearch is empty. Row 13 (permissive=1) is its inverse: chasing a “denial” that never blocked anything because that domain is permissive. Row 18 (relabel after Disabled) is why the standing advice is “permissive if worried, never disabled” — permissive keeps labels current, disabled lets them rot. And row 7 (unconfined init_t) is the quiet one: nothing fails, so no ticket is filed, yet the daemon has no protection at all — sweep for it proactively with ps -eZ.

Best practices

Security notes

The security value of SELinux is containment, and every practice above either preserves or squanders it. Least privilege is the governing principle: a custom domain should be able to do exactly what the daemon needs and nothing more, which is why private types and narrow interfaces beat broad allows — a compromise of the daemon then reaches only its own config, its own logs, and its own port. Auditability is the second pillar: because every denial is logged with the exact actor and target, a confined, enforcing host produces a forensic trail that an unconfined one cannot; this is why the audit log this article reads pairs directly with Building a Linux Audit Trail with auditd and eBPF Runtime Visibility.

Specific hardening points that matter for this topic:

Concern The SELinux control Why it matters
A daemon compromise reading secrets Private types; no dac_override/dac_read_search Contains RCE to the daemon’s own files, not /etc/shadow
Writable+executable memory (exploit staging) Deny execmem/execstack; never grant to “fix” an app Preserves W^X; a granted execmem is a gift to an attacker
Lateral movement between services Type enforcement + private port types httpd can’t connect to the DB socket unless policy says so
Multi-tenant/container isolation MCS categories (:Z volumes) Two containers as the same UID still can’t read each other’s data
Privilege escalation via mislabelled setuid Correct *_exec_t labels + entrypoints Prevents a domain executing something in the wrong context
Bypassing the whole control Enforcing mode; no setenforce 0 Permissive/disabled removes the mandatory guarantee

Two anti-patterns are security-relevant enough to restate: shipping raw audit2allow output trains your team to grant exactly the permissions a real exploit wants — execmem, dac_override, writes to system types — so read the .te every time; and running a daemon unconfined in init_t gives a false sense of protection worse than knowing you have none, because you will not compensate elsewhere. When in doubt, prove containment with sesearch -A -s <domain> — if you cannot point at the rules and say “it can do only this,” the module is not done.

Cost & sizing

SELinux has no licence cost — it ships in the kernel and the base OS on every RHEL-family and Fedora distribution — so the “cost” is entirely operational: engineering time to read denials, author and maintain policy, and a small, usually negligible, runtime overhead. Budget in INR/USD terms is about people, not infrastructure.

Cost dimension Rough magnitude What drives it How to reduce it
Runtime CPU overhead ~1–3% typical; higher on syscall-heavy workloads LSM hook on every access decision Usually not worth optimising; profile only if extreme
Memory A few tens of MB for the loaded policy Policy size (targeted is modest) Not a concern on server-class RAM
Labelling / relabel time Seconds to many minutes on a full relabel Filesystem size and inode count Avoid Disabled (which forces relabels); use restorecon -R on subtrees
Engineer time — triage Minutes per denial once you know the method Team’s SELinux fluency This article; runbooks; sealert for suggestions
Engineer time — policy authoring Hours per new custom domain Daemon complexity; interface familiarity sepolicy generate scaffolding; reuse interfaces; CI
Engineer time — the setenforce 0 tax Potentially enormous (a breach, an audit finding) Disabling instead of diagnosing Culture: treat denials as data; forbid disabling

The sizing guidance is really a staffing observation: the marginal cost of keeping SELinux enforcing, once your team is fluent, is small — a few minutes per denial and a few hours per genuinely new daemon. The cost of not being fluent is where the money hides: a single setenforce 0 that turns a containable RCE into a full host compromise, or a failed audit, dwarfs any policy-authoring time. On the relabel front, size maintenance windows for a full fixfiles on your largest hosts and parallelise with restorecon -Rp where you can — but the best cost control is never going to Disabled, so a relabel is never forced on you.

Interview & exam questions

Q1. What is the difference between DAC and MAC, and which is checked first? DAC (discretionary access control) is the traditional Unix owner/group/mode model, where the resource owner decides access. MAC (mandatory access control) is SELinux’s system-wide policy that the owner cannot override. DAC is checked first; a SELinux denial in audit.log means DAC already passed and SELinux is the one refusing. Maps to RHCE/RHCSA and CIS hardening objectives.

Q2. In the context system_u:system_r:httpd_t:s0, which field does enforcement work, and what is it called for a process versus a file? The third field, the type. For a process it is called the domain (httpd_t); for a file it is the type or label (httpd_sys_content_t). In default targeted policy the user and role fields are largely inert; the type is what you troubleshoot.

Q3. Read this denial: avc: denied { name_bind } ... tclass=tcp_socket ... dest=8888. What’s wrong and how do you fix it? A domain tried to bind TCP port 8888, which is not registered to that domain’s port type. Confirm with semanage port -l | grep 8888 (absent), fix with semanage port -a -t <type>_port_t -p tcp 8888. This is the “port” branch of triage.

Q4. A file has -rw-r--r-- root root but a root process can’t read it. Why, and what do you run? The Unix mode bits allow it, so the block is SELinux: the file’s type is wrong for the reading domain (commonly after a mv preserved a foreign label). Confirm with ls -Z and ausearch -m AVC, fix with restorecon -Rv <path> (register with semanage fcontext first if the path is non-standard).

Q5. Why is chcon dangerous in a configuration-management role? chcon sets a label imperatively and does not register it in the fcontext database, so any subsequent restorecon -R, fixfiles, or /.autorelabel reverts it and the service breaks with no obvious cause. Use semanage fcontext -a + restorecon, which survives relabels.

Q6. What does setsebool -P do that setsebool alone does not? -P writes the boolean change to the policy store so it persists across reboots. Without -P the change is runtime-only and reverts on the next boot — the classic “it worked until we rebooted” bug.

Q7. A third-party daemon “works fine” but ps -eZ shows it in init_t. Is that good? No — init_t means it is effectively unconfined: its binary is only bin_t, so no transition fired and SELinux protects nothing. The fix is to write a module with an *_exec_t label and init_daemon_domain() so it transitions into a dedicated, minimal domain.

Q8. Why should you never blindly load audit2allow output? audit2allow answers “what would unblock this?”, not “what should this be allowed to do?” It will happily generate execmem, dac_override, and writes to shared system types — exactly the permissions an exploit wants. Read the .te, then express each need with private types and reference-policy interfaces.

Q9. How do you bring up a new confined daemon in production without risking an outage? Install the module, semanage permissive -a <domain> so only that domain logs-but-doesn’t-block, exercise every code path, harvest residual denials with ausearch ... | audit2allow -R, fold the legitimate ones in, rebuild, then semanage permissive -d <domain> to flip it to enforcing. The rest of the system stays enforcing throughout.

Q10. A container gets “permission denied” on a bind-mounted host directory that is chmod 0777. Why, and what’s the fix? The host path’s type is wrong for container_t (SELinux, not DAC, is refusing). Confirm with ls -Z on the host path (it shows a non-container type) and ausearch. Fix by mounting with :Z (relabel private to this container) or :z (relabel shared). This is MCS/container-selinux in action.

Q11. What does dontaudit do, and how can it mislead you during troubleshooting? dontaudit rules silence specific denials so they never reach the audit log. If a service is clearly broken by SELinux but ausearch shows nothing, a dontaudit rule may be hiding the denial. Disable them with semodule -DB, reproduce, then re-enable with semodule -B.

Q12. Why is going to Disabled worse than Permissive, even temporarily? In Disabled mode the kernel maintains no labels, so new and modified files never get a correct security.selinux xattr and the on-disk labels drift. Re-enabling then requires a full filesystem relabel (fixfiles onboot / /.autorelabel), which can be a long maintenance window. Permissive keeps labels current and is instantly reversible.

Quick check

  1. In user:role:type:level, which field does the enforcement, and what is it called for a process?
  2. You moved a web file with mv and now get a 403. What’s the one command that fixes the label, and why did mv cause it?
  3. A denial reads { name_connect } ... tclass=tcp_socket. Which of the three triage branches (context/port/boolean) is this, and what’s a likely boolean too?
  4. What flag makes a setsebool change survive a reboot, and what’s the failure mode without it?
  5. Your custom daemon shows init_t in ps -eZ. What does that mean for its security, and what must a policy module provide to fix it?

Answers

  1. The third field, the type; for a process it is called the domain (e.g. httpd_t). The user and role fields are largely inert in default targeted policy.
  2. restorecon -Rv <path>. mv preserves the file’s existing security.selinux xattr (its old label, e.g. user_home_t), whereas cp lets the destination policy assign a fresh one (httpd_sys_content_t); restorecon resets it to what policy says the path should have.
  3. It is the port branch — a service connecting/binding to an unregistered port; fix with semanage port -a -t <type> -p tcp <port>. For an outbound httpd connection the boolean httpd_can_network_connect (or the narrower httpd_can_network_connect_db) is frequently also required.
  4. -P (persistent). Without it the change is runtime-only and reverts on the next reboot — the “works until reboot” bug.
  5. init_t means the daemon is effectively unconfined — its binary is bin_t, no transition fired, and SELinux is protecting nothing. A module must provide an *_exec_t label on the binary and an entrypoint + type_transition (both from init_daemon_domain()) so it transitions into a dedicated confined domain.

Glossary

Next steps

SELinuxLinuxSecurityRHELPolicyHardeningaudit2allowTroubleshooting
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading