Files
abrown38andClaude Opus 5.5 9d766b9823 Boot fallback, flowtable devices that come and go, healthz, renames
The boot unit fails closed: if ruleset.nft doesn't load, it loads
ruleset-fallback.nft (render.Fallback), saved beside it from the same
configuration: nothing new in from external zones, established, the
ICMPv6 IPv6 needs and DHCPv6 replies let in, the inside left alone and
masqueraded, and the unit marked failed with a critical message.
housecarld replaces it when it applies; status, the dashboard and
/healthz say while it is in force. no_external_zone now also says the
fallback closes everything without an external zone.

The boot ruleset has no flowtable: nftables on Debian 13's kernel
refuses one naming a device that doesn't exist yet (ppp0 before PPPoE),
and with it the whole file. internal/linkwatch listens to rtnetlink link
messages; housecarld applies again when a flowtable device appears or
comes back as a new interface (by ifindex), under the engine's lock.
docs/flowtable.md: what it does, when to use it and when not, how.

GET /healthz reports firewall (applied, unconfigured, fallback,
missing), changes on probation and the machine's warnings, with 503
unless in force, read at most every five seconds.

Renaming a zone, set or service changes what names it in the same
change (one history entry, one undo); dynamic sets keep their names.
The importer keeps a chain's head in order: once a would-be built-in
stays a rule, the ones after it do too, so the maintainer's ICMPv6
accepts stay behind the invalid drop as written. hc settings set
--flowtable none empties the list.

make test (nft 1.1.3) and make e2e (242 checks, with VLAN
sub-interfaces, the fallback and the flowtable) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:16:46 +01:00

615 lines
22 KiB
Go

// Package validate checks a whole configuration. Every problem has a
// stable code (docs/validation.md); errors stop a change from being saved,
// warnings are reported with it.
package validate
import (
"fmt"
"net/netip"
"regexp"
"strings"
"housecarl/internal/model"
)
// Severities.
const (
Error = "error"
Warning = "warning"
)
// Problem is one finding.
type Problem struct {
Severity string `json:"severity"`
Code string `json:"code"`
Message string `json:"message"`
// Object names what it is about: "zone wan", "input #12 (ssh)".
Object string `json:"object,omitempty"`
}
// Env describes the host, for checks against its interfaces. A nil Env
// skips them (tests, imports for another machine).
type Env struct {
// Interfaces maps each interface name to its addresses.
Interfaces map[string][]netip.Prefix
}
// HasErrors reports whether any problem is an error.
func HasErrors(ps []Problem) bool {
for _, p := range ps {
if p.Severity == Error {
return true
}
}
return false
}
// Errors returns only the errors.
func Errors(ps []Problem) []Problem {
var out []Problem
for _, p := range ps {
if p.Severity == Error {
out = append(out, p)
}
}
return out
}
type checker struct {
st *model.State
env *Env
out []Problem
}
func (c *checker) add(sev, code, object, format string, args ...any) {
c.out = append(c.out, Problem{Severity: sev, Code: code, Object: object, Message: fmt.Sprintf(format, args...)})
}
// Check validates a configuration.
func Check(st *model.State, env *Env) []Problem {
c := &checker{st: st, env: env}
c.settings()
c.zones()
c.sets()
c.services()
for _, r := range st.Rules {
c.rule(r)
}
c.forwards()
for _, x := range st.SNATs {
c.snat(x)
}
for _, x := range st.Redirects {
c.redirect(x)
}
return c.out
}
// rawIgnores names what a raw rule has besides its text, which the
// ruleset wouldn't use (the action too is the text's; it isn't checked).
func rawIgnores(r *model.Rule) []string {
m := r.Match
var out []string
for _, x := range []struct {
name string
set bool
}{
{"zones", len(m.InZones) > 0 || len(m.OutZones) > 0},
{"addresses", len(m.Sources) > 0 || len(m.Destinations) > 0},
{"services", len(m.Services) > 0},
{"family", m.Family != ""},
{"connection state", len(r.CtState) > 0 || len(r.CtStatus) > 0},
{"time window", len(r.Days) > 0 || r.HourFrom != "" || r.HourTo != ""},
{"rate limit", r.Limit != nil},
{"connection limit", r.ConnLimit != 0},
{"quota", r.Quota != nil},
{"address grouping", r.GroupV4 != 0 || r.GroupV6 != 0},
{"log", r.Log || r.LogPrefix != ""},
{"reject with", r.RejectWith != ""},
{"every packet", r.EveryPacket},
} {
if x.set {
out = append(out, x.name)
}
}
return out
}
// maxByteRate is the largest byte rate per period nftables (1.1.3) can
// hold: the kernel's token bucket multiplies the rate by the period.
var maxByteRate = map[string]int64{"second": 4294967294, "minute": 307445734, "hour": 5124095, "day": 213503}
// byteSize writes bytes the way nftables reads them: "1 mbytes".
func byteSize(n int64) string {
for _, u := range []string{"mbytes", "kbytes"} {
if n >= model.ByteUnits[u] && n%model.ByteUnits[u] == 0 {
return fmt.Sprintf("%d %s", n/model.ByteUnits[u], u)
}
}
if n >= 10*model.ByteUnits["kbytes"] {
return fmt.Sprintf("%d kbytes", n/model.ByteUnits["kbytes"])
}
return fmt.Sprintf("%d bytes", n)
}
var nameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,47}$`)
// ifaceRe: Linux interface names (15 bytes), with an optional trailing *
// for nftables' wildcard.
var ifaceRe = regexp.MustCompile(`^[A-Za-z0-9_.:@-]{1,15}\*?$|^\*$`)
func (c *checker) name(kind, obj, name string, seen map[string]string) {
switch {
case !nameRe.MatchString(name):
c.add(Error, kind+"_name", obj, "%q isn't a valid name: start with a letter; then letters, digits, - and _ (up to 48)", name)
case model.IsLiteral(name):
c.add(Error, kind+"_name", obj, "%q looks like an address; give it a name", name)
default:
k := strings.ToLower(name)
if o, ok := seen[k]; ok {
c.add(Error, kind+"_name_taken", obj, "the name %s is already used by %s", name, o)
}
seen[k] = obj
}
}
func (c *checker) settings() {
s := c.st.Settings
for _, p := range []struct{ name, v string }{{"input", s.InputPolicy}, {"forward", s.ForwardPolicy}, {"output", s.OutputPolicy}} {
if p.v != model.PolicyAccept && p.v != model.PolicyDrop {
c.add(Error, "policy", "settings", "the %s policy is %q; use accept or drop", p.name, p.v)
}
}
for _, l := range []struct {
name string
list []string
v6 int // 0 v4, 1 v6, 2 either
}{{"antispoof_v4", s.AntispoofV4, 0}, {"antispoof_v6", s.AntispoofV6, 1}, {"own_prefixes", s.OwnPrefixes, 2}} {
for _, e := range l.list {
a, err := model.ParseAddress(e)
if err != nil {
c.add(Error, "settings_address", "settings", "%s: %v", l.name, err)
continue
}
if l.v6 != 2 && a.IsV6() != (l.v6 == 1) {
c.add(Error, "settings_address", "settings", "%s: %s is the wrong address family", l.name, e)
}
}
}
for _, d := range s.FlowtableDevices {
if !ifaceRe.MatchString(d) || strings.HasSuffix(d, "*") {
c.add(Error, "flowtable_device", "settings", "flowtable device %q: give an exact interface name (no wildcards)", d)
} else if c.env != nil {
if _, ok := c.env.Interfaces[d]; !ok {
c.add(Warning, "flowtable_device_missing", "settings", "flowtable device %s doesn't exist here; the flowtable is left out until it does", d)
}
}
}
if s.StatsPerDevice && len(c.st.ExternalInterfaces()) == 0 {
c.add(Warning, "stats_no_external", "settings", "traffic per device counts what goes to and comes from external zones, and no zone is external (with interfaces)")
}
if s.StatsPerDevice && len(s.FlowtableDevices) > 0 {
c.add(Warning, "stats_flowtable", "settings", "the flowtable's fast path skips the forward chain once a connection is established, so traffic per device only counts each connection's first packets; turn the flowtable off for full figures")
}
if p := s.ProbationSeconds; p != 0 && (p < 10 || p > 3600) {
c.add(Error, "probation_range", "settings", "the probation is %d seconds; use 10 to 3600", p)
}
}
func (c *checker) zones() {
seen := map[string]string{}
owner := map[string]string{}
external := false
for _, z := range c.st.Zones {
obj := "zone " + z.Name
c.name("zone", obj, z.Name, seen)
external = external || z.External
if len(z.Interfaces) == 0 {
c.add(Error, "zone_empty", obj, "the zone has no interfaces")
}
for _, i := range z.Interfaces {
if !ifaceRe.MatchString(i) {
c.add(Error, "zone_interface", obj, "%q isn't an interface name (up to 15 characters; a trailing * matches many)", i)
continue
}
if o, ok := owner[i]; ok {
c.add(Error, "zone_interface_taken", obj, "interface %s is already in %s; an interface belongs to one zone", i, o)
}
owner[i] = obj
if c.env != nil && !strings.HasSuffix(i, "*") {
if _, ok := c.env.Interfaces[i]; !ok {
c.add(Warning, "zone_interface_missing", obj, "interface %s doesn't exist here (yet); its rules apply once it does", i)
}
}
}
}
if !c.st.Empty() && !external {
c.add(Warning, "no_external_zone", "zones", "no zone is external: antispoofing, MSS clamping and the DHCPv6 client rule need one, and port forwards without public addresses; and if the saved ruleset fails to load at boot, the fallback can't tell which interface faces the internet, and lets nothing but loopback through")
}
}
func (c *checker) sets() {
seen := map[string]string{}
for _, s := range c.st.Sets {
obj := "set " + s.Name
c.name("set", obj, s.Name, seen)
for _, pre := range []string{"meter_", "conn_", "quota_", "stats_"} {
if strings.HasPrefix(strings.ToLower(s.Name), pre) {
c.add(Error, "set_name_reserved", obj, "set names starting with %s are Housecarl's own (rate limits, quotas, statistics); choose another name", pre)
}
}
for _, e := range s.Entries {
if _, err := model.ParseAddress(e); err != nil {
c.add(Error, "set_entry", obj, "%v", err)
}
}
if len(s.Entries) > 0 && s.Dynamic {
c.add(Error, "set_dynamic_entries", obj, "a dynamic set gets its entries at run time (hc set entries add, the Entries button), each with its timeout; leave out the configured ones, or make it a set that isn't dynamic")
}
if len(s.Entries) == 0 && !s.Dynamic {
c.add(Warning, "set_empty", obj, "the set is empty: rules using it match nothing")
}
if s.Timeout < 0 || s.Timeout > 0 && !s.Dynamic {
c.add(Error, "set_timeout", obj, "only a dynamic set has a timeout, of 1 second or more")
}
}
}
func (c *checker) services() {
seen := map[string]string{}
for _, s := range c.st.Services {
obj := "service " + s.Name
c.name("service", obj, s.Name, seen)
if len(s.Entries) == 0 {
c.add(Error, "service_empty", obj, "the service has no ports or ICMP types")
}
for _, e := range s.Entries {
if _, err := model.ParseServiceEntry(e); err != nil {
c.add(Error, "service_entry", obj, "%v", err)
}
}
}
}
// match checks a Match and returns which families it can match.
func (c *checker) match(obj string, m model.Match, allowIn, allowOut bool) (v4, v6 bool) {
for _, z := range m.InZones {
if !allowIn {
c.add(Error, "match_in_zone", obj, "this chain has no incoming interface; leave out the in zones")
break
}
if c.st.Zone(z) == nil {
c.add(Error, "unknown_zone", obj, "there is no zone %s", z)
}
}
for _, z := range m.OutZones {
if !allowOut {
c.add(Error, "match_out_zone", obj, "this chain has no outgoing interface; leave out the out zones")
break
}
if c.st.Zone(z) == nil {
c.add(Error, "unknown_zone", obj, "there is no zone %s", z)
}
}
refs := func(what string, list []string) (bool, bool) {
if len(list) == 0 {
return true, true
}
for _, r := range list {
if !model.IsLiteral(r) && c.st.Set(r) == nil {
if _, err := model.ParseAddress(r); strings.ContainsAny(r, "./:") {
c.add(Error, "match_address", obj, "%s: %v", what, err)
} else {
c.add(Error, "unknown_set", obj, "%s: there is no set %s", what, r)
}
}
}
f4, f6 := c.st.SplitRefs(list)
return !f4.Empty(), !f6.Empty()
}
s4, s6 := refs("sources", m.Sources)
d4, d6 := refs("destinations", m.Destinations)
v4, v6 = s4 && d4, s6 && d6
icmp4, icmp6, ports := false, false, false
for _, r := range m.Services {
if model.IsServiceEntry(r) {
if _, err := model.ParseServiceEntry(r); err != nil {
c.add(Error, "service_entry", obj, "%v", err)
}
} else if c.st.Service(r) == nil {
c.add(Error, "unknown_service", obj, "there is no service %s (built-in or yours)", r)
}
}
for _, e := range c.st.ServiceEntries(m.Services) {
switch e.Proto {
case "icmp":
icmp4 = true
case "icmpv6":
icmp6 = true
default:
ports = true
}
}
if !ports && (icmp4 || icmp6) {
v4, v6 = v4 && icmp4, v6 && icmp6
}
switch m.Family {
case "":
case model.FamilyIPv4:
v6 = false
case model.FamilyIPv6:
v4 = false
default:
c.add(Error, "family", obj, "the family is %q; use ipv4, ipv6 or nothing for both", m.Family)
}
if !v4 && !v6 {
c.add(Error, "match_never", obj, "the addresses, services and family together can never match (an IPv4-only part and an IPv6-only part)")
}
return v4, v6
}
var (
ctStates = map[string]bool{"new": true, "established": true, "related": true, "invalid": true, "untracked": true}
ctStatuses = map[string]bool{"dnat": true, "snat": true, "confirmed": true, "assured": true, "expected": true}
days = map[string]bool{"monday": true, "tuesday": true, "wednesday": true, "thursday": true, "friday": true, "saturday": true, "sunday": true}
hourRe = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
limitPer = map[string]bool{"second": true, "minute": true, "hour": true, "day": true}
)
func (c *checker) rule(r *model.Rule) {
obj := r.Label()
switch r.Chain {
case model.ChainEarly, model.ChainInput, model.ChainForward, model.ChainOutput:
default:
c.add(Error, "rule_chain", obj, "the chain is %q; use %s", r.Chain, strings.Join(model.Chains, ", "))
return
}
actionOK := false
for _, a := range model.Actions {
actionOK = actionOK || r.Action == a
}
if !actionOK {
c.add(Error, "rule_action", obj, "the action is %q; use %s", r.Action, strings.Join(model.Actions, ", "))
}
if r.Raw != "" {
if strings.ContainsAny(r.Raw, ";\n\r") {
c.add(Error, "raw_rule", obj, "a raw rule is one nftables rule on one line, without ;")
}
if ignored := rawIgnores(r); len(ignored) > 0 {
c.add(Error, "raw_conflict", obj, "a raw rule is used as written, so its %s would be ignored: write them in the nftables rule, or leave them out", strings.Join(ignored, ", "))
}
return
}
in := r.Chain != model.ChainOutput
out := r.Chain == model.ChainForward || r.Chain == model.ChainOutput
c.match(obj, r.Match, in, out)
if r.Chain == model.ChainEarly {
if len(r.CtState) > 0 || len(r.CtStatus) > 0 || r.ConnLimit > 0 {
c.add(Error, "early_conntrack", obj, "the early chain runs before connection tracking: leave out connection states and limits")
}
if r.Action == model.ActionReject {
c.add(Error, "early_reject", obj, "the early chain can't reject; drop instead")
}
}
for _, s := range r.CtState {
if !ctStates[s] {
c.add(Error, "ct_state", obj, "%q isn't a connection state (new, established, related, invalid, untracked)", s)
}
}
for _, s := range r.CtStatus {
if !ctStatuses[s] {
c.add(Error, "ct_status", obj, "%q isn't a connection status (dnat, snat, confirmed, assured, expected)", s)
}
}
for _, d := range r.Days {
if !days[strings.ToLower(d)] {
c.add(Error, "rule_day", obj, "%q isn't a day (Monday … Sunday)", d)
}
}
if (r.HourFrom == "") != (r.HourTo == "") {
c.add(Error, "rule_hours", obj, "give both hours of the time window, from and to (08:00, 17:30)")
} else if r.HourFrom != "" && (!hourRe.MatchString(r.HourFrom) || !hourRe.MatchString(r.HourTo)) {
c.add(Error, "rule_hours", obj, "write hours as HH:MM (08:00, 17:30)")
}
if l := r.Limit; l != nil {
_, byteUnit := model.ByteUnits[l.Unit]
switch {
case l.Rate < 1 || !limitPer[l.Per] || l.Burst < 0:
c.add(Error, "rule_limit", obj, "a rate is a number of packets (or bytes, kbytes, mbytes) per second, minute, hour or day, with an optional burst")
case l.Unit != "" && l.Unit != "packets" && (!byteUnit || l.Unit == "gbytes"):
c.add(Error, "rule_limit_unit", obj, "the rate's unit is %q; use packets, bytes, kbytes or mbytes", l.Unit)
case byteUnit && int64(l.Rate)*model.ByteUnits[l.Unit] > maxByteRate[l.Per]:
perSecond := int64(l.Rate) * model.ByteUnits[l.Unit] / map[string]int64{"second": 1, "minute": 60, "hour": 3600, "day": 86400}[l.Per]
c.add(Error, "rule_limit_bytes", obj, "nftables can't hold a byte rate of %d %s per %s (at most %s per %s); give it per second: %s/second",
l.Rate, l.Unit, l.Per, byteSize(maxByteRate[l.Per]), l.Per, byteSize(perSecond))
}
if l.PerSource && l.PerDestination {
c.add(Error, "rule_limit_key", obj, "a limit is per source or per destination, not both")
}
}
if r.ConnLimit < 0 {
c.add(Error, "rule_conn_limit", obj, "the connection limit must be 1 or more")
}
if q := r.Quota; q != nil {
if q.Bytes < 1 {
c.add(Error, "rule_quota", obj, "a quota is an amount of bytes, 1 or more")
}
if q.PerSource && q.PerDestination {
c.add(Error, "rule_quota_key", obj, "a quota is per source or per destination, not both")
}
switch {
case q.Reset != "" && q.Reset != "daily" && q.Reset != "weekly" && q.Reset != "monthly":
c.add(Error, "rule_quota_reset", obj, "the quota resets %q; use daily, weekly, monthly, or nothing (only by hand)", q.Reset)
case q.Day != 0 && q.Reset != "monthly":
c.add(Error, "rule_quota_reset", obj, "a day of the month is only for a monthly reset")
case q.Day < 0 || q.Day > 31:
c.add(Error, "rule_quota_reset", obj, "the day of the month is 1 to 31 (a later day than a month has means its last day)")
}
}
switch {
case r.CountsTraffic() && r.Action == model.ActionAccept:
c.add(Error, "rule_traffic_accept", obj, "a rule with a quota or a byte rate counts every packet, before established connections are accepted, so it can only drop, reject or log; let the traffic in with a rule of its own")
case r.ChecksEveryPacket() && r.Action == model.ActionAccept:
c.add(Error, "rule_every_packet_accept", obj, "a rule checked against every packet comes before established connections are accepted, so accepting there would skip every later rule; it can drop, reject or log")
}
if r.ChecksEveryPacket() && r.Chain == model.ChainForward && len(c.st.Settings.FlowtableDevices) > 0 && !r.Disabled {
c.add(Warning, "flowtable_traffic", obj, "the flowtable's fast path skips the forward chain once a connection is established, so this rule only sees each connection's first packets; turn the flowtable off (settings) for it to see all of them")
}
if r.GroupV4 < 0 || r.GroupV4 > 32 || r.GroupV6 < 0 || r.GroupV6 > 128 {
c.add(Error, "rule_group", obj, "group addresses by a prefix length: 1 to 32 for IPv4, 1 to 128 for IPv6")
}
if r.RejectWith != "" {
if r.RejectWith != "tcp-reset" {
c.add(Error, "reject_with", obj, "reject with is %q; use tcp-reset, or nothing for an ICMP error", r.RejectWith)
} else {
for _, e := range c.st.ServiceEntries(r.Services) {
if e.Proto != "tcp" {
c.add(Error, "reject_with", obj, "tcp-reset needs a rule that matches TCP only (%s isn't)", e)
break
}
}
if len(r.Services) == 0 {
c.add(Error, "reject_with", obj, "tcp-reset needs a rule that matches TCP only; add a tcp service")
}
}
if r.Action != model.ActionReject {
c.add(Error, "reject_with", obj, "reject with only goes with the reject action")
}
}
if len(r.LogPrefix) > 100 {
c.add(Error, "log_prefix", obj, "the log prefix is %d characters; 100 at most", len(r.LogPrefix))
}
}
func (c *checker) forwards() {
type slot struct {
addr, proto string
lo, hi int
}
var taken []struct {
s slot
obj string
}
hasExternal := len(c.st.ExternalInterfaces()) > 0
seen := map[string]string{}
for _, f := range c.st.Forwards {
obj := f.Label()
if f.Name != "" {
c.name("forward", obj, f.Name, seen)
}
if len(f.Protocols) == 0 {
c.add(Error, "forward_protocol", obj, "give the protocols: tcp, udp or both")
}
for _, p := range f.Protocols {
if p != "tcp" && p != "udp" && p != "sctp" {
c.add(Error, "forward_protocol", obj, "the protocol is %q; use tcp, udp or sctp", p)
}
}
if _, err := model.NormalizePorts(f.Ports); err != nil {
c.add(Error, "forward_ports", obj, "public port: %v", err)
continue
}
lo, hi := model.PortRange(f.Ports)
if f.ToPort != "" {
if _, err := model.NormalizePorts(f.ToPort); err != nil || strings.Contains(f.ToPort, "-") {
c.add(Error, "forward_to_port", obj, "the inside port must be one port")
} else if hi != lo {
c.add(Error, "forward_to_port", obj, "a range of public ports keeps its ports: leave out the inside port")
}
}
if f.To == "" && f.To6 == "" {
c.add(Error, "forward_target", obj, "give the inside host: an IPv4 address (to), an IPv6 one (to6), or both")
}
if f.To != "" {
if a, err := netip.ParseAddr(f.To); err != nil || !a.Is4() {
c.add(Error, "forward_target", obj, "to must be one IPv4 address (IPv6 hosts are reached with to6, without translation)")
}
}
if f.To6 != "" {
if a, err := netip.ParseAddr(f.To6); err != nil || !a.Is6() || a.Is4In6() {
c.add(Error, "forward_target", obj, "to6 must be one IPv6 address")
}
}
if f.To != "" && len(f.PublicAddresses) == 0 && !hasExternal {
c.add(Error, "forward_public", obj, "without public addresses, a port forward listens on the external zones, and there is none")
}
for _, a := range f.PublicAddresses {
if pa, err := model.ParseAddress(a); err == nil {
if pa.IsV6() {
c.add(Error, "forward_public", obj, "public addresses are IPv4 (%s isn't); IPv6 hosts are reached with to6", a)
}
} else if c.st.Set(a) == nil {
c.add(Error, "unknown_set", obj, "public addresses: %s is neither an address nor a set", a)
}
}
c.match(obj, model.Match{Sources: f.Sources}, true, false)
if f.Hairpin && len(f.PublicAddresses) == 0 {
c.add(Warning, "forward_hairpin", obj, "hairpin needs public addresses to match inside clients' connections; add them")
}
for _, x := range f.HairpinSources {
if !model.IsLiteral(x) && c.st.Set(x) == nil {
c.add(Error, "unknown_set", obj, "hairpin sources: %s is neither an address nor a set", x)
}
}
if f.Disabled {
continue
}
addrs := f.PublicAddresses
if len(addrs) == 0 {
addrs = []string{"(external zones)"}
}
for _, a := range addrs {
for _, p := range f.Protocols {
s := slot{a, p, lo, hi}
for _, t := range taken {
if t.s.addr == s.addr && t.s.proto == s.proto && s.lo <= t.s.hi && t.s.lo <= s.hi {
c.add(Error, "forward_overlap", obj, "%s port %s on %s is already forwarded by %s", p, f.Ports, a, t.obj)
}
}
taken = append(taken, struct {
s slot
obj string
}{s, obj})
}
}
}
}
func (c *checker) snat(x *model.SourceNAT) {
obj := x.Label()
v4, v6 := c.match(obj, x.Match, true, true)
if x.To != "" {
a, err := netip.ParseAddr(x.To)
switch {
case err != nil:
c.add(Error, "snat_to", obj, "to must be one address (or empty to masquerade)")
case a.Is4() && v6 && !v4 || a.Is6() && v4 && !v6:
c.add(Error, "snat_to", obj, "to is %s, but the rule only matches the other family", x.To)
case a.Is4() && v6 || a.Is6() && v4:
c.add(Error, "snat_to", obj, "to is one family's address: limit the rule to that family (family %s)", map[bool]string{true: "ipv4", false: "ipv6"}[a.Is4()])
}
}
if len(x.OutZones) == 0 && len(x.Sources) == 0 {
c.add(Warning, "snat_everything", obj, "no out zones and no sources: everything leaving the firewall is translated")
}
}
func (c *checker) redirect(x *model.Redirect) {
obj := x.Label()
c.match(obj, x.Match, true, false)
entries := c.st.ServiceEntries(x.Services)
if len(entries) == 0 {
c.add(Error, "redirect_service", obj, "give the ports to redirect (a service such as dns, or tcp/80)")
}
for _, e := range entries {
if e.IsICMP() {
c.add(Error, "redirect_service", obj, "only TCP, UDP and SCTP ports can be redirected (%s)", e)
}
}
if x.ToPort != "" {
if _, err := model.NormalizePorts(x.ToPort); err != nil || strings.Contains(x.ToPort, "-") {
c.add(Error, "redirect_to_port", obj, "the local port must be one port")
}
}
}