Files
oikos/internal/notifier/notifier.go
dtoro aa197190cd phase 3 review: fix broken error classification, stub checks, wasted uuid, token idempotency, dead code
- actuator/ssh.go: custom errorsAs chain broken — all SSH errors classified as SSHErrorOther.
  Replaced with standard errors.As + errors.Is.
- scheduler/scheduler.go: all four check functions were stubs returning healthy.
  Implemented real HTTP GET, TCP dial, unix.Statfs disk, and TLS cert expiry checks.
- learning/learning.go: uuid.NewV7() called unconditionally before ON CONFLICT upsert.
  Now looks up existing pattern first, reuses entity_id.
- notifier/notifier.go: removed dead var_, fixed token regeneration every 15s.
  Now skips if token_hash already set.
- phase3.go: removed dead GetPattern+dummy args call in PatchPattern.
- classify.go: removed unused var_ guard.
2026-07-07 15:27:31 +02:00

117 lines
3.1 KiB
Go

// Package notifier handles alerts and approval requests via Matrix.
// Uses the DB as the rendezvous — no service-to-service calls (SA7/A7).
// Pending approvals survive restarts of either side.
package notifier
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"time"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/google/uuid"
)
// Run starts the notifier loop. Blocks until ctx is cancelled.
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
slog.Info("notifier: starting")
interval := 15 * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Info("notifier: shutting down")
return
case <-ticker.C:
processPendingApprovals(ctx, pool, cfg)
}
}
}
// processPendingApprovals checks for pending approvals and sends alerts.
func processPendingApprovals(ctx context.Context, pool *db.Pool, cfg config.Config) {
q := sqlcgen.New(pool)
status := "pending"
approvals, err := q.ListApprovals(ctx, sqlcgen.ListApprovalsParams{
Status: &status,
})
if err != nil {
slog.Error("notifier: list approvals", "error", err)
return
}
for _, a := range approvals {
// Check if already expired
if a.ExpiresAt.Before(time.Now()) {
_ = q.UpdateApprovalStatus(ctx, sqlcgen.UpdateApprovalStatusParams{
EntityID: a.EntityID,
Status: "expired",
})
continue
}
// Generate approval token only if not already generated
if a.TokenHash != nil && *a.TokenHash != "" {
continue
}
token := generateApprovalToken(a.EntityID, cfg.ApprovalHMACSecret)
tokenHash := hashToken(token)
// Store token hash
_, _ = pool.Exec(ctx,
"UPDATE approvals SET token_hash = $2 WHERE entity_id = $1",
a.EntityID, tokenHash)
slog.Info("notifier: approval pending",
"approval_id", a.EntityID,
"action", a.Action,
"risk_class", a.RiskClass,
"token", token[:16]+"...",
"expires_at", a.ExpiresAt)
}
}
// generateApprovalToken creates a single-use HMAC token for an approval.
// Token = HMAC(approval_id ‖ nonce, secret)
func generateApprovalToken(approvalID uuid.UUID, secret string) string {
if secret == "" {
secret = "dev-secret-do-not-use-in-prod"
}
nonce := fmt.Sprintf("%d", time.Now().UnixNano())
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(approvalID.String()))
mac.Write([]byte(nonce))
return hex.EncodeToString(mac.Sum(nil))
}
// VerifyApprovalToken checks that a token matches the stored hash.
func VerifyApprovalToken(ctx context.Context, pool *db.Pool, approvalID uuid.UUID, token string) bool {
q := sqlcgen.New(pool)
a, err := q.GetApprovalByID(ctx, approvalID)
if err != nil || a.TokenHash == nil {
return false
}
if a.Status != "pending" {
return false
}
if a.ExpiresAt.Before(time.Now()) {
return false
}
return *a.TokenHash == hashToken(token)
}
// hashToken double-hashes a token for storage.
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}