- Connections: the kernel's tracked connections, read and closed over ctnetlink (internal/conntrack, raw netlink like the drop log), with their NAT and TCP state; closing one makes the rules check its next packet again (GET /connections, POST /connections/close; hc connections; Traffic → Connections). - Rules can check every packet (every_packet, --every-packet): before established connections are accepted, so a drop or reject also stops connections already open. They can't accept. - A packet tracer (internal/trace): a table of its own, inet housecarl-trace, marks the packets a filter picks, `nft monitor trace` reports every rule and policy they meet in every table, and the tracer names what accepted or dropped them, Housecarl's rules by name (POST, GET, DELETE /trace; hc trace; Traffic → Trace). One trace at a time, at most 200 packets and 20 a second; the table goes when it ends. e2e: 157 checks, among them an upload a normal block doesn't stop and an every-packet one does, and a trace naming the blocking rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
604 lines
21 KiB
Go
604 lines
21 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 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 len(c.st.Zones) > 0 && !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")
|
|
}
|
|
}
|
|
|
|
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 _, 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")
|
|
}
|
|
}
|
|
}
|