# Extension Hooks

> Extend pxf with post-apply and post-disable hook scripts so third-party services (e.g. pxShield) can keep their kernel rules in sync without coupling pxf to them.

Source: https://www.pyxsoft.com/docs/firewall-cli/pxf-hooks
Updated: 2026-11-10

---

`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

| Directory | Triggered 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:

| Variable | Possible values | Notes |
| --- | --- | --- |
| `PXF_EVENT` | `apply`, `disable` | The lifecycle event that triggered the hook. |
| `PXF_BACKEND` | `nftables`, `iptables-legacy`, `iptables-nft` | Active firewall backend. May be empty when the backend cannot be determined; hooks should treat the empty string as "do nothing". |
| `PXF_VERSION` | e.g. `1.5.0` | Version 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, `80` → `8080`).

```bash
#!/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`:

```bash
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`:

```bash
%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`:

```bash
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.
