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) <noreply@anthropic.com>
This commit is contained in:
@@ -49,7 +49,8 @@ eng-pad-server/
|
|||||||
│ └── eng-pad-server/ CLI entry point (cobra)
|
│ └── eng-pad-server/ CLI entry point (cobra)
|
||||||
│ ├── main.go
|
│ ├── main.go
|
||||||
│ ├── server.go server subcommand
|
│ ├── server.go server subcommand
|
||||||
│ └── init.go init subcommand
|
│ ├── init.go init subcommand
|
||||||
|
│ └── passwd.go password reset subcommand
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── auth/
|
│ ├── auth/
|
||||||
│ │ ├── argon2.go Password hashing
|
│ │ ├── argon2.go Password hashing
|
||||||
|
|||||||
52
README.md
52
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.)
|
# Edit configuration (TLS certs, database path, etc.)
|
||||||
|
|
||||||
# Initialize (creates database, prompts for admin user)
|
# 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
|
# Run
|
||||||
./eng-pad-server server
|
./eng-pad-server server -c /srv/eng-pad-server/eng-pad-server.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
@@ -43,6 +43,54 @@ make proto # regenerate gRPC code from .proto files
|
|||||||
make proto-lint # buf lint + breaking change detection
|
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 <username> -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
|
## Documentation
|
||||||
|
|
||||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — full system specification
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — full system specification
|
||||||
|
|||||||
125
RUNBOOK.md
125
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
|
Pad Android app via gRPC, stores it in SQLite, and serves read-only
|
||||||
views through a web UI. Single authenticated user.
|
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/`
|
**Data**: `/srv/eng-pad-server/`
|
||||||
**Config**: `/srv/eng-pad-server/eng-pad-server.toml`
|
**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
|
## 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
|
docker logs eng-pad-server --tail 20
|
||||||
```
|
|
||||||
|
|
||||||
4. Check gRPC responds:
|
|
||||||
```
|
|
||||||
grpcurl -insecure localhost:9443 list
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Common Operations
|
## 3. Common Operations
|
||||||
@@ -38,84 +36,69 @@ views through a web UI. Single authenticated user.
|
|||||||
### Start / Stop / Restart
|
### Start / Stop / Restart
|
||||||
|
|
||||||
```
|
```
|
||||||
systemctl start eng-pad-server
|
docker start eng-pad-server
|
||||||
systemctl stop eng-pad-server
|
docker stop eng-pad-server
|
||||||
systemctl restart eng-pad-server
|
docker restart eng-pad-server
|
||||||
```
|
```
|
||||||
|
|
||||||
### View Logs
|
### 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 <username> -c /srv/eng-pad-server/eng-pad-server.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Backup
|
### 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/`.
|
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
|
### 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`.
|
2. Navigate to `/keys`.
|
||||||
3. Enter a name for the key (e.g., "YubiKey 5").
|
3. Enter a name for the key (e.g., "YubiKey 5").
|
||||||
4. Click "Register" and touch the key when prompted.
|
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
|
## 4. Alerting
|
||||||
|
|
||||||
No automated alerting is configured. Monitor via:
|
No automated alerting is configured. Monitor via:
|
||||||
@@ -129,12 +112,12 @@ No automated alerting is configured. Monitor via:
|
|||||||
|
|
||||||
1. Check logs:
|
1. Check logs:
|
||||||
```
|
```
|
||||||
journalctl -u eng-pad-server -n 50 --no-pager
|
docker logs eng-pad-server --tail 50
|
||||||
```
|
```
|
||||||
2. Common causes:
|
2. Common causes:
|
||||||
- Config file missing or invalid → fix config
|
- Config file missing or invalid → fix `/srv/eng-pad-server/eng-pad-server.toml`
|
||||||
- TLS cert/key missing → regenerate or copy
|
- TLS cert/key missing → re-copy from Let's Encrypt (see Renew TLS above)
|
||||||
- Port already in use → `ss -tlnp | grep 8443`
|
- 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 locked → check for zombie processes: `fuser /srv/eng-pad-server/eng-pad-server.db`
|
||||||
|
|
||||||
### Database Corruption
|
### Database Corruption
|
||||||
|
|||||||
77
cmd/eng-pad-server/passwd.go
Normal file
77
cmd/eng-pad-server/passwd.go
Normal file
@@ -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 <username>",
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ func (ws *WebServer) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
userID, err := auth.AuthenticateUser(ws.db, username, password)
|
userID, err := auth.AuthenticateUser(ws.db, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("login failed", "username", username, "error", err)
|
||||||
ws.render(w, "login.html", map[string]string{"Error": "Invalid credentials"})
|
ws.render(w, "login.html", map[string]string{"Error": "Invalid credentials"})
|
||||||
return
|
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) {
|
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")
|
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)
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type Config struct {
|
|||||||
type WebServer struct {
|
type WebServer struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
baseURL string
|
baseURL string
|
||||||
tmpl *template.Template
|
tmpls map[string]*template.Template
|
||||||
webauthn *webauthn.WebAuthn
|
webauthn *webauthn.WebAuthn
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
sessions map[string]*webauthn.SessionData
|
sessions map[string]*webauthn.SessionData
|
||||||
@@ -43,15 +43,32 @@ func Start(cfg Config) (*http.Server, error) {
|
|||||||
return nil, fmt.Errorf("template fs: %w", err)
|
return nil, fmt.Errorf("template fs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl, err := template.ParseFS(templateFS, "*.html")
|
layoutData, err := fs.ReadFile(templateFS, "layout.html")
|
||||||
if err != nil {
|
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{
|
ws := &WebServer{
|
||||||
db: cfg.DB,
|
db: cfg.DB,
|
||||||
baseURL: cfg.BaseURL,
|
baseURL: cfg.BaseURL,
|
||||||
tmpl: tmpl,
|
tmpls: tmpls,
|
||||||
sessions: make(map[string]*webauthn.SessionData),
|
sessions: make(map[string]*webauthn.SessionData),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user