- New Capabilities: PortPair, Negation, RejectAction, FamilyWithoutAddress, DenyActionFromConfig, advertised per backend. - coversDirection isolates DirForward even when output is unowned; add splitNATDualRow so a concrete-family removal re-adds the opposite family's NAT translation. - Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the "invalid" sentinel as input while JSON round-trips it. - Sync counts additions on mid-batch failure and uses RuleBatcher. - NewManager runs a probe loop joining each backend's reason for diagnosability; services.go drops "generated" from enabled, handles it on enable, clears start-limit-hit on restart, and matches rc.local by token. - nftables: per-source connection limits (meter set), quoted-token parsing preserving log-prefix spacing, digit-led prefix sanitizing. - apf/csf: deny-action-from-config with cached STOP settings, port lists and inexpressible shapes routed through the pre-hook, confKeyApplies guard against a missing config line. - atomic config writes fsync before rename and resolve symlinks; readConfValue is last-assignment-wins; runCommand preserves the exit code through the wrapped error. - Move coreos/go-systemd to the maintained v22 module directly.
1763 lines
62 KiB
Go
1763 lines
62 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"
|
|
// UFWBefore and its peers are the iptables rules files holding 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: %w", err)
|
|
}
|
|
|
|
// 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,
|
|
PortPair: true,
|
|
ConnState: true,
|
|
InterfaceMatch: true,
|
|
Logging: true,
|
|
RateLimit: true,
|
|
ConnLimit: true,
|
|
NAT: true,
|
|
RuleOrdering: true,
|
|
DefaultPolicy: true,
|
|
RuleCounters: false,
|
|
AddressSets: true,
|
|
Comments: true,
|
|
Negation: true,
|
|
RejectAction: true,
|
|
FamilyWithoutAddress: 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 is ufw's old storage format (six core fields plus dapp/sapp
|
|
// with no direction field), which this library does not model; it is rejected so
|
|
// the row stays opaque and is preserved verbatim.
|
|
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 dual-interface route rule stays at seven tokens — ufw joins both
|
|
// interfaces into one trailing "!"-separated token (`in_eth0!out_eth1`) — so
|
|
// eight tokens only occurs in ufw's unmodeled old format.
|
|
if n < 6 || n > 9 || n == 8 {
|
|
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 after the six core fields carries the direction and any
|
|
// interface binding: `in`, `out`, an interface-bound `in_eth0`/`out_eth0`, or
|
|
// — on a route rule binding both interfaces — ufw's "!"-joined pair
|
|
// `in_eth0!out_eth1`, still one token. 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 token fixes the in/out direction and
|
|
// its interface.
|
|
var dirTok string
|
|
switch n {
|
|
case 7:
|
|
dirTok = tokens[6]
|
|
case 9:
|
|
dirTok = tokens[8]
|
|
}
|
|
if dirTok != "" {
|
|
parts := strings.SplitN(dirTok, "!", 2)
|
|
if len(parts) == 2 && !forward {
|
|
// Only a route rule carries both interfaces in one token.
|
|
return nil, fmt.Errorf("unsupported direction: %s", dirTok)
|
|
}
|
|
for _, part := range parts {
|
|
name, iface, hasIface := strings.Cut(part, "_")
|
|
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", dirTok)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 (ufw's old 8-field
|
|
// format, an application profile it cannot resolve, a malformed line) 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: the family is inferred and the destination normalized
|
|
// below, and the caller's rule must not be mutated.
|
|
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
|
|
}
|
|
// Anchor on the *filter section's COMMIT, not the file's first one: the
|
|
// canonical ufw NAT setup adds a *nat block above *filter, and a filter rule
|
|
// spliced into that block references an undeclared chain, failing the whole
|
|
// reload at iptables-restore.
|
|
commitIdx := -1
|
|
inFilter := false
|
|
for i, line := range lines {
|
|
t := strings.TrimSpace(line)
|
|
if strings.HasPrefix(t, "*") {
|
|
inFilter = t == "*filter"
|
|
continue
|
|
}
|
|
if inFilter && t == "COMMIT" {
|
|
commitIdx = i
|
|
break
|
|
}
|
|
}
|
|
if commitIdx == -1 {
|
|
return false, fmt.Errorf("no *filter 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: the prefix tag and any user-supplied Comment are combined
|
|
// (prefix first, see combineComment) so the rule is identifiable as ours and
|
|
// the user text still surfaces. 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
|
|
// (an old-format or otherwise unparseable tuple); it still occupies a physical
|
|
// slot, so such a row preceding the anchor shifts the native position instead of
|
|
// being ignored — which would place the rule one slot too early per preceding
|
|
// unmodeled row 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's list also counts tuples 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 tuples it does not model. Map to that native position so an
|
|
// unmodeled tuple 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 may be backed by a single native
|
|
// `any`-proto tuple, by a separately-added tcp tuple + udp tuple, or — since
|
|
// ufw treats a differing protocol as a distinct rule — by both at once, so a
|
|
// removal sweeps every backing rather than stopping at the first.
|
|
if onProtocolAxis(r.Proto) {
|
|
native, err := f.storedNativeTCPUDP(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if native != nil {
|
|
// Delete the native `any` tuple via its bare/`any` form (a TCPUDP
|
|
// marshal of the target).
|
|
del := *r
|
|
del.Proto = TCPUDP
|
|
if err := f.deleteNative(ctx, &del); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Delete the concrete-transport tuples: a TCPUDP target expands into
|
|
// tcp+udp, a concrete target deletes itself. deleteNative is a no-op for a
|
|
// tuple ufw does not hold.
|
|
for _, sub := range expandProtocols(r) {
|
|
if err := f.deleteNative(ctx, sub); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// When a concrete-transport target consumed a native both-transports
|
|
// tuple, re-add the surviving opposite transport so its coverage is kept —
|
|
// the protocol analog of splitting a dual-family row on removal. AddRule
|
|
// dedups, so an already-present concrete twin is not doubled.
|
|
if native != nil {
|
|
if s := splitDualRowProtocol(native, r); s != nil {
|
|
return f.AddRule(ctx, zoneName, s)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|