7a9ff7ad54
Two paired backends sharing the events.Bus seam:
Event triggers (consumer-side):
- internal/store/event_triggers.go — CRUD with action_secret
redaction on read (placeholder echo treated as "no change" on
PATCH so secrets aren't accidentally wiped).
- internal/events/dispatcher.go — bus subscriber, AND-composed
filters (severity CSV, source CSV, message regex with memoized
compile cache). Structural loop-prevention: never writes to
event_log. Sends via notifier.SendPayload.
- internal/notify: SendPayload + SendSyncForTestPayload methods,
TierEventTrigger constant, doSendRaw shared with the legacy
Event-shaped path.
- internal/api/event_triggers.go — admin-gated CRUD + /test
sending the real TriggerWebhookPayload shape. SSRF guard
rejects loopback / link-local / unspecified targets. PATCH
uses pointer-typed DTO for partial updates.
Log scanner (producer-side):
- internal/logscanner/ — engine (per-rule cooldown +
per-container token bucket, atomic drop counters), tail
(multiplexed docker frame demuxer with TTY fallback + 16 MiB
payload cap + 1 MiB reassembly cap + RFC3339Nano-validated
timestamp strip + UTF-8-safe message truncation), manager
(5s container polling, atomic.Pointer[Snapshot] hot-reload,
HitEmitter writes event_log + publishes EventLog so the
trigger dispatcher picks them up immediately).
- internal/docker/container.go — ContainerLogsOpts exposes
stream selection for stderr-only / stdout-only rules.
- internal/store: log_scan_rules table + CRUD with
EffectiveLogScanRules resolver (globals minus per-workload
overrides plus workload-only additions). Transactional
cascade-delete of overrides when a global rule is removed.
- internal/api/log_scan_rules.go — admin-gated CRUD + /test
(sample_line → matched/captures) + /stats (drop counters +
active tail count + last-snapshot compile errors) +
GET /api/workloads/{id}/effective-rules.
cmd/server/main.go wires both subsystems next to the existing
RegisterPersistentLogger. Coverage spans engine cooldown / bucket
counter tests, snapshot effective-set semantics, manager compile-
error capture, dispatcher matching, store validation +
cascade-delete, API URL validator + secret redaction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
562 lines
21 KiB
Go
562 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/tinyforge/internal/auth"
|
|
"github.com/alexei/tinyforge/internal/backup"
|
|
"github.com/alexei/tinyforge/internal/crypto"
|
|
"github.com/alexei/tinyforge/internal/dns"
|
|
"github.com/alexei/tinyforge/internal/docker"
|
|
"github.com/alexei/tinyforge/internal/events"
|
|
"github.com/alexei/tinyforge/internal/notify"
|
|
"github.com/alexei/tinyforge/internal/npm"
|
|
"github.com/alexei/tinyforge/internal/proxy"
|
|
"github.com/alexei/tinyforge/internal/stack"
|
|
"github.com/alexei/tinyforge/internal/stale"
|
|
"github.com/alexei/tinyforge/internal/staticsite"
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
"github.com/alexei/tinyforge/internal/webhook"
|
|
)
|
|
|
|
// DNSProviderChangedFunc is called when DNS settings change so the caller can
|
|
// update the provider on the deployer.
|
|
type DNSProviderChangedFunc func(provider dns.Provider)
|
|
|
|
// Server holds all dependencies for the API layer.
|
|
type Server struct {
|
|
store *store.Store
|
|
docker *docker.Client
|
|
npm *npm.Client // optional: only for NPM-specific endpoints (certificates)
|
|
proxyProvider proxy.Provider
|
|
deployer DeployTriggerer
|
|
notifier *notify.Notifier
|
|
webhook *webhook.Handler
|
|
eventBus *events.Bus
|
|
encKey [32]byte
|
|
localAuth *auth.LocalAuth
|
|
oidcProvider *auth.OIDCProvider
|
|
staleScanner *stale.Scanner
|
|
|
|
dnsProviderMu sync.RWMutex
|
|
dnsProvider dns.Provider
|
|
onDNSProviderChanged DNSProviderChangedFunc
|
|
|
|
staticSiteManager *staticsite.Manager
|
|
stackManager *stack.Manager
|
|
backupEngine *backup.Engine
|
|
sseGate *sseGate
|
|
logScanReloader LogScanReloader
|
|
dbPath string
|
|
shutdownFunc func() // called after restore to trigger graceful shutdown
|
|
onBackupSettingsChanged func(enabled bool, intervalHours int) // called when backup settings change
|
|
onProxyProviderChanged func(provider proxy.Provider) // called when proxy provider changes
|
|
}
|
|
|
|
// NewServer creates a new API Server with all required dependencies.
|
|
func NewServer(
|
|
st *store.Store,
|
|
dockerClient *docker.Client,
|
|
npmClient *npm.Client,
|
|
proxyProvider proxy.Provider,
|
|
deployer DeployTriggerer,
|
|
notifier *notify.Notifier,
|
|
webhookHandler *webhook.Handler,
|
|
eventBus *events.Bus,
|
|
encKey [32]byte,
|
|
) *Server {
|
|
localAuth := auth.NewLocalAuth(encKey)
|
|
|
|
s := &Server{
|
|
store: st,
|
|
docker: dockerClient,
|
|
npm: npmClient,
|
|
proxyProvider: proxyProvider,
|
|
deployer: deployer,
|
|
notifier: notifier,
|
|
webhook: webhookHandler,
|
|
eventBus: eventBus,
|
|
encKey: encKey,
|
|
localAuth: localAuth,
|
|
sseGate: newSSEGate(maxConcurrentSSEStreams),
|
|
}
|
|
|
|
// Try to initialize OIDC provider from stored settings.
|
|
authSettings, err := st.GetAuthSettings()
|
|
if err == nil && authSettings.AuthMode == "oidc" && authSettings.OIDCIssuerURL != "" {
|
|
s.initOIDCProvider(context.Background(), authSettings)
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// SetStaticSiteManager sets the static site manager on the server.
|
|
func (s *Server) SetStaticSiteManager(mgr *staticsite.Manager) {
|
|
s.staticSiteManager = mgr
|
|
}
|
|
|
|
// SetStackManager sets the docker-compose stack manager on the server.
|
|
func (s *Server) SetStackManager(mgr *stack.Manager) {
|
|
s.stackManager = mgr
|
|
}
|
|
|
|
// SetStaleScanner sets the stale scanner on the server.
|
|
// Called after both the API server and scanner are initialized.
|
|
func (s *Server) SetStaleScanner(scanner *stale.Scanner) {
|
|
s.staleScanner = scanner
|
|
}
|
|
|
|
// SetBackupEngine sets the backup engine on the server.
|
|
func (s *Server) SetBackupEngine(engine *backup.Engine) {
|
|
s.backupEngine = engine
|
|
}
|
|
|
|
// SetDBPath sets the database file path (needed for restore).
|
|
func (s *Server) SetDBPath(path string) {
|
|
s.dbPath = path
|
|
}
|
|
|
|
// SetShutdownFunc sets the function called after a restore to trigger graceful shutdown.
|
|
func (s *Server) SetShutdownFunc(fn func()) {
|
|
s.shutdownFunc = fn
|
|
}
|
|
|
|
// SetBackupSettingsChangedCallback sets the callback for when backup settings change.
|
|
func (s *Server) SetBackupSettingsChangedCallback(fn func(enabled bool, intervalHours int)) {
|
|
s.onBackupSettingsChanged = fn
|
|
}
|
|
|
|
// SetProxyProviderChangedCallback sets the callback for when the proxy provider changes.
|
|
func (s *Server) SetProxyProviderChangedCallback(fn func(provider proxy.Provider)) {
|
|
s.onProxyProviderChanged = fn
|
|
}
|
|
|
|
// SetProxyProvider updates the proxy provider at runtime.
|
|
func (s *Server) SetProxyProvider(provider proxy.Provider) {
|
|
s.proxyProvider = provider
|
|
}
|
|
|
|
// SetDNSProvider sets the current DNS provider on the server.
|
|
func (s *Server) SetDNSProvider(provider dns.Provider) {
|
|
s.dnsProviderMu.Lock()
|
|
defer s.dnsProviderMu.Unlock()
|
|
s.dnsProvider = provider
|
|
}
|
|
|
|
// getDNSProviderLocked returns the current DNS provider under read lock.
|
|
func (s *Server) getDNSProviderLocked() dns.Provider {
|
|
s.dnsProviderMu.RLock()
|
|
defer s.dnsProviderMu.RUnlock()
|
|
return s.dnsProvider
|
|
}
|
|
|
|
// SetDNSProviderChangedCallback sets the callback for when DNS settings change.
|
|
func (s *Server) SetDNSProviderChangedCallback(fn DNSProviderChangedFunc) {
|
|
s.onDNSProviderChanged = fn
|
|
}
|
|
|
|
// initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal.
|
|
func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) {
|
|
// Decrypt the OIDC client secret if it's encrypted.
|
|
clientSecret := as.OIDCClientSecret
|
|
if clientSecret != "" {
|
|
if decrypted, err := crypto.Decrypt(s.encKey, clientSecret); err == nil {
|
|
clientSecret = decrypted
|
|
}
|
|
// If decrypt fails, assume it's already plaintext (migration scenario).
|
|
}
|
|
provider, err := auth.NewOIDCProvider(ctx, auth.OIDCConfig{
|
|
IssuerURL: as.OIDCIssuerURL,
|
|
ClientID: as.OIDCClientID,
|
|
ClientSecret: clientSecret,
|
|
RedirectURL: as.OIDCRedirectURL,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("failed to initialize OIDC provider", "error", err)
|
|
return
|
|
}
|
|
s.oidcProvider = provider
|
|
slog.Info("OIDC provider initialized", "issuer", as.OIDCIssuerURL)
|
|
}
|
|
|
|
// Router returns a chi router with all API routes mounted.
|
|
func (s *Server) Router() chi.Router {
|
|
r := chi.NewRouter()
|
|
|
|
// Global middleware.
|
|
r.Use(recovery)
|
|
r.Use(securityHeaders)
|
|
r.Use(logging)
|
|
r.Use(cors)
|
|
|
|
loginLimiter := newRateLimiter()
|
|
webhookLimiter := newRateLimiter()
|
|
|
|
r.Route("/api", func(r chi.Router) {
|
|
// JSON content type and body size limit for API routes.
|
|
r.Use(jsonContentType)
|
|
r.Use(limitBody)
|
|
|
|
// Public auth endpoints (no auth required).
|
|
r.Get("/auth/mode", s.authMode)
|
|
r.Post("/auth/login", s.rateLimitedLogin(loginLimiter))
|
|
r.Get("/auth/oidc/login", s.oidcLogin)
|
|
r.Get("/auth/oidc/callback", s.oidcCallback)
|
|
r.Post("/auth/oidc/token", s.oidcExchangeToken)
|
|
|
|
// Webhook handler (uses its own secret-based auth).
|
|
// Per-IP rate limit prevents an attacker who has guessed (or leaked)
|
|
// a secret from triggering a deploy storm, and rejects unauthenticated
|
|
// brute-force probes over the secret URL space.
|
|
r.With(rateLimitMiddleware(webhookLimiter)).Mount("/webhook", s.webhook.Route())
|
|
|
|
// Protected routes: require valid JWT.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(auth.Middleware(s.localAuth))
|
|
|
|
// Plugin registry inspection + unified ingress (Workload refactor).
|
|
// /hooks/kinds is informational and visible to any authenticated
|
|
// caller. /hooks/generic dispatches deploys and is admin-gated —
|
|
// vendor-specific webhooks (with their own per-target HMAC
|
|
// secrets) live under /webhook/* and remain the only ingress
|
|
// reachable by external CI systems until Phase 5 consolidates them.
|
|
r.Get("/hooks/kinds", s.listHookKinds)
|
|
r.Get("/hooks/kinds/{kind}/schema", s.getHookKindSchema)
|
|
r.With(auth.AdminOnly).Post("/hooks/generic", s.dispatchGeneric)
|
|
|
|
// Read-only endpoints (any authenticated user).
|
|
r.Get("/health", s.getHealth)
|
|
r.Get("/auth/me", s.currentUser)
|
|
r.Post("/auth/logout", s.logout)
|
|
r.Get("/proxies", s.listProxyRoutes)
|
|
r.Get("/docker/unused-images", s.unusedImageStats)
|
|
// Legacy project/stage/site/stack endpoints carry a Deprecation
|
|
// header pointing at /api/workloads. Functional behavior is
|
|
// unchanged until the hard cutover removes them.
|
|
r.With(deprecated("/api/workloads")).Get("/projects", s.listProjects)
|
|
r.Route("/projects/{id}", func(r chi.Router) {
|
|
r.Get("/", s.getProject)
|
|
r.Get("/stages/{stage}/env", s.listStageEnv)
|
|
r.Get("/stages/{stage}/instances", s.listInstances)
|
|
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
|
|
r.Get("/stages/{stage}/instances/{iid}/stats/history", s.getInstanceStatsHistory)
|
|
r.Get("/stages/{stage}/instances/{iid}/logs", s.streamContainerLogs)
|
|
r.Get("/images", s.listProjectImages)
|
|
r.Get("/volumes", s.listVolumes)
|
|
r.Get("/volumes/{volId}/browse", s.browseVolume)
|
|
r.Get("/volumes/{volId}/download", s.downloadVolume)
|
|
|
|
// Admin-only project mutations.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(auth.AdminOnly)
|
|
r.Put("/", s.updateProject)
|
|
r.Delete("/", s.deleteProject)
|
|
|
|
// Per-project webhook URL management.
|
|
r.Get("/webhook", s.getProjectWebhook)
|
|
r.Post("/webhook/regenerate", s.regenerateProjectWebhook)
|
|
// Inbound HMAC signing — secret rotation + enforcement toggle.
|
|
r.Post("/webhook/signing-secret/regenerate", s.regenerateProjectSigningSecret)
|
|
r.Delete("/webhook/signing-secret", s.disableProjectSigningSecret)
|
|
r.Put("/webhook/require-signature", s.updateProjectSigningRequirement)
|
|
r.Get("/webhook/deliveries", s.listProjectWebhookDeliveries)
|
|
|
|
// Per-project outgoing-webhook signing & test.
|
|
r.Get("/notification-secret", s.getProjectNotificationSecret)
|
|
r.Post("/notification-secret/regenerate", s.regenerateProjectNotificationSecret)
|
|
r.Post("/notification-secret/disable", s.disableProjectNotificationSigning)
|
|
r.Post("/notification-test", s.projectNotificationTest)
|
|
|
|
// Stage endpoints.
|
|
r.Post("/stages", s.createStage)
|
|
r.Put("/stages/{stage}", s.updateStage)
|
|
r.Delete("/stages/{stage}", s.deleteStage)
|
|
|
|
// Per-stage outgoing-webhook signing & test.
|
|
r.Get("/stages/{stage}/notification-secret", s.getStageNotificationSecret)
|
|
r.Post("/stages/{stage}/notification-secret/regenerate", s.regenerateStageNotificationSecret)
|
|
r.Post("/stages/{stage}/notification-secret/disable", s.disableStageNotificationSigning)
|
|
r.Post("/stages/{stage}/notification-test", s.stageNotificationTest)
|
|
|
|
// Stage env override endpoints.
|
|
r.Post("/stages/{stage}/env", s.createStageEnv)
|
|
r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv)
|
|
r.Delete("/stages/{stage}/env/{envId}", s.deleteStageEnv)
|
|
|
|
// Instance endpoints.
|
|
r.Post("/stages/{stage}/instances", s.deployInstance)
|
|
r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance)
|
|
|
|
// Instance control endpoints.
|
|
r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance)
|
|
r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance)
|
|
r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance)
|
|
|
|
// Volume endpoints.
|
|
r.Post("/volumes", s.createVolume)
|
|
r.Put("/volumes/{volId}", s.updateVolume)
|
|
r.Delete("/volumes/{volId}", s.deleteVolume)
|
|
r.Post("/volumes/{volId}/upload", s.uploadToVolume)
|
|
})
|
|
})
|
|
// Stacks (docker-compose).
|
|
r.With(deprecated("/api/workloads?kind=plugin&source_kind=compose")).Get("/stacks", s.listStacks)
|
|
r.Route("/stacks/{id}", func(r chi.Router) {
|
|
r.Get("/", s.getStack)
|
|
r.Get("/revisions", s.listStackRevisions)
|
|
r.Get("/revisions/{revId}", s.getStackRevision)
|
|
r.Get("/services", s.getStackServices)
|
|
r.Get("/logs", s.getStackLogs)
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(auth.AdminOnly)
|
|
r.Put("/", s.updateStack)
|
|
r.Delete("/", s.deleteStack)
|
|
r.Post("/revisions", s.createStackRevision)
|
|
r.Post("/rollback/{revId}", s.rollbackStack)
|
|
r.Post("/stop", s.stopStack)
|
|
r.Post("/start", s.startStack)
|
|
})
|
|
})
|
|
r.With(auth.AdminOnly).Post("/stacks", s.createStack)
|
|
|
|
// Static sites.
|
|
r.With(deprecated("/api/workloads?kind=plugin&source_kind=static")).Get("/sites", s.listStaticSites)
|
|
r.Route("/sites/{id}", func(r chi.Router) {
|
|
r.Get("/", s.getStaticSite)
|
|
r.Get("/secrets", s.listStaticSiteSecrets)
|
|
r.Get("/storage", s.getStaticSiteStorage)
|
|
r.Get("/logs", s.streamStaticSiteLogs)
|
|
r.Get("/stats", s.getStaticSiteStats)
|
|
r.Get("/stats/history", s.getStaticSiteStatsHistory)
|
|
|
|
// Admin-only mutations.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(auth.AdminOnly)
|
|
r.Put("/", s.updateStaticSite)
|
|
r.Delete("/", s.deleteStaticSite)
|
|
r.Post("/deploy", s.deployStaticSite)
|
|
r.Post("/stop", s.stopStaticSite)
|
|
r.Post("/start", s.startStaticSite)
|
|
r.Get("/webhook", s.getStaticSiteWebhook)
|
|
r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook)
|
|
r.Post("/webhook/signing-secret/regenerate", s.regenerateStaticSiteSigningSecret)
|
|
r.Delete("/webhook/signing-secret", s.disableStaticSiteSigningSecret)
|
|
r.Put("/webhook/require-signature", s.updateStaticSiteSigningRequirement)
|
|
r.Get("/webhook/deliveries", s.listStaticSiteWebhookDeliveries)
|
|
|
|
// Per-site outgoing-webhook signing & test.
|
|
r.Get("/notification-secret", s.getStaticSiteNotificationSecret)
|
|
r.Post("/notification-secret/regenerate", s.regenerateStaticSiteNotificationSecret)
|
|
r.Post("/notification-secret/disable", s.disableStaticSiteNotificationSigning)
|
|
r.Post("/notification-test", s.staticSiteNotificationTest)
|
|
r.Post("/secrets", s.createStaticSiteSecret)
|
|
r.Put("/secrets/{sid}", s.updateStaticSiteSecret)
|
|
r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret)
|
|
})
|
|
})
|
|
|
|
r.Get("/deploys", s.listDeploys)
|
|
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
|
|
r.Get("/events", s.streamEvents)
|
|
r.Get("/events/log", s.listEventLog)
|
|
r.Get("/events/log/stats", s.getEventLogStats)
|
|
r.Get("/registries", s.listRegistries)
|
|
r.Route("/registries/{id}", func(r chi.Router) {
|
|
r.Get("/tags/*", s.listRegistryTags)
|
|
r.Get("/images", s.listRegistryImages)
|
|
|
|
// Admin-only registry mutations.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(auth.AdminOnly)
|
|
r.Put("/", s.updateRegistry)
|
|
r.Delete("/", s.deleteRegistry)
|
|
r.Post("/test", s.testRegistry)
|
|
})
|
|
})
|
|
r.Get("/settings", s.getSettings)
|
|
r.Get("/settings/npm-certificates", s.listNpmCertificates)
|
|
r.Get("/settings/npm-access-lists", s.listNpmAccessLists)
|
|
|
|
// Volume scope metadata (read-only).
|
|
r.Get("/volumes/scopes", s.listVolumeScopes)
|
|
|
|
// Stale container endpoints (read).
|
|
r.Get("/containers/stale", s.listStaleContainers)
|
|
|
|
// Workload-shaped endpoints (the unifying layer over project /
|
|
// stack / site). Read endpoints are open to any authenticated
|
|
// user; create / update / deploy mutate state and are admin-gated.
|
|
// Plugin-native workloads (source_kind + trigger_kind set) are
|
|
// created here; legacy project / stack / site mutations remain at
|
|
// their dedicated endpoints during the cutover.
|
|
r.Get("/workloads", s.listWorkloads)
|
|
r.With(auth.AdminOnly).Post("/workloads", s.createPluginWorkload)
|
|
r.Route("/workloads/{id}", func(r chi.Router) {
|
|
r.Get("/", s.getWorkload)
|
|
r.Get("/containers", s.listWorkloadContainers)
|
|
r.Get("/containers/{cid}/logs", s.streamWorkloadContainerLogs)
|
|
r.With(auth.AdminOnly).Patch("/app", s.updateWorkloadAppID)
|
|
r.With(auth.AdminOnly).Put("/plugin", s.updatePluginWorkload)
|
|
r.With(auth.AdminOnly).Post("/deploy", s.deployPluginWorkload)
|
|
r.With(auth.AdminOnly).Delete("/", s.deletePluginWorkload)
|
|
|
|
// Per-workload env vars (analog of legacy stage_env).
|
|
// Listing is open to authenticated readers; mutations are
|
|
// admin-gated. Encrypted values are write-only after store.
|
|
r.Get("/env", s.listWorkloadEnv)
|
|
r.With(auth.AdminOnly).Put("/env", s.setWorkloadEnv)
|
|
r.With(auth.AdminOnly).Delete("/env/{envID}", s.deleteWorkloadEnv)
|
|
|
|
// Per-workload inbound webhook URL: rotate the secret + fetch
|
|
// the canonical URL. Mirrors the project / site webhook UX.
|
|
r.With(auth.AdminOnly).Get("/webhook", s.getWorkloadWebhook)
|
|
r.With(auth.AdminOnly).Post("/webhook/regenerate", s.regenerateWorkloadWebhook)
|
|
|
|
// Per-workload volume mounts (analog of legacy project volumes).
|
|
// Reads are open to authenticated users; mutations admin-gated.
|
|
// Source/target paths are validated for traversal safety here;
|
|
// host-path allow-listing happens at deploy time.
|
|
r.Get("/volumes", s.listWorkloadVolumes)
|
|
r.With(auth.AdminOnly).Put("/volumes", s.setWorkloadVolume)
|
|
r.With(auth.AdminOnly).Delete("/volumes/{volID}", s.deleteWorkloadVolume)
|
|
|
|
// Stages chain: parent + self + direct children, plus a
|
|
// promote-from action that copies the source workload's
|
|
// running image tag onto this workload's default_tag.
|
|
r.Get("/chain", s.getWorkloadChain)
|
|
r.With(auth.AdminOnly).Post("/promote-from/{sourceID}", s.promoteFromWorkload)
|
|
})
|
|
|
|
// Global container index, joined to workload + app names.
|
|
r.Get("/containers", s.listAllContainers)
|
|
r.Get("/containers/{id}", s.getContainer)
|
|
|
|
// App grouping (optional UI; admin-gated mutations).
|
|
r.Get("/apps", s.listApps)
|
|
r.Get("/apps/{id}", s.getApp)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(auth.AdminOnly)
|
|
r.Post("/apps", s.createApp)
|
|
r.Put("/apps/{id}", s.updateApp)
|
|
r.Delete("/apps/{id}", s.deleteApp)
|
|
})
|
|
|
|
// Event triggers: filter+action rules over the event_log
|
|
// stream. Read endpoints are available to any authenticated
|
|
// user; mutations + test-dispatch are admin-gated since they
|
|
// can fire arbitrary outbound webhooks.
|
|
r.Get("/event-triggers", s.listEventTriggers)
|
|
r.Get("/event-triggers/{id}", s.getEventTrigger)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(auth.AdminOnly)
|
|
r.Post("/event-triggers", s.createEventTrigger)
|
|
r.Patch("/event-triggers/{id}", s.updateEventTrigger)
|
|
r.Delete("/event-triggers/{id}", s.deleteEventTrigger)
|
|
r.Post("/event-triggers/{id}/test", s.testEventTrigger)
|
|
})
|
|
|
|
// Log-scan rules: regex patterns the scanner manager
|
|
// applies to container log lines. Read endpoints are
|
|
// available to any authenticated user; mutations are
|
|
// admin-gated since they can change global observability
|
|
// behavior across every workload.
|
|
r.Get("/log-scan-rules", s.listLogScanRules)
|
|
r.Get("/log-scan-rules/stats", s.getLogScanStats)
|
|
r.Get("/log-scan-rules/{id}", s.getLogScanRule)
|
|
r.Get("/workloads/{id}/effective-rules", s.getEffectiveLogScanRules)
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(auth.AdminOnly)
|
|
r.Post("/log-scan-rules", s.createLogScanRule)
|
|
r.Patch("/log-scan-rules/{id}", s.updateLogScanRule)
|
|
r.Delete("/log-scan-rules/{id}", s.deleteLogScanRule)
|
|
r.Post("/log-scan-rules/{id}/test", s.testLogScanRule)
|
|
})
|
|
|
|
// System resources (read-only).
|
|
r.Get("/system/stats", s.getSystemStats)
|
|
r.Get("/system/stats/history", s.getSystemStatsHistory)
|
|
r.Get("/system/stats/top", s.listTopContainers)
|
|
|
|
// Admin-only routes: require admin role.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(auth.AdminOnly)
|
|
|
|
// Config export (reveals project/infra details).
|
|
r.Get("/config/export", s.exportConfig)
|
|
|
|
// Event log management.
|
|
r.Delete("/events/log/{id}", s.deleteEvent)
|
|
r.Delete("/events/log", s.clearEvents)
|
|
|
|
// Auth management.
|
|
r.Get("/auth/settings", s.getAuthSettings)
|
|
r.Put("/auth/settings", s.updateAuthSettings)
|
|
r.Get("/auth/users", s.listUsers)
|
|
r.Post("/auth/users", s.createUser)
|
|
r.Put("/auth/users/{uid}", s.updateUser)
|
|
r.Put("/auth/users/{uid}/password", s.changePassword)
|
|
r.Delete("/auth/users/{uid}", s.deleteUser)
|
|
|
|
// Project creation.
|
|
r.Post("/projects", s.createProject)
|
|
|
|
// Static site creation and tools.
|
|
r.Post("/sites", s.createStaticSite)
|
|
r.Post("/sites/test-connection", s.testStaticSiteConnection)
|
|
r.Post("/sites/branches", s.listStaticSiteBranches)
|
|
r.Post("/sites/tree", s.listStaticSiteTree)
|
|
r.Post("/sites/detect-provider", s.detectStaticSiteProvider)
|
|
r.Post("/sites/repos", s.listStaticSiteRepos)
|
|
|
|
// Quick deploy endpoints.
|
|
r.Post("/deploy/inspect", s.inspectImage)
|
|
r.Post("/deploy/quick", s.quickDeploy)
|
|
|
|
// Registry creation.
|
|
r.Post("/registries", s.createRegistry)
|
|
|
|
// Stale container cleanup endpoints.
|
|
// Bulk route must be registered before parameterized route.
|
|
r.Post("/containers/stale/cleanup", s.bulkCleanupStaleContainers)
|
|
r.Post("/containers/stale/{id}/cleanup", s.cleanupStaleContainer)
|
|
|
|
// Settings endpoints.
|
|
r.Put("/settings", s.updateSettings)
|
|
|
|
// Global outgoing-webhook signing & test.
|
|
r.Get("/settings/notification-secret", s.getSettingsNotificationSecret)
|
|
r.Post("/settings/notification-secret/regenerate", s.regenerateSettingsNotificationSecret)
|
|
r.Post("/settings/notification-secret/disable", s.disableSettingsNotificationSigning)
|
|
r.Post("/settings/notification-test", s.settingsNotificationTest)
|
|
|
|
// Docker management.
|
|
r.Post("/docker/prune-images", s.pruneImages)
|
|
|
|
// NPM connection test.
|
|
r.Post("/settings/npm/test", s.testNpmConnection)
|
|
|
|
// DNS management endpoints.
|
|
r.Post("/settings/dns/test", s.testDNSConnection)
|
|
r.Post("/settings/dns/zones", s.listDNSZones)
|
|
r.Get("/dns/records", s.listDNSRecords)
|
|
r.Post("/dns/sync", s.syncDNSRecords)
|
|
r.Delete("/dns/records/{fqdn}", s.deleteDNSRecord)
|
|
|
|
// Backup endpoints.
|
|
r.Get("/backups", s.listBackups)
|
|
r.Post("/backups", s.triggerBackup)
|
|
r.Get("/backups/{id}/download", s.downloadBackup)
|
|
r.Delete("/backups/{id}", s.deleteBackup)
|
|
r.Post("/backups/{id}/restore", s.restoreBackup)
|
|
})
|
|
})
|
|
})
|
|
|
|
return r
|
|
}
|