go-firewall/wf_windows.go
James Coleman a036c8e6e9 Add TCPUDP protocol, coverage relation, and drop read-side merging
Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged
value spanning both transports, distinct from ProtocolAny (which matches
every IP protocol and carries no port). Backends whose native syntax holds
both transports in one row (nftables, ufw, apf) store and read it as one
rule; the rest fan it out with expandProtocols. Removing one transport of a
merged row splits it via splitMergedRow, which composes the family and
protocol splits so an nftables row merged on both axes leaves a correct,
non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT.

Remove read-side merging. GetRules now reports the firewall's actual rows
and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up
separately-stored ones, so mergeFamilies, mergeDirections and their helpers
are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead
compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the
NATRule pair) expand a rule across family, transport and direction and decide
containment cell by cell, which is what lets Sync stay a no-op against its
own output whichever representation a backend chose.

Extract the systemd/SysV service helpers out of the iptables backend into
services.go so every Linux backend shares one implementation, and document
the multi-state rule model and the coverage helpers in the README.
2026-07-09 17:52:19 -05:00

932 lines
32 KiB
Go

package firewall
import (
"context"
"fmt"
"net"
"strings"
wapi "github.com/iamacarpet/go-win64api"
"go4.org/netipx"
)
const (
// The IP protocol numbers for the transport/tunnel protocols the model adds.
// Windows filters by raw protocol number, so these map directly.
wfProtocolGRE = 47
wfProtocolESP = 50
wfProtocolAH = 51
wfProtocolSCTP = 132
// wfManagedDescription is the default filter description for rules this
// library creates that carry no user comment. It is treated as "no comment"
// on read so it does not surface as a Rule.Comment.
wfManagedDescription = "Managed by private-network firewall manager"
)
// WF manages firewall rules through the Windows Filtering Platform via the
// go-win64api binding, tagging its rules with the configured name prefix.
type WF struct {
rulePrefix string
}
// NewWF constructs a WF backend using rulePrefix as its rule-name namespace,
// confirming the Windows firewall interface is reachable.
func NewWF(ctx context.Context, rulePrefix string) (*WF, error) {
// Honor an already-cancelled context before touching the Windows API, whose
// calls are synchronous and cannot be cancelled mid-flight.
if err := ctx.Err(); err != nil {
return nil, err
}
wf := &WF{
rulePrefix: rulePrefix,
}
// Confirm the firewall interface works.
_, err := wapi.FirewallCurrentProfiles()
if err != nil {
return nil, err
}
// Return the firewall pointer.
return wf, nil
}
// Type returns the backend type string for the Windows firewall.
func (f *WF) Type() string {
return WFType
}
// Capabilities returns the set of features the Windows firewall backend can express.
func (f *WF) Capabilities() Capabilities {
return Capabilities{
Output: true,
ICMPv6: true,
PortList: true,
ConnState: false,
InterfaceMatch: false,
Logging: false,
RateLimit: false,
ConnLimit: false,
NAT: false,
RuleOrdering: false,
DefaultPolicy: false,
RuleCounters: false,
AddressSets: false,
Comments: true,
}
}
// GetZone reports no zone; Windows Firewall is profile-based, so an interface maps to no single zone.
func (f *WF) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
if err := ctx.Err(); err != nil {
return "", err
}
// Windows Firewall is profile-based (Domain, Private, Public), and an interface
// can carry multiple profiles, so an interface name maps to no single zone.
// Return empty to fall back to profile-wide rules.
return "", nil
}
// decodeAddress normalizes a Windows Firewall address string to a single CIDR/IP.
func (f *WF) decodeAddress(addr string) (newAddr string, err error) {
addr = strings.TrimSpace(addr)
// If wildcard or empty, return empty string.
if addr == "*" || addr == "" {
return
}
// WFP built-in rules frequently carry a comma-separated address list, which the
// single-valued Rule model cannot hold faithfully. Decode the first entry so the
// rule still surfaces on read instead of being silently dropped; the remaining
// addresses are not represented (a documented limitation of this backend).
if i := strings.IndexByte(addr, ','); i >= 0 {
return f.decodeAddress(strings.TrimSpace(addr[:i]))
}
// Parse IP range to single prefix if possible.
if strings.Contains(addr, "-") {
// Make IP range from parts.
r, err := netipx.ParseIPRange(addr)
if err != nil {
return "", err
}
// Get prefixes from the range.
prefixes := r.Prefixes()
if len(prefixes) != 1 {
return "", fmt.Errorf("unable to parse range into single prefix")
}
// Return the found prefix.
return prefixes[0].String(), nil
}
// Parse prefix from cidr or ip/netmask.
// Example: 192.168.1.0/24 OR 192.168.1.0/255.255.255.0
if strings.Contains(addr, "/") {
// Parse CIDR or IP/netmask.
var ipNet *net.IPNet
var ip net.IP
// Split into parts and confirm the length.
parts := strings.Split(addr, "/")
if len(parts) != 2 {
err = fmt.Errorf("invalid prefix length")
return
}
// The length side is a dotted-quad (IPv4) or colon-form (IPv6) netmask
// rather than a plain prefix length. A plain prefix is all digits, so any
// "." or ":" marks the ip/netmask form.
if strings.ContainsAny(parts[1], ".:") {
// Parse the netmask.
netmask := net.ParseIP(parts[1])
if netmask == nil {
err = fmt.Errorf("invalid netmask")
return
}
// Parse the network address.
ip = net.ParseIP(parts[0])
if ip == nil {
err = fmt.Errorf("invalid ip")
return
}
// Normalize an IPv4 pair to 4-byte form so the mask width matches the
// address width; leave IPv6 in 16-byte form. A family mismatch between
// the address and the netmask is invalid.
if m4 := netmask.To4(); m4 != nil {
ip4 := ip.To4()
if ip4 == nil {
err = fmt.Errorf("ip/netmask family mismatch")
return
}
netmask, ip = m4, ip4
} else if ip.To4() != nil {
err = fmt.Errorf("ip/netmask family mismatch")
return
}
mask := net.IPMask(netmask)
// Make the IP network, masking host bits so the ip/netmask form
// normalizes identically to the equivalent CIDR (net.ParseCIDR masks
// the network address; "192.168.1.5/255.255.255.0" and
// "192.168.1.5/24" must both decode to "192.168.1.0/24" or the two
// spellings would compare unequal in Rule.Equal).
ipNet = &net.IPNet{
IP: ip.Mask(mask),
Mask: mask,
}
} else {
// For standard CIDRs try and parse normally.
ip, ipNet, err = net.ParseCIDR(addr)
if err != nil {
return
}
}
// Get CIDR string.
newAddr = ipNet.String()
// See if this is an individual IP and update new address.
ones, bits := ipNet.Mask.Size()
if ones == bits {
newAddr = ip.String()
}
// Returned the parsed address.
return
}
// Handle single IP
ip := net.ParseIP(addr)
if ip == nil {
err = fmt.Errorf("invalid IP")
return
}
newAddr = ip.String()
return
}
// UnmarshallFWRule decodes a Windows FWRule into a Rule, returning nil for a rule the model cannot represent.
func (f *WF) UnmarshallFWRule(fr wapi.FWRule) *Rule {
r := &Rule{}
// A rule scoped by an attribute this model cannot represent — an application
// path, a Windows service, or a specific interface-type category (LAN,
// Wireless, RemoteAccess) — would decode into a bare, unscoped rule, silently
// widening a narrow foreign rule ("allow inbound TCP for program X") into a
// match-all one ("allow all inbound TCP"). Windows ships many such built-in
// rules; surfacing them misrepresented would let them compare equal to a
// genuinely bare rule and be reconciled or removed as if identical. Drop them
// from the view instead, as the ICMP-code and multi-pair cases below do. Our
// own rules never set these fields (MarshallFWRule cannot express an interface
// match and never sets an application or service), so this hides only foreign
// rules the model cannot faithfully hold.
if fr.ApplicationName != "" || fr.ServiceName != "" {
return nil
}
if it := strings.TrimSpace(fr.InterfaceTypes); it != "" && !strings.EqualFold(it, "All") {
return nil
}
// Map direction.
if fr.Direction == wapi.NET_FW_RULE_DIR_OUT {
r.Direction = DirOutput
} else {
r.Direction = DirInput
}
// Map action.
switch fr.Action {
case wapi.NET_FW_ACTION_ALLOW:
r.Action = Accept
case wapi.NET_FW_ACTION_BLOCK:
r.Action = Drop
default:
return nil
}
// Map protocol.
switch fr.Protocol {
case wapi.NET_FW_IP_PROTOCOL_TCP:
r.Proto = TCP
case wapi.NET_FW_IP_PROTOCOL_UDP:
r.Proto = UDP
case wapi.NET_FW_IP_PROTOCOL_ANY:
r.Proto = ProtocolAny
case wapi.NET_FW_IP_PROTOCOL_ICMPv6:
r.Proto = ICMPv6
case wapi.NET_FW_IP_PROTOCOL_ICMPv4:
r.Proto = ICMP
case wfProtocolSCTP:
r.Proto = SCTP
case wfProtocolGRE:
r.Proto = GRE
case wfProtocolESP:
r.Proto = ESP
case wfProtocolAH:
r.Proto = AH
default:
return nil
}
// Decode an ICMP type from the "type:code" field. Only a single type is
// modeled; a "*" (or empty) type matches every type.
if r.Proto.IsICMP() {
raw := strings.TrimSpace(fr.ICMPTypesAndCodes)
if raw != "" && raw != "*" {
// Multiple type:code pairs cannot be represented by a single rule.
if strings.Contains(raw, ",") {
return nil
}
typePart, codePart, hasCode := strings.Cut(raw, ":")
typePart = strings.TrimSpace(typePart)
codePart = strings.TrimSpace(codePart)
// The Rule model carries an ICMP type but no code. A rule scoped to a
// specific code (e.g. "3:4") cannot be represented, and re-adding it would
// emit "3:*" — silently widening it to every code of that type. Drop it
// from the view (like the multi-pair case above) rather than misrepresent
// and then widen it.
if hasCode && codePart != "" && codePart != "*" {
return nil
}
if typePart != "" && typePart != "*" {
// Resolve a named type through the family-appropriate table: ICMPv6
// reuses several ICMPv4 names for different numbers. Windows stores
// types numerically, where both tables agree, so this only matters if
// a rule carries a named type.
n, ok := parseICMPTypeFamily(typePart, r.Proto == ICMPv6)
if !ok {
return nil
}
r.ICMPType = Ptr(n)
}
}
}
// Windows uses local/remote ports; map them by direction. For an input rule the
// destination is the local port and the source is the remote port; for an output
// rule it is reversed. Windows expresses each as a string that may hold a list
// and dash ranges (e.g. "80,443,1000-2000").
destPortsRaw, srcPortsRaw := fr.LocalPorts, fr.RemotePorts
if r.IsOutput() {
destPortsRaw, srcPortsRaw = fr.RemotePorts, fr.LocalPorts
}
if r.Proto == TCP || r.Proto == UDP {
if destPortsRaw != "" && destPortsRaw != "*" {
specs, err := ParsePortRanges(destPortsRaw, ",")
if err != nil {
return nil
}
if len(specs) == 1 && specs[0].Start == specs[0].End {
r.Port = specs[0].Start
} else {
r.Ports = specs
}
}
if srcPortsRaw != "" && srcPortsRaw != "*" {
specs, err := ParsePortRanges(srcPortsRaw, ",")
if err != nil {
return nil
}
if len(specs) == 1 && specs[0].Start == specs[0].End {
r.SourcePort = specs[0].Start
} else {
r.SourcePorts = specs
}
}
}
// Based on direction, map the source and destination address.
// Our rule uses source/destination where as windows uses local/remote.
var srcRaw, dstRaw string
if r.IsOutput() {
srcRaw = fr.LocalAddresses
dstRaw = fr.RemoteAddresses
} else {
srcRaw = fr.RemoteAddresses
dstRaw = fr.LocalAddresses
}
// Parse addresses.
var err error
r.Source, err = f.decodeAddress(srcRaw)
if err != nil {
return nil
}
r.Destination, err = f.decodeAddress(dstRaw)
if err != nil {
return nil
}
// Map family.
r.Family = FamilyAny
if strings.Contains(r.Source, ":") || strings.Contains(r.Destination, ":") {
r.Family = IPv6
} else if strings.Contains(r.Source, ".") || strings.Contains(r.Destination, ".") {
r.Family = IPv4
}
// A description other than our managed default is a user comment.
if fr.Description != "" && fr.Description != wfManagedDescription {
r.Comment = fr.Description
}
return r
}
// hasPrefix reports whether a listed rule's name carries the configured prefix
// (see MarshallFWRule), marking it as one this manager tagged. Everything else —
// notably Windows' many built-in rules — reports false. With no prefix the
// manager has no namespace of its own, so no rule reports HasPrefix.
func (f *WF) hasPrefix(fr wapi.FWRule) bool {
return f.rulePrefix != "" && strings.HasPrefix(fr.Name, f.rulePrefix+" ")
}
// profileFilter maps a zone name to the single Windows profile bit that GetRules
// and RemoveRule filter on, so both scope to the same rules. ok is false when the
// zone names no specific profile, meaning every profile is in scope (matching an
func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) {
switch {
case strings.EqualFold(zoneName, "public"):
return wapi.NET_FW_PROFILE2_PUBLIC, true
case strings.EqualFold(zoneName, "private"):
return wapi.NET_FW_PROFILE2_PRIVATE, true
case strings.EqualFold(zoneName, "domain"):
return wapi.NET_FW_PROFILE2_DOMAIN, true
}
return 0, false
}
// profileMatches reports whether a rule's Profiles bitmask is in scope for a
func (f *WF) profileMatches(rulesProfiles, filterProfile int32, useFilter bool) bool {
if !useFilter {
return true
}
return rulesProfiles == filterProfile
}
// GetRules returns the existing filter rules from the zone.
func (f *WF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
if err := ctx.Err(); err != nil {
return nil, err
}
fwRules, err := wapi.FirewallRulesGet()
if err != nil {
return nil, fmt.Errorf("failed to fetch firewall rules: %w", err)
}
// Filter by profile if a zone names one.
filterProfile, useFilter := f.profileFilter(zoneName)
// Parse all rules. Windows ships hundreds of built-in rules; rather than hide
// them, every rule is surfaced with HasPrefix reporting whether this manager
// tagged it (identified by the configured name prefix), so callers can tell
// them apart.
for _, fr := range fwRules {
// If filtered by profile, skip rules not scoped to exactly this profile.
if !f.profileMatches(fr.Profiles, filterProfile, useFilter) {
continue
}
// Decode the rule and skip it if it cannot be decoded.
r := f.UnmarshallFWRule(fr)
if r == nil {
continue
}
r.HasPrefix = f.hasPrefix(fr)
// Add decoded rule to list.
rules = append(rules, r)
}
// Every filter above is reported as WFP stores it. WFP has no per-rule family
// selector, so a FamilyAny rule is one dual-family filter and reads back as
// FamilyAny on its own. Its protocol field carries a single protocol number and
// its direction field a single direction, so a TCPUDP rule is a tcp filter plus a
// udp filter and a DirAny rule an inbound plus an outbound filter — each reported
// as its own rule.
return rules, nil
}
// MarshallFWRule encodes a Rule as a Windows FWRule for the given zone.
func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) {
// A WFP filter carries one protocol number, so the TCPUDP protocol has
// no single-filter form; AddRule/RemoveRule fan it into a tcp filter and a udp
// filter with expandProtocols before reaching here. A TCPUDP rule at this row
// level means that fan-out was skipped, so reject it with a clear message before
// the tcp/udp-only port check below can reject it more obscurely.
if err := r.CheckExpandedProtocol(); err != nil {
return nil, err
}
// The Windows Firewall rule model has only inbound and outbound directions;
// forwarded (routed) traffic is handled out of band (RRAS/portproxy), so a
// forward rule cannot be expressed here.
if r.IsForward() {
return nil, unsupportedForward("windows firewall")
}
// Windows Filtering Platform cannot match a port without a concrete
// protocol; dropping the port would silently widen the rule to match all
// traffic, so reject it instead.
if r.PortNeedsConcreteProtocol() {
return nil, fmt.Errorf("a port requires a tcp, udp or sctp protocol")
}
// Windows only matches ports for TCP and UDP; a port on any other protocol
// (e.g. SCTP) has no representation, so reject rather than silently drop it.
if (r.HasPorts() || r.HasSourcePorts()) && r.Proto != TCP && r.Proto != UDP {
return nil, fmt.Errorf("windows firewall only matches ports for tcp or udp: %w", ErrUnsupported)
}
// Features this backend cannot express are rejected up front rather than
// silently dropped. Windows firewall rules are stateful by default, and the
// go-win64api binding cannot set the interface fields, so neither a
// connection-state nor a per-rule interface match is expressible here.
if r.State != 0 {
return nil, fmt.Errorf("windows firewall does not support connection-state matching in this model: %w", ErrUnsupportedState)
}
if r.InInterface != "" || r.OutInterface != "" {
return nil, fmt.Errorf("windows firewall does not support per-rule interface matching in this model: %w", ErrUnsupportedInterface)
}
// WFP has no reject action; mapping Reject to Drop would make a rule added
// as Reject unreadable as Reject and impossible to reconcile. Reject it up
// front so callers use Drop instead.
if r.Action == Reject {
return nil, fmt.Errorf("windows firewall has no reject action: %w", ErrUnsupported)
}
// WFP cannot express address negation (decodeAddress rejects a '!'-prefixed
// token on read), so reject it up front rather than passing an invalid
// address string to Windows and producing a rule that can never be read back
// or removed.
if strings.HasPrefix(strings.TrimSpace(r.Source), "!") || strings.HasPrefix(strings.TrimSpace(r.Destination), "!") {
return nil, fmt.Errorf("windows firewall does not support address negation in this model: %w", ErrUnsupported)
}
// A WFP rule carries an IP family only through an address or an ICMP protocol.
// An explicit Family with neither cannot be honored: applying the rule to both
// families widens it, and it reads back as FamilyAny so it can never reconcile.
// Reject it rather than widen.
if r.Family != FamilyAny && !r.Proto.IsICMP() &&
familyOfAddr(r.Source) == FamilyAny && familyOfAddr(r.Destination) == FamilyAny {
return nil, fmt.Errorf("windows firewall cannot scope a rule to an IP family without an address; use family any or add an address: %w", ErrUnsupported)
}
if err := r.checkICMPType(); err != nil {
return nil, err
}
// Setup base rule. A user comment is carried in the filter description;
// otherwise the managed default marks the rule as ours.
fwRule := &wapi.FWRule{
Description: wfManagedDescription,
Enabled: true,
}
if r.Comment != "" {
fwRule.Description = r.Comment
}
// Base rule name.
// Format: [prefix] [dir] [proto] [port X] [from S] [to D] [allow/block]
var nameParts []string
if f.rulePrefix != "" {
nameParts = append(nameParts, f.rulePrefix)
}
// Set the profile based on provided zone, and fold the profile into the rule
// name. RemoveRule deletes by name, so two otherwise-identical rules added to
// different profiles must get distinct names — otherwise removing one deletes
// the other. An all-profiles (default) rule keeps the plain name.
switch strings.ToLower(zoneName) {
case "private":
fwRule.Profiles = wapi.NET_FW_PROFILE2_PRIVATE
nameParts = append(nameParts, "private")
case "public":
fwRule.Profiles = wapi.NET_FW_PROFILE2_PUBLIC
nameParts = append(nameParts, "public")
case "domain":
fwRule.Profiles = wapi.NET_FW_PROFILE2_DOMAIN
nameParts = append(nameParts, "domain")
default:
fwRule.Profiles = wapi.NET_FW_PROFILE2_ALL
}
// Based on the rule direction.
if r.IsOutput() {
nameParts = append(nameParts, "out")
fwRule.Direction = wapi.NET_FW_RULE_DIR_OUT
} else {
nameParts = append(nameParts, "in")
fwRule.Direction = wapi.NET_FW_RULE_DIR_IN
}
// Set the protocol.
fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_ANY
switch r.Proto {
case TCP:
fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_TCP
nameParts = append(nameParts, "tcp")
case UDP:
fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_UDP
nameParts = append(nameParts, "udp")
case ICMP:
fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_ICMPv4
nameParts = append(nameParts, "icmp")
case ICMPv6:
fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_ICMPv6
nameParts = append(nameParts, "icmpv6")
case SCTP:
fwRule.Protocol = wfProtocolSCTP
nameParts = append(nameParts, "sctp")
case GRE:
fwRule.Protocol = wfProtocolGRE
nameParts = append(nameParts, "gre")
case ESP:
fwRule.Protocol = wfProtocolESP
nameParts = append(nameParts, "esp")
case AH:
fwRule.Protocol = wfProtocolAH
nameParts = append(nameParts, "ah")
}
// An ICMP type match is expressed via the ICMPTypesAndCodes field as
// "type:code"; a "*" code matches every code of that type.
if r.Proto.IsICMP() && r.ICMPType != nil {
fwRule.ICMPTypesAndCodes = fmt.Sprintf("%d:*", *r.ICMPType)
nameParts = append(nameParts, "type", fmt.Sprintf("%d", *r.ICMPType))
}
// If TCP/UDP, and a port set is defined, add the ports. Windows firewall
// accepts a comma list with dash ranges (e.g. "80,443,1000-2000").
dstSpecs := r.PortSpecs()
if (r.Proto == TCP || r.Proto == UDP) && len(dstSpecs) > 0 {
portS := FormatPortRanges(dstSpecs, ",")
nameParts = append(nameParts, "port", portS)
// Set remote/local based on direction.
if r.IsOutput() {
fwRule.RemotePorts = portS
} else {
fwRule.LocalPorts = portS
}
}
// Source ports are mapped to the opposite side of direction.
srcSpecs := r.SourcePortSpecs()
if (r.Proto == TCP || r.Proto == UDP) && len(srcSpecs) > 0 {
portS := FormatPortRanges(srcSpecs, ",")
nameParts = append(nameParts, "sport", portS)
if r.IsOutput() {
fwRule.LocalPorts = portS
} else {
fwRule.RemotePorts = portS
}
}
// Add addresses according to direction due to difference in local/remote vs source/dest.
if r.IsOutput() {
fwRule.LocalAddresses = r.Source
fwRule.RemoteAddresses = r.Destination
} else {
fwRule.RemoteAddresses = r.Source
fwRule.LocalAddresses = r.Destination
}
// Append addresses to name if present.
if r.Source != "" {
nameParts = append(nameParts, "from", r.Source)
}
if r.Destination != "" {
nameParts = append(nameParts, "to", r.Destination)
}
// Set the rule action.
if r.Action == Accept {
nameParts = append(nameParts, "allow")
fwRule.Action = wapi.NET_FW_ACTION_ALLOW
} else {
nameParts = append(nameParts, "block")
fwRule.Action = wapi.NET_FW_ACTION_BLOCK
}
fwRule.Name = strings.Join(nameParts, " ")
// Set the grouping
fwRule.Grouping = f.rulePrefix
return fwRule, nil
}
// AddRule that stored the rule under the all-profiles default). A rule matches only
// when its Profiles exactly equals this single bit: AddRule always stores a named
// zone's rule under exactly one profile bit or the all-profiles default (never a
// combination), so an exact-equality test is what keeps a specific zone's rules
// disjoint from another zone's and from an all-profiles rule. Testing overlap
// instead (fr.Profiles&profile != 0) would let a single-zone query and, worse, a
// single-zone RemoveRule/Sync match and delete an all-profiles rule — silently
// affecting every other zone too.
func (f *WF) AddRule(ctx context.Context, zoneName string, r *Rule) error {
if err := ctx.Err(); err != nil {
return err
}
if r == nil {
return fmt.Errorf("rule cannot be nil")
}
// A TCPUDP rule fans out into a tcp filter plus a udp filter; a WFP filter
// carries one protocol number, so it has no single-filter form.
if r.Proto == TCPUDP {
for _, sub := range expandProtocols(r) {
if err := f.AddRule(ctx, zoneName, sub); err != nil {
return err
}
}
return nil
}
// A DirAny rule fans out into an inbound filter plus its role-swapped outbound
// filter; WFP stores each direction as its own rule object.
if r.Direction == DirAny {
for _, sub := range expandDirections(r) {
if err := f.AddRule(ctx, zoneName, sub); err != nil {
return err
}
}
return nil
}
if err := r.rejectLogAndLimit(f.Type()); err != nil {
return err
}
// Encode the rule.
fwRule, err := f.MarshallFWRule(zoneName, r)
if err != nil {
return fmt.Errorf("failed to marshall rule: %w", err)
}
// Skip if an equivalent rule already exists: Windows rejects a duplicate rule
// name, and AddRule is expected to be idempotent like the other backends.
if existing, gerr := f.GetRules(ctx, zoneName); gerr == nil {
for _, e := range existing {
// Any equivalent rule already in the firewall counts as a duplicate
// (Windows rejects a duplicate rule name), so the add stays idempotent.
if e.EqualBase(r, true) {
return nil
}
}
}
// Attempt to add the rule. FirewallRuleAddAdvanced reports success=false with a
// nil error in exactly one case: a rule already exists under this exact Name;
// every other failure carries a non-nil error. That is a benign duplicate (a
// name collision the EqualBase check above missed, e.g. a concurrent or foreign
// add), so treat it as a no-op rather than a failure.
success, err := wapi.FirewallRuleAddAdvanced(*fwRule)
if err != nil {
return fmt.Errorf("failed to add firewall rule %q: %w", fwRule.Name, err)
}
if !success {
return nil
}
return nil
}
// InsertRule is unsupported: Windows Filtering Platform rules are not
// positionally ordered in the same way as iptables/nftables.
func (f *WF) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
return unsupportedOrdering(f.Type())
}
// MoveRule is unsupported for the same reason as InsertRule.
func (f *WF) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
return unsupportedOrdering(f.Type())
}
// RemoveRule removes a rule from the zone.
func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
if err := ctx.Err(); err != nil {
return err
}
// A TCPUDP target removes both its tcp filter and its udp filter; WFP stores only
// concrete-protocol filters, so removing the tcp half leaves the udp filter and
// vice versa (the surviving transport re-adds nothing, unlike a container backend).
if r.Proto == TCPUDP {
for _, sub := range expandProtocols(r) {
if err := f.RemoveRule(ctx, zoneName, sub); err != nil {
return err
}
}
return nil
}
// A DirAny target removes both its inbound and its role-swapped outbound filter.
if r.Direction == DirAny {
for _, sub := range expandDirections(r) {
if err := f.RemoveRule(ctx, zoneName, sub); err != nil {
return err
}
}
return nil
}
// A WFP rule carries an IP family only through an address or an ICMP protocol,
// so a concrete-family rule with neither is unexpressible (see AddRule) — and a
// FamilyAny bare rule is stored as one dual-family filter. A concrete-family
// removal of that shape could only be honored by dropping the dual filter, which
// takes the untargeted family with it; the surviving single-family rule cannot
// be re-added without an address. Reject it rather than over-remove.
if r.Family != FamilyAny && !r.Proto.IsICMP() &&
familyOfAddr(r.Source) == FamilyAny && familyOfAddr(r.Destination) == FamilyAny {
return fmt.Errorf("windows firewall cannot scope a removal to an IP family without an address; use family any or add an address: %w", ErrUnsupported)
}
// Get a list of existing rules.
fwRules, err := wapi.FirewallRulesGet()
if err != nil {
return fmt.Errorf("failed to list rules for deletion: %w", err)
}
// Scope the deletion to the same profile GetRules/AddRule use for this zone, so
// each zone stays isolated: the manager adds and lists rules per profile, so it
// must also remove them per profile. profileMatches' exact-equality match (not
// overlap) is what keeps this from also deleting an all-profiles rule when
// zoneName names a single zone.
filterProfile, useFilter := f.profileFilter(zoneName)
// Delete every matching rule. EqualBase ignores the IP family because Windows
// records a concrete family on the rule it lists back even when the added rule
// left it unset (mirroring GetRules). Removal is idempotent, matching the other
// backends: a rule that is not present is not an error.
for _, fr := range fwRules {
// Skip rules outside the target profile, matching GetRules' filter.
if !f.profileMatches(fr.Profiles, filterProfile, useFilter) {
continue
}
// Decode the rule, and skip if it can't be decoded.
rule := f.UnmarshallFWRule(fr)
if rule == nil {
continue
}
if r.EqualBase(rule, true) {
ok, err := wapi.FirewallRuleDelete(fr.Name)
if err != nil {
return fmt.Errorf("failed to delete rule %q: %w", fr.Name, err)
}
if !ok {
return fmt.Errorf("failed to delete rule %q: reported failure", fr.Name)
}
}
}
return nil
}
// GetNATRules is unsupported; WFP is a stateful packet filter only and NAT on
// Windows is handled out of band (netsh portproxy or RRAS).
func (f *WF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
return nil, unsupportedNAT(f.Type())
}
// AddNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
func (f *WF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
return unsupportedNAT(f.Type())
}
// InsertNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
func (f *WF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
return unsupportedNAT(f.Type())
}
// RemoveNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
func (f *WF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
return unsupportedNAT(f.Type())
}
// GetDefaultPolicy is unsupported; the Windows firewall exposes no default policy in this model.
func (f *WF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
return nil, unsupportedPolicy(f.Type())
}
// SetDefaultPolicy is unsupported; the Windows firewall exposes no default policy in this model.
func (f *WF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
return unsupportedPolicy(f.Type())
}
// GetAddressSets is unsupported; the Windows firewall backend has no address sets.
func (f *WF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
return nil, unsupportedSet(f.Type())
}
// GetAddressSet is unsupported; the Windows firewall backend has no address sets.
func (f *WF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
return nil, unsupportedSet(f.Type())
}
// AddAddressSet is unsupported; the Windows firewall backend has no address sets.
func (f *WF) AddAddressSet(ctx context.Context, set *AddressSet) error {
return unsupportedSet(f.Type())
}
// RemoveAddressSet is unsupported; the Windows firewall backend has no address sets.
func (f *WF) RemoveAddressSet(ctx context.Context, name string) error {
return unsupportedSet(f.Type())
}
// AddAddressSetEntry is unsupported; the Windows firewall backend has no address sets.
func (f *WF) AddAddressSetEntry(ctx context.Context, name, entry string) error {
return unsupportedSet(f.Type())
}
// RemoveAddressSetEntry is unsupported; the Windows firewall backend has no address sets.
func (f *WF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
return unsupportedSet(f.Type())
}
// Backup captures the current filter rules managed by this backend.
func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
rules, err := f.GetRules(ctx, zoneName)
if err != nil {
return nil, err
}
// Backup captures the full filter rule state; Restore reconciles the live rules
// to this set, so every rule read is preserved.
return &Backup{Rules: rules}, nil
}
// Restore replaces the managed rules with the contents of a Backup.
func (f *WF) Restore(ctx context.Context, zoneName string, backup *Backup) error {
if backup == nil {
return fmt.Errorf("backup cannot be nil")
}
// Reconcile the live rule set to the backup with a minimal add/remove diff
// rather than removing every rule and re-adding it. Removing all rules first
// leaves a window with no matching filter, and WFP drops in-flight connections
// that no longer match one — including a foreign inbound-allow rule the backup
// itself captured (e.g. the rule keeping this host reachable over SSH while a
// remote restore runs). Sync leaves a rule present in both the firewall and the
// backup untouched, so such a rule is never briefly removed. WFP has no NAT, so
// backup.NATRules is not applied here.
_, _, err := Sync(ctx, f, zoneName, backup.Rules)
return err
}
// Reload is a no-op; Windows Firewall applies rule changes immediately.
func (f *WF) Reload(ctx context.Context) error {
return nil
}
// Close releases any resources held by the backend; the Windows firewall holds none.
func (f *WF) Close(ctx context.Context) error {
return nil
}