Files
tiny-forge/cmd/server/main.go
T
alexei.dolgolyov 0405ecd9ce
Build / build (push) Successful in 10m36s
feat(notify): HMAC-signed outgoing webhooks with per-tier secrets and test sender
Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.

Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.

Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.

Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).

API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.

UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.

Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.

Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
2026-05-07 02:03:32 +03:00

470 lines
14 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/robfig/cron/v3"
tinyforge "github.com/alexei/tinyforge"
"github.com/alexei/tinyforge/internal/api"
"github.com/alexei/tinyforge/internal/auth"
"github.com/alexei/tinyforge/internal/config"
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/backup"
"github.com/alexei/tinyforge/internal/deployer"
"github.com/alexei/tinyforge/internal/dns"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/health"
"github.com/alexei/tinyforge/internal/logging"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/npm"
"github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/tinyforge/internal/registry"
"github.com/alexei/tinyforge/internal/stale"
"github.com/alexei/tinyforge/internal/stack"
"github.com/alexei/tinyforge/internal/stats"
"github.com/alexei/tinyforge/internal/staticsite"
"github.com/alexei/tinyforge/internal/store"
"github.com/alexei/tinyforge/internal/webhook"
)
func main() {
// Initialize structured JSON logging.
logging.Setup()
dataDir := envOrDefault("DATA_DIR", "./data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
slog.Error("create data directory", "error", err)
os.Exit(1)
}
// Open database.
dbPath := filepath.Join(dataDir, "tinyforge.db")
db, err := store.New(dbPath)
if err != nil {
slog.Error("open store", "error", err)
os.Exit(1)
}
defer db.Close()
// Derive encryption key from environment (required).
encKey, err := crypto.KeyFromEnv()
if err != nil {
slog.Error("ENCRYPTION_KEY is required — set it to a random 32+ character string")
os.Exit(1)
}
// Import seed config on first launch (idempotent).
seedPath := envOrDefault("SEED_FILE", "./tinyforge.yaml")
if err := config.ImportSeed(db, seedPath); err != nil {
slog.Error("seed import", "error", err)
os.Exit(1)
}
// Ensure default admin user exists on first launch.
if err := ensureDefaultAdmin(db); err != nil {
slog.Error("ensure default admin", "error", err)
os.Exit(1)
}
// Initialize Docker client.
dockerClient, err := docker.New()
if err != nil {
slog.Error("create docker client", "error", err)
os.Exit(1)
}
defer dockerClient.Close()
// Read settings for NPM URL and polling interval.
settings, err := db.GetSettings()
if err != nil {
slog.Error("get settings", "error", err)
os.Exit(1)
}
// Initialize NPM client (used for NPM-specific endpoints like certificates).
npmURL := envOrDefault("NPM_URL", settings.NpmURL)
npmClient := npm.New(npmURL)
// Build proxy provider based on settings.
var proxyProvider proxy.Provider
switch settings.ProxyProvider {
case "none":
proxyProvider = proxy.NewNoneProvider()
slog.Info("proxy provider: none")
case "traefik":
proxyProvider = proxy.NewTraefikProvider(
settings.TraefikEntrypoint,
settings.TraefikCertResolver,
settings.TraefikNetwork,
settings.TraefikAPIURL,
)
slog.Info("proxy provider: traefik", "entrypoint", settings.TraefikEntrypoint)
default:
// Default to NPM for backward compatibility (including "npm" and empty string).
npmPassword := ""
if settings.NpmPassword != "" {
decrypted, err := crypto.Decrypt(encKey, settings.NpmPassword)
if err != nil {
slog.Warn("failed to decrypt NPM password for proxy provider", "error", err)
} else {
npmPassword = decrypted
}
}
proxyProvider = proxy.NewNpmProvider(npmClient, settings.NpmEmail, npmPassword)
slog.Info("proxy provider: npm", "url", npmURL)
}
// Initialize services.
healthChecker := health.New()
notifier := notify.New()
eventBus := events.New()
// Auto-persist warn/error events from the event bus to the database.
stopLogger := eventBus.RegisterPersistentLogger(func(source, severity, message, metadata string) (int64, string, error) {
evt, err := db.InsertEvent(store.EventLog{
Source: source,
Severity: severity,
Message: message,
Metadata: metadata,
})
if err != nil {
return 0, "", err
}
return evt.ID, evt.CreatedAt, nil
})
defer stopLogger()
dep := deployer.New(dockerClient, proxyProvider, db, healthChecker, notifier, eventBus, encKey)
// Initialize webhook handler. Per-project and per-site secrets are stored
// on their respective rows; the static-site triggerer is wired in below
// once the site manager has been constructed.
webhookHandler := webhook.NewHandler(db, dep, nil)
// Initialize registry poller.
poller := registry.NewPoller(db, dep, encKey)
pollingInterval := envOrDefault("POLLING_INTERVAL", settings.PollingInterval)
if pollingInterval != "" {
if err := poller.Start(pollingInterval); err != nil {
slog.Warn("failed to start poller", "error", err)
}
}
// Initialize stale container scanner.
staleScanner := stale.New(db, dockerClient, eventBus)
if err := staleScanner.Start("1h"); err != nil {
slog.Warn("failed to start stale scanner", "error", err)
}
// Start daily event log pruning cron job.
cronScheduler := cron.New()
if _, err := cronScheduler.AddFunc("@daily", func() {
pruned, err := db.PruneEvents(30)
if err != nil {
slog.Error("event log prune failed", "error", err)
return
}
if pruned > 0 {
slog.Info("pruned old event log entries", "count", pruned)
}
}); err != nil {
slog.Warn("failed to schedule event prune cron", "error", err)
}
cronScheduler.Start()
// Subscribe to error events and forward notifications.
notifySub := eventBus.Subscribe(func(evt events.Event) bool {
if evt.Type != events.EventLog {
return false
}
p, ok := evt.Payload.(events.EventLogPayload)
if !ok {
return false
}
return p.Severity == "error"
})
go func() {
for evt := range notifySub {
p, ok := evt.Payload.(events.EventLogPayload)
if !ok {
continue
}
currentSettings, err := db.GetSettings()
if err != nil || currentSettings.NotificationURL == "" {
continue
}
notifier.SendSigned(currentSettings.NotificationURL, currentSettings.NotificationSecret, notify.TierSettings, notify.Event{
Type: p.Source + "_error",
Project: p.Source,
Error: p.Message,
})
}
}()
// Initialize DNS provider from settings (nil for wildcard mode).
dnsProvider := initDNSProvider(settings, encKey)
if dnsProvider != nil {
dep.SetDNSProvider(dnsProvider)
slog.Info("DNS provider initialized", "provider", settings.DNSProvider)
}
// Initialize backup engine.
backupEngine, err := backup.New(db, dbPath, dataDir)
if err != nil {
slog.Error("create backup engine", "error", err)
os.Exit(1)
}
// Clean orphaned backup files and prune on startup.
if cleaned, err := backupEngine.CleanOrphans(); err != nil {
slog.Warn("backup: clean orphans on startup", "error", err)
} else if cleaned > 0 {
slog.Info("backup: cleaned orphaned files on startup", "count", cleaned)
}
if settings.BackupRetentionCount > 0 {
if pruned, err := backupEngine.Prune(settings.BackupRetentionCount); err != nil {
slog.Warn("backup: prune on startup", "error", err)
} else if pruned > 0 {
slog.Info("backup: pruned old backups on startup", "count", pruned)
}
}
// Schedule autobackup if enabled. Track entry ID for rescheduling.
var backupCronID cron.EntryID
scheduleAutobackup := func(enabled bool, intervalHours int) {
// Remove existing schedule if any.
if backupCronID != 0 {
cronScheduler.Remove(backupCronID)
backupCronID = 0
slog.Info("autobackup: removed previous schedule")
}
if !enabled || intervalHours <= 0 {
return
}
interval := fmt.Sprintf("@every %dh", intervalHours)
id, err := cronScheduler.AddFunc(interval, func() {
b, err := backupEngine.CreateBackup("auto")
if err != nil {
slog.Error("autobackup failed", "error", err)
return
}
slog.Info("autobackup completed", "id", b.ID, "filename", b.Filename)
currentSettings, err := db.GetSettings()
if err == nil && currentSettings.BackupRetentionCount > 0 {
backupEngine.Prune(currentSettings.BackupRetentionCount)
}
})
if err != nil {
slog.Warn("failed to schedule autobackup", "error", err)
} else {
backupCronID = id
slog.Info("autobackup scheduled", "interval_hours", intervalHours)
}
}
scheduleAutobackup(settings.BackupEnabled, settings.BackupIntervalHours)
// Initialize resource stats collector. Interval + retention are read from
// settings on each tick, so configuration changes take effect within one
// tick without a restart.
statsCollector := stats.New(db, dockerClient)
statsCollector.Start()
// Initialize static site manager and health checker.
staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, notifier, encKey)
webhookHandler.SetSiteSyncTriggerer(staticSiteMgr)
staticSiteHealth := staticsite.NewHealthChecker(db, dockerClient, staticSiteMgr)
if err := staticSiteHealth.Start("2m"); err != nil {
slog.Warn("failed to start static site health checker", "error", err)
}
// Initialize stack (docker-compose) manager. Disabled gracefully if
// `docker compose` is not available on the host.
stackWorkDir := filepath.Join(filepath.Dir(dbPath), "stacks")
stackMgr, err := stack.NewManager(db, stack.NewCompose(""), eventBus, stackWorkDir)
if err != nil {
slog.Warn("failed to init stack manager", "error", err)
stackMgr = nil
} else if err := stackMgr.Available(context.Background()); err != nil {
slog.Warn("docker compose not available — stacks feature disabled", "error", err)
stackMgr = nil
}
// Build API server.
apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, notifier, webhookHandler, eventBus, encKey)
apiServer.SetStaticSiteManager(staticSiteMgr)
if stackMgr != nil {
apiServer.SetStackManager(stackMgr)
}
apiServer.SetStaleScanner(staleScanner)
apiServer.SetBackupEngine(backupEngine)
apiServer.SetDBPath(dbPath)
apiServer.SetBackupSettingsChangedCallback(scheduleAutobackup)
apiServer.SetDNSProvider(dnsProvider)
apiServer.SetDNSProviderChangedCallback(func(provider dns.Provider) {
dep.SetDNSProvider(provider)
})
apiServer.SetProxyProviderChangedCallback(func(provider proxy.Provider) {
dep.SetProxyProvider(provider)
})
router := apiServer.Router()
// Serve embedded static files for the SPA frontend.
// The embed.FS has "web/build" as a prefix, so we sub it to get the root.
webBuildFS, err := fs.Sub(tinyforge.WebBuildFS, "web/build")
if err != nil {
slog.Warn("embedded frontend not available", "error", err)
} else {
staticHandler := api.StaticHandler(webBuildFS)
// Handle all non-API routes with the static file server.
router.NotFound(staticHandler.ServeHTTP)
}
// Start HTTP server.
addr := envOrDefault("LISTEN_ADDR", ":8080")
httpServer := &http.Server{
Addr: addr,
Handler: router,
ReadTimeout: 30 * time.Second,
// WriteTimeout is disabled (0) to support SSE long-lived connections.
// Individual non-SSE handlers should use context timeouts as needed.
WriteTimeout: 0,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown.
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGTERM)
// Allow restore to trigger shutdown.
apiServer.SetShutdownFunc(func() {
done <- syscall.SIGTERM
})
go func() {
slog.Info("Tinyforge started", "addr", addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("HTTP server error", "error", err)
os.Exit(1)
}
}()
<-done
slog.Info("shutting down...")
// Stop accepting new work.
cronScheduler.Stop()
eventBus.Unsubscribe(notifySub)
staticSiteHealth.Stop()
staleScanner.Stop()
poller.Stop()
statsCollector.Stop()
// Drain in-progress deploys, site syncs, and notifications.
dep.Drain()
webhookHandler.Drain()
notifier.Drain()
// Shut down HTTP server.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
slog.Error("HTTP server shutdown error", "error", err)
}
// Close database.
if err := db.Close(); err != nil {
slog.Error("database close error", "error", err)
}
slog.Info("Tinyforge stopped")
}
// envOrDefault reads an environment variable or returns the fallback value.
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// ensureDefaultAdmin creates a default admin user on first launch if no users exist.
// The password comes from ADMIN_PASSWORD env var, defaulting to "admin".
func ensureDefaultAdmin(db *store.Store) error {
count, err := db.UserCount()
if err != nil {
return err
}
if count > 0 {
return nil // Users already exist, skip.
}
password := os.Getenv("ADMIN_PASSWORD")
if password == "" {
slog.Error("ADMIN_PASSWORD is required on first launch — set it to a secure password")
os.Exit(1)
}
hash, err := auth.HashPassword(password)
if err != nil {
return err
}
_, err = db.CreateUser(store.User{
Username: "admin",
PasswordHash: hash,
Email: "",
Role: "admin",
})
if err != nil {
// Ignore duplicate key errors (race condition on concurrent startup).
if errors.Is(err, store.ErrNotFound) {
return nil
}
return err
}
slog.Info("default admin user created", "username", "admin")
return nil
}
// initDNSProvider creates a DNS provider from settings. Returns nil for wildcard mode.
func initDNSProvider(settings store.Settings, encKey [32]byte) dns.Provider {
if settings.WildcardDNS || settings.DNSProvider == "" {
return nil
}
token := settings.CloudflareAPIToken
if token != "" {
decrypted, err := crypto.Decrypt(encKey, token)
if err != nil {
slog.Error("dns: failed to decrypt API token", "error", err)
return nil
}
token = decrypted
}
provider, err := dns.NewProvider(settings.DNSProvider, dns.Config{
Token: token,
ZoneID: settings.CloudflareZoneID,
})
if err != nil {
slog.Error("dns: failed to create provider", "error", err)
return nil
}
return provider
}