package engine import ( "net/netip" "slices" "strings" "jarl/internal/model" "jarl/internal/namedconf" ) // ExposureFix returns the requests that fix an exposure finding of a view // (viewID 0: the server), and what they do, or nil when there is no // automatic fix: what to allow instead (your secondaries, your hosts) // is for you to decide. The requests go through a changeset like any // edit (docs/exposure.md). func ExposureFix(st *model.State, viewID int64, code string) ([]Request, string) { v := st.Views[viewID] scope, scopeID := model.ScopeOptions, int64(0) if v != nil { scope, scopeID = model.ScopeView, v.ID } set := func(name, value string) Request { if o := st.FindOption(scope, scopeID, name); o != nil { return NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value}) } return NewRequest(OpOptionCreate, 0, OptionInput{Scope: &scope, ScopeID: &scopeID, Name: &name, Value: &value}) } server := func(name, value string) Request { if o := st.FindOption(model.ScopeOptions, 0, name); o != nil { return NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value}) } return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: &name, Value: &value}) } switch code { case "exposure_rate_limit": value := RecommendedRateLimit(st, v) return []Request{set("rate-limit", value)}, "start rate limiting in log-only mode: rate-limit " + value case "exposure_rate_limit_log_only": o := st.FindOption(scope, scopeID, "rate-limit") if o == nil { o = st.FindOption(model.ScopeOptions, 0, "rate-limit") } if o == nil { return nil, "" } value := withoutLogOnly(o.Value) return []Request{NewRequest(OpOptionUpdate, o.ID, OptionInput{Value: &value})}, "start enforcing the rate limit: rate-limit " + value case "exposure_minimal_any": return []Request{set("minimal-any", "yes")}, "answer ANY queries minimally: minimal-any yes" case "exposure_cache_snooping": return []Request{set("allow-query-cache", "{ none; }")}, "close the cache to everyone: allow-query-cache { none; }" case "exposure_version": return []Request{server("version", "none")}, "hide BIND's version: version none" case "exposure_hostname": return []Request{server("hostname", "none")}, "hide the host name: hostname none" case "security_open_resolver": if v == nil { return nil, "" // a server without views is also your own resolver } return []Request{set("recursion", "no")}, "turn recursion off in view " + v.Name + ": recursion no" } return nil, "" } // RecommendedRateLimit is the rate-limit block Jarl suggests for a public // view (v nil: the server): ISC's example of 10 identical responses per // second per client network, in log-only mode until you have looked at // what it would limit, with your secondaries exempt so zone transfers // and their queries are never limited. func RecommendedRateLimit(st *model.State, v *model.View) string { parts := []string{"responses-per-second 10;", "log-only yes;"} if ex := secondaries(st, v); len(ex) > 0 { parts = append(parts, "exempt-clients { "+strings.Join(ex, "; ")+"; };") } return "{ " + strings.Join(parts, " ") + " }" } // secondaries returns the addresses the view's zones notify and allow // transfers to (ACLs resolved), the servers that rate limiting must leave // alone. func secondaries(st *model.State, v *model.View) []string { var opts []*model.Option add := func(scope string, id int64) { for _, name := range []string{"also-notify", "allow-transfer"} { if o := st.FindOption(scope, id, name); o != nil { opts = append(opts, o) } } } add(model.ScopeOptions, 0) vid := int64(0) if v != nil { vid = v.ID add(model.ScopeView, v.ID) } for _, z := range st.SortedZones() { if z.ViewID == vid { add(model.ScopeZone, z.ID) } } seen := map[string]bool{} var out []string var addElems func(list []string, depth int) addElems = func(list []string, depth int) { for _, s := range list { e, err := namedconf.ParseAMElement(s) if err != nil || e.Negated { continue } switch e.Kind { case namedconf.AMPrefix, namedconf.AMRange: if t := e.String(); !seen[t] { seen[t] = true out = append(out, t) } case namedconf.AMName: if a := st.ACLByName(e.Name); a != nil && depth < 10 { addElems(a.Elements, depth+1) } } } } for _, o := range opts { stmt, err := namedconf.ParseOptionValue(o.Name, o.Value) if err != nil || stmt.FirstBlock() == nil { continue } var list []string for _, s := range stmt.FirstBlock().Stmts { if o.Name == "also-notify" { // "ADDRESS [port N] [key K]": the address only. if len(s.Items) > 0 && s.Items[0].Value != nil { if _, err := netip.ParseAddr(s.Items[0].Value.Text); err == nil { list = append(list, s.Items[0].Value.Text) } } continue } list = append(list, namedconf.Compact(s.Items)) } addElems(list, 0) } slices.Sort(out) return out } // withoutLogOnly removes "log-only ...;" from a rate-limit block. func withoutLogOnly(value string) string { st, err := namedconf.ParseOptionValue("rate-limit", value) if err != nil || st.FirstBlock() == nil { return value } b := st.FirstBlock() var keep []*namedconf.Stmt for _, s := range b.Stmts { if s.Keyword() != "log-only" { keep = append(keep, s) } } b.Stmts = keep return namedconf.Compact(st.Items[1:]) }