go-firewall/services.go
James Coleman 5095d90fa4 Advertise capability flags and harden backend semantics
- New Capabilities: PortPair, Negation, RejectAction,
  FamilyWithoutAddress, DenyActionFromConfig, advertised per backend.
- coversDirection isolates DirForward even when output is unowned;
  add splitNATDualRow so a concrete-family removal re-adds the opposite
  family's NAT translation.
- Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the
  "invalid" sentinel as input while JSON round-trips it.
- Sync counts additions on mid-batch failure and uses RuleBatcher.
- NewManager runs a probe loop joining each backend's reason for
  diagnosability; services.go drops "generated" from enabled, handles it
  on enable, clears start-limit-hit on restart, and matches rc.local by
  token.
- nftables: per-source connection limits (meter set), quoted-token
  parsing preserving log-prefix spacing, digit-led prefix sanitizing.
- apf/csf: deny-action-from-config with cached STOP settings, port lists
  and inexpressible shapes routed through the pre-hook, confKeyApplies
  guard against a missing config line.
- atomic config writes fsync before rename and resolve symlinks;
  readConfValue is last-assignment-wins; runCommand preserves the exit
  code through the wrapped error.
- Move coreos/go-systemd to the maintained v22 module directly.
2026-07-13 17:50:43 -05:00

288 lines
9.6 KiB
Go

//go:build linux
package firewall
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
dbus "github.com/coreos/go-systemd/v22/dbus"
)
// systemdActive reports whether systemd is the running init (/run/systemd/system
// exists), the same marker systemctl uses to decide it can reach the manager.
func systemdActive() bool {
_, err := os.Stat("/run/systemd/system")
return err == nil
}
// commandExists reports whether name resolves on PATH.
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// systemdUnitEnabled reports whether systemd reports name's unit as set to
// start at boot. Only "enabled" and "enabled-runtime" count: a "generated"
// unit wraps a SysV init.d script and exists for every script regardless of
// its rc registration, so boot enablement for those is resolved through the
// SysV checks instead. Returns false when systemd is not running or the unit
// is absent.
func systemdUnitEnabled(ctx context.Context, name string) bool {
conn, err := dbus.NewWithContext(ctx)
if err != nil {
return false
}
defer conn.Close()
prop, err := conn.GetUnitPropertyContext(ctx, name+".service", "UnitFileState")
if err != nil {
return false
}
switch prop.Value.Value() {
case "enabled", "enabled-runtime":
return true
}
return false
}
// systemdUnitState returns name's unit-file state (e.g. "enabled", "disabled",
// "static") and whether its unit file is installed. It lists all unit files and
// filters by name rather than calling ListUnitFilesByPatterns, which older
// systemd (CentOS 7's v219) does not export over D-Bus. Used to tell
// installed-but-disabled units (which can be enabled) from absent ones (which
// cannot be). Returns "", false when systemd is not running.
func systemdUnitState(ctx context.Context, name string) (state string, present bool) {
conn, err := dbus.NewWithContext(ctx)
if err != nil {
return "", false
}
defer conn.Close()
files, err := conn.ListUnitFilesContext(ctx)
if err != nil {
return "", false
}
target := name + ".service"
for _, uf := range files {
if filepath.Base(uf.Path) == target {
return uf.Type, true
}
}
return "", false
}
// chkconfigOn reports whether `chkconfig --list name` shows any runlevel on,
// the RHEL-family enablement signal. Returns false when chkconfig is absent.
func chkconfigOn(ctx context.Context, name string) bool {
results, err := runCommand(ctx, "chkconfig", "--list", name)
if err != nil {
return false
}
for _, line := range results {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != name {
continue
}
for _, f := range fields[1:] {
if _, status, found := strings.Cut(f, ":"); found && status == "on" {
return true
}
}
}
return false
}
// rcSymlinksOn reports whether an S*name start symlink exists in any rcN.d
// tree, the enablement signal for both update-rc.d (Debian/Ubuntu, /etc/rcN.d)
// and Slackware (/etc/rc.d/rcN.d).
func rcSymlinksOn(name string) bool {
for _, rl := range []string{"2", "3", "4", "5"} {
for _, dir := range []string{"/etc/rc" + rl + ".d", "/etc/rc.d/rc" + rl + ".d"} {
if matches, _ := filepath.Glob(filepath.Join(dir, "S*"+name)); len(matches) > 0 {
return true
}
}
}
return false
}
// openrcOn reports whether name is linked into an OpenRC runlevel directory
// (default or boot), the enablement signal created by `rc-update add` on Gentoo.
func openrcOn(name string) bool {
for _, rl := range []string{"default", "boot"} {
if _, err := os.Lstat(filepath.Join("/etc/runlevels", rl, name)); err == nil {
return true
}
}
return false
}
// rcLocalOn reports whether name is invoked from an rc.local file, apf's
// last-resort enablement when neither systemd nor an init.d registration
// applies. Commented lines are ignored, and name must appear as its own token
// (bare or path-qualified) so an unrelated mention such as a log-file path
// does not count.
func rcLocalOn(name string) bool {
for _, p := range []string{"/etc/rc.local", "/etc/rc.d/rc.local"} {
data, err := os.ReadFile(p)
if err != nil {
continue
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
for _, tok := range strings.Fields(line) {
if tok == name || strings.HasSuffix(tok, "/"+name) {
return true
}
}
}
}
return false
}
// sysvServiceEnabled reports whether name is enabled under any SysV-family init
// system, the fallback when systemd is absent or does not report the service
// enabled. It checks chkconfig, Debian/Slackware rcN.d start symlinks, OpenRC
// runlevels, and rc.local in turn; the first to report it on wins.
func sysvServiceEnabled(ctx context.Context, name string) bool {
if chkconfigOn(ctx, name) {
return true
}
if rcSymlinksOn(name) {
return true
}
if openrcOn(name) {
return true
}
return rcLocalOn(name)
}
// serviceInstalled reports whether name's unit or init.d script is installed,
// regardless of whether it is enabled. Used to tell a merely-disabled service
// (which can be enabled) from an absent one (which cannot be).
func serviceInstalled(ctx context.Context, name string) bool {
if systemdActive() {
_, present := systemdUnitState(ctx, name)
return present
}
for _, dir := range []string{"/etc/init.d", "/etc/rc.d/init.d"} {
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
return true
}
}
return false
}
// serviceEnabled reports whether name is enabled to start, checking systemd
// first and then every SysV-family init mechanism the supported firewalls
// install under: chkconfig (RHEL), update-rc.d (Debian/Ubuntu, via /etc/rcN.d
// symlinks), rc-update (Gentoo/OpenRC, via /etc/runlevels), Slackware rc.d
// symlinks, and rc.local (apf's last resort). name is the service base name
// without a ".service" suffix (e.g. "csf", "apf", "netfilter-persistent"); any
// one mechanism reporting it on counts as enabled.
func serviceEnabled(ctx context.Context, name string) bool {
if systemdUnitEnabled(ctx, name) {
return true
}
return sysvServiceEnabled(ctx, name)
}
// enableService enables name to start at boot under whatever init system is
// active, mirroring how the firewalls' installers register themselves. It is a
// no-op (returns nil) when the service is not installed or already enabled. On
// systemd it runs `systemctl enable` (preceded by daemon-reload so a freshly
// installed unit is picked up); otherwise it uses chkconfig, update-rc.d, or
// rc-update, whichever is present.
func enableService(ctx context.Context, name string) error {
if systemdActive() {
state, present := systemdUnitState(ctx, name)
if !present {
return nil
}
switch state {
case "enabled", "enabled-runtime", "static":
// Static units have no install info and are pulled in by other
// units, so there is nothing to enable.
return nil
case "generated":
// A generated unit wraps a SysV init.d script whose enablement
// lives in its rc registration, not the unit file. `systemctl
// enable` reaches that registration through systemd-sysv-install.
if sysvServiceEnabled(ctx, name) {
return nil
}
}
if _, err := runCommand(ctx, "systemctl", "daemon-reload"); err != nil {
return fmt.Errorf("failed to reload systemd for %s: %s", name, err)
}
if _, err := runCommand(ctx, "systemctl", "enable", name+".service"); err != nil {
return fmt.Errorf("failed to enable %s: %s", name, err)
}
return nil
}
if !serviceInstalled(ctx, name) {
return nil
}
switch {
case commandExists("chkconfig"):
if _, err := runCommand(ctx, "chkconfig", "--add", name); err != nil {
return fmt.Errorf("failed to enable %s: %s", name, err)
}
if _, err := runCommand(ctx, "chkconfig", name, "on"); err != nil {
return fmt.Errorf("failed to enable %s: %s", name, err)
}
case commandExists("update-rc.d"):
if _, err := runCommand(ctx, "update-rc.d", name, "defaults"); err != nil {
return fmt.Errorf("failed to enable %s: %s", name, err)
}
case commandExists("rc-update"):
if _, err := runCommand(ctx, "rc-update", "add", name, "default"); err != nil {
return fmt.Errorf("failed to enable %s: %s", name, err)
}
default:
return fmt.Errorf("no supported init system found to enable %s", name)
}
return nil
}
// restartService restarts name under whatever init system is active. On systemd
// it runs `systemctl restart`; otherwise it prefers the `service` wrapper, then
// OpenRC's `rc-service`, then the init.d script directly.
func restartService(ctx context.Context, name string) error {
if systemdActive() {
if _, err := runCommand(ctx, "systemctl", "restart", name+".service"); err != nil {
// A burst of restarts (a caller reconciling several times in quick
// succession) trips systemd's start rate limit and the unit lands in
// a failed start-limit-hit state; clear it and retry once rather
// than fail a reload the unit itself is healthy for.
if _, rerr := runCommand(ctx, "systemctl", "reset-failed", name+".service"); rerr == nil {
if _, err2 := runCommand(ctx, "systemctl", "restart", name+".service"); err2 == nil {
return nil
}
}
return fmt.Errorf("failed to restart %s: %s", name, err)
}
return nil
}
switch {
case commandExists("service"):
if _, err := runCommand(ctx, "service", name, "restart"); err != nil {
return fmt.Errorf("failed to restart %s: %s", name, err)
}
case commandExists("rc-service"):
if _, err := runCommand(ctx, "rc-service", name, "restart"); err != nil {
return fmt.Errorf("failed to restart %s: %s", name, err)
}
default:
if _, err := runCommand(ctx, filepath.Join("/etc/init.d", name), "restart"); err != nil {
return fmt.Errorf("failed to restart %s: %s", name, err)
}
}
return nil
}