Files
tomswall/internal/config/rules.go
T
unkinben 2a3eb3b04d Initial scaffold for tomswall
Spiritual successor to shorewall — manages nftables directly via
google/nftables. Reads a single YAML config covering zones, interfaces,
hosts, policy, rules, snat, and named portgroups. Computes differential
changes against the running nftables state and applies them atomically.
Supports detecting and purging rules added outside of tomswall.
2026-06-28 23:43:16 +10:00

122 lines
3.1 KiB
Go

package config
import "fmt"
type RuleAction string
const (
RuleAccept RuleAction = "accept"
RuleDrop RuleAction = "drop"
RuleReject RuleAction = "reject"
RuleDNAT RuleAction = "dnat"
RuleRedirect RuleAction = "redirect"
RuleLog RuleAction = "log"
)
type Rule struct {
Action RuleAction `yaml:"action"`
Source string `yaml:"source"`
Dest string `yaml:"dest"`
Proto string `yaml:"proto,omitempty"`
DPort PortSpec `yaml:"dport,omitempty"`
SPort PortSpec `yaml:"sport,omitempty"`
PortGroup string `yaml:"portgroup,omitempty"`
Log string `yaml:"log,omitempty"`
DNATDest string `yaml:"dnat_dest,omitempty"`
RateLimit string `yaml:"rate_limit,omitempty"`
ConnLimit int `yaml:"conn_limit,omitempty"`
Comment string `yaml:"comment,omitempty"`
}
// PortSpec supports single ports, ranges, and lists.
// Examples: [80], [443], [80, 443], ["1024-65535"], [53, "80-90"]
type PortSpec []string
func (ps *PortSpec) UnmarshalYAML(unmarshal func(interface{}) error) error {
var multi []interface{}
if err := unmarshal(&multi); err == nil {
for _, v := range multi {
switch val := v.(type) {
case int:
*ps = append(*ps, fmt.Sprintf("%d", val))
case float64:
*ps = append(*ps, fmt.Sprintf("%d", int(val)))
case string:
*ps = append(*ps, val)
default:
return fmt.Errorf("unsupported port value type %T", v)
}
}
return nil
}
var single string
if err := unmarshal(&single); err == nil {
*ps = PortSpec{single}
return nil
}
var num int
if err := unmarshal(&num); err == nil {
*ps = PortSpec{fmt.Sprintf("%d", num)}
return nil
}
return fmt.Errorf("invalid port spec")
}
func (c *Config) validateRules() error {
for i, r := range c.Rules {
switch r.Action {
case RuleAccept, RuleDrop, RuleReject, RuleDNAT, RuleRedirect, RuleLog:
default:
return fmt.Errorf("rule[%d]: unknown action %q", i, r.Action)
}
if r.Source == "" {
return fmt.Errorf("rule[%d]: source required", i)
}
if r.Dest == "" {
return fmt.Errorf("rule[%d]: dest required", i)
}
srcZone := zoneFromSpec(r.Source)
if srcZone != "all" {
if _, ok := c.Zones[srcZone]; !ok {
return fmt.Errorf("rule[%d]: source zone %q not defined", i, srcZone)
}
}
dstZone := zoneFromSpec(r.Dest)
if dstZone != "all" {
if _, ok := c.Zones[dstZone]; !ok {
return fmt.Errorf("rule[%d]: dest zone %q not defined", i, dstZone)
}
}
if r.PortGroup != "" {
if _, ok := c.PortGroups[r.PortGroup]; !ok {
return fmt.Errorf("rule[%d]: portgroup %q not defined", i, r.PortGroup)
}
if r.Proto != "" || len(r.DPort) > 0 {
return fmt.Errorf("rule[%d]: portgroup is mutually exclusive with proto/dport", i)
}
}
if r.Action == RuleDNAT && r.DNATDest == "" {
return fmt.Errorf("rule[%d]: dnat_dest required for DNAT action", i)
}
}
return nil
}
// zoneFromSpec extracts the zone name from a zone spec like "net" or "net:192.168.1.0/24".
func zoneFromSpec(spec string) string {
for i, c := range spec {
if c == ':' {
return spec[:i]
}
}
return spec
}