Changesets, as in Jarl: configuration changes are operations that can be staged in a changeset, checked as they are added (validation and nft -c), reviewed with the diff of the ruleset, and applied together as one history entry, then on probation like any change made over the network. The web UI collects edits for review by default (pending rows, a bar with Review and Apply now, a Changes page, a switch for live edits); hc stages with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has hc changeset create/list/show/diff/apply/discard/remove. Undo, import and takeover refuse a changeset. --dry-run shows the ruleset's diff. Bandwidth and quotas: rate limits in bytes, per source or destination; quotas per rule or per address that reset daily, weekly, monthly on a day, or by hand, with their usage carried across reloads, saved for reboots, shown by hc quotas and on the rules page, and reset with hc rule reset-quota. Rules that count traffic are checked before established connections are accepted (so they see every packet) and can't accept. Per-address limits group IPv6 by /64 by default (--group-v4/-v6). Per-source rate meters use update. The importer turns meters, byte rates and quotas into these fields. The examples in the web UI, hc's help and the docs use documentation addresses and generic names instead of the maintainer's network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
565 lines
20 KiB
Go
565 lines
20 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
|
|
}
|
|
|
|
// 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(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 ;")
|
|
}
|
|
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)")
|
|
}
|
|
}
|
|
if 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")
|
|
}
|
|
if r.CountsTraffic() && 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 quota or byte rate only counts each connection's first packets; turn the flowtable off (settings) to count all of it")
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
}
|