1// Package updates implements a mechanism for checking if software updates are
2// available, and fetching a changelog.
3//
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.
8//
9// Downloading or upgrading to the latest version is not part of this package.
10package updates
11
12import (
13 "bytes"
14 "context"
15 "crypto/ed25519"
16 "encoding/json"
17 "errors"
18 "fmt"
19 "log/slog"
20 "net/http"
21 "strconv"
22 "strings"
23 "time"
24
25 "github.com/mjl-/mox/dns"
26 "github.com/mjl-/mox/mlog"
27 "github.com/mjl-/mox/moxio"
28 "github.com/mjl-/mox/stub"
29)
30
31var (
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
35)
36
37var (
38 // Lookup errors.
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")
44
45 // Fetch changelog errors.
46 ErrChangelogFetch = errors.New("updates: fetching changelog")
47)
48
49// Change is a an entry in the changelog, a released version.
50type Change struct {
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".
54}
55
56// Changelog is returned as JSON.
57//
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
60// new versions.
61type Changelog struct {
62 Changes []Change // Newest first.
63}
64
65// Lookup looks up the updates DNS TXT record at "_updates.<domain>" and returns
66// the parsed form.
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)
69 start := time.Now()
70 defer func() {
71 var result = "ok"
72 if rerr != nil {
73 result = "error"
74 }
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)))
81 }()
82
83 nctx, cancel := context.WithTimeout(ctx, 30*time.Second)
84 defer cancel()
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)
91 }
92 var record *Record
93 for _, txt := range txts {
94 r, isupdates, err := ParseRecord(txt)
95 if !isupdates {
96 continue
97 } else if err != nil {
98 return Version{}, nil, err
99 }
100 if record != nil {
101 return Version{}, nil, ErrMultipleRecords
102 }
103 record = r
104 }
105
106 if record == nil {
107 return Version{}, nil, ErrNoRecord
108 }
109 return record.Latest, record, nil
110}
111
112// FetchChangelog fetches the changelog compared against the base version, which
113// can be the Version zero value.
114//
115// The changelog is requested using HTTP GET from baseURL with optional "from"
116// query string parameter.
117//
118// Individual changes are verified using pubKey. If any signature is invalid, an
119// error is returned.
120//
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)
124 start := time.Now()
125 defer func() {
126 var result = "ok"
127 if rerr != nil {
128 result = "error"
129 }
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)))
135 }()
136
137 url := baseURL + "?from=" + base.String()
138 nctx, cancel := context.WithTimeout(ctx, time.Minute)
139 defer cancel()
140 req, err := http.NewRequestWithContext(nctx, "GET", url, nil)
141 if err != nil {
142 return nil, fmt.Errorf("making request: %v", err)
143 }
144 req.Header.Add("Accept", "application/json")
145 resp, err := http.DefaultClient.Do(req)
146 if resp == nil {
147 resp = &http.Response{StatusCode: 0}
148 }
149 HTTPClientObserve(ctx, log.Logger, "updates", req.Method, resp.StatusCode, err, start)
150 if err != nil {
151 return nil, fmt.Errorf("%w: making http request: %s", ErrChangelogFetch, err)
152 }
153 defer func() {
154 err := resp.Body.Close()
155 log.Check(err, "closing http response body")
156 }()
157 if resp.StatusCode != http.StatusOK {
158 return nil, fmt.Errorf("%w: http status: %s", ErrChangelogFetch, resp.Status)
159 }
160 var cl Changelog
161 if err := json.NewDecoder(&moxio.LimitReader{R: resp.Body, Limit: 1024 * 1024}).Decode(&cl); err != nil {
162 return nil, fmt.Errorf("%w: parsing changelog: %s", ErrChangelogFetch, err)
163 }
164 for _, c := range cl.Changes {
165 if !bytes.Equal(c.PubKey, pubKey) {
166 return nil, fmt.Errorf("%w: verifying change: signed with unknown public key %x instead of %x", ErrChangelogFetch, c.PubKey, pubKey)
167 }
168 if !ed25519.Verify(c.PubKey, []byte(c.Text), c.Sig) {
169 return nil, fmt.Errorf("%w: verifying change: invalid signature for change", ErrChangelogFetch)
170 }
171 }
172
173 return &cl, nil
174}
175
176// Check checks for an updated version through DNS and fetches a
177// changelog if so.
178//
179// Check looks up a TXT record at _updates.<domain>, and parses the record. If the
180// latest version is more recent than lastKnown, an update is available, and Check
181// will fetch the signed changes since lastKnown, verify the signatures, and
182// return the changelog. The latest version and parsed DNS record is returned
183// regardless of whether a new version was found. A non-nil changelog is only
184// returned when a new version was found and a changelog could be fetched and
185// verified.
186func 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) {
187 log := mlog.New("updates", elog)
188 start := time.Now()
189 defer func() {
190 log.Debugx("updates check result", rerr,
191 slog.Any("domain", domain),
192 slog.Any("lastknown", lastKnown),
193 slog.String("changelogbaseurl", changelogBaseURL),
194 slog.Any("version", rversion),
195 slog.Any("record", rrecord),
196 slog.Duration("duration", time.Since(start)))
197 }()
198
199 latest, record, err := Lookup(ctx, log.Logger, resolver, domain)
200 if err != nil {
201 return latest, record, nil, err
202 }
203
204 if latest.After(lastKnown) {
205 changelog, err = FetchChangelog(ctx, log.Logger, changelogBaseURL, lastKnown, pubKey)
206 }
207 return latest, record, changelog, err
208}
209
210// Version is a specified version in an updates records.
211type Version struct {
212 Major int
213 Minor int
214 Patch int
215}
216
217// After returns if v comes after ov.
218func (v Version) After(ov Version) bool {
219 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
220}
221
222// String returns a human-reasonable version, also for use in the updates
223// record.
224func (v Version) String() string {
225 return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
226}
227
228// ParseVersion parses a version as used in an updates records.
229//
230// Rules:
231// - Optionally start with "v"
232// - A dash and anything after it is ignored, e.g. for non-release modifiers.
233// - Remaining string must be three dot-separated numbers.
234func ParseVersion(s string) (Version, error) {
235 s = strings.TrimPrefix(s, "v")
236 s = strings.Split(s, "-")[0]
237 t := strings.Split(s, ".")
238 if len(t) != 3 {
239 return Version{}, fmt.Errorf("%w: %v", ErrBadVersion, t)
240 }
241 nums := make([]int, 3)
242 for i, v := range t {
243 n, err := strconv.ParseInt(v, 10, 32)
244 if err != nil {
245 return Version{}, fmt.Errorf("%w: parsing int %q: %s", ErrBadVersion, v, err)
246 }
247 nums[i] = int(n)
248 }
249 return Version{nums[0], nums[1], nums[2]}, nil
250}
251
252// Record is an updates DNS record.
253type Record struct {
254 Version string // v=UPDATES0, required and must always be first.
255 Latest Version // l=<version>, required.
256}
257
258// ParseRecord parses an updates DNS TXT record as served at
259func ParseRecord(txt string) (record *Record, isupdates bool, err error) {
260 l := strings.Split(txt, ";")
261 vkv := strings.SplitN(strings.TrimSpace(l[0]), "=", 2)
262 if len(vkv) != 2 || vkv[0] != "v" || !strings.EqualFold(vkv[1], "UPDATES0") {
263 return nil, false, nil
264 }
265
266 r := &Record{Version: "UPDATES0"}
267 seen := map[string]bool{}
268 for _, t := range l[1:] {
269 kv := strings.SplitN(strings.TrimSpace(t), "=", 2)
270 if len(kv) != 2 {
271 return nil, true, ErrRecordSyntax
272 }
273 k := strings.ToLower(kv[0])
274 if seen[k] {
275 return nil, true, fmt.Errorf("%w: duplicate key %q", ErrRecordSyntax, k)
276 }
277 seen[k] = true
278 switch k {
279 case "l":
280 v, err := ParseVersion(kv[1])
281 if err != nil {
282 return nil, true, fmt.Errorf("%w: %s", ErrRecordSyntax, err)
283 }
284 r.Latest = v
285 default:
286 continue
287 }
288 }
289 return r, true, nil
290}
291