Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged value spanning both transports, distinct from ProtocolAny (which matches every IP protocol and carries no port). Backends whose native syntax holds both transports in one row (nftables, ufw, apf) store and read it as one rule; the rest fan it out with expandProtocols. Removing one transport of a merged row splits it via splitMergedRow, which composes the family and protocol splits so an nftables row merged on both axes leaves a correct, non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT. Remove read-side merging. GetRules now reports the firewall's actual rows and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up separately-stored ones, so mergeFamilies, mergeDirections and their helpers are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the NATRule pair) expand a rule across family, transport and direction and decide containment cell by cell, which is what lets Sync stay a no-op against its own output whichever representation a backend chose. Extract the systemd/SysV service helpers out of the iptables backend into services.go so every Linux backend shares one implementation, and document the multi-state rule model and the coverage helpers in the README.
1734 lines
60 KiB
Go
1734 lines
60 KiB
Go
package firewall
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
// 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
|
|
|
|
// Confirm ufw is enabled under whatever init system the host uses
|
|
// (systemd, chkconfig, update-rc.d, OpenRC, Slackware rc.d, or rc.local).
|
|
if !serviceEnabled(ctx, "ufw") {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Resolve the protocol token. ufw's `any` is deferred: on a ported tuple it means
|
|
// tcp+udp together (TCPUDP), on a portless tuple it means every IP protocol
|
|
// (ProtocolAny), so the ports must be parsed first to tell them apart. A non-`any`
|
|
// token must name a known protocol; GetProtocol returns ProtocolAny for an unknown
|
|
// value, so an unknown token that is not literally `any` is rejected.
|
|
isAny := strings.EqualFold(tokens[1], "any")
|
|
r.Proto = GetProtocol(tokens[1])
|
|
if r.Proto == ProtocolAny && !isAny {
|
|
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
|
|
}
|
|
|
|
// Resolve ufw's `any` protocol now that the ports are known: a ported `any` tuple
|
|
// matches tcp+udp together (TCPUDP), a portless one matches every IP protocol
|
|
// (ProtocolAny, the value GetProtocol already assigned). TCPUDP carries ports,
|
|
// ProtocolAny does not, so this keeps the two ufw meanings of `any` distinct.
|
|
if isAny && (r.HasPorts() || r.HasSourcePorts()) {
|
|
r.Proto = TCPUDP
|
|
}
|
|
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 returns the existing filter rules from the zone: ufw's own numbered
|
|
// tuples from user.rules and user6.rules, then the raw iptables rules from the
|
|
// before.rules files, which carry the matches the tuple format cannot express.
|
|
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...)
|
|
}
|
|
|
|
// Every row above is reported as ufw stores it. ufw keys its two families into
|
|
// separate files and its raw entries into per-family before.rules, so a rule
|
|
// spanning both families is two rows; only the ported `any` tuple carries both
|
|
// transports in one entry, and UnmarshalRule reads that back as TCPUDP on its own.
|
|
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 cannot match a port without a port-carrying protocol. Its `any` protocol on
|
|
// a ported rule means tcp+udp only (TCPUDP, which carries ports), never every
|
|
// protocol: a ProtocolAny port would silently widen the match to ICMP, GRE and the
|
|
// rest. PortNeedsConcreteProtocol is true for exactly that case (a port with a
|
|
// non-port-carrying protocol) — TCPUDP passes, ProtocolAny with a port is rejected.
|
|
if r.PortNeedsConcreteProtocol() {
|
|
return "", fmt.Errorf("ufw cannot match a port without a port-carrying protocol (tcp, udp or tcpudp)")
|
|
}
|
|
// A portless TCPUDP has no single-tuple form: omitting the protocol on a portless
|
|
// rule yields ufw's every-protocol match, silently widening tcp+udp to every
|
|
// protocol. AddRule fans such a rule out into concrete tcp+udp tuples before it
|
|
// reaches here (see tcpudpNeedsExpand), so one arriving at MarshalRule is a caller
|
|
// error rather than a shape to mis-encode.
|
|
if r.Proto == TCPUDP && !r.HasPorts() && !r.HasSourcePorts() {
|
|
return "", fmt.Errorf("ufw cannot express a portless tcp+udp match 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. TCPUDP is excluded
|
|
// here deliberately: ufw itself rejects a bare multiport with "Must specify 'tcp'
|
|
// or 'udp' with multiple ports" (backend_iptables.py), so tcp+udp on a port list or
|
|
// range has no native tuple form and is fanned out into concrete tcp+udp tuples
|
|
// before reaching MarshalRule (see tcpudpNeedsExpand).
|
|
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. TCPUDP is
|
|
// excluded for the same reason as the destination case above.
|
|
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. A proto clause is emitted only for a concrete
|
|
// protocol: ufw's tuple has no `tcpudp` keyword — it expresses tcp+udp by OMITTING
|
|
// the protocol (its `any` protocol on a ported rule) — so TCPUDP emits no proto
|
|
// clause, exactly as ProtocolAny does.
|
|
if r.Proto != ProtocolAny && r.Proto != TCPUDP && (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. The protocol rides along as `port/proto` for a concrete
|
|
// transport, or bare `port` for ufw's `any` protocol — which on a ported rule
|
|
// means tcp+udp, i.e. TCPUDP. A ProtocolAny port was rejected up front, so the
|
|
// bare form here is reached only by TCPUDP.
|
|
if r.HasPorts() && dstAddr == "" && srcAddr == "" {
|
|
val := iptMultiportValue(r.PortSpecs())
|
|
if r.Proto == TCPUDP {
|
|
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. A removal clears every
|
|
// line the rule covers. 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
|
|
}
|
|
// Drop every line the target covers, not only the first, so a before.rules
|
|
// file holding the rule twice comes back clean in one pass. The target is
|
|
// already one concrete family/transport/direction cell — RemoveRule fanned
|
|
// out the merged axes — and the file itself pins the family.
|
|
if 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
|
|
}
|
|
|
|
// tcpudpNeedsExpand reports whether a TCPUDP rule cannot be written as a single
|
|
// native ufw tuple and must be fanned out into concrete tcp+udp rows. ufw carries
|
|
// tcp+udp in one tuple only through its `any` protocol on a ported CLI rule: a
|
|
// portless TCPUDP would become ufw's every-protocol match, a multiport TCPUDP is
|
|
// rejected by ufw itself ("Must specify 'tcp' or 'udp' with multiple ports"), and a
|
|
// TCPUDP rule routed to the before.rules raw path (state, icmp, a non-native limit,
|
|
// a negated/ipset address) must reach the iptables marshaller as a concrete
|
|
// transport since that path has no both-transports form either. Any of those splits
|
|
// into a tcp row and a udp row before the rule is written or removed, mirroring the
|
|
// DirAny fan-out.
|
|
func (f *UFW) tcpudpNeedsExpand(r *Rule) bool {
|
|
if r.Proto != TCPUDP {
|
|
return false
|
|
}
|
|
if f.needsIPTablesRules(r) {
|
|
return true
|
|
}
|
|
// Native only for a single-port `any` tuple: it must carry a port (a portless
|
|
// `any` matches every protocol) and match single ports only.
|
|
if !r.HasPorts() && !r.HasSourcePorts() {
|
|
return true
|
|
}
|
|
return r.HasPortSet() || r.HasSourcePortSet()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// A TCPUDP rule ufw cannot hold in a single native `any`-proto tuple is fanned out
|
|
// into a concrete tcp tuple and a udp tuple, the same way a DirAny rule fans out by
|
|
// direction. A native single-port TCPUDP falls through to the CLI path below, where
|
|
// MarshalRule emits ufw's bare/`any` short form for it.
|
|
if f.tcpudpNeedsExpand(r) {
|
|
for _, sub := range expandProtocols(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 logical 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. Every modeled tuple is its own rule, so with no
|
|
// un-representable rows this reduces to the plain physical position.
|
|
func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int {
|
|
var physPos []int
|
|
for i, r := range rows {
|
|
if r != nil {
|
|
physPos = append(physPos, i+1)
|
|
}
|
|
}
|
|
if position < 1 {
|
|
position = 1
|
|
}
|
|
if position-1 >= len(physPos) {
|
|
// 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[position-1]
|
|
}
|
|
|
|
// nativeInsertPosition maps a 1-based logical position (a rule's Number, as GetRules
|
|
// reports it) to the 1-based position ufw's own numbered list uses for `ufw insert`.
|
|
// The two index spaces differ because ufw counts route rules this backend does not
|
|
// model. The tuple order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's
|
|
// native order, so the logical position's row in that list is its native position. A
|
|
// position past the last logical rule maps past the native count, which ufw rejects
|
|
// and InsertRule then handles as a plain append.
|
|
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
|
|
}
|
|
|
|
// A TCPUDP rule with no single native tuple form is inserted as its concrete
|
|
// tcp+udp rows at the same position, mirroring AddRule's fan-out.
|
|
if f.tcpudpNeedsExpand(r) {
|
|
for _, sub := range expandProtocols(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 Number GetRules reported, which counts only the tuples this
|
|
// backend models, while `ufw insert` counts ufw's own numbered list — which also
|
|
// counts the route rules it does not model. Map to that native position so a
|
|
// route rule earlier in the list does not skew the insert.
|
|
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
|
|
}
|
|
|
|
// deleteNative marshals r into a ufw rulespec and removes it with `ufw delete`,
|
|
// treating an already-absent rule as a no-op (matching every other backend). It is
|
|
// the single-tuple removal primitive RemoveRule builds its protocol-axis handling on.
|
|
func (f *UFW) deleteNative(ctx context.Context, r *Rule) error {
|
|
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 {
|
|
// ufw reports a missing rule as "Could not delete non-existent rule".
|
|
if strings.Contains(strings.ToLower(err.Error()), "could not delete") {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// storedNativeTCPUDP returns the single native `any`-proto tuple ufw currently holds
|
|
// that backs r — one stored tuple UnmarshalRule read back as TCPUDP (ufw's ported
|
|
// `any`) — or nil when none does. A TCPUDP target may instead be backed by a
|
|
// separately-added tcp tuple and udp tuple, which delete individually, so the actual
|
|
// backing has to be resolved before deleting. It matches on everything but the
|
|
// transport (EqualForRemoval). It backs RemoveRule's choice between deleting one
|
|
// native tuple (splitting it for a concrete-transport target) and deleting a
|
|
// concrete tcp/udp pair, the analog of apf reading its CPORTS list before removal.
|
|
func (f *UFW) storedNativeTCPUDP(r *Rule) (*Rule, error) {
|
|
v4, err := f.ParseRules(UFWIPv4, IPv4)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v6, err := f.ParseRules(UFWIPv6, IPv6)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, e := range append(v4, v6...) {
|
|
if e.Proto == TCPUDP && e.EqualForRemoval(r, true) {
|
|
return e, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// A TCPUDP rule with no single native tuple form (a raw-path, portless or multiport
|
|
// both-transports match) is removed as its concrete tcp+udp rows, mirroring
|
|
// AddRule's fan-out. A native single-port TCPUDP falls through to the tuple-backing
|
|
// resolution below.
|
|
if f.tcpudpNeedsExpand(r) {
|
|
for _, sub := range expandProtocols(r) {
|
|
if err := f.RemoveRule(ctx, zoneName, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if f.needsIPTablesRules(r) {
|
|
return f.editIPTablesRules(r, true)
|
|
}
|
|
|
|
// Protocol-axis removal. A TCPUDP rule is backed either by a single native
|
|
// `any`-proto tuple or by a separately-added tcp tuple + udp tuple, so resolve the
|
|
// actual backing before deleting.
|
|
if onProtocolAxis(r.Proto) {
|
|
native, err := f.storedNativeTCPUDP(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if native != nil {
|
|
// Backed by a single native `any` tuple. Delete that tuple via its bare/`any`
|
|
// form (a TCPUDP marshal of the target). When the target names a single
|
|
// transport, re-add the surviving opposite transport so its coverage is kept —
|
|
// the protocol analog of splitting a dual-family row on removal
|
|
// (splitDualRowProtocol returns nil for a TCPUDP target, dropping the whole row).
|
|
del := *r
|
|
del.Proto = TCPUDP
|
|
if err := f.deleteNative(ctx, &del); err != nil {
|
|
return err
|
|
}
|
|
if s := splitDualRowProtocol(native, r); s != nil {
|
|
return f.AddRule(ctx, zoneName, s)
|
|
}
|
|
return nil
|
|
}
|
|
// No native tuple: the rule is backed by concrete-transport tuples. Expand a
|
|
// TCPUDP target into tcp+udp and delete each; a concrete target deletes itself.
|
|
for _, sub := range expandProtocols(r) {
|
|
if err := f.deleteNative(ctx, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return f.deleteNative(ctx, r)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|