Introduction

SELinux provides powerful tools like booleans and port labels/types to adjust security policy without deep policy-writing knowledge, but those tools only cover what the system already knows about. When you deploy your own custom daemons or services, SELinux has no built-in context for them. You need to write a policy from scratch.

In this lab, I work through the full lifecycle of creating a custom SELinux policy for a custom binary called apachelogger. The process involves three phases:

  1. Generating a skeleton policy using sepolicy
  2. Diagnosing what the policy is missing by reading SELinux audit logs with ausearch
  3. Refining the policy by adding the correct interface calls and reapplying it

The goal is to move from a running-but-unconstrained daemon to one that is properly confined by SELinux with only the permissions it actually needs.


Environment

OS: RHEL 9 (AWS cloud instance) Tools used: sepolicy, ausearch, audit2allow, vim, systemctl, ps


Step 1 - Inspect the Custom Daemon

Before writing any policy, I need to understand what the daemon is and where it lives. I start by checking its status and reading its unit file.

mkdir policy
cd policy
sudo systemctl status apachelogger

Example output:

● apachelogger.service - Simple testing daemon
     Loaded: loaded (/usr/lib/systemd/system/apachelogger.service; disabled; preset: disabled)
     Active: active (running) since Wed 2026-05-20 13:42:08 UTC; 3min 42s ago
   Main PID: 2596 (apachelogger)
      Tasks: 1 (limit: 11750)
     Memory: 196.0K (peak: 452.0K)
        CPU: 7ms
     CGroup: /system.slice/apachelogger.service
             └─2596 /usr/local/bin/apachelogger

The daemon is active. I note its binary path: /usr/local/bin/apachelogger. I confirm this by reading the unit file directly:

cat /usr/lib/systemd/system/apachelogger.service

Example output:

[Unit]
Description=Simple testing daemon
 
[Service]
Type=simple
ExecStart=/usr/local/bin/apachelogger
 
[Install]
WantedBy=multi-user.target

The ExecStart line confirms the binary path, this is what I’ll pass to sepolicy generate.


Step 2 - Generate the Policy Skeleton

sepolicy generate builds a skeleton policy from the binary. The --init flag tells it the binary is a system daemon (started by init/systemd), which shapes the type enforcement rules it creates.

sudo sepolicy generate --init /usr/local/bin/apachelogger

Troubleshooting: RHUI Repository Error

Running this command on the lab server initially fails with a DNS resolution error:

Errors during downloading metadata for repository 'rhel-9-appstream-rhui-rpms':
  - Curl error (6): Couldn't resolve host name for
    https://rhui.REGION.aws.ce.redhat.com/...
dnf.exceptions.RepoError: Failed to download metadata for repo
  'rhel-9-appstream-rhui-rpms': Cannot prepare internal mirrorlist

Why this happens: Under the hood, sepolicy generate calls dnf to query system repositories for RPM metadata. This lab environment has no outbound internet access, so DNS resolution for AWS RHUI servers fails entirely.

Fix: Temporarily disable the RHUI repos so the command can run in an offline context:

sudo dnf config-manager --disable "*rhui*"
sudo sepolicy generate --init /usr/local/bin/apachelogger

Example output after fix:

Created the following files:
/home/cloud_user/policy/apachelogger.te   # Type Enforcement file
/home/cloud_user/policy/apachelogger.if   # Interface file
/home/cloud_user/policy/apachelogger.fc   # File Contexts file
/home/cloud_user/policy/apachelogger_selinux.spec  # Spec file
/home/cloud_user/policy/apachelogger.sh   # Setup Script

Verifying the files are present:

ls
apachelogger.fc  apachelogger.if  apachelogger_selinux.spec  apachelogger.sh  apachelogger.te

Each file plays a specific role:

FilePurpose
apachelogger.teType Enforcement - defines the actual allow rules
apachelogger.ifInterface - exposes macros for other policies to use
apachelogger.fcFile Contexts - maps file paths to SELinux labels
apachelogger.shBuild script - compiles and loads the policy module
apachelogger_selinux.specRPM spec - packages the policy for distribution

Step 3 - Apply the Initial Policy

I run the generated build script to compile and load the policy into the kernel:

sudo ./apachelogger.sh

The script does several things automatically: it compiles the .te file into a binary policy package (.pp), loads it with semodule, restores file contexts with restorecon, and generates a man page for the new domain. A key excerpt from the script shows the build steps:

make -f /usr/share/selinux/devel/Makefile apachelogger.pp || exit
/usr/sbin/semodule -i apachelogger.pp
sepolicy manpage -p . -d apachelogger_t
/sbin/restorecon -F -R -v /usr/local/bin/apachelogger

After the policy is loaded, I restart the daemon and confirm it now runs under the correct SELinux type:

sudo systemctl restart apachelogger
ps -efZ | grep apachelogger

Example output:

system_u:system_r:apachelogger_t:s0  root  2965  1  0 14:39 ?  00:00:00 /usr/local/bin/apachelogger

The apachelogger_t type is now present in the process label, meaning the daemon is running under its own SELinux domain. However, the policy is set to permissive mode for apachelogger_t, meaning denials are logged but not enforced yet. This is intentional, as I want to capture what the daemon actually tries to do before enforcing.


Step 4 - Diagnose Access Denials

With the daemon running in permissive mode, I query the audit log to see what it was denied:

sudo ausearch -m AVC -ts recent | grep apachelogger

AVC (Access Vector Cache)

Example output (relevant AVC lines):

type=AVC msg=audit(...): avc: denied { open } for pid=2965 comm="apachelogger"
  path="/var/log/httpd/access_log"
  scontext=system_u:system_r:apachelogger_t:s0
  tcontext=system_u:object_r:httpd_log_t:s0 tclass=file permissive=1

type=AVC msg=audit(...): avc: denied { write } for pid=2965 comm="apachelogger"
  name="access_log"
  scontext=system_u:system_r:apachelogger_t:s0
  tcontext=system_u:object_r:httpd_log_t:s0 tclass=file permissive=1

type=AVC msg=audit(...): avc: denied { search } for pid=2965 comm="apachelogger"
  name="httpd"
  scontext=system_u:system_r:apachelogger_t:s0
  tcontext=system_u:object_r:httpd_log_t:s0 tclass=dir permissive=1

Reading these AVC messages tells the story clearly:

  • Source context (scontext): apachelogger_t - the daemon’s domain
  • Target context (tcontext): httpd_log_t - Apache log files type
  • Denied actions: open, write on the file; search on the directory

The daemon is trying to open and write to /var/log/httpd/access_log, but its policy gives it no permission to touch anything labeled httpd_log_t.

👀 permissive=1 means SELinux logged the denial but did not enforce it, the action went through anyway.


Step 5 - Find the Right Policy Interfaces

Rather than writing raw allow rules, SELinux policy uses reusable interfaces, named macros that group related permissions together. I use audit2allow to suggest which interfaces apply here:

sudo ausearch -m AVC -ts recent | audit2allow -R

The output suggests two interfaces:

  • apache_manage_log(apachelogger_t) - grants full management of Apache log files
  • apache_read_log(apachelogger_t) - grants read-only access to Apache log files

To understand what each one actually does before adding them, I look them up in the SELinux interface library:

grep -r "apache_manage_log" /usr/share/selinux/devel/include/ | grep .if
grep -r "apache_read_log" /usr/share/selinux/devel/include/ | grep .if

Both point to /usr/share/selinux/devel/include/contrib/apache.if. Inspecting that file shows their definitions:

apache_manage_log - allows the domain to fully manage Apache log directories and files:

interface(`apache_manage_log',`
        gen_require(`
                type httpd_log_t;
        ')
        logging_search_logs($1)
        manage_dirs_pattern($1, httpd_log_t, httpd_log_t)
        manage_files_pattern($1, httpd_log_t, httpd_log_t)
        read_lnk_files_pattern($1, httpd_log_t, httpd_log_t)
')

apache_read_log - allows the domain to read Apache log files (list directory, read files and symlinks):

interface(`apache_read_log',`
        gen_require(`
                type httpd_log_t;
        ')
        logging_search_logs($1)
        allow $1 httpd_log_t:dir list_dir_perms;
        read_files_pattern($1, httpd_log_t, httpd_log_t)
        read_lnk_files_pattern($1, httpd_log_t, httpd_log_t)
')

Since apachelogger writes to the log file, it needs apache_manage_log. I add both to be thorough, covering read and write paths.


Step 6 - Update the Type Enforcement File

I edit apachelogger.te to add the two interface calls:

sudo vim apachelogger.te

I scroll to the bottom of the file and append the two lines:

apache_manage_log(apachelogger_t)
apache_read_log(apachelogger_t)

After saving, the complete .te file looks like this:

policy_module(apachelogger, 1.0.0)

########################################
#
# Declarations
#

type apachelogger_t;
type apachelogger_exec_t;
init_daemon_domain(apachelogger_t, apachelogger_exec_t)

permissive apachelogger_t;

########################################
#
# apachelogger local policy
#
allow apachelogger_t self:fifo_file rw_fifo_file_perms;
allow apachelogger_t self:unix_stream_socket create_stream_socket_perms;

domain_use_interactive_fds(apachelogger_t)

files_read_etc_files(apachelogger_t)

miscfiles_read_localization(apachelogger_t)
apache_manage_log(apachelogger_t)
apache_read_log(apachelogger_t)

Step 7 - Reapply and Verify

With the updated policy in place, I rebuild and reload it:

sudo ./apachelogger.sh
sudo systemctl restart apachelogger

Finally, I check the audit log one more time to confirm there are no new AVC denials:

sudo ausearch -m AVC -ts recent

Example output:

<no matches>

No denials. The daemon is now properly confined, running under its own apachelogger_t domain, with exactly the permissions it needs to access Apache log files and generating no AVC errors.


Summary

PhaseToolWhat it does
Inspectsystemctl, catLocate the binary and understand the service
Generatesepolicy generateCreate a skeleton policy from the binary
Apply./apachelogger.shCompile and load the policy module
Diagnoseausearch, audit2allowFind what the daemon was denied
Researchgrep, less on .if filesUnderstand what each interface permits
Refinevim apachelogger.teAdd the correct interface calls
VerifyausearchConfirm no remaining denials

The key insight in this workflow is the loop between applying, checking logs, and refining, as the permissive mode flag on apachelogger_t is what makes this iterative approach safe. The daemon runs, its access patterns get recorded, and you can tune the policy without ever breaking the service mid-diagnosis.