Skip to content

Repository files navigation

Process Slice Manager (Rust)

Complementary daemon for HestiaCP that makes php-fpm workers and dovecot session processes use the existing HestiaCP cgroup mechanism.

HestiaCP is the owner of cgroups: it sets the primary resource limits (CPUQuota, MemoryMax, ...) and is the source of truth for users and packages. The daemon does not replace HestiaCP's Resource Limits and never touches a slice that already exists.

After a reboot nothing recreates the user-<uid>.slice slices on their own (they only appear after the first Hestia event or a login), so the daemon recreates missing slices at startup, writing the user's HestiaCP limits (CPU_QUOTA / CPU_QUOTA_PERIOD / MEMORY_LIMIT / SWAP_LIMIT from user.conf) into cpu.max, memory.max and memory.swap.max - the same conversion the old bash daemon used.

The daemon also:

  • detects new php-fpm workers and dovecot session processes (imap, pop3, managesieve, dovecot-lda - which run as the authenticated mail user after login),
  • finds their owner (uid) and the owner's HestiaCP package,
  • moves them into a dedicated leaf scope of the owner's slice (user-<uid>.slice/psm-<uid>.scope). A leaf scope is required because cgroup v2 forbids processes directly inside a non-leaf cgroup: the user slice is non-leaf whenever a session-*.scope exists (e.g. an active SSH/SFTP login), so writing the slice's own cgroup.procs fails with EBUSY. The scope inherits the slice's limits, so the workers still count against the user's quota,
  • applies optional cgroup v2 attributes from the config for that package (e.g. cpu.weight) - attributes HestiaCP does not expose,
  • enforces a hard memory policy on every user slice: memory.max = MEMORY_LIMIT (hard limit), memory.high = max (no throttle) and memory.oom.group = 0 (no group kill). HestiaCP only sets memory.high
    • a throttle: once a slice's usage crosses it, the kernel stalls and reclaims the whole slice instead of killing one process, which freezes the user's PHP-FPM (the default pool has up to 8 workers, ~170 MB RSS each, easily exceeding a small package's limit). With the hard limit, the kernel OOM-kills a single worker (an occasional 500, respawned by PHP-FPM) instead of freezing the site. The daemon re-asserts this policy every cycle because HestiaCP's v-update-user-cgroup resets memory.high to the throttle on every user/package event.

Console/cron/CLI processes are already placed in the same slices by systemd, so the whole account's usage sums against one limit.

A process counts as a dovecot session process only when it actually lives under the dovecot.service cgroup - so a user running their own binary named imap is not picked up. The login pre-auth processes (imap-login, pop3-login, auth, anvil) run as dovenull/root and never match a HestiaCP user's uid.

This is a native Rust rewrite of the original bash daemon. No shell, jq, ps, grep or awk runs inside the monitor loop; the loop talks to /proc and cgroupfs directly.

Requirements

  • HestiaCP on Ubuntu 22.04 / 24.04 (any edition the panel supports) - the daemon reads /usr/local/hestia/data/users and /usr/local/hestia/data/packages and works alongside the panel's Resource Limits, it does not replace them.
  • Linux cgroup v2 (systemd unified hierarchy). Check with [ -f /sys/fs/cgroup/cgroup.controllers ]. On a v1-only host the daemon installs but the assignment features need v2.
  • root install (via install.sh or the systemd unit), a stable rustc toolchain for building from source (only if cargo is missing; the installer can fetch it), and git to clone the repository. rustc also needs a C linker (cc/gcc); install.sh installs build-essential automatically when no compiler is present.
  • No external runtime dependencies: the daemon uses /proc and cgroupfs directly, no ps, grep, awk, jq or a separate agent.
  • The optional Exim addon (--with-exim) additionally expects HestiaCP's Exim running in non-split mode (HestiaCP's default) and that the admin accepts a small warn rule injected into the generated Exim config.

Architecture

src/main.rs     entry point, start/stop/status/inspect CLI, SIGHUP reload orchestration
src/config.rs   /etc/process-slice-manager.conf parser (global keys + [Package])
src/logger.rs   leveled logging (ERROR < WARN < INFO < DEBUG < TRACE)
src/errors.rs   central ManagerError type (no unwrap/expect in production)
src/users.rs    reads /usr/local/hestia/data/users/<user>/user.conf + cache
src/proc.rs     /proc scan, comm/uid reads, pid ticks/RSS, cgroup verification, self stats
src/monitor.rs  polling loop, worker tracking, re-verification, counters, stats
src/cgroups.rs  v2 attribute application + shared assign core (all providers)
src/providers/  assignment providers: discovery (php.rs, dovecot.rs) + event (exim.rs)
src/ipc.rs      root-only unix socket; JSON protocol for provider events (Exim, future)
src/signals.rs  SIGHUP/SIGTERM/SIGINT handling (atomic flags)
src/systemd.rs  pid file + start/stop helpers
src/status.rs   status socket server + client (JSON snapshot for `status`)
src/inspect.rs  `inspect <user>`: native cgroup diagnostic (limits, workers, verification)

Data flow

  1. start loads config, initializes logging, registers signal handlers, writes the pid file and detects the cgroup hierarchy (expects v2).
  2. users::load_user_data reads every user.conf under HESTIA_USERS_DIR (/usr/local/hestia/data/users), skipping admin, and records each user's package (PACKAGE) and resource limits (CPU_QUOTA, CPU_QUOTA_PERIOD, MEMORY_LIMIT, SWAP_LIMIT). UID mapping comes from /etc/passwd (the <user>_webmaster companion account is ignored). All HestiaCP packages are also read from HESTIA_PACKAGES_DIR (/usr/local/hestia/data/packages, every <name>.pkg file is a package; the .sh per-package hooks are ignored), so status lists every package - even ones no user is assigned to yet (e.g. the always-present default).
  3. Every user's user-<uid>.slice is ensured every cycle: a missing slice is created and the user's HestiaCP limits are written to cpu.max, memory.max and memory.swap.max (the old bash daemon's conversion). Existence is re-checked each cycle because systemd removes empty user slices (e.g. after the user's last session ends) - without the re-check the limits would silently disappear until the daemon was restarted or reloaded. A freshly recreated slice also has its package attributes re-applied. A slice that cannot be created is retried with a backoff. 3b. The hard memory policy is enforced on every existing slice too: memory.max = MEMORY_LIMIT, memory.high = max, memory.oom.group = 0. HestiaCP sets memory.high (a throttle) via v-update-user-cgroup on every user/package event, so this is re-asserted each cycle.
  4. The optional attributes configured for each user's package are applied to that user's slice at startup / on reload for every user. A slice that still does not exist is retried with a backoff.
  5. The monitor loop scans /proc; php-fpm workers are matched by comm prefix and their UID, dovecot session processes by comm + the dovecot.service cgroup + their UID, then both are moved into the user-<uid>.slice/psm-<uid>.scope leaf scope. Non-php pids go into a negative cache (check_expiry) so comm is not re-read every cycle.
  6. Interactive SSH/SFTP sessions (sshd, sftp-server, sftp, scp) are handled in the opposite direction: pam_systemd places them in user-<uid>.slice/session-*.scope, so the user's own limits would throttle or (memory.oom.group) kill the session while their PHP-FPM is under load - breaking SFTP/file-manager transfers exactly then. With exempt_ssh_sftp (default true) the daemon relocates the session's processes into a dedicated leaf scope (user.slice/psm-<uid>-session-<id>) that carries no per-user limits, keeping interactive file transfer usable under load.
  7. SIGHUP reloads config + user data, rebuilds all caches, re-ensures the slices and re-applies the package attributes without restarting the daemon. The same reload runs periodically (refresh_interval, default 300 s) because HestiaCP's package hooks fire on user creation / package change but not on v-delete-user - the periodic refresh is how the daemon notices a deleted account and reclaims its slice.

What the daemon does NOT do

  • does not create users, packages, or a separate cgroup tree,
  • creates missing user-<uid>.slice slices only - it never modifies an existing slice's primary limits (CPUQuota / MemoryMax come from HestiaCP),
  • removes a slice only when the owning user no longer exists in HestiaCP data and the slice is empty (no processes): rmdir on cgroup v2 fails with EBUSY while the slice still holds processes, so live workloads are never touched. Non-empty slices of deleted users are simply left running,
  • never overrides a limit HestiaCP already applied.

Configuration

Copy process-slice-manager.conf.example to /etc/process-slice-manager.conf. Global keys can also be set via PSM_<KEY> environment variables. There is no compile-time configuration.

Packages are auto-discovered, not listed by hand. The daemon reads the packages HestiaCP already maintains from hestia_packages_dir (/usr/local/hestia/data/packages, every <name>.pkg file is a package; the .sh files next to them are Hestia's own per-package hooks and are ignored) and maps each user to its package via the user's user.conf. The panel stays the single source of truth: add, rename or remove a package in HestiaCP and the daemon picks it up on its next reload - which happens automatically when a user is added or a package changes (the installed <Package>.sh hooks send SIGHUP).

The [Package] sections below are entirely optional extensions - they only add extra cgroup v2 attributes HestiaCP does not expose. They never define packages. A package with no section simply gets no extensions; the core behaviour (moving php-fpm/Dovecot/Exim processes into the account's user-<uid>.slice) still works for it, because that relies on the limits HestiaCP already set. The shipped Mini/Pro/Business/default sections are examples - you can delete them all and the daemon runs fine.

# Global settings
poll_interval = 2
log_level = info
hestia_packages_dir = /usr/local/hestia/data/packages

# Assignment providers (IPC socket is bound only when this is non-empty).
# Exim reports pid + $authenticated_id after SMTP AUTH; the daemon moves the
# process into the owner's user slice. No Exim config changes are required
# for the daemon side.
# providers = exim
# ipc_socket = /run/process-slice-manager-ipc.sock
# ipc_socket_group = Debian-exim
# exim_bin = /usr/sbin/exim4

# Optional cgroup v2 attributes per package
[default]

[Mini]
cpu.weight = 200

[Pro]
cpu.weight = 500
  • Each [Package] section is optional; the name must match a <name>.pkg package in HestiaCP (hestia_packages_dir), which is also the PACKAGE value in the user's user.conf.
  • default always exists in HestiaCP, so it is a safe catch-all section for attributes that should apply to users whose package has no section.
  • An attribute is applied only if it is listed in a package section; everything else (including all of HestiaCP's settings) is left untouched.
  • Boolean tokens (true/false/yes/no/on/off) are written as 1/0.
  • A percentage value (e.g. memory.swap.max = 50%) is resolved against the slice's own finite memory.swap.max (set by HestiaCP) - or the parent's if the slice has none - and converted to absolute bytes. Unresolvable percentages are skipped with a warning.
  • Everything else is passed through verbatim to the cgroup attribute file.

Supported attribute keys:

attribute meaning
memory.swap.max maximum swap usage
cpu.weight cpu share weight (default 100, range 1..10000)
cpu.uclamp.min minimum cpu utilization clamp
cpu.uclamp.max maximum cpu utilization clamp
io.weight io weight (default 100, range 1..10000)
memory.zswap.max maximum zswap pool usage
memory.reclaim proactive reclaim
pids.max maximum number of processes in the slice

memory.high, memory.swap.high and memory.oom.group are not supported: memory throttling and OOM group-kill freeze (or kill) the user's whole slice under load, which is why the daemon always writes memory.high = max and memory.oom.group = 0 instead (see the memory-policy note above). Unknown keys in a section are ignored with a warning.

Build

cargo build --release
install -m 0755 target/release/process-slice-manager /usr/local/bin/process-slice-manager
install -m 0644 process-slice-manager.service /etc/systemd/system/process-slice-manager.service
systemctl daemon-reload
systemctl enable --now process-slice-manager

Or use install.sh (installs Rust if missing, logrotate config, the HestiaCP user-create hooks and the psm-monitor.sh live viewer). Pass --with-exim to also install the Exim addon (the notifier, the idempotent template patch and its hourly self-healing timer):

sudo bash install.sh --with-exim

To remove the daemon and everything the installer put in place (binary, config, systemd unit, logrotate, HestiaCP package hooks, the monitor, and the whole Exim addon - including the warn rule injected into the Exim template, which is stripped before the config is regenerated):

sudo bash uninstall.sh

uninstall.sh stops and disables the services, removes the injected psm-exim-notify rule from /etc/exim4/exim4.conf.template (then runs update-exim4.conf and reloads Exim), deletes the notifier and patch script, and only then removes the daemon files. HestiaCP's own user-<uid>.slice cgroups and the daemon's log file are left untouched.

The --with-exim step is exactly what deploy/psm_install_exim_patch.sh does; it can also be run standalone later without re-running the whole installer.

Provider IPC (Exim)

The daemon can receive assignment events from services that know the authenticated identity (Exim after SMTP AUTH) instead of guessing the owner from /proc. Exim only reports the process pid and $authenticated_id; the daemon owns all cgroup logic.

Transport: a unix domain socket (default /run/process-slice-manager-ipc.sock), one JSON request per line, requests capped at 64 KiB. The socket is bound only when providers lists an event provider.

By default the socket is root-only (0600). HestiaCP's Exim refuses to run as root (never_users = root) and always runs as Debian-exim, so to let the post-AUTH notifier connect you must set ipc_socket_group to the Exim group (typically Debian-exim); the daemon then chowns the socket to that group and opens it 0660. The daemon still verifies every submitted PID is really Exim before assigning, so opening the socket to the Exim group does not widen what a client can make the daemon do.

{"source":"exim","pid":12345,"authenticated_id":"alice@example.com"}

Response (one JSON line):

{"status":"ok"}
{"status":"error","message":"user not found"}

The daemon resolves authenticated_id -> mail domain -> HestiaCP user -> uid using its cached user data (rebuilt on reload, never queried per event) and moves the process into the owner's user-<uid>.slice/psm-<uid>.scope. Before moving anything it verifies the pid still exists and /proc/<pid>/exe really is the Exim binary (exim_bin), so a random pid cannot be pushed into a slice. Malformed messages, unknown source values and unknown mail domains are rejected.

Exim addon (deploy/)

The daemon side needs no Exim changes; the deploy/ directory holds the notifier and a patch script for HestiaCP's Exim:

file purpose
deploy/psm-exim-notify Notifier invoked from the Exim ACL. Reads $authenticated_id from argv[1], connects to the daemon's IPC socket and submits {source,pid,authenticated_id}. Failure-safe (1s timeout, all errors swallowed) - mail is never blocked.
deploy/psm_exim_patch.sh Injects the notifier call into the monolithic Exim config. Idempotent.
deploy/psm_install_exim_patch.sh Installs the patch script plus a systemd timer (see below) so the rule survives apt upgrades and HestiaCP re-deploying its Exim template.
deploy/systemd/psm-exim-patch.service oneshot unit that runs psm_exim_patch.sh.
deploy/systemd/psm-exim-patch.timer Re-runs the patch hourly (OnCalendar=*-*-* *:00:00, RandomizedDelaySec=300, Persistent=true).
deploy/exim-conf.d/ Split-mode config snippets. HestiaCP runs Exim in non-split mode, so these are kept as a reference only and are not applied.

HestiaCP's Exim runs in non-split mode (dc_use_split_config='false'): the generated config comes from /etc/exim4/exim4.conf.template and conf.d/ files are ignored. psm_exim_patch.sh therefore edits the template directly - it inserts a warn rule into acl_check_data, right after the header so it runs first:

warn
  condition = ${if def:authenticated_id}
  condition = ${run{/usr/local/bin/psm-exim-notify $authenticated_id}{yes}{no}}

The rule is idempotent (re-running the patch replaces an older injected rule, never stacks duplicates) and backs the template up to exim4.conf.template.bak.psm before editing. The script then runs update-exim4.conf, reloads Exim and verifies the notifier path appears in the generated /var/lib/exim4/config.autogenerated - but only when something actually changed, so a periodic no-op invocation does not reload the service.

Install the addon on the server:

install -m 0755 deploy/psm-exim-notify /usr/local/bin/psm-exim-notify
sudo bash deploy/psm_install_exim_patch.sh

psm_install_exim_patch.sh copies psm_exim_patch.sh to /usr/local/bin/psm_exim_patch.sh, installs the two systemd units, starts the timer and runs the patch once. The timer re-applies the patch every hour.

Why the timer is needed. /etc/exim4/exim4.conf.template is a conffile of the exim4-config package, and HestiaCP keeps its own master copy in /usr/local/hestia/install/deb/exim/ which it re-deploys through v-change-sys-service-config. An apt upgrade of exim4-config or a HestiaCP service-config change can therefore overwrite the injected rule. Normal HestiaCP mail changes (v-add-mail-domain, ...) only touch the /etc/exim4/domains/ symlinks and regenerate /var/lib/exim4/config.autogenerated from the template, so the patch survives those - but the self-healing timer guarantees the rule is restored within an hour no matter what reset it.

Exim's ${run{cmd args}{string1}{string2}} passes the command's arguments on the command line (the command's stdin is empty) and expands string1 on success, string2 on failure. So $authenticated_id is passed as argv[1] and the condition evaluates to a proper boolean (yes/no). The notifier sends the pid of its parent process ($PPID) - the Exim SMTP connection process - which is what the daemon verifies and moves into the user's slice.

Operations

process-slice-manager start
process-slice-manager stop
process-slice-manager status            # reads a live snapshot over the unix socket
process-slice-manager inspect <user>    # native cgroup diagnostic for one user
systemctl reload process-slice-manager  # SIGHUP: full reload

status reports users/packages/known processes, assignment counters, reloads, loop timings, daemon CPU and RAM:

$ process-slice-manager status
Daemon running: yes
Version: 1.3
Users: 39
Packages: 4
Known processes: 8
...

Inspect (inspect <user>)

process-slice-manager inspect <user> prints a native diagnostic of the user's cgroup slice, reading only /proc, /sys/fs/cgroup and the user cache (no ps/grep/awk/jq). It shows the slice's limits, the controllers the kernel exposes, live runtime statistics and whether every running worker (php-fpm or a dovecot session process) is actually inside the expected user-<uid>.slice (or the daemon's psm-<uid>.scope leaf beneath it):

$ process-slice-manager inspect alice
User:      alice (uid 1001)
Package:   Business
Slice:     user.slice/user-1001.slice
Cgroup:    v2

Controllers:
  cpu memory io pids

Limits:
  cpu.max          400000 100000
  memory.max       4.0 GB
  ...

Runtime:
  cpu.stat         usage_usec 10295893
  CPU throttled    0
  memory.current   176 MB

Workers:
  PID     COMM           KIND     CGROUP                                      STATUS
  1234    php-fpm8.2     php      /user.slice/user-1001.slice/psm-1001.scope   ✔ OK
  9876    imap           dovecot  /user.slice/user-1001.slice/psm-1001.scope   ✔ OK

Verification:  ✔ OK (all 2 workers in user.slice/user-1001.slice/psm-1001.scope)

Missing attribute files are rendered as N/A; a missing slice or an unknown user are reported as errors.

Live viewer (psm-monitor.sh)

psm-monitor.sh is a small live viewer that watches the same things the daemon assigns, but from the outside: it refreshes once a second and prints a ps-style table of PHP-FPM, Exim and Dovecot processes with their current cgroup and an OK/SYSTEM/FAIL verdict per row. It is installed to /usr/local/bin/psm-monitor.sh by install.sh and is meant to be run manually in a terminal:

sudo psm-monitor.sh

It must run as root (or a user in the /proc hidepid group): HestiaCP mounts /proc with hidepid=invisible, so a regular shell user cannot see the Exim/Dovecot connection processes at all.

Status logic per row:

  • PHP-FPM workers are matched by comm; they run as the account's real uid, so the expected slice is user-<uid>.slice: OK when the process is inside it, FAIL otherwise.
  • Exim/Dovecot run as their own service users (uid < 1000), so the process uid cannot identify the target slice. The verdict keys off the cgroup instead: a connection process the daemon has moved into a user-<uid>.slice shows OK; one still in system.slice shows SYSTEM. Both are normal - SYSTEM just means "not (yet) assigned to a user slice".

Example while an authenticated SMTP session is held open:

PID      USER       UID    SERVICE          CGROUP                                             STATUS
476879   Debian-+   108    EXIM             /system.slice/exim4.service                         SYSTEM
943450   Debian-+   108    EXIM             /user.slice/user-1008.slice                         OK
940577   alice      1024    PHP-FPM          /user.slice/user-1024.slice                         OK

The last EXIM row is the connection process that the daemon moved from the Exim service slice into user 1008's slice after SMTP AUTH.

Tests

Pure logic (config parser, user.conf/passwd parsing, /proc self-stats) is covered by unit tests in each module:

cargo test

License

Released under the MIT License. See LICENSE for the full text.

About

HestiaCP extension: assigns php-fpm, Dovecot and Exim processes to existing user cgroup v2 slices

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages