Publish DHCP clients' names in DNS
router-webui leases-dns reads Kea's leases and publishes <name>.<domain> with reverse records in the running Unbound (router.dns.dynamicLeases). Names the configuration publishes are never taken over; a client's name is cut to its first label and kept inside the local domain; of two clients the newer lease wins. The DNS page switches it on and shows what is published and what was skipped; router-setup enables it with a domain. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
@@ -391,7 +391,7 @@ let
|
|||||||
+ (if $v6 == "y" then { ipv6: { pd: true } } else {} end))
|
+ (if $v6 == "y" then { ipv6: { pd: true } } else {} end))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+ (if $domain == "" then {} else { dns: { domain: $domain } } end))
|
+ (if $domain == "" then {} else { dns: { domain: $domain, dynamicLeases: true } } end))
|
||||||
}' > "$config/settings.json"
|
}' > "$config/settings.json"
|
||||||
|
|
||||||
if [ "$wan_type" = pppoe ]; then
|
if [ "$wan_type" = pppoe ]; then
|
||||||
|
|||||||
+20
-2
@@ -43,12 +43,23 @@ let
|
|||||||
lib.nameValuePair selfName selfAddrs
|
lib.nameValuePair selfName selfAddrs
|
||||||
);
|
);
|
||||||
|
|
||||||
|
hostRecords = lib.mapAttrsToList (n: a: lib.nameValuePair (fqdn n) a) dns.hosts;
|
||||||
|
reservationRecords = map (r: lib.nameValuePair (fqdn r.name) r.value) reservations;
|
||||||
records =
|
records =
|
||||||
selfRecord
|
selfRecord
|
||||||
++ lib.mapAttrsToList (n: a: lib.nameValuePair (fqdn n) a) dns.hosts
|
++ hostRecords
|
||||||
++ map (r: lib.nameValuePair (fqdn r.name) r.value) reservations
|
++ reservationRecords
|
||||||
++ lib.mapAttrsToList (n: a: lib.nameValuePair n a) dns.overrides;
|
++ lib.mapAttrsToList (n: a: lib.nameValuePair n a) dns.overrides;
|
||||||
|
|
||||||
|
# What the configuration publishes itself: dynamic names (the clients' host
|
||||||
|
# names, see dns.dynamicLeases) must never take one of these over.
|
||||||
|
staticNames = builtins.toJSON {
|
||||||
|
inherit domain;
|
||||||
|
names = map (r: r.name) (selfRecord ++ hostRecords ++ reservationRecords) ++ map (r: r.name) selfBare;
|
||||||
|
overrides = lib.attrNames dns.overrides;
|
||||||
|
forwardZones = lib.attrNames dns.forwardZones;
|
||||||
|
};
|
||||||
|
|
||||||
# Unbound reads these as one quoted argument.
|
# Unbound reads these as one quoted argument.
|
||||||
localData = lib.concatMap (r: map (a: ''"${r.name}. ${rrOf a} ${a}"'') r.value) (records ++ selfBare);
|
localData = lib.concatMap (r: map (a: ''"${r.name}. ${rrOf a} ${a}"'') r.value) (records ++ selfBare);
|
||||||
# Reverse records for hosts and reservations (not for wildcard overrides).
|
# Reverse records for hosts and reservations (not for wildcard overrides).
|
||||||
@@ -67,8 +78,15 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
|
environment.etc."router/dns-static.json".text = staticNames;
|
||||||
|
warnings = lib.optional (dns.dynamicLeases && domain == null) ''
|
||||||
|
router.dns.dynamicLeases has no effect without router.dns.domain: the clients' names need a domain to live in.
|
||||||
|
'';
|
||||||
|
|
||||||
services.unbound = {
|
services.unbound = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
# What router-webui leases-dns publishes the clients' names through.
|
||||||
|
localControlSocketPath = lib.mkIf dns.dynamicLeases "/run/unbound/unbound.ctl";
|
||||||
settings = {
|
settings = {
|
||||||
server = {
|
server = {
|
||||||
interface = [
|
interface = [
|
||||||
|
|||||||
@@ -528,6 +528,21 @@ in
|
|||||||
description = "Like `dns.hosts`, but the name and everything below it answer with these addresses (wildcard).";
|
description = "Like `dns.hosts`, but the name and everything below it answer with these addresses (wildcard).";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
dns.dynamicLeases = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Publish the host names of DHCP clients as `<name>.<dns.domain>` (with
|
||||||
|
reverse records), updated as leases come and go. Needs `dns.domain`.
|
||||||
|
|
||||||
|
A client picks its own name, so only its first label is used, only
|
||||||
|
under the local domain, and a name that is configured here (`dns.hosts`,
|
||||||
|
a reservation, the router itself, a wildcard or a forward zone it falls
|
||||||
|
under) is never taken over. Of two clients claiming one name the newer
|
||||||
|
lease wins. Run by `router-webui leases-dns` (the settings module).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
dns.forwardZones = mkOption {
|
dns.forwardZones = mkOption {
|
||||||
type = types.attrsOf (types.listOf types.str);
|
type = types.attrsOf (types.listOf types.str);
|
||||||
default = { };
|
default = { };
|
||||||
|
|||||||
@@ -190,6 +190,37 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# The DHCP clients' names in DNS (router.dns.dynamicLeases): reads Kea's
|
||||||
|
# leases and publishes them in the running Unbound.
|
||||||
|
systemd.services.router-leases-dns = lib.mkIf (config.router.enable && config.router.dns.dynamicLeases) {
|
||||||
|
description = "Publish DHCP clients' names in DNS";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [
|
||||||
|
"unbound.service"
|
||||||
|
"kea-dhcp4-server.service"
|
||||||
|
];
|
||||||
|
path = [ config.services.unbound.package ];
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = "${lib.getExe webui} leases-dns -leases /var/lib/kea/dhcp4.leases -static /etc/router/dns-static.json -socket ${config.services.unbound.localControlSocketPath} -run /run/router-leases-dns";
|
||||||
|
RuntimeDirectory = "router-leases-dns";
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = 5;
|
||||||
|
# It reads leases and talks to Unbound's socket; nothing else.
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
PrivateNetwork = true;
|
||||||
|
PrivateDevices = true;
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
RestrictAddressFamilies = [ "AF_UNIX" ];
|
||||||
|
LockPersonality = true;
|
||||||
|
MemoryDenyWriteExecute = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# Rebuilding on the device must work without internet (the uplink may be
|
# Rebuilding on the device must work without internet (the uplink may be
|
||||||
# what is being configured). What a changed configuration rebuilds is all
|
# what is being configured). What a changed configuration rebuilds is all
|
||||||
# generated by NixOS, but those builds need these packages; found by
|
# generated by NixOS, but those builds need these packages; found by
|
||||||
|
|||||||
@@ -446,6 +446,7 @@ type Lease struct {
|
|||||||
Address string
|
Address string
|
||||||
MAC string
|
MAC string
|
||||||
Hostname string
|
Hostname string
|
||||||
|
Start time.Time // when it was handed out or last renewed
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
LAN string
|
LAN string
|
||||||
}
|
}
|
||||||
@@ -514,6 +515,7 @@ func parseLeases(r io.Reader, latest map[string]Lease, now time.Time) {
|
|||||||
MAC: get(rec, "hwaddr"),
|
MAC: get(rec, "hwaddr"),
|
||||||
Hostname: strings.ReplaceAll(get(rec, "hostname"), ",", ","),
|
Hostname: strings.ReplaceAll(get(rec, "hostname"), ",", ","),
|
||||||
Expires: time.Unix(expire, 0),
|
Expires: time.Unix(expire, 0),
|
||||||
|
Start: time.Unix(expire-lifetime, 0),
|
||||||
}
|
}
|
||||||
if lifetime == 0 || (state != "" && state != "0") {
|
if lifetime == 0 || (state != "" && state != "0") {
|
||||||
l.Expires = time.Time{} // released, declined or reclaimed
|
l.Expires = time.Time{} // released, declined or reclaimed
|
||||||
|
|||||||
@@ -170,6 +170,32 @@ type dnsData struct {
|
|||||||
Errors FormErrors
|
Errors FormErrors
|
||||||
Domain string
|
Domain string
|
||||||
Auto []string // published without a record here
|
Auto []string // published without a record here
|
||||||
|
Dynamic bool // router.dns.dynamicLeases
|
||||||
|
Dyn *dynStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Settings) dynamicLeases() bool {
|
||||||
|
router, _ := s["router"].(map[string]any)
|
||||||
|
dns, _ := router["dns"].(map[string]any)
|
||||||
|
return boolean(dns, false, "dynamicLeases")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Settings) setDynamicLeases(on bool) {
|
||||||
|
dns := obj(obj(s, "router"), "dns")
|
||||||
|
if on {
|
||||||
|
dns["dynamicLeases"] = true
|
||||||
|
} else {
|
||||||
|
delete(dns, "dynamicLeases")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnsView is the page's data for settings s.
|
||||||
|
func (a *app) dnsView(s Settings, records, zones []DNSRecord, errs FormErrors) dnsData {
|
||||||
|
d := dnsData{Records: records, Zones: zones, Errors: errs, Domain: s.domain(), Auto: s.dnsAuto(), Dynamic: s.dynamicLeases()}
|
||||||
|
if st, ok := readDynStatus(a.sys.leasesDNSDir()); ok {
|
||||||
|
d.Dyn = &st
|
||||||
|
}
|
||||||
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Settings) dnsAuto() []string {
|
func (s Settings) dnsAuto() []string {
|
||||||
@@ -198,8 +224,7 @@ func (a *app) dnsPage(w http.ResponseWriter, r *http.Request, sess session) {
|
|||||||
a.render(w, "dns", p)
|
a.render(w, "dns", p)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.Data = dnsData{Records: append(s.dnsRecords(), DNSRecord{}), Zones: append(s.forwardZones(), DNSRecord{}),
|
p.Data = a.dnsView(s, append(s.dnsRecords(), DNSRecord{}), append(s.forwardZones(), DNSRecord{}), nil)
|
||||||
Domain: s.domain(), Auto: s.dnsAuto()}
|
|
||||||
a.render(w, "dns", p)
|
a.render(w, "dns", p)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,15 +247,17 @@ func (a *app) dns(w http.ResponseWriter, r *http.Request, sess session) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
records, zones := dnsRows(r, "rec"), dnsRows(r, "zone")
|
records, zones := dnsRows(r, "rec"), dnsRows(r, "zone")
|
||||||
|
dynamic := r.PostFormValue("dynamic") == "on"
|
||||||
if err := s.applyDNS(records, zones); err != nil {
|
if err := s.applyDNS(records, zones); err != nil {
|
||||||
var fe FormErrors
|
var fe FormErrors
|
||||||
errors.As(err, &fe)
|
errors.As(err, &fe)
|
||||||
p.Error = "Please correct the marked fields."
|
p.Error = "Please correct the marked fields."
|
||||||
p.Data = dnsData{Records: records, Zones: zones, Errors: fe, Domain: s.domain(), Auto: s.dnsAuto()}
|
p.Data = a.dnsView(s, records, zones, fe)
|
||||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
a.render(w, "dns", p)
|
a.render(w, "dns", p)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.setDynamicLeases(dynamic)
|
||||||
if err := s.save(a.sys.settingsPath()); err != nil {
|
if err := s.save(a.sys.settingsPath()); err != nil {
|
||||||
p.Error = "Saving: " + err.Error()
|
p.Error = "Saving: " + err.Error()
|
||||||
a.render(w, "dns", p)
|
a.render(w, "dns", p)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func withDomain(t *testing.T) Settings {
|
func withDomain(t *testing.T) Settings {
|
||||||
@@ -145,3 +146,53 @@ func TestDNSPage(t *testing.T) {
|
|||||||
t.Error("the network form dropped the DNS records")
|
t.Error("the network form dropped the DNS records")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDynamicLeasesOnThePage(t *testing.T) {
|
||||||
|
a, sys := newTestApp(t)
|
||||||
|
a.sys.LeasesDNSDir = t.TempDir()
|
||||||
|
h := a.routes()
|
||||||
|
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
|
||||||
|
token := func() string {
|
||||||
|
body := do(h, "GET", "/dns", nil, cookie).Body.String()
|
||||||
|
return regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||||
|
}
|
||||||
|
save := func(extra ...string) {
|
||||||
|
form := url.Values{"csrf": {token()}, "recs": {"0"}, "zones": {"0"}}
|
||||||
|
for i := 0; i < len(extra); i += 2 {
|
||||||
|
form.Set(extra[i], extra[i+1])
|
||||||
|
}
|
||||||
|
if rec := do(h, "POST", "/dns", form, cookie); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save("dynamic", "on")
|
||||||
|
if s, _ := loadSettings(sys.settingsPath()); !s.dynamicLeases() {
|
||||||
|
t.Fatal("not switched on")
|
||||||
|
}
|
||||||
|
body := do(h, "GET", "/dns", nil, cookie).Body.String()
|
||||||
|
if !strings.Contains(body, `name="dynamic" checked`) || !strings.Contains(body, "apply the settings so the service starts") {
|
||||||
|
t.Errorf("the page after switching on:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the running service reports is shown.
|
||||||
|
writeDynStatus(a.sys.LeasesDNSDir, dynStatus{
|
||||||
|
Published: []DynRecord{{Name: "phone.home.example.org", Address: "192.168.1.101"}},
|
||||||
|
Skipped: []DynSkip{{Hostname: "nas", Address: "192.168.1.102", Reason: "the configuration has a record with this name"}},
|
||||||
|
Updated: time.Now(),
|
||||||
|
})
|
||||||
|
body = do(h, "GET", "/dns", nil, cookie).Body.String()
|
||||||
|
for _, want := range []string{"phone.home.example.org", "192.168.1.101", "the configuration has a record with this name"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("status lacks %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save() // unticked
|
||||||
|
if s, _ := loadSettings(sys.settingsPath()); s.dynamicLeases() {
|
||||||
|
t.Error("not switched off")
|
||||||
|
}
|
||||||
|
if _, ok := obj(obj(mustLoad(sys), "router"), "dns")["dynamicLeases"]; ok {
|
||||||
|
t.Error("the key stays after switching off")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The DHCP clients' names in DNS. Kea hands out the leases and Unbound
|
||||||
|
// answers, and nothing connects them, so this does: `router-webui leases-dns`
|
||||||
|
// reads Kea's lease file and publishes `<hostname>.<domain>` (and the reverse
|
||||||
|
// record) in the running Unbound through unbound-control.
|
||||||
|
//
|
||||||
|
// A client chooses its own host name, so it is not trusted: only the first
|
||||||
|
// label is used, only under the router's local domain, and a name that the
|
||||||
|
// configuration publishes (hosts, DHCP reservations, the router itself, a
|
||||||
|
// wildcard or a forward zone it falls under) is never taken over. Of two
|
||||||
|
// clients claiming one name the newer lease wins.
|
||||||
|
|
||||||
|
// staticNames is what the running configuration publishes itself
|
||||||
|
// (/etc/router/dns-static.json, written by the router module).
|
||||||
|
type staticNames struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Names []string `json:"names"` // full names with records
|
||||||
|
Overrides []string `json:"overrides"` // wildcard bases: the name and everything below
|
||||||
|
ForwardZones []string `json:"forwardZones"` // answered by other resolvers
|
||||||
|
}
|
||||||
|
|
||||||
|
// DynRecord is a published client name.
|
||||||
|
type DynRecord struct {
|
||||||
|
Name string `json:"name"` // without the trailing dot
|
||||||
|
Address string `json:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DynSkip is a client that was not published, and why.
|
||||||
|
type DynSkip struct {
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var labelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||||
|
|
||||||
|
func under(name, zone string) bool {
|
||||||
|
return name == zone || strings.HasSuffix(name, "."+zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// planDynamic decides which leases are published.
|
||||||
|
func planDynamic(leases []Lease, st staticNames, now time.Time) (map[string]DynRecord, []DynSkip) {
|
||||||
|
want := map[string]DynRecord{}
|
||||||
|
var skipped []DynSkip
|
||||||
|
if st.Domain == "" {
|
||||||
|
return want, skipped
|
||||||
|
}
|
||||||
|
domain := strings.ToLower(strings.TrimSuffix(st.Domain, "."))
|
||||||
|
static := map[string]bool{}
|
||||||
|
for _, n := range st.Names {
|
||||||
|
static[strings.ToLower(strings.TrimSuffix(n, "."))] = true
|
||||||
|
}
|
||||||
|
var active []Lease
|
||||||
|
for _, l := range leases {
|
||||||
|
if l.Hostname != "" && l.Expires.After(now) {
|
||||||
|
active = append(active, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Newest lease first: it wins a name.
|
||||||
|
slices.SortStableFunc(active, func(a, b Lease) int {
|
||||||
|
if c := b.Start.Compare(a.Start); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return strings.Compare(a.Address, b.Address)
|
||||||
|
})
|
||||||
|
for _, l := range active {
|
||||||
|
label, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(l.Hostname)), ".")
|
||||||
|
skip := func(reason string) {
|
||||||
|
skipped = append(skipped, DynSkip{Hostname: l.Hostname, Address: l.Address, Reason: reason})
|
||||||
|
}
|
||||||
|
if !labelRe.MatchString(label) {
|
||||||
|
skip("not a usable host name")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a, err := netip.ParseAddr(l.Address); err != nil || !a.Is4() {
|
||||||
|
skip("not an IPv4 address")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := label + "." + domain
|
||||||
|
switch {
|
||||||
|
case static[name]:
|
||||||
|
skip("the configuration has a record with this name")
|
||||||
|
case slices.ContainsFunc(st.Overrides, func(z string) bool { return under(name, strings.ToLower(strings.TrimSuffix(z, "."))) }):
|
||||||
|
skip("below a wildcard record")
|
||||||
|
case slices.ContainsFunc(st.ForwardZones, func(z string) bool { return under(name, strings.ToLower(strings.TrimSuffix(z, "."))) }):
|
||||||
|
skip("below a forwarded zone")
|
||||||
|
default:
|
||||||
|
if other, ok := want[name]; ok {
|
||||||
|
skip("a newer lease (" + other.Address + ") has this name")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want[name] = DynRecord{Name: name, Address: l.Address}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return want, skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseName is the in-addr.arpa name of an IPv4 address.
|
||||||
|
func reverseName(addr string) string {
|
||||||
|
a, err := netip.ParseAddr(addr)
|
||||||
|
if err != nil || !a.Is4() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
b := a.As4()
|
||||||
|
return fmt.Sprintf("%d.%d.%d.%d.in-addr.arpa", b[3], b[2], b[1], b[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// controller runs unbound-control commands.
|
||||||
|
type controller interface {
|
||||||
|
run(args ...string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type unboundControl struct{ socket, config string }
|
||||||
|
|
||||||
|
func (u unboundControl) run(args ...string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
pre := []string{"-s", u.socket}
|
||||||
|
if u.config != "" { // else unbound-control reads /etc/unbound/unbound.conf
|
||||||
|
pre = append(pre, "-c", u.config)
|
||||||
|
}
|
||||||
|
out, err := exec.CommandContext(ctx, "unbound-control", append(pre, args...)...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), err
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynTTL = "300"
|
||||||
|
|
||||||
|
func publish(ctl controller, r DynRecord) error {
|
||||||
|
if _, err := ctl.run("local_data", r.Name+".", dynTTL, "IN", "A", r.Address); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rev := reverseName(r.Address); rev != "" {
|
||||||
|
_, err := ctl.run("local_data", rev+".", dynTTL, "IN", "PTR", r.Name+".")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unpublish(ctl controller, r DynRecord) error {
|
||||||
|
var first error
|
||||||
|
for _, n := range []string{r.Name, reverseName(r.Address)} {
|
||||||
|
if n == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := ctl.run("local_data_remove", n+"."); err != nil && first == nil {
|
||||||
|
first = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncDynamic brings Unbound from what was published to what is wanted. With
|
||||||
|
// fresh set (a new Unbound, or a restarted daemon) everything published
|
||||||
|
// before is removed first and the wanted records are all added.
|
||||||
|
func syncDynamic(ctl controller, published, want map[string]DynRecord, fresh bool) (map[string]DynRecord, error) {
|
||||||
|
now := map[string]DynRecord{}
|
||||||
|
var firstErr error
|
||||||
|
note := func(err error) {
|
||||||
|
if err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, r := range published {
|
||||||
|
if w, ok := want[name]; fresh || !ok || w.Address != r.Address {
|
||||||
|
note(unpublish(ctl, r))
|
||||||
|
} else {
|
||||||
|
now[name] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, w := range want {
|
||||||
|
if _, ok := now[name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := publish(ctl, w); err != nil {
|
||||||
|
note(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
now[name] = w
|
||||||
|
}
|
||||||
|
return now, firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// dynStatus is what the daemon leaves for the web UI.
|
||||||
|
type dynStatus struct {
|
||||||
|
Published []DynRecord `json:"published"`
|
||||||
|
Skipped []DynSkip `json:"skipped"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDynStatus(dir string) (dynStatus, bool) {
|
||||||
|
var st dynStatus
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "status.json"))
|
||||||
|
if err != nil || json.Unmarshal(data, &st) != nil {
|
||||||
|
return st, false
|
||||||
|
}
|
||||||
|
return st, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDynStatus(dir string, st dynStatus) {
|
||||||
|
data, _ := json.Marshal(st)
|
||||||
|
if err := writeFileAtomic(filepath.Join(dir, "status.json"), data, 0o644); err != nil {
|
||||||
|
log.Printf("status: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordsOf(m map[string]DynRecord) []DynRecord {
|
||||||
|
out := make([]DynRecord, 0, len(m))
|
||||||
|
for _, r := range m {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b DynRecord) int { return strings.Compare(a.Name, b.Name) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadStatic(path string) staticNames {
|
||||||
|
var st staticNames
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
json.Unmarshal(data, &st)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// unboundPID tells a restarted Unbound (which lost what was published) from
|
||||||
|
// the same one.
|
||||||
|
func unboundPID(ctl controller) (string, error) {
|
||||||
|
out, err := ctl.run("status")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return parseUnboundPID(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pidRe = regexp.MustCompile(`\(pid ([0-9]+)\)`)
|
||||||
|
|
||||||
|
// parseUnboundPID reads the last line of `unbound-control status`:
|
||||||
|
// "unbound (pid 1234) is running...".
|
||||||
|
func parseUnboundPID(status string) (string, error) {
|
||||||
|
if m := pidRe.FindStringSubmatch(status); m != nil {
|
||||||
|
return m[1], nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no pid in unbound-control status")
|
||||||
|
}
|
||||||
|
|
||||||
|
// leasesDNS is `router-webui leases-dns`.
|
||||||
|
func leasesDNS(args []string) {
|
||||||
|
fs := flag.NewFlagSet("leases-dns", flag.ExitOnError)
|
||||||
|
leases := fs.String("leases", "/var/lib/kea/dhcp4.leases", "Kea's DHCPv4 lease file")
|
||||||
|
staticPath := fs.String("static", "/etc/router/dns-static.json", "the names the configuration publishes")
|
||||||
|
socket := fs.String("socket", "/run/unbound/unbound.ctl", "Unbound's control socket")
|
||||||
|
config := fs.String("unbound-config", "", "Unbound's config file (default: unbound-control's own)")
|
||||||
|
run := fs.String("run", "/run/router-leases-dns", "directory for the status the web UI shows")
|
||||||
|
interval := fs.Duration("interval", 5*time.Second, "how often the leases are read")
|
||||||
|
once := fs.Bool("once", false, "do one round and exit")
|
||||||
|
fs.Parse(args)
|
||||||
|
|
||||||
|
ctl := unboundControl{socket: *socket, config: *config}
|
||||||
|
if err := os.MkdirAll(*run, 0o755); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
// What a previous run published is removed first: the daemon may have
|
||||||
|
// been restarted while Unbound kept its data.
|
||||||
|
published := map[string]DynRecord{}
|
||||||
|
if st, ok := readDynStatus(*run); ok {
|
||||||
|
for _, r := range st.Published {
|
||||||
|
published[r.Name] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pid, fresh := "", true
|
||||||
|
var lastErr string
|
||||||
|
for {
|
||||||
|
st := loadStatic(*staticPath)
|
||||||
|
want, skipped := planDynamic(readLeasesAt(*leases, time.Now()), st, time.Now())
|
||||||
|
var err error
|
||||||
|
if cur, perr := unboundPID(ctl); perr != nil {
|
||||||
|
err = perr
|
||||||
|
} else {
|
||||||
|
if cur != pid {
|
||||||
|
fresh = true // a new Unbound has none of it
|
||||||
|
}
|
||||||
|
var now map[string]DynRecord
|
||||||
|
if now, err = syncDynamic(ctl, published, want, fresh); err == nil {
|
||||||
|
fresh, pid = false, cur
|
||||||
|
}
|
||||||
|
published = now
|
||||||
|
}
|
||||||
|
status := dynStatus{Published: recordsOf(published), Skipped: skipped, Updated: time.Now()}
|
||||||
|
if err != nil {
|
||||||
|
status.Error = err.Error()
|
||||||
|
if status.Error != lastErr {
|
||||||
|
log.Printf("unbound-control: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastErr = status.Error
|
||||||
|
writeDynStatus(*run, status)
|
||||||
|
if *once {
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Leave nothing behind: the names would outlive the daemon.
|
||||||
|
syncDynamic(ctl, published, nil, false)
|
||||||
|
writeDynStatus(*run, dynStatus{Updated: time.Now()})
|
||||||
|
return
|
||||||
|
case <-time.After(*interval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLeasesAt reads the active leases of the lease file (without the LAN
|
||||||
|
// names the dashboard adds).
|
||||||
|
func readLeasesAt(path string, now time.Time) []Lease {
|
||||||
|
return readLeases(path, nil, now)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var t0 = time.Unix(1_800_000_000, 0)
|
||||||
|
|
||||||
|
func lease(host, addr string, startAgo time.Duration) Lease {
|
||||||
|
return Lease{Hostname: host, Address: addr, Start: t0.Add(-startAgo), Expires: t0.Add(time.Hour - startAgo)}
|
||||||
|
}
|
||||||
|
|
||||||
|
var testStatic = staticNames{
|
||||||
|
Domain: "home.example.org",
|
||||||
|
Names: []string{"router.home.example.org", "nas.home.example.org"},
|
||||||
|
Overrides: []string{"apps.home.example.org"},
|
||||||
|
ForwardZones: []string{"branch.home.example.org"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanDynamic(t *testing.T) {
|
||||||
|
leases := []Lease{
|
||||||
|
lease("Phone", "192.168.1.101", time.Minute),
|
||||||
|
lease("laptop.evil.example", "192.168.1.102", time.Minute), // only the first label
|
||||||
|
lease("nas", "192.168.1.103", time.Minute), // a static name
|
||||||
|
lease("router", "192.168.1.104", time.Minute), // the router itself
|
||||||
|
lease("web.apps", "192.168.1.105", time.Minute), // first label "web": fine
|
||||||
|
lease("x_y", "192.168.1.106", time.Minute), // not a host name
|
||||||
|
lease("", "192.168.1.107", time.Minute), // none sent
|
||||||
|
lease("tv", "192.168.1.108", 2*time.Hour), // expired (started 2h ago)
|
||||||
|
lease("printer", "192.168.1.110", 10*time.Minute),
|
||||||
|
lease("printer", "192.168.1.111", time.Minute), // newer: wins
|
||||||
|
}
|
||||||
|
want, skipped := planDynamic(leases, testStatic, t0)
|
||||||
|
var names []string
|
||||||
|
for n := range want {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
slices.Sort(names)
|
||||||
|
if strings.Join(names, " ") != "laptop.home.example.org phone.home.example.org printer.home.example.org web.home.example.org" {
|
||||||
|
t.Errorf("published = %v", names)
|
||||||
|
}
|
||||||
|
if want["printer.home.example.org"].Address != "192.168.1.111" {
|
||||||
|
t.Errorf("the newer lease did not win: %+v", want["printer.home.example.org"])
|
||||||
|
}
|
||||||
|
reasons := map[string]string{}
|
||||||
|
for _, s := range skipped {
|
||||||
|
reasons[s.Address] = s.Reason
|
||||||
|
}
|
||||||
|
for addr, part := range map[string]string{"192.168.1.103": "configuration", "192.168.1.104": "configuration",
|
||||||
|
"192.168.1.106": "not a usable", "192.168.1.110": "newer lease"} {
|
||||||
|
if !strings.Contains(reasons[addr], part) {
|
||||||
|
t.Errorf("%s: skipped for %q, want %q", addr, reasons[addr], part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Names below a wildcard or a forward zone are not taken.
|
||||||
|
_, skipped = planDynamic([]Lease{lease("a", "10.0.0.1", 0)}, staticNames{Domain: "apps.home.example.org", Overrides: []string{"apps.home.example.org"}}, t0)
|
||||||
|
if len(skipped) != 1 || !strings.Contains(skipped[0].Reason, "wildcard") {
|
||||||
|
t.Errorf("wildcard: %+v", skipped)
|
||||||
|
}
|
||||||
|
_, skipped = planDynamic([]Lease{lease("a", "10.0.0.1", 0)}, staticNames{Domain: "b.example.org", ForwardZones: []string{"b.example.org"}}, t0)
|
||||||
|
if len(skipped) != 1 || !strings.Contains(skipped[0].Reason, "forwarded") {
|
||||||
|
t.Errorf("forward zone: %+v", skipped)
|
||||||
|
}
|
||||||
|
// Without a domain nothing is published.
|
||||||
|
if w, _ := planDynamic(leases, staticNames{}, t0); len(w) != 0 {
|
||||||
|
t.Error("published without a domain")
|
||||||
|
}
|
||||||
|
// Not IPv4.
|
||||||
|
if w, s := planDynamic([]Lease{lease("v6", "fd00::1", 0)}, testStatic, t0); len(w) != 0 || len(s) != 1 {
|
||||||
|
t.Errorf("IPv6 lease: %v %v", w, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReverseName(t *testing.T) {
|
||||||
|
if got := reverseName("192.168.1.101"); got != "101.1.168.192.in-addr.arpa" {
|
||||||
|
t.Errorf("reverse = %q", got)
|
||||||
|
}
|
||||||
|
if reverseName("fd00::1") != "" || reverseName("x") != "" {
|
||||||
|
t.Error("a non-IPv4 address has no reverse name here")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeCtl struct {
|
||||||
|
cmds []string
|
||||||
|
fail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCtl) run(args ...string) (string, error) {
|
||||||
|
cmd := strings.Join(args, " ")
|
||||||
|
f.cmds = append(f.cmds, cmd)
|
||||||
|
if f.fail != "" && strings.Contains(cmd, f.fail) {
|
||||||
|
return "", errors.New("boom")
|
||||||
|
}
|
||||||
|
return "ok", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncDynamic(t *testing.T) {
|
||||||
|
a := DynRecord{Name: "a.home.example.org", Address: "192.168.1.101"}
|
||||||
|
b := DynRecord{Name: "b.home.example.org", Address: "192.168.1.102"}
|
||||||
|
ctl := &fakeCtl{}
|
||||||
|
got, err := syncDynamic(ctl, nil, map[string]DynRecord{a.Name: a, b.Name: b}, true)
|
||||||
|
if err != nil || len(got) != 2 {
|
||||||
|
t.Fatalf("first sync: %v %v", got, err)
|
||||||
|
}
|
||||||
|
if !slices.Contains(ctl.cmds, "local_data a.home.example.org. 300 IN A 192.168.1.101") ||
|
||||||
|
!slices.Contains(ctl.cmds, "local_data 101.1.168.192.in-addr.arpa. 300 IN PTR a.home.example.org.") {
|
||||||
|
t.Errorf("commands = %v", ctl.cmds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing changed: nothing is sent.
|
||||||
|
ctl.cmds = nil
|
||||||
|
if _, err := syncDynamic(ctl, got, map[string]DynRecord{a.Name: a, b.Name: b}, false); err != nil || len(ctl.cmds) != 0 {
|
||||||
|
t.Errorf("no change sent %v (%v)", ctl.cmds, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// b went away, a moved: b is removed, a replaced.
|
||||||
|
a2 := DynRecord{Name: a.Name, Address: "192.168.1.150"}
|
||||||
|
got, err = syncDynamic(ctl, got, map[string]DynRecord{a.Name: a2}, false)
|
||||||
|
if err != nil || len(got) != 1 || got[a.Name].Address != "192.168.1.150" {
|
||||||
|
t.Fatalf("second sync: %v %v", got, err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"local_data_remove b.home.example.org.", "local_data_remove 102.1.168.192.in-addr.arpa.",
|
||||||
|
"local_data_remove a.home.example.org.", "local_data a.home.example.org. 300 IN A 192.168.1.150"} {
|
||||||
|
if !slices.Contains(ctl.cmds, want) {
|
||||||
|
t.Errorf("missing %q in %v", want, ctl.cmds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh Unbound (or daemon): everything is removed, then added again.
|
||||||
|
ctl.cmds = nil
|
||||||
|
if _, err := syncDynamic(ctl, got, got, true); err != nil || len(ctl.cmds) != 4 {
|
||||||
|
t.Errorf("fresh sync: %v %v", ctl.cmds, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failing add is not recorded as published, so it is retried.
|
||||||
|
ctl = &fakeCtl{fail: "local_data a."}
|
||||||
|
got, err = syncDynamic(ctl, nil, map[string]DynRecord{a.Name: a, b.Name: b}, true)
|
||||||
|
if err == nil || len(got) != 1 || got[b.Name].Name == "" {
|
||||||
|
t.Errorf("failing add: %v %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stopping removes everything.
|
||||||
|
ctl = &fakeCtl{}
|
||||||
|
if got, _ := syncDynamic(ctl, map[string]DynRecord{a.Name: a, b.Name: b}, nil, false); len(got) != 0 || len(ctl.cmds) != 4 {
|
||||||
|
t.Errorf("stop: %v %v", got, ctl.cmds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUnboundPID(t *testing.T) {
|
||||||
|
out := "version: 1.26.1\nverbosity: 1\nthreads: 1\nuptime: 0 seconds\nunbound (pid 1458081) is running...\n"
|
||||||
|
if pid, err := parseUnboundPID(out); err != nil || pid != "1458081" {
|
||||||
|
t.Errorf("pid = %q %v", pid, err)
|
||||||
|
}
|
||||||
|
if _, err := parseUnboundPID("error: connection refused"); err == nil {
|
||||||
|
t.Error("a pid out of nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeaseStartIsRead(t *testing.T) {
|
||||||
|
csv := "address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id\n" +
|
||||||
|
"192.168.1.101,aa:bb:cc:00:00:01,,3600,4102444800,1,0,0,phone,0,,0\n"
|
||||||
|
latest := map[string]Lease{}
|
||||||
|
parseLeases(strings.NewReader(csv), latest, time.Unix(1_800_000_000, 0))
|
||||||
|
l := latest["192.168.1.101"]
|
||||||
|
if l.Expires.Unix() != 4102444800 || l.Start.Unix() != 4102444800-3600 {
|
||||||
|
t.Errorf("lease = %+v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
// router-webui [-listen :443] [-config DIR] [-state DIR] [-leases FILE]
|
// router-webui [-listen :443] [-config DIR] [-state DIR] [-leases FILE]
|
||||||
// router-webui passwd FILE (reads the new password from stdin)
|
// router-webui passwd FILE (reads the new password from stdin)
|
||||||
// router-webui unbundle FILE DIR (stages an update bundle; FILE - is stdin)
|
// router-webui unbundle FILE DIR (stages an update bundle; FILE - is stdin)
|
||||||
|
// router-webui leases-dns (publishes DHCP clients' names in Unbound)
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -51,6 +52,10 @@ var embedded embed.FS
|
|||||||
var assets fs.FS = embedded
|
var assets fs.FS = embedded
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "leases-dns" {
|
||||||
|
leasesDNS(os.Args[2:])
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(os.Args) == 3 && os.Args[1] == "passwd" {
|
if len(os.Args) == 3 && os.Args[1] == "passwd" {
|
||||||
if err := setPassword(os.Args[2]); err != nil {
|
if err := setPassword(os.Args[2]); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ type System struct {
|
|||||||
StateDir string // /var/lib/router: secrets, UI state
|
StateDir string // /var/lib/router: secrets, UI state
|
||||||
// ReleaseFile describes the installed nixos-router; /etc/router-release if empty.
|
// ReleaseFile describes the installed nixos-router; /etc/router-release if empty.
|
||||||
ReleaseFile string
|
ReleaseFile string
|
||||||
|
// LeasesDNSDir is where leases-dns leaves its status; /run/router-leases-dns if empty.
|
||||||
|
LeasesDNSDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s System) leasesDNSDir() string {
|
||||||
|
if s.LeasesDNSDir != "" {
|
||||||
|
return s.LeasesDNSDir
|
||||||
|
}
|
||||||
|
return "/run/router-leases-dns"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") }
|
func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") }
|
||||||
|
|||||||
@@ -42,9 +42,41 @@
|
|||||||
</table>
|
</table>
|
||||||
<p class="hint">Names below these domains are asked from these resolvers (for example across a tunnel), not from the internet.</p>
|
<p class="hint">Names below these domains are asked from these resolvers (for example across a tunnel), not from the internet.</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>DHCP clients</legend>
|
||||||
|
<label><input type="checkbox" name="dynamic" {{if .Dynamic}}checked{{end}}> Publish the names of DHCP clients as <code>name.{{if .Domain}}{{.Domain}}{{else}}domain{{end}}</code></label>
|
||||||
|
{{if and .Dynamic (not .Domain)}}<p class="field-error">This needs a local domain (Network page).</p>{{end}}
|
||||||
|
<p class="hint">A client chooses its own name, so only its first label is used, only in your local domain, and a name
|
||||||
|
that is configured here (a record above, a reservation, the router itself, a wildcard) is never taken over.
|
||||||
|
Of two clients with one name the newer lease wins. Takes effect when applied.</p>
|
||||||
|
</fieldset>
|
||||||
<button>Save</button>
|
<button>Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{{if .Dyn}}
|
||||||
|
<section>
|
||||||
|
<h2>DHCP clients in DNS</h2>
|
||||||
|
{{if .Dyn.Error}}<p class="field-error">The last update failed: {{.Dyn.Error}}</p>{{end}}
|
||||||
|
{{if .Dyn.Published}}
|
||||||
|
<table>
|
||||||
|
<tr><th>Name</th><th>Address</th></tr>
|
||||||
|
{{range .Dyn.Published}}<tr><td>{{.Name}}</td><td>{{.Address}}</td></tr>{{end}}
|
||||||
|
</table>
|
||||||
|
{{else}}<p class="muted">No client names are published at the moment.</p>{{end}}
|
||||||
|
{{if .Dyn.Skipped}}
|
||||||
|
<p class="hint">Not published:</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Client name</th><th>Address</th><th>Why</th></tr>
|
||||||
|
{{range .Dyn.Skipped}}<tr><td>{{.Hostname}}</td><td>{{.Address}}</td><td>{{.Reason}}</td></tr>{{end}}
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
<p class="hint">As of {{ago .Dyn.Updated}} ago, from the running configuration.</p>
|
||||||
|
</section>
|
||||||
|
{{else if .Dynamic}}
|
||||||
|
<p class="hint">Client names are not published yet: apply the settings so the service starts.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .Auto}}
|
{{if .Auto}}
|
||||||
<section>
|
<section>
|
||||||
<h2>Published automatically</h2>
|
<h2>Published automatically</h2>
|
||||||
|
|||||||
+1
-1
@@ -145,7 +145,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
assert r["wan"]["ipv6"] == {"enable": True, "transport": "ppp"}, s
|
assert r["wan"]["ipv6"] == {"enable": True, "transport": "ppp"}, s
|
||||||
assert r["lans"]["lan"]["interface"] == "eth1" and r["lans"]["lan"]["ipv6"] == {"pd": True}, s
|
assert r["lans"]["lan"]["interface"] == "eth1" and r["lans"]["lan"]["ipv6"] == {"pd": True}, s
|
||||||
assert "vlan" not in r["lans"]["lan"], s
|
assert "vlan" not in r["lans"]["lan"], s
|
||||||
assert r["dns"]["domain"] == "home.test", s
|
assert r["dns"] == {"domain": "home.test", "dynamicLeases": True}, s
|
||||||
assert "pppoe-secret" not in s
|
assert "pppoe-secret" not in s
|
||||||
installer.succeed("grep -qx pppoe-secret /tmp/vlan-config/pppoe-password")
|
installer.succeed("grep -qx pppoe-secret /tmp/vlan-config/pppoe-password")
|
||||||
installer.fail("blkid /dev/vda1")
|
installer.fail("blkid /dev/vda1")
|
||||||
|
|||||||
Reference in New Issue
Block a user