iptables: persist ipsets across reboot to match rule persistence. - Detect the ipset save-file + restore unit (RHEL ipset.service / /etc/sysconfig/ipset; Debian netfilter-persistent / /etc/iptables/ipsets), non-fatally. - After each set mutation, `ipset save` into the layout's save-file and auto-enable a present-but-disabled restore unit; warn when no mechanism exists (sets stay live-only). - Use ListUnitFiles (not ListUnitFilesByPatterns, which needs systemd >= 230; CentOS 7 ships 219). APF/CSF: gain address sets by persisting ipset commands in the pre-hook. - The hook carries an `ipset create/flush/add` block ordered ahead of the `-m set --match-set` rule lines, so the firewall recreates the set on every (re)start before any rule references it. - Route set-referencing rules (Source/Destination names an ipset) through the hook rather than a literal trust-file line (ruleNeedsHook/bareHostShape). - Implement the six address-set methods, advertise AddressSets, and wire sets into Backup/Restore via captureBackupState/restoreBackupSets. Validated live: reboot simulation for iptables; generated-hook source for APF/CSF. Unit tests cover the hook ipset round-trip, ordering, in-use guard and set-ref routing; the capability-gated integration subtest now covers APF/CSF.
1586 lines
53 KiB
Go
1586 lines
53 KiB
Go
package firewall
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
dbus "github.com/coreos/go-systemd/dbus"
|
|
)
|
|
|
|
const (
|
|
// UFWType identifies the ufw backend.
|
|
UFWType = "ufw"
|
|
// UFWIPv4 is ufw's IPv4 user-rules file.
|
|
UFWIPv4 = "/etc/ufw/user.rules"
|
|
// UFWIPv6 is ufw's IPv6 user-rules file.
|
|
UFWIPv6 = "/etc/ufw/user6.rules"
|
|
// UFWConf is ufw's main configuration file (ENABLED/LOGLEVEL).
|
|
UFWConf = "/etc/ufw/ufw.conf"
|
|
// UFWDefaults is ufw's defaults file, where the default-policy keys
|
|
// (DEFAULT_INPUT_POLICY, ...) actually live — not ufw.conf.
|
|
UFWDefaults = "/etc/default/ufw"
|
|
// The iptables rules files hold rules that run before/after the user rules,
|
|
// in iptables-restore format with ufw's own chains. They are the raw-iptables
|
|
// fallback for what ufw's tuple format cannot express — ICMP, SCTP, state
|
|
// matches, custom log prefixes, rate/connection limits, and NAT (written into
|
|
// before.rules' nat table via natHelper).
|
|
UFWBefore = "/etc/ufw/before.rules"
|
|
UFWBefore6 = "/etc/ufw/before6.rules"
|
|
UFWAfter = "/etc/ufw/after.rules"
|
|
UFWAfter6 = "/etc/ufw/after6.rules"
|
|
)
|
|
|
|
// UFW manages a host firewall through the ufw command-line tool and its rules files.
|
|
type UFW struct {
|
|
// rulePrefix, when set, is attached as a ufw comment on rules this library
|
|
// creates so they can be told apart from pre-existing rules.
|
|
rulePrefix string
|
|
// iptablesRulesChanged records whether a before.rules/before6.rules file was
|
|
// edited this session, so Reload knows to run `ufw reload`.
|
|
iptablesRulesChanged bool
|
|
}
|
|
|
|
// NewUFW connects to ufw, verifies it is enabled, and returns a manager for it.
|
|
func NewUFW(ctx context.Context, rulePrefix string) (*UFW, error) {
|
|
ufw := new(UFW)
|
|
ufw.rulePrefix = rulePrefix
|
|
|
|
// Connect to systemd dbus interface.
|
|
conn, err := dbus.NewWithContext(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect to systemd: %s", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Find the systemd service for ufw and confirm it was loaded.
|
|
prop, err := conn.GetUnitPropertyContext(ctx, "ufw.service", "UnitFileState")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error getting ufw service property: %s", err)
|
|
}
|
|
if prop.Value.Value() != "enabled" {
|
|
return nil, fmt.Errorf("the ufw service is not enabled on this server")
|
|
}
|
|
|
|
// Try and read the ufw config file.
|
|
fd, err := os.Open(UFWConf)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ufw config is not readable")
|
|
}
|
|
|
|
// Scan file for the enabled state.
|
|
scanner := bufio.NewScanner(fd)
|
|
enabled := false
|
|
for scanner.Scan() {
|
|
// Get the line.
|
|
line := scanner.Text()
|
|
|
|
// Remove comments.
|
|
ci := strings.IndexByte(line, '#')
|
|
if ci >= 0 {
|
|
line = line[:ci]
|
|
}
|
|
|
|
// Trim spaces.
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Ignore zero lines.
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Parse key/value.
|
|
key, val, found := strings.Cut(line, "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
val = trimQuotes(strings.TrimSpace(val))
|
|
|
|
// Check if enabled.
|
|
if key == "ENABLED" && strings.EqualFold(val, "yes") {
|
|
enabled = true
|
|
}
|
|
}
|
|
|
|
// Close file.
|
|
_ = fd.Close()
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If disabled, return error.
|
|
if !enabled {
|
|
return nil, fmt.Errorf("ufw is currently disabled")
|
|
}
|
|
|
|
// Confirm config files exist.
|
|
files := []string{UFWIPv4, UFWIPv6}
|
|
for _, f := range files {
|
|
if _, err := os.Stat(f); err != nil {
|
|
return nil, fmt.Errorf("the config file %s is missing", f)
|
|
}
|
|
}
|
|
|
|
// Return the new ufw object.
|
|
return ufw, nil
|
|
}
|
|
|
|
// Type returns the backend identifier for ufw.
|
|
func (f *UFW) Type() string {
|
|
return UFWType
|
|
}
|
|
|
|
// Capabilities reports the features this backend supports.
|
|
func (f *UFW) Capabilities() Capabilities {
|
|
return Capabilities{
|
|
Output: true,
|
|
Forward: true,
|
|
ICMPv6: true,
|
|
PortList: true,
|
|
ConnState: true,
|
|
InterfaceMatch: true,
|
|
Logging: true,
|
|
RateLimit: true,
|
|
ConnLimit: true,
|
|
NAT: true,
|
|
RuleOrdering: true,
|
|
DefaultPolicy: true,
|
|
RuleCounters: false,
|
|
AddressSets: true,
|
|
Comments: true,
|
|
}
|
|
}
|
|
|
|
// --- default policy ---------------------------------------------------------
|
|
|
|
// GetZone reports no zone; ufw has no zone support.
|
|
func (f *UFW) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
|
|
return "", nil
|
|
}
|
|
|
|
// ipTablesChain maps a ufw iptables chain to a rule direction, reporting
|
|
// whether it is one this backend surfaces. Internal chains (logging, not-local,
|
|
// skip-to-policy) are not represented and return ok=false. Both the IPv4 (`ufw-*`)
|
|
// and IPv6 (`ufw6-*`) chain names are accepted, since before6.rules declares its
|
|
// chains with the `ufw6-` prefix.
|
|
func (f *UFW) ipTablesChain(chain string) (dir Direction, ok bool) {
|
|
switch chain {
|
|
case "ufw-before-input", "ufw-after-input", "ufw-user-input",
|
|
"ufw6-before-input", "ufw6-after-input", "ufw6-user-input":
|
|
return DirInput, true
|
|
case "ufw-before-output", "ufw-after-output", "ufw-user-output",
|
|
"ufw6-before-output", "ufw6-after-output", "ufw6-user-output":
|
|
return DirOutput, true
|
|
case "ufw-before-forward", "ufw-after-forward", "ufw-user-forward",
|
|
"ufw6-before-forward", "ufw6-after-forward", "ufw6-user-forward":
|
|
return DirForward, true
|
|
}
|
|
return DirInput, false
|
|
}
|
|
|
|
// ParseIPTablesRules parses a ufw before/after rules file, which is in
|
|
// iptables-restore format using ufw's own chains. Each `-A <chain> ...` line on
|
|
// an input/output/forward chain is reparsed with the iptables rulespec parser;
|
|
// lines whose match or action this model cannot represent are skipped.
|
|
func (f *UFW) ParseIPTablesRules(filePath string, family Family) (rules []*Rule, err error) {
|
|
fd, err := os.Open(filePath)
|
|
if err != nil {
|
|
// A missing iptables rules file simply contributes no rules.
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
defer func() { _ = fd.Close() }()
|
|
|
|
scanner := bufio.NewScanner(fd)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || line[0] == '#' || line[0] == '*' || line[0] == ':' || line == "COMMIT" {
|
|
continue
|
|
}
|
|
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 3 || (fields[0] != "-A" && fields[0] != "--append") {
|
|
continue
|
|
}
|
|
dir, ok := f.ipTablesChain(fields[1])
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Rewrite the ufw chain to its INPUT/OUTPUT/FORWARD equivalent and reuse the
|
|
// iptables parser.
|
|
spec := "-A " + iptChainForDirection(dir) + " " + strings.Join(fields[2:], " ")
|
|
rule, perr := unmarshalIPTablesRule(spec, family)
|
|
if perr != nil {
|
|
continue
|
|
}
|
|
// Strip the prefix so only the user-facing comment surfaces, and flag
|
|
// whether the prefix marked this as one of our rules.
|
|
text, hasPrefix := prefixedComment(f.rulePrefix, rule.Comment)
|
|
rule.Comment = text
|
|
rule.HasPrefix = hasPrefix
|
|
rules = append(rules, rule)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
// A logged rule is a LOG line followed by its action line; fold the pair
|
|
// back into one logical rule.
|
|
return coalesceLoggedRules(rules), nil
|
|
}
|
|
|
|
// parseAddr validates a ufw tuple address token (an IP or CIDR) and returns
|
|
// it, blanking a zero-network ("0.0.0.0/0" or "::/0") to the empty "any"
|
|
// address.
|
|
func (f *UFW) parseAddr(tok string) (string, error) {
|
|
_, network, err := net.ParseCIDR(tok)
|
|
ip := net.ParseIP(tok)
|
|
if err != nil && ip == nil {
|
|
return "", fmt.Errorf("invalid address parameter %q", tok)
|
|
}
|
|
if network != nil {
|
|
if ones, _ := network.Mask.Size(); ones == 0 {
|
|
return "", nil
|
|
}
|
|
}
|
|
return tok, nil
|
|
}
|
|
|
|
// UnmarshalRule decodes a ufw tuple into a firewall rule. A ufw tuple carries six core fields
|
|
// (action, proto, dport, dst, sport, src), an optional pair of application-name
|
|
// fields (dapp, sapp), and a trailing direction/interface field, so a tuple ufw
|
|
// itself writes has 7 tokens, or 9 for the application-profile form; a bare
|
|
// 6-token tuple (direction/interface omitted, defaulting to inbound) is also
|
|
// accepted here for tolerance, though ufw does not generate one. An
|
|
// application-profile rule's six core fields already carry the concrete
|
|
// proto/port the profile expands to (e.g. `allow tcp 80 ... Apache - in`) — dapp
|
|
// and sapp are just the profile's name, informational labels this library has no
|
|
// field for, so they are parsed to locate the trailing direction field and then
|
|
// discarded; the rule decodes exactly like an ordinary 7-token tuple otherwise.
|
|
// An 8-token tuple never occurs in a real ufw file (ufw always writes both dapp
|
|
// and sapp, using "-" for whichever is absent) and is rejected as malformed.
|
|
func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) {
|
|
r = &Rule{
|
|
Family: family,
|
|
}
|
|
tokens := strings.Split(tuple, " ")
|
|
|
|
// A `route:` prefix on the action marks a forward-chain (routed) rule. Strip it
|
|
// and flag the direction; a route rule's interfaces are read from the trailing
|
|
// field(s) below rather than fixing a single in/out direction.
|
|
forward := false
|
|
if strings.HasPrefix(tokens[0], "route:") {
|
|
forward = true
|
|
tokens[0] = strings.TrimPrefix(tokens[0], "route:")
|
|
r.Direction = DirForward
|
|
}
|
|
|
|
n := len(tokens)
|
|
// A route rule binding both interfaces adds a second trailing interface token,
|
|
// so it alone may reach eight tokens; every other tuple with eight is malformed.
|
|
if n < 6 || n > 9 || (n == 8 && !forward) {
|
|
return nil, fmt.Errorf("invalid rule length")
|
|
}
|
|
|
|
// Check action. ufw encodes the action field as a base action with an optional
|
|
// `_<logtype>` suffix (`log` or `log-all`) — e.g. `allow_log`, `limit_log`.
|
|
// Split that off so a logged or rate-limited tuple is read rather than dropped
|
|
// as an "unsupported action".
|
|
action := tokens[0]
|
|
if base, logtype, hasLog := strings.Cut(action, "_"); hasLog {
|
|
if logtype != "log" && logtype != "log-all" {
|
|
return nil, fmt.Errorf("unsupported action: %s", tokens[0])
|
|
}
|
|
action = base
|
|
r.Log = true
|
|
}
|
|
switch action {
|
|
case "allow":
|
|
r.Action = Accept
|
|
case "deny":
|
|
r.Action = Drop
|
|
case "reject":
|
|
r.Action = Reject
|
|
case "limit":
|
|
// ufw's `limit` is an accept that rate-limits new connections (its
|
|
// built-in policy blocks a source with 6 or more connections in 30
|
|
// seconds, i.e. 6 per 30s). Represent it as an accept carrying that rate
|
|
// so the rule is reported by GetRules and stays distinct from a plain
|
|
// allow; the window is expressed per-minute (12/minute == 6/30s) as the
|
|
// model has no sub-minute unit.
|
|
r.Action = Accept
|
|
r.RateLimit = &RateLimit{Rate: 12, Unit: PerMinute, Burst: 6}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported action: %s", tokens[0])
|
|
}
|
|
|
|
// The trailing token(s) after the six core fields carry the direction and any
|
|
// interface binding. An ordinary rule has one such token (`in`, `out`, or an
|
|
// interface-bound `in_eth0`/`out_eth0`); a route rule binding both interfaces
|
|
// has two (`in_eth0 out_eth1`). An application-profile tuple (n==9) carries the
|
|
// dapp/sapp labels in tokens 6-7 and the direction in token 8. A 6-token tuple
|
|
// omits the field and defaults to inbound. For a route rule the direction stays
|
|
// forward and the interfaces populate InInterface/OutInterface; for an ordinary
|
|
// rule the single token fixes the in/out direction and its interface.
|
|
var dirToks []string
|
|
switch n {
|
|
case 7:
|
|
dirToks = tokens[6:7]
|
|
case 8:
|
|
dirToks = tokens[6:8]
|
|
case 9:
|
|
dirToks = tokens[8:9]
|
|
}
|
|
for _, tok := range dirToks {
|
|
name, iface, hasIface := strings.Cut(tok, "_")
|
|
switch name {
|
|
case "in":
|
|
if !forward {
|
|
r.Direction = DirInput
|
|
}
|
|
if hasIface {
|
|
r.InInterface = iface
|
|
}
|
|
case "out":
|
|
if !forward {
|
|
r.Direction = DirOutput
|
|
}
|
|
if hasIface {
|
|
r.OutInterface = iface
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported direction: %s", tok)
|
|
}
|
|
}
|
|
|
|
// Verify the protocol value is valid.
|
|
r.Proto = GetProtocol(tokens[1])
|
|
if r.Proto == ProtocolAny && !strings.EqualFold(tokens[1], "any") {
|
|
return nil, fmt.Errorf("invalid protocol parameter")
|
|
}
|
|
|
|
// Parse destination port(s): a single port, a colon range, or a comma list.
|
|
if !strings.EqualFold(tokens[2], "any") {
|
|
specs, perr := ParsePortRanges(tokens[2], ",")
|
|
if perr != nil {
|
|
return nil, fmt.Errorf("the port argument %s is invalid", tokens[2])
|
|
}
|
|
portSpecsToRule(r, specs)
|
|
}
|
|
|
|
// Parse destination address.
|
|
r.Destination, err = f.parseAddr(tokens[3])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse source port(s).
|
|
if !strings.EqualFold(tokens[4], "any") {
|
|
specs, perr := ParsePortRanges(tokens[4], ",")
|
|
if perr != nil {
|
|
return nil, fmt.Errorf("the source port argument %s is invalid", tokens[4])
|
|
}
|
|
sourcePortSpecsToRule(r, specs)
|
|
}
|
|
|
|
// Parse source address.
|
|
r.Source, err = f.parseAddr(tokens[5])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return
|
|
}
|
|
|
|
// parseTupleRows scans a ufw rules file and returns one entry per `### tuple ###`
|
|
// line, in file order: the parsed rule, or nil for a non-empty tuple this backend
|
|
// does not model (one that fails to parse). ufw counts every tuple in its own
|
|
// numbered list, so keeping such rows as nil lets callers map a representable rule
|
|
// to its true physical position. Only a tuple whose body is empty after stripping
|
|
// the comment is dropped without occupying a slot.
|
|
func (f *UFW) parseTupleRows(filePath string, family Family) ([]*Rule, error) {
|
|
fd, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = fd.Close() }()
|
|
|
|
var rows []*Rule
|
|
scanner := bufio.NewScanner(fd)
|
|
for scanner.Scan() {
|
|
// Get the line.
|
|
line := scanner.Text()
|
|
|
|
// Ignore non-tuple lines.
|
|
tuplePrefix := "### tuple ### "
|
|
if !strings.HasPrefix(line, tuplePrefix) {
|
|
continue
|
|
}
|
|
line = strings.TrimPrefix(line, tuplePrefix)
|
|
|
|
// Remove comments.
|
|
ci := strings.IndexByte(line, '#')
|
|
if ci >= 0 {
|
|
line = line[:ci]
|
|
}
|
|
// A trailing ` comment=<hex>` carries the ufw rule comment, hex-encoded
|
|
// UTF-8. Capture and decode it, then strip it before parsing the tuple.
|
|
var comment string
|
|
if ci = strings.LastIndex(line, " comment="); ci >= 0 {
|
|
hexVal := strings.TrimSpace(line[ci+len(" comment="):])
|
|
if b, derr := hex.DecodeString(hexVal); derr == nil {
|
|
comment = string(b)
|
|
}
|
|
line = line[:ci]
|
|
}
|
|
|
|
// Trim spaces.
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Ignore zero lines.
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Parse rule. A tuple this backend cannot model (e.g. a route/forward rule)
|
|
// is kept as a nil row so it still occupies a physical position.
|
|
rule, err := f.UnmarshalRule(line, family)
|
|
if err != nil {
|
|
rows = append(rows, nil)
|
|
continue
|
|
}
|
|
// Strip the prefix so only the user-facing comment surfaces, and flag
|
|
// whether the prefix marked this as one of our rules.
|
|
text, hasPrefix := prefixedComment(f.rulePrefix, comment)
|
|
rule.Comment = text
|
|
rule.HasPrefix = hasPrefix
|
|
rows = append(rows, rule)
|
|
}
|
|
if serr := scanner.Err(); serr != nil {
|
|
return nil, serr
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// ParseRules reads a ufw rules file and returns the rules it models, in file order.
|
|
func (f *UFW) ParseRules(filePath string, family Family) (rules []*Rule, err error) {
|
|
rows, err := f.parseTupleRows(filePath, family)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range rows {
|
|
if r != nil {
|
|
rules = append(rules, r)
|
|
}
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
// GetRules reports it) to the 1-based position ufw's own numbered list uses for
|
|
// `ufw insert`. GetRules merges IPv4/IPv6 tuple pairs, but ufw numbers every IPv4
|
|
// tuple then every IPv6 tuple without merging, so the two index spaces diverge
|
|
// once a dual-family rule and a single-family rule coexist. The pre-merge tuple
|
|
// order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's native order, so
|
|
// the merged position's anchor row in that list is its native position. A position
|
|
// past the last logical rule maps past the native count, which ufw rejects and
|
|
func (f *UFW) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
|
|
// Parse IPv4 user rules.
|
|
tupleRules, err := f.ParseRules(UFWIPv4, IPv4)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse IPv6 user rules.
|
|
v6Rules, err := f.ParseRules(UFWIPv6, IPv6)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tupleRules = append(tupleRules, v6Rules...)
|
|
|
|
// Number the tuple rules as one ordered list: `ufw insert` positions within a
|
|
// single numbered list spanning both families. The raw before.rules entries read
|
|
// below sit outside that list, so they keep Number 0.
|
|
numberSequential(tupleRules)
|
|
rules = append(rules, tupleRules...)
|
|
|
|
// Parse the before.rules iptables files, which carry ICMP and other rules the
|
|
// user-rule tuple format cannot express. Only the before.rules files are read:
|
|
// this backend writes and removes raw rules exclusively there (see
|
|
// iptablesFilesFor), so reading after.rules too would surface rules it cannot
|
|
// remove — Restore then re-added them into before.rules, duplicating them.
|
|
iptablesFiles := []struct {
|
|
path string
|
|
family Family
|
|
}{
|
|
{UFWBefore, IPv4},
|
|
{UFWBefore6, IPv6},
|
|
}
|
|
for _, ff := range iptablesFiles {
|
|
iptablesRules, ferr := f.ParseIPTablesRules(ff.path, ff.family)
|
|
if ferr != nil {
|
|
return nil, ferr
|
|
}
|
|
rules = append(rules, iptablesRules...)
|
|
}
|
|
|
|
// Merge rules across families, then renumber the ufw-list rules so a collapsed
|
|
// v4/v6 pair leaves no gap. Only the numbered tuple rules (Number != 0) are
|
|
// resequenced; before.rules entries kept Number 0 and stay outside the list.
|
|
rules = mergeFamilies(rules)
|
|
n := 0
|
|
for _, r := range rules {
|
|
if r.Number != 0 {
|
|
n++
|
|
r.Number = n
|
|
}
|
|
}
|
|
// Collapse each input/output twin into one DirAny rule after numbering, so the
|
|
// surviving tuple rows keep their list position.
|
|
rules = mergeDirections(rules)
|
|
return
|
|
}
|
|
|
|
// zeroNet returns the zero-network ("any") CIDR for a family, defaulting to
|
|
// the IPv4 form when the family is unspecified.
|
|
func (f *UFW) zeroNet(fam Family) string {
|
|
if fam == IPv6 {
|
|
return "::/0"
|
|
}
|
|
return "0.0.0.0/0"
|
|
}
|
|
|
|
// anyAddr returns the address literal used to stand in for an unspecified
|
|
// endpoint when ufw's grammar forces one. A concrete family uses its
|
|
// zero-network CIDR; a family-agnostic rule uses the literal "any" so ufw
|
|
// installs both the IPv4 and IPv6 rule — a zero-network CIDR (which is
|
|
// family-specific) would silently pin the rule to a single family and break the
|
|
// round-trip back to FamilyAny.
|
|
func (f *UFW) anyAddr(fam Family) string {
|
|
if fam == FamilyAny {
|
|
return "any"
|
|
}
|
|
return f.zeroNet(fam)
|
|
}
|
|
|
|
// isNativeLimit reports whether r is expressible as ufw's built-in `limit`
|
|
// action: an accept carrying exactly ufw's fixed rate (6 connections per 30s,
|
|
// modeled as 12/minute burst 6) and no other modifier the tuple form cannot
|
|
// hold. UnmarshalRule decodes a `limit` tuple into this exact shape, so it is the
|
|
// signature that round-trips through the CLI rather than the before.rules files.
|
|
func (f *UFW) isNativeLimit(r *Rule) bool {
|
|
// Logging is allowed: `ufw limit log ...` writes a `limit_log` tuple, which
|
|
// UnmarshalRule decodes back into this same shape with Log set. Excluding
|
|
// logged limits would route such a rule to the before.rules files even though
|
|
// it lives in user.rules, leaving it unremovable there and duplicating it on
|
|
// Restore. A custom LogPrefix still cannot be expressed in a tuple, so a limit
|
|
// carrying one stays false here and is routed to before.rules (which can).
|
|
return r.Action == Accept && r.ConnLimit == nil && r.State == 0 && r.LogPrefix == "" &&
|
|
r.RateLimit != nil && *r.RateLimit == RateLimit{Rate: 12, Unit: PerMinute, Burst: 6}
|
|
}
|
|
|
|
// protoNeedsRaw reports whether a protocol cannot be expressed through ufw's
|
|
// CLI/tuple format and must instead be written as a raw before.rules rule. ufw's
|
|
// supported_protocols list (src/util.py) carries tcp, udp, esp, ah and gre
|
|
// natively, so only ICMP/ICMPv6 and SCTP — which ufw does not accept — go through
|
|
// the iptables rules files.
|
|
func (f *UFW) protoNeedsRaw(p Protocol) bool {
|
|
return p.IsICMP() || p == SCTP
|
|
}
|
|
|
|
// MarshalRule encodes a firewall rule into a ufw rulespec.
|
|
func (f *UFW) MarshalRule(r *Rule) (string, error) {
|
|
// Features this backend cannot express in its rule model are rejected up
|
|
// front rather than silently dropped. ufw's tuple carries tcp, udp, esp, ah
|
|
// and gre; ICMP/ICMPv6 and SCTP go through before.rules (needsIPTablesRules).
|
|
if f.protoNeedsRaw(r.Proto) {
|
|
return "", fmt.Errorf("ufw does not express the %s protocol in a tuple", r.Proto)
|
|
}
|
|
if r.State != 0 {
|
|
return "", fmt.Errorf("ufw does not support connection-state matching")
|
|
}
|
|
// Connection limits, and any rate limit other than ufw's built-in `limit`,
|
|
// live in the before.rules files, not a user.rules tuple — callers route them
|
|
// via needsIPTablesRules. Reaching MarshalRule with one means it would be
|
|
// silently dropped, so reject it rather than lose it.
|
|
if r.ConnLimit != nil {
|
|
return "", fmt.Errorf("ufw does not express a connection limit in a tuple")
|
|
}
|
|
if r.RateLimit != nil && !f.isNativeLimit(r) {
|
|
return "", fmt.Errorf("ufw expresses only its built-in rate limit (`limit`) in a tuple")
|
|
}
|
|
// ufw logs a matched rule with its `log`/`log-all` keyword, but always with its
|
|
// own built-in log prefixes — it cannot set a custom LogPrefix. A rule that
|
|
// needs a custom prefix is routed to before.rules by needsIPTablesRules;
|
|
// reaching here with one means it would be silently dropped, so reject it.
|
|
if r.Log && r.LogPrefix != "" {
|
|
return "", fmt.Errorf("ufw cannot set a custom log prefix in a tuple")
|
|
}
|
|
// ufw needs a concrete tcp/udp protocol to match multiple ports (a list or a
|
|
// range); a single port may be matched across any protocol.
|
|
if r.HasPortSet() && r.Proto != TCP && r.Proto != UDP {
|
|
return "", fmt.Errorf("ufw requires tcp or udp with multiple ports")
|
|
}
|
|
// ufw can match a source port across any protocol for a single port, but (like
|
|
// a multiport destination) needs a concrete tcp/udp protocol for a list or
|
|
// range. A single source port with an unspecified protocol is fine.
|
|
if r.HasSourcePortSet() && r.Proto != TCP && r.Proto != UDP {
|
|
return "", fmt.Errorf("ufw requires tcp or udp for a source-port list or range")
|
|
}
|
|
|
|
// A negated address (plain or ipset) has no ufw tuple form, so AddRule diverts
|
|
// it to before.rules (needsIPTablesRules) and never reaches here with one.
|
|
|
|
// An input rule binds only an in-interface, an output rule only an
|
|
// out-interface; a forward (route) rule may bind either or both.
|
|
if r.IsOutput() && r.InInterface != "" {
|
|
return "", fmt.Errorf("an input interface cannot be matched on an output rule")
|
|
}
|
|
if r.IsInput() && r.OutInterface != "" {
|
|
return "", fmt.Errorf("an output interface cannot be matched on an input rule")
|
|
}
|
|
|
|
// Work on a copy as we infer the family and normalize the destination
|
|
// below, and we do not want to mutate the caller's rule.
|
|
ruleCopy := *r
|
|
r = &ruleCopy
|
|
|
|
// Start out with the action. ufw's built-in rate limit is its own `limit` verb
|
|
// (an accept), so a native-limit rule emits that rather than `allow`;
|
|
// UnmarshalRule reads it back into the same rate.
|
|
action := "allow"
|
|
if f.isNativeLimit(r) {
|
|
action = "limit"
|
|
} else if r.Action == Drop {
|
|
action = "deny"
|
|
} else if r.Action == Reject {
|
|
action = "reject"
|
|
}
|
|
parts := []string{action}
|
|
|
|
// Direction and interface binding. A forward rule is emitted as a route rule
|
|
// carrying an `in on <in>` and/or `out on <out>` clause (the `route` keyword and
|
|
// the command verb are prepended by the caller in ruleArgs); ufw rejects a
|
|
// bare direction on a route rule, so none is emitted. An ordinary rule carries
|
|
// its single direction and, when set, its interface.
|
|
hasIface := r.InInterface != "" || r.OutInterface != ""
|
|
if r.IsForward() {
|
|
if r.InInterface != "" {
|
|
parts = append(parts, "in", "on", r.InInterface)
|
|
}
|
|
if r.OutInterface != "" {
|
|
parts = append(parts, "out", "on", r.OutInterface)
|
|
}
|
|
} else {
|
|
dir := "in"
|
|
iface := r.InInterface
|
|
if r.IsOutput() {
|
|
dir = "out"
|
|
iface = r.OutInterface
|
|
}
|
|
parts = append(parts, dir)
|
|
if iface != "" {
|
|
parts = append(parts, "on", iface)
|
|
}
|
|
}
|
|
|
|
// Per-rule logging: ufw's `log` keyword follows the direction and any interface
|
|
// clause (a non-interface rule has its direction stripped by ufw before the
|
|
// keyword is read, so `allow in log ...` and `allow in on eth0 log ...` are both
|
|
// valid). ufw uses its own log prefixes; a custom LogPrefix was rejected above.
|
|
if r.Log {
|
|
parts = append(parts, "log")
|
|
}
|
|
|
|
// If family is not defined, but a source or destination address is, find out the family.
|
|
if r.Family == FamilyAny {
|
|
addr := r.Source
|
|
if r.Destination != "" {
|
|
addr = r.Destination
|
|
}
|
|
if addr != "" {
|
|
netIP, _, err := net.ParseCIDR(addr)
|
|
ip := net.ParseIP(addr)
|
|
if err != nil && ip == nil {
|
|
return "", fmt.Errorf("bad IP format")
|
|
}
|
|
r.Family = IPv4
|
|
if err == nil {
|
|
// Address parsed as a CIDR, use the network IP to determine family.
|
|
if netIP.To4() == nil {
|
|
r.Family = IPv6
|
|
}
|
|
} else if ip.To4() == nil {
|
|
// Address parsed as a plain IP, use it to determine family.
|
|
r.Family = IPv6
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure the destination for family-specific rules
|
|
// has a zero IP address to allow using `to/from` definition.
|
|
if r.Family != FamilyAny && r.Destination == "" {
|
|
r.Destination = f.zeroNet(r.Family)
|
|
}
|
|
|
|
// A source port needs a `from ... port` clause, and a destination port that
|
|
// follows a from clause cannot use the bare short form, so synthesize
|
|
// zero-network addresses where needed to keep the grammar well formed.
|
|
srcAddr := r.Source
|
|
if r.HasSourcePorts() && srcAddr == "" {
|
|
srcAddr = f.anyAddr(r.Family)
|
|
}
|
|
dstAddr := r.Destination
|
|
if r.HasPorts() && dstAddr == "" && srcAddr != "" {
|
|
dstAddr = f.anyAddr(r.Family)
|
|
}
|
|
// ufw's short port form (`22/tcp`) is rejected when the rule also binds an
|
|
// interface (`on eth0`); that combination needs the full `to <any> port ...
|
|
// proto ...` grammar. Synthesize a destination so the full form is emitted,
|
|
// using the literal `any` for a family-agnostic rule so ufw still covers both
|
|
// IPv4 and IPv6.
|
|
if r.HasPorts() && dstAddr == "" && hasIface {
|
|
dstAddr = f.anyAddr(r.Family)
|
|
}
|
|
// A portless, address-less rule has no short form to hold its protocol, so give
|
|
// it an `any` destination and let the `proto` clause below carry the protocol.
|
|
// This covers a portless native protocol (gre, esp, ah) and, crucially, a bare
|
|
// tcp/udp match ("allow all TCP inbound"): without the synthesized destination
|
|
// the proto clause never fires and ufw is handed a bare `allow in`, which it
|
|
// rejects ("Invalid interface clause"). A true match-all rule (ProtocolAny, no
|
|
// match at all) likewise becomes `... to any`, the only form ufw accepts for it.
|
|
if !r.HasPorts() && !r.HasSourcePorts() && dstAddr == "" && srcAddr == "" {
|
|
dstAddr = f.anyAddr(r.Family)
|
|
}
|
|
|
|
// Add protocol only when an IP address (or a source port, which forces a
|
|
// from clause) is present; a bare destination-port rule carries its protocol
|
|
// in the short form below.
|
|
if r.Proto != ProtocolAny && (dstAddr != "" || srcAddr != "") {
|
|
parts = append(parts, "proto", r.Proto.String())
|
|
}
|
|
|
|
// Add source and its port(s).
|
|
if srcAddr != "" {
|
|
parts = append(parts, "from", srcAddr)
|
|
if r.HasSourcePorts() {
|
|
parts = append(parts, "port", iptMultiportValue(r.SourcePortSpecs()))
|
|
}
|
|
}
|
|
|
|
// Add destination and its port(s). ufw accepts a comma list and colon ranges,
|
|
// the same form as iptables multiport.
|
|
if dstAddr != "" {
|
|
parts = append(parts, "to", dstAddr)
|
|
if r.HasPorts() {
|
|
parts = append(parts, "port", iptMultiportValue(r.PortSpecs()))
|
|
}
|
|
}
|
|
|
|
// If destination port(s) defined and no address on either side, add them in
|
|
// ufw's short form.
|
|
if r.HasPorts() && dstAddr == "" && srcAddr == "" {
|
|
val := iptMultiportValue(r.PortSpecs())
|
|
if r.Proto == ProtocolAny {
|
|
parts = append(parts, val)
|
|
} else {
|
|
parts = append(parts, fmt.Sprintf("%s/%s", val, r.Proto.String()))
|
|
}
|
|
}
|
|
|
|
// Return the built parts joined with spaces.
|
|
return strings.Join(parts, " "), nil
|
|
}
|
|
|
|
// commentFor returns the comment text ufw should tag a rule with: the configured
|
|
// prefix carried alongside the user-supplied comment (prefix + " " + comment), so
|
|
// rules this library creates stay identifiable.
|
|
func (f *UFW) commentFor(r *Rule) string {
|
|
return combineComment(f.rulePrefix, r.Comment)
|
|
}
|
|
|
|
// rewriteToChain rewrites an iptables `-A INPUT/OUTPUT ...` line to use ufw's
|
|
// own before-chain names. The IPv6 rules file (before6.rules) declares its chains
|
|
// with the `ufw6-` prefix, so a rule bound there must use those names or
|
|
// ip6tables-restore rejects the file on `ufw reload`.
|
|
func (f *UFW) rewriteToChain(line string, family Family) (string, error) {
|
|
prefix := "ufw"
|
|
if family == IPv6 {
|
|
prefix = "ufw6"
|
|
}
|
|
if rest, ok := strings.CutPrefix(line, "-A INPUT "); ok {
|
|
return "-A " + prefix + "-before-input " + rest, nil
|
|
}
|
|
if rest, ok := strings.CutPrefix(line, "-A OUTPUT "); ok {
|
|
return "-A " + prefix + "-before-output " + rest, nil
|
|
}
|
|
if rest, ok := strings.CutPrefix(line, "-A FORWARD "); ok {
|
|
return "-A " + prefix + "-before-forward " + rest, nil
|
|
}
|
|
return "", fmt.Errorf("unexpected iptables rule form: %s", line)
|
|
}
|
|
|
|
// marshalIPTablesLines encodes a rule as the before.rules line(s) for the given
|
|
// family, reusing the iptables marshaller and rewriting the chain to ufw's own
|
|
// input/output/forward chain. A logged rule yields a LOG line followed by its
|
|
// action line.
|
|
func (f *UFW) marshalIPTablesLines(r *Rule, family Family) ([]string, error) {
|
|
ipt := &IPTables{rulePrefix: f.rulePrefix}
|
|
lines, err := ipt.marshalRuleLines(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
rewritten, rerr := f.rewriteToChain(line, family)
|
|
if rerr != nil {
|
|
return nil, rerr
|
|
}
|
|
out = append(out, rewritten)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// parseIPTablesLine parses a raw before.rules line into the rule it represents
|
|
// (one line, so a LOG line yields a rule with Log set and no action), reporting
|
|
// whether the line is an input/output iptables rule this model surfaces.
|
|
func (f *UFW) parseIPTablesLine(line string, family Family) (*Rule, bool) {
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 3 || (fields[0] != "-A" && fields[0] != "--append") {
|
|
return nil, false
|
|
}
|
|
dir, ok := f.ipTablesChain(fields[1])
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
parsed, err := unmarshalIPTablesRule("-A "+iptChainForDirection(dir)+" "+strings.Join(fields[2:], " "), family)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return parsed, true
|
|
}
|
|
|
|
// writeIPTablesRulesFile atomically replaces a before.rules file with out,
|
|
// preserving the existing file's mode and ownership.
|
|
func (f *UFW) writeIPTablesRulesFile(path string, out []string) error {
|
|
if err := writeConfigFile(path, []byte(strings.Join(out, "\n")), 0640); err != nil {
|
|
return fmt.Errorf("failed to move new firewall rules into place: %s", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// editIPTablesRulesFile inserts (or removes) a rule's line(s) in a before.rules
|
|
// file, just before its COMMIT. A logged rule occupies a LOG line plus an action
|
|
// line, coalesced on read and dropped together on remove. It returns whether the
|
|
// file was changed.
|
|
func (f *UFW) editIPTablesRulesFile(path string, r *Rule, family Family, remove bool) (bool, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
// Nothing to remove from a file that is not there.
|
|
if os.IsNotExist(err) && remove {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
lines := strings.Split(string(data), "\n")
|
|
|
|
if remove {
|
|
// Scan with a one-line lookback so a LOG line and its action partner are
|
|
// removed together.
|
|
out := make([]string, 0, len(lines))
|
|
removed := false
|
|
var pendingRaw string
|
|
var pendingRule *Rule
|
|
flush := func() {
|
|
if pendingRule != nil {
|
|
out = append(out, pendingRaw)
|
|
pendingRule = nil
|
|
}
|
|
}
|
|
for _, raw := range lines {
|
|
rule, ok := f.parseIPTablesLine(strings.TrimSpace(raw), family)
|
|
if !ok {
|
|
flush()
|
|
out = append(out, raw)
|
|
continue
|
|
}
|
|
if rule.Action == ActionInvalid && rule.Log {
|
|
flush()
|
|
pendingRaw = raw
|
|
pendingRule = rule
|
|
continue
|
|
}
|
|
logical := rule
|
|
merged := pendingRule != nil && iptSameMatch(pendingRule, rule)
|
|
if merged {
|
|
m := *rule
|
|
m.Log = true
|
|
m.LogPrefix = pendingRule.LogPrefix
|
|
logical = &m
|
|
}
|
|
if !removed && logical.EqualBase(r, true) {
|
|
removed = true
|
|
// Only drop the held LOG line when it was this rule's own LOG half
|
|
// (merged into logical). An unmerged pending line is a separate,
|
|
// foreign rule that must survive removal of this one, so flush it.
|
|
if !merged {
|
|
flush()
|
|
}
|
|
pendingRule = nil
|
|
continue
|
|
}
|
|
flush()
|
|
out = append(out, raw)
|
|
}
|
|
flush()
|
|
if !removed {
|
|
return false, nil
|
|
}
|
|
return true, f.writeIPTablesRulesFile(path, out)
|
|
}
|
|
|
|
// Add: skip if the logical rule already exists.
|
|
var perLine []*Rule
|
|
for _, line := range lines {
|
|
if rule, ok := f.parseIPTablesLine(strings.TrimSpace(line), family); ok {
|
|
perLine = append(perLine, rule)
|
|
}
|
|
}
|
|
for _, e := range coalesceLoggedRules(perLine) {
|
|
if e.EqualBase(r, true) {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
specs, err := f.marshalIPTablesLines(r, family)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
commitIdx := -1
|
|
for i, line := range lines {
|
|
if strings.TrimSpace(line) == "COMMIT" {
|
|
commitIdx = i
|
|
break
|
|
}
|
|
}
|
|
if commitIdx == -1 {
|
|
return false, fmt.Errorf("no COMMIT line found in %s", path)
|
|
}
|
|
out := make([]string, 0, len(lines)+len(specs))
|
|
out = append(out, lines[:commitIdx]...)
|
|
out = append(out, specs...)
|
|
out = append(out, lines[commitIdx:]...)
|
|
return true, f.writeIPTablesRulesFile(path, out)
|
|
}
|
|
|
|
// iptablesFilesFor returns the before.rules file(s) a rule applies to. An ICMP
|
|
// protocol pins the family; a family-agnostic rule (e.g. a bare state match)
|
|
// touches both the IPv4 and IPv6 files.
|
|
func (f *UFW) iptablesFilesFor(r *Rule) []string {
|
|
switch r.impliedFamily() {
|
|
case IPv4:
|
|
return []string{UFWBefore}
|
|
case IPv6:
|
|
return []string{UFWBefore6}
|
|
default:
|
|
return []string{UFWBefore, UFWBefore6}
|
|
}
|
|
}
|
|
|
|
// editIPTablesRules applies an add/remove across every before.rules file the rule
|
|
// touches, recording whether a reload is needed.
|
|
func (f *UFW) editIPTablesRules(r *Rule, remove bool) error {
|
|
for _, path := range f.iptablesFilesFor(r) {
|
|
family := IPv4
|
|
if path == UFWBefore6 {
|
|
family = IPv6
|
|
}
|
|
changed, err := f.editIPTablesRulesFile(path, r, family, remove)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if changed {
|
|
f.iptablesRulesChanged = true
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// needsIPTablesRules reports whether a rule must be written as raw iptables
|
|
// rules rather than through ufw's command line. The ufw CLI and its user.rules
|
|
// tuple format cannot express ICMP/SCTP, a connection-state match, a custom log
|
|
// prefix or a rate/connection limit, but the before.rules files can. Plain
|
|
// logging (no custom prefix) is expressed natively with ufw's `log` keyword, so
|
|
// it stays on the CLI path.
|
|
func (f *UFW) needsIPTablesRules(r *Rule) bool {
|
|
if f.protoNeedsRaw(r.Proto) || r.State != 0 || (r.Log && r.LogPrefix != "") || r.ConnLimit != nil {
|
|
return true
|
|
}
|
|
// ufw's tuple format takes only addresses in from/to; an ipset reference is
|
|
// written as a raw before.rules rule (`-m set --match-set`) instead.
|
|
if isSetRef(r.Source) || isSetRef(r.Destination) {
|
|
return true
|
|
}
|
|
// ufw's tuple grammar has no address negation, but before.rules can express it
|
|
// as `iptables ! -s/-d`, so a negated plain address routes there rather than
|
|
// being rejected. (A negated ipset reference is already covered above.)
|
|
if neg, _ := splitAddrNeg(r.Source); neg {
|
|
return true
|
|
}
|
|
if neg, _ := splitAddrNeg(r.Destination); neg {
|
|
return true
|
|
}
|
|
// ufw's built-in `limit` action is expressed through the CLI/user.rules, so a
|
|
// rule carrying exactly that rate stays on the tuple path; any other rate
|
|
// limit can only be written as raw iptables in the before.rules files.
|
|
if r.RateLimit != nil && !f.isNativeLimit(r) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ruleArgs builds the argument list for a ufw rule command: the optional
|
|
// command verb tokens (e.g. {"prepend"}, {"insert", "3"}, {"delete"}, or none for
|
|
// a plain tail append) followed by the marshaled rule spec split into tokens. A
|
|
// forward rule is a ufw route rule, so the `route` keyword precedes the verb
|
|
// (`ufw route prepend allow in on eth0 ...`).
|
|
func (f *UFW) ruleArgs(r *Rule, verb []string, spec string) []string {
|
|
tokens := strings.Split(spec, " ")
|
|
args := make([]string, 0, 1+len(verb)+len(tokens))
|
|
if r.IsForward() {
|
|
args = append(args, "route")
|
|
}
|
|
args = append(args, verb...)
|
|
args = append(args, tokens...)
|
|
return args
|
|
}
|
|
|
|
// AddRule adds a filter rule to the zone.
|
|
func (f *UFW) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
|
// A DirAny rule fans out into an inbound tuple plus its role-swapped outbound
|
|
// tuple; add each concrete-direction half (either may route to before.rules).
|
|
if r.Direction == DirAny {
|
|
for _, sub := range expandDirections(r) {
|
|
if err := f.AddRule(ctx, zoneName, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ICMP, connection-state, logging and rate/connection-limit rules are not
|
|
// expressible through the ufw CLI, so they are written to the iptables-based
|
|
// before.rules files instead.
|
|
if f.needsIPTablesRules(r) {
|
|
return f.editIPTablesRules(r, false)
|
|
}
|
|
|
|
rule, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
args := f.ruleArgs(r, []string{"prepend"}, rule)
|
|
// Attach a comment: a user-supplied Comment takes precedence, otherwise the
|
|
// rule is tagged with our prefix so it can be identified as ours. The comment
|
|
// is only added on insert; ufw matches deletes on the rule without it.
|
|
if c := f.commentFor(r); c != "" {
|
|
args = append(args, "comment", c)
|
|
}
|
|
_, err = runCommand(ctx, "ufw", args...)
|
|
return err
|
|
}
|
|
|
|
// appendRule adds a rule at the end of ufw's numbered list with a plain
|
|
// `ufw <rule>` (ufw appends a non-inserted rule). It mirrors AddRule but does not
|
|
// use `ufw prepend`, so callers that need a tail append — InsertRule past the end,
|
|
// and MoveRule to the end — get end placement rather than front placement. Its
|
|
// only caller, InsertRule, already diverts raw rules to editIPTablesRules before
|
|
// reaching here, so r is always a native ufw rule at this point.
|
|
func (f *UFW) appendRule(ctx context.Context, r *Rule) error {
|
|
rule, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
args := f.ruleArgs(r, nil, rule)
|
|
if c := f.commentFor(r); c != "" {
|
|
args = append(args, "comment", c)
|
|
}
|
|
_, err = runCommand(ctx, "ufw", args...)
|
|
return err
|
|
}
|
|
|
|
// nativeInsertPositionFromRows maps a 1-based merged position to ufw's 1-based
|
|
// native insert position, given the physical tuple rows in ufw's own order. A nil
|
|
// row is a tuple ufw counts in its numbered list but this backend does not model (a
|
|
// route/forward rule); it still occupies a physical slot, so a route rule preceding
|
|
// the anchor shifts the native position instead of being ignored — which would
|
|
// place the rule one slot too early per preceding route rule and, for a first-match
|
|
// firewall, change enforcement. With no un-representable rows this reduces to the
|
|
// plain merged index (position within the representable, merge-collapsed list).
|
|
func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int {
|
|
var tuples []*Rule
|
|
var physPos []int
|
|
for i, r := range rows {
|
|
if r != nil {
|
|
tuples = append(tuples, r)
|
|
physPos = append(physPos, i+1)
|
|
}
|
|
}
|
|
repIdx := mergedInsertIndex(mergedFamilyAnchors(tuples), len(tuples), position)
|
|
if repIdx >= len(tuples) {
|
|
// Past the last logical rule: point past the last physical tuple so ufw
|
|
// rejects the position and InsertRule falls back to a plain append.
|
|
return len(rows) + 1
|
|
}
|
|
return physPos[repIdx]
|
|
}
|
|
|
|
// nativeInsertPosition maps a 1-based merged position (a rule's Number, as
|
|
func (f *UFW) nativeInsertPosition(position int) (int, error) {
|
|
v4, err := f.parseTupleRows(UFWIPv4, IPv4)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
v6, err := f.parseTupleRows(UFWIPv6, IPv6)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// Physical order is every IPv4 tuple then every IPv6 tuple — ufw's own numbered
|
|
// order.
|
|
return f.nativeInsertPositionFromRows(append(v4, v6...), position), nil
|
|
}
|
|
|
|
// InsertRule inserts rule before the given 1-based position using `ufw insert`.
|
|
// position <= 0 is treated as 1; a position larger than the current rule count
|
|
// appends the rule (ufw itself rejects an out-of-range position, so that case
|
|
// falls back to a plain add).
|
|
func (f *UFW) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
|
|
// A DirAny rule occupies a tuple in each direction; insert each half at the
|
|
// requested position.
|
|
if r.Direction == DirAny {
|
|
for _, sub := range expandDirections(r) {
|
|
if err := f.InsertRule(ctx, zoneName, position, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if f.needsIPTablesRules(r) {
|
|
return f.editIPTablesRules(r, false)
|
|
}
|
|
if position <= 0 {
|
|
position = 1
|
|
}
|
|
|
|
rule, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// position is a merged Number: GetRules collapses IPv4/IPv6 tuple pairs and
|
|
// numbers the result, while `ufw insert` counts ufw's own numbered list, which
|
|
// lists every IPv4 tuple then every IPv6 tuple without merging. Map the merged
|
|
// position to that native position so a dual-family rule earlier in the list
|
|
// does not skew the insert (splitting a pair) when single-family rules coexist.
|
|
native, err := f.nativeInsertPosition(position)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
position = native
|
|
|
|
args := f.ruleArgs(r, []string{"insert", strconv.Itoa(position)}, rule)
|
|
if c := f.commentFor(r); c != "" {
|
|
args = append(args, "comment", c)
|
|
}
|
|
_, err = runCommand(ctx, "ufw", args...)
|
|
// ufw rejects a position past the end of its (per-family) numbered rule list.
|
|
// The interface contract asks to append there, and ufw's own validation is the
|
|
// only reliable measure of that list's length. Append with a plain `ufw <rule>`
|
|
// (which adds at the tail); AddRule instead uses `ufw prepend`, which would put
|
|
// the rule at the front rather than the end.
|
|
if err != nil && strings.Contains(err.Error(), "Invalid position") {
|
|
return f.appendRule(ctx, r)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// RemoveRule removes a filter rule from the zone.
|
|
func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
|
// A DirAny target removes both its inbound and outbound tuple.
|
|
if r.Direction == DirAny {
|
|
for _, sub := range expandDirections(r) {
|
|
if err := f.RemoveRule(ctx, zoneName, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if f.needsIPTablesRules(r) {
|
|
return f.editIPTablesRules(r, true)
|
|
}
|
|
|
|
rule, err := f.MarshalRule(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
args := f.ruleArgs(r, []string{"delete"}, rule)
|
|
if _, err = runCommand(ctx, "ufw", args...); err != nil {
|
|
// Removing an already-absent rule is a no-op, matching every other backend.
|
|
// ufw reports the miss as "Could not delete non-existent rule".
|
|
if strings.Contains(strings.ToLower(err.Error()), "could not delete") {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MoveRule repositions an existing rule. ufw has no native move verb, so a move
|
|
// is a positional delete-then-insert: the rule is removed and re-inserted at the
|
|
// requested slot. It is therefore not atomic — if the re-insert fails the rule is
|
|
// left removed. A position larger than the rule count moves the rule to the end
|
|
// (via InsertRule's append fallback).
|
|
func (f *UFW) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
|
|
if err := f.RemoveRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
return f.InsertRule(ctx, zoneName, position, r)
|
|
}
|
|
|
|
// natHelper returns an iptables backend scoped to ufw's before.rules files, so
|
|
// the iptables nat-table machinery (marshal/parse/edit) can be reused: ufw's
|
|
// before.rules is loaded through iptables-restore and takes a standard `*nat`
|
|
// table. Edits set iptablesRulesChanged so Reload runs `ufw reload`.
|
|
func (f *UFW) natHelper() *IPTables {
|
|
return &IPTables{rulePrefix: f.rulePrefix, IP4Path: UFWBefore, IP6Path: UFWBefore6}
|
|
}
|
|
|
|
// GetNATRules returns the current NAT rules for the zone.
|
|
func (f *UFW) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
|
|
return f.natHelper().GetNATRules(ctx, zoneName)
|
|
}
|
|
|
|
// AddNATRule adds a NAT rule to the zone.
|
|
func (f *UFW) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
|
if err := f.natHelper().AddNATRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
f.iptablesRulesChanged = true
|
|
return nil
|
|
}
|
|
|
|
// InsertNATRule positions a NAT rule within ufw's before.rules nat table, reusing
|
|
// the iptables helper's insert machinery.
|
|
func (f *UFW) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
|
|
if err := f.natHelper().InsertNATRule(ctx, zoneName, position, r); err != nil {
|
|
return err
|
|
}
|
|
f.iptablesRulesChanged = true
|
|
return nil
|
|
}
|
|
|
|
// RemoveNATRule removes a NAT rule from the zone.
|
|
func (f *UFW) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
|
if err := f.natHelper().RemoveNATRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
f.iptablesRulesChanged = true
|
|
return nil
|
|
}
|
|
|
|
// Backup captures the current filter and NAT rules managed by this backend.
|
|
func (f *UFW) Backup(ctx context.Context, zoneName string) (*Backup, error) {
|
|
rules, err := f.GetRules(ctx, zoneName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
natRules, err := f.GetNATRules(ctx, zoneName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Backup captures the full filter and NAT rule state plus the default policy and
|
|
// managed ipsets; Restore rebuilds them, so every rule read is preserved and
|
|
// re-applied.
|
|
backup := &Backup{Rules: rules, NATRules: natRules}
|
|
if err := captureBackupState(ctx, f, zoneName, backup); err != nil {
|
|
return nil, err
|
|
}
|
|
return backup, nil
|
|
}
|
|
|
|
// Restore replaces the managed rules with the contents of a Backup.
|
|
func (f *UFW) Restore(ctx context.Context, zoneName string, backup *Backup) error {
|
|
if backup == nil {
|
|
return fmt.Errorf("backup cannot be nil")
|
|
}
|
|
|
|
// Snapshot the actual current state and remove it, so Restore reconciles the
|
|
// live firewall to the backup rather than only re-touching the backup's own
|
|
// rules: a rule present now but absent from the backup must be removed. Removal
|
|
// of an already-absent rule is tolerated as a no-op (RemoveRule/RemoveNATRule
|
|
// are idempotent), so a partially-applied backup can be re-restored cleanly.
|
|
current, err := f.GetRules(ctx, zoneName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
currentNAT, err := f.GetNATRules(ctx, zoneName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, r := range current {
|
|
// RemoveRule itself dispatches an iptables-file rule to editIPTablesRules, so
|
|
// every current rule — whichever form it takes — goes through the same call.
|
|
if err := f.RemoveRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, r := range currentNAT {
|
|
if err := f.RemoveNATRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Recreate the ipsets now that the current rules are gone (so nothing holds a
|
|
// set reference) and before the backup rules that reference them are re-added.
|
|
if err := restoreBackupSets(ctx, f, backup, false); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Re-add rules from backup, reproducing their backed-up order. AddRule appends
|
|
// an iptables-based rule (inserted before COMMIT in before.rules) but prepends a
|
|
// CLI rule (`ufw prepend`, always position 1), so the two groups need opposite
|
|
// iteration: append the iptables rules front-to-back, then prepend the CLI rules
|
|
// back-to-front so each prepend pushes the earlier rules down and rebuilds the
|
|
// original top-to-bottom order. Re-adding CLI rules front-to-back would reverse
|
|
// them, inverting first-match evaluation (a specific deny above a broad allow
|
|
// would land below it and never fire).
|
|
for _, r := range backup.Rules {
|
|
if f.needsIPTablesRules(r) {
|
|
if err := f.AddRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for i := len(backup.Rules) - 1; i >= 0; i-- {
|
|
r := backup.Rules[i]
|
|
if f.needsIPTablesRules(r) {
|
|
continue
|
|
}
|
|
if err := f.AddRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, r := range backup.NATRules {
|
|
if err := f.AddNATRule(ctx, zoneName, r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return applyBackupPolicy(ctx, f, zoneName, backup)
|
|
}
|
|
|
|
// policyKey is the /etc/default/ufw key for a direction's default policy.
|
|
func (f *UFW) policyKey(d Direction) string {
|
|
switch d {
|
|
case DirOutput:
|
|
return "DEFAULT_OUTPUT_POLICY"
|
|
case DirForward:
|
|
return "DEFAULT_FORWARD_POLICY"
|
|
}
|
|
return "DEFAULT_INPUT_POLICY"
|
|
}
|
|
|
|
// readPolicy reads /etc/default/ufw and returns the default policy for each
|
|
// direction. A direction whose key is absent is reported as ActionInvalid.
|
|
func (f *UFW) readPolicy() (*DefaultPolicy, error) {
|
|
fd, err := os.Open(UFWDefaults)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = fd.Close() }()
|
|
vals := map[string]string{}
|
|
scanner := bufio.NewScanner(fd)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if ci := strings.IndexByte(line, '#'); ci >= 0 {
|
|
line = line[:ci]
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
key, val, found := strings.Cut(line, "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
vals[strings.TrimSpace(key)] = trimQuotes(strings.TrimSpace(val))
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
policy := &DefaultPolicy{}
|
|
for _, d := range []Direction{DirInput, DirOutput, DirForward} {
|
|
v, ok := vals[f.policyKey(d)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
// ufw stores the policy as a quoted ACCEPT/DROP/REJECT token.
|
|
if a, err := ParseAction(v); err == nil && a != ActionInvalid {
|
|
policy.set(d, a)
|
|
}
|
|
}
|
|
return policy, nil
|
|
}
|
|
|
|
// GetDefaultPolicy returns the default filter policy for each direction.
|
|
func (f *UFW) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
|
|
return f.readPolicy()
|
|
}
|
|
|
|
// policyValue renders an action as ufw's quoted policy token
|
|
// (DEFAULT_*_POLICY="ACCEPT"), matching how ufw itself writes the file.
|
|
func (f *UFW) policyValue(a Action) string {
|
|
switch a {
|
|
case Accept:
|
|
return `"ACCEPT"`
|
|
case Drop:
|
|
return `"DROP"`
|
|
case Reject:
|
|
return `"REJECT"`
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// writePolicy writes the default policy for each direction into
|
|
// /etc/default/ufw, updating an existing key in place and appending any that is
|
|
// absent.
|
|
func (f *UFW) writePolicy(policy *DefaultPolicy) error {
|
|
existing, err := os.ReadFile(UFWDefaults)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lines := strings.Split(string(existing), "\n")
|
|
written := map[Direction]bool{}
|
|
for i, line := range lines {
|
|
body := line
|
|
if ci := strings.IndexByte(body, '#'); ci >= 0 {
|
|
body = body[:ci]
|
|
}
|
|
key, _, found := strings.Cut(strings.TrimSpace(body), "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
for _, d := range []Direction{DirInput, DirOutput, DirForward} {
|
|
if key != f.policyKey(d) || policy.get(d) == ActionInvalid {
|
|
continue
|
|
}
|
|
lines[i] = fmt.Sprintf("%s=%s", f.policyKey(d), f.policyValue(policy.get(d)))
|
|
written[d] = true
|
|
}
|
|
}
|
|
for _, d := range []Direction{DirInput, DirOutput, DirForward} {
|
|
if written[d] || policy.get(d) == ActionInvalid {
|
|
continue
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%s=%s", f.policyKey(d), f.policyValue(policy.get(d))))
|
|
}
|
|
return writeConfigFile(UFWDefaults, []byte(strings.Join(lines, "\n")), 0640)
|
|
}
|
|
|
|
// SetDefaultPolicy sets the default filter policy for each direction.
|
|
func (f *UFW) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
|
|
if policy == nil {
|
|
return fmt.Errorf("policy cannot be nil")
|
|
}
|
|
// ufw supports accept, drop and reject as a default policy; a direction left
|
|
// ActionInvalid is skipped by writePolicy and left unchanged.
|
|
if err := f.writePolicy(policy); err != nil {
|
|
return err
|
|
}
|
|
f.iptablesRulesChanged = true
|
|
return nil
|
|
}
|
|
|
|
// --- address sets (ipset) ---------------------------------------------------
|
|
|
|
// setHelper returns a plain iptables backend (system default paths) that carries
|
|
// ufw's rule prefix, used to reach the ipset-backed address-set implementation.
|
|
func (f *UFW) setHelper() *IPTables {
|
|
return &IPTables{rulePrefix: f.rulePrefix}
|
|
}
|
|
|
|
// GetAddressSets returns all managed address sets.
|
|
func (f *UFW) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
|
return f.setHelper().GetAddressSets(ctx)
|
|
}
|
|
|
|
// GetAddressSet returns the named address set.
|
|
func (f *UFW) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
|
return f.setHelper().GetAddressSet(ctx, name)
|
|
}
|
|
|
|
// AddAddressSet creates an address set.
|
|
func (f *UFW) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
|
return f.setHelper().AddAddressSet(ctx, set)
|
|
}
|
|
|
|
// RemoveAddressSet removes the named address set.
|
|
func (f *UFW) RemoveAddressSet(ctx context.Context, name string) error {
|
|
return f.setHelper().RemoveAddressSet(ctx, name)
|
|
}
|
|
|
|
// AddAddressSetEntry adds an entry to an address set.
|
|
func (f *UFW) AddAddressSetEntry(ctx context.Context, name, entry string) error {
|
|
return f.setHelper().AddAddressSetEntry(ctx, name, entry)
|
|
}
|
|
|
|
// RemoveAddressSetEntry removes an entry from an address set.
|
|
func (f *UFW) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
|
|
return f.setHelper().RemoveAddressSetEntry(ctx, name, entry)
|
|
}
|
|
|
|
// Reload re-applies edits to the iptables rules files; rules added through the
|
|
// ufw CLI apply immediately, but edits to those files only take effect after a
|
|
// reload.
|
|
func (f *UFW) Reload(ctx context.Context) error {
|
|
if f.iptablesRulesChanged {
|
|
if _, err := runCommand(ctx, "ufw", "reload"); err != nil {
|
|
return err
|
|
}
|
|
f.iptablesRulesChanged = false
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Close releases any resources held by the backend.
|
|
func (f *UFW) Close(ctx context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// get returns the action for a direction on a DefaultPolicy.
|
|
func (p *DefaultPolicy) get(d Direction) Action {
|
|
switch d {
|
|
case DirOutput:
|
|
return p.Output
|
|
case DirForward:
|
|
return p.Forward
|
|
}
|
|
return p.Input
|
|
}
|
|
|
|
// set assigns the action for a direction on a DefaultPolicy.
|
|
func (p *DefaultPolicy) set(d Direction, a Action) {
|
|
switch d {
|
|
case DirOutput:
|
|
p.Output = a
|
|
case DirForward:
|
|
p.Forward = a
|
|
default:
|
|
p.Input = a
|
|
}
|
|
}
|