c730cfaa45
Add flexible DNS management to Docker Watcher. By default, wildcard DNS is assumed (current behavior). When disabled, users can configure a Cloudflare DNS provider with API token and zone selection. DNS A records are automatically created/updated/deleted in sync with proxy consumers (deployed instances and standalone proxies). - Settings: wildcard_dns toggle, dns_provider, cloudflare credentials - Cloudflare client: Provider interface with EnsureRecord/DeleteRecord/ListRecords - DNS lifecycle hooks in deployer and proxy manager (best-effort) - Settings UI: DNS config section with provider picker, zone selector, test button - DNS Records page at /dns with filtering, sync status, reconciliation - Records visible in both wildcard and managed modes - Cleanup on provider change: removes old records when switching modes
384 lines
11 KiB
Go
384 lines
11 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/alexei/docker-watcher/internal/dns"
|
|
"github.com/alexei/docker-watcher/internal/npm"
|
|
"github.com/alexei/docker-watcher/internal/store"
|
|
)
|
|
|
|
// Manager handles the lifecycle of standalone proxy hosts.
|
|
type Manager struct {
|
|
store *store.Store
|
|
npm *npm.Client
|
|
dns dns.Provider // nil when wildcard DNS is active
|
|
}
|
|
|
|
// NewManager creates a new proxy manager.
|
|
func NewManager(st *store.Store, npmClient *npm.Client) *Manager {
|
|
return &Manager{
|
|
store: st,
|
|
npm: npmClient,
|
|
}
|
|
}
|
|
|
|
// SetDNSProvider sets the DNS provider for managing DNS records.
|
|
func (m *Manager) SetDNSProvider(provider dns.Provider) {
|
|
m.dns = provider
|
|
}
|
|
|
|
// CreateProxyRequest is the input for creating a standalone proxy.
|
|
type CreateProxyRequest struct {
|
|
Domain string `json:"domain"`
|
|
DestinationURL string `json:"destination_url"`
|
|
DestinationPort int `json:"destination_port"`
|
|
}
|
|
|
|
// UpdateProxyRequest is the input for updating a standalone proxy.
|
|
type UpdateProxyRequest struct {
|
|
Domain string `json:"domain"`
|
|
DestinationURL string `json:"destination_url"`
|
|
DestinationPort int `json:"destination_port"`
|
|
}
|
|
|
|
// ProxyView is a unified view of both standalone and deploy-managed proxies.
|
|
type ProxyView struct {
|
|
ID string `json:"id"`
|
|
Domain string `json:"domain"`
|
|
Destination string `json:"destination"`
|
|
Type string `json:"type"` // "standalone" or "managed"
|
|
ProjectName string `json:"project_name,omitempty"`
|
|
StageName string `json:"stage_name,omitempty"`
|
|
HealthStatus string `json:"health_status"`
|
|
SSLEnabled bool `json:"ssl_enabled"`
|
|
NpmProxyID int `json:"npm_proxy_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// CreateProxy validates the destination, creates an NPM proxy host, and saves to the store.
|
|
func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (store.StandaloneProxy, error) {
|
|
// Validate destination.
|
|
result := ValidateDestination(ctx, req.DestinationURL, req.DestinationPort)
|
|
if !result.Valid {
|
|
return store.StandaloneProxy{}, fmt.Errorf("destination validation failed: %s", lastFailedStep(result))
|
|
}
|
|
|
|
// Load settings for SSL certificate and domain.
|
|
settings, err := m.store.GetSettings()
|
|
if err != nil {
|
|
return store.StandaloneProxy{}, fmt.Errorf("get settings: %w", err)
|
|
}
|
|
|
|
// Build NPM proxy host config.
|
|
config := npm.ProxyHostConfig{
|
|
DomainNames: []string{req.Domain},
|
|
ForwardScheme: "http",
|
|
ForwardHost: req.DestinationURL,
|
|
ForwardPort: req.DestinationPort,
|
|
CertificateID: settings.SSLCertificateID,
|
|
SSLForced: settings.SSLCertificateID > 0,
|
|
BlockExploits: true,
|
|
AllowWebsocket: true,
|
|
HTTP2Support: true,
|
|
HSTSEnabled: settings.SSLCertificateID > 0,
|
|
Locations: []any{},
|
|
}
|
|
|
|
// Create NPM proxy host.
|
|
npmHost, err := m.npm.CreateProxyHost(ctx, config)
|
|
if err != nil {
|
|
return store.StandaloneProxy{}, fmt.Errorf("create NPM proxy host: %w", err)
|
|
}
|
|
|
|
slog.Info("created NPM proxy host for standalone proxy",
|
|
"domain", req.Domain, "npm_proxy_id", npmHost.ID)
|
|
|
|
// Save to store.
|
|
proxy, err := m.store.CreateStandaloneProxy(store.StandaloneProxy{
|
|
Domain: req.Domain,
|
|
DestinationURL: req.DestinationURL,
|
|
DestinationPort: req.DestinationPort,
|
|
SSLCertificateID: settings.SSLCertificateID,
|
|
NpmProxyID: npmHost.ID,
|
|
HealthStatus: "unknown",
|
|
})
|
|
if err != nil {
|
|
// Best effort: clean up the NPM host if store insert fails.
|
|
if delErr := m.npm.DeleteProxyHost(ctx, npmHost.ID); delErr != nil {
|
|
slog.Error("failed to clean up NPM proxy host after store error",
|
|
"npm_proxy_id", npmHost.ID, "error", delErr)
|
|
}
|
|
return store.StandaloneProxy{}, fmt.Errorf("save standalone proxy: %w", err)
|
|
}
|
|
|
|
// Create DNS record after successful store save.
|
|
m.ensureDNS(ctx, req.Domain, proxy.ID)
|
|
|
|
return proxy, nil
|
|
}
|
|
|
|
// UpdateProxy re-validates the destination, updates the NPM proxy host, and updates the store.
|
|
func (m *Manager) UpdateProxy(ctx context.Context, id string, req UpdateProxyRequest) (store.StandaloneProxy, error) {
|
|
existing, err := m.store.GetStandaloneProxy(id)
|
|
if err != nil {
|
|
return store.StandaloneProxy{}, fmt.Errorf("get proxy: %w", err)
|
|
}
|
|
|
|
// Validate new destination.
|
|
result := ValidateDestination(ctx, req.DestinationURL, req.DestinationPort)
|
|
if !result.Valid {
|
|
return store.StandaloneProxy{}, fmt.Errorf("destination validation failed: %s", lastFailedStep(result))
|
|
}
|
|
|
|
// Load settings for SSL certificate.
|
|
settings, err := m.store.GetSettings()
|
|
if err != nil {
|
|
return store.StandaloneProxy{}, fmt.Errorf("get settings: %w", err)
|
|
}
|
|
|
|
// Update NPM proxy host.
|
|
config := npm.ProxyHostConfig{
|
|
DomainNames: []string{req.Domain},
|
|
ForwardScheme: "http",
|
|
ForwardHost: req.DestinationURL,
|
|
ForwardPort: req.DestinationPort,
|
|
CertificateID: settings.SSLCertificateID,
|
|
SSLForced: settings.SSLCertificateID > 0,
|
|
BlockExploits: true,
|
|
AllowWebsocket: true,
|
|
HTTP2Support: true,
|
|
HSTSEnabled: settings.SSLCertificateID > 0,
|
|
Locations: []any{},
|
|
}
|
|
|
|
if _, err := m.npm.UpdateProxyHost(ctx, existing.NpmProxyID, config); err != nil {
|
|
return store.StandaloneProxy{}, fmt.Errorf("update NPM proxy host: %w", err)
|
|
}
|
|
|
|
// Update store.
|
|
updated := existing
|
|
updated.Domain = req.Domain
|
|
updated.DestinationURL = req.DestinationURL
|
|
updated.DestinationPort = req.DestinationPort
|
|
updated.SSLCertificateID = settings.SSLCertificateID
|
|
|
|
if err := m.store.UpdateStandaloneProxy(updated); err != nil {
|
|
return store.StandaloneProxy{}, fmt.Errorf("update standalone proxy: %w", err)
|
|
}
|
|
|
|
// Update DNS records if domain changed.
|
|
if existing.Domain != req.Domain {
|
|
m.removeDNS(ctx, existing.Domain)
|
|
m.ensureDNS(ctx, req.Domain, id)
|
|
}
|
|
|
|
// Re-read from store to get updated timestamps.
|
|
return m.store.GetStandaloneProxy(id)
|
|
}
|
|
|
|
// DeleteProxy removes the NPM proxy host and deletes from the store.
|
|
func (m *Manager) DeleteProxy(ctx context.Context, id string) error {
|
|
proxy, err := m.store.GetStandaloneProxy(id)
|
|
if err != nil {
|
|
return fmt.Errorf("get proxy: %w", err)
|
|
}
|
|
|
|
// Delete NPM proxy host.
|
|
if proxy.NpmProxyID > 0 {
|
|
if err := m.npm.DeleteProxyHost(ctx, proxy.NpmProxyID); err != nil {
|
|
slog.Warn("failed to delete NPM proxy host (continuing with store deletion)",
|
|
"npm_proxy_id", proxy.NpmProxyID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Remove DNS record.
|
|
m.removeDNS(ctx, proxy.Domain)
|
|
|
|
if err := m.store.DeleteStandaloneProxy(id); err != nil {
|
|
return fmt.Errorf("delete standalone proxy: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetProxy returns a single standalone proxy by ID.
|
|
func (m *Manager) GetProxy(id string) (store.StandaloneProxy, error) {
|
|
proxy, err := m.store.GetStandaloneProxy(id)
|
|
if err != nil {
|
|
return store.StandaloneProxy{}, fmt.Errorf("get proxy: %w", err)
|
|
}
|
|
return proxy, nil
|
|
}
|
|
|
|
// ListProxies returns all standalone proxies.
|
|
func (m *Manager) ListProxies() ([]store.StandaloneProxy, error) {
|
|
proxies, err := m.store.ListStandaloneProxies()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list proxies: %w", err)
|
|
}
|
|
return proxies, nil
|
|
}
|
|
|
|
// ListAllProxies returns a merged view of standalone and deploy-managed proxies.
|
|
func (m *Manager) ListAllProxies() ([]ProxyView, error) {
|
|
views := []ProxyView{}
|
|
|
|
// Standalone proxies.
|
|
standalones, err := m.store.ListStandaloneProxies()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list standalone proxies: %w", err)
|
|
}
|
|
|
|
for _, p := range standalones {
|
|
views = append(views, ProxyView{
|
|
ID: p.ID,
|
|
Domain: p.Domain,
|
|
Destination: fmt.Sprintf("%s:%d", p.DestinationURL, p.DestinationPort),
|
|
Type: "standalone",
|
|
HealthStatus: p.HealthStatus,
|
|
SSLEnabled: p.SSLCertificateID > 0,
|
|
NpmProxyID: p.NpmProxyID,
|
|
CreatedAt: p.CreatedAt,
|
|
})
|
|
}
|
|
|
|
// Deploy-managed proxies: instances with npm_proxy_id > 0.
|
|
instances, err := m.store.ListAllInstances()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list instances: %w", err)
|
|
}
|
|
|
|
// Pre-load project and stage names to avoid N+1 queries.
|
|
allProjects, _ := m.store.GetAllProjects()
|
|
projectNames := make(map[string]string, len(allProjects))
|
|
for _, p := range allProjects {
|
|
projectNames[p.ID] = p.Name
|
|
}
|
|
stageNames := make(map[string]string)
|
|
for _, p := range allProjects {
|
|
stages, _ := m.store.GetStagesByProjectID(p.ID)
|
|
for _, s := range stages {
|
|
stageNames[s.ID] = s.Name
|
|
}
|
|
}
|
|
|
|
for _, inst := range instances {
|
|
if inst.NpmProxyID <= 0 {
|
|
continue
|
|
}
|
|
|
|
projectName := projectNames[inst.ProjectID]
|
|
if projectName == "" {
|
|
projectName = inst.ProjectID
|
|
}
|
|
stageName := stageNames[inst.StageID]
|
|
if stageName == "" {
|
|
stageName = inst.StageID
|
|
}
|
|
|
|
cid := inst.ContainerID
|
|
if len(cid) > 12 {
|
|
cid = cid[:12]
|
|
}
|
|
destination := fmt.Sprintf("%s:%d", cid, inst.Port)
|
|
if inst.Subdomain != "" {
|
|
destination = fmt.Sprintf("%s:%d", inst.Subdomain, inst.Port)
|
|
}
|
|
|
|
healthStatus := "unknown"
|
|
if inst.Status == "running" {
|
|
healthStatus = "healthy"
|
|
} else if inst.Status == "stopped" || inst.Status == "failed" {
|
|
healthStatus = "unhealthy"
|
|
}
|
|
|
|
views = append(views, ProxyView{
|
|
ID: inst.ID,
|
|
Domain: inst.Subdomain,
|
|
Destination: destination,
|
|
Type: "managed",
|
|
ProjectName: projectName,
|
|
StageName: stageName,
|
|
HealthStatus: healthStatus,
|
|
SSLEnabled: true, // managed proxies always get SSL from settings
|
|
NpmProxyID: inst.NpmProxyID,
|
|
CreatedAt: inst.CreatedAt,
|
|
})
|
|
}
|
|
|
|
return views, nil
|
|
}
|
|
|
|
// ensureDNS creates or updates a DNS record for a standalone proxy domain. Best-effort.
|
|
func (m *Manager) ensureDNS(ctx context.Context, domain, proxyID string) {
|
|
if m.dns == nil {
|
|
return
|
|
}
|
|
settings, err := m.store.GetSettings()
|
|
if err != nil {
|
|
slog.Warn("dns: get settings for server IP", "error", err)
|
|
return
|
|
}
|
|
if settings.ServerIP == "" {
|
|
slog.Warn("dns: server IP not configured, skipping DNS record creation", "domain", domain)
|
|
return
|
|
}
|
|
|
|
recordID, err := m.dns.EnsureRecord(ctx, domain, settings.ServerIP)
|
|
if err != nil {
|
|
slog.Warn("dns: failed to create/update record for standalone proxy", "domain", domain, "error", err)
|
|
return
|
|
}
|
|
|
|
if _, err := m.store.CreateDNSRecord(store.DNSRecord{
|
|
FQDN: domain,
|
|
ProviderRecordID: recordID,
|
|
ConsumerType: "standalone",
|
|
ConsumerID: proxyID,
|
|
}); err != nil {
|
|
// May already exist — try updating.
|
|
if updateErr := m.store.UpdateDNSRecordProviderID(domain, recordID); updateErr != nil {
|
|
slog.Warn("dns: failed to track record", "domain", domain, "error", updateErr)
|
|
}
|
|
}
|
|
slog.Info("dns: record ensured for standalone proxy", "domain", domain)
|
|
}
|
|
|
|
// removeDNS deletes a DNS record for a standalone proxy domain. Best-effort.
|
|
func (m *Manager) removeDNS(ctx context.Context, domain string) {
|
|
if m.dns == nil {
|
|
return
|
|
}
|
|
if err := m.dns.DeleteRecord(ctx, domain); err != nil {
|
|
slog.Warn("dns: failed to delete record for standalone proxy", "domain", domain, "error", err)
|
|
return
|
|
}
|
|
if err := m.store.DeleteDNSRecord(domain); err != nil {
|
|
slog.Warn("dns: failed to remove tracking record", "domain", domain, "error", err)
|
|
}
|
|
slog.Info("dns: record deleted for standalone proxy", "domain", domain)
|
|
}
|
|
|
|
// lastFailedStep returns the message of the last failed validation step.
|
|
func lastFailedStep(result ValidationResult) string {
|
|
for _, step := range result.Steps {
|
|
if !step.Passed {
|
|
msg := step.Message
|
|
if step.Hint != "" {
|
|
msg += " — " + step.Hint
|
|
}
|
|
return msg
|
|
}
|
|
}
|
|
return "unknown validation failure"
|
|
}
|
|
|
|
// IsNotFound checks if an error wraps store.ErrNotFound.
|
|
func IsNotFound(err error) bool {
|
|
return errors.Is(err, store.ErrNotFound)
|
|
}
|