Files
tiny-forge/internal/api/settings.go
T
alexei.dolgolyov 9f284932a1 feat: SSL wildcard certificate picker from NPM
- NPM client: ListCertificates endpoint
- API: GET /api/settings/npm-certificates (wildcard-only filter)
- Settings UI: EntityPicker for selecting wildcard certs
- Deployer: applies certificate_id + ssl_forced to proxy hosts
- Uses HTTPS subdomain URLs when SSL cert is configured
2026-03-29 13:07:58 +03:00

207 lines
6.0 KiB
Go

package api
import (
"fmt"
"net/http"
"strings"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/npm"
"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
}
if req.SSLCertificateID != nil {
updated.SSLCertificateID = *req.SSLCertificateID
}
if err := s.store.UpdateSettings(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
return
}
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
}