Address ranges in address match lists (IPv4 and IPv6)

ACLs, views' match lists, options with address match lists and blocking
client groups take ranges such as 10.0.10.101-10.0.10.199 or
2001:db8::100-2001:db8::1ff (negated, or with spaces around the dash, too).
Jarl stores the range as written and writes the fewest prefixes that cover
exactly that range to named.conf, with the range as a comment. New package
internal/iprange; namedconf gets an AMRange element (a range used to parse
as an ACL name), ExpandRanges and statement comments. Mistakes are refused
when entered, validation sees what ranges cover, and IPv6 ranges in the
IPv4-mapped space get a warning. The web UI shows what a range becomes
while typing (GET /catalog/ranges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-11 23:17:14 +01:00
1 parent 49b1c872c9
commit e2e5007f94
32 files changed
+905 -31

No files matched your search

+21
View File
@@ -6,6 +6,12 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased] ## [Unreleased]
### Upgrading from 0.4
- Once you use address ranges, the database can't go back to 0.4: its
parser doesn't know them. Remove the ranges first if you need to
downgrade.
### Fixed ### Fixed
- A changeset with a staged record delete could not be applied after that - A changeset with a staged record delete could not be applied after that
@@ -19,6 +25,21 @@ All notable changes to Jarl are documented here. The format follows
### Added ### Added
- Address ranges such as `10.0.10.101-10.0.10.199` or
`2001:db8::100-2001:db8::1ff` (also `!` negated, and with spaces around
the dash) wherever an address match list is used: ACLs, views'
match-clients and match-destinations, options such as allow-query, and
blocking's client groups ([docs/concepts.md](docs/concepts.md)). Jarl
keeps the range as written and writes the fewest prefixes that cover
exactly that range to named.conf, with the range as a comment. The web
UI shows what a range becomes while you type it (`GET
/catalog/ranges`). Mistakes are refused when entered: an end that isn't
a full address, the start after the end, IPv4 mixed with IPv6, a range
in an option that takes no address match list (forwarders). A range
covered by an earlier element is reported as never matching, a range of
every address counts as "any" for the open resolver and open transfer
checks, and an IPv6 range in the IPv4-mapped space gets a warning. ACL
names can no longer look like a range.
- Prometheus metrics at `/metrics` ([docs/metrics.md](docs/metrics.md)), - Prometheus metrics at `/metrics` ([docs/metrics.md](docs/metrics.md)),
set up in the new `metrics:` section of jarl.yaml (off by default). set up in the new `metrics:` section of jarl.yaml (off by default).
`metrics.allow` lists the addresses that may scrape (loopback by `metrics.allow` lists the addresses that may scrape (loopback by
+2 -1
View File
@@ -415,7 +415,7 @@ send the request with that changeset (`?changeset=` or
| Method | Path | Body | | Method | Path | Body |
|--------|------|------| |--------|------|------|
| GET | `/acls` | each with `used_by` | | GET | `/acls` | each with `used_by` |
| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "key xfer", "localhost"], "comment": ""}` | | POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "10.0.10.101-10.0.10.199", "key xfer", "localhost"], "comment": ""}` (ranges: [concepts.md](concepts.md#model-views-zones-records-options)) |
| GET / PATCH / DELETE | `/acls/{id}` | renaming updates references; deleting one in use → 409 | | GET / PATCH / DELETE | `/acls/{id}` | renaming updates references; deleting one in use → 409 |
| GET | `/tsig-keys` | without secrets | | GET | `/tsig-keys` | without secrets |
| POST | `/tsig-keys` | `{"name": "xfer", "algorithm": "hmac-sha256", "secret": "<base64>"}` (secret generated when absent; the response `object` shows it once) | | POST | `/tsig-keys` | `{"name": "xfer", "algorithm": "hmac-sha256", "secret": "<base64>"}` (secret generated when absent; the response `object` shows it once) |
@@ -471,6 +471,7 @@ written in Jarl's fixed order (docs/concepts.md).
| GET | `/catalog/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself | | GET | `/catalog/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself |
| GET | `/catalog/rrtypes` | record types with field hints and examples | | GET | `/catalog/rrtypes` | record types with field hints and examples |
| GET | `/catalog/grants` | resources, roles and the grant syntax | | GET | `/catalog/grants` | resources, roles and the grant syntax |
| GET | `/catalog/ranges?r=A-B,C-D` | for each address range: `range` (canonical), `size` ("99 addresses"), and the `prefixes` it is written to named.conf as; or `error`. Text that isn't a range is left out |
| GET | `/search?q=text` | zones, records and ACLs matching the text | | GET | `/search?q=text` | zones, records and ACLs matching the text |
### Changesets ### Changesets
+2 -2
View File
@@ -177,8 +177,8 @@ jarl block group list
In the web UI, the Blocking page's **Devices** panel holds the steps and In the web UI, the Blocking page's **Devices** panel holds the steps and
the groups, and each list has a step. the groups, and each list has a step.
- **Members** are addresses, networks (`10.10.0.0/24`), or ACLs made only - **Members** are addresses, networks (`10.10.0.0/24`), ranges
of those (`key`, `localnets` and `!` can't be used: blocking matches (`10.10.0.20-10.10.0.49`), or ACLs made only of those (`key`, `localnets` and `!` can't be used: blocking matches
clients by address). A device is in one group: two groups with the same clients by address). A device is in one group: two groups with the same
address are refused, but a more specific address in another group wins address are refused, but a more specific address in another group wins
(`10.10.0.0/24` in one group, `10.10.0.20` in another). (`10.10.0.0/24` in one group, `10.10.0.20` in another).
+2
View File
@@ -371,6 +371,7 @@ jarl view color internal green # web UI colour; "default" resets
jarl view key external [--named-conf] # the key that selects the view (dig -k), docs/views.md jarl view key external [--named-conf] # the key that selects the view (dig -k), docs/views.md
jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
jarl acl create dhcp 10.0.10.101-10.0.10.199 2001:db8::100-2001:db8::1ff # ranges (concepts.md)
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16 jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
jarl acl update homelab --rename lan # references are updated jarl acl update homelab --rename lan # references are updated
jarl acl delete lan # refused while in use jarl acl delete lan # refused while in use
@@ -386,6 +387,7 @@ jarl option set allow-recursion '{ homelab; localhost; }'
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }' jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
jarl option set forwarders 1.1.1.1 9.9.9.9 # the same: braces are added to a list jarl option set forwarders 1.1.1.1 9.9.9.9 # the same: braces are added to a list
jarl option set allow-transfer 'key xfer; 10.0.10.53' --zone adbraun.lol jarl option set allow-transfer 'key xfer; 10.0.10.53' --zone adbraun.lol
jarl option set allow-query '10.0.20.1-10.0.20.99; localhost' --view internal
jarl option set response-policy '{ zone "rpz.local"; }' --view internal jarl option set response-policy '{ zone "rpz.local"; }' --view internal
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
jarl option unset forwarders jarl option unset forwarders
+26
View File
@@ -167,6 +167,32 @@ RFC 1982 arithmetic.
alphabetical within a group. So the same option is in the same place in alphabetical within a group. So the same option is in the same place in
every view and zone. Top-level statements stay in the order they were every view and zone. Top-level statements stay in the order they were
added. added.
* **Address ranges.** Wherever named takes an address match list (ACLs,
views' `match-clients` and `match-destinations`, options such as
`allow-query` or `allow-recursion`) and in blocking's client groups, Jarl
also takes a range of addresses: `10.0.10.101-10.0.10.199`,
`2001:db8::100-2001:db8::1ff`, `!10.0.10.150-10.0.10.160`. BIND only
matches addresses and prefixes, so Jarl keeps the range as you wrote it
and writes the fewest prefixes that cover exactly that range to
named.conf, with the range as a comment above them:
```
acl "dhcp" {
// 10.0.10.101-10.0.10.199
10.0.10.101;
10.0.10.102/31;
10.0.10.104/29;
10.0.10.112/28;
10.0.10.128/26;
10.0.10.192/29;
};
```
Nothing outside the range is matched (writing such lists by hand, it is
easy to round to `10.0.10.96/27` and let in more than you meant). Both
ends are full addresses of the same family, and spaces around the dash
are fine. A range takes at most 62 prefixes for IPv4 and 254 for IPv6.
The web UI shows what a range becomes while you type it. A range in the
IPv4-mapped space (`::ffff:0:0/96`) gets a warning: named can see IPv4
clients as such addresses, so it may match IPv4 clients too.
* **Settings** are Jarl's defaults for new zones: name servers, SOA contact * **Settings** are Jarl's defaults for new zones: name servers, SOA contact
and timers, default TTL, serial policy. and timers, default TTL, serial policy.
+1
View File
@@ -10,6 +10,7 @@ internal/
dnsname/ DNS name handling: FQDNs, relative names, check-names rules, reverse zones dnsname/ DNS name handling: FQDNs, relative names, check-names rules, reverse zones
rr/ record data parsing/canonicalization (miekg/dns), TXT quoting, the record type catalog rr/ record data parsing/canonicalization (miekg/dns), TXT quoting, the record type catalog
namedconf/ named.conf lexer, parser and printer; address match lists namedconf/ named.conf lexer, parser and printer; address match lists
iprange/ address ranges (10.0.10.101-10.0.10.199): parsing and the exact prefix cover
catalog/ every BIND 9.20 statement: grammar (from named.conf(5)) + ARM descriptions, embedded catalog/ every BIND 9.20 statement: grammar (from named.conf(5)) + ARM descriptions, embedded
validate/ whole-configuration validation and security lints validate/ whole-configuration validation and security lints
render/ State → named.conf, keys.conf and zone files, with a source map for error messages render/ State → named.conf, keys.conf and zone files, with a source map for error messages
+3 -2
View File
@@ -67,10 +67,11 @@ showing them.
| Code | Severity | Rule | | Code | Severity | Rule |
|------|----------|------| |------|----------|------|
| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets` | | `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), ranges (`10.0.10.101-10.0.10.199`: both ends full addresses of one family, start not after end), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets`. Ranges in options are checked when they're entered, and only options with address match lists take them (not `forwarders`, say) |
| `range_mapped4` | warning | An IPv6 range that reaches into `::ffff:0:0/96`: named can see IPv4 clients as such addresses, so it may match IPv4 clients too |
| `acl_ref_missing`, `key_ref_missing` | error | Every ACL or TSIG key that is referenced must exist | | `acl_ref_missing`, `key_ref_missing` | error | Every ACL or TSIG key that is referenced must exist |
| `acl_cycle` | error | ACLs that include each other | | `acl_cycle` | error | ACLs that include each other |
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, elements that can never match | | `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, names that look like addresses or ranges, elements that can never match (a prefix or range an earlier one already covers) |
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak. Names starting with `jarl-view-` are reserved for the view keys (`key_name_reserved`) | | `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak. Names starting with `jarl-view-` are reserved for the view keys (`key_name_reserved`) |
| `view_duplicate`, `view_name_invalid`, `view_acl_invalid` | error | Valid, unique views and match lists | | `view_duplicate`, `view_name_invalid`, `view_acl_invalid` | error | Valid, unique views and match lists |
| `view_unreachable` | warning | A view that comes after one matching every client is never used | | `view_unreachable` | warning | A view that comes after one matching every client is never used |
+7
View File
@@ -493,6 +493,13 @@ func spaceElements(fields []string) []string {
f += " " + fields[i+1] f += " " + fields[i+1]
i++ i++
} }
case "-":
// A range with spaces around the dash: 10.0.0.1 - 10.0.0.9.
if len(out) > 0 && i+1 < len(fields) {
out[len(out)-1] += " - " + fields[i+1]
i++
continue
}
case "port", "tls": case "port", "tls":
if len(out) > 0 && i+1 < len(fields) { if len(out) > 0 && i+1 < len(fields) {
out[len(out)-1] += " " + f + " " + fields[i+1] out[len(out)-1] += " " + f + " " + fields[i+1]
+8 -1
View File
@@ -5,6 +5,7 @@ import (
"net/netip" "net/netip"
"strings" "strings"
"jarl/internal/iprange"
"jarl/internal/model" "jarl/internal/model"
) )
@@ -52,10 +53,16 @@ func NormalizeMember(st *model.State, m string) (string, error) {
if p, err := netip.ParsePrefix(m); err == nil { if p, err := netip.ParsePrefix(m); err == nil {
return p.Masked().String(), nil return p.Masked().String(), nil
} }
if r, ok, err := iprange.Parse(m); ok {
if err != nil {
return "", errf(KindBadRequest, "%v", err)
}
return r.String(), nil
}
if a := st.ACLByName(m); a != nil { if a := st.ACLByName(m); a != nil {
return a.Name, nil return a.Name, nil
} }
return "", errf(KindBadRequest, "%q is not an address, a prefix (10.10.0.0/24) or an ACL", m) return "", errf(KindBadRequest, "%q is not an address, a prefix (10.10.0.0/24), a range (10.10.0.20-10.10.0.49) or an ACL", m)
} }
func applyBlockStepPut(st *model.State, op model.Op, c *applyCtx) error { func applyBlockStepPut(st *model.State, op model.Op, c *applyCtx) error {
+36
View File
@@ -11,6 +11,7 @@ import (
"jarl/internal/auth" "jarl/internal/auth"
"jarl/internal/catalog" "jarl/internal/catalog"
"jarl/internal/dnsname" "jarl/internal/dnsname"
"jarl/internal/iprange"
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/namedconf" "jarl/internal/namedconf"
"jarl/internal/rr" "jarl/internal/rr"
@@ -1144,9 +1145,44 @@ func NormalizeOption(st *model.State, scope string, scopeID int64, name, value s
if err != nil { if err != nil {
return "", "", errf(KindBadRequest, "option %s: %v", n, err) return "", "", errf(KindBadRequest, "option %s: %v", n, err)
} }
namedconf.JoinRanges(parsed)
if err := checkOptionRanges(parsed, strings.Contains(stmt.Grammar, "<address_match_element>")); err != nil {
return "", "", errf(KindBadRequest, "option %s: %v", n, err)
}
return n, namedconf.Compact(parsed.Items[1:]), nil return n, namedconf.Compact(parsed.Items[1:]), nil
} }
// checkOptionRanges checks the address ranges in an option's blocks: they
// must be valid, and only options with address match lists take them.
func checkOptionRanges(st *namedconf.Stmt, aml bool) error {
for _, it := range st.Items {
if it.Block == nil {
continue
}
for _, s := range it.Block.Stmts {
items := s.Items
if len(items) == 2 && items[0].Bang {
items = items[1:]
}
if len(items) == 1 && items[0].Value != nil && !items[0].Value.Quoted {
if _, ok, err := iprange.Parse(items[0].Value.Text); ok {
if err != nil {
return err
}
if !aml {
return fmt.Errorf("%s: address ranges only work in address match lists (such as allow-query or an ACL), not here", items[0].Value.Text)
}
continue
}
}
if err := checkOptionRanges(s, aml); err != nil {
return err
}
}
}
return nil
}
func applyOptionPut(st *model.State, op model.Op, c *applyCtx) error { func applyOptionPut(st *model.State, op model.Op, c *applyCtx) error {
var in OptionInput var in OptionInput
if err := decode(op.Data, &in); err != nil { if err := decode(op.Data, &in); err != nil {
+113
View File
@@ -0,0 +1,113 @@
package engine
import (
"os"
"path/filepath"
"strings"
"testing"
"jarl/internal/model"
"jarl/internal/validate"
)
func hasProblem(ps []validate.Problem, code string) bool {
for _, p := range ps {
if p.Code == code {
return true
}
}
return false
}
func describeProblems(e *Error) string {
var out []string
for _, p := range e.Problems {
out = append(out, p.Message)
}
return strings.Join(out, "; ")
}
func optionReq(name, value string) Request {
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: &name, Value: &value})
}
func TestAddressRanges(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin,
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("dhcp"), Elements: &[]string{"!10.0.10.150 - 10.0.10.160", "10.0.10.101-10.0.10.199", "2001:db8::100-2001:db8::1ff"}}),
optionReq("allow-query", "10.0.20.1 - 10.0.20.6; localhost"),
)
st := env.state()
if a := st.ACLByName("dhcp"); strings.Join(a.Elements, " ") != "!10.0.10.150-10.0.10.160 10.0.10.101-10.0.10.199 2001:db8::100-2001:db8::1ff" {
t.Errorf("stored elements = %q", a.Elements)
}
if o := st.FindOption(model.ScopeOptions, 0, "allow-query"); o == nil || o.Value != "{ 10.0.20.1-10.0.20.6; localhost; }" {
t.Errorf("stored option = %+v", o)
}
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
c := string(conf)
for _, want := range []string{
"acl \"dhcp\" {\n\t// !10.0.10.150-10.0.10.160\n\t!10.0.10.150/31;\n\t!10.0.10.152/29;\n\t!10.0.10.160;\n\t// 10.0.10.101-10.0.10.199\n\t10.0.10.101;\n",
"\t10.0.10.192/29;\n\t// 2001:db8::100-2001:db8::1ff\n\t2001:db8::100/120;\n};\n",
"\tallow-query {\n\t\t// 10.0.20.1-10.0.20.6\n\t\t10.0.20.1;\n\t\t10.0.20.2/31;\n\t\t10.0.20.4/31;\n\t\t10.0.20.6;\n\t\tlocalhost;\n\t};\n",
} {
if !strings.Contains(c, want) {
t.Errorf("named.conf lacks %q:\n%s", want, c)
}
}
for _, l := range strings.Split(c, "\n") {
if code, _, _ := strings.Cut(l, "//"); strings.Contains(code, "-10.0.") || strings.Contains(code, "-2001:") {
t.Errorf("a range reached named.conf outside a comment: %q", l)
}
}
// Mistakes are caught when they're entered.
for _, tc := range []struct {
req Request
want string
}{
{NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("bad"), Elements: &[]string{"10.0.10.199-10.0.10.101"}}), "start is after the end"},
{NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("bad"), Elements: &[]string{"10.0.10.101-199"}}), "not a full IP address"},
{optionReq("forwarders", "{ 192.0.2.1-192.0.2.9; }"), "only work in address match lists"},
{optionReq("allow-recursion", "{ 10.0.0.9-10.0.0.1; }"), "start is after the end"},
{NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("10.0.0.1-10.0.0.9"), Elements: &[]string{"10.0.0.1"}}), "looks like an address range"},
} {
if e := env.submitErr(env.admin, tc.req); !strings.Contains(e.Error(), tc.want) && !strings.Contains(describeProblems(e), tc.want) {
t.Errorf("%s: %v %s, want %q", tc.req.Op, e, describeProblems(e), tc.want)
}
}
// Validation sees what a range covers: everything is "any".
res := env.submit(env.admin, optionReq("allow-recursion", "0.0.0.0-255.255.255.255"))
if !hasProblem(res.Problems, "security_open_resolver") {
t.Errorf("no open resolver warning for 0.0.0.0-255.255.255.255: %+v", res.Problems)
}
// IPv6 ranges in the IPv4-mapped space get a warning.
res = env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("mapped"), Elements: &[]string{"::ffff:10.0.0.1-::ffff:10.0.0.9"}}))
if !hasProblem(res.Problems, "range_mapped4") {
t.Errorf("no range_mapped4 warning: %+v", res.Problems)
}
}
func TestClientGroupRanges(t *testing.T) {
env := newEnv(t)
setupZone(env)
_, srv := newListServer(t, "||ads.example^\n")
env.submit(env.admin,
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("tablets"), Elements: &[]string{"10.0.0.50-10.0.0.51"}}),
)
env.submit(env.admin, blocklist("ads", srv.URL+"/a.txt"), clientGroup("kids", "off", "tablets", "10.0.0.20 - 10.0.0.23"))
if g := env.state().SortedClientGroups(); len(g) != 1 || strings.Join(g[0].Members, " ") != "tablets 10.0.0.20-10.0.0.23" {
t.Fatalf("groups = %+v", g)
}
b, err := os.ReadFile(filepath.Join(env.dir, "zones/_default/off.gate.jarl.invalid.rpz"))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"31.50.0.0.10.rpz-client-ip", "30.20.0.0.10.rpz-client-ip"} {
if !strings.Contains(string(b), want) {
t.Errorf("off gate lacks %s:\n%s", want, b)
}
}
}
+148
View File
@@ -0,0 +1,148 @@
// Package iprange handles address ranges such as 10.0.10.101-10.0.10.199
// or 2001:db8::100-2001:db8::1ff. BIND matches only addresses and
// prefixes, so Jarl keeps a range as it was written and turns it into the
// exact set of prefixes that covers it when it writes named.conf.
package iprange
import (
"fmt"
"math/big"
"net/netip"
"strings"
)
// Range is an inclusive range of addresses of one family.
type Range struct {
Start, End netip.Addr
}
// String returns the range as Jarl writes it: START-END.
func (r Range) String() string { return r.Start.String() + "-" + r.End.String() }
// Parse parses START-END, both full addresses of the same family. ok is
// false when s isn't meant as a range (no "-", or text before it that
// isn't an address, such as the ACL name "branch-office"); err is set when
// it is meant as one but is wrong.
func Parse(s string) (r Range, ok bool, err error) {
left, right, found := strings.Cut(s, "-")
if !found {
return Range{}, false, nil
}
left, right = strings.TrimSpace(left), strings.TrimSpace(right)
if !looksLikeAddr(left) {
return Range{}, false, nil
}
start, err := netip.ParseAddr(left)
if err != nil {
return Range{}, true, fmt.Errorf("range %q: %q is not a full IP address", s, left)
}
end, err := netip.ParseAddr(right)
if err != nil {
if right == "" {
return Range{}, true, fmt.Errorf("range %q has no end address", s)
}
return Range{}, true, fmt.Errorf("range %q: %q is not a full IP address (write both ends in full, e.g. 10.0.10.101-10.0.10.199)", s, right)
}
if start.Zone() != "" || end.Zone() != "" {
return Range{}, true, fmt.Errorf("range %q: addresses with a zone (%%interface) can't be matched", s)
}
if start.Is4() != end.Is4() {
return Range{}, true, fmt.Errorf("range %q mixes IPv4 and IPv6", s)
}
if end.Less(start) {
return Range{}, true, fmt.Errorf("range %q: the start is after the end", s)
}
return Range{Start: start, End: end}, true, nil
}
// looksLikeAddr reports whether s could only be meant as an address: it
// has a colon, or it is digits and dots with at least one dot.
func looksLikeAddr(s string) bool {
if strings.Contains(s, ":") {
return true
}
if !strings.Contains(s, ".") {
return false
}
for i := 0; i < len(s); i++ {
if c := s[i]; !(c >= '0' && c <= '9' || c == '.') {
return false
}
}
return true
}
// Prefixes returns the fewest prefixes that together cover exactly the
// range, in address order: 10.0.10.101-10.0.10.199 is 10.0.10.101/32,
// 10.0.10.102/31, 10.0.10.104/29, 10.0.10.112/28, 10.0.10.128/26 and
// 10.0.10.192/29. That is at most 62 prefixes for IPv4 and 254 for IPv6.
func (r Range) Prefixes() []netip.Prefix {
var out []netip.Prefix
a := r.Start
for {
// The largest prefix that starts at a and ends at or before End.
p := netip.PrefixFrom(a, a.BitLen())
for bits := a.BitLen() - 1; bits >= 0; bits-- {
q := netip.PrefixFrom(a, bits)
if q.Masked().Addr() != a || r.End.Less(last(q)) {
break
}
p = q
}
out = append(out, p)
end := last(p)
if end == r.End {
return out
}
a = end.Next()
}
}
// last returns the last address of a prefix.
func last(p netip.Prefix) netip.Addr {
b := p.Addr().AsSlice()
for i := p.Bits(); i < len(b)*8; i++ {
b[i/8] |= 0x80 >> (i % 8)
}
a, _ := netip.AddrFromSlice(b)
return a
}
// Size returns the number of addresses in the range.
func (r Range) Size() *big.Int {
s := new(big.Int).SetBytes(r.Start.AsSlice())
e := new(big.Int).SetBytes(r.End.AsSlice())
return e.Sub(e, s).Add(e, big.NewInt(1))
}
// SizeText describes the size for people: "99 addresses", or a power of
// two for very large IPv6 ranges ("about 2^80 addresses").
func (r Range) SizeText() string {
n := r.Size()
if n.BitLen() <= 40 {
if n.Int64() == 1 {
return "1 address"
}
return n.String() + " addresses"
}
return fmt.Sprintf("about 2^%d addresses", n.BitLen()-1)
}
// Mapped4 is the IPv4-mapped IPv6 space; BIND can see IPv4 clients as
// addresses in it.
var Mapped4 = netip.MustParsePrefix("::ffff:0:0/96")
// TouchesMapped4 reports whether an IPv6 range overlaps ::ffff:0:0/96.
func (r Range) TouchesMapped4() bool {
if r.Start.Is4() {
return false
}
lo, hi := Mapped4.Addr(), last(Mapped4)
return !(r.End.Less(lo) || hi.Less(r.Start))
}
// FromPrefix returns the addresses of a prefix as a range.
func FromPrefix(p netip.Prefix) Range {
p = p.Masked()
return Range{Start: p.Addr(), End: last(p)}
}
+123
View File
@@ -0,0 +1,123 @@
package iprange
import (
"math/rand"
"net/netip"
"strings"
"testing"
)
func TestParse(t *testing.T) {
for _, tc := range []struct {
in, want, err string
ok bool
}{
{in: "10.0.10.101-10.0.10.199", want: "10.0.10.101-10.0.10.199", ok: true},
{in: "10.0.10.101 - 10.0.10.199", want: "10.0.10.101-10.0.10.199", ok: true},
{in: "2001:DB8::100-2001:db8::1ff", want: "2001:db8::100-2001:db8::1ff", ok: true},
{in: "10.0.0.5-10.0.0.5", want: "10.0.0.5-10.0.0.5", ok: true},
{in: "branch-office", ok: false},
{in: "10-net", ok: false},
{in: "10.0.0.0/24", ok: false},
{in: "10.0.10.101-199", ok: true, err: "not a full IP address"},
{in: "10.0.10.101-", ok: true, err: "no end address"},
{in: "2001:db8::100-1ff", ok: true, err: "not a full IP address"},
{in: "10.0.0.9-10.0.0.1", ok: true, err: "start is after the end"},
{in: "10.0.0.1-2001:db8::1", ok: true, err: "mixes IPv4 and IPv6"},
{in: "fe80::1%eth0-fe80::9%eth0", ok: true, err: "zone"},
{in: "10.0.300.1-10.0.300.9", ok: true, err: "not a full IP address"},
{in: "10.0.0.1-10.0.0.9-10.0.0.20", ok: true, err: "not a full IP address"},
} {
r, ok, err := Parse(tc.in)
if ok != tc.ok || (err == nil) != (tc.err == "") || err != nil && !strings.Contains(err.Error(), tc.err) {
t.Errorf("%s: ok=%v err=%v, want ok=%v err~%q", tc.in, ok, err, tc.ok, tc.err)
continue
}
if err == nil && ok && r.String() != tc.want {
t.Errorf("%s = %s, want %s", tc.in, r, tc.want)
}
}
}
func TestPrefixes(t *testing.T) {
for in, want := range map[string]string{
"10.0.10.101-10.0.10.199": "10.0.10.101/32 10.0.10.102/31 10.0.10.104/29 10.0.10.112/28 10.0.10.128/26 10.0.10.192/29",
"10.0.0.0-10.0.0.255": "10.0.0.0/24",
"10.0.0.5-10.0.0.5": "10.0.0.5/32",
"0.0.0.0-255.255.255.255": "0.0.0.0/0",
"255.255.255.254-255.255.255.255": "255.255.255.254/31",
"2001:db8::100-2001:db8::1ff": "2001:db8::100/120",
"::-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff": "::/0",
} {
r, _, err := Parse(in)
if err != nil {
t.Fatal(err)
}
var got []string
for _, p := range r.Prefixes() {
got = append(got, p.String())
}
if strings.Join(got, " ") != want {
t.Errorf("%s = %s, want %s", in, strings.Join(got, " "), want)
}
}
// The worst cases stay small.
if n := len(must(t, "0.0.0.1-255.255.255.254").Prefixes()); n != 62 {
t.Errorf("worst IPv4 case = %d prefixes, want 62", n)
}
if n := len(must(t, "::1-ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffe").Prefixes()); n != 254 {
t.Errorf("worst IPv6 case = %d prefixes, want 254", n)
}
}
// Every address of a random range is covered once, and nothing outside it.
func TestPrefixesExact(t *testing.T) {
rnd := rand.New(rand.NewSource(1))
for i := 0; i < 300; i++ {
lo, hi := rnd.Intn(4096), rnd.Intn(4096)
if hi < lo {
lo, hi = hi, lo
}
base := []byte{10, 20, 0, 0}
addr := func(n int) netip.Addr {
return netip.AddrFrom4([4]byte{base[0], base[1], byte(n >> 8), byte(n)})
}
r := Range{Start: addr(lo), End: addr(hi)}
ps := r.Prefixes()
for n := max(lo-3, 0); n <= min(hi+3, 4095); n++ {
c := 0
for _, p := range ps {
if p.Contains(addr(n)) {
c++
}
}
if in := n >= lo && n <= hi; in && c != 1 || !in && c != 0 {
t.Fatalf("%s: %s covered %d times (%v)", r, addr(n), c, ps)
}
}
if got := r.Size().Int64(); got != int64(hi-lo+1) {
t.Fatalf("%s: size %d", r, got)
}
}
}
func TestSizeAndMapped(t *testing.T) {
if s := must(t, "10.0.10.101-10.0.10.199").SizeText(); s != "99 addresses" {
t.Errorf("size = %s", s)
}
if s := must(t, "2001:db8::-2001:db8:0:ffff:ffff:ffff:ffff:ffff").SizeText(); s != "about 2^80 addresses" {
t.Errorf("size = %s", s)
}
if !must(t, "::ffff:10.0.0.1-::ffff:10.0.0.9").TouchesMapped4() || must(t, "2001:db8::1-2001:db8::9").TouchesMapped4() || must(t, "10.0.0.1-10.0.0.9").TouchesMapped4() {
t.Error("TouchesMapped4")
}
}
func must(t *testing.T, s string) Range {
t.Helper()
r, ok, err := Parse(s)
if !ok || err != nil {
t.Fatalf("%s: %v %v", s, ok, err)
}
return r
}
+4
View File
@@ -7,6 +7,8 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"jarl/internal/iprange"
) )
// Blocklist is a list of domains to block, downloaded from URL and kept up // Blocklist is a list of domains to block, downloaded from URL and kept up
@@ -268,6 +270,8 @@ func (s *State) GroupPrefixes(g *ClientGroup) []netip.Prefix {
out = append(out, netip.PrefixFrom(a, a.BitLen())) out = append(out, netip.PrefixFrom(a, a.BitLen()))
} else if p, err := netip.ParsePrefix(e); err == nil { } else if p, err := netip.ParsePrefix(e); err == nil {
out = append(out, p.Masked()) out = append(out, p.Masked())
} else if r, ok, err := iprange.Parse(e); ok && err == nil {
out = append(out, r.Prefixes()...)
} else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] { } else if acl := s.ACLByName(e); acl != nil && !seen[acl.Name] {
seen[acl.Name] = true seen[acl.Name] = true
add(acl.Elements) add(acl.Elements)
+141 -8
View File
@@ -6,6 +6,8 @@ import (
"net/netip" "net/netip"
"strconv" "strconv"
"strings" "strings"
"jarl/internal/iprange"
) )
// AMKind is the kind of an address match list element. // AMKind is the kind of an address match list element.
@@ -17,6 +19,9 @@ const (
AMName // an ACL name (or built-in ACL) AMName // an ACL name (or built-in ACL)
AMNested // { ... } AMNested // { ... }
AMOther // anything else (e.g. geoip), passed through AMOther // anything else (e.g. geoip), passed through
// AMRange is an address range, START-END: Jarl's own syntax, written
// to named.conf as the prefixes that cover it (ExpandRanges).
AMRange
) )
// Builtin ACL names. // Builtin ACL names.
@@ -26,10 +31,11 @@ var BuiltinACLs = map[string]bool{"any": true, "none": true, "localhost": true,
type AMElement struct { type AMElement struct {
Negated bool Negated bool
Kind AMKind Kind AMKind
Prefix netip.Prefix // AMPrefix Prefix netip.Prefix // AMPrefix
Name string // AMKey, AMName Name string // AMKey, AMName
Nested []AMElement // AMNested Nested []AMElement // AMNested
Raw string // AMOther Raw string // AMOther
Range iprange.Range // AMRange
} }
// String returns the element in canonical named.conf syntax, without the // String returns the element in canonical named.conf syntax, without the
@@ -55,6 +61,8 @@ func (e AMElement) String() string {
s = "{ " + strings.Join(parts, " ") + " }" s = "{ " + strings.Join(parts, " ") + " }"
case AMOther: case AMOther:
s = e.Raw s = e.Raw
case AMRange:
s = e.Range.String()
} }
if e.Negated { if e.Negated {
return "!" + s return "!" + s
@@ -137,6 +145,7 @@ func parseAMItems(items []Item) (AMElement, error) {
if len(items) == 0 { if len(items) == 0 {
return e, errors.New("empty address match element") return e, errors.New("empty address match element")
} }
items = joinRange(items)
first := items[0] first := items[0]
if first.Block != nil { if first.Block != nil {
if len(items) != 1 { if len(items) != 1 {
@@ -172,6 +181,16 @@ func parseAMItems(items []Item) (AMElement, error) {
} }
return e, fmt.Errorf("unexpected text after %q", word) return e, fmt.Errorf("unexpected text after %q", word)
} }
if !first.Value.Quoted {
if r, ok, err := iprange.Parse(word); ok {
if err != nil {
return e, err
}
e.Kind = AMRange
e.Range = r
return e, nil
}
}
if !first.Value.Quoted || BuiltinACLs[lw] { if !first.Value.Quoted || BuiltinACLs[lw] {
if p, ok, err := parsePrefix(word); ok { if p, ok, err := parsePrefix(word); ok {
if err != nil { if err != nil {
@@ -215,6 +234,9 @@ func CheckACLName(name string) error {
return fmt.Errorf("ACL name %q looks like an address", name) return fmt.Errorf("ACL name %q looks like an address", name)
} }
} }
if _, ok, _ := iprange.Parse(name); ok {
return fmt.Errorf("ACL name %q looks like an address range", name)
}
return nil return nil
} }
@@ -318,18 +340,18 @@ func AMRefs(elems []AMElement) (acls, keys []string) {
func Shadowed(elems []AMElement) []string { func Shadowed(elems []AMElement) []string {
var warns []string var warns []string
for i, e := range elems { for i, e := range elems {
if e.Kind != AMPrefix && e.Kind != AMName { if e.Kind != AMPrefix && e.Kind != AMRange && e.Kind != AMName {
continue continue
} }
er, eok := e.addrRange()
for j := 0; j < i; j++ { for j := 0; j < i; j++ {
prev := elems[j] prev := elems[j]
if prev.Kind == AMName && prev.Name == "any" { if prev.Kind == AMName && prev.Name == "any" {
warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already matches every address", e.String(), prev.String())) warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already matches every address", e.String(), prev.String()))
break break
} }
if prev.Kind == AMPrefix && e.Kind == AMPrefix && if pr, ok := prev.addrRange(); ok && eok && pr.Start.Is4() == er.Start.Is4() &&
prev.Prefix.Addr().Is4() == e.Prefix.Addr().Is4() && !er.Start.Less(pr.Start) && !pr.End.Less(er.End) {
prev.Prefix.Bits() <= e.Prefix.Bits() && prev.Prefix.Contains(e.Prefix.Addr()) {
warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already covers it (first match wins; put the more specific element first)", e.String(), prev.String())) warns = append(warns, fmt.Sprintf("element %q never matches: %q before it already covers it (first match wins; put the more specific element first)", e.String(), prev.String()))
break break
} }
@@ -338,6 +360,117 @@ func Shadowed(elems []AMElement) []string {
return warns return warns
} }
// addrRange returns the addresses a prefix or range element covers.
func (e AMElement) addrRange() (iprange.Range, bool) {
switch e.Kind {
case AMRange:
return e.Range, true
case AMPrefix:
return iprange.FromPrefix(e.Prefix), true
}
return iprange.Range{}, false
}
// Expand returns the element as named.conf can take it: a range becomes
// the prefixes that cover it (each negated when the range is), nested
// lists are expanded too, and anything else is returned as it is.
func (e AMElement) Expand() []AMElement {
switch e.Kind {
case AMRange:
var out []AMElement
for _, p := range e.Range.Prefixes() {
out = append(out, AMElement{Negated: e.Negated, Kind: AMPrefix, Prefix: p})
}
return out
case AMNested:
n := e
n.Nested = nil
for _, in := range e.Nested {
n.Nested = append(n.Nested, in.Expand()...)
}
return []AMElement{n}
}
return []AMElement{e}
}
// joinRange turns a range written with spaces around the dash
// ("10.0.0.1 - 10.0.0.9", three words) into a single word.
func joinRange(items []Item) []Item {
if len(items) != 3 || items[0].Value == nil || items[1].Value == nil || items[2].Value == nil ||
items[0].Value.Quoted || items[1].Value.Quoted || items[2].Value.Quoted || items[1].Value.Text != "-" {
return items
}
text := items[0].Value.Text + "-" + items[2].Value.Text
if _, ok, _ := iprange.Parse(text); !ok {
return items
}
return []Item{{Value: &Value{Text: text, Pos: items[0].Value.Pos}}}
}
// JoinRanges rewrites ranges written with spaces around the dash, in st's
// blocks, as single words (the form Jarl stores).
func JoinRanges(st *Stmt) {
for _, it := range st.Items {
if it.Block == nil {
continue
}
for _, s := range it.Block.Stmts {
if len(s.Items) == 4 && s.Items[0].Bang {
s.Items = append(s.Items[:1], joinRange(s.Items[1:])...)
} else {
s.Items = joinRange(s.Items)
}
JoinRanges(s)
}
}
}
// ExpandRanges replaces the address ranges in st's blocks (at any depth)
// by the prefixes that cover them, with the range as a comment, so named
// can read it. A range is a block statement that is a single word START-END,
// optionally negated; nothing else in named.conf's grammar looks like one.
func ExpandRanges(st *Stmt) {
for _, it := range st.Items {
if it.Block != nil {
expandBlock(it.Block)
}
}
}
func expandBlock(b *Block) {
var out []*Stmt
for _, s := range b.Stmts {
items, neg := s.Items, false
if len(items) == 2 && items[0].Bang {
items, neg = items[1:], true
}
if len(items) == 1 && items[0].Value != nil && !items[0].Value.Quoted {
if r, ok, err := iprange.Parse(items[0].Value.Text); ok && err == nil {
for i, p := range r.Prefixes() {
var ns Stmt
if neg {
ns.Items = append(ns.Items, Item{Bang: true})
}
text := p.String()
if p.IsSingleIP() {
text = p.Addr().String()
}
ns.Items = append(ns.Items, Item{Value: &Value{Text: text}})
ns.Pos = s.Pos
if i == 0 {
ns.Comment = AMElement{Negated: neg, Kind: AMRange, Range: r}.String()
}
out = append(out, &ns)
}
continue
}
}
ExpandRanges(s)
out = append(out, s)
}
b.Stmts = out
}
// ContainsAny reports whether elems contain a non-negated "any" before any // ContainsAny reports whether elems contain a non-negated "any" before any
// negated element would restrict it, which makes the list match everyone. // negated element would restrict it, which makes the list match everyone.
// It is used for security lints (open resolvers, open transfers). // It is used for security lints (open resolvers, open transfers).
+3
View File
@@ -30,6 +30,9 @@ type Item struct {
type Stmt struct { type Stmt struct {
Items []Item Items []Item
Pos Pos Pos Pos
// Comment is written before the statement (only by Jarl: comments in
// parsed text are dropped). ExpandRanges notes the range there.
Comment string
} }
// Keyword returns the statement's first word, lowercased, or "" if the // Keyword returns the statement's first word, lowercased, or "" if the
+6
View File
@@ -77,6 +77,9 @@ func valueText(v *Value) string {
func writeStmt(sb *strings.Builder, s *Stmt, indent int) { func writeStmt(sb *strings.Builder, s *Stmt, indent int) {
tabs := strings.Repeat("\t", indent) tabs := strings.Repeat("\t", indent)
if s.Comment != "" {
sb.WriteString(tabs + "// " + s.Comment + "\n")
}
sb.WriteString(tabs) sb.WriteString(tabs)
inline := Compact(s.Items) inline := Compact(s.Items)
if !hasNestedBlocks(s.Items) && len(inline)+indent*8 <= maxInline+8 { if !hasNestedBlocks(s.Items) && len(inline)+indent*8 <= maxInline+8 {
@@ -167,6 +170,9 @@ func writeItemsInline(sb *strings.Builder, items []Item) {
} else { } else {
sb.WriteString("{ ") sb.WriteString("{ ")
for _, s := range it.Block.Stmts { for _, s := range it.Block.Stmts {
if s.Comment != "" {
sb.WriteString("/* " + s.Comment + " */ ")
}
writeItemsInline(sb, s.Items) writeItemsInline(sb, s.Items)
sb.WriteString("; ") sb.WriteString("; ")
} }
+90
View File
@@ -0,0 +1,90 @@
package namedconf
import (
"strings"
"testing"
)
func TestRangeElements(t *testing.T) {
e, err := ParseAMElement("!10.0.10.150 - 10.0.10.160;")
if err != nil || e.Kind != AMRange || !e.Negated || e.String() != "!10.0.10.150-10.0.10.160" {
t.Fatalf("negated range = %+v, %v", e, err)
}
if e, err := ParseAMElement("2001:db8::100-2001:db8::1ff"); err != nil || e.Kind != AMRange {
t.Errorf("IPv6 range = %+v, %v", e, err)
}
// ACL names with dashes are still names.
if e, err := ParseAMElement("branch-office"); err != nil || e.Kind != AMName {
t.Errorf("name = %+v, %v", e, err)
}
for _, bad := range []string{"10.0.10.199-10.0.10.101", "10.0.10.101-199", "10.0.0.1-2001:db8::1"} {
if _, err := ParseAMElement(bad); err == nil {
t.Errorf("%s accepted", bad)
}
}
if err := CheckACLName("10.0.0.1-10.0.0.9"); err == nil {
t.Error("an ACL name that looks like a range was accepted")
}
var got []string
for _, x := range e.Expand() {
got = append(got, x.String())
}
if strings.Join(got, " ") != "!10.0.10.150/31 !10.0.10.152/29 !10.0.10.160" {
t.Errorf("expand = %v", got)
}
}
func TestExpandRanges(t *testing.T) {
st, err := ParseStatement(`acl "lan" { !10.0.10.150-10.0.10.160; 10.0.10.101-10.0.10.199; key xfer; branch-office; { 10.0.0.1-10.0.0.2; }; }`)
if err != nil {
t.Fatal(err)
}
ExpandRanges(st)
want := `acl "lan" {
// !10.0.10.150-10.0.10.160
!10.0.10.150/31;
!10.0.10.152/29;
!10.0.10.160;
// 10.0.10.101-10.0.10.199
10.0.10.101;
10.0.10.102/31;
10.0.10.104/29;
10.0.10.112/28;
10.0.10.128/26;
10.0.10.192/29;
key xfer;
branch-office;
{ /* 10.0.0.1-10.0.0.2 */ 10.0.0.1; 10.0.0.2; };
};
`
if got := FormatStmt(st, 0); got != want {
t.Errorf("got:\n%s\nwant:\n%s", got, want)
}
// Short lists stay on one line, with the range as a /* */ comment;
// both forms parse again.
st, _ = ParseOptionValue("allow-query", "{ 2001:db8::100-2001:db8::1ff; localhost; }")
ExpandRanges(st)
got := FormatStmt(st, 0)
if got != "allow-query { /* 2001:db8::100-2001:db8::1ff */ 2001:db8::100/120; localhost; };\n" {
t.Errorf("inline = %q", got)
}
if _, err := ParseStatement(strings.TrimSuffix(strings.TrimSpace(got), ";")); err != nil {
t.Errorf("output doesn't parse: %v", err)
}
}
func TestShadowedRanges(t *testing.T) {
elems, _ := ParseAMListString("10.0.10.0/24; 10.0.10.101-10.0.10.199; 10.0.10.150-10.0.11.5; 10.0.11.1")
w := Shadowed(elems)
if len(w) != 2 || !strings.Contains(w[0], `"10.0.10.101-10.0.10.199" never matches`) || !strings.Contains(w[1], `"10.0.11.1" never matches: "10.0.10.150-10.0.11.5"`) {
t.Errorf("shadowed = %q", w)
}
elems, _ = ParseAMListString("10.0.10.101-10.0.10.199; 10.0.10.120")
if w := Shadowed(elems); len(w) != 1 || !strings.Contains(w[0], `"10.0.10.120" never matches`) {
t.Errorf("shadowed by a range = %q", w)
}
elems, _ = ParseAMListString("0.0.0.0-255.255.255.255")
if ContainsAny(elems) {
t.Error("ContainsAny should only see expanded elements") // callers expand first
}
}
+10 -3
View File
@@ -229,7 +229,7 @@ func optionText(o *model.Option, indent int) string {
// named-checkconf reports the problem. // named-checkconf reports the problem.
return prefix + strings.Repeat("\t", indent) + o.Text() + "\n" return prefix + strings.Repeat("\t", indent) + o.Text() + "\n"
} }
return prefix + namedconf.FormatStmt(st, indent) return prefix + formatStmt(st, indent)
} }
func indentLines(s string, indent int) string { func indentLines(s string, indent int) string {
@@ -313,7 +313,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
if err != nil { if err != nil {
w.write("acl " + namedconf.QuoteString(a.Name) + " " + amlBlock(a.Elements) + ";\n") w.write("acl " + namedconf.QuoteString(a.Name) + " " + amlBlock(a.Elements) + ";\n")
} else { } else {
w.write(namedconf.FormatStmt(st, 0)) w.write(formatStmt(st, 0))
} }
w.end() w.end()
} }
@@ -382,7 +382,7 @@ func writeAML(w *confWriter, name string, elems []string) {
w.write("\t" + name + " " + amlBlock(elems) + ";\n") w.write("\t" + name + " " + amlBlock(elems) + ";\n")
return return
} }
w.write(namedconf.FormatStmt(st, 1)) w.write(formatStmt(st, 1))
} }
func yesNo(b bool) string { func yesNo(b bool) string {
@@ -507,3 +507,10 @@ func ZoneContentHash(s *model.State, z *model.Zone) string {
} }
return hex.EncodeToString(h.Sum(nil)) return hex.EncodeToString(h.Sum(nil))
} }
// formatStmt lays out a statement for named.conf, with address ranges
// (Jarl's own syntax) written as the prefixes that cover them.
func formatStmt(st *namedconf.Stmt, indent int) string {
namedconf.ExpandRanges(st)
return namedconf.FormatStmt(st, indent)
}
+3 -3
View File
@@ -62,13 +62,13 @@ func (h *StatsHooks) statsStatement(user *model.Option) string {
} }
} }
b.Stmts = append(b.Stmts, h.statsInet()) b.Stmts = append(b.Stmts, h.statsInet())
return commentLines(user.Comment) + commentBlock("//", statsComment) + namedconf.FormatStmt(st, 0) return commentLines(user.Comment) + commentBlock("//", statsComment) + formatStmt(st, 0)
} }
// Unparsable: validation reports it; leave it alone. // Unparsable: validation reports it; leave it alone.
return optionText(user, 0) return optionText(user, 0)
} }
st := namedconf.NewStmt("statistics-channels", namedconf.NewBlock(h.statsInet())) st := namedconf.NewStmt("statistics-channels", namedconf.NewBlock(h.statsInet()))
return commentBlock("//", statsComment) + namedconf.FormatStmt(st, 0) return commentBlock("//", statsComment) + formatStmt(st, 0)
} }
// loggingStatement returns the logging statement with Jarl's channel and // loggingStatement returns the logging statement with Jarl's channel and
@@ -108,7 +108,7 @@ func (h *StatsHooks) loggingStatement(user *model.Option, rpz bool) string {
if user != nil { if user != nil {
prefix = commentLines(user.Comment) prefix = commentLines(user.Comment)
} }
return prefix + commentBlock("//", statsComment) + namedconf.FormatStmt(st, 0) return prefix + commentBlock("//", statsComment) + formatStmt(st, 0)
} }
func commentLines(c string) string { func commentLines(c string) string {
+37
View File
@@ -13,6 +13,7 @@ import (
"jarl/internal/catalog" "jarl/internal/catalog"
"jarl/internal/dnsname" "jarl/internal/dnsname"
"jarl/internal/engine" "jarl/internal/engine"
"jarl/internal/iprange"
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/render" "jarl/internal/render"
"jarl/internal/rr" "jarl/internal/rr"
@@ -554,6 +555,42 @@ func (s *Server) catalogRRTypes(w http.ResponseWriter, r *http.Request, p *auth.
return nil return nil
} }
// catalogRanges: GET /catalog/ranges?r=A-B,C-D shows the prefixes each
// address range is written to named.conf as (the web UI shows them while
// you type). Text that isn't a range is left out; a range with a mistake
// comes back with its error.
func (s *Server) catalogRanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
type rangeInfo struct {
Input string `json:"input"`
Range string `json:"range,omitempty"`
Prefixes []string `json:"prefixes,omitempty"`
Size string `json:"size,omitempty"`
Error string `json:"error,omitempty"`
}
out := []rangeInfo{}
for _, in := range strings.Split(r.URL.Query().Get("r"), ",") {
in = strings.TrimSpace(in)
if in == "" || len(out) >= 100 {
continue
}
rg, ok, err := iprange.Parse(strings.TrimSpace(strings.TrimPrefix(in, "!")))
switch {
case !ok:
continue
case err != nil:
out = append(out, rangeInfo{Input: in, Error: err.Error()})
default:
ri := rangeInfo{Input: in, Range: rg.String(), Size: rg.SizeText()}
for _, pf := range rg.Prefixes() {
ri.Prefixes = append(ri.Prefixes, pf.String())
}
out = append(out, ri)
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) catalogGrants(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { func (s *Server) catalogGrants(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
roles := map[string][]string{} roles := map[string][]string{}
for name, gs := range auth.RoleTemplates { for name, gs := range auth.RoleTemplates {
+31
View File
@@ -0,0 +1,31 @@
package server
import (
"encoding/json"
"testing"
)
func TestCatalogRanges(t *testing.T) {
e := newAPIEnv(t)
b, status := e.req("GET", "/catalog/ranges?r=10.0.10.101-10.0.10.199,!2001:db8::100-2001:db8::1ff,branch-office,10.0.0.9-10.0.0.1", e.token, nil)
if status != 200 {
t.Fatalf("status %d: %s", status, b)
}
var out []struct {
Input, Range, Size, Error string
Prefixes []string
}
json.Unmarshal(b, &out)
if len(out) != 3 {
t.Fatalf("got %s", b)
}
if out[0].Range != "10.0.10.101-10.0.10.199" || len(out[0].Prefixes) != 6 || out[0].Size != "99 addresses" {
t.Errorf("IPv4 = %+v", out[0])
}
if out[1].Input != "!2001:db8::100-2001:db8::1ff" || len(out[1].Prefixes) != 1 || out[1].Prefixes[0] != "2001:db8::100/120" {
t.Errorf("IPv6 = %+v", out[1])
}
if out[2].Error == "" {
t.Errorf("reversed range = %+v", out[2])
}
}
+1
View File
@@ -136,6 +136,7 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/catalog/options", s.authed(s.catalogOptions)) m.HandleFunc("GET "+p+"/catalog/options", s.authed(s.catalogOptions))
m.HandleFunc("GET "+p+"/catalog/rrtypes", s.authed(s.catalogRRTypes)) m.HandleFunc("GET "+p+"/catalog/rrtypes", s.authed(s.catalogRRTypes))
m.HandleFunc("GET "+p+"/catalog/grants", s.authed(s.catalogGrants)) m.HandleFunc("GET "+p+"/catalog/grants", s.authed(s.catalogGrants))
m.HandleFunc("GET "+p+"/catalog/ranges", s.authed(s.catalogRanges))
m.HandleFunc("GET "+p+"/search", s.authed(s.search)) m.HandleFunc("GET "+p+"/search", s.authed(s.search))
// Changesets // Changesets
m.HandleFunc("POST "+p+"/apply", s.authed(s.applyBatch)) m.HandleFunc("POST "+p+"/apply", s.authed(s.applyBatch))
+4
View File
@@ -4,6 +4,7 @@ import (
"net/netip" "net/netip"
"strings" "strings"
"jarl/internal/iprange"
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/namedconf" "jarl/internal/namedconf"
) )
@@ -162,6 +163,9 @@ func (c *checker) checkMember(g *model.ClientGroup, m string, seen map[string]bo
if _, err := netip.ParsePrefix(m); err == nil { if _, err := netip.ParsePrefix(m); err == nil {
return return
} }
if _, ok, err := iprange.Parse(m); ok && err == nil {
return
}
acl := c.s.ACLByName(m) acl := c.s.ACLByName(m)
if acl == nil { if acl == nil {
c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s: %q is not an address, a prefix or an ACL", g.Name, m) c.errf("client_group_member_invalid", "clientgroup", g.ID, "client group %s: %q is not an address, a prefix or an ACL", g.Name, m)
+16 -1
View File
@@ -126,6 +126,7 @@ func (c *checker) checkACLs() {
c.warnf("acl_empty", "acl", a.ID, "%s is empty and matches nothing", label) c.warnf("acl_empty", "acl", a.ID, "%s is empty and matches nothing", label)
} }
c.checkRefs(elems, label, "acl", a.ID) c.checkRefs(elems, label, "acl", a.ID)
c.checkRanges(elems, label, "acl", a.ID)
for _, w := range namedconf.Shadowed(elems) { for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "acl", a.ID, "%s: %s", label, w) c.warnf("acl_shadowed", "acl", a.ID, "%s: %s", label, w)
} }
@@ -179,6 +180,7 @@ func (c *checker) checkViews() {
c.errf("view_acl_invalid", "view", v.ID, "%s: %s element %q: %v", label, field, s, err) c.errf("view_acl_invalid", "view", v.ID, "%s: %s element %q: %v", label, field, s, err)
}) })
c.checkRefs(elems, label+" "+field, "view", v.ID) c.checkRefs(elems, label+" "+field, "view", v.ID)
c.checkRanges(elems, label+" "+field, "view", v.ID)
for _, w := range namedconf.Shadowed(elems) { for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "view", v.ID, "%s %s: %s", label, field, w) c.warnf("acl_shadowed", "view", v.ID, "%s %s: %s", label, field, w)
} }
@@ -237,11 +239,24 @@ func (c *checker) flatten(list []string, depth int) []namedconf.AMElement {
continue continue
} }
} }
out = append(out, e) out = append(out, e.Expand()...)
} }
return out return out
} }
// checkRanges warns about IPv6 ranges in the IPv4-mapped space, which may
// match IPv4 clients too.
func (c *checker) checkRanges(elems []namedconf.AMElement, where, objType string, id int64) {
for _, e := range elems {
if e.Kind == namedconf.AMRange && e.Range.TouchesMapped4() {
c.warnf("range_mapped4", objType, id, "%s: range %s reaches into ::ffff:0:0/96, where named can see IPv4 clients as IPv6 addresses, so it may match IPv4 clients too; for IPv4 clients, write an IPv4 range", where, e.Range)
}
if e.Kind == namedconf.AMNested {
c.checkRanges(e.Nested, where, objType, id)
}
}
}
// optionScope maps an option to its catalog scope. // optionScope maps an option to its catalog scope.
func (c *checker) optionScope(o *model.Option) (string, string, bool) { func (c *checker) optionScope(o *model.Option) (string, string, bool) {
switch o.Scope { switch o.Scope {
+32 -2
View File
@@ -16,6 +16,8 @@ export async function amlContext() {
} }
const ipRe = /^(\d{1,3}(\.\d{1,3}){0,3}|[0-9a-f:]*:[0-9a-f:.]*)(\/\d{1,3})?$/i; const ipRe = /^(\d{1,3}(\.\d{1,3}){0,3}|[0-9a-f:]*:[0-9a-f:.]*)(\/\d{1,3})?$/i;
// rangeRe finds address ranges (START-END); the server checks them.
const rangeRe = /!?\s*[0-9a-f]*[.:][0-9a-f:.]*\s*-\s*[0-9a-f]*[.:][0-9a-f:.]*/gi;
// checkElement returns a problem with one element, or "". // checkElement returns a problem with one element, or "".
function checkElement(line, ctx, exclude) { function checkElement(line, ctx, exclude) {
@@ -24,6 +26,7 @@ function checkElement(line, ctx, exclude) {
if (e.startsWith("!")) e = e.slice(1).trim(); if (e.startsWith("!")) e = e.slice(1).trim();
const lower = e.toLowerCase(); const lower = e.toLowerCase();
if (builtins.includes(lower) || ipRe.test(e) || e.startsWith("{") || lower.startsWith("geoip ")) return ""; if (builtins.includes(lower) || ipRe.test(e) || e.startsWith("{") || lower.startsWith("geoip ")) return "";
if (e.replace(rangeRe, "").trim() === "") return ""; // a range: rangeHints explains it
const key = e.match(/^key\s+"?([^"\s]+)"?$/i); const key = e.match(/^key\s+"?([^"\s]+)"?$/i);
if (key) return ctx.keys.some((k) => k.toLowerCase() === key[1].toLowerCase()) ? "" : "no TSIG key called " + key[1]; if (key) return ctx.keys.some((k) => k.toLowerCase() === key[1].toLowerCase()) ? "" : "no TSIG key called " + key[1];
if (/\s/.test(e)) return ""; if (/\s/.test(e)) return "";
@@ -32,6 +35,33 @@ function checkElement(line, ctx, exclude) {
return ctx.acls.some((a) => a.name.toLowerCase() === name.toLowerCase()) ? "" : "no ACL called " + name + (ctx.acls.length ? "" : " (there are no ACLs yet)"); return ctx.acls.some((a) => a.name.toLowerCase() === name.toLowerCase()) ? "" : "no ACL called " + name + (ctx.acls.length ? "" : " (there are no ACLs yet)");
} }
// rangeHints shows, below a field, what each address range in it
// (10.0.10.101-10.0.10.199) becomes in named.conf, which only knows
// prefixes, and what is wrong with a range that can't be used.
export function rangeHints(field) {
const box = h("div", { class: "range-hints" });
let timer, last = "";
const update = async () => {
const found = (field.value.match(rangeRe) || []).map((r) => r.replace(/\s+/g, ""));
const key = found.join(",");
if (key === last) return;
last = key;
if (!found.length) { mount(box); return; }
try {
const info = await get("/catalog/ranges", { r: key });
if (key !== last) return;
mount(box, info.map((x) => x.error
? h("div", { class: "msg warn small" }, x.error)
: h("details", { class: "range-hint small" },
h("summary", null, h("span", { class: "mono" }, x.input), " = " + x.size + ", written to named.conf as " + (x.prefixes.length === 1 ? "1 prefix" : x.prefixes.length + " prefixes")),
h("div", { class: "prefixes mono" }, x.prefixes.map((p) => h("span", null, p))))));
} catch { mount(box); }
};
field.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(update, 300); });
update();
return box;
}
// suggestions adds clickable names below a text field. mode "lines" adds // suggestions adds clickable names below a text field. mode "lines" adds
// one element per line (and checks the names); "inline" inserts // one element per line (and checks the names); "inline" inserts
// "name; " at the cursor in named.conf syntax. // "name; " at the cursor in named.conf syntax.
@@ -58,7 +88,7 @@ export function suggestions(field, ctx, { mode = "lines", exclude = "" } = {}) {
acls.length ? [h("span", { class: "muted small" }, "ACLs:"), acls.map((a) => chip(a.name, a.name, a.detail))] : null, acls.length ? [h("span", { class: "muted small" }, "ACLs:"), acls.map((a) => chip(a.name, a.name, a.detail))] : null,
ctx.keys.length ? [h("span", { class: "muted small" }, "Keys:"), ctx.keys.map((k) => chip("key " + k, "key " + k, "TSIG key"))] : null, ctx.keys.length ? [h("span", { class: "muted small" }, "Keys:"), ctx.keys.map((k) => chip("key " + k, "key " + k, "TSIG key"))] : null,
h("span", { class: "muted small" }, "Built-in:"), builtins.map((b) => chip(b, b, { any: "every address", none: "no address", localhost: "this server's own addresses", localnets: "networks this server is directly attached to" }[b])), h("span", { class: "muted small" }, "Built-in:"), builtins.map((b) => chip(b, b, { any: "every address", none: "no address", localhost: "this server's own addresses", localnets: "networks this server is directly attached to" }[b])),
mode === "lines" ? h("span", { class: "muted small" }, "Prefix a line with ! to exclude it.") : null); mode === "lines" ? h("span", { class: "muted small" }, "Prefix a line with ! to exclude it. Ranges: 10.0.10.101-10.0.10.199.") : null);
if (mode === "lines") { if (mode === "lines") {
const check = () => { const check = () => {
const msgs = field.value.split("\n").map((l) => [l.trim(), checkElement(l, ctx, exclude)]).filter(([, m]) => m); const msgs = field.value.split("\n").map((l) => [l.trim(), checkElement(l, ctx, exclude)]).filter(([, m]) => m);
@@ -67,5 +97,5 @@ export function suggestions(field, ctx, { mode = "lines", exclude = "" } = {}) {
field.addEventListener("input", check); field.addEventListener("input", check);
check(); check();
} }
return h("div", { class: "aml" }, row, problems); return h("div", { class: "aml" }, row, problems, rangeHints(field));
} }
+1 -1
View File
@@ -30,7 +30,7 @@ async function aclDialog(a) {
const comment = input({ value: a ? a.comment : "" }); const comment = input({ value: a ? a.comment : "" });
await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" }, await modal(a ? "Edit ACL " + a.name : "New ACL", h("div", null, h("div", { class: "form-grid" },
field("Name", name, a ? "Renaming updates every reference automatically." : ""), field("Name", name, a ? "Renaming updates every reference automatically." : ""),
field("Elements", h("div", null, elems, suggestions(elems, ctx, { exclude: a ? a.name : "" })), "One per line: address, prefix (host bits must be zero), !negated, key NAME, another ACL's name, any/none/localhost/localnets."), field("Elements", h("div", null, elems, suggestions(elems, ctx, { exclude: a ? a.name : "" })), "One per line: address, prefix (host bits must be zero), range (10.0.10.101-10.0.10.199), !negated, key NAME, another ACL's name, any/none/localhost/localnets."),
field("Comment", comment)), err), [ field("Comment", comment)), err), [
{ label: "Cancel", value: null }, { label: "Cancel", value: null },
{ label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => { { label: a ? "Save" : "Create ACL", kind: "primary", onclick: async () => {
+7 -5
View File
@@ -3,6 +3,7 @@ import { h, mount, table, field, input, select, checkbox, toast, errorBox, badge
import { get, change, describeResult, draftQuery, can, api } from "../api.js"; import { get, change, describeResult, draftQuery, can, api } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
import { pendingBadge } from "./zones.js"; import { pendingBadge } from "./zones.js";
import { rangeHints } from "../aml.js";
// Well-known lists in Adblock format (they say which entries cover // Well-known lists in Adblock format (they say which entries cover
// subdomains). Sizes change; these are rough guides. // subdomains). Sizes change; these are rough guides.
@@ -355,7 +356,7 @@ async function del(path, what) {
function devicesPanel(groups, steps, views, autoViews, writable) { function devicesPanel(groups, steps, views, autoViews, writable) {
const live = steps.filter((s) => s.pending !== "delete"); const live = steps.filter((s) => s.pending !== "delete");
const name = input({ placeholder: "kids", class: "mono", style: { width: "9em" } }); const name = input({ placeholder: "kids", class: "mono", style: { width: "9em" } });
const members = input({ placeholder: "10.10.0.50 10.10.0.51 or an ACL", class: "mono" }); const members = input({ placeholder: "10.10.0.50 10.10.0.20-10.10.0.29 or an ACL", class: "mono" });
const step = stepSelect(steps, live.length ? live[0].name : "off", true); const step = stepSelect(steps, live.length ? live[0].name : "off", true);
const vp = viewPicker(views, autoViews); const vp = viewPicker(views, autoViews);
const err = h("div"); const err = h("div");
@@ -368,7 +369,7 @@ function devicesPanel(groups, steps, views, autoViews, writable) {
} catch (e) { mount(err, errorBox(e)); } } catch (e) { mount(err, errorBox(e)); }
} }, } },
h("div", { class: "f" }, h("label", null, "Group"), name), h("div", { class: "f" }, h("label", null, "Group"), name),
h("div", { class: "f grow" }, h("label", null, "Devices"), members), h("div", { class: "f grow" }, h("label", null, "Devices"), members, rangeHints(members)),
h("div", { class: "f" }, h("label", null, "Step"), step), h("div", { class: "f" }, h("label", null, "Step"), step),
vp.el, vp.el,
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add group"))); h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add group")));
@@ -389,9 +390,10 @@ function devicesPanel(groups, steps, views, autoViews, writable) {
].filter(Boolean), groups, { empty: "No groups: every device is at normal.", rowClass: (g) => g.pending ? "pending-" + g.pending : "" })); ].filter(Boolean), groups, { empty: "No groups: every device is at normal.", rowClass: (g) => g.pending ? "pending-" + g.pending : "" }));
} }
// splitMembers turns "a, b c" into a list. // splitMembers turns "a, b c" into a list; a range may have spaces
// around its dash.
function splitMembers(s) { function splitMembers(s) {
return s.split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean); return s.replace(/\s*-\s*/g, "-").split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean);
} }
// stepsLine shows the steps in order, with controls for the ones above // stepsLine shows the steps in order, with controls for the ones above
@@ -463,7 +465,7 @@ function editGroup(g, steps, views, autoViews) {
const vp = viewPicker(views, autoViews, ownViews(g)); const vp = viewPicker(views, autoViews, ownViews(g));
editDialog("Edit client group " + g.name, "/blocking/groups/" + g.id, [ editDialog("Edit client group " + g.name, "/blocking/groups/" + g.id, [
["Name", name], ["Name", name],
["Devices", members, "Addresses, networks (10.10.0.0/24) or ACLs, separated by spaces. A device can be in one group; a more specific address in another group wins."], ["Devices", h("div", null, members, rangeHints(members)), "Addresses, networks (10.10.0.0/24), ranges (10.10.0.20-10.10.0.49) or ACLs, separated by spaces. A device can be in one group; a more specific address in another group wins."],
["Step", step], ["Step", step],
vp.picker ? ["Views", vp.picker, "None ticked: every view with blocking."] : null, vp.picker ? ["Views", vp.picker, "None ticked: every view with blocking."] : null,
["Comment", comment], ["Comment", comment],
+1 -1
View File
@@ -25,7 +25,7 @@ function valueWidget(stmt, value, ctx) {
if (kind === "address_match_list") { if (kind === "address_match_list") {
const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, ""); const inner = (value || "").replace(/^\s*\{\s*/, "").replace(/\s*\}\s*$/, "");
const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" }); const t = textarea(inner.split(";").map((s) => s.trim()).filter(Boolean).join("\n"), { class: "mono", rows: 4, placeholder: "192.168.1.0/24\n!192.168.1.66\nkey xfer-key\nlocalhost" });
return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." }; return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address, prefix or range (10.0.10.101-10.0.10.199), !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." };
} }
if (kind === "raw") { if (kind === "raw") {
const t = codeArea(value || ""); const t = codeArea(value || "");
+1 -1
View File
@@ -128,7 +128,7 @@ async function viewForm(v, viewless) {
const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true); const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true);
const body = h("div", null, h("div", { class: "form-grid" }, const body = h("div", null, h("div", { class: "form-grid" },
field("Name", name), field("Name", name),
field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."), field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ranges (10.0.10.101-10.0.10.199), ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."),
field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."), field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."),
field("Recursive only", mro, "If yes, only recursive queries match this view."), field("Recursive only", mro, "If yes, only recursive queries match this view."),
field("Comment", comment), field("Comment", comment),
+5
View File
@@ -426,6 +426,11 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); } button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); }
button.chip:hover { background: var(--accent-soft); border-color: var(--accent); } button.chip:hover { background: var(--accent-soft); border-color: var(--accent); }
.aml-problems .msg { margin: 4px 0 0; padding: 3px 8px; } .aml-problems .msg { margin: 4px 0 0; padding: 3px 8px; }
.range-hints .msg { margin: 4px 0 0; padding: 3px 8px; }
.range-hint { margin-top: 4px; color: var(--muted); }
.range-hint summary { cursor: pointer; }
.range-hint .prefixes { display: flex; flex-wrap: wrap; gap: 2px 12px; margin: 2px 0 0 14px; }
.range-hint .prefixes span { white-space: nowrap; }
/* Zone name and record previews. */ /* Zone name and record previews. */
.segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); } .segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); }
+20
View File
@@ -239,6 +239,26 @@ contains "172.16.0.0/12"
run "acl delete in-use refused" 5 sh -c "$J option set allow-transfer '{ trusted; }' --zone adbraun.test && $J acl delete trusted" run "acl delete in-use refused" 5 sh -c "$J option set allow-transfer '{ trusted; }' --zone adbraun.test && $J acl delete trusted"
run "option unset" 0 $J option unset allow-transfer --zone adbraun.test run "option unset" 0 $J option unset allow-transfer --zone adbraun.test
run "acl delete" 0 $J acl delete trusted run "acl delete" 0 $J acl delete trusted
# Address ranges: kept as written, written to named.conf as prefixes.
run "acl with ranges" 0 $J acl create ranged "127.0.0.2 - 127.0.0.3" 2001:db8::100-2001:db8::1ff
run "acl list keeps the ranges" 0 $J acl list
contains "127.0.0.2-127.0.0.3"
run "named.conf has the prefixes" 0 grep -A4 'acl "ranged"' $E/etc-jarl/named.conf
contains "127.0.0.2/31;"
contains "2001:db8::100/120;"
contains "// 127.0.0.2-127.0.0.3"
run "a reversed range is refused (exit 6)" 6 $J acl create badrange 127.0.0.9-127.0.0.1
contains "start is after the end"
run "zone allow-query by the range" 0 $J option set allow-query '{ ranged; }' --zone adbraun.test
range_dig() { $DIG +tries=1 +time=3 -b "$1" -p $DNS @127.0.0.1 ns1.adbraun.test A | awk '/status:/ {sub(",", "", $6); print $6}'; }
for src in 127.0.0.2 127.0.0.3; do
if [ "$(range_dig $src)" = NOERROR ]; then ok "range: $src may query"; else bad "range: $src got $(range_dig $src)"; fi
done
for src in 127.0.0.1 127.0.0.4; do
if [ "$(range_dig $src)" = REFUSED ]; then ok "range: $src is refused"; else bad "range: $src got $(range_dig $src)"; fi
done
run "unset the zone's allow-query" 0 $J option unset allow-query --zone adbraun.test
run "acl delete ranged" 0 $J acl delete ranged
run "tsig create" 0 $J tsig create xfer-test run "tsig create" 0 $J tsig create xfer-test
contains "secret:" contains "secret:"
run "tsig rotate" 0 $J tsig rotate xfer-test run "tsig rotate" 0 $J tsig rotate xfer-test