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 ...` 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 // `_` 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=` 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 ` and/or `out on ` 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 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 ` (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 ` // (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 } }