swisspost-evoting-go-poc/cmd/evote/cockpit.go
saymrwulf a1e4ec6ce7 Cockpit UX: auto-open browser, Replay button, graceful serialization
Make the live cockpit foolproof to run:
- Auto-open the default browser on start (macOS/Linux/Windows; --no-open to
  disable). Binds the listener first, so the browser only opens once the server
  is accepting connections.
- Clear message when the port is already in use instead of a raw bind error.
- A prominent "▶ Run the election again" button appears when the ceremony
  finishes, so replaying needs no arcane page-reload knowledge.
- Second viewer now waits gracefully (mutex) instead of getting a 409.

Verified in-browser: replay button shows on completion; port-busy prints a
friendly message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:13:20 +02:00

220 lines
6.5 KiB
Go

package main
import (
"crypto/rand"
"encoding/json"
"fmt"
"io/fs"
"math/big"
"net"
"net/http"
"os/exec"
"runtime"
"sync"
"time"
"github.com/spf13/cobra"
"github.com/user/evote/pkg/party"
"github.com/user/evote/pkg/protocol"
"github.com/user/evote/pkg/trace"
)
var (
cockpitPort int
cockpitVoters int
cockpitOptions int
cockpitDelayMs int
cockpitTmux bool
cockpitNoOpen bool
cockpitMu sync.Mutex // serializes ceremonies (global trace context)
)
var cockpitCmd = &cobra.Command{
Use: "cockpit",
Short: "Watch the cryptography execute live in the browser (typeset math)",
Long: "Runs the multi-party election and streams every cryptographic operation " +
"— sampling, ElGamal encryption, Fiat-Shamir challenges, the Bayer-Groth " +
"shuffle, Ed25519 signatures, X25519 key agreement — to a browser page that " +
"renders each as typeset mathematics with the real runtime values, the instant " +
"it runs. Open the printed URL.",
RunE: func(cmd *cobra.Command, args []string) error {
if cockpitVoters < 1 || cockpitVoters > 200 {
return fmt.Errorf("--voters must be in [1, 200]")
}
if cockpitOptions < 1 || cockpitOptions > 200 {
return fmt.Errorf("--options must be in [1, 200]")
}
if cockpitTmux {
return runCockpitTmux()
}
return runCockpit()
},
}
func init() {
cockpitCmd.Flags().IntVar(&cockpitPort, "port", 8090, "HTTP port (browser mode)")
cockpitCmd.Flags().IntVar(&cockpitVoters, "voters", 3, "Number of voters")
cockpitCmd.Flags().IntVar(&cockpitOptions, "options", 3, "Number of voting options")
cockpitCmd.Flags().IntVar(&cockpitDelayMs, "delay", 350, "Milliseconds between events (pacing so it's watchable)")
cockpitCmd.Flags().BoolVar(&cockpitTmux, "tmux", false, "Terminal mode: one tmux pane per stakeholder instead of the browser")
cockpitCmd.Flags().BoolVar(&cockpitNoOpen, "no-open", false, "Do not open the browser automatically")
rootCmd.AddCommand(cockpitCmd)
}
func runCockpit() error {
mux := http.NewServeMux()
// The cockpit page + assets are embedded under web/ (see serve.go's webContent).
webFS, err := fs.Sub(webContent, "web")
if err != nil {
return err
}
fileServer := http.FileServer(http.FS(webFS))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
data, err := fs.ReadFile(webFS, "cockpit.html")
if err != nil {
http.Error(w, "cockpit.html not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(data)
return
}
fileServer.ServeHTTP(w, r)
})
// SSE stream: on connect, run one ceremony and stream its crypto events.
mux.HandleFunc("/events", cockpitEventsHandler)
srv := &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
// Bind the listener first so we only open the browser once we're accepting
// connections — and so a busy port fails with a clear message, not a race.
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cockpitPort))
if err != nil {
return fmt.Errorf("cannot listen on port %d (is another cockpit already running?): %w", cockpitPort, err)
}
url := fmt.Sprintf("http://localhost:%d/", cockpitPort)
fmt.Println("========================================")
fmt.Println(" Swiss Post E-Voting — Live Crypto Cockpit")
fmt.Println("========================================")
fmt.Printf(" Watching at %s\n", url)
fmt.Printf(" Voters: %d, Options: %d, pacing: %dms/event\n", cockpitVoters, cockpitOptions, cockpitDelayMs)
fmt.Println(" The election plays automatically when the page opens.")
fmt.Println(" Press Ctrl+C here to stop.")
if !cockpitNoOpen {
go openBrowser(url)
}
return srv.Serve(ln)
}
// openBrowser opens url in the default browser, cross-platform. Best-effort.
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond) // let Serve settle
var cmd string
var args []string
switch runtime.GOOS {
case "darwin":
cmd = "open"
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler"}
default:
cmd = "xdg-open"
}
_ = exec.Command(cmd, append(args, url)...).Start()
}
// cockpitEventsHandler streams one ceremony's crypto events as Server-Sent
// Events. It subscribes a buffered sink, runs the ceremony in a goroutine, and
// drains the buffer to the client with pacing so a human can follow along.
func cockpitEventsHandler(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
// The trace stream and its party/phase context are global, so only one
// ceremony runs at a time. A second viewer waits gracefully for the first to
// finish rather than getting an error (single-viewer desktop tool).
cockpitMu.Lock()
defer cockpitMu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
sink := trace.NewChanSink(8192)
unsub := trace.Subscribe(sink)
defer unsub()
done := make(chan error, 1)
go func() { done <- runCockpitCeremony() }()
delay := time.Duration(cockpitDelayMs) * time.Millisecond
send := func(eventType string, payload any) {
b, _ := json.Marshal(payload)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, b)
flusher.Flush()
}
for {
select {
case e := <-sink.C:
send("op", e)
if delay > 0 {
time.Sleep(delay)
}
case err := <-done:
// Drain any remaining buffered events before closing.
for {
select {
case e := <-sink.C:
send("op", e)
default:
msg := "complete"
if err != nil {
msg = "error: " + err.Error()
}
send("done", map[string]string{"status": msg})
return
}
}
case <-r.Context().Done():
return
}
}
}
// runCockpitCeremony runs a full multi-party election (the same one netdemo
// runs), emitting trace events as it goes.
func runCockpitCeremony() error {
cfg := protocol.DefaultConfig(cockpitVoters, cockpitOptions)
c, err := party.NewCeremony(cfg, func(string, ...any) {})
if err != nil {
return err
}
if err := c.RunSetup(); err != nil {
return err
}
if err := c.RunCards(); err != nil {
return err
}
selections := make([][]int, cockpitVoters)
for v := 0; v < cockpitVoters; v++ {
n, err := rand.Int(rand.Reader, big.NewInt(int64(cockpitOptions)))
if err != nil {
return err
}
selections[v] = []int{int(n.Int64())}
}
if err := c.RunVoting(selections); err != nil {
return err
}
if err := c.RunTally(); err != nil {
return err
}
return c.RunVerify()
}