Build Housecarl 0.1.0: the nftables firewall manager
Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and services, ordered rules in the early, input, forward and output chains, port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects, and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS clamping, DHCPv6 client, flowtable). Every change is validated, checked with nft -c and applied in one transaction to Housecarl's own table, inet housecarl; changes made over the network roll back unless confirmed, and the confirmed ruleset is loaded at boot by housecarl-ruleset.service. An empty configuration applies nothing. Also: hit counters and the drop log (nflog), 'hc import' of an existing nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's certificate through Jarl, Debian packages, docs, unit tests with real nft 1.1.3, and an e2e test with real traffic (71 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54beb0bba3
commit
34292eeebb
118 files changed
+22165
-114
No files matched your search
@@ -0,0 +1,511 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
if l.Rate < 1 || !limitPer[l.Per] || l.Burst < 0 {
|
||||
c.add(Error, "rule_limit", obj, "a rate is a number of packets per second, minute, hour or day, with an optional burst")
|
||||
}
|
||||
}
|
||||
if r.ConnLimit < 0 {
|
||||
c.add(Error, "rule_conn_limit", obj, "the connection limit must be 1 or more")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// base is a valid configuration each case changes.
|
||||
func base() *model.State {
|
||||
return &model.State{
|
||||
Settings: model.DefaultSettings(),
|
||||
Zones: []*model.Zone{
|
||||
{ID: 1, Name: "wan", Interfaces: []string{"wan"}, External: true},
|
||||
{ID: 2, Name: "lan", Interfaces: []string{"eth1"}},
|
||||
},
|
||||
Sets: []*model.AddressSet{{ID: 3, Name: "trusted", Entries: []string{"10.0.10.0/24", "2001:db8:10::/64"}}},
|
||||
Services: []*model.Service{{ID: 4, Name: "jellyfin", Entries: []string{"tcp/8096"}}},
|
||||
Rules: []*model.Rule{
|
||||
{ID: 5, Chain: model.ChainInput, Position: 1, Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh"}}, Action: model.ActionAccept},
|
||||
{ID: 6, Chain: model.ChainForward, Position: 1, Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, Action: model.ActionAccept},
|
||||
},
|
||||
Forwards: []*model.PortForward{{ID: 7, Position: 1, Name: "jellyfin", Protocols: []string{"tcp"}, Ports: "8096", To: "10.0.10.57",
|
||||
PublicAddresses: []string{"203.0.113.7"}, Hairpin: true}},
|
||||
SNATs: []*model.SourceNAT{{ID: 8, Position: 1, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"trusted"}}}},
|
||||
}
|
||||
}
|
||||
|
||||
func codes(ps []Problem) string {
|
||||
var out []string
|
||||
for _, p := range ps {
|
||||
out = append(out, p.Code)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
func TestBaseIsValid(t *testing.T) {
|
||||
env := &Env{Interfaces: map[string][]netip.Prefix{"wan": nil, "eth1": nil}}
|
||||
if ps := Check(base(), env); len(ps) > 0 {
|
||||
t.Fatalf("problems: %+v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodes(t *testing.T) {
|
||||
rule := func(st *model.State) *model.Rule { return st.Rules[0] }
|
||||
cases := []struct {
|
||||
code string
|
||||
edit func(st *model.State)
|
||||
}{
|
||||
{"zone_name", func(st *model.State) { st.Zones[0].Name = "1wan" }},
|
||||
{"zone_name_taken", func(st *model.State) { st.Zones[1].Name = "WAN" }},
|
||||
{"forward_name", func(st *model.State) { st.Forwards[0].Name = "jelly fin" }},
|
||||
{"policy", func(st *model.State) { st.Settings.InputPolicy = "reject" }},
|
||||
{"settings_address", func(st *model.State) { st.Settings.AntispoofV4 = []string{"fc00::/7"} }},
|
||||
{"flowtable_device", func(st *model.State) { st.Settings.FlowtableDevices = []string{"eth*"} }},
|
||||
{"probation_range", func(st *model.State) { st.Settings.ProbationSeconds = 5 }},
|
||||
{"zone_empty", func(st *model.State) { st.Zones[1].Interfaces = nil }},
|
||||
{"zone_interface", func(st *model.State) { st.Zones[1].Interfaces = []string{"a-very-long-interface"} }},
|
||||
{"zone_interface_taken", func(st *model.State) { st.Zones[1].Interfaces = []string{"wan"} }},
|
||||
{"no_external_zone", func(st *model.State) {
|
||||
st.Zones[0].External = false
|
||||
st.Forwards[0].PublicAddresses = []string{"203.0.113.7"}
|
||||
}},
|
||||
{"set_entry", func(st *model.State) { st.Sets[0].Entries = []string{"10.0.10.5/24"} }},
|
||||
{"set_empty", func(st *model.State) { st.Sets[0].Entries = nil }},
|
||||
{"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }},
|
||||
{"service_empty", func(st *model.State) { st.Services[0].Entries = nil }},
|
||||
{"service_entry", func(st *model.State) { st.Services[0].Entries = []string{"tcp/99999"} }},
|
||||
{"unknown_zone", func(st *model.State) { st.Rules[1].InZones = []string{"dmz"} }},
|
||||
{"match_in_zone", func(st *model.State) {
|
||||
st.Rules = append(st.Rules, &model.Rule{ID: 9, Chain: model.ChainOutput, Position: 1, Match: model.Match{InZones: []string{"lan"}}, Action: "accept"})
|
||||
}},
|
||||
{"match_out_zone", func(st *model.State) { rule(st).OutZones = []string{"wan"} }},
|
||||
{"unknown_set", func(st *model.State) { rule(st).Sources = []string{"friends"} }},
|
||||
{"unknown_service", func(st *model.State) { rule(st).Services = []string{"gopher"} }},
|
||||
{"family", func(st *model.State) { rule(st).Family = "ipv5" }},
|
||||
{"match_never", func(st *model.State) {
|
||||
rule(st).Sources = []string{"10.0.0.1"}
|
||||
rule(st).Services = []string{"icmpv6/echo-request"}
|
||||
}},
|
||||
{"rule_chain", func(st *model.State) { rule(st).Chain = "prerouting" }},
|
||||
{"rule_action", func(st *model.State) { rule(st).Action = "allow" }},
|
||||
{"raw_rule", func(st *model.State) { rule(st).Raw = "tcp dport 22 accept; udp dport 53 accept" }},
|
||||
{"early_conntrack", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).CtState = []string{"new"} }},
|
||||
{"early_reject", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).Action = model.ActionReject }},
|
||||
{"ct_state", func(st *model.State) { rule(st).CtState = []string{"fresh"} }},
|
||||
{"ct_status", func(st *model.State) { rule(st).CtStatus = []string{"translated"} }},
|
||||
{"rule_day", func(st *model.State) { rule(st).Days = []string{"Caturday"} }},
|
||||
{"rule_hours", func(st *model.State) { rule(st).HourFrom = "08:00" }},
|
||||
{"rule_limit", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 10, Per: "week"} }},
|
||||
{"rule_conn_limit", func(st *model.State) { rule(st).ConnLimit = -1 }},
|
||||
{"reject_with", func(st *model.State) { rule(st).RejectWith = "tcp-reset" }},
|
||||
{"log_prefix", func(st *model.State) { rule(st).Log = true; rule(st).LogPrefix = strings.Repeat("x", 101) }},
|
||||
{"forward_protocol", func(st *model.State) { st.Forwards[0].Protocols = []string{"icmp"} }},
|
||||
{"forward_ports", func(st *model.State) { st.Forwards[0].Ports = "http" }},
|
||||
{"forward_to_port", func(st *model.State) { st.Forwards[0].Ports = "8000-8010"; st.Forwards[0].ToPort = "80" }},
|
||||
{"forward_target", func(st *model.State) { st.Forwards[0].To = "2001:db8::57" }},
|
||||
{"forward_public", func(st *model.State) { st.Forwards[0].PublicAddresses = []string{"2001:db8::1"} }},
|
||||
{"forward_overlap", func(st *model.State) {
|
||||
f := *st.Forwards[0]
|
||||
f.ID, f.Name = 10, "other"
|
||||
st.Forwards = append(st.Forwards, &f)
|
||||
}},
|
||||
{"forward_hairpin", func(st *model.State) { st.Forwards[0].PublicAddresses = nil }},
|
||||
{"snat_to", func(st *model.State) { st.SNATs[0].To = "2001:db8::1" }},
|
||||
{"snat_everything", func(st *model.State) { st.SNATs[0].OutZones, st.SNATs[0].Sources = nil, nil }},
|
||||
{"redirect_service", func(st *model.State) {
|
||||
st.Redirects = []*model.Redirect{{ID: 11, Position: 1, Match: model.Match{InZones: []string{"lan"}}}}
|
||||
}},
|
||||
{"redirect_to_port", func(st *model.State) {
|
||||
st.Redirects = []*model.Redirect{{ID: 11, Position: 1, Match: model.Match{InZones: []string{"lan"}, Services: []string{"dns"}}, ToPort: "53-54"}}
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
st := base()
|
||||
c.edit(st)
|
||||
if got := codes(Check(st, nil)); !strings.Contains(" "+got+" ", " "+c.code+" ") {
|
||||
t.Errorf("%s: got [%s]", c.code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostWarnings(t *testing.T) {
|
||||
st := base()
|
||||
st.Settings.FlowtableDevices = []string{"wan"}
|
||||
env := &Env{Interfaces: map[string][]netip.Prefix{"eth1": nil}}
|
||||
got := codes(Check(st, env))
|
||||
for _, want := range []string{"zone_interface_missing", "flowtable_device_missing"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("%s missing: [%s]", want, got)
|
||||
}
|
||||
}
|
||||
for _, p := range Check(st, env) {
|
||||
if p.Severity != Warning {
|
||||
t.Errorf("a missing interface is only a warning: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user