387 lines
11 KiB
Go
387 lines
11 KiB
Go
package kubesource
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
|
|
)
|
|
|
|
// maxZoneRecords bounds one DNSZone. An object is capped near 1.5 MiB by etcd
|
|
// anyway; this keeps a hostile or runaway one from costing a render.
|
|
const maxZoneRecords = 5000
|
|
|
|
// group is a converted ClientGroup with its ordering key.
|
|
type group struct {
|
|
cfg config.Group
|
|
priority int32
|
|
}
|
|
|
|
// zone is a converted DNSZone: the apex and the rendered master file.
|
|
type zone struct {
|
|
apex string
|
|
text string
|
|
}
|
|
|
|
func spec(u *unstructured.Unstructured, into any) error {
|
|
raw, ok := u.Object["spec"].(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("the object has no spec")
|
|
}
|
|
return runtime.DefaultUnstructuredConverter.FromUnstructured(raw, into)
|
|
}
|
|
|
|
func convertGroup(u *unstructured.Unstructured) (group, error) {
|
|
var s ClientGroupSpec
|
|
if err := spec(u, &s); err != nil {
|
|
return group{}, err
|
|
}
|
|
return groupFromSpec(u.GetName(), s)
|
|
}
|
|
|
|
// FieldError is a validation failure tied to one field of a ClientGroup, so an
|
|
// editor can show the message next to the input that caused it. Field is the
|
|
// JSON path inside the group ("policy", "match.cidrs", "rate_limit"); "name"
|
|
// and "match" are used for the whole-object cases.
|
|
type FieldError struct {
|
|
Field string
|
|
Msg string
|
|
}
|
|
|
|
func (e FieldError) Error() string { return e.Msg }
|
|
|
|
// CheckGroup validates a ClientGroup spec the way the runtime will, so an
|
|
// editor can refuse a bad one before it is written. It reports the first
|
|
// problem; CheckGroupAll reports every one.
|
|
func CheckGroup(name string, s ClientGroupSpec) (config.Group, error) {
|
|
g, errs := checkGroup(name, s)
|
|
if len(errs) > 0 {
|
|
return g.cfg, errs[0]
|
|
}
|
|
return g.cfg, nil
|
|
}
|
|
|
|
// CheckGroupAll is CheckGroup for an editor that wants to show every problem
|
|
// at once instead of one per save.
|
|
func CheckGroupAll(name string, s ClientGroupSpec) []FieldError {
|
|
_, errs := checkGroup(name, s)
|
|
return errs
|
|
}
|
|
|
|
func groupFromSpec(name string, s ClientGroupSpec) (group, error) {
|
|
g, errs := checkGroup(name, s)
|
|
if len(errs) > 0 {
|
|
return group{}, errs[0]
|
|
}
|
|
return g, nil
|
|
}
|
|
|
|
func checkGroup(name string, s ClientGroupSpec) (group, []FieldError) {
|
|
g := config.Group{
|
|
Name: name,
|
|
Comment: s.Comment,
|
|
Match: s.Match,
|
|
Policy: s.Policy,
|
|
AllowDomains: s.AllowDomains,
|
|
DenyDomains: s.DenyDomains,
|
|
SkipBlocklist: s.SkipBlocklist,
|
|
SkipCache: s.SkipCache,
|
|
RateLimit: s.RateLimit,
|
|
MaxConcurrent: s.MaxConcurrent,
|
|
LogQueries: s.LogQueries,
|
|
Upstreams: s.Upstreams,
|
|
}
|
|
var errs []FieldError
|
|
bad := func(field, format string, args ...any) {
|
|
errs = append(errs, FieldError{Field: field, Msg: fmt.Sprintf(format, args...)})
|
|
}
|
|
switch g.Policy {
|
|
case "allow", "deny", "block_only":
|
|
default:
|
|
bad("policy", "policy must be allow, deny or block_only (got %q)", g.Policy)
|
|
}
|
|
for _, cidr := range g.Match.CIDRs {
|
|
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
|
bad("match.cidrs", "bad CIDR %q", cidr)
|
|
}
|
|
}
|
|
// Copy before normalising: the slice belongs to the decoded object.
|
|
g.Match.PTRSuffixes = append([]string(nil), g.Match.PTRSuffixes...)
|
|
for i, s := range g.Match.PTRSuffixes {
|
|
if !strings.HasSuffix(s, ".") {
|
|
g.Match.PTRSuffixes[i] = s + "."
|
|
}
|
|
}
|
|
selectsSomething := len(g.Match.CIDRs) > 0 || len(g.Match.PTRSuffixes) > 0 || len(g.Match.Tags) > 0
|
|
switch {
|
|
case !g.Match.Any && !selectsSomething:
|
|
bad("match", "match selects no clients: set cidrs, ptr_suffixes, tags or any")
|
|
case g.Match.Any && !selectsSomething:
|
|
// config.Validate refuses this shape for every group. Refusing it here
|
|
// rejects one object instead of the whole merged configuration.
|
|
bad("match.any", "match.any alone is not accepted; use a catch-all in the YAML acl.groups")
|
|
}
|
|
if g.RateLimit < 0 {
|
|
bad("rate_limit", "rate_limit must not be negative")
|
|
}
|
|
if g.MaxConcurrent < 0 {
|
|
bad("max_concurrent", "max_concurrent must not be negative")
|
|
}
|
|
return group{cfg: g, priority: s.Priority}, errs
|
|
}
|
|
|
|
func convertList(u *unstructured.Unstructured) (config.ListSource, error) {
|
|
var s BlockListSpec
|
|
if err := spec(u, &s); err != nil {
|
|
return config.ListSource{}, err
|
|
}
|
|
return listFromSpec(u.GetName(), s)
|
|
}
|
|
|
|
// CheckList validates a BlockList spec the way the runtime will.
|
|
func CheckList(name string, s BlockListSpec) (config.ListSource, error) {
|
|
return listFromSpec(name, s)
|
|
}
|
|
|
|
func listFromSpec(name string, s BlockListSpec) (config.ListSource, error) {
|
|
l := config.ListSource{
|
|
Name: name,
|
|
Format: s.Format,
|
|
URL: s.URL,
|
|
Mirror: s.Mirror,
|
|
Enabled: !s.Disabled,
|
|
MaxBytes: s.MaxBytes,
|
|
Categories: s.Categories,
|
|
Comment: s.Comment,
|
|
InsecureSkipTLSVerify: s.InsecureSkipTLSVerify,
|
|
}
|
|
if !isHTTP(l.URL) {
|
|
return config.ListSource{}, fmt.Errorf("url must be http(s) (got %q)", l.URL)
|
|
}
|
|
for _, m := range l.Mirror {
|
|
if !isHTTP(m) {
|
|
return config.ListSource{}, fmt.Errorf("mirror must be http(s) (got %q)", m)
|
|
}
|
|
}
|
|
switch l.Format {
|
|
case "", "auto", "hosts", "adblock", "plain", "rpz":
|
|
default:
|
|
return config.ListSource{}, fmt.Errorf("unknown format %q", l.Format)
|
|
}
|
|
var err error
|
|
if l.Refresh, err = duration(s.Refresh); err != nil {
|
|
return config.ListSource{}, fmt.Errorf("refresh: %w", err)
|
|
}
|
|
if l.Timeout, err = duration(s.Timeout); err != nil {
|
|
return config.ListSource{}, fmt.Errorf("timeout: %w", err)
|
|
}
|
|
if l.MaxBytes < 0 {
|
|
return config.ListSource{}, fmt.Errorf("max_bytes must not be negative")
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
func isHTTP(u string) bool {
|
|
return strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://")
|
|
}
|
|
|
|
func duration(s string) (config.Duration, error) {
|
|
if s == "" {
|
|
return 0, nil
|
|
}
|
|
d, err := time.ParseDuration(s)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if d < 0 {
|
|
return 0, fmt.Errorf("must not be negative")
|
|
}
|
|
return config.Duration(d), nil
|
|
}
|
|
|
|
func convertZone(u *unstructured.Unstructured) (zone, error) {
|
|
var s DNSZoneSpec
|
|
if err := spec(u, &s); err != nil {
|
|
return zone{}, err
|
|
}
|
|
return zoneFromSpec(s, u.GetGeneration())
|
|
}
|
|
|
|
// CheckZone validates a DNSZone spec by rendering it exactly as the runtime
|
|
// will, and returns the master file.
|
|
func CheckZone(s DNSZoneSpec) (string, error) {
|
|
z, err := zoneFromSpec(s, 1)
|
|
return z.text, err
|
|
}
|
|
|
|
func zoneFromSpec(s DNSZoneSpec, generation int64) (zone, error) {
|
|
if s.Zone == "" {
|
|
return zone{}, fmt.Errorf("spec.zone is required")
|
|
}
|
|
if len(s.Records) > maxZoneRecords {
|
|
return zone{}, fmt.Errorf("%d records exceeds the limit of %d", len(s.Records), maxZoneRecords)
|
|
}
|
|
apex, err := localzone.NormalizeOrigin(s.Zone)
|
|
if err != nil {
|
|
return zone{}, fmt.Errorf("spec.zone: %w", err)
|
|
}
|
|
var ns []string
|
|
recs := make([]localzone.Record, 0, len(s.Records))
|
|
for _, r := range s.Records {
|
|
name := r.Name
|
|
if name == "@" {
|
|
name = apex
|
|
}
|
|
recs = append(recs, localzone.Record{Name: name, Type: r.Type, TTL: r.TTL, Value: r.Value})
|
|
if strings.EqualFold(r.Type, "NS") && name == apex {
|
|
ns = append(ns, r.Value)
|
|
}
|
|
}
|
|
if s.SOA == "" && len(ns) == 0 {
|
|
return zone{}, fmt.Errorf("a zone needs an apex NS record (name \"@\") or an explicit soa")
|
|
}
|
|
// The generation changes exactly when the spec does, so every replica
|
|
// renders the same bytes for the same object version.
|
|
serial := uint32(generation)
|
|
if serial == 0 {
|
|
serial = 1
|
|
}
|
|
text, err := localzone.RenderZone(apex, localzone.ZoneDefaults{TTL: s.TTL, Soa: s.SOA}, recs, serial)
|
|
if err != nil {
|
|
return zone{}, err
|
|
}
|
|
return zone{apex: apex, text: text}, nil
|
|
}
|
|
|
|
// Override is a converted AddressOverride.
|
|
type Override struct {
|
|
Domain string `json:"domain"`
|
|
Addresses []string `json:"addresses"`
|
|
}
|
|
|
|
// Rules is a converted DomainRule.
|
|
type Rules struct {
|
|
Action string `json:"action"`
|
|
Names []string `json:"names"`
|
|
}
|
|
|
|
func convertOverride(u *unstructured.Unstructured) (Override, error) {
|
|
var s AddressOverrideSpec
|
|
if err := spec(u, &s); err != nil {
|
|
return Override{}, err
|
|
}
|
|
return CheckOverride(s)
|
|
}
|
|
|
|
// CheckOverride validates an AddressOverride spec and returns it in canonical
|
|
// form (lower-case name without the trailing dot, addresses as printed by
|
|
// netip).
|
|
func CheckOverride(s AddressOverrideSpec) (Override, error) {
|
|
domain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s.Domain)), ".")
|
|
if err := checkRuleName(domain, false); err != nil {
|
|
return Override{}, fmt.Errorf("domain: %w", err)
|
|
}
|
|
if len(s.Addresses) == 0 {
|
|
return Override{}, fmt.Errorf("at least one address is required")
|
|
}
|
|
out := Override{Domain: domain}
|
|
seen := map[string]bool{}
|
|
for _, raw := range s.Addresses {
|
|
ip, err := netip.ParseAddr(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return Override{}, fmt.Errorf("%q is not an IPv4 or IPv6 address", raw)
|
|
}
|
|
ip = ip.Unmap()
|
|
if !seen[ip.String()] {
|
|
seen[ip.String()] = true
|
|
out.Addresses = append(out.Addresses, ip.String())
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func convertRules(u *unstructured.Unstructured) (Rules, error) {
|
|
var s DomainRuleSpec
|
|
if err := spec(u, &s); err != nil {
|
|
return Rules{}, err
|
|
}
|
|
return CheckRules(s)
|
|
}
|
|
|
|
// CheckRules validates a DomainRule spec.
|
|
func CheckRules(s DomainRuleSpec) (Rules, error) {
|
|
if s.Action != "block" && s.Action != "allow" {
|
|
return Rules{}, fmt.Errorf("action must be block or allow (got %q)", s.Action)
|
|
}
|
|
if len(s.Names) > maxRuleNames {
|
|
return Rules{}, fmt.Errorf("%d names exceeds the limit of %d; use a BlockList for large sets", len(s.Names), maxRuleNames)
|
|
}
|
|
out := Rules{Action: s.Action}
|
|
for _, n := range s.Names {
|
|
n = strings.ToLower(strings.TrimSpace(n))
|
|
if err := checkRuleName(n, true); err != nil {
|
|
return Rules{}, fmt.Errorf("name %q: %w", n, err)
|
|
}
|
|
out.Names = append(out.Names, n)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// maxRuleNames bounds one DomainRule; an object is capped near 1.5 MiB by etcd
|
|
// anyway, and a bigger set belongs in a BlockList that is fetched.
|
|
const maxRuleNames = 5000
|
|
|
|
// checkRuleName accepts only what a DNS name or a wildcard pattern can contain.
|
|
// This is a security check, not tidiness: names are joined into the blocklist
|
|
// parser's line-oriented input, so a newline or a comment character in a name
|
|
// could smuggle in a rule of its own.
|
|
func checkRuleName(n string, wildcard bool) error {
|
|
if n == "" {
|
|
return fmt.Errorf("a name is required")
|
|
}
|
|
if wildcard {
|
|
n = strings.TrimPrefix(strings.TrimPrefix(n, "**."), "*.")
|
|
}
|
|
n = strings.TrimSuffix(n, ".")
|
|
if n == "" || len(n) > 253 {
|
|
return fmt.Errorf("not a valid DNS name")
|
|
}
|
|
for _, label := range strings.Split(n, ".") {
|
|
if label == "" || len(label) > 63 {
|
|
return fmt.Errorf("not a valid DNS name")
|
|
}
|
|
for _, c := range label {
|
|
ok := c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_'
|
|
if !ok {
|
|
return fmt.Errorf("not a valid DNS name (only letters, digits, '-' and '_' are allowed)")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RenderRPZ renders the overrides as the RPZ text the blocklist manager reads:
|
|
// one "name A address" line per address, applying to the name and everything
|
|
// under it.
|
|
func RenderRPZ(in []Override) string {
|
|
var b strings.Builder
|
|
b.WriteString("$TTL 60\n")
|
|
for _, o := range in {
|
|
for _, a := range o.Addresses {
|
|
typ := "A"
|
|
if strings.Contains(a, ":") {
|
|
typ = "AAAA"
|
|
}
|
|
fmt.Fprintf(&b, "%s %s %s\n", o.Domain, typ, a)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|