diff options
Diffstat
| -rw-r--r-- | cmd/mirumd/config.go | 2 | +1 −1 |
| -rw-r--r-- | cmd/mirumd/main.go | 8 | +5 −3 |
| -rw-r--r-- | cmd/mirumd/server.go | 18 | +18 −0 |
| -rw-r--r-- | cmd/mirumd/server_grpc.go | 35 | +18 −17 |
| -rw-r--r-- | cmd/mirumd/server_web.go | 121 | +119 −2 |
| -rw-r--r-- | cmd/mirumd/templates/index.html | 16 | +16 −0 |
| -rw-r--r-- | cmd/mirumd/templates/layout.html | 14 | +14 −0 |
| -rw-r--r-- | cmd/mirumd/templates/login.html | 21 | +21 −0 |
| -rw-r--r-- | internal/database/database.go | 197 | +177 −20 |
| -rw-r--r-- | pkg/mirumd.service | 4 | +2 −2 |
| -rw-r--r-- | pkg/mirumd.yaml | 6 | +3 −3 |
11 files changed, 394 insertions, 48 deletions
diff --git a/cmd/mirumd/config.go b/cmd/mirumd/config.go index c856d01..7eae4e3 100644 --- a/cmd/mirumd/config.go +++ b/cmd/mirumd/config.go @@ -15,7 +15,7 @@ type config struct { GrpcAddr string `yaml:"grpc_addr"` AdminSocket string `yaml:"admin_socket"` DatabaseUri string `yaml:"database_uri"` - WorkerSecret string `yaml:"secret"` + WorkerSecret string `yaml:"worker_secret"` Pepper string `yaml:"pepper"` GitHubToken string `yaml:"token"` diff --git a/cmd/mirumd/main.go b/cmd/mirumd/main.go index 1a048d7..9c413f8 100644 --- a/cmd/mirumd/main.go +++ b/cmd/mirumd/main.go @@ -159,6 +159,8 @@ func daemon(configFile, socketFlag string) { } }() + go srv.PurgeSessions(ctx) + sup.Ready() go sup.StartWatchdog(ctx) @@ -175,12 +177,12 @@ func daemon(configFile, socketFlag string) { srv.Close() adminSrv.GracefulStop() - wwwSrv.Shutdown(ctx) + wwwSrv.Shutdown(context.Background()) grpcSrv.GracefulStop() } -// listeners returns gRPC and HTTP listeners. -// With systemd socket activation it expects two named fds: "grpc" and "http". +// listeners returns gRPC, web, and admin listeners. +// With systemd socket activation it expects two named fds: "grpc" and "web". // Without socket activation it falls back to configured addresses. func listeners(cfg *config) (grpcLn, webLn, adminLn net.Listener, err error) { named, err := activation.ListenersWithNames() diff --git a/cmd/mirumd/server.go b/cmd/mirumd/server.go index 2d7b524..a93c9b8 100644 --- a/cmd/mirumd/server.go +++ b/cmd/mirumd/server.go @@ -9,6 +9,7 @@ import ( "log/slog" "sync" "sync/atomic" + "time" "dimidiumlabs/mirum/internal/database" "dimidiumlabs/mirum/internal/forges" @@ -30,6 +31,23 @@ func (s *server) Close() { close(s.queue) } +// PurgeSessions periodically deletes expired sessions until ctx is cancelled. +func (s *server) PurgeSessions(ctx context.Context) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := s.db.PurgeExpiredSessions(ctx); err != nil { + slog.Error("purge sessions", "err", err) + } + case <-ctx.Done(): + return + } + } +} + func (s *server) enqueue(ev *forges.PushEvent) string { s.taskCounter.Add(1) id := fmt.Sprintf("task-%d", s.taskCounter.Load()) diff --git a/cmd/mirumd/server_grpc.go b/cmd/mirumd/server_grpc.go index 82063cf..6490715 100644 --- a/cmd/mirumd/server_grpc.go +++ b/cmd/mirumd/server_grpc.go @@ -8,6 +8,7 @@ import ( "fmt" "log/slog" "sync" + "sync/atomic" "time" "dimidiumlabs/mirum/internal/protocol" @@ -15,12 +16,14 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/codes" - "google.golang.org/grpc/peer" "google.golang.org/grpc/stats" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" ) +// connIDKey is the context key for the unique connection identifier. +type connIDKey struct{} + func NewGrpcServer(ctx context.Context, srv *server, secret []byte) *grpc.Server { gsrv := &grpcService{ ctx: ctx, @@ -43,7 +46,8 @@ type grpcService struct { ctx context.Context srv *server secret []byte - authedPeers sync.Map // peer addr string → true + authedConns sync.Map // conn ID (uint64) → true + nextConnID atomic.Uint64 } func (g *grpcService) Poll(ctx context.Context, req *pb.PollRequest) (*pb.Task, error) { @@ -128,8 +132,8 @@ func (g *grpcService) Handshake(stream pb.Mirum_HandshakeServer) error { ) // Step 4: accept - if p, ok := peer.FromContext(stream.Context()); ok { - g.authedPeers.Store(p.Addr.String(), true) + if id, ok := stream.Context().Value(connIDKey{}).(uint64); ok { + g.authedConns.Store(id, true) } return g.sendResult(stream, nil, warnings) } @@ -154,18 +158,15 @@ func (g *grpcService) reject(stream pb.Mirum_HandshakeServer, reason string) err return fmt.Errorf("%s", reason) } -func peerAddr(ctx context.Context) string { - if p, ok := peer.FromContext(ctx); ok { - return p.Addr.String() - } - return "" -} - func (g *grpcService) requireAuth(ctx context.Context, method string) error { if method == pb.Mirum_Handshake_FullMethodName { return nil } - if _, ok := g.authedPeers.Load(peerAddr(ctx)); !ok { + id, ok := ctx.Value(connIDKey{}).(uint64) + if !ok { + return status.Error(codes.Unauthenticated, "handshake required") + } + if _, ok := g.authedConns.Load(id); !ok { return status.Error(codes.Unauthenticated, "handshake required") } return nil @@ -202,7 +203,8 @@ type connTracker struct { } func (t *connTracker) TagConn(ctx context.Context, info *stats.ConnTagInfo) context.Context { - return ctx + id := t.gsrv.nextConnID.Add(1) + return context.WithValue(ctx, connIDKey{}, id) } func (t *connTracker) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context { @@ -213,10 +215,9 @@ func (t *connTracker) HandleRPC(ctx context.Context, s stats.RPCStats) {} func (t *connTracker) HandleConn(ctx context.Context, s stats.ConnStats) { if _, ok := s.(*stats.ConnEnd); ok { - addr := peerAddr(ctx) - if addr != "" { - t.gsrv.authedPeers.Delete(addr) - slog.Debug("peer disconnected", "addr", addr) + if id, ok := ctx.Value(connIDKey{}).(uint64); ok { + t.gsrv.authedConns.Delete(id) + slog.Debug("peer disconnected", "conn", id) } } } diff --git a/cmd/mirumd/server_web.go b/cmd/mirumd/server_web.go index d8ed590..5c922c6 100644 --- a/cmd/mirumd/server_web.go +++ b/cmd/mirumd/server_web.go @@ -5,15 +5,68 @@ package main import ( "context" + "crypto/rand" + "crypto/subtle" + "embed" + "encoding/base64" "errors" - "fmt" + "html/template" "io" "net" "net/http" + "dimidiumlabs/mirum/internal/database" "dimidiumlabs/mirum/internal/forges" ) +//go:embed templates/*.html +var templateFS embed.FS + +var ( + indexTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/index.html")) + loginTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/login.html")) +) + +// csrfToken returns the current CSRF token, setting a cookie if absent. +func csrfToken(w http.ResponseWriter, r *http.Request) string { + if c, err := r.Cookie("csrf"); err == nil && c.Value != "" { + return c.Value + } + b := make([]byte, 32) + rand.Read(b) + token := base64.RawURLEncoding.EncodeToString(b) + http.SetCookie(w, &http.Cookie{ + Name: "csrf", + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) + return token +} + +// csrfOK checks that the form field matches the cookie (double-submit). +func csrfOK(r *http.Request) bool { + cookie, err := r.Cookie("csrf") + if err != nil || cookie.Value == "" { + return false + } + field := r.FormValue("csrf") + return subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(field)) == 1 +} + +func clearCookie(w http.ResponseWriter, name string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + MaxAge: -1, + }) +} + func NewWwwServer(ctx context.Context, srv *server) *http.Server { return &http.Server{ Handler: wwwRoutes(srv), @@ -28,7 +81,14 @@ func wwwRoutes(srv *server) http.Handler { mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Mirum</title></head><body><h1>Mirum</h1><p>CI server is running.</p></body></html>`) + var data struct{ Email, CSRF string } + if c, err := r.Cookie("session"); err == nil { + if sess, err := srv.db.GetSession(r.Context(), c.Value); err == nil { + data.Email = sess.Email + data.CSRF = csrfToken(w, r) + } + } + indexTmpl.ExecuteTemplate(w, "layout", data) }) mux.HandleFunc("POST /webhook", func(w http.ResponseWriter, r *http.Request) { @@ -56,5 +116,62 @@ func wwwRoutes(srv *server) http.Handler { w.WriteHeader(http.StatusAccepted) }) + mux.HandleFunc("GET /auth/login", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + loginTmpl.ExecuteTemplate(w, "layout", map[string]string{"CSRF": csrfToken(w, r)}) + }) + + mux.HandleFunc("POST /auth/login", func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 4096) + if !csrfOK(r) { + clearCookie(w, "csrf") + http.Error(w, "invalid csrf token", http.StatusForbidden) + return + } + + email := r.FormValue("email") + password := r.FormValue("password") + + userID, err := srv.db.VerifyPassword(r.Context(), email, password, []byte(srv.cfg.Pepper)) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + loginTmpl.ExecuteTemplate(w, "layout", map[string]string{ + "Error": "Invalid credentials", + "CSRF": csrfToken(w, r), + }) + return + } + + token, err := srv.db.CreateSession(r.Context(), userID) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(database.SessionTTL.Seconds()), + }) + http.Redirect(w, r, "/", http.StatusSeeOther) + }) + + mux.HandleFunc("POST /auth/logout", func(w http.ResponseWriter, r *http.Request) { + if !csrfOK(r) { + http.Error(w, "invalid csrf token", http.StatusForbidden) + return + } + if c, err := r.Cookie("session"); err == nil { + srv.db.DeleteSession(r.Context(), c.Value) + } + clearCookie(w, "session") + clearCookie(w, "csrf") + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) + }) + return mux } diff --git a/cmd/mirumd/templates/index.html b/cmd/mirumd/templates/index.html new file mode 100644 --- /dev/null +++ b/cmd/mirumd/templates/index.html @@ -0,0 +1,16 @@ +{{/* Copyright (c) 2026 Nikolay Govorov */}} +{{/* SPDX-License-Identifier: AGPL-3.0-or-later */}} +{{define "title"}}Home{{end}} +{{define "content"}} +<h1>Mirum</h1> +{{if .Email}} +<p>Logged in as {{.Email}}</p> +<form method="POST" action="/auth/logout"> +<input type="hidden" name="csrf" value="{{.CSRF}}"> +<button type="submit">Logout</button> +</form> +{{else}} +<p>CI server is running.</p> +<p><a href="/auth/login">Login</a></p> +{{end}} +{{end}} diff --git a/cmd/mirumd/templates/layout.html b/cmd/mirumd/templates/layout.html new file mode 100644 --- /dev/null +++ b/cmd/mirumd/templates/layout.html @@ -0,0 +1,14 @@ +{{/* Copyright (c) 2026 Nikolay Govorov */}} +{{/* SPDX-License-Identifier: AGPL-3.0-or-later */}} +{{define "layout"}} +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>{{template "title" .}} — Mirum</title> + </head> + <body> + {{template "content" .}} + </body> +</html> +{{end}} diff --git a/cmd/mirumd/templates/login.html b/cmd/mirumd/templates/login.html new file mode 100644 --- /dev/null +++ b/cmd/mirumd/templates/login.html @@ -0,0 +1,21 @@ +{{/* Copyright (c) 2026 Nikolay Govorov */}} +{{/* SPDX-License-Identifier: AGPL-3.0-or-later */}} +{{define "title"}}Login{{end}} + +{{define "content"}} +<h1>Login</h1> + +{{if .Error}} +<p style="color:red">{{.Error}}</p> +{{end}} + +<form method="POST" action="/auth/login"> + <input type="hidden" name="csrf" value="{{.CSRF}}"> + <label>Email<br><input type="email" name="email" required></label> + <br><br> + <label>Password<br><input type="password" name="password" required></label> + <br><br> + <button type="submit">Login</button> +</form> + +{{end}} diff --git a/internal/database/database.go b/internal/database/database.go index 1f88692..6ae3cd2 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -8,9 +8,12 @@ import ( "crypto/hmac" "crypto/rand" "crypto/sha256" + "crypto/subtle" "encoding/base64" "errors" "fmt" + "strings" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/tern/v2/migrate" @@ -26,13 +29,14 @@ const ( ) var ( - errOpen = errors.New("database: failed to open") - errPing = errors.New("database: failed to ping") - errAcquire = errors.New("database: failed to acquire connection") - errMigrate = errors.New("database: failed to create migrator") - errCreateUser = errors.New("database: failed to create user") - errSetPassword = errors.New("database: failed to set password") - errDeleteUser = errors.New("database: failed to delete user") + errOpen = errors.New("database: failed to open") + errPing = errors.New("database: failed to ping") + errAcquire = errors.New("database: failed to acquire connection") + errMigrate = errors.New("database: failed to create migrator") + errCreateUser = errors.New("database: failed to create user") + errSetPassword = errors.New("database: failed to set password") + errDeleteUser = errors.New("database: failed to delete user") + errInvalidCreds = errors.New("invalid credentials") ) // DB wraps a pgx connection pool. @@ -82,9 +86,91 @@ func (db *DB) Migrate(ctx context.Context) error { `DROP TABLE users`, ) + migrator.AppendMigration("create_sessions", + `CREATE TABLE sessions ( + token TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL + ); + CREATE INDEX sessions_expires_at ON sessions (expires_at)`, + `DROP TABLE sessions`, + ) + return migrator.Migrate(ctx) } +const SessionTTL = 14 * 24 * time.Hour // 14 days + +// hashToken returns the hex-encoded SHA-256 of a session token. +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return fmt.Sprintf("%x", h) +} + +// CreateSession generates a random token, stores its hash, and returns the token. +func (db *DB) CreateSession(ctx context.Context, userID string) (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + token := base64.RawURLEncoding.EncodeToString(b) + + _, err := db.Pool.Exec(ctx, + `INSERT INTO sessions (token, user_id, expires_at) VALUES ($1, $2, now() + $3)`, + hashToken(token), userID, SessionTTL, + ) + if err != nil { + return "", err + } + return token, nil +} + +// Session holds info about an authenticated session. +type Session struct { + UserID string + Email string +} + +// GetSession returns session info for a valid, non-expired session. +// It extends the session expiry only when less than half the TTL remains, +// avoiding a write on every request. +func (db *DB) GetSession(ctx context.Context, token string) (*Session, error) { + h := hashToken(token) + var s Session + var expiresAt time.Time + err := db.Pool.QueryRow(ctx, + `SELECT s.user_id, u.email, s.expires_at + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token = $1 AND s.expires_at > now() AND u.deleted_at IS NULL`, + h, + ).Scan(&s.UserID, &s.Email, &expiresAt) + if err != nil { + return nil, err + } + + if time.Until(expiresAt) < SessionTTL/2 { + db.Pool.Exec(ctx, + `UPDATE sessions SET expires_at = now() + $2 WHERE token = $1`, + h, SessionTTL, + ) + } + + return &s, nil +} + +// DeleteSession removes a session (logout). +func (db *DB) DeleteSession(ctx context.Context, token string) error { + _, err := db.Pool.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, hashToken(token)) + return err +} + +// PurgeExpiredSessions deletes all expired sessions. +func (db *DB) PurgeExpiredSessions(ctx context.Context) error { + _, err := db.Pool.Exec(ctx, `DELETE FROM sessions WHERE expires_at < now()`) + return err +} + // CreateUser hashes the password with argon2id and inserts a new user. // The pepper is a server-side secret not stored in the database. func (db *DB) CreateUser(ctx context.Context, email, password string, pepper []byte) (string, error) { @@ -105,40 +191,111 @@ func (db *DB) CreateUser(ctx context.Context, email, password string, pepper []b return id, nil } -// SetPassword updates the password for a user identified by email. +// SetPassword updates the password and invalidates all existing sessions. func (db *DB) SetPassword(ctx context.Context, email, password string, pepper []byte) error { hash, err := hashPassword(password, pepper) if err != nil { return err } - tag, err := db.Pool.Exec(ctx, - `UPDATE users SET password = $1 WHERE email = $2`, + tx, err := db.Pool.Begin(ctx) + if err != nil { + return errors.Join(errSetPassword, err) + } + defer tx.Rollback(ctx) + + var id string + err = tx.QueryRow(ctx, + `UPDATE users SET password = $1 WHERE email = $2 AND deleted_at IS NULL RETURNING id`, hash, email, - ) + ).Scan(&id) if err != nil { return errors.Join(errSetPassword, err) } - if tag.RowsAffected() == 0 { - return fmt.Errorf("user not found: %s", email) + + _, err = tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id) + if err != nil { + return errors.Join(errSetPassword, err) } - return nil + return tx.Commit(ctx) } // DeleteUser clears all fields but keeps the row to preserve the id. +// Also deletes all sessions for that user in a single transaction. func (db *DB) DeleteUser(ctx context.Context, email string) error { - tag, err := db.Pool.Exec(ctx, - `UPDATE users SET email = '<invalid>', password = '<invalid>', deleted_at = now() WHERE email = $1`, + tx, err := db.Pool.Begin(ctx) + if err != nil { + return errors.Join(errDeleteUser, err) + } + defer tx.Rollback(ctx) + + var id string + err = tx.QueryRow(ctx, + `UPDATE users SET email = id::text, password = '', deleted_at = now() WHERE email = $1 RETURNING id`, email, - ) + ).Scan(&id) if err != nil { return errors.Join(errDeleteUser, err) } - if tag.RowsAffected() == 0 { - return fmt.Errorf("user not found: %s", email) + + _, err = tx.Exec(ctx, `DELETE FROM sessions WHERE user_id = $1`, id) + if err != nil { + return errors.Join(errDeleteUser, err) + } + + return tx.Commit(ctx) +} + +// VerifyPassword checks credentials and returns the user ID. +func (db *DB) VerifyPassword(ctx context.Context, email, password string, pepper []byte) (string, error) { + var id, hash string + err := db.Pool.QueryRow(ctx, + `SELECT id, password FROM users WHERE email = $1 AND deleted_at IS NULL`, + email, + ).Scan(&id, &hash) + if err != nil { + return "", errInvalidCreds + } + + if !verifyHash(password, hash, pepper) { + return "", errInvalidCreds } - return nil + + return id, nil +} + +// verifyHash parses a PHC-format argon2id string and compares. +// Format: $argon2id$v=19$m=65536,t=3,p=2$<salt>$<key> +func verifyHash(password, encoded string, pepper []byte) bool { + // $argon2id$v=19$m=65536,t=3,p=2$salt$key → 6 parts + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + + var memory, time uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return false + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + expectedKey, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + + mac := hmac.New(sha256.New, pepper) + mac.Write([]byte(password)) + peppered := mac.Sum(nil) + + key := argon2.IDKey(peppered, salt, time, memory, threads, uint32(len(expectedKey))) + + return subtle.ConstantTimeCompare(key, expectedKey) == 1 } // hashPassword produces a PHC-format string: diff --git a/pkg/mirumd.service b/pkg/mirumd.service index e470c99..ab46526 100644 --- a/pkg/mirumd.service +++ b/pkg/mirumd.service @@ -4,7 +4,7 @@ [Unit] Description=Mirum daemon (modern CI platform) Requires=network-online.target -After=time-sync.target network-online.target remote-fs.target nss-lookup.target +After=time-sync.target network-online.target remote-fs.target nss-lookup.target postgresql.service Wants=time-sync.target # Socket activation (optional): @@ -29,7 +29,7 @@ RestartSec=30 WatchdogSec=30 NotifyAccess=main ExecPaths=/usr/local/bin/mirumd /usr/lib -ExecStart=/usr/local/bin/mirumd --config=/etc/mirum/mirumd.yaml +ExecStart=/usr/local/bin/mirumd daemon --config=/etc/mirum/mirumd.yaml LimitCORE=infinity LimitNOFILE=500000 AmbientCapabilities=CAP_NET_BIND_SERVICE diff --git a/pkg/mirumd.yaml b/pkg/mirumd.yaml index f140100..63d7d72 100644 --- a/pkg/mirumd.yaml +++ b/pkg/mirumd.yaml @@ -5,9 +5,9 @@ # See mirumd.socket for details (FileDescriptorName=grpc / web). grpc_addr: :2026 www_addr: :3000 -secret: "" +worker_secret: "" +webhook_secret: "" token: "" pepper: "" -dsn: "" +database_uri: "" admin_socket: /run/mirum/admin.sock -script: .mirum/main.star |
