A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).
hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
725 lines
27 KiB
Go
725 lines
27 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)
|
|
}
|
|
c.banLists()
|
|
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_", "ban_"} {
|
|
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, ban lists); 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.Runtime() {
|
|
c.add(Warning, "set_empty", obj, "the set is empty: rules using it match nothing")
|
|
}
|
|
if s.Jarl != nil {
|
|
c.jarlSet(obj, s)
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
|
|
// dnsLabel is one label of a name in Jarl (underscores allowed, as in
|
|
// _service names).
|
|
var dnsLabel = regexp.MustCompile(`^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$`)
|
|
|
|
// jarlSet checks a set whose entries come from Jarl.
|
|
func (c *checker) jarlSet(obj string, s *model.AddressSet) {
|
|
j := s.Jarl
|
|
if len(s.Entries) > 0 {
|
|
c.add(Error, "set_jarl_entries", obj, "a Jarl set's entries are the addresses of its names in Jarl; leave out the configured ones, or make it a set that doesn't come from Jarl")
|
|
}
|
|
if s.Dynamic {
|
|
c.add(Error, "set_jarl_dynamic", obj, "a set is dynamic or comes from Jarl, not both")
|
|
}
|
|
if len(j.Names) == 0 {
|
|
c.add(Error, "set_jarl_names", obj, "name at least one name in Jarl, such as nas.example.com (or nas.example.com@internal for one view)")
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, n := range j.Names {
|
|
if !validDNSName(n.Name) {
|
|
c.add(Error, "set_jarl_names", obj, "%q isn't a DNS name; give the full name, such as nas.example.com (wildcards aren't followed)", n.Name)
|
|
continue
|
|
}
|
|
if strings.ContainsAny(n.View, " \t@") {
|
|
c.add(Error, "set_jarl_names", obj, "%q isn't a view name: write NAME@VIEW, such as nas.example.com@internal", n.View)
|
|
continue
|
|
}
|
|
if seen[n.String()] {
|
|
c.add(Error, "set_jarl_names", obj, "%s is listed twice", n)
|
|
}
|
|
seen[n.String()] = true
|
|
}
|
|
if len(j.Within) == 0 {
|
|
c.add(Error, "set_jarl_within", obj, "say where its addresses may be (within): zones, whose connected networks count, or networks such as 10.10.0.0/24; an address from Jarl elsewhere is left out, so a changed record can't open the firewall to anything")
|
|
}
|
|
for _, w := range j.Within {
|
|
if a, err := model.ParseAddress(w); err == nil {
|
|
if a.Prefix.IsValid() && a.Prefix.Bits() == 0 && !a.First.IsValid() {
|
|
c.add(Warning, "set_jarl_anywhere", obj, "within %s lets any address in: whoever can change the names in Jarl decides what the set holds; name zones or networks instead", w)
|
|
}
|
|
continue
|
|
}
|
|
if c.st.Zone(w) == nil {
|
|
c.add(Error, "set_jarl_within", obj, "within %q: no such zone, and not an address or network", w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// validDNSName reports whether n is a name a Jarl set can look up: LDH
|
|
// labels (and underscores), no wildcard, without the final dot.
|
|
func validDNSName(n string) bool {
|
|
if n == "" || len(n) > 253 {
|
|
return false
|
|
}
|
|
for _, l := range strings.Split(n, ".") {
|
|
if !dnsLabel.MatchString(l) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *checker) banLists() {
|
|
seen := map[string]string{}
|
|
for _, b := range c.st.BanLists {
|
|
obj := "ban list " + b.Name
|
|
c.name("banlist", obj, b.Name, seen)
|
|
known := false
|
|
for _, sc := range model.BanScopes {
|
|
known = known || b.Scope == sc
|
|
}
|
|
if !known {
|
|
c.add(Error, "banlist_scope", obj, "the scope is %q; use firewall (the firewall itself), hosts (the list's hosts) or everything", b.Scope)
|
|
}
|
|
switch {
|
|
case b.Scope == model.BanScopeHosts && len(b.Hosts) == 0:
|
|
c.add(Error, "banlist_hosts", obj, "a list with scope hosts needs its hosts: the addresses, or sets, of the machines it protects")
|
|
case b.Scope != model.BanScopeHosts && len(b.Hosts) > 0:
|
|
c.add(Error, "banlist_hosts", obj, "only a list with scope hosts has hosts; leave them out, or use scope hosts")
|
|
}
|
|
refs := func(what string, list []string) {
|
|
for _, r := range list {
|
|
if !model.IsLiteral(r) && c.st.Set(r) == nil {
|
|
if _, err := model.ParseAddress(r); strings.ContainsAny(r, "./:") {
|
|
c.add(Error, "banlist_address", obj, "%s: %v", what, err)
|
|
} else {
|
|
c.add(Error, "unknown_set", obj, "%s: there is no set %s", what, r)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
refs("hosts", b.Hosts)
|
|
refs("never", b.Never)
|
|
if b.DefaultTime < 0 || b.MaxTime < -1 {
|
|
c.add(Error, "banlist_time", obj, "ban times are seconds: default_time 0 (an hour) or more, max_time 0 (a week), more, or -1 for no limit")
|
|
} else if max := b.BanMaxTime(); max > 0 && b.BanTime() > max {
|
|
c.add(Error, "banlist_time", obj, "the default ban time (%ds) is longer than the longest (%ds)", b.BanTime(), max)
|
|
}
|
|
if b.MaxEntries < 0 || b.MaxEntries > 1000000 {
|
|
c.add(Error, "banlist_entries", obj, "max_entries is %d; use 1 to 1000000, or 0 for 10000", b.MaxEntries)
|
|
}
|
|
if b.GroupV6 != 0 && (b.GroupV6 < 16 || b.GroupV6 > 128) {
|
|
c.add(Error, "banlist_group", obj, "group_v6 is /%d; use 16 to 128 (64 bans what one customer usually gets, 128 one address)", b.GroupV6)
|
|
}
|
|
}
|
|
}
|