f71c314262
When the SSL certificate is changed in settings, automatically updates all existing NPM proxy hosts managed by Docker Watcher in the background. Clears SSL if cert is removed.
307 lines
8.7 KiB
Go
307 lines
8.7 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"`
|
|
}
|
|
|
|
// 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,
|
|
"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 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))
|
|
}
|
|
|