mirror of
https://github.com/saymrwulf/swisspost-evoting-go-poc.git
synced 2026-09-03 20:13:43 +00:00
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>
220 lines
6.5 KiB
Go
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()
|
|
}
|