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 = "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) _, err := io.ReadFull(base64.NewDecoder(base64.StdEncoding, os.Stdin), seed) 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(` mox changelog

Changes{{ if .FromVersion }} since {{ .FromVersion }}{{ end }}

{{ if not .Changes }}
No changes
{{ end }} {{ range .Changes }}
{{ .Text }}

{{ end }} `)) 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)) }