Files
tiny-forge/internal/api/settings.go
T
alexei.dolgolyov c38b7d4c78 feat(observability): phase 1 - schema, models & event log backend
Add database foundation for observability features:
- event_log table with severity/source filtering and pagination
- standalone_proxies table for user-created reverse proxies
- stale_threshold_days setting (default 7 days)
- Auto-persist warn/error events from event bus to database
- SSE broadcast of persistent events for real-time UI updates
- Frontend types and API functions for downstream UI phases
2026-03-30 10:59:13 +03:00

316 lines
9.1 KiB
Go

package api
import (
"context"
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/npm"
"github.com/alexei/docker-watcher/internal/store"
"github.com/alexei/docker-watcher/internal/webhook"
)
// settingsRequest is the expected JSON body for updating settings.
type settingsRequest struct {
Domain string `json:"domain"`
ServerIP string `json:"server_ip"`
Network string `json:"network"`
SubdomainPattern string `json:"subdomain_pattern"`
NotificationURL string `json:"notification_url"`
NpmURL string `json:"npm_url"`
NpmEmail string `json:"npm_email"`
NpmPassword string `json:"npm_password"`
PollingInterval string `json:"polling_interval"`
SSLCertificateID *int `json:"ssl_certificate_id,omitempty"`
StaleThresholdDays *int `json:"stale_threshold_days,omitempty"`
}
// getSettings handles GET /api/settings.
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
// Return settings without sensitive fields.
respondJSON(w, http.StatusOK, map[string]any{
"domain": settings.Domain,
"server_ip": settings.ServerIP,
"network": settings.Network,
"subdomain_pattern": settings.SubdomainPattern,
"notification_url": settings.NotificationURL,
"npm_url": settings.NpmURL,
"npm_email": settings.NpmEmail,
"has_npm_password": settings.NpmPassword != "",
"polling_interval": settings.PollingInterval,
"ssl_certificate_id": settings.SSLCertificateID,
"stale_threshold_days": settings.StaleThresholdDays,
"updated_at": settings.UpdatedAt,
})
}
// updateSettings handles PUT /api/settings.
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
var req settingsRequest
if !decodeJSON(w, r, &req) {
return
}
existing, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
updated := existing
if req.Domain != "" {
updated.Domain = req.Domain
}
if req.ServerIP != "" {
updated.ServerIP = req.ServerIP
}
if req.Network != "" {
updated.Network = req.Network
}
if req.SubdomainPattern != "" {
updated.SubdomainPattern = req.SubdomainPattern
}
// Allow clearing notification URL.
updated.NotificationURL = req.NotificationURL
if req.NpmURL != "" {
updated.NpmURL = req.NpmURL
}
if req.NpmEmail != "" {
updated.NpmEmail = req.NpmEmail
}
if req.NpmPassword != "" {
encPassword, err := crypto.Encrypt(s.encKey, req.NpmPassword)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt npm password: "+err.Error())
return
}
updated.NpmPassword = encPassword
}
if req.PollingInterval != "" {
updated.PollingInterval = req.PollingInterval
}
sslChanged := false
if req.SSLCertificateID != nil && *req.SSLCertificateID != updated.SSLCertificateID {
updated.SSLCertificateID = *req.SSLCertificateID
sslChanged = true
}
if req.StaleThresholdDays != nil {
if *req.StaleThresholdDays < 1 {
respondError(w, http.StatusBadRequest, "stale_threshold_days must be at least 1")
return
}
updated.StaleThresholdDays = *req.StaleThresholdDays
}
if err := s.store.UpdateSettings(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
return
}
// If SSL cert changed, update all existing NPM proxy hosts in the background.
if sslChanged {
go s.reapplySSLToAllProxies(updated)
}
respondJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
// getWebhookURL handles GET /api/settings/webhook-url.
func (s *Server) getWebhookURL(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
webhookURL := ""
if settings.WebhookSecret != "" && settings.Domain != "" {
webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, settings.WebhookSecret)
}
respondJSON(w, http.StatusOK, map[string]string{
"webhook_url": webhookURL,
})
}
// regenerateWebhookSecret handles POST /api/settings/regenerate.
func (s *Server) regenerateWebhookSecret(w http.ResponseWriter, r *http.Request) {
secret, err := webhook.RegenerateWebhookSecret(s.store)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to regenerate webhook secret: "+err.Error())
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
webhookURL := ""
if settings.Domain != "" {
webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, secret)
}
respondJSON(w, http.StatusOK, map[string]string{
"webhook_url": webhookURL,
"webhook_secret": secret,
})
}
// listNpmCertificates handles GET /api/settings/npm-certificates.
// It authenticates to NPM using the stored credentials and returns only wildcard certificates.
func (s *Server) listNpmCertificates(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
if settings.NpmURL == "" || settings.NpmEmail == "" || settings.NpmPassword == "" {
respondError(w, http.StatusBadRequest, "NPM credentials not configured")
return
}
npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to decrypt npm password: "+err.Error())
return
}
client := npm.New(settings.NpmURL)
if err := client.Authenticate(r.Context(), settings.NpmEmail, npmPassword); err != nil {
respondError(w, http.StatusBadGateway, "failed to authenticate to NPM: "+err.Error())
return
}
certs, err := client.ListCertificates(r.Context())
if err != nil {
respondError(w, http.StatusBadGateway, "failed to list certificates: "+err.Error())
return
}
// Filter to wildcard certificates only.
var wildcards []npm.Certificate
for _, cert := range certs {
if isWildcardCert(cert) {
wildcards = append(wildcards, cert)
}
}
if wildcards == nil {
wildcards = []npm.Certificate{}
}
respondJSON(w, http.StatusOK, wildcards)
}
// isWildcardCert returns true if any of the certificate's domain names contains "*".
func isWildcardCert(cert npm.Certificate) bool {
for _, d := range cert.DomainNames {
if strings.Contains(d, "*") {
return true
}
}
return false
}
// reapplySSLToAllProxies updates all existing NPM proxy hosts managed by Docker Watcher
// to use the new SSL certificate. Runs in the background after settings change.
func (s *Server) reapplySSLToAllProxies(settings store.Settings) {
ctx := context.Background()
npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword)
if err != nil {
slog.Error("reapply SSL: decrypt npm password", "error", err)
return
}
npmClient := npm.New(settings.NpmURL)
if err := npmClient.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil {
slog.Error("reapply SSL: authenticate to NPM", "error", err)
return
}
// Get all proxy hosts from NPM.
hosts, err := npmClient.ListProxyHosts(ctx)
if err != nil {
slog.Error("reapply SSL: list proxy hosts", "error", err)
return
}
// Get all our managed instances to identify which proxy hosts are ours.
projects, err := s.store.GetAllProjects()
if err != nil {
slog.Error("reapply SSL: get projects", "error", err)
return
}
// Build a set of NPM proxy IDs that belong to our instances.
managedProxyIDs := make(map[int]bool)
for _, p := range projects {
stages, err := s.store.GetStagesByProjectID(p.ID)
if err != nil {
continue
}
for _, st := range stages {
instances, err := s.store.GetInstancesByStageID(st.ID)
if err != nil {
continue
}
for _, inst := range instances {
if inst.NpmProxyID > 0 {
managedProxyIDs[inst.NpmProxyID] = true
}
}
}
}
updated := 0
for _, host := range hosts {
if !managedProxyIDs[host.ID] {
continue
}
config := npm.ProxyHostConfig{
DomainNames: host.DomainNames,
ForwardScheme: host.ForwardScheme,
ForwardHost: host.ForwardHost,
ForwardPort: host.ForwardPort,
BlockExploits: true,
AllowWebsocket: true,
HTTP2Support: true,
Meta: npm.Meta{},
Locations: []any{},
}
if settings.SSLCertificateID > 0 {
config.CertificateID = settings.SSLCertificateID
config.SSLForced = true
config.HSTSEnabled = true
} else {
config.CertificateID = 0
config.SSLForced = false
config.HSTSEnabled = false
}
if _, err := npmClient.UpdateProxyHost(ctx, host.ID, config); err != nil {
slog.Warn("reapply SSL: update proxy host failed", "host_id", host.ID, "error", err)
continue
}
updated++
}
slog.Info("reapply SSL: completed", "updated", updated, "total_managed", len(managedProxyIDs))
}