1// Package updates implements a mechanism for checking if software updates are
2// available, and fetching a changelog.
4// Given a domain, the latest version of the software is queried in DNS from
5// "_updates.<domain>" as a TXT record. If a new version is available, the
6// changelog compared to a last known version can be retrieved. A changelog base
7// URL and public key for signatures has to be specified explicitly.
9// Downloading or upgrading to the latest version is not part of this package.
25 "github.com/mjl-/mox/dns"
26 "github.com/mjl-/mox/mlog"
27 "github.com/mjl-/mox/moxio"
28 "github.com/mjl-/mox/stub"
32 MetricLookup stub.HistogramVec = stub.HistogramVecIgnore{}
33 MetricFetchChangelog stub.HistogramVec = stub.HistogramVecIgnore{}
34 HTTPClientObserve func(ctx context.Context, log *slog.Logger, pkg, method string, statusCode int, err error, start time.Time) = stub.HTTPClientObserveIgnore
39 ErrDNS = errors.New("updates: dns error")
40 ErrRecordSyntax = errors.New("updates: dns record syntax")
41 ErrNoRecord = errors.New("updates: no dns record")
42 ErrMultipleRecords = errors.New("updates: multiple dns records")
43 ErrBadVersion = errors.New("updates: malformed version")
45 // Fetch changelog errors.
46 ErrChangelogFetch = errors.New("updates: fetching changelog")
49// Change is a an entry in the changelog, a released version.
51 PubKey []byte // Key used for signing.
52 Sig []byte // Signature over text, with ed25519.
53 Text string // Signed changelog entry, starts with header similar to email, with at least fields "version" and "date".
56// Changelog is returned as JSON.
58// The changelog itself is not signed, only individual changes. The goal is to
59// prevent a potential future different domain owner from notifying users about
61type Changelog struct {
62 Changes []Change // Newest first.
65// Lookup looks up the updates DNS TXT record at "_updates.<domain>" and returns
67func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain) (rversion Version, rrecord *Record, rerr error) {
68 log := mlog.New("updates", elog)
75 MetricLookup.ObserveLabels(float64(time.Since(start))/float64(time.Second), result)
76 log.Debugx("updates lookup result", rerr,
77 slog.Any("domain", domain),
78 slog.Any("version", rversion),
79 slog.Any("record", rrecord),
80 slog.Duration("duration", time.Since(start)))
83 nctx, cancel := context.WithTimeout(ctx, 30*time.Second)
85 name := "_updates." + domain.ASCII + "."
86 txts, _, err := dns.WithPackage(resolver, "updates").LookupTXT(nctx, name)
87 if dns.IsNotFound(err) {
88 return Version{}, nil, ErrNoRecord
89 } else if err != nil {
90 return Version{}, nil, fmt.Errorf("%w: %s", ErrDNS, err)
93 for _, txt := range txts {
94 r, isupdates, err := ParseRecord(txt)
97 } else if err != nil {
98 return Version{}, nil, err
101 return Version{}, nil, ErrMultipleRecords
107 return Version{}, nil, ErrNoRecord
109 return record.Latest, record, nil
112// FetchChangelog fetches the changelog compared against the base version, which
113// can be the Version zero value.
115// The changelog is requested using HTTP GET from baseURL with optional "from"
116// query string parameter.
118// Individual changes are verified using pubKey. If any signature is invalid, an
121// A changelog can be maximum 1 MB.
122func FetchChangelog(ctx context.Context, elog *slog.Logger, baseURL string, base Version, pubKey []byte) (changelog *Changelog, rerr error) {
123 log := mlog.New("updates", elog)
130 MetricFetchChangelog.ObserveLabels(float64(time.Since(start))/float64(time.Second), result)
131 log.Debugx("updates fetch changelog result", rerr,
132 slog.String("baseurl", baseURL),
133 slog.Any("base", base),
134 slog.Duration("duration", time.Since(start)))
137 url := baseURL + "?from=" + base.String()
138 nctx, cancel := context.WithTimeout(ctx, time.Minute)
140 req, err := http.NewRequestWithContext(nctx, "GET", url, nil)
142 return nil, fmt.Errorf("making request: %v", err)
144 req.Header.Add("Accept", "application/json")
145 resp, err := http.DefaultClient.Do(req)
147 resp = &http.Response{StatusCode: 0}
149 HTTPClientObserve(ctx, log.Logger, "updates", req.Method, resp.StatusCode, err, start)
151 return nil, fmt.Errorf("%w: making http request: %s", ErrChangelogFetch, err)
153 defer resp.Body.Close()
154 if resp.StatusCode != http.StatusOK {
155 return nil, fmt.Errorf("%w: http status: %s", ErrChangelogFetch, resp.Status)
158 if err := json.NewDecoder(&moxio.LimitReader{R: resp.Body, Limit: 1024 * 1024}).Decode(&cl); err != nil {
159 return nil, fmt.Errorf("%w: parsing changelog: %s", ErrChangelogFetch, err)
161 for _, c := range cl.Changes {
162 if !bytes.Equal(c.PubKey, pubKey) {
163 return nil, fmt.Errorf("%w: verifying change: signed with unknown public key %x instead of %x", ErrChangelogFetch, c.PubKey, pubKey)
165 if !ed25519.Verify(c.PubKey, []byte(c.Text), c.Sig) {
166 return nil, fmt.Errorf("%w: verifying change: invalid signature for change", ErrChangelogFetch)
173// Check checks for an updated version through DNS and fetches a
176// Check looks up a TXT record at _updates.<domain>, and parses the record. If the
177// latest version is more recent than lastKnown, an update is available, and Check
178// will fetch the signed changes since lastKnown, verify the signatures, and
179// return the changelog. The latest version and parsed DNS record is returned
180// regardless of whether a new version was found. A non-nil changelog is only
181// returned when a new version was found and a changelog could be fetched and
183func Check(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain, lastKnown Version, changelogBaseURL string, pubKey []byte) (rversion Version, rrecord *Record, changelog *Changelog, rerr error) {
184 log := mlog.New("updates", elog)
187 log.Debugx("updates check result", rerr,
188 slog.Any("domain", domain),
189 slog.Any("lastknown", lastKnown),
190 slog.String("changelogbaseurl", changelogBaseURL),
191 slog.Any("version", rversion),
192 slog.Any("record", rrecord),
193 slog.Duration("duration", time.Since(start)))
196 latest, record, err := Lookup(ctx, log.Logger, resolver, domain)
198 return latest, record, nil, err
201 if latest.After(lastKnown) {
202 changelog, err = FetchChangelog(ctx, log.Logger, changelogBaseURL, lastKnown, pubKey)
204 return latest, record, changelog, err
207// Version is a specified version in an updates records.
214// After returns if v comes after ov.
215func (v Version) After(ov Version) bool {
216 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
219// String returns a human-reasonable version, also for use in the updates
221func (v Version) String() string {
222 return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
225// ParseVersion parses a version as used in an updates records.
228// - Optionally start with "v"
229// - A dash and anything after it is ignored, e.g. for non-release modifiers.
230// - Remaining string must be three dot-separated numbers.
231func ParseVersion(s string) (Version, error) {
232 s = strings.TrimPrefix(s, "v")
233 s = strings.Split(s, "-")[0]
234 t := strings.Split(s, ".")
236 return Version{}, fmt.Errorf("%w: %v", ErrBadVersion, t)
238 nums := make([]int, 3)
239 for i, v := range t {
240 n, err := strconv.ParseInt(v, 10, 32)
242 return Version{}, fmt.Errorf("%w: parsing int %q: %s", ErrBadVersion, v, err)
246 return Version{nums[0], nums[1], nums[2]}, nil
249// Record is an updates DNS record.
251 Version string // v=UPDATES0, required and must always be first.
252 Latest Version // l=<version>, required.
255// ParseRecord parses an updates DNS TXT record as served at
256func ParseRecord(txt string) (record *Record, isupdates bool, err error) {
257 l := strings.Split(txt, ";")
258 vkv := strings.SplitN(strings.TrimSpace(l[0]), "=", 2)
259 if len(vkv) != 2 || vkv[0] != "v" || !strings.EqualFold(vkv[1], "UPDATES0") {
260 return nil, false, nil
263 r := &Record{Version: "UPDATES0"}
264 seen := map[string]bool{}
265 for _, t := range l[1:] {
266 kv := strings.SplitN(strings.TrimSpace(t), "=", 2)
268 return nil, true, ErrRecordSyntax
270 k := strings.ToLower(kv[0])
272 return nil, true, fmt.Errorf("%w: duplicate key %q", ErrRecordSyntax, k)
277 v, err := ParseVersion(kv[1])
279 return nil, true, fmt.Errorf("%w: %s", ErrRecordSyntax, err)