mox/updates.go

277 lines
7.4 KiB
Go

package main
import (
"bytes"
"crypto/ed25519"
cryptorand "crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
htmltemplate "html/template"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/updates"
)
func cmdUpdatesAddSigned(c *cmd) {
c.unlisted = true
c.params = "privkey-file changes-file < message"
c.help = "Add a signed change to the changes file."
args := c.Parse()
if len(args) != 2 {
c.Usage()
}
f, err := os.Open(args[0])
xcheckf(err, "open private key file")
defer f.Close()
seed, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, f))
xcheckf(err, "read private key file")
if len(seed) != ed25519.SeedSize {
log.Fatalf("private key is %d bytes, must be %d", len(seed), ed25519.SeedSize)
}
vf, err := os.Open(args[1])
xcheckf(err, "open changes file")
var changelog updates.Changelog
err = json.NewDecoder(vf).Decode(&changelog)
xcheckf(err, "parsing changes file")
privKey := ed25519.NewKeyFromSeed(seed)
fmt.Fprintln(os.Stderr, "reading changelog text from stdin")
buf, err := io.ReadAll(os.Stdin)
xcheckf(err, "parse message")
if len(buf) == 0 {
log.Fatalf("empty message")
}
// Message starts with headers similar to email, with "version" and "date".
// todo future: enforce this format?
sig := ed25519.Sign(privKey, buf)
change := updates.Change{
PubKey: privKey.Public().(ed25519.PublicKey),
Sig: sig,
Text: string(buf),
}
changelog.Changes = append([]updates.Change{change}, changelog.Changes...)
var b bytes.Buffer
enc := json.NewEncoder(&b)
enc.SetIndent("", "\t")
err = enc.Encode(changelog)
xcheckf(err, "encode changelog as json")
err = os.WriteFile(args[1], b.Bytes(), 0644)
xcheckf(err, "writing versions file")
}
func cmdUpdatesVerify(c *cmd) {
c.unlisted = true
c.params = "pubkey-base64 < changelog-file"
c.help = "Verify the changelog file against the public key."
args := c.Parse()
if len(args) != 1 {
c.Usage()
}
pubKey := ed25519.PublicKey(base64Decode(args[0]))
var changelog updates.Changelog
err := json.NewDecoder(os.Stdin).Decode(&changelog)
xcheckf(err, "parsing changelog file")
for i, c := range changelog.Changes {
if !bytes.Equal(c.PubKey, pubKey) {
log.Fatalf("change has different public key %x, expected %x", c.PubKey, pubKey)
} else if !ed25519.Verify(pubKey, []byte(c.Text), c.Sig) {
log.Fatalf("verification failed for change with index %d", i)
}
}
fmt.Printf("%d change(s) verified\n", len(changelog.Changes))
}
func cmdUpdatesGenkey(c *cmd) {
c.unlisted = true
c.params = ">privkey"
c.help = "Generate a key for signing a changelog file with."
args := c.Parse()
if len(args) != 0 {
c.Usage()
}
buf := make([]byte, ed25519.SeedSize)
_, err := cryptorand.Read(buf)
xcheckf(err, "generating key")
enc := base64.NewEncoder(base64.StdEncoding, os.Stdout)
_, err = enc.Write(buf)
xcheckf(err, "writing private key")
err = enc.Close()
xcheckf(err, "writing private key")
}
func cmdUpdatesPubkey(c *cmd) {
c.unlisted = true
c.params = "<privkey >pubkey"
c.help = "Print the public key for a private key."
args := c.Parse()
if len(args) != 0 {
c.Usage()
}
seed := make([]byte, ed25519.SeedSize)
n, err := io.ReadFull(base64.NewDecoder(base64.StdEncoding, os.Stdin), seed)
log.Printf("n %d", n)
xcheckf(err, "reading private key")
privKey := ed25519.NewKeyFromSeed(seed)
pubKey := []byte(privKey.Public().(ed25519.PublicKey))
enc := base64.NewEncoder(base64.StdEncoding, os.Stdout)
_, err = enc.Write(pubKey)
xcheckf(err, "writing public key")
err = enc.Close()
xcheckf(err, "writing public key")
}
var updatesTemplate = htmltemplate.Must(htmltemplate.New("changelog").Parse(`<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mox changelog</title>
<style>
body, html { padding: 1em; font-size: 16px; }
* { font-size: inherit; font-family: ubuntu, lato, sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
h1, h2, h3, h4 { margin-bottom: 1ex; }
h1 { font-size: 1.2rem; }
.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; }
</style>
</head>
<body>
<h1>Changes{{ if .FromVersion }} since {{ .FromVersion }}{{ end }}</h1>
{{ if not .Changes }}
<div>No changes</div>
{{ end }}
{{ range .Changes }}
<pre class="literal">{{ .Text }}</pre>
<hr style="margin:1ex 0" />
{{ end }}
</body>
</html>
`))
func cmdUpdatesServe(c *cmd) {
c.unlisted = true
c.help = "Serve changelog.json with updates."
var address, changelog string
c.flag.StringVar(&address, "address", "127.0.0.1:8596", "address to serve /changelog on")
c.flag.StringVar(&changelog, "changelog", "changelog.json", "changelog file to serve")
args := c.Parse()
if len(args) != 0 {
c.Usage()
}
parseFile := func() (*updates.Changelog, error) {
f, err := os.Open(changelog)
if err != nil {
return nil, err
}
defer f.Close()
var cl updates.Changelog
if err := json.NewDecoder(f).Decode(&cl); err != nil {
return nil, err
}
return &cl, nil
}
_, err := parseFile()
if err != nil {
log.Fatalf("parsing %s: %v", changelog, err)
}
srv := http.NewServeMux()
srv.HandleFunc("/changelog", func(w http.ResponseWriter, r *http.Request) {
cl, err := parseFile()
if err != nil {
log.Printf("parsing %s: %v", changelog, err)
http.Error(w, "500 - internal server error", http.StatusInternalServerError)
return
}
from := r.URL.Query().Get("from")
var fromVersion *updates.Version
if from != "" {
v, err := updates.ParseVersion(from)
if err == nil {
fromVersion = &v
}
}
if fromVersion != nil {
nextchange:
for i, c := range cl.Changes {
for _, line := range strings.Split(strings.Split(c.Text, "\n\n")[0], "\n") {
if strings.HasPrefix(line, "version:") {
v, err := updates.ParseVersion(strings.TrimSpace(strings.TrimPrefix(line, "version:")))
if err == nil && !v.After(*fromVersion) {
cl.Changes = cl.Changes[:i]
break nextchange
}
}
}
}
}
// Check if client accepts html. If so, we'll provide a human-readable version.
accept := r.Header.Get("Accept")
var html bool
accept:
for _, ac := range strings.Split(accept, ",") {
var ok bool
for i, kv := range strings.Split(strings.TrimSpace(ac), ";") {
if i == 0 {
ct := strings.TrimSpace(kv)
if strings.EqualFold(ct, "text/html") || strings.EqualFold(ct, "text/*") {
ok = true
continue
}
continue accept
}
t := strings.SplitN(strings.TrimSpace(kv), "=", 2)
if !strings.EqualFold(t[0], "q") || len(t) != 2 {
continue
}
switch t[1] {
case "0", "0.", "0.0", "0.00", "0.000":
ok = false
continue accept
}
break
}
if ok {
html = true
break
}
}
if html {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err := updatesTemplate.Execute(w, map[string]any{
"FromVersion": fromVersion,
"Changes": cl.Changes,
})
if err != nil && !moxio.IsClosed(err) {
log.Printf("writing changelog html: %v", err)
}
} else {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(cl); err != nil && !moxio.IsClosed(err) {
log.Printf("writing changelog json: %v", err)
}
}
})
log.Printf("listening on %s", address)
log.Fatalln(http.ListenAndServe(address, srv))
}