Encrypted DNS health, comparing a zone's views, --local everywhere
jarld checks the DoT and DoH listeners every 10 minutes as a client would (page, dashboard, jarl encrypted-dns health, jarl_encrypted_dns_up). The Compare views tab and jarl zone diff show what differs between a zone's views, with Use this version. --local works on unlinked zones. A changed certificates.dir is followed by encrypted DNS's tls statements. Sharing an already shared version replaces other views' overrides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
67ae0091b3
commit
13ffd8cad4
31 files changed
+1153
-18
No files matched your search
@@ -34,6 +34,9 @@ type Zone struct {
|
||||
LinkedWith []string `json:"linked_with,omitempty"`
|
||||
// Links are the zones this one is linked with, with their views.
|
||||
Links []ZoneLink `json:"links,omitempty"`
|
||||
// OtherViews are the other views with a primary zone of this name
|
||||
// (linked or not): what GET /zones/{zone}/compare compares.
|
||||
OtherViews []string `json:"other_views,omitempty"`
|
||||
// Pending is set when listing with a changeset: add, change or delete.
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
@@ -266,6 +269,9 @@ type StatusResponse struct {
|
||||
// Certificates sums up the TLS certificates Jarl manages
|
||||
// (administrators only, when there are any).
|
||||
Certificates *CertSummary `json:"certificates,omitempty"`
|
||||
// EncryptedDNS is the last check of DNS over TLS and HTTPS, when it
|
||||
// is set up (for those who may see options).
|
||||
EncryptedDNS *EncDNSSummary `json:"encrypted_dns,omitempty"`
|
||||
}
|
||||
|
||||
// CertSummary is what the dashboard reports about certificates.
|
||||
@@ -575,3 +581,11 @@ type ZoneLink struct {
|
||||
ID int64 `json:"id"`
|
||||
View string `json:"view"`
|
||||
}
|
||||
|
||||
// EncDNSSummary is what the dashboard says about encrypted DNS.
|
||||
type EncDNSSummary struct {
|
||||
Checked time.Time `json:"checked"`
|
||||
OK bool `json:"ok"`
|
||||
Problems []string `json:"problems"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/encdns"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/porkbun"
|
||||
@@ -488,3 +489,34 @@ func TestCleanLegoError(t *testing.T) {
|
||||
t.Errorf("rate limit: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// When certificates.dir changes, Start writes the certificates to the new
|
||||
// place and points encrypted DNS's tls statement there.
|
||||
func TestDirChangeFollowed(t *testing.T) {
|
||||
e := newEnv(t, false)
|
||||
ctx := context.Background()
|
||||
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
|
||||
now := time.Now()
|
||||
certFile, keyFile := filepath.Join(e.dir, "c.pem"), filepath.Join(e.dir, "k.pem")
|
||||
cert, key := makeCert(t, []string{"dns.example.com"}, now.Add(-time.Hour), now.Add(60*24*time.Hour), true)
|
||||
os.WriteFile(certFile, cert, 0o600)
|
||||
os.WriteFile(keyFile, key, 0o600)
|
||||
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("dns"), Source: model.Ptr("file"), CertFile: &certFile, KeyFile: &keyFile, Uses: &[]string{"dns"}}, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.submit(engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{Scope: model.Ptr(model.ScopeTopLevel), Name: model.Ptr("tls"), Value: model.Ptr(encdns.TLSValue("dns", e.m.cfg.Dir))}))
|
||||
|
||||
moved := filepath.Join(e.dir, "tls-moved")
|
||||
cfg := e.m.cfg
|
||||
cfg.Dir = moved
|
||||
m2 := New(cfg, e.st, e.eng, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
m2.Start(ctx)
|
||||
if _, err := os.Stat(filepath.Join(moved, "dns", ChainFile)); err != nil {
|
||||
t.Fatalf("not written to the new place: %v", err)
|
||||
}
|
||||
for _, o := range e.state().ScopedOptions(model.ScopeTopLevel, 0) {
|
||||
if o.Name == "tls" && !strings.Contains(o.Value, moved+"/dns/fullchain.pem") {
|
||||
t.Errorf("tls statement still points elsewhere: %s", o.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
legolog "github.com/go-acme/lego/v4/log"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/encdns"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/porkbun"
|
||||
@@ -766,6 +767,40 @@ func (m *Manager) Start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
m.followDir(ctx, all)
|
||||
}
|
||||
|
||||
// followDir points the tls statements encrypted DNS wrote (jarl-NAME) at
|
||||
// certificates.dir when it has changed: Start has just written the
|
||||
// certificates there, and named would otherwise keep serving the old
|
||||
// copies, which renewals no longer update.
|
||||
func (m *Manager) followDir(ctx context.Context, all []*model.Certificate) {
|
||||
st, err := m.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var reqs []engine.Request
|
||||
for _, b := range encdns.TLSBlocks(st) {
|
||||
name, ok := strings.CutPrefix(b.Name, encdns.Prefix)
|
||||
if !ok || filepath.Dir(b.CertFile) == m.FilesDir(name) {
|
||||
continue
|
||||
}
|
||||
i := slices.IndexFunc(all, func(c *model.Certificate) bool { return c.Name == name })
|
||||
if i < 0 || !all[i].HasUse(model.CertUseDNS) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(m.FilesDir(name), ChainFile)); err != nil {
|
||||
continue // not written there (yet)
|
||||
}
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpOptionUpdate, b.OptionID, engine.OptionInput{Value: model.Ptr(encdns.TLSValue(name, m.cfg.Dir))}))
|
||||
m.log.Info("certificates.dir changed: pointing named at the new files", "tls", b.Name, "was", filepath.Dir(b.CertFile), "now", m.FilesDir(name))
|
||||
}
|
||||
if len(reqs) == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := m.eng.Submit(ctx, reqs, auth.System, "jarld", "certificates.dir changed: tls statements follow it", engine.ApplyOptions{}); err != nil {
|
||||
m.log.Error("pointing named at the moved certificate files failed; run 'jarl encrypted-dns setup' again", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run renews certificates when they are due and rereads file-source ones
|
||||
|
||||
@@ -272,9 +272,7 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
|
||||
return nil, nil, fmt.Errorf("choose at least one address to listen on")
|
||||
}
|
||||
tlsName := Prefix + s.Certificate
|
||||
dir := filepath.Join(certDir, s.Certificate)
|
||||
tlsValue := fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`,
|
||||
tlsName, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem"))
|
||||
tlsValue := TLSValue(s.Certificate, certDir)
|
||||
|
||||
var reqs []engine.Request
|
||||
// Take out the earlier setup's listeners and tls statements.
|
||||
@@ -393,3 +391,11 @@ func Private(a netip.Addr) bool {
|
||||
a = a.Unmap()
|
||||
return a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() || netip.MustParsePrefix("100.64.0.0/10").Contains(a)
|
||||
}
|
||||
|
||||
// TLSValue is the tls statement the setup writes for a certificate whose
|
||||
// files are in certDir/NAME.
|
||||
func TLSValue(cert, certDir string) string {
|
||||
dir := filepath.Join(certDir, cert)
|
||||
return fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`,
|
||||
Prefix+cert, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem"))
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package encdns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Probe is the outcome of checking one DoT or DoH listener the way a
|
||||
// client would: connect, check the certificate, ask a question.
|
||||
type Probe struct {
|
||||
Kind string `json:"kind"` // dot or doh
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
// OK: it answered over TLS with a certificate for the host name that
|
||||
// is current. Problem says what is wrong when not; Warning notes
|
||||
// something that doesn't stop clients from working everywhere.
|
||||
OK bool `json:"ok"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
// What the certificate named served says.
|
||||
CertNames []string `json:"cert_names,omitempty"`
|
||||
CertNotAfter *time.Time `json:"cert_not_after,omitempty"`
|
||||
CertSerial string `json:"cert_serial,omitempty"`
|
||||
// Trusted: it chains to a CA this server trusts (not so for a
|
||||
// staging or test CA).
|
||||
Trusted bool `json:"trusted"`
|
||||
RCode string `json:"rcode,omitempty"`
|
||||
Millis int64 `json:"ms"`
|
||||
}
|
||||
|
||||
// Health is the last check of every listener.
|
||||
type Health struct {
|
||||
Checked time.Time `json:"checked"`
|
||||
Probes []Probe `json:"probes"`
|
||||
}
|
||||
|
||||
// OK reports whether every listener works.
|
||||
func (h *Health) OK() bool {
|
||||
for _, p := range h.Probes {
|
||||
if !p.OK {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Check probes every DoT and DoH listener of the setup. hostname is what
|
||||
// clients ask for, serial the certificate Jarl last wrote for named ("" if
|
||||
// unknown), roots the CAs to trust (nil: the system's).
|
||||
func Check(ctx context.Context, s Status, hostname, serial string, roots *x509.CertPool) *Health {
|
||||
h := &Health{Checked: time.Now(), Probes: []Probe{}}
|
||||
for _, e := range s.DoT {
|
||||
h.Probes = append(h.Probes, probe(ctx, "dot", e, hostname, serial, roots))
|
||||
}
|
||||
for _, e := range s.DoH {
|
||||
h.Probes = append(h.Probes, probe(ctx, "doh", e, hostname, serial, roots))
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
const probeTimeout = 5 * time.Second
|
||||
|
||||
func probe(ctx context.Context, kind string, e Endpoint, hostname, serial string, roots *x509.CertPool) Probe {
|
||||
p := Probe{Kind: kind, Address: e.Address, Port: e.Port}
|
||||
addr := net.JoinHostPort(e.Address, strconv.Itoa(e.Port))
|
||||
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
q := new(dns.Msg)
|
||||
q.SetQuestion(dns.Fqdn(hostname), dns.TypeA)
|
||||
q.RecursionDesired = true
|
||||
var state tls.ConnectionState
|
||||
var answer *dns.Msg
|
||||
var err error
|
||||
cfg := &tls.Config{ServerName: hostname, InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} // checked below, to say what is wrong
|
||||
if kind == "dot" {
|
||||
cfg.NextProtos = []string{"dot"}
|
||||
state, answer, err = askDoT(ctx, addr, cfg, q)
|
||||
} else {
|
||||
state, answer, err = askDoH(ctx, addr, hostname, e.Port, cfg, q)
|
||||
}
|
||||
p.Millis = time.Since(start).Milliseconds()
|
||||
if len(state.PeerCertificates) > 0 {
|
||||
leaf := state.PeerCertificates[0]
|
||||
p.CertNames, p.CertSerial = leaf.DNSNames, fmt.Sprintf("%x", leaf.SerialNumber)
|
||||
na := leaf.NotAfter
|
||||
p.CertNotAfter = &na
|
||||
inter := x509.NewCertPool()
|
||||
for _, c := range state.PeerCertificates[1:] {
|
||||
inter.AddCert(c)
|
||||
}
|
||||
_, verr := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inter, DNSName: hostname})
|
||||
p.Trusted = verr == nil
|
||||
switch {
|
||||
case leaf.VerifyHostname(hostname) != nil:
|
||||
p.Problem = fmt.Sprintf("named serves a certificate for %s, not %s: clients reject it", strings.Join(leaf.DNSNames, ", "), hostname)
|
||||
case time.Now().After(leaf.NotAfter):
|
||||
p.Problem = "named serves an expired certificate (until " + leaf.NotAfter.Format(time.DateOnly) + ")"
|
||||
case serial != "" && p.CertSerial != serial:
|
||||
p.Problem = "named still serves an older certificate (serial " + p.CertSerial + ", Jarl has " + serial + "): it hasn't reread the files (rndc reconfig)"
|
||||
case !p.Trusted:
|
||||
p.Warning = "the certificate isn't from a CA this server trusts (a staging or test CA?): clients reject it unless they trust that CA"
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
p.Problem = describe(err, e)
|
||||
case p.Problem != "":
|
||||
case answer == nil:
|
||||
p.Problem = "no DNS answer"
|
||||
default:
|
||||
p.RCode = dns.RcodeToString[answer.Rcode]
|
||||
p.OK = true
|
||||
if answer.Rcode == dns.RcodeRefused {
|
||||
p.Warning = strings.TrimPrefix(p.Warning+"; ", "; ") + "it answers, but REFUSED: this server's own address gets a view that doesn't answer for " + hostname
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func askDoT(ctx context.Context, addr string, cfg *tls.Config, q *dns.Msg) (tls.ConnectionState, *dns.Msg, error) {
|
||||
d := &tls.Dialer{NetDialer: &net.Dialer{Timeout: probeTimeout}, Config: cfg}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return tls.ConnectionState{}, nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
tc := conn.(*tls.Conn)
|
||||
state := tc.ConnectionState()
|
||||
conn.SetDeadline(time.Now().Add(probeTimeout))
|
||||
co := &dns.Conn{Conn: conn}
|
||||
if err := co.WriteMsg(q); err != nil {
|
||||
return state, nil, err
|
||||
}
|
||||
r, err := co.ReadMsg()
|
||||
return state, r, err
|
||||
}
|
||||
|
||||
func askDoH(ctx context.Context, addr, hostname string, port int, cfg *tls.Config, q *dns.Msg) (tls.ConnectionState, *dns.Msg, error) {
|
||||
cfg = cfg.Clone()
|
||||
cfg.NextProtos = []string{"h2", "http/1.1"}
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: cfg,
|
||||
ForceAttemptHTTP2: true,
|
||||
// Whatever the host name resolves to here: the listener's address.
|
||||
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{Timeout: probeTimeout}).DialContext(ctx, network, addr)
|
||||
},
|
||||
}
|
||||
defer tr.CloseIdleConnections()
|
||||
wire, err := q.Pack()
|
||||
if err != nil {
|
||||
return tls.ConnectionState{}, nil, err
|
||||
}
|
||||
url := "https://" + net.JoinHostPort(hostname, strconv.Itoa(port)) + "/dns-query"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wire))
|
||||
if err != nil {
|
||||
return tls.ConnectionState{}, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/dns-message")
|
||||
req.Header.Set("Accept", "application/dns-message")
|
||||
resp, err := (&http.Client{Transport: tr, Timeout: probeTimeout}).Do(req)
|
||||
if err != nil {
|
||||
return tls.ConnectionState{}, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var state tls.ConnectionState
|
||||
if resp.TLS != nil {
|
||||
state = *resp.TLS
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if err != nil {
|
||||
return state, nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return state, nil, fmt.Errorf("HTTP %d at /dns-query", resp.StatusCode)
|
||||
}
|
||||
r := new(dns.Msg)
|
||||
if err := r.Unpack(body); err != nil {
|
||||
return state, nil, fmt.Errorf("not a DNS answer: %w", err)
|
||||
}
|
||||
return state, r, nil
|
||||
}
|
||||
|
||||
// describe says what a failed probe means.
|
||||
func describe(err error, e Endpoint) string {
|
||||
var ne net.Error
|
||||
switch {
|
||||
case errors.Is(err, syscall.ECONNREFUSED):
|
||||
return fmt.Sprintf("nothing listens on %s port %d: named may not have been able to bind it (look in named's log; ports below 1024 need named to start as root)", e.Address, e.Port)
|
||||
case errors.As(err, &ne) && ne.Timeout(), errors.Is(err, context.DeadlineExceeded):
|
||||
return fmt.Sprintf("no answer from %s port %d within %s (a firewall on the server?)", e.Address, e.Port, probeTimeout)
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package encdns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func testCert(t *testing.T, names ...string) (tls.Certificate, *x509.Certificate) {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{SerialNumber: big.NewInt(0xabc123), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf, _ := x509.ParseCertificate(der)
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, leaf
|
||||
}
|
||||
|
||||
// answer answers every question with 192.0.2.53.
|
||||
var answer = dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Answer = append(m.Answer, &dns.A{Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP("192.0.2.53")})
|
||||
w.WriteMsg(m)
|
||||
})
|
||||
|
||||
func listeners(t *testing.T, cert tls.Certificate) (dot, doh Endpoint) {
|
||||
cfg := &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &dns.Server{Listener: ln, Net: "tcp-tls", Handler: answer}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
hl, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hs := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/dns-query" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
q := new(dns.Msg)
|
||||
if q.Unpack(b) != nil {
|
||||
http.Error(w, "bad", 400)
|
||||
return
|
||||
}
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(q)
|
||||
m.Rcode = dns.RcodeSuccess
|
||||
out, _ := m.Pack()
|
||||
w.Header().Set("Content-Type", "application/dns-message")
|
||||
w.Write(out)
|
||||
})}
|
||||
go hs.Serve(hl)
|
||||
t.Cleanup(func() { hs.Close() })
|
||||
port := func(l net.Listener) int { return l.Addr().(*net.TCPAddr).Port }
|
||||
return Endpoint{"127.0.0.1", port(ln)}, Endpoint{"127.0.0.1", port(hl)}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cert, leaf := testCert(t, "dns.example.com")
|
||||
dot, doh := listeners(t, cert)
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(leaf)
|
||||
st := Status{Enabled: true, DoT: []Endpoint{dot}, DoH: []Endpoint{doh}}
|
||||
|
||||
h := Check(ctx, st, "dns.example.com", "abc123", roots)
|
||||
if !h.OK() || len(h.Probes) != 2 {
|
||||
t.Fatalf("working setup: %+v", h.Probes)
|
||||
}
|
||||
for _, p := range h.Probes {
|
||||
if !p.Trusted || p.RCode != "NOERROR" || p.Warning != "" || p.CertSerial != "abc123" {
|
||||
t.Errorf("%s: %+v", p.Kind, p)
|
||||
}
|
||||
}
|
||||
// named hasn't reread a renewed certificate.
|
||||
if h := Check(ctx, st, "dns.example.com", "def456", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "older certificate") {
|
||||
t.Errorf("stale: %+v", h.Probes[0])
|
||||
}
|
||||
// The wrong name.
|
||||
if h := Check(ctx, st, "other.example.com", "", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "not other.example.com") {
|
||||
t.Errorf("wrong name: %+v", h.Probes[0])
|
||||
}
|
||||
// A CA this server doesn't trust: works, with a warning.
|
||||
if h := Check(ctx, st, "dns.example.com", "abc123", x509.NewCertPool()); !h.OK() || !strings.Contains(h.Probes[1].Warning, "staging") {
|
||||
t.Errorf("untrusted: %+v", h.Probes[1])
|
||||
}
|
||||
// Nothing listening.
|
||||
l, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
gone := l.Addr().(*net.TCPAddr).Port
|
||||
l.Close()
|
||||
if h := Check(ctx, Status{Enabled: true, DoT: []Endpoint{{"127.0.0.1", gone}}}, "dns.example.com", "", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "nothing listens") {
|
||||
t.Errorf("nothing listening: %+v", h.Probes[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// ViewSet is what one view has for a name and type.
|
||||
type ViewSet struct {
|
||||
View string `json:"view"`
|
||||
ZoneID int64 `json:"zone_id"`
|
||||
Records []string `json:"records"` // data (with TTL and "(disabled)" when set), sorted
|
||||
IDs []int64 `json:"ids"` // the records, in the same order
|
||||
Linked string `json:"linked,omitempty"` // shared or local, in a linked zone
|
||||
}
|
||||
|
||||
// CompareRow is one name and type across views.
|
||||
type CompareRow struct {
|
||||
Name string `json:"name"` // relative to the zone
|
||||
FQDN string `json:"fqdn"`
|
||||
Type string `json:"type"`
|
||||
Same bool `json:"same"`
|
||||
Views []ViewSet `json:"views"` // in the order of the zones given
|
||||
}
|
||||
|
||||
// CompareZones lines up the records of zones of the same name in
|
||||
// different views (docs/views.md#linked-zones), one row per name and type,
|
||||
// sorted like a zone file.
|
||||
func CompareZones(st *model.State, zones []*model.Zone) []CompareRow {
|
||||
type key struct{ name, typ string }
|
||||
sets := make([]map[key][]*model.Record, len(zones))
|
||||
var keys []key
|
||||
seen := map[key]bool{}
|
||||
var all []*model.Record
|
||||
for i, z := range zones {
|
||||
sets[i] = map[key][]*model.Record{}
|
||||
for _, r := range st.ZoneRecords(z.ID) {
|
||||
k := key{r.Name, r.Type}
|
||||
sets[i][k] = append(sets[i][k], r)
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
keys = append(keys, k)
|
||||
all = append(all, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(zones) > 0 {
|
||||
// Zone file order: the apex first, then by name and type.
|
||||
model.SortRecords(all, zones[0])
|
||||
keys = keys[:0]
|
||||
for _, r := range all {
|
||||
keys = append(keys, key{r.Name, r.Type})
|
||||
}
|
||||
}
|
||||
var out []CompareRow
|
||||
for _, k := range keys {
|
||||
row := CompareRow{FQDN: k.name, Type: k.typ, Same: true}
|
||||
if len(zones) > 0 {
|
||||
row.Name = relName(k.name, zones[0].Name)
|
||||
}
|
||||
var first string
|
||||
for i, z := range zones {
|
||||
vs := ViewSet{View: st.ViewName(z), ZoneID: z.ID, Records: []string{}, IDs: []int64{}}
|
||||
rs := slices.Clone(sets[i][k])
|
||||
slices.SortFunc(rs, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
||||
for _, r := range rs {
|
||||
d := r.Data
|
||||
if r.TTL != nil {
|
||||
d = fmt.Sprintf("%s (TTL %d)", d, *r.TTL)
|
||||
}
|
||||
if r.Disabled {
|
||||
d += " (disabled)"
|
||||
}
|
||||
vs.Records = append(vs.Records, d)
|
||||
vs.IDs = append(vs.IDs, r.ID)
|
||||
}
|
||||
if z.LinkGroup != 0 && len(rs) > 0 {
|
||||
vs.Linked = "shared"
|
||||
if rs[0].SharedID == 0 {
|
||||
vs.Linked = "local"
|
||||
}
|
||||
}
|
||||
sig := strings.Join(vs.Records, "\n")
|
||||
if i == 0 {
|
||||
first = sig
|
||||
} else if sig != first {
|
||||
row.Same = false
|
||||
}
|
||||
row.Views = append(row.Views, vs)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func relName(name, zone string) string {
|
||||
if name == zone {
|
||||
return "@"
|
||||
}
|
||||
return strings.TrimSuffix(name, "."+zone)
|
||||
}
|
||||
+18
-11
@@ -801,9 +801,10 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
||||
}
|
||||
in.SharedID = &ids[0]
|
||||
}
|
||||
} else if in.Local != nil && *in.Local {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||
} else if in.Local != nil && !*in.Local {
|
||||
return nil, notLinked(x.st, z)
|
||||
}
|
||||
// local on a zone that isn't linked: its records are local anyway.
|
||||
in.Local = nil
|
||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
||||
if ptr {
|
||||
@@ -834,10 +835,10 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
}
|
||||
var pre []model.Op
|
||||
if in.Local != nil {
|
||||
if z.LinkGroup == 0 {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||
if z.LinkGroup == 0 && !*in.Local {
|
||||
return nil, notLinked(x.st, z)
|
||||
}
|
||||
if want := *in.Local; want != (r.SharedID == 0) {
|
||||
if want := *in.Local; z.LinkGroup != 0 && want != (r.SharedID == 0) {
|
||||
// The whole set of this name and type changes sides.
|
||||
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
|
||||
id := int64(0)
|
||||
@@ -848,10 +849,11 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
}
|
||||
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
|
||||
}
|
||||
if !want {
|
||||
// Sharing a version makes it every view's.
|
||||
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
||||
}
|
||||
}
|
||||
if z.LinkGroup != 0 && !*in.Local {
|
||||
// Sharing a version makes it every view's, also when it is
|
||||
// shared already and another view overrides it.
|
||||
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
||||
}
|
||||
in.Local = nil
|
||||
if string(mustJSON(in)) == "{}" {
|
||||
@@ -1153,8 +1155,8 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
||||
// Explicitly shared: this version becomes every view's.
|
||||
pre = x.dropOverrides(z, owner, typ)
|
||||
}
|
||||
} else if in.Local != nil && *in.Local {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||
} else if in.Local != nil && !*in.Local {
|
||||
return nil, notLinked(x.st, z)
|
||||
}
|
||||
in.Local = nil
|
||||
if len(canon) > 0 {
|
||||
@@ -1356,3 +1358,8 @@ func firstNonEmptyStr(a, b string) string {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// notLinked refuses sharing a record of a zone that isn't linked.
|
||||
func notLinked(st *model.State, z *model.Zone) error {
|
||||
return errf(KindBadRequest, "zone %s isn't linked with other views, so there is nothing to share its records with (link it first: jarl zone link)", zoneDesc(st, z))
|
||||
}
|
||||
@@ -285,3 +285,59 @@ func TestLinkExisting(t *testing.T) {
|
||||
t.Errorf("shared from internal: external %q", env.view("external"))
|
||||
}
|
||||
}
|
||||
|
||||
// --local on a zone that isn't linked is fine (its records are local
|
||||
// anyway); --shared there is refused.
|
||||
func TestLocalOnUnlinkedZone(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
local, shared := true, false
|
||||
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("a"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.1"), Local: &local}))
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "b", Type: "A", Data: []string{"10.0.0.2"}, Local: &local}))
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Data: model.Ptr("10.0.0.3"), Local: &local}))
|
||||
if v := env.view("internal"); !slices.Contains(v, "a A 10.0.0.3 L") || !slices.Contains(v, "b A 10.0.0.2 L") {
|
||||
t.Errorf("records: %q", v)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("c"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.4"), Local: &shared})); !strings.Contains(e.Message, "nothing to share") {
|
||||
t.Errorf("--shared on an unlinked zone: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareZones(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"203.0.113.10"}, Local: model.Ptr(true)}))
|
||||
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.20"), Local: model.Ptr(true)}))
|
||||
st := env.state()
|
||||
zones := st.ZonesNamed("example.com.")
|
||||
st.SortByView(zones)
|
||||
diff := map[string][]string{}
|
||||
same := 0
|
||||
for _, row := range CompareZones(st, zones) {
|
||||
if row.Same {
|
||||
same++
|
||||
continue
|
||||
}
|
||||
var cells []string
|
||||
for _, vs := range row.Views {
|
||||
cells = append(cells, vs.View+"="+strings.Join(vs.Records, ",")+"/"+vs.Linked)
|
||||
}
|
||||
diff[row.Name+" "+row.Type] = cells
|
||||
}
|
||||
want := map[string][]string{
|
||||
"www A": {"internal=192.0.2.10/shared", "external=203.0.113.10/local"},
|
||||
"nas A": {"internal=10.0.0.20/local", "external=/"},
|
||||
}
|
||||
for k, v := range want {
|
||||
if !slices.Equal(diff[k], v) {
|
||||
t.Errorf("%s: %q, want %q", k, diff[k], v)
|
||||
}
|
||||
}
|
||||
if len(diff) != 2 || same < 3 {
|
||||
t.Errorf("differ %v, same %d", diff, same)
|
||||
}
|
||||
// "Use this version" on the shared one replaces external's override.
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||
if v := env.view("external"); !slices.Contains(v, "www A 192.0.2.10 S") || slices.Contains(v, "www A 203.0.113.10 L") {
|
||||
t.Errorf("sharing the shared version: external %q", v)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -285,6 +286,20 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
resp.TOTP = s.totpOverview(r.Context())
|
||||
resp.Certificates = s.certSummary(r.Context())
|
||||
}
|
||||
if p.Can(auth.Action{Resource: auth.ResOptions}) {
|
||||
if h := s.encryptedDNSHealth(); h != nil {
|
||||
sum := &api.EncDNSSummary{Checked: h.Checked, OK: h.OK(), Problems: []string{}, Warnings: []string{}}
|
||||
for _, pr := range h.Probes {
|
||||
where := fmt.Sprintf("%s on %s port %d", map[string]string{"dot": "DNS over TLS", "doh": "DNS over HTTPS"}[pr.Kind], pr.Address, pr.Port)
|
||||
if pr.Problem != "" {
|
||||
sum.Problems = append(sum.Problems, where+": "+pr.Problem)
|
||||
} else if pr.Warning != "" {
|
||||
sum.Warnings = append(sum.Warnings, where+": "+pr.Warning)
|
||||
}
|
||||
}
|
||||
resp.EncryptedDNS = sum
|
||||
}
|
||||
}
|
||||
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
||||
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
||||
if st, err := s.store.LoadState(r.Context()); err == nil {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// zoneCompare: GET /zones/{zone}/compare lines up a zone's records across
|
||||
// the views that have it (docs/views.md#linked-zones). ?views=a,b picks the
|
||||
// views (default: every view with a primary zone of that name), ?all=1
|
||||
// includes the names and types that are the same everywhere.
|
||||
func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
// A name in several views is what there is to compare.
|
||||
ref := r.PathValue("zone")
|
||||
if ref == "root" {
|
||||
ref = "."
|
||||
}
|
||||
name, perr := engine.ParseZoneName(ref)
|
||||
if zs := st.ZonesNamed(name); perr != nil || len(zs) < 2 {
|
||||
return err
|
||||
} else {
|
||||
z = zs[0]
|
||||
}
|
||||
}
|
||||
var want []string
|
||||
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
want = append(want, v)
|
||||
}
|
||||
}
|
||||
var zones []*model.Zone
|
||||
for _, o := range st.ZonesNamed(z.Name) {
|
||||
vn := st.ViewName(o)
|
||||
if o.Type != model.ZonePrimary || !p.CanSeeZone(o.Name, vn) || !p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn}) {
|
||||
continue
|
||||
}
|
||||
if len(want) > 0 && !containsFold(want, vn) {
|
||||
continue
|
||||
}
|
||||
zones = append(zones, o)
|
||||
}
|
||||
st.SortByView(zones)
|
||||
if len(zones) < 2 {
|
||||
return badRequest("zone %s is in only one view (that you may read): there is nothing to compare", engine.Display(z.Name))
|
||||
}
|
||||
rows := engine.CompareZones(st, zones)
|
||||
all := queryBool(r, "all")
|
||||
out := struct {
|
||||
Zone string `json:"zone"`
|
||||
Views []string `json:"views"`
|
||||
Linked bool `json:"linked"` // all of them in one link
|
||||
Same int `json:"same"` // names and types the same everywhere
|
||||
Rows []engine.CompareRow `json:"rows"`
|
||||
}{Zone: engine.Display(z.Name), Rows: []engine.CompareRow{}, Linked: true}
|
||||
for _, o := range zones {
|
||||
out.Views = append(out.Views, st.ViewName(o))
|
||||
out.Linked = out.Linked && o.LinkGroup != 0 && o.LinkGroup == zones[0].LinkGroup
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Same {
|
||||
out.Same++
|
||||
if !all {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.Rows = append(out.Rows, row)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsFold(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if strings.EqualFold(x, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
@@ -43,6 +44,8 @@ type encStatus struct {
|
||||
// DoTHost and DoHURL are what to give clients.
|
||||
DoTHost string `json:"dot_host,omitempty"`
|
||||
DoHURL string `json:"doh_url,omitempty"`
|
||||
// Health is the last check of the listeners (every 10 minutes).
|
||||
Health *encdns.Health `json:"health,omitempty"`
|
||||
// For the setup form.
|
||||
Addresses []encAddress `json:"addresses"`
|
||||
Views []encView `json:"views"`
|
||||
@@ -104,6 +107,9 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
||||
out.DoHURL += "/dns-query"
|
||||
}
|
||||
}
|
||||
if out.Enabled {
|
||||
out.Health = s.encryptedDNSHealth()
|
||||
}
|
||||
seen := map[netip.Addr]bool{}
|
||||
for _, a := range localAddrs() {
|
||||
a = a.Unmap()
|
||||
@@ -234,3 +240,81 @@ func (s *Server) encryptedDNSDisable(w http.ResponseWriter, r *http.Request, p *
|
||||
}
|
||||
return s.mutate(w, r, p, "turn DNS over TLS and HTTPS off", reqs, nil)
|
||||
}
|
||||
|
||||
// encTarget returns the host name clients connect to and the serial of
|
||||
// the certificate Jarl last wrote for named.
|
||||
func (s *Server) encTarget(ctx context.Context, st encdns.Status) (host, serial string) {
|
||||
if s.cfg.Certs == nil || st.Certificate == "" {
|
||||
return "", ""
|
||||
}
|
||||
c, err := s.cfg.Certs.Get(ctx, st.Certificate)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
for _, n := range append(c.Names, c.Domains...) {
|
||||
if !strings.HasPrefix(n, "*.") {
|
||||
host = n
|
||||
break
|
||||
}
|
||||
}
|
||||
return host, c.Serial
|
||||
}
|
||||
|
||||
// checkEncryptedDNS checks the DoT and DoH listeners as a client would and
|
||||
// keeps the result; a change for the worse is logged.
|
||||
func (s *Server) checkEncryptedDNS(ctx context.Context) *encdns.Health {
|
||||
if s.cfg.Certs == nil {
|
||||
return nil
|
||||
}
|
||||
state, err := s.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
st := encdns.GetStatus(state, s.cfg.Certs.FilesDir(""))
|
||||
var h *encdns.Health
|
||||
if st.Enabled {
|
||||
if host, serial := s.encTarget(ctx, st); host != "" {
|
||||
h = encdns.Check(ctx, st, host, serial, s.encRoots)
|
||||
}
|
||||
}
|
||||
s.encHealth.Lock()
|
||||
prev := s.encHealth.h
|
||||
s.encHealth.h = h
|
||||
s.encHealth.Unlock()
|
||||
if h != nil {
|
||||
for _, p := range h.Probes {
|
||||
if p.OK {
|
||||
continue
|
||||
}
|
||||
was := false
|
||||
if prev != nil {
|
||||
for _, q := range prev.Probes {
|
||||
was = was || q.Kind == p.Kind && q.Address == p.Address && q.Port == p.Port && !q.OK
|
||||
}
|
||||
}
|
||||
if !was {
|
||||
s.log.Warn("encrypted DNS isn't working", "kind", p.Kind, "address", p.Address, "port", p.Port, "problem", p.Problem)
|
||||
}
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (s *Server) encryptedDNSHealth() *encdns.Health {
|
||||
s.encHealth.Lock()
|
||||
defer s.encHealth.Unlock()
|
||||
return s.encHealth.h
|
||||
}
|
||||
|
||||
// encryptedDNSCheckNow: POST /encrypted-dns/health checks the listeners now.
|
||||
func (s *Server) encryptedDNSCheckNow(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.encReady(p, false); err != nil {
|
||||
return err
|
||||
}
|
||||
h := s.checkEncryptedDNS(r.Context())
|
||||
if h == nil {
|
||||
return conflict("encrypted DNS isn't set up")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h)
|
||||
return nil
|
||||
}
|
||||
@@ -289,6 +289,13 @@ func (s *Server) collectState(ctx context.Context, pw *promWriter, now time.Time
|
||||
}
|
||||
}
|
||||
|
||||
if h := s.encryptedDNSHealth(); h != nil {
|
||||
for _, pr := range h.Probes {
|
||||
pw.gauge("jarl_encrypted_dns_up", "1 when the DNS over TLS or HTTPS listener answered with the right, current certificate at the last check (every 10 minutes).", b2f(pr.OK),
|
||||
"kind", pr.Kind, "address", pr.Address, "port", strconv.Itoa(pr.Port))
|
||||
}
|
||||
}
|
||||
|
||||
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
|
||||
for _, t := range tokens {
|
||||
if t.ExpiresAt != nil && t.RevokedAt == nil {
|
||||
|
||||
@@ -22,6 +22,13 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
|
||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||
}
|
||||
out.Registrar = s.registrarFor(z)
|
||||
if z.Type == model.ZonePrimary && z.ViewID != 0 {
|
||||
for _, o := range st.ZonesNamed(z.Name) {
|
||||
if o.ID != z.ID && o.Type == model.ZonePrimary {
|
||||
out.OtherViews = append(out.OtherViews, st.ViewName(o))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, o := range st.LinkedWith(z) {
|
||||
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
|
||||
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
|
||||
|
||||
@@ -111,6 +111,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
|
||||
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/compare", s.authed(s.zoneCompare))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/link", s.authed(s.zoneLink(true)))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
||||
@@ -176,6 +177,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST "+p+"/encrypted-dns", s.authed(s.encryptedDNSSetup))
|
||||
m.HandleFunc("DELETE "+p+"/encrypted-dns", s.authed(s.encryptedDNSDisable))
|
||||
m.HandleFunc("POST "+p+"/encrypted-dns/check", s.authed(s.encryptedDNSCheck))
|
||||
m.HandleFunc("POST "+p+"/encrypted-dns/health", s.authed(s.encryptedDNSCheckNow))
|
||||
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
|
||||
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
|
||||
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
|
||||
|
||||
@@ -4,6 +4,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/certs"
|
||||
"jarl/internal/encdns"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
@@ -90,6 +92,13 @@ type Server struct {
|
||||
// state part of /metrics.
|
||||
started time.Time
|
||||
metricsCache metricsCache
|
||||
// encHealth is the last check of encrypted DNS's listeners
|
||||
// (h_encdns.go); encRoots the CAs it trusts (nil: the system's).
|
||||
encHealth struct {
|
||||
sync.Mutex
|
||||
h *encdns.Health
|
||||
}
|
||||
encRoots *x509.CertPool
|
||||
|
||||
mu sync.Mutex
|
||||
srvs []*http.Server
|
||||
@@ -226,6 +235,14 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
|
||||
ready()
|
||||
}
|
||||
go s.housekeeping(ctx)
|
||||
go func() {
|
||||
// Once jarld and named have settled after a start.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(30 * time.Second):
|
||||
s.checkEncryptedDNS(ctx)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case err := <-errc:
|
||||
@@ -300,6 +317,7 @@ func (s *Server) housekeeping(ctx context.Context) {
|
||||
}
|
||||
s.limiter.cleanup()
|
||||
s.warnExpiringTokens(ctx, time.Now())
|
||||
s.checkEncryptedDNS(ctx)
|
||||
if s.certs != nil {
|
||||
s.certs.reloadIfChanged()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ export async function render(el) {
|
||||
st.backup ? backupCard(st.backup) : null,
|
||||
exposure ? exposureCard(exposure) : null,
|
||||
st.certificates ? certCard(st.certificates) : null,
|
||||
st.encrypted_dns ? h("a", { class: "card link", href: "#/encrypted-dns" }, h("div", { class: "label" }, "Encrypted DNS"),
|
||||
h("div", { class: "value" }, st.encrypted_dns.ok ? badge("working", "ok") : badge("not working", "err")),
|
||||
h("div", { class: "sub" }, "checked ", time(st.encrypted_dns.checked))) : null,
|
||||
);
|
||||
const last = s.last_applied;
|
||||
const parts = [h("div", { class: "page-head" },
|
||||
@@ -43,6 +46,9 @@ export async function render(el) {
|
||||
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
|
||||
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
|
||||
}
|
||||
for (const p of (st.encrypted_dns || {}).problems || []) {
|
||||
parts.push(h("div", { class: "msg err" }, h("strong", null, "Encrypted DNS: "), p, " ", h("a", { href: "#/encrypted-dns" }, "Encrypted DNS")));
|
||||
}
|
||||
for (const c of (st.certificates || {}).problems || []) {
|
||||
parts.push(h("div", { class: "msg " + (c.state === "expiring" ? "warn" : "err") },
|
||||
h("strong", null, "Certificate " + c.name + " " + (c.state === "expired" ? "has expired" : c.state === "expiring" ? "expires soon" : "can't be renewed") + ": "), c.message, " ",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// set up and what to give clients, and a setup that obtains the
|
||||
// certificate if needed, checks the ports and stages named's
|
||||
// configuration like any change.
|
||||
import { h, mount, field, input, badge, toast, errorBox, confirmDialog, copyBox, checkbox } from "../ui.js";
|
||||
import { h, mount, field, input, badge, toast, errorBox, confirmDialog, copyBox, checkbox, fmtTime, relTime } from "../ui.js";
|
||||
import { api, get, change, describeResult, state } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
@@ -50,6 +50,7 @@ function statusPanel(s) {
|
||||
}
|
||||
return [
|
||||
h("div", { class: "panel" }, h("h2", null, "Set up ", badge("on", "ok")), h("dl", { class: "kv" }, rows)),
|
||||
healthPanel(s.health),
|
||||
h("div", { class: "panel" }, h("h2", null, "For your clients"),
|
||||
h("p", { class: "muted small" }, "Clients check the certificate against the host name, so it must resolve to this server for them (Jarl added it to the private views you chose). Open the ports in the server's firewall for your networks only."),
|
||||
clients),
|
||||
@@ -223,3 +224,28 @@ async function disable() {
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
// healthPanel shows the last check of the listeners: Jarl connects to each
|
||||
// as a client would, checks the certificate and asks a question.
|
||||
function healthPanel(hl) {
|
||||
const kind = { dot: "DNS over TLS", doh: "DNS over HTTPS" };
|
||||
const out = h("div");
|
||||
const draw = (x) => {
|
||||
if (!x) return mount(out, h("div", { class: "muted" }, "Not checked yet: Jarl checks a minute after it starts, then every 10 minutes."));
|
||||
mount(out, h("ul", { class: "probes" }, x.probes.map((p) => h("li", null,
|
||||
badge(p.ok ? "working" : "not working", p.ok ? "ok" : "err"), " ",
|
||||
h("strong", null, kind[p.kind]), " ", h("span", { class: "mono" }, p.address + " port " + p.port),
|
||||
p.ok ? h("span", { class: "muted small" }, " answered " + p.rcode + " in " + p.ms + " ms" + (p.cert_not_after ? " · certificate until " + fmtTime(p.cert_not_after) : "")) : null,
|
||||
p.problem ? h("div", { class: "msg err small" }, p.problem) : null,
|
||||
p.warning ? h("div", { class: "msg warn small" }, p.warning) : null))),
|
||||
h("div", { class: "muted small" }, "Checked ", relTime(x.checked), "."));
|
||||
};
|
||||
draw(hl);
|
||||
const btn = h("button", { class: "small", onclick: async () => {
|
||||
btn.disabled = true;
|
||||
try { draw(await api("POST", "/encrypted-dns/health", { body: {} })); } catch (e) { toast(e.message, "err", 8000); } finally { btn.disabled = false; }
|
||||
} }, "Check now");
|
||||
return h("div", { class: "panel" }, h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Health"), h("div", { class: "actions" }, btn)),
|
||||
h("p", { class: "muted small" }, "Jarl connects to each listener as a client would: the certificate must be for the host name, current, and the one Jarl last wrote; then it asks for the host name."),
|
||||
out);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export async function render(el, params, query) {
|
||||
zone.type === "primary" ? ["file", "Zone file"] : null,
|
||||
["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
|
||||
zone.type === "primary" ? ["dnssec", "DNSSEC"] : null,
|
||||
zone.other_views ? ["compare", "Compare views"] : null,
|
||||
zone.registrar && state.me && state.me.admin ? ["registrar", "Registrar"] : null,
|
||||
].filter(Boolean);
|
||||
const body = h("div");
|
||||
@@ -64,6 +65,7 @@ export async function render(el, params, query) {
|
||||
else if (tab === "servers") await serversTab(body, zone);
|
||||
else if (tab === "dnssec") await dnssecTab(body, zone);
|
||||
else if (tab === "registrar") await registrarTab(body, zone);
|
||||
else if (tab === "compare") await compareTab(body, zone);
|
||||
// A later click (another tab) has taken over the page.
|
||||
if (seq !== renderSeq || !holder.isConnected) return;
|
||||
holder.replaceWith(body);
|
||||
@@ -71,6 +73,40 @@ export async function render(el, params, query) {
|
||||
if (first) first.focus();
|
||||
}
|
||||
|
||||
// ---- comparing a zone across views
|
||||
|
||||
// compareTab shows the names and types that differ between the views that
|
||||
// have the zone, with a button to share one view's version (linked zones).
|
||||
async function compareTab(el, zone) {
|
||||
let all = false;
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const r = await get("/zones/" + zone.id + "/compare", { all: all ? 1 : "" });
|
||||
const head = h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)));
|
||||
const rows = r.rows.map((row) => h("tr", { class: row.same ? "muted" : "" },
|
||||
h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
|
||||
row.views.map((vs) => h("td", { class: "compare-cell" },
|
||||
vs.records.length ? vs.records.map((d) => h("div", { class: "mono small" }, d)) : h("span", { class: "muted" }, "–"),
|
||||
vs.linked === "local" ? [badge("only " + vs.view, "accent"), " "] : null,
|
||||
r.linked && !row.same && vs.records.length ? h("button", { class: "small", title: "Make view " + vs.view + "'s version every view's: the others' records of this name and type are replaced",
|
||||
onclick: () => share(vs, row) }, "Use this version") : null))));
|
||||
mount(out,
|
||||
h("p", { class: "muted" }, r.same + " names and types are the same in " + r.views.join(", ") + (r.rows.length && !all ? "; these differ." : ".") + " ",
|
||||
r.linked ? "The zones are linked: shared records are kept in step, and a view's own records override them there." : "The zones aren't linked: a change in one doesn't reach the others. Link them (Link… above) to keep what is the same in step; linking changes no answer."),
|
||||
r.rows.length ? h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("thead", null, head), h("tbody", null, rows))) : h("div", { class: "empty" }, "Nothing differs."),
|
||||
h("label", { class: "check" }, h("input", { type: "checkbox", checked: all, onchange: (e) => { all = e.target.checked; draw(); } }), "Show what is the same too"));
|
||||
};
|
||||
const share = async (vs, row) => {
|
||||
if (!await confirmDialog("Use view " + vs.view + "'s " + row.name + " " + row.type, "Every view gets " + vs.records.join(", ") + " for " + row.name + " " + row.type + ", shared from now on; the other views' records of that name and type are replaced.", "Use this version")) return;
|
||||
try {
|
||||
toast(describeResult(await change("PATCH", "/records/" + vs.ids[0], { local: false })));
|
||||
await draw();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
await draw();
|
||||
mount(el, out);
|
||||
}
|
||||
|
||||
// ---- linked zones (docs/views.md#linked-zones)
|
||||
|
||||
// linkButton links the zone with the same zone in other views, or takes
|
||||
|
||||
@@ -557,3 +557,13 @@ header.top .header-right { display: flex; align-items: center; gap: 12px; margin
|
||||
header.top form.search input { width: 160px; }
|
||||
header.top { gap: 10px; }
|
||||
}
|
||||
|
||||
/* Encrypted DNS: the health of each listener. */
|
||||
ul.probes { list-style: none; padding: 0; margin: 0 0 8px; }
|
||||
ul.probes > li { padding: 6px 0; border-bottom: 1px solid var(--border); }
|
||||
ul.probes > li .msg { margin: 6px 0 0; }
|
||||
|
||||
/* Comparing a zone across views. */
|
||||
table.compare td.compare-cell { vertical-align: top; }
|
||||
table.compare td.compare-cell button { margin-top: 4px; }
|
||||
table.compare tr.muted td { color: var(--muted); }
|
||||
Reference in new issue
Block a user