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