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 resp.Body.Close()
154 if resp.StatusCode != http.StatusOK {
155 return nil, fmt.Errorf("%w: http status: %s", ErrChangelogFetch, resp.Status)
156 }
157 var cl Changelog
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)
160 }
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)
164 }
165 if !ed25519.Verify(c.PubKey, []byte(c.Text), c.Sig) {
166 return nil, fmt.Errorf("%w: verifying change: invalid signature for change", ErrChangelogFetch)
167 }
168 }
169
170 return &cl, nil
171}
172
173// Check checks for an updated version through DNS and fetches a
174// changelog if so.
175//
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
182// verified.
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)
185 start := time.Now()
186 defer func() {
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)))
194 }()
195
196 latest, record, err := Lookup(ctx, log.Logger, resolver, domain)
197 if err != nil {
198 return latest, record, nil, err
199 }
200
201 if latest.After(lastKnown) {
202 changelog, err = FetchChangelog(ctx, log.Logger, changelogBaseURL, lastKnown, pubKey)
203 }
204 return latest, record, changelog, err
205}
206
207// Version is a specified version in an updates records.
208type Version struct {
209 Major int
210 Minor int
211 Patch int
212}
213
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
217}
218
219// String returns a human-reasonable version, also for use in the updates
220// record.
221func (v Version) String() string {
222 return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
223}
224
225// ParseVersion parses a version as used in an updates records.
226//
227// Rules:
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, ".")
235 if len(t) != 3 {
236 return Version{}, fmt.Errorf("%w: %v", ErrBadVersion, t)
237 }
238 nums := make([]int, 3)
239 for i, v := range t {
240 n, err := strconv.ParseInt(v, 10, 32)
241 if err != nil {
242 return Version{}, fmt.Errorf("%w: parsing int %q: %s", ErrBadVersion, v, err)
243 }
244 nums[i] = int(n)
245 }
246 return Version{nums[0], nums[1], nums[2]}, nil
247}
248
249// Record is an updates DNS record.
250type Record struct {
251 Version string // v=UPDATES0, required and must always be first.
252 Latest Version // l=<version>, required.
253}
254
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
261 }
262
263 r := &Record{Version: "UPDATES0"}
264 seen := map[string]bool{}
265 for _, t := range l[1:] {
266 kv := strings.SplitN(strings.TrimSpace(t), "=", 2)
267 if len(kv) != 2 {
268 return nil, true, ErrRecordSyntax
269 }
270 k := strings.ToLower(kv[0])
271 if seen[k] {
272 return nil, true, fmt.Errorf("%w: duplicate key %q", ErrRecordSyntax, k)
273 }
274 seen[k] = true
275 switch k {
276 case "l":
277 v, err := ParseVersion(kv[1])
278 if err != nil {
279 return nil, true, fmt.Errorf("%w: %s", ErrRecordSyntax, err)
280 }
281 r.Latest = v
282 default:
283 continue
284 }
285 }
286 return r, true, nil
287}
288