Skip to main content

Extension Hooks

pxf runs a small set of extension hooks after each apply and after each disable. The mechanism is inspired by csfpost.sh in CSF and cron.d/sudoers.d in the OS: third-party packages drop a script into a well-known directory, and pxf invokes it with a stable environment.

This page documents the contract between pxf and hook authors.

Why hooks exist

pxf is a declarative firewall manager: every apply flushes the kernel and rebuilds the rule set from the journal. That model is great for predictability, but it conflicts with services that need to keep custom rules in place across applies (e.g. pxShield redirecting 80 → 19080 and 443 → 19443 to its WAF).

Rather than hard-coding awareness of each integration into pxf, the hook system gives those services a generic extension point. pxf stays agnostic of who is using it; the integration logic lives entirely on the consumer side.

Hook directories

DirectoryTriggered by
/etc/pxf/post-apply.d/End of pxf apply (after the journal snapshot has been applied).
/etc/pxf/post-disable.d/End of pxf disable (after the firewall has been flushed and set to ALLOW ALL). Skipped when --no-hooks is passed.

Both directories are created with mode 0755, owned by root:root, during pxf installation.

A hook is any executable file under one of those directories whose name ends in .sh. Other entries (no extension, .disabled, .bak, subdirectories…) are ignored. Files are sorted alphabetically before execution; the convention is NN-name.sh where NN is a two-digit prefix (e.g. 50-pxshield.sh).

Environment variables

When pxf invokes a hook it adds the following environment variables on top of the inherited environment:

VariablePossible valuesNotes
PXF_EVENTapply, disableThe lifecycle event that triggered the hook.
PXF_BACKENDnftables, iptables-legacy, iptables-nftActive firewall backend. May be empty when the backend cannot be determined; hooks should treat the empty string as "do nothing".
PXF_VERSIONe.g. 1.5.0Version of pxf invoking the hook.

Hooks should branch on PXF_BACKEND to choose between nft and iptables/ip6tables syntax.

Security model

pxf hooks run as root. They cannot be sandboxed in a meaningful way without losing the ability to manage netfilter rules. The threat model assumes that anyone who can write into the hook directories already has root, so the security goal is to make sure no non-root path can place or modify a hook.

Before invoking a hook, pxf validates it. If any check fails, the hook is skipped with a warning and pxf continues with the next one. The validations are:

  • Owner: the file (and the parent directory) must be owned by root:root.
  • Permissions: the file (and the parent directory) must not be writable by group or other (i.e. mode & 0o022 == 0). Typical mode is 0755.
  • No symlinks: neither the hook file nor its directory may be a symlink. This prevents redirect attacks via writable parent directories.
  • Regular file: the hook entry must be a regular file (no FIFOs, sockets, devices…).
  • Suffix: the file name must end in .sh.

If you write a hook, ensure your packaging installs it owned by root with mode 0755, e.g. install -m 0755 -o root -g root your-hook.sh /etc/pxf/post-apply.d/your-hook.sh.

Idempotency and execution order

pxf apply flushes the netfilter tables before re-applying rules from the journal, so when your hook runs the relevant tables are already at a clean baseline. You can insert rules without first checking for duplicates.

There is one important caveat for the nftables-native backend: nft flush ruleset drops every table, including ip nat / ip6 nat. If pxf has no redirects of its own in the journal, those tables will not exist when your hook runs. Hooks that touch nat should therefore bootstrap the table and chain themselves (see the example below). On the iptables backends (iptables-legacy / iptables-nft) the nat table and PREROUTING chain are built-in and survive a flush, so no bootstrap is required there.

Hooks run sequentially, in alphabetical order of file name. There is no parallelism. Order between unrelated packages should be controlled with the NN- prefix convention.

Error handling

A failing hook (non-zero exit, timeout, validation failure) does not abort the surrounding pxf operation. The error is logged with the hook path, exit code and elapsed time, and pxf moves on. Hooks therefore should fail loudly (exit non-zero with a meaningful message) rather than swallow errors silently.

The default per-hook timeout is 30 seconds.

Example: NAT redirect hook

The skeleton below is a generic template you can adapt for any service that needs a port redirect or extra rule kept in place across pxf apply / pxf disable. It demonstrates the patterns you should follow:

  • set -e so real failures surface (pxf logs the exit code).
  • A guard at the top so the hook becomes a no-op when its dependent service is not running — replace your-service.service with whatever systemd unit owns the rules. If your hook does not depend on a service, drop the guard.
  • A case "$PXF_BACKEND" switch with branches for nftables and iptables. The empty / unknown case exits cleanly.
  • On the nftables branch, bootstrap the nat table and PREROUTING chain (with priority -100, the canonical NAT priority). After pxf apply on a journal without redirects, nft flush ruleset has dropped everything; if you skip the bootstrap your nft insert rule will fail.
  • IPv4 lives under set -e; IPv6 is wrapped with set +e so containers/kernels without nf_nat_ipv6 do not break IPv4 protection.

Replace <EXTERNAL_PORT> and <INTERNAL_PORT> with the values your service needs (for example, 808080).

#!/usr/bin/env bash
set -e

# Optional: only act when the dependent service is up. Drop this guard if
# your hook is unconditional.
systemctl -q is-active your-service.service || exit 0

case "$PXF_BACKEND" in
nftables)
# Bootstrap IPv4 nat infrastructure — pxf may have flushed the whole ruleset.
nft list table ip nat >/dev/null 2>&1 \
|| nft add table ip nat
nft list chain ip nat PREROUTING >/dev/null 2>&1 \
|| nft add chain ip nat PREROUTING '{ type nat hook prerouting priority -100 ; }'

nft insert rule ip nat PREROUTING tcp dport <EXTERNAL_PORT> counter redirect to :<INTERNAL_PORT>

# IPv6 best-effort (some kernels/containers lack nf_nat_ipv6).
set +e
nft list table ip6 nat >/dev/null 2>&1 \
|| nft add table ip6 nat
nft list chain ip6 nat PREROUTING >/dev/null 2>&1 \
|| nft add chain ip6 nat PREROUTING '{ type nat hook prerouting priority -100 ; }'
nft insert rule ip6 nat PREROUTING tcp dport <EXTERNAL_PORT> counter redirect to :<INTERNAL_PORT>
;;
iptables-legacy|iptables-nft)
iptables -t nat -I PREROUTING -p tcp --dport <EXTERNAL_PORT> -j REDIRECT --to-ports <INTERNAL_PORT>

set +e
ip6tables -t nat -I PREROUTING -p tcp --dport <EXTERNAL_PORT> -j REDIRECT --to-ports <INTERNAL_PORT>
;;
*)
# Unknown / empty backend — do nothing rather than guess.
exit 0
;;
esac

exit 0

A real-world consumer of this pattern is pxShield, which ships a hook of its own that redirects 80 → 19080 and 443 → 19443 so HTTP/HTTPS traffic transparently flows through the WAF. The hook is installed automatically by pxShield's package — no manual setup needed.

Skipping hooks

You can disable hook execution per-invocation with --no-hooks:

pxf apply --no-hooks    # apply without running post-apply hooks
pxf disable --no-hooks # absolute killswitch — flush + ALLOW ALL, no hooks

--no-hooks is an emergency lever. The expected use is troubleshooting (e.g. ruling out a hook as a cause of a problem) or an absolute killswitch when responding to an incident. Routine operation should leave hooks enabled.

Packaging integration: handling install order

Hooks living under /etc/pxf/post-apply.d/ only fire when pxf apply runs. That means an integrator package needs to make sure (a) its hook is present in the directory at the moment pxf applies, and (b) pxf apply is invoked at least once after the hook is installed. Two install orders matter:

1. Integrator installed first, pxf installed afterwards. The integrator's postinstall cannot drop a hook because /etc/pxf/post-apply.d/ does not exist yet. When pxf is later installed, pxf knows nothing about the integrator and will not run the hook automatically. The recommended fix is an RPM trigger in the integrator package that fires when pxf gets installed. The pxf RPM package name is pyxsoft-pxf:

%triggerin -- pyxsoft-pxf
# Or, with fpm: --rpm-trigger-after-install 'pyxsoft-pxf:trigger-pxf-installed.sh'
set -eu
HOOK_SRC=/opt/your-service/scripts/50-your-service.sh
[ -x /usr/sbin/pxf ] || exit 0
[ -f "$HOOK_SRC" ] || exit 0

install -d -m 0755 -o root -g root /etc/pxf/post-apply.d
install -d -m 0755 -o root -g root /etc/pxf/post-disable.d
install -m 0755 -o root -g root "$HOOK_SRC" /etc/pxf/post-apply.d/50-your-service.sh
install -m 0755 -o root -g root "$HOOK_SRC" /etc/pxf/post-disable.d/50-your-service.sh

# Reconcile kernel state only if pxf is enabled. If disabled, the hook is
# pre-positioned and will run on the next 'pxf enable'.
if pxf is-enabled --quiet; then
pxf apply || logger -t your-service-trigger "pxf apply failed during trigger"
fi

Naming note: the trigger target (pyxsoft-pxf) is the RPM package name, while the binary invoked at runtime is pxf (installed as /usr/sbin/pxf). Use pyxsoft-pxf only in the %triggerin / fpm flag; everywhere else (script bodies, command -v, is-enabled, etc.) keep using pxf.

The script must be idempotent: RPM triggers fire on both install and upgrade, and may fire when either side of the relationship changes. Use install -m 0755 (which overwrites) rather than cp, and never accumulate state.

2. pxf installed first, integrator installed afterwards. This is the easy case: the integrator's normal postinstall (or service ExecStartPost) drops the hook and runs pxf apply directly. No trigger is required, but the same idempotency rules apply.

Checking pxf state from scripts

Integrator scripts that need to know whether pxf is enabled should call pxf is-enabled --quiet:

if pxf is-enabled --quiet; then
pxf apply
else
echo "pxf is disabled; the hook is in place but will not run until 'pxf enable'." >&2
fi

pxf is-enabled exits 0 when enabled and 1 when disabled, with no output in --quiet mode. This is the supported contract for scripts; do not parse pxf status --json from postinstall scriptlets, as the JSON shape is not part of the script-stable API.

Cleanup on uninstall

When the integrator is uninstalled, its preremove (or postremove) should:

  1. Remove its hook script from /etc/pxf/post-apply.d/ and /etc/pxf/post-disable.d/.
  2. Run pxf apply if pxf is enabled, so the kernel reconciles to the new (no-hook) state.

Symmetrically, an RPM %triggerpostun -- pyxsoft-pxf lets the integrator reapply its rules directly when pxf itself is uninstalled (since pxf's preremove will flush the kernel). See pxShield's packaging for a worked example.

Authoring checklist

  • Filename matches NN-<vendor>.sh with executable bit set.
  • Owned by root:root, mode 0755, not a symlink.
  • Branches on PXF_BACKEND and treats empty as "no-op".
  • Idempotent (assumes a flushed table at start; uses insert/-I).
  • Fails loudly on real errors; uses || true or 2>/dev/null only for known-benign cases (e.g. missing IPv6 module).
  • Cleans up on uninstall: the package's %preun (or systemd ExecStop) removes the hook from both post-apply.d/ and post-disable.d/ and triggers a fresh pxf apply to reconcile the kernel.