1package main
2
3import (
4 "bytes"
5 "crypto/ed25519"
6 cryptorand "crypto/rand"
7 "encoding/base64"
8 "encoding/json"
9 "fmt"
10 htmltemplate "html/template"
11 "io"
12 "log"
13 "net/http"
14 "os"
15 "strings"
16
17 "github.com/mjl-/mox/mlog"
18 "github.com/mjl-/mox/updates"
19)
20
21func cmdUpdatesAddSigned(c *cmd) {
22 c.unlisted = true
23 c.params = "privkey-file changes-file < message"
24 c.help = "Add a signed change to the changes file."
25 args := c.Parse()
26 if len(args) != 2 {
27 c.Usage()
28 }
29
30 f, err := os.Open(args[0])
31 xcheckf(err, "open private key file")
32 defer func() {
33 err := f.Close()
34 c.log.Check(err, "closing private key file")
35 }()
36 seed, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, f))
37 xcheckf(err, "read private key file")
38 if len(seed) != ed25519.SeedSize {
39 log.Fatalf("private key is %d bytes, must be %d", len(seed), ed25519.SeedSize)
40 }
41
42 vf, err := os.Open(args[1])
43 xcheckf(err, "open changes file")
44 var changelog updates.Changelog
45 err = json.NewDecoder(vf).Decode(&changelog)
46 xcheckf(err, "parsing changes file")
47
48 privKey := ed25519.NewKeyFromSeed(seed)
49
50 fmt.Fprintln(os.Stderr, "reading changelog text from stdin")
51 buf, err := io.ReadAll(os.Stdin)
52 xcheckf(err, "parse message")
53
54 if len(buf) == 0 {
55 log.Fatalf("empty message")
56 }
57 // Message starts with headers similar to email, with "version" and "date".
58 // todo future: enforce this format?
59 sig := ed25519.Sign(privKey, buf)
60
61 change := updates.Change{
62 PubKey: privKey.Public().(ed25519.PublicKey),
63 Sig: sig,
64 Text: string(buf),
65 }
66 changelog.Changes = append([]updates.Change{change}, changelog.Changes...)
67
68 var b bytes.Buffer
69 enc := json.NewEncoder(&b)
70 enc.SetIndent("", "\t")
71 err = enc.Encode(changelog)
72 xcheckf(err, "encode changelog as json")
73 err = os.WriteFile(args[1], b.Bytes(), 0644)
74 xcheckf(err, "writing versions file")
75}
76
77func cmdUpdatesVerify(c *cmd) {
78 c.unlisted = true
79 c.params = "pubkey-base64 < changelog-file"
80 c.help = "Verify the changelog file against the public key."
81 args := c.Parse()
82 if len(args) != 1 {
83 c.Usage()
84 }
85
86 pubKey := ed25519.PublicKey(base64Decode(args[0]))
87
88 var changelog updates.Changelog
89 err := json.NewDecoder(os.Stdin).Decode(&changelog)
90 xcheckf(err, "parsing changelog file")
91
92 for i, c := range changelog.Changes {
93 if !bytes.Equal(c.PubKey, pubKey) {
94 log.Fatalf("change has different public key %x, expected %x", c.PubKey, pubKey)
95 } else if !ed25519.Verify(pubKey, []byte(c.Text), c.Sig) {
96 log.Fatalf("verification failed for change with index %d", i)
97 }
98 }
99 fmt.Printf("%d change(s) verified\n", len(changelog.Changes))
100}
101
102func cmdUpdatesGenkey(c *cmd) {
103 c.unlisted = true
104 c.params = ">privkey"
105 c.help = "Generate a key for signing a changelog file with."
106 args := c.Parse()
107 if len(args) != 0 {
108 c.Usage()
109 }
110
111 buf := make([]byte, ed25519.SeedSize)
112 cryptorand.Read(buf)
113 enc := base64.NewEncoder(base64.StdEncoding, os.Stdout)
114 _, err := enc.Write(buf)
115 xcheckf(err, "writing private key")
116 err = enc.Close()
117 xcheckf(err, "writing private key")
118}
119
120func cmdUpdatesPubkey(c *cmd) {
121 c.unlisted = true
122 c.params = "<privkey >pubkey"
123 c.help = "Print the public key for a private key."
124 args := c.Parse()
125 if len(args) != 0 {
126 c.Usage()
127 }
128
129 seed := make([]byte, ed25519.SeedSize)
130 _, err := io.ReadFull(base64.NewDecoder(base64.StdEncoding, os.Stdin), seed)
131 xcheckf(err, "reading private key")
132 privKey := ed25519.NewKeyFromSeed(seed)
133 pubKey := []byte(privKey.Public().(ed25519.PublicKey))
134 enc := base64.NewEncoder(base64.StdEncoding, os.Stdout)
135 _, err = enc.Write(pubKey)
136 xcheckf(err, "writing public key")
137 err = enc.Close()
138 xcheckf(err, "writing public key")
139}
140
141var updatesTemplate = htmltemplate.Must(htmltemplate.New("changelog").Parse(`<!doctype html>
142<html>
143 <head>
144 <meta charset="utf-8" />
145 <meta name="viewport" content="width=device-width, initial-scale=1" />
146 <title>mox changelog</title>
147 <style>
148body, html { padding: 1em; font-size: 16px; }
149* { font-size: inherit; font-family: ubuntu, lato, sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
150h1, h2, h3, h4 { margin-bottom: 1ex; }
151h1 { font-size: 1.2rem; }
152.literal { background-color: #fdfdfd; padding: .5em 1em; border: 1px solid #eee; border-radius: 4px; white-space: pre-wrap; font-family: monospace; font-size: 15px; tab-size: 4; }
153 </style>
154 </head>
155 <body>
156 <h1>Changes{{ if .FromVersion }} since {{ .FromVersion }}{{ end }}</h1>
157 {{ if not .Changes }}
158 <div>No changes</div>
159 {{ end }}
160 {{ range .Changes }}
161 <pre class="literal">{{ .Text }}</pre>
162 <hr style="margin:1ex 0" />
163 {{ end }}
164 </body>
165</html>
166`))
167
168func cmdUpdatesServe(c *cmd) {
169 c.unlisted = true
170 c.help = "Serve changelog.json with updates."
171 var address, changelog string
172 c.flag.StringVar(&address, "address", "127.0.0.1:8596", "address to serve /changelog on")
173 c.flag.StringVar(&changelog, "changelog", "changelog.json", "changelog file to serve")
174 args := c.Parse()
175 if len(args) != 0 {
176 c.Usage()
177 }
178
179 parseFile := func() (*updates.Changelog, error) {
180 f, err := os.Open(changelog)
181 if err != nil {
182 return nil, err
183 }
184 defer func() {
185 err := f.Close()
186 c.log.Check(err, "closing changelog file")
187 }()
188 var cl updates.Changelog
189 if err := json.NewDecoder(f).Decode(&cl); err != nil {
190 return nil, err
191 }
192 return &cl, nil
193 }
194
195 _, err := parseFile()
196 if err != nil {
197 log.Fatalf("parsing %s: %v", changelog, err)
198 }
199
200 srv := http.NewServeMux()
201 srv.HandleFunc("/changelog", func(w http.ResponseWriter, r *http.Request) {
202 cl, err := parseFile()
203 if err != nil {
204 log.Printf("parsing %s: %v", changelog, err)
205 http.Error(w, "500 - internal server error", http.StatusInternalServerError)
206 return
207 }
208 from := r.URL.Query().Get("from")
209 var fromVersion *updates.Version
210 if from != "" {
211 v, err := updates.ParseVersion(from)
212 if err == nil {
213 fromVersion = &v
214 }
215 }
216 if fromVersion != nil {
217 nextchange:
218 for i, c := range cl.Changes {
219 for line := range strings.SplitSeq(strings.Split(c.Text, "\n\n")[0], "\n") {
220 if after, ok := strings.CutPrefix(line, "version:"); ok {
221 v, err := updates.ParseVersion(strings.TrimSpace(after))
222 if err == nil && !v.After(*fromVersion) {
223 cl.Changes = cl.Changes[:i]
224 break nextchange
225 }
226 }
227 }
228 }
229 }
230
231 // Check if client accepts html. If so, we'll provide a human-readable version.
232 accept := r.Header.Get("Accept")
233 var html bool
234 accept:
235 for ac := range strings.SplitSeq(accept, ",") {
236 var ok bool
237 for i, kv := range strings.Split(strings.TrimSpace(ac), ";") {
238 if i == 0 {
239 ct := strings.TrimSpace(kv)
240 if strings.EqualFold(ct, "text/html") || strings.EqualFold(ct, "text/*") {
241 ok = true
242 continue
243 }
244 continue accept
245 }
246 t := strings.SplitN(strings.TrimSpace(kv), "=", 2)
247 if !strings.EqualFold(t[0], "q") || len(t) != 2 {
248 continue
249 }
250 switch t[1] {
251 case "0", "0.", "0.0", "0.00", "0.000":
252 ok = false
253 continue accept
254 }
255 break
256 }
257 if ok {
258 html = true
259 break
260 }
261 }
262
263 if html {
264 w.Header().Set("Content-Type", "text/html; charset=utf-8")
265 err := updatesTemplate.Execute(w, map[string]any{
266 "FromVersion": fromVersion,
267 "Changes": cl.Changes,
268 })
269 if err != nil && !mlog.IsClosed(err) {
270 log.Printf("writing changelog html: %v", err)
271 }
272 } else {
273 w.Header().Set("Content-Type", "application/json; charset=utf-8")
274 if err := json.NewEncoder(w).Encode(cl); err != nil && !mlog.IsClosed(err) {
275 log.Printf("writing changelog json: %v", err)
276 }
277 }
278 })
279 log.Printf("listening on %s", address)
280 log.Fatalln(http.ListenAndServe(address, srv))
281}
282