mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 18:24:35 +03:00
mox!
This commit is contained in:
282
updates/updates.go
Normal file
282
updates/updates.go
Normal file
@ -0,0 +1,282 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
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
|
||||
}
|
152
updates/updates_test.go
Normal file
152
updates/updates_test.go
Normal file
@ -0,0 +1,152 @@
|
||||
package updates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
)
|
||||
|
||||
func TestUpdates(t *testing.T) {
|
||||
resolver := dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
"_updates.mox.example.": {"v=UPDATES0; l=v0.0.1"},
|
||||
"_updates.one.example.": {"other", "v=UPDATES0; l=v0.0.1-rc1"},
|
||||
"_updates.dup.example.": {"v=UPDATES0; l=v0.0.1", "v=UPDATES0; l=v0.0.1"},
|
||||
"_updates.other.example.": {"other"},
|
||||
"_updates.malformed.example.": {"v=UPDATES0; l=bogus"},
|
||||
"_updates.malformed2.example.": {"v=UPDATES0; bogus"},
|
||||
"_updates.malformed3.example.": {"v=UPDATES0; l=v0.0.1; l=v0.0.1"},
|
||||
"_updates.temperror.example.": {"v=UPDATES0; l=v0.0.1"},
|
||||
"_updates.unknown.example.": {"v=UPDATES0; l=v0.0.1; unknown=ok"},
|
||||
},
|
||||
Fail: map[dns.Mockreq]struct{}{
|
||||
{Type: "txt", Name: "_updates.temperror.example."}: {},
|
||||
},
|
||||
}
|
||||
|
||||
lookup := func(dom string, expVersion string, expRecord *Record, expErr error) {
|
||||
t.Helper()
|
||||
|
||||
d, _ := dns.ParseDomain(dom)
|
||||
expv, _ := ParseVersion(expVersion)
|
||||
|
||||
version, record, err := Lookup(context.Background(), resolver, d)
|
||||
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
|
||||
t.Fatalf("lookup: got err %v, expected %v", err, expErr)
|
||||
}
|
||||
if version != expv || !reflect.DeepEqual(record, expRecord) {
|
||||
t.Fatalf("lookup: got version %v, record %#v, expected %v %#v", version, record, expv, expRecord)
|
||||
}
|
||||
}
|
||||
|
||||
lookup("mox.example", "v0.0.1", &Record{Version: "UPDATES0", Latest: Version{0, 0, 1}}, nil)
|
||||
lookup("one.example", "v0.0.1", &Record{Version: "UPDATES0", Latest: Version{0, 0, 1}}, nil)
|
||||
lookup("absent.example", "", nil, ErrNoRecord)
|
||||
lookup("dup.example", "", nil, ErrMultipleRecords)
|
||||
lookup("other.example", "", nil, ErrNoRecord)
|
||||
lookup("malformed.example", "", nil, ErrRecordSyntax)
|
||||
lookup("malformed2.example", "", nil, ErrRecordSyntax)
|
||||
lookup("malformed3.example", "", nil, ErrRecordSyntax)
|
||||
lookup("temperror.example", "", nil, ErrDNS)
|
||||
lookup("unknown.example", "v0.0.1", &Record{Version: "UPDATES0", Latest: Version{0, 0, 1}}, nil)
|
||||
|
||||
seed := make([]byte, ed25519.SeedSize)
|
||||
priv := ed25519.NewKeyFromSeed(seed)
|
||||
pub := []byte(priv.Public().(ed25519.PublicKey))
|
||||
changelog := Changelog{
|
||||
Changes: []Change{
|
||||
{
|
||||
PubKey: pub,
|
||||
Sig: ed25519.Sign(priv, []byte("test")),
|
||||
Text: "test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fetch := func(baseURL string, version Version, status int, pubKey []byte, expChangelog *Changelog, expErr error) {
|
||||
t.Helper()
|
||||
|
||||
mux := &http.ServeMux{}
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if status == 0 {
|
||||
panic("bad serve")
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
err := json.NewEncoder(w).Encode(changelog)
|
||||
if err != nil {
|
||||
t.Fatalf("encode changelog: %v", err)
|
||||
}
|
||||
})
|
||||
s := httptest.NewUnstartedServer(mux)
|
||||
s.Config.ErrorLog = log.New(io.Discard, "", 0)
|
||||
s.Start()
|
||||
defer s.Close()
|
||||
if baseURL == "" {
|
||||
baseURL = s.URL
|
||||
}
|
||||
|
||||
changelog, err := FetchChangelog(context.Background(), baseURL, version, pubKey)
|
||||
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
|
||||
t.Fatalf("fetch changelog: got err %v, expected %v", err, expErr)
|
||||
}
|
||||
if !reflect.DeepEqual(changelog, expChangelog) {
|
||||
t.Fatalf("fetch changelog: got changelog %v, expected %v", changelog, expChangelog)
|
||||
}
|
||||
}
|
||||
|
||||
fetch("", Version{}, 200, pub, &changelog, nil)
|
||||
fetch("", Version{1, 1, 1}, 200, pub, &changelog, nil)
|
||||
fetch("", Version{}, 200, make([]byte, ed25519.PublicKeySize), nil, ErrChangelogFetch) // Invalid public key.
|
||||
changelog.Changes[0].Text = "bad"
|
||||
fetch("", Version{}, 200, pub, nil, ErrChangelogFetch) // Invalid signature.
|
||||
changelog.Changes[0].Text = "test"
|
||||
fetch("", Version{}, 404, pub, nil, ErrChangelogFetch)
|
||||
fetch("", Version{}, 503, pub, nil, ErrChangelogFetch)
|
||||
fetch("", Version{}, 0, pub, nil, ErrChangelogFetch)
|
||||
fetch("bogusurl", Version{}, 200, pub, nil, ErrChangelogFetch)
|
||||
|
||||
check := func(dom string, base Version, baseURL string, status int, pubKey []byte, expVersion Version, expRecord *Record, expChangelog *Changelog, expErr error) {
|
||||
t.Helper()
|
||||
|
||||
mux := &http.ServeMux{}
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if status == 0 {
|
||||
panic("bad serve")
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
err := json.NewEncoder(w).Encode(changelog)
|
||||
if err != nil {
|
||||
t.Fatalf("encode changelog: %v", err)
|
||||
}
|
||||
})
|
||||
s := httptest.NewUnstartedServer(mux)
|
||||
s.Config.ErrorLog = log.New(io.Discard, "", 0)
|
||||
s.Start()
|
||||
defer s.Close()
|
||||
if baseURL == "" {
|
||||
baseURL = s.URL
|
||||
}
|
||||
|
||||
version, record, changelog, err := Check(context.Background(), resolver, dns.Domain{ASCII: dom}, base, baseURL, pubKey)
|
||||
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
|
||||
t.Fatalf("check: got err %v, expected %v", err, expErr)
|
||||
}
|
||||
if version != expVersion || !reflect.DeepEqual(record, expRecord) || !reflect.DeepEqual(changelog, expChangelog) {
|
||||
t.Fatalf("check: got version %v, record %#v, changelog %v, expected %v %#v %v", version, record, changelog, expVersion, expRecord, expChangelog)
|
||||
}
|
||||
}
|
||||
|
||||
check("mox.example", Version{0, 0, 1}, "", 0, pub, Version{0, 0, 1}, &Record{Version: "UPDATES0", Latest: Version{0, 0, 1}}, nil, nil)
|
||||
check("mox.example", Version{0, 0, 0}, "", 200, pub, Version{0, 0, 1}, &Record{Version: "UPDATES0", Latest: Version{0, 0, 1}}, &changelog, nil)
|
||||
check("mox.example", Version{0, 0, 0}, "", 0, pub, Version{0, 0, 1}, &Record{Version: "UPDATES0", Latest: Version{0, 0, 1}}, nil, ErrChangelogFetch)
|
||||
check("absent.example", Version{0, 0, 1}, "", 200, pub, Version{}, nil, nil, ErrNoRecord)
|
||||
}
|
Reference in New Issue
Block a user