Encrypted DNS: several host names, views per listen address, what named uses a certificate for
The setup takes several host names (one certificate for all; the health check says when the served certificate misses one), stored in the settings. Each listen address gets its own views for the host names' A/AAAA records (--address ADDR=VIEW,..., a view checkbox per address in the form), always local to the view, with a warning when a public view would get a private address; a view no longer given loses the address, other records of the names stay, and the form starts from where the records are now. Certificates (page and jarl cert show) say what named uses each for: DNS over TLS or HTTPS, on which addresses and ports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bfab83da6e
commit
fef916b7ab
19 files changed
+587
-122
No files matched your search
+183
-25
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -208,8 +209,10 @@ func GetStatus(st *model.State, certDir string) Status {
|
||||
|
||||
// Setup is what to set up.
|
||||
type Setup struct {
|
||||
// Hostname is the name clients connect to (the certificate's).
|
||||
Hostname string `json:"hostname"`
|
||||
// Hostnames are the names clients connect to; the certificate must be
|
||||
// for all of them. Hostname (one) works too.
|
||||
Hostnames []string `json:"hostnames,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
// Certificate is the Jarl certificate (with the dns use) to serve.
|
||||
Certificate string `json:"certificate"`
|
||||
// Addresses are the addresses of this server to listen on.
|
||||
@@ -218,9 +221,27 @@ type Setup struct {
|
||||
DoH bool `json:"doh"`
|
||||
DoHPort int `json:"doh_port"` // default 443
|
||||
DoTPort int `json:"dot_port"` // default 853 (clients expect it)
|
||||
// Views get the hostname's A/AAAA records (default: none). In a
|
||||
// linked zone they are local to the view.
|
||||
Views []string `json:"views"`
|
||||
// AddressViews says, per listen address, the views where the host
|
||||
// names get it (A or AAAA): a private address shouldn't reach a public
|
||||
// view. Views (every address in each) works too. The records are local
|
||||
// to their view in a linked zone.
|
||||
AddressViews map[string][]string `json:"address_views,omitempty"`
|
||||
Views []string `json:"views,omitempty"`
|
||||
// PublicViews are the views the internet reaches (filled in by jarld,
|
||||
// for a warning).
|
||||
PublicViews map[string]bool `json:"-"`
|
||||
}
|
||||
|
||||
// Hosts returns the setup's host names, normalized, the first first.
|
||||
func (s Setup) Hosts() []string {
|
||||
var out []string
|
||||
for _, h := range append(append([]string{}, s.Hostnames...), s.Hostname) {
|
||||
h = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(h), "."))
|
||||
if h != "" && !slices.Contains(out, h) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Plan returns the requests that set up encrypted DNS as s says,
|
||||
@@ -248,10 +269,15 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
|
||||
if s.Certificate == "" {
|
||||
return nil, nil, fmt.Errorf("choose the certificate to serve")
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s.Hostname), "."))
|
||||
if host == "" || !strings.Contains(host, ".") {
|
||||
hosts := s.Hosts()
|
||||
if len(hosts) == 0 {
|
||||
return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com")
|
||||
}
|
||||
for _, host := range hosts {
|
||||
if !strings.Contains(host, ".") || strings.HasPrefix(host, "*") {
|
||||
return nil, nil, fmt.Errorf("%q isn't a host name clients can connect to, e.g. dns.example.com", host)
|
||||
}
|
||||
}
|
||||
var v4, v6 []string
|
||||
for _, a := range s.Addresses {
|
||||
ip, err := netip.ParseAddr(strings.TrimSpace(a))
|
||||
@@ -325,32 +351,98 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
|
||||
add("listen-on", v4)
|
||||
add("listen-on-v6", v6)
|
||||
|
||||
// The host name's addresses, in the views asked for.
|
||||
fqdn := host + "."
|
||||
for _, vn := range s.Views {
|
||||
v := st.ViewByName(vn)
|
||||
if v == nil {
|
||||
// The host names' addresses, in the views asked for, per address.
|
||||
want := map[string][]string{} // view -> addresses
|
||||
all := append(append([]string{}, v4...), v6...)
|
||||
if s.AddressViews != nil {
|
||||
for a, vs := range s.AddressViews {
|
||||
ip, err := netip.ParseAddr(strings.TrimSpace(a))
|
||||
if err != nil || !slices.Contains(all, ip.Unmap().String()) {
|
||||
return nil, nil, fmt.Errorf("address_views: %s isn't one of the addresses to listen on", a)
|
||||
}
|
||||
for _, vn := range vs {
|
||||
want[vn] = append(want[vn], ip.Unmap().String())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, vn := range s.Views {
|
||||
want[vn] = all
|
||||
}
|
||||
}
|
||||
for vn, addrs := range want {
|
||||
if st.ViewByName(vn) == nil {
|
||||
return nil, nil, fmt.Errorf("view %q does not exist", vn)
|
||||
}
|
||||
z := zoneFor(st, fqdn, v.ID)
|
||||
if z == nil {
|
||||
notes = append(notes, fmt.Sprintf("view %s has no zone for %s, so no address records were added there; clients must find it some other way", v.Name, host))
|
||||
continue
|
||||
if s.PublicViews[vn] {
|
||||
for _, a := range addrs {
|
||||
if ip, err := netip.ParseAddr(a); err == nil && Private(ip) {
|
||||
notes = append(notes, fmt.Sprintf("view %s is reachable from the internet, but gets the private address %s for %s: its clients can't reach that", vn, a, strings.Join(hosts, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
var local *bool
|
||||
if z.LinkGroup != 0 {
|
||||
local = model.Ptr(true)
|
||||
}
|
||||
// Ours are the addresses this setup (or the one before) gives the host
|
||||
// names: a view that no longer gets one loses it; other records of the
|
||||
// names stay.
|
||||
ours := map[string]bool{}
|
||||
for _, a := range all {
|
||||
ours[a] = true
|
||||
}
|
||||
for _, l := range current {
|
||||
if l.Managed() {
|
||||
for _, a := range l.Addrs {
|
||||
ours[a] = true
|
||||
}
|
||||
}
|
||||
for _, set := range []struct {
|
||||
typ string
|
||||
addrs []string
|
||||
}{{"A", v4}, {"AAAA", v6}} {
|
||||
if len(set.addrs) == 0 {
|
||||
}
|
||||
names := slices.Clone(hosts)
|
||||
for _, h := range st.Settings.EncryptedDNSHosts {
|
||||
if !slices.Contains(names, h) {
|
||||
names = append(names, h)
|
||||
}
|
||||
}
|
||||
for _, v := range st.OrderedViews() {
|
||||
for _, host := range names {
|
||||
fqdn := host + "."
|
||||
z := zoneFor(st, fqdn, v.ID)
|
||||
wanted := want[v.Name]
|
||||
if !slices.Contains(hosts, host) {
|
||||
wanted = nil // a host name no longer used
|
||||
}
|
||||
if z == nil {
|
||||
if len(wanted) > 0 {
|
||||
notes = append(notes, fmt.Sprintf("view %s has no zone for %s, so no address records were added there; clients must find it some other way", v.Name, host))
|
||||
}
|
||||
continue
|
||||
}
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: set.typ, Data: set.addrs, Local: local}))
|
||||
for _, typ := range []string{"A", "AAAA"} {
|
||||
var have, next []string
|
||||
for _, r := range st.RRset(z.ID, fqdn, typ) {
|
||||
have = append(have, r.Data)
|
||||
if !ours[r.Data] {
|
||||
next = append(next, r.Data)
|
||||
}
|
||||
}
|
||||
for _, a := range wanted {
|
||||
if strings.Contains(a, ":") == (typ == "AAAA") && !slices.Contains(next, a) {
|
||||
next = append(next, a)
|
||||
}
|
||||
}
|
||||
slices.Sort(have)
|
||||
slices.Sort(next)
|
||||
if slices.Equal(have, next) {
|
||||
continue
|
||||
}
|
||||
if next == nil {
|
||||
next = []string{}
|
||||
}
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: typ, Data: next, Local: model.Ptr(true)}))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !slices.Equal(st.Settings.EncryptedDNSHosts, hosts) {
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{EncryptedDNSHosts: &hosts}))
|
||||
}
|
||||
return reqs, notes, nil
|
||||
}
|
||||
|
||||
@@ -369,6 +461,69 @@ func zoneFor(st *model.State, name string, viewID int64) *model.Zone {
|
||||
return best
|
||||
}
|
||||
|
||||
// CurrentAddressViews returns, per address, the views whose records give
|
||||
// one of the host names that address (what the setup form starts from).
|
||||
func CurrentAddressViews(st *model.State, hosts []string) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, v := range st.OrderedViews() {
|
||||
for _, host := range hosts {
|
||||
fqdn := strings.TrimSuffix(host, ".") + "."
|
||||
z := zoneFor(st, fqdn, v.ID)
|
||||
if z == nil {
|
||||
continue
|
||||
}
|
||||
for _, typ := range []string{"A", "AAAA"} {
|
||||
for _, r := range st.RRset(z.ID, fqdn, typ) {
|
||||
if !r.Disabled && !slices.Contains(out[r.Data], v.Name) {
|
||||
out[r.Data] = append(out[r.Data], v.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CertUses says what of named's configuration uses the certificate whose
|
||||
// files are in dir: "DNS over TLS on 10.0.10.5, fd00::5 port 853", or the
|
||||
// tls statement alone when no listener uses it.
|
||||
func CertUses(st *model.State, dir string) []string {
|
||||
var out []string
|
||||
for _, b := range TLSBlocks(st) {
|
||||
if filepath.Dir(b.CertFile) != filepath.Clean(dir) {
|
||||
continue
|
||||
}
|
||||
used := false
|
||||
byKind := map[string]map[int][]string{}
|
||||
for _, l := range Listeners(st) {
|
||||
if l.TLS != b.Name {
|
||||
continue
|
||||
}
|
||||
used = true
|
||||
k := l.Kind()
|
||||
if byKind[k] == nil {
|
||||
byKind[k] = map[int][]string{}
|
||||
}
|
||||
byKind[k][l.EffectivePort()] = append(byKind[k][l.EffectivePort()], l.Addrs...)
|
||||
}
|
||||
for _, k := range []string{"dot", "doh"} {
|
||||
label := map[string]string{"dot": "DNS over TLS", "doh": "DNS over HTTPS"}[k]
|
||||
ports := make([]int, 0, len(byKind[k]))
|
||||
for p := range byKind[k] {
|
||||
ports = append(ports, p)
|
||||
}
|
||||
slices.Sort(ports)
|
||||
for _, p := range ports {
|
||||
out = append(out, fmt.Sprintf("%s on %s port %d", label, strings.Join(byKind[k][p], ", "), p))
|
||||
}
|
||||
}
|
||||
if !used {
|
||||
out = append(out, "tls statement "+b.Name+" (no listener uses it)")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Disable returns the requests that take the setup out: its listeners and
|
||||
// tls statement. Plain listeners and the host name's records stay.
|
||||
func Disable(st *model.State) []engine.Request {
|
||||
@@ -383,6 +538,9 @@ func Disable(st *model.State) []engine.Request {
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
|
||||
}
|
||||
}
|
||||
if len(reqs) > 0 && len(st.Settings.EncryptedDNSHosts) > 0 {
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{EncryptedDNSHosts: &[]string{}}))
|
||||
}
|
||||
return reqs
|
||||
}
|
||||
|
||||
|
||||
@@ -186,3 +186,70 @@ func TestPorts(t *testing.T) {
|
||||
t.Errorf("free DoH port = %d, want 4443", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Each address gets its own views; a view no longer given loses it, and
|
||||
// records of the host names that aren't the setup's stay.
|
||||
func TestSetupAddressViews(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
e.submit(engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{Zone: "example.com", View: "external", Name: "doh.example.com.", Type: "A", Data: []string{"198.51.100.9"}, Local: model.Ptr(true)}))
|
||||
hosts := func(st *model.State, view string) []string {
|
||||
z := st.FindZone("example.com.", st.ViewByName(view).ID)
|
||||
var out []string
|
||||
for _, r := range st.ZoneRecords(z.ID) {
|
||||
if strings.HasPrefix(r.Name, "dns.") || strings.HasPrefix(r.Name, "doh.") {
|
||||
out = append(out, strings.SplitN(r.Name, ".", 2)[0]+" "+r.Type+" "+r.Data)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
setup := Setup{Hostnames: []string{"dns.example.com", "doh.example.com"}, Certificate: "dns", DoT: true, DoH: true,
|
||||
Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"},
|
||||
AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}, "10.0.10.6": {"external"}},
|
||||
PublicViews: map[string]bool{"external": true}}
|
||||
reqs, notes, err := Plan(e.state(), setup, e.certDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.ContainsFunc(notes, func(n string) bool {
|
||||
return strings.Contains(n, "view external is reachable from the internet, but gets the private address 10.0.10.6")
|
||||
}) {
|
||||
t.Errorf("notes %q", notes)
|
||||
}
|
||||
e.submit(reqs...)
|
||||
st := e.state()
|
||||
if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5", "doh A 10.0.10.5"}; !slices.Equal(got, want) {
|
||||
t.Errorf("internal %q, want %q", got, want)
|
||||
}
|
||||
// The external doh record that isn't the setup's stays.
|
||||
if got, want := hosts(st, "external"), []string{"dns A 10.0.10.6", "dns A 203.0.113.5", "doh A 10.0.10.6", "doh A 198.51.100.9", "doh A 203.0.113.5"}; !slices.Equal(got, want) {
|
||||
t.Errorf("external %q, want %q", got, want)
|
||||
}
|
||||
if !slices.Equal(st.Settings.EncryptedDNSHosts, []string{"dns.example.com", "doh.example.com"}) {
|
||||
t.Errorf("hosts %q", st.Settings.EncryptedDNSHosts)
|
||||
}
|
||||
av := CurrentAddressViews(st, st.Settings.EncryptedDNSHosts)
|
||||
if !slices.Equal(av["10.0.10.5"], []string{"internal"}) || !slices.Equal(av["10.0.10.6"], []string{"external"}) {
|
||||
t.Errorf("address views %v", av)
|
||||
}
|
||||
uses := CertUses(st, filepath.Join(e.certDir, "dns"))
|
||||
if len(uses) != 2 || !strings.HasPrefix(uses[0], "DNS over TLS on 10.0.10.5, 203.0.113.5, 10.0.10.6 port 853") || !strings.HasPrefix(uses[1], "DNS over HTTPS on") {
|
||||
t.Errorf("cert uses %q", uses)
|
||||
}
|
||||
|
||||
// 10.0.10.6 no longer in external, doh.example.com no longer a host name.
|
||||
setup = Setup{Hostname: "dns.example.com", Certificate: "dns", DoT: true, Addresses: []string{"10.0.10.5", "203.0.113.5", "10.0.10.6"},
|
||||
AddressViews: map[string][]string{"10.0.10.5": {"internal"}, "203.0.113.5": {"external"}}}
|
||||
reqs, _, err = Plan(st, setup, e.certDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.submit(reqs...)
|
||||
st = e.state()
|
||||
if got, want := hosts(st, "external"), []string{"dns A 203.0.113.5", "doh A 198.51.100.9"}; !slices.Equal(got, want) {
|
||||
t.Errorf("external after %q, want %q", got, want)
|
||||
}
|
||||
if got, want := hosts(st, "internal"), []string{"dns A 10.0.10.5"}; !slices.Equal(got, want) {
|
||||
t.Errorf("internal after %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -206,3 +206,40 @@ func describe(err error, e Endpoint) string {
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// RequireNames marks the probes whose certificate doesn't cover every host
|
||||
// name clients may use (the first is what the probe asked for).
|
||||
func (h *Health) RequireNames(hosts []string) {
|
||||
for i := range h.Probes {
|
||||
p := &h.Probes[i]
|
||||
if p.Problem != "" || len(p.CertNames) == 0 {
|
||||
continue
|
||||
}
|
||||
var missing []string
|
||||
for _, host := range hosts {
|
||||
if !nameCovered(p.CertNames, host) {
|
||||
missing = append(missing, host)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
p.OK = false
|
||||
p.Problem = fmt.Sprintf("named serves a certificate for %s, which doesn't cover %s: clients using that name reject it", strings.Join(p.CertNames, ", "), strings.Join(missing, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nameCovered(names []string, host string) bool {
|
||||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
for _, n := range names {
|
||||
n = strings.ToLower(strings.TrimSuffix(n, "."))
|
||||
if n == host {
|
||||
return true
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(n, "*."); ok {
|
||||
if _, parent, ok := strings.Cut(host, "."); ok && parent == rest {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -113,3 +113,21 @@ func TestHealth(t *testing.T) {
|
||||
t.Errorf("nothing listening: %+v", h.Probes[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireNames(t *testing.T) {
|
||||
h := &Health{Probes: []Probe{
|
||||
{Kind: "dot", OK: true, CertNames: []string{"dns.example.com", "*.example.net"}},
|
||||
{Kind: "doh", OK: false, Problem: "nothing listens", CertNames: nil},
|
||||
}}
|
||||
h.RequireNames([]string{"dns.example.com", "doh.example.net"})
|
||||
if !h.Probes[0].OK {
|
||||
t.Errorf("covered names: %+v", h.Probes[0])
|
||||
}
|
||||
h.RequireNames([]string{"dns.example.com", "doh.example.com"})
|
||||
if p := h.Probes[0]; p.OK || !strings.Contains(p.Problem, "doesn't cover doh.example.com") {
|
||||
t.Errorf("missing name: %+v", p)
|
||||
}
|
||||
if h.Probes[1].Problem != "nothing listens" {
|
||||
t.Errorf("other problem replaced: %+v", h.Probes[1])
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
s := to.Settings
|
||||
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
||||
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy,
|
||||
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse))}, "restore settings")
|
||||
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse)),
|
||||
EncryptedDNSHosts: model.Ptr(slices.Clone(s.EncryptedDNSHosts))}, "restore settings")
|
||||
}
|
||||
|
||||
for _, k := range sortedKeys(to.Keys) {
|
||||
|
||||
@@ -314,6 +314,8 @@ type SettingsInput struct {
|
||||
// DismissedReverse are dismissed reverse zone suggestions (without
|
||||
// views).
|
||||
DismissedReverse *[]string `json:"dismissed_reverse,omitempty"`
|
||||
// EncryptedDNSHosts are the encrypted DNS setup's host names.
|
||||
EncryptedDNSHosts *[]string `json:"encrypted_dns_hosts,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteInput carries options for delete operations.
|
||||
|
||||
@@ -1414,6 +1414,9 @@ func applySettings(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if in.DismissedReverse != nil {
|
||||
s.DismissedReverse = slices.Clone(*in.DismissedReverse)
|
||||
}
|
||||
if in.EncryptedDNSHosts != nil {
|
||||
s.EncryptedDNSHosts = slices.Clone(*in.EncryptedDNSHosts)
|
||||
}
|
||||
s.Version++
|
||||
return nil
|
||||
}
|
||||
@@ -275,7 +275,10 @@ type Settings struct {
|
||||
// DismissedReverse are dismissed reverse zone suggestions (without
|
||||
// views; see View).
|
||||
DismissedReverse []string `json:"dismissed_reverse,omitempty"`
|
||||
Version int `json:"version"`
|
||||
// EncryptedDNSHosts are the host names clients use for DNS over TLS
|
||||
// and HTTPS (docs/encrypted-dns.md); the first is checked first.
|
||||
EncryptedDNSHosts []string `json:"encrypted_dns_hosts,omitempty"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// BlockingPaused reports whether blocking is paused, for a time or until
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/certs"
|
||||
"jarl/internal/encdns"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,9 @@ type certView struct {
|
||||
NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next
|
||||
Files *certFiles `json:"files,omitempty"` // dns use: where named reads it
|
||||
Serving bool `json:"serving,omitempty"` // jarld serves it now (web use)
|
||||
// NamedUse says what of named's configuration uses it: "DNS over TLS
|
||||
// on 10.0.10.5 port 853" (list and get only).
|
||||
NamedUse []string `json:"named_use,omitempty"`
|
||||
// IssuingSince is set while Jarl is obtaining it.
|
||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||
}
|
||||
@@ -79,9 +83,15 @@ func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := s.store.LoadState(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []certView{}
|
||||
for _, c := range all {
|
||||
out = append(out, s.presentCert(c))
|
||||
v := s.presentCert(c)
|
||||
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||
out = append(out, v)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||
return nil
|
||||
@@ -96,7 +106,13 @@ func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.presentCert(c))
|
||||
st, err := s.store.LoadState(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v := s.presentCert(c)
|
||||
v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name))
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+41
-10
@@ -39,8 +39,13 @@ type encCert struct {
|
||||
|
||||
type encStatus struct {
|
||||
encdns.Status
|
||||
// Hostname is the name clients connect to (the certificate's first).
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
// Hostnames are the names clients connect to (the setup's, else the
|
||||
// certificate's first); Hostname is the first.
|
||||
Hostnames []string `json:"hostnames"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
// AddressViews are, per address, the views whose records give a host
|
||||
// name that address.
|
||||
AddressViews map[string][]string `json:"address_views"`
|
||||
// DoTHost and DoHURL are what to give clients.
|
||||
DoTHost string `json:"dot_host,omitempty"`
|
||||
DoHURL string `json:"doh_url,omitempty"`
|
||||
@@ -92,6 +97,15 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
||||
}
|
||||
}
|
||||
}
|
||||
if hs := st.Settings.EncryptedDNSHosts; len(hs) > 0 {
|
||||
out.Hostname = hs[0]
|
||||
out.Hostnames = hs
|
||||
} else if out.Hostname != "" {
|
||||
out.Hostnames = []string{out.Hostname}
|
||||
}
|
||||
if out.Hostnames == nil {
|
||||
out.Hostnames = []string{}
|
||||
}
|
||||
if out.Hostname != "" {
|
||||
if len(out.DoT) > 0 {
|
||||
out.DoTHost = out.Hostname
|
||||
@@ -120,17 +134,25 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
||||
out.Addresses = append(out.Addresses, encAddress{Address: a.String(), Private: encdns.Private(a)})
|
||||
}
|
||||
slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) })
|
||||
host := strings.TrimSuffix(r.URL.Query().Get("hostname"), ".")
|
||||
if host == "" {
|
||||
host = out.Hostname
|
||||
hosts := out.Hostnames
|
||||
if q := r.URL.Query().Get("hostname"); q != "" {
|
||||
hosts = nil
|
||||
for _, h := range strings.Split(q, ",") {
|
||||
if h = strings.TrimSuffix(strings.TrimSpace(h), "."); h != "" {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Where the records of these host names give each address now, the
|
||||
// setup's or not: what the form starts from.
|
||||
out.AddressViews = encdns.CurrentAddressViews(st, hosts)
|
||||
for _, x := range s.eng.Exposures(st) {
|
||||
if x.View == "" {
|
||||
continue
|
||||
}
|
||||
v := encView{Name: x.View, Public: x.Public}
|
||||
if host != "" {
|
||||
if vv := st.ViewByName(x.View); vv != nil {
|
||||
if vv := st.ViewByName(x.View); vv != nil {
|
||||
for _, host := range hosts {
|
||||
for _, z := range st.ZonesInView(vv.ID) {
|
||||
if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) {
|
||||
v.HasZone = true
|
||||
@@ -204,13 +226,17 @@ func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *au
|
||||
return badRequest("certificate %s doesn't have the dns use, so named can't read it: jarl cert update %s --use dns (and keep its other uses)", c.Name, c.Name)
|
||||
case c.NotAfter == nil:
|
||||
return conflict("certificate %s hasn't been obtained yet (%s); set up encrypted DNS once it has", c.Name, s.cfg.Certs.StatusOf(c).Message)
|
||||
case in.Hostname != "" && !certs.Covers(c.Names, []string{in.Hostname}):
|
||||
return badRequest("certificate %s is for %s, not %s: clients would reject it", c.Name, strings.Join(c.Names, ", "), in.Hostname)
|
||||
case len(in.Hosts()) > 0 && !certs.Covers(c.Names, in.Hosts()):
|
||||
return badRequest("certificate %s is for %s, not all of %s: clients would reject it (add the names to it: jarl cert update %s --domain …)", c.Name, strings.Join(c.Names, ", "), strings.Join(in.Hosts(), ", "), c.Name)
|
||||
}
|
||||
st, err := s.store.LoadState(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.PublicViews = map[string]bool{}
|
||||
for _, x := range s.eng.Exposures(st) {
|
||||
in.PublicViews[x.View] = x.Public
|
||||
}
|
||||
reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir(""))
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
@@ -222,7 +248,7 @@ func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *au
|
||||
if in.DoH {
|
||||
what = append(what, "DNS over HTTPS")
|
||||
}
|
||||
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+in.Hostname, reqs, nil, notes)
|
||||
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+strings.Join(in.Hosts(), ", "), reqs, nil, notes)
|
||||
}
|
||||
|
||||
// encryptedDNSDisable: DELETE /encrypted-dns.
|
||||
@@ -274,7 +300,12 @@ func (s *Server) checkEncryptedDNS(ctx context.Context) *encdns.Health {
|
||||
var h *encdns.Health
|
||||
if st.Enabled {
|
||||
if host, serial := s.encTarget(ctx, st); host != "" {
|
||||
hosts := state.Settings.EncryptedDNSHosts
|
||||
if len(hosts) > 0 {
|
||||
host = hosts[0]
|
||||
}
|
||||
h = encdns.Check(ctx, st, host, serial, s.encRoots)
|
||||
h.RequireNames(append([]string{host}, hosts...))
|
||||
}
|
||||
}
|
||||
s.encHealth.Lock()
|
||||
|
||||
@@ -33,7 +33,8 @@ export async function render(el) {
|
||||
{ label: "Names", render: (c) => h("div", { class: "mono small" }, (c.names && c.names.length ? c.names : c.domains).map((n) => h("div", null, n))) },
|
||||
{ label: "Used for", render: (c) => [
|
||||
c.uses.includes("web") ? badge(c.serving ? "web UI (serving)" : "web UI", c.serving ? "ok" : "") : null, " ",
|
||||
c.uses.includes("dns") ? badge("named", "") : null,
|
||||
c.uses.includes("dns") ? [badge("named", ""), (c.named_use || []).map((u) => h("div", { class: "small muted", title: u }, u.replace("DNS over TLS on ", "DoT ").replace("DNS over HTTPS on ", "DoH "))),
|
||||
!(c.named_use || []).length ? h("div", { class: "small muted" }, "not used by named yet") : null] : null,
|
||||
!c.uses.length ? h("span", { class: "muted" }, "nothing yet") : null] },
|
||||
{ label: "Source", render: (c) => c.source === "acme" ? h("span", null, "Jarl", h("div", { class: "small muted" }, "challenge " + c.challenge)) : h("span", null, "your files", h("div", { class: "small muted mono" }, c.cert_file)) },
|
||||
{ label: "Expires", render: (c) => c.not_after ? h("span", null, time(c.not_after), c.source === "acme" && c.next_attempt ? h("div", { class: "small muted" }, "renews " + fmtTime(c.next_attempt)) : null) : h("span", { class: "muted" }, "–") },
|
||||
@@ -79,6 +80,7 @@ function details(c) {
|
||||
modal("Certificate " + c.name, h("div", null,
|
||||
h("dl", { class: "kv" }, rows.map(([k, v]) => [h("dt", null, k), h("dd", null, v)])),
|
||||
c.files ? h("div", null, h("h3", null, "For named"),
|
||||
(c.named_use || []).length ? h("ul", { class: "small" }, c.named_use.map((u) => h("li", null, u))) : h("p", { class: "small muted" }, "named doesn't use it yet (Encrypted DNS sets that up)."),
|
||||
h("p", { class: "small muted" }, "Point a tls block in named's configuration at these files; Jarl rewrites them on renewal and has named reread them."),
|
||||
copyBox(c.files.chain), copyBox(c.files.key)) : null), [{ label: "Close", value: null }], { wide: true });
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ export async function render(el) {
|
||||
function statusPanel(s) {
|
||||
const eps = (list) => list.map((e) => e.address + " port " + e.port).join(", ");
|
||||
const rows = [
|
||||
h("dt", null, "Certificate"), h("dd", null, h("a", { href: "#/certificates" }, s.certificate || "?"), s.hostname ? " (" + s.hostname + ")" : null),
|
||||
h("dt", null, s.hostnames.length > 1 ? "Host names" : "Host name"), h("dd", { class: "mono" }, s.hostnames.join(", ") || "?"),
|
||||
h("dt", null, "Certificate"), h("dd", null, h("a", { href: "#/certificates" }, s.certificate || "?")),
|
||||
Object.keys(s.address_views || {}).length ? [h("dt", null, "Records"), h("dd", null, Object.entries(s.address_views).filter(([a]) => [...s.dot, ...s.doh].some((e) => e.address === a))
|
||||
.map(([a, vs]) => h("div", null, h("span", { class: "mono" }, a), h("span", { class: "muted" }, " in " + vs.join(", ")))))] : null,
|
||||
s.dot.length ? [h("dt", null, "DNS over TLS"), h("dd", { class: "mono" }, eps(s.dot))] : null,
|
||||
s.doh.length ? [h("dt", null, "DNS over HTTPS"), h("dd", { class: "mono" }, eps(s.doh))] : null,
|
||||
];
|
||||
@@ -58,7 +61,8 @@ function statusPanel(s) {
|
||||
}
|
||||
|
||||
function setupPanel(s) {
|
||||
const host = input({ value: s.hostname || "", placeholder: "dns.adbraun.lol", class: "mono", style: { maxWidth: "320px" } });
|
||||
const hostIn = input({ value: (s.hostnames || []).join(", "), placeholder: "dns.adbraun.lol", class: "mono", style: { maxWidth: "420px" } });
|
||||
const hosts = () => hostIn.value.split(/[\s,]+/).map((x) => x.trim().toLowerCase().replace(/\.$/, "")).filter(Boolean);
|
||||
const certSel = h("select");
|
||||
const dot = checkbox("DNS over TLS", s.enabled ? s.dot.length > 0 : true);
|
||||
const doh = checkbox("DNS over HTTPS", s.enabled ? s.doh.length > 0 : true);
|
||||
@@ -69,40 +73,64 @@ function setupPanel(s) {
|
||||
const portOut = h("div");
|
||||
const err = h("div");
|
||||
const progress = h("div", { class: "muted small" });
|
||||
let views = s.views;
|
||||
// Per address, the views where the host names get it: what their records
|
||||
// say now (the setup's, or ones made by hand, which would otherwise be
|
||||
// taken out), nothing else.
|
||||
const viewsFor = new Map(s.addresses.map((a) => [a.address, new Set((s.address_views || {})[a.address] || [])]));
|
||||
const isPrivate = new Map(s.addresses.map((a) => [a.address, a.private]));
|
||||
|
||||
// Addresses: one per row, nothing ticked (but the current setup's).
|
||||
const addrRows = s.addresses.map((a) => pickRow(a.address, picked, [a.private ? h("span", { class: "muted" }, "private")
|
||||
: a.address.includes(":") ? h("span", { class: "muted", title: "A global IPv6 address: reachable from the internet unless the firewall stops it" }, "global")
|
||||
: badge("public", "warn")],
|
||||
"Listen on " + a.address, () => checkPorts()));
|
||||
// Views for the host name's records: the private ones that have its zone.
|
||||
const viewPicked = new Set();
|
||||
const viewBody = h("tbody");
|
||||
const drawViews = (views) => {
|
||||
viewPicked.clear();
|
||||
mount(viewBody, views.map((v) => {
|
||||
if (!v.public && v.has_zone) viewPicked.add(v.name);
|
||||
return pickRow(v.name, viewPicked, [v.public ? badge("public", "warn") : h("span", { class: "muted" }, "private"), " ",
|
||||
v.has_zone ? h("span", { class: "muted small" }, "has the zone") : h("span", { class: "muted small" }, "no zone for this name: skipped")], "Add the host name to view " + v.name);
|
||||
}));
|
||||
const viewCells = new Map();
|
||||
const drawViewCell = (a) => {
|
||||
const cell = viewCells.get(a);
|
||||
if (!cell) return;
|
||||
if (!picked.has(a)) return mount(cell);
|
||||
const set = viewsFor.get(a);
|
||||
const warn = isPrivate.get(a) && views.some((v) => v.public && set.has(v.name));
|
||||
mount(cell, h("div", { class: "view-chips" }, h("span", { class: "muted small" }, "records in: "),
|
||||
views.map((v) => {
|
||||
const box = h("input", { type: "checkbox", checked: set.has(v.name), disabled: !v.has_zone && !set.has(v.name) });
|
||||
box.addEventListener("change", () => { if (box.checked) set.add(v.name); else set.delete(v.name); drawViewCell(a); });
|
||||
return h("label", { class: "chip" + (v.public ? " public" : ""), title: v.has_zone ? (v.public ? "A public view: the internet's clients get these records" : "") : "View " + v.name + " has no zone for the host names" }, box, " " + v.name, v.public ? h("span", { class: "muted small" }, " (public)") : null);
|
||||
}),
|
||||
!views.length ? h("span", { class: "muted small" }, "no views") : null),
|
||||
warn ? h("div", { class: "msg warn small" }, a + " is a private address: clients of a public view can't reach it.") : null);
|
||||
};
|
||||
drawViews(s.views);
|
||||
const addrRows = s.addresses.map((a) => {
|
||||
const cell = h("td", { class: "addr-views" });
|
||||
// The row toggles the address; clicks on its views shouldn't.
|
||||
cell.addEventListener("click", (e) => e.stopPropagation());
|
||||
viewCells.set(a.address, cell);
|
||||
const tr = pickRow(a.address, picked, [a.private ? h("span", { class: "muted" }, "private")
|
||||
: a.address.includes(":") ? h("span", { class: "muted", title: "A global IPv6 address: reachable from the internet unless the firewall stops it" }, "global")
|
||||
: badge("public", "warn")],
|
||||
"Listen on " + a.address, () => { drawViewCell(a.address); checkPorts(); });
|
||||
tr.append(cell);
|
||||
drawViewCell(a.address);
|
||||
return tr;
|
||||
});
|
||||
const redrawViews = () => { for (const a of viewCells.keys()) drawViewCell(a); };
|
||||
|
||||
const fillCerts = () => {
|
||||
const name = host.value.trim().toLowerCase().replace(/\.$/, "");
|
||||
const fit = s.certificates.filter((c) => covers([...(c.names || []), ...(c.domains || [])], name));
|
||||
const names = hosts();
|
||||
const fit = s.certificates.filter((c) => names.length && names.every((n) => covers([...(c.names || []), ...(c.domains || [])], n)));
|
||||
mount(certSel,
|
||||
fit.map((c) => h("option", { value: c.name, selected: c.name === s.certificate }, c.name + " (" + (c.names || c.domains).join(", ") + (c.obtained ? "" : ", not obtained yet") + ")")),
|
||||
name ? h("option", { value: "" }, "Obtain a new certificate for " + name) : h("option", { value: "" }, "Type the host name first"));
|
||||
names.length ? h("option", { value: "" }, "Obtain a new certificate for " + names.join(", ")) : h("option", { value: "" }, "Type the host names first"));
|
||||
};
|
||||
fillCerts();
|
||||
let viewTimer = null;
|
||||
host.addEventListener("input", () => {
|
||||
hostIn.addEventListener("input", () => {
|
||||
fillCerts();
|
||||
clearTimeout(viewTimer);
|
||||
viewTimer = setTimeout(async () => {
|
||||
const r = await get("/encrypted-dns", { hostname: host.value.trim() }).catch(() => null);
|
||||
if (r) drawViews(r.views);
|
||||
const r = await get("/encrypted-dns", { hostname: hosts().join(",") }).catch(() => null);
|
||||
if (r) {
|
||||
views = r.views;
|
||||
// Records the new host names already have stay ticked.
|
||||
for (const [a, vs] of Object.entries(r.address_views || {})) if (viewsFor.has(a)) vs.forEach((v) => viewsFor.get(a).add(v));
|
||||
redrawViews();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
@@ -129,23 +157,26 @@ function setupPanel(s) {
|
||||
if (picked.size) checkPorts();
|
||||
|
||||
const body = (full) => {
|
||||
const b = { hostname: host.value.trim(), addresses: [...picked], dot: dot.input.checked, doh: doh.input.checked, dot_port: Number(dotPort.value) || 853, doh_port: Number(dohPort.value) || 443 };
|
||||
if (full) { b.certificate = certSel.value; b.views = [...viewPicked]; }
|
||||
const b = { hostnames: hosts(), addresses: [...picked], dot: dot.input.checked, doh: doh.input.checked, dot_port: Number(dotPort.value) || 853, doh_port: Number(dohPort.value) || 443 };
|
||||
if (full) {
|
||||
b.certificate = certSel.value;
|
||||
b.address_views = Object.fromEntries([...picked].map((a) => [a, [...(viewsFor.get(a) || [])]]));
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
const submit = async (btn) => {
|
||||
mount(err);
|
||||
const b = body(true);
|
||||
if (!b.hostname) return mount(err, h("div", { class: "msg err" }, "Give the host name clients connect to."));
|
||||
if (!b.hostnames.length) return mount(err, h("div", { class: "msg err" }, "Give the host name clients connect to."));
|
||||
if (!b.addresses.length) return mount(err, h("div", { class: "msg err" }, "Choose at least one address to listen on."));
|
||||
btn.disabled = true;
|
||||
try {
|
||||
if (!b.certificate) {
|
||||
// Obtain one for the host name, for named, and wait for it.
|
||||
b.certificate = b.hostname.split(".")[0].replace(/[^a-z0-9_-]/g, "-");
|
||||
mount(progress, "Obtaining a certificate for " + b.hostname + " from the CA…");
|
||||
await api("POST", "/certificates", { body: { name: b.certificate, domains: [b.hostname], uses: ["dns"] } });
|
||||
// Obtain one for the host names, for named, and wait for it.
|
||||
b.certificate = b.hostnames[0].split(".")[0].replace(/[^a-z0-9_-]/g, "-");
|
||||
mount(progress, "Obtaining a certificate for " + b.hostnames.join(", ") + " from the CA…");
|
||||
await api("POST", "/certificates", { body: { name: b.certificate, domains: b.hostnames, uses: ["dns"] } });
|
||||
await waitObtained(b.certificate, progress);
|
||||
} else {
|
||||
const c = await get("/certificates/" + encodeURIComponent(b.certificate));
|
||||
@@ -170,14 +201,12 @@ function setupPanel(s) {
|
||||
return h("div", { class: "panel" },
|
||||
h("h2", null, s.enabled ? "Change the setup" : "Set up"),
|
||||
h("div", { class: "form-grid" },
|
||||
field("Host name", host, "The name clients connect to. The certificate is for it, and it gets this server's addresses in the views below."),
|
||||
field("Certificate", certSel, "One with this name, or a new one Jarl obtains and renews (docs/certificates.md)."),
|
||||
field("Host names", hostIn, "The names clients connect to, separated by commas: dns.adbraun.lol, or several (dns.adbraun.lol, doh.adbraun.lol). The certificate must be for all of them."),
|
||||
field("Certificate", certSel, "One for all the host names, or a new one Jarl obtains and renews (docs/certificates.md)."),
|
||||
field("Listen on", h("div", null, h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, h("tbody", null, addrRows))), portOut),
|
||||
"This server's addresses on your own networks. A public address works too, but then anyone who reaches it can use these listeners, as with port 53 there."),
|
||||
"This server's addresses on your own networks. For each, tick the views where the host names get it (A or AAAA records, local to the view): a view's clients should only be given an address they can reach."),
|
||||
field("Serve", h("div", { class: "enc-ports" }, h("div", null, dot, " on port ", dotPort), h("div", null, doh, " on port ", dohPort, h("span", { class: "muted small" }, " at /dns-query"))),
|
||||
"Clients expect DoT on port 853. DoH can use any free port; 443 is the default, and needs nothing else listening there."),
|
||||
field("Host name in views", h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, viewBody)),
|
||||
"Where the host name gets the chosen addresses (A and AAAA), local to the view in a linked zone. Clients outside these views can't find it.")),
|
||||
"Clients expect DoT on port 853. DoH can use any free port; 443 is the default, and needs nothing else listening there. Ports can be changed later: clients set up with another port need it changed too.")),
|
||||
err, progress,
|
||||
h("div", { class: "buttons" }, go));
|
||||
}
|
||||
|
||||
@@ -590,3 +590,8 @@ pre.wrap { white-space: pre-wrap; word-break: break-all; }
|
||||
.size-chips button.active { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
.cands { display: flex; flex-direction: column; gap: 3px; max-height: 260px; overflow-y: auto; }
|
||||
.cands button.cand { flex-shrink: 0; text-align: left; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* Encrypted DNS: per listen address, the views that get its records. */
|
||||
td.addr-views { width: 100%; }
|
||||
.view-chips { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; }
|
||||
.view-chips label.chip { white-space: nowrap; font-size: 0.9em; }
|
||||
Reference in new issue
Block a user