mirror of
https://github.com/mjl-/mox.git
synced 2024-12-27 08:53:48 +03:00
daa908e9f4
the vendored dns resolver code is a copy of the go stdlib dns resolver, with awareness of the "authentic data" (i.e. dnssec secure) added, as well as support for enhanced dns errors, and looking up tlsa records (for dane). ideally it would be upstreamed, but the chances seem slim. dnssec-awareness is added to all packages, e.g. spf, dkim, dmarc, iprev. their dnssec status is added to the Received message headers for incoming email. but the main reason to add dnssec was for implementing dane. with dane, the verification of tls certificates can be done through certificates/public keys published in dns (in the tlsa records). this only makes sense (is trustworthy) if those dns records can be verified to be authentic. mox now applies dane to delivering messages over smtp. mox already implemented mta-sts for webpki/pkix-verification of certificates against the (large) pool of CA's, and still enforces those policies when present. but it now also checks for dane records, and will verify those if present. if dane and mta-sts are both absent, the regular opportunistic tls with starttls is still done. and the fallback to plaintext is also still done. mox also makes it easy to setup dane for incoming deliveries, so other servers can deliver with dane tls certificate verification. the quickstart now generates private keys that are used when requesting certificates with acme. the private keys are pre-generated because they must be static and known during setup, because their public keys must be published in tlsa records in dns. autocert would generate private keys on its own, so had to be forked to add the option to provide the private key when requesting a new certificate. hopefully upstream will accept the change and we can drop the fork. with this change, using the quickstart to setup a new mox instance, the checks at internet.nl result in a 100% score, provided the domain is dnssec-signed and the network doesn't have any issues.
283 lines
9 KiB
Go
283 lines
9 KiB
Go
// Package updates implements a mechanism for checking if software updates are
|
|
// available, and fetching a changelog.
|
|
//
|
|
// Given a domain, the latest version of the software is queried in DNS from
|
|
// "_updates.<domain>" as a TXT record. If a new version is available, the
|
|
// changelog compared to a last known version can be retrieved. A changelog base
|
|
// URL and public key for signatures has to be specified explicitly.
|
|
//
|
|
// Downloading or upgrading to the latest version is not part of this package.
|
|
package updates
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
|
|
"github.com/mjl-/mox/dns"
|
|
"github.com/mjl-/mox/metrics"
|
|
"github.com/mjl-/mox/mlog"
|
|
"github.com/mjl-/mox/moxio"
|
|
)
|
|
|
|
var xlog = mlog.New("updates")
|
|
|
|
var (
|
|
metricLookup = promauto.NewHistogramVec(
|
|
prometheus.HistogramOpts{
|
|
Name: "mox_updates_lookup_duration_seconds",
|
|
Help: "Updates lookup with result.",
|
|
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20, 30},
|
|
},
|
|
[]string{"result"},
|
|
)
|
|
metricFetchChangelog = promauto.NewHistogramVec(
|
|
prometheus.HistogramOpts{
|
|
Name: "mox_updates_fetchchangelog_duration_seconds",
|
|
Help: "Fetch changelog with result.",
|
|
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20, 30},
|
|
},
|
|
[]string{"result"},
|
|
)
|
|
)
|
|
|
|
var (
|
|
// Lookup errors.
|
|
ErrDNS = errors.New("updates: dns error")
|
|
ErrRecordSyntax = errors.New("updates: dns record syntax")
|
|
ErrNoRecord = errors.New("updates: no dns record")
|
|
ErrMultipleRecords = errors.New("updates: multiple dns records")
|
|
ErrBadVersion = errors.New("updates: malformed version")
|
|
|
|
// Fetch changelog errors.
|
|
ErrChangelogFetch = errors.New("updates: fetching changelog")
|
|
)
|
|
|
|
// Change is a an entry in the changelog, a released version.
|
|
type Change struct {
|
|
PubKey []byte // Key used for signing.
|
|
Sig []byte // Signature over text, with ed25519.
|
|
Text string // Signed changelog entry, starts with header similar to email, with at least fields "version" and "date".
|
|
}
|
|
|
|
// Changelog is returned as JSON.
|
|
//
|
|
// The changelog itself is not signed, only individual changes. The goal is to
|
|
// prevent a potential future different domain owner from notifying users about
|
|
// new versions.
|
|
type Changelog struct {
|
|
Changes []Change // Newest first.
|
|
}
|
|
|
|
// Lookup looks up the updates DNS TXT record at "_updates.<domain>" and returns
|
|
// the parsed form.
|
|
func Lookup(ctx context.Context, resolver dns.Resolver, domain dns.Domain) (rversion Version, rrecord *Record, rerr error) {
|
|
log := xlog.WithContext(ctx)
|
|
start := time.Now()
|
|
defer func() {
|
|
var result = "ok"
|
|
if rerr != nil {
|
|
result = "error"
|
|
}
|
|
metricLookup.WithLabelValues(result).Observe(float64(time.Since(start)) / float64(time.Second))
|
|
log.Debugx("updates lookup result", rerr, mlog.Field("domain", domain), mlog.Field("version", rversion), mlog.Field("record", rrecord), mlog.Field("duration", time.Since(start)))
|
|
}()
|
|
|
|
nctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
name := "_updates." + domain.ASCII + "."
|
|
txts, _, err := dns.WithPackage(resolver, "updates").LookupTXT(nctx, name)
|
|
if dns.IsNotFound(err) {
|
|
return Version{}, nil, ErrNoRecord
|
|
} else if err != nil {
|
|
return Version{}, nil, fmt.Errorf("%w: %s", ErrDNS, err)
|
|
}
|
|
var record *Record
|
|
for _, txt := range txts {
|
|
r, isupdates, err := ParseRecord(txt)
|
|
if !isupdates {
|
|
continue
|
|
} else if err != nil {
|
|
return Version{}, nil, err
|
|
}
|
|
if record != nil {
|
|
return Version{}, nil, ErrMultipleRecords
|
|
}
|
|
record = r
|
|
}
|
|
|
|
if record == nil {
|
|
return Version{}, nil, ErrNoRecord
|
|
}
|
|
return record.Latest, record, nil
|
|
}
|
|
|
|
// FetchChangelog fetches the changelog compared against the base version, which
|
|
// can be the Version zero value.
|
|
//
|
|
// The changelog is requested using HTTP GET from baseURL with optional "from"
|
|
// query string parameter.
|
|
//
|
|
// Individual changes are verified using pubKey. If any signature is invalid, an
|
|
// error is returned.
|
|
//
|
|
// A changelog can be maximum 1 MB.
|
|
func FetchChangelog(ctx context.Context, baseURL string, base Version, pubKey []byte) (changelog *Changelog, rerr error) {
|
|
log := xlog.WithContext(ctx)
|
|
start := time.Now()
|
|
defer func() {
|
|
var result = "ok"
|
|
if rerr != nil {
|
|
result = "error"
|
|
}
|
|
metricFetchChangelog.WithLabelValues(result).Observe(float64(time.Since(start)) / float64(time.Second))
|
|
log.Debugx("updates fetch changelog result", rerr, mlog.Field("baseurl", baseURL), mlog.Field("base", base), mlog.Field("duration", time.Since(start)))
|
|
}()
|
|
|
|
url := baseURL + "?from=" + base.String()
|
|
nctx, cancel := context.WithTimeout(ctx, time.Minute)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(nctx, "GET", url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("making request: %v", err)
|
|
}
|
|
req.Header.Add("Accept", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if resp == nil {
|
|
resp = &http.Response{StatusCode: 0}
|
|
}
|
|
metrics.HTTPClientObserve(ctx, "updates", req.Method, resp.StatusCode, err, start)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: making http request: %s", ErrChangelogFetch, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("%w: http status: %s", ErrChangelogFetch, resp.Status)
|
|
}
|
|
var cl Changelog
|
|
if err := json.NewDecoder(&moxio.LimitReader{R: resp.Body, Limit: 1024 * 1024}).Decode(&cl); err != nil {
|
|
return nil, fmt.Errorf("%w: parsing changelog: %s", ErrChangelogFetch, err)
|
|
}
|
|
for _, c := range cl.Changes {
|
|
if !bytes.Equal(c.PubKey, pubKey) {
|
|
return nil, fmt.Errorf("%w: verifying change: signed with unknown public key %x instead of %x", ErrChangelogFetch, c.PubKey, pubKey)
|
|
}
|
|
if !ed25519.Verify(c.PubKey, []byte(c.Text), c.Sig) {
|
|
return nil, fmt.Errorf("%w: verifying change: invalid signature for change", ErrChangelogFetch)
|
|
}
|
|
}
|
|
|
|
return &cl, nil
|
|
}
|
|
|
|
// Check checks for an updated version through DNS and fetches a
|
|
// changelog if so.
|
|
func Check(ctx context.Context, resolver dns.Resolver, domain dns.Domain, lastKnown Version, changelogBaseURL string, pubKey []byte) (rversion Version, rrecord *Record, changelog *Changelog, rerr error) {
|
|
log := xlog.WithContext(ctx)
|
|
start := time.Now()
|
|
defer func() {
|
|
log.Debugx("updates check result", rerr, mlog.Field("domain", domain), mlog.Field("lastknown", lastKnown), mlog.Field("changelogbaseurl", changelogBaseURL), mlog.Field("version", rversion), mlog.Field("record", rrecord), mlog.Field("duration", time.Since(start)))
|
|
}()
|
|
|
|
latest, record, err := Lookup(ctx, resolver, domain)
|
|
if err != nil {
|
|
return latest, record, nil, err
|
|
}
|
|
|
|
if latest.After(lastKnown) {
|
|
changelog, err = FetchChangelog(ctx, changelogBaseURL, lastKnown, pubKey)
|
|
}
|
|
return latest, record, changelog, err
|
|
}
|
|
|
|
// Version is a specified version in an updates records.
|
|
type Version struct {
|
|
Major int
|
|
Minor int
|
|
Patch int
|
|
}
|
|
|
|
// After returns if v comes after ov.
|
|
func (v Version) After(ov Version) bool {
|
|
return v.Major > ov.Major || v.Major == ov.Major && v.Minor > ov.Minor || v.Major == ov.Major && v.Minor == ov.Minor && v.Patch > ov.Patch
|
|
}
|
|
|
|
// String returns a human-reasonable version, also for use in the updates
|
|
// record.
|
|
func (v Version) String() string {
|
|
return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
|
|
}
|
|
|
|
// ParseVersion parses a version as used in an updates records.
|
|
//
|
|
// Rules:
|
|
// - Optionally start with "v"
|
|
// - A dash and anything after it is ignored, e.g. for non-release modifiers.
|
|
// - Remaining string must be three dot-separated numbers.
|
|
func ParseVersion(s string) (Version, error) {
|
|
s = strings.TrimPrefix(s, "v")
|
|
s = strings.Split(s, "-")[0]
|
|
t := strings.Split(s, ".")
|
|
if len(t) != 3 {
|
|
return Version{}, fmt.Errorf("%w: %v", ErrBadVersion, t)
|
|
}
|
|
nums := make([]int, 3)
|
|
for i, v := range t {
|
|
n, err := strconv.ParseInt(v, 10, 32)
|
|
if err != nil {
|
|
return Version{}, fmt.Errorf("%w: parsing int %q: %s", ErrBadVersion, v, err)
|
|
}
|
|
nums[i] = int(n)
|
|
}
|
|
return Version{nums[0], nums[1], nums[2]}, nil
|
|
}
|
|
|
|
// Record is an updates DNS record.
|
|
type Record struct {
|
|
Version string // v=UPDATES0, required and must always be first.
|
|
Latest Version // l=<version>, required.
|
|
}
|
|
|
|
// ParseRecord parses an updates DNS TXT record as served at
|
|
func ParseRecord(txt string) (record *Record, isupdates bool, err error) {
|
|
l := strings.Split(txt, ";")
|
|
vkv := strings.SplitN(strings.TrimSpace(l[0]), "=", 2)
|
|
if len(vkv) != 2 || vkv[0] != "v" || !strings.EqualFold(vkv[1], "UPDATES0") {
|
|
return nil, false, nil
|
|
}
|
|
|
|
r := &Record{Version: "UPDATES0"}
|
|
seen := map[string]bool{}
|
|
for _, t := range l[1:] {
|
|
kv := strings.SplitN(strings.TrimSpace(t), "=", 2)
|
|
if len(kv) != 2 {
|
|
return nil, true, ErrRecordSyntax
|
|
}
|
|
k := strings.ToLower(kv[0])
|
|
if seen[k] {
|
|
return nil, true, fmt.Errorf("%w: duplicate key %q", ErrRecordSyntax, k)
|
|
}
|
|
seen[k] = true
|
|
switch k {
|
|
case "l":
|
|
v, err := ParseVersion(kv[1])
|
|
if err != nil {
|
|
return nil, true, fmt.Errorf("%w: %s", ErrRecordSyntax, err)
|
|
}
|
|
r.Latest = v
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
return r, true, nil
|
|
}
|