From 2185bbe56389b641be3edf6c3580244b5f1bd43e Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Wed, 25 Mar 2026 08:27:31 -0700 Subject: [PATCH] Add passwd command, fix template rendering, update deployment docs - Add `passwd` CLI command to reset user passwords - Fix web UI templates: parse each page template with layout so blocks render correctly (was outputting empty pages) - Add login error logging for debugging auth failures - Update README with deploy workflow and container management commands - Update RUNBOOK for Docker-on-deimos deployment (replaces systemd refs) Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 3 +- README.md | 52 +++++++++++++- RUNBOOK.md | 125 ++++++++++++++------------------- cmd/eng-pad-server/passwd.go | 77 ++++++++++++++++++++ internal/webserver/handlers.go | 9 ++- internal/webserver/server.go | 25 +++++-- 6 files changed, 212 insertions(+), 79 deletions(-) create mode 100644 cmd/eng-pad-server/passwd.go diff --git a/CLAUDE.md b/CLAUDE.md index b6e1924..d35ab94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,8 @@ eng-pad-server/ │ └── eng-pad-server/ CLI entry point (cobra) │ ├── main.go │ ├── server.go server subcommand -│ └── init.go init subcommand +│ ├── init.go init subcommand +│ └── passwd.go password reset subcommand ├── internal/ │ ├── auth/ │ │ ├── argon2.go Password hashing diff --git a/README.md b/README.md index 50762dc..a5cc298 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ cp eng-pad-server.toml.example /srv/eng-pad-server/eng-pad-server.toml # Edit configuration (TLS certs, database path, etc.) # Initialize (creates database, prompts for admin user) -./eng-pad-server init +./eng-pad-server init -c /srv/eng-pad-server/eng-pad-server.toml # Run -./eng-pad-server server +./eng-pad-server server -c /srv/eng-pad-server/eng-pad-server.toml ``` ## Build @@ -43,6 +43,54 @@ make proto # regenerate gRPC code from .proto files make proto-lint # buf lint + breaking change detection ``` +## User Management + +```bash +# Create initial user (interactive — prompts for username and password) +eng-pad-server init -c /srv/eng-pad-server/eng-pad-server.toml + +# Reset a user's password +eng-pad-server passwd -c /srv/eng-pad-server/eng-pad-server.toml +``` + +## Deployment (deimos.wntrmute.net) + +The production instance runs as a Docker container on deimos behind nginx. + +- **Web UI**: `https://pad.metacircular.net` (nginx → container:8080) +- **REST API**: `https://pad.metacircular.net:8443` (direct TLS) +- **gRPC sync**: `pad.metacircular.net:9443` (direct TLS, for Android app) +- **Data**: `/srv/eng-pad-server/` on deimos +- **TLS**: Let's Encrypt cert, shared by nginx and the container + +### Deploy workflow + +```bash +# From local machine: +rsync -az --exclude='.git' --exclude='srv/' . deimos.wntrmute.net:/tmp/eng-pad-server-build/ +ssh deimos.wntrmute.net "cd /tmp/eng-pad-server-build && \ + docker build -t eng-pad-server . && \ + docker stop eng-pad-server && docker rm eng-pad-server && \ + docker run -d --name eng-pad-server --restart unless-stopped \ + -p 127.0.0.1:8090:8080 -p 8443:8443 -p 9443:9443 \ + -v /srv/eng-pad-server:/srv/eng-pad-server eng-pad-server" +``` + +### Container management + +```bash +# View logs +ssh deimos.wntrmute.net "docker logs eng-pad-server" + +# Create/reset user +ssh -t deimos.wntrmute.net "docker exec -it eng-pad-server \ + eng-pad-server passwd kyle -c /srv/eng-pad-server/eng-pad-server.toml" + +# Renew TLS certs (after certbot renews) +ssh deimos.wntrmute.net "sudo cp /etc/letsencrypt/live/pad.metacircular.net/{fullchain,privkey}.pem \ + /srv/eng-pad-server/certs/ && docker restart eng-pad-server" +``` + ## Documentation - [ARCHITECTURE.md](ARCHITECTURE.md) — full system specification diff --git a/RUNBOOK.md b/RUNBOOK.md index 12a6f43..b8e271d 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -6,31 +6,29 @@ eng-pad-server receives engineering notebook data from the Engineering Pad Android app via gRPC, stores it in SQLite, and serves read-only views through a web UI. Single authenticated user. -**Ports**: 8443 (REST/HTTPS), 9443 (gRPC/TLS), 8080 (Web UI) +**Host**: deimos.wntrmute.net +**URL**: https://pad.metacircular.net +**Ports**: 443 (nginx → 8080 web UI), 8443 (REST/TLS), 9443 (gRPC/TLS) **Data**: `/srv/eng-pad-server/` **Config**: `/srv/eng-pad-server/eng-pad-server.toml` -**Binary**: `/usr/local/bin/eng-pad-server` +**TLS**: Let's Encrypt (`/etc/letsencrypt/live/pad.metacircular.net/`), copied to `/srv/eng-pad-server/certs/` +**Container**: `eng-pad-server` (Docker, `--restart unless-stopped`) ## 2. Health Checks -1. Check service is running: +1. Check container is running: ``` - systemctl status eng-pad-server + docker ps | grep eng-pad-server ``` -2. Check database health: +2. Check web UI responds: ``` - eng-pad-server status -c /srv/eng-pad-server/eng-pad-server.toml + curl -s https://pad.metacircular.net/login | head -1 ``` -3. Check web UI responds: +3. Check container logs: ``` - curl -k https://localhost:8443/login - ``` - -4. Check gRPC responds: - ``` - grpcurl -insecure localhost:9443 list + docker logs eng-pad-server --tail 20 ``` ## 3. Common Operations @@ -38,84 +36,69 @@ views through a web UI. Single authenticated user. ### Start / Stop / Restart ``` -systemctl start eng-pad-server -systemctl stop eng-pad-server -systemctl restart eng-pad-server +docker start eng-pad-server +docker stop eng-pad-server +docker restart eng-pad-server ``` ### View Logs ``` -journalctl -u eng-pad-server -f +docker logs eng-pad-server -f +``` + +### Deploy New Version + +```bash +# From local machine: +rsync -az --exclude='.git' --exclude='srv/' . deimos.wntrmute.net:/tmp/eng-pad-server-build/ +ssh deimos.wntrmute.net "cd /tmp/eng-pad-server-build && \ + docker build -t eng-pad-server . && \ + docker stop eng-pad-server && docker rm eng-pad-server && \ + docker run -d --name eng-pad-server --restart unless-stopped \ + -p 127.0.0.1:8090:8080 -p 8443:8443 -p 9443:9443 \ + -v /srv/eng-pad-server:/srv/eng-pad-server eng-pad-server" +``` + +### Create User + +``` +docker exec -it eng-pad-server \ + eng-pad-server init -c /srv/eng-pad-server/eng-pad-server.toml +``` + +### Reset User Password + +``` +docker exec -it eng-pad-server \ + eng-pad-server passwd -c /srv/eng-pad-server/eng-pad-server.toml ``` ### Manual Backup ``` -eng-pad-server snapshot -c /srv/eng-pad-server/eng-pad-server.toml +docker exec eng-pad-server \ + eng-pad-server snapshot -c /srv/eng-pad-server/eng-pad-server.toml ``` Backup saved to `/srv/eng-pad-server/backups/`. -### Check Backup Timer +### Renew TLS Certificates +After certbot renews the Let's Encrypt cert: ``` -systemctl list-timers eng-pad-server-backup.timer +sudo cp /etc/letsencrypt/live/pad.metacircular.net/{fullchain,privkey}.pem \ + /srv/eng-pad-server/certs/ +docker restart eng-pad-server ``` -### Initialize (First Time) - -1. Install the binary and config: - ``` - sudo deploy/scripts/install.sh - ``` - -2. Edit the config file: - ``` - sudo -u engpad vi /srv/eng-pad-server/eng-pad-server.toml - ``` - -3. Generate TLS certificates (or copy existing ones): - ``` - # Self-signed for development: - openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ - -keyout /srv/eng-pad-server/certs/key.pem \ - -out /srv/eng-pad-server/certs/cert.pem \ - -days 3650 -nodes -subj '/CN=pad.metacircular.net' - chown engpad:engpad /srv/eng-pad-server/certs/*.pem - chmod 600 /srv/eng-pad-server/certs/key.pem - ``` - -4. Create the admin user: - ``` - eng-pad-server init -c /srv/eng-pad-server/eng-pad-server.toml - ``` - -5. Start the service: - ``` - systemctl enable --now eng-pad-server - systemctl enable --now eng-pad-server-backup.timer - ``` - ### Register a FIDO2/U2F Security Key -1. Log in to the web UI with password. +1. Log in to the web UI at https://pad.metacircular.net with password. 2. Navigate to `/keys`. 3. Enter a name for the key (e.g., "YubiKey 5"). 4. Click "Register" and touch the key when prompted. -### Docker Deployment - -``` -cd deploy/docker -docker compose up -d -``` - -First-time setup inside the container: -``` -docker compose exec eng-pad-server eng-pad-server init -c /srv/eng-pad-server/eng-pad-server.toml -``` - ## 4. Alerting No automated alerting is configured. Monitor via: @@ -129,12 +112,12 @@ No automated alerting is configured. Monitor via: 1. Check logs: ``` - journalctl -u eng-pad-server -n 50 --no-pager + docker logs eng-pad-server --tail 50 ``` 2. Common causes: - - Config file missing or invalid → fix config - - TLS cert/key missing → regenerate or copy - - Port already in use → `ss -tlnp | grep 8443` + - Config file missing or invalid → fix `/srv/eng-pad-server/eng-pad-server.toml` + - TLS cert/key missing → re-copy from Let's Encrypt (see Renew TLS above) + - Port already in use → `ss -tlnp | grep -E '8443|9443|8090'` - Database locked → check for zombie processes: `fuser /srv/eng-pad-server/eng-pad-server.db` ### Database Corruption diff --git a/cmd/eng-pad-server/passwd.go b/cmd/eng-pad-server/passwd.go new file mode 100644 index 0000000..994f561 --- /dev/null +++ b/cmd/eng-pad-server/passwd.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "git.wntrmute.dev/kyle/eng-pad-server/internal/auth" + "git.wntrmute.dev/kyle/eng-pad-server/internal/config" + "git.wntrmute.dev/kyle/eng-pad-server/internal/db" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var passwdCmd = &cobra.Command{ + Use: "passwd ", + Short: "Set password for a user", + Args: cobra.ExactArgs(1), + RunE: runPasswd, +} + +func init() { + rootCmd.AddCommand(passwdCmd) +} + +func runPasswd(cmd *cobra.Command, args []string) error { + username := args[0] + + cfg, err := config.Load(cfgFile) + if err != nil { + return err + } + + database, err := db.Open(cfg.Database.Path) + if err != nil { + return err + } + defer func() { _ = database.Close() }() + + // Verify user exists. + var userID int64 + err = database.QueryRow("SELECT id FROM users WHERE username = ?", username).Scan(&userID) + if err != nil { + return fmt.Errorf("user %q not found", username) + } + + fmt.Print("New password: ") + passBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return fmt.Errorf("read password: %w", err) + } + + password := strings.TrimSpace(string(passBytes)) + if password == "" { + return fmt.Errorf("password cannot be empty") + } + + params := auth.Argon2Params{ + Memory: cfg.Auth.Argon2Memory, + Time: cfg.Auth.Argon2Time, + Threads: cfg.Auth.Argon2Threads, + } + + hash, err := auth.HashPassword(password, params) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + _, err = database.Exec("UPDATE users SET password_hash = ?, updated_at = unixepoch() WHERE id = ?", hash, userID) + if err != nil { + return fmt.Errorf("update password: %w", err) + } + + fmt.Printf("Password updated for %q.\n", username) + return nil +} diff --git a/internal/webserver/handlers.go b/internal/webserver/handlers.go index 04f0612..7880d85 100644 --- a/internal/webserver/handlers.go +++ b/internal/webserver/handlers.go @@ -25,6 +25,7 @@ func (ws *WebServer) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { userID, err := auth.AuthenticateUser(ws.db, username, password) if err != nil { + slog.Error("login failed", "username", username, "error", err) ws.render(w, "login.html", map[string]string{"Error": "Invalid credentials"}) return } @@ -254,8 +255,14 @@ func (ws *WebServer) authMiddleware(next http.Handler) http.Handler { } func (ws *WebServer) render(w http.ResponseWriter, name string, data any) { + t, ok := ws.tmpls[name] + if !ok { + http.Error(w, "Template not found", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := ws.tmpl.ExecuteTemplate(w, name, data); err != nil { + if err := t.ExecuteTemplate(w, "layout.html", data); err != nil { + slog.Error("render template", "name", name, "error", err) http.Error(w, "Template error", http.StatusInternalServerError) } } diff --git a/internal/webserver/server.go b/internal/webserver/server.go index 5deb048..a51da5a 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -31,7 +31,7 @@ type Config struct { type WebServer struct { db *sql.DB baseURL string - tmpl *template.Template + tmpls map[string]*template.Template webauthn *webauthn.WebAuthn mu sync.Mutex sessions map[string]*webauthn.SessionData @@ -43,15 +43,32 @@ func Start(cfg Config) (*http.Server, error) { return nil, fmt.Errorf("template fs: %w", err) } - tmpl, err := template.ParseFS(templateFS, "*.html") + layoutData, err := fs.ReadFile(templateFS, "layout.html") if err != nil { - return nil, fmt.Errorf("parse templates: %w", err) + return nil, fmt.Errorf("read layout: %w", err) + } + + pages := []string{"login.html", "notebooks.html", "notebook.html", "page.html", "keys.html"} + tmpls := make(map[string]*template.Template, len(pages)) + for _, page := range pages { + t, err := template.New("layout.html").Parse(string(layoutData)) + if err != nil { + return nil, fmt.Errorf("parse layout: %w", err) + } + pageData, err := fs.ReadFile(templateFS, page) + if err != nil { + return nil, fmt.Errorf("read %s: %w", page, err) + } + if _, err := t.Parse(string(pageData)); err != nil { + return nil, fmt.Errorf("parse %s: %w", page, err) + } + tmpls[page] = t } ws := &WebServer{ db: cfg.DB, baseURL: cfg.BaseURL, - tmpl: tmpl, + tmpls: tmpls, sessions: make(map[string]*webauthn.SessionData), }