refactor: remove standalone proxies, add Traefik provider with Docker labels
Standalone proxy removal: - Delete store, API handlers, proxy manager, health monitor, validator, hints - Delete frontend pages (proxies list, create, edit) and components (ProxyCard, ProxyForm, ProxyFilter, ProxyGroup, ValidationChecklist) - Remove proxy routes from router, nav items, dashboard references - Clean up SystemHealthCard to remove proxy section Traefik provider: - Add TraefikProvider implementing proxy.Provider via Docker labels - ContainerLabels() returns traefik.enable, router rule, entrypoints, service port, TLS cert resolver, docker network - ConfigureRoute() returns router name (labels handle routing at container creation) - DeleteRoute() is no-op (container removal auto-deregisters) - Ping() checks Traefik API health (optional) - Wire ContainerLabels into deployer (executeDeploy + blueGreenDeploy) - Add Traefik settings: entrypoint, cert_resolver, network, api_url - Add traefik option to proxy provider selector in settings UI - Show conditional Traefik config fields - Add i18n keys (EN + RU)
This commit is contained in:
@@ -202,12 +202,6 @@ func (s *Server) buildConsumerNameMap() map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
// Standalone proxy consumers: "standalone:id" -> domain
|
||||
proxies, _ := s.store.ListStandaloneProxies()
|
||||
for _, p := range proxies {
|
||||
names["standalone:"+p.ID] = p.Domain
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -373,13 +367,5 @@ func (s *Server) computeExpectedFQDNs(settings store.Settings) (map[string]strin
|
||||
}
|
||||
}
|
||||
|
||||
// Standalone proxies.
|
||||
proxies, _ := s.store.ListStandaloneProxies()
|
||||
for _, p := range proxies {
|
||||
if p.Domain != "" {
|
||||
expected[p.Domain] = "standalone:" + p.ID
|
||||
}
|
||||
}
|
||||
|
||||
return expected, nil
|
||||
}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/proxy"
|
||||
)
|
||||
|
||||
// validateProxy runs the validation pipeline without creating a proxy.
|
||||
// POST /api/proxies/validate
|
||||
func (s *Server) validateProxy(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Host == "" {
|
||||
respondError(w, http.StatusBadRequest, "host is required")
|
||||
return
|
||||
}
|
||||
if req.Port < 1 || req.Port > 65535 {
|
||||
respondError(w, http.StatusBadRequest, "port must be between 1 and 65535")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result := proxy.ValidateDestination(ctx, req.Host, req.Port)
|
||||
respondJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// createProxy creates a new standalone proxy.
|
||||
// POST /api/proxies
|
||||
func (s *Server) createProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if s.proxyManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "proxy manager not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req proxy.CreateProxyRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Domain == "" {
|
||||
respondError(w, http.StatusBadRequest, "domain is required")
|
||||
return
|
||||
}
|
||||
if req.DestinationURL == "" {
|
||||
respondError(w, http.StatusBadRequest, "destination_url is required")
|
||||
return
|
||||
}
|
||||
if req.DestinationPort < 1 || req.DestinationPort > 65535 {
|
||||
respondError(w, http.StatusBadRequest, "destination_port must be between 1 and 65535")
|
||||
return
|
||||
}
|
||||
|
||||
p, err := s.proxyManager.CreateProxy(r.Context(), req)
|
||||
if err != nil {
|
||||
slog.Error("failed to create proxy", "domain", req.Domain, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to create proxy")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
// listProxies returns all standalone proxies.
|
||||
// GET /api/proxies
|
||||
func (s *Server) listProxies(w http.ResponseWriter, r *http.Request) {
|
||||
if s.proxyManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "proxy manager not configured")
|
||||
return
|
||||
}
|
||||
|
||||
proxies, err := s.proxyManager.ListProxies()
|
||||
if err != nil {
|
||||
slog.Error("proxy operation failed", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "proxy operation failed")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, proxies)
|
||||
}
|
||||
|
||||
// getProxy returns a single standalone proxy.
|
||||
// GET /api/proxies/{id}
|
||||
func (s *Server) getProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if s.proxyManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "proxy manager not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
p, err := s.proxyManager.GetProxy(id)
|
||||
if err != nil {
|
||||
if proxy.IsNotFound(err) {
|
||||
respondNotFound(w, "proxy")
|
||||
return
|
||||
}
|
||||
slog.Error("proxy operation failed", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "proxy operation failed")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// updateProxy updates an existing standalone proxy.
|
||||
// PUT /api/proxies/{id}
|
||||
func (s *Server) updateProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if s.proxyManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "proxy manager not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req proxy.UpdateProxyRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Domain == "" {
|
||||
respondError(w, http.StatusBadRequest, "domain is required")
|
||||
return
|
||||
}
|
||||
if req.DestinationURL == "" {
|
||||
respondError(w, http.StatusBadRequest, "destination_url is required")
|
||||
return
|
||||
}
|
||||
if req.DestinationPort < 1 || req.DestinationPort > 65535 {
|
||||
respondError(w, http.StatusBadRequest, "destination_port must be between 1 and 65535")
|
||||
return
|
||||
}
|
||||
|
||||
p, err := s.proxyManager.UpdateProxy(r.Context(), id, req)
|
||||
if err != nil {
|
||||
if proxy.IsNotFound(err) {
|
||||
respondNotFound(w, "proxy")
|
||||
return
|
||||
}
|
||||
slog.Error("proxy operation failed", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "proxy operation failed")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// deleteProxy removes a standalone proxy.
|
||||
// DELETE /api/proxies/{id}
|
||||
func (s *Server) deleteProxy(w http.ResponseWriter, r *http.Request) {
|
||||
if s.proxyManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "proxy manager not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if err := s.proxyManager.DeleteProxy(r.Context(), id); err != nil {
|
||||
if proxy.IsNotFound(err) {
|
||||
respondNotFound(w, "proxy")
|
||||
return
|
||||
}
|
||||
slog.Error("proxy operation failed", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "proxy operation failed")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
|
||||
// listAllProxies returns a merged view of standalone and deploy-managed proxies.
|
||||
// GET /api/proxies/all
|
||||
func (s *Server) listAllProxies(w http.ResponseWriter, r *http.Request) {
|
||||
if s.proxyManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "proxy manager not configured")
|
||||
return
|
||||
}
|
||||
|
||||
views, err := s.proxyManager.ListAllProxies()
|
||||
if err != nil {
|
||||
slog.Error("proxy operation failed", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "proxy operation failed")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, views)
|
||||
}
|
||||
+1
-25
@@ -21,7 +21,7 @@ import (
|
||||
)
|
||||
|
||||
// DNSProviderChangedFunc is called when DNS settings change so the caller can
|
||||
// update the provider on the deployer and proxy manager.
|
||||
// update the provider on the deployer.
|
||||
type DNSProviderChangedFunc func(provider dns.Provider)
|
||||
|
||||
// Server holds all dependencies for the API layer.
|
||||
@@ -37,7 +37,6 @@ type Server struct {
|
||||
localAuth *auth.LocalAuth
|
||||
oidcProvider *auth.OIDCProvider
|
||||
staleScanner *stale.Scanner
|
||||
proxyManager *proxy.Manager
|
||||
|
||||
dnsProviderMu sync.RWMutex
|
||||
dnsProvider dns.Provider
|
||||
@@ -89,12 +88,6 @@ func (s *Server) SetStaleScanner(scanner *stale.Scanner) {
|
||||
s.staleScanner = scanner
|
||||
}
|
||||
|
||||
// SetProxyManager sets the proxy manager on the server.
|
||||
// Called after both the API server and proxy manager are initialized.
|
||||
func (s *Server) SetProxyManager(pm *proxy.Manager) {
|
||||
s.proxyManager = pm
|
||||
}
|
||||
|
||||
// SetBackupEngine sets the backup engine on the server.
|
||||
func (s *Server) SetBackupEngine(engine *backup.Engine) {
|
||||
s.backupEngine = engine
|
||||
@@ -261,19 +254,6 @@ func (s *Server) Router() chi.Router {
|
||||
// Stale container endpoints (read).
|
||||
r.Get("/containers/stale", s.listStaleContainers)
|
||||
|
||||
// Proxy endpoints (read-only for any authenticated user).
|
||||
r.Get("/proxies", s.listProxies)
|
||||
r.Get("/proxies/all", s.listAllProxies)
|
||||
r.Route("/proxies/{id}", func(r chi.Router) {
|
||||
r.Get("/", s.getProxy)
|
||||
// Admin-only proxy mutations.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.AdminOnly)
|
||||
r.Put("/", s.updateProxy)
|
||||
r.Delete("/", s.deleteProxy)
|
||||
})
|
||||
})
|
||||
|
||||
// Admin-only routes: require admin role.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.AdminOnly)
|
||||
@@ -300,10 +280,6 @@ func (s *Server) Router() chi.Router {
|
||||
// Registry creation.
|
||||
r.Post("/registries", s.createRegistry)
|
||||
|
||||
// Proxy mutation endpoints.
|
||||
r.Post("/proxies/validate", s.validateProxy)
|
||||
r.Post("/proxies", s.createProxy)
|
||||
|
||||
// Stale container cleanup endpoints.
|
||||
// Bulk route must be registered before parameterized route.
|
||||
r.Post("/containers/stale/cleanup", s.bulkCleanupStaleContainers)
|
||||
|
||||
@@ -35,6 +35,10 @@ type settingsRequest struct {
|
||||
CloudflareAPIToken string `json:"cloudflare_api_token"`
|
||||
CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"`
|
||||
ProxyProvider *string `json:"proxy_provider,omitempty"`
|
||||
TraefikEntrypoint *string `json:"traefik_entrypoint,omitempty"`
|
||||
TraefikCertResolver *string `json:"traefik_cert_resolver,omitempty"`
|
||||
TraefikNetwork *string `json:"traefik_network,omitempty"`
|
||||
TraefikAPIURL *string `json:"traefik_api_url,omitempty"`
|
||||
BackupEnabled *bool `json:"backup_enabled,omitempty"`
|
||||
BackupIntervalHours *int `json:"backup_interval_hours,omitempty"`
|
||||
BackupRetentionCount *int `json:"backup_retention_count,omitempty"`
|
||||
@@ -67,6 +71,10 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"has_cloudflare_api_token": settings.CloudflareAPIToken != "",
|
||||
"cloudflare_zone_id": settings.CloudflareZoneID,
|
||||
"proxy_provider": settings.ProxyProvider,
|
||||
"traefik_entrypoint": settings.TraefikEntrypoint,
|
||||
"traefik_cert_resolver": settings.TraefikCertResolver,
|
||||
"traefik_network": settings.TraefikNetwork,
|
||||
"traefik_api_url": settings.TraefikAPIURL,
|
||||
"backup_enabled": settings.BackupEnabled,
|
||||
"backup_interval_hours": settings.BackupIntervalHours,
|
||||
"backup_retention_count": settings.BackupRetentionCount,
|
||||
@@ -171,13 +179,27 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
// Proxy provider setting.
|
||||
if req.ProxyProvider != nil {
|
||||
prov := *req.ProxyProvider
|
||||
if prov != "" && prov != "none" && prov != "npm" {
|
||||
respondError(w, http.StatusBadRequest, "proxy_provider must be 'none' or 'npm'")
|
||||
if prov != "" && prov != "none" && prov != "npm" && prov != "traefik" {
|
||||
respondError(w, http.StatusBadRequest, "proxy_provider must be 'none', 'npm', or 'traefik'")
|
||||
return
|
||||
}
|
||||
updated.ProxyProvider = prov
|
||||
}
|
||||
|
||||
// Traefik provider settings.
|
||||
if req.TraefikEntrypoint != nil {
|
||||
updated.TraefikEntrypoint = *req.TraefikEntrypoint
|
||||
}
|
||||
if req.TraefikCertResolver != nil {
|
||||
updated.TraefikCertResolver = *req.TraefikCertResolver
|
||||
}
|
||||
if req.TraefikNetwork != nil {
|
||||
updated.TraefikNetwork = *req.TraefikNetwork
|
||||
}
|
||||
if req.TraefikAPIURL != nil {
|
||||
updated.TraefikAPIURL = *req.TraefikAPIURL
|
||||
}
|
||||
|
||||
// Backup settings.
|
||||
if req.BackupEnabled != nil {
|
||||
updated.BackupEnabled = *req.BackupEnabled
|
||||
|
||||
@@ -89,6 +89,19 @@ func (d *Deployer) blueGreenDeploy(
|
||||
Mounts: mounts,
|
||||
}
|
||||
|
||||
// Set proxy labels for providers that use Docker labels (e.g., Traefik).
|
||||
if stage.EnableProxy {
|
||||
fqdn := subdomain + "." + settings.Domain
|
||||
if proxyLabels := d.proxy.ContainerLabels(fqdn, project.Port); proxyLabels != nil {
|
||||
if containerCfg.Labels == nil {
|
||||
containerCfg.Labels = make(map[string]string)
|
||||
}
|
||||
for k, v := range proxyLabels {
|
||||
containerCfg.Labels[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d.logDeploy(deployID, fmt.Sprintf("Blue-green: creating green container %s", containerName), "info")
|
||||
containerID, err := d.docker.CreateContainer(ctx, containerCfg)
|
||||
if err != nil {
|
||||
|
||||
@@ -315,6 +315,19 @@ func (d *Deployer) executeDeploy(
|
||||
Mounts: mounts,
|
||||
}
|
||||
|
||||
// Set proxy labels for providers that use Docker labels (e.g., Traefik).
|
||||
if stage.EnableProxy {
|
||||
fqdn := subdomain + "." + settings.Domain
|
||||
if proxyLabels := d.proxy.ContainerLabels(fqdn, project.Port); proxyLabels != nil {
|
||||
if containerCfg.Labels == nil {
|
||||
containerCfg.Labels = make(map[string]string)
|
||||
}
|
||||
for k, v := range proxyLabels {
|
||||
containerCfg.Labels[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d.logDeploy(deployID, fmt.Sprintf("Creating container %s", containerName), "info")
|
||||
containerID, err = d.docker.CreateContainer(ctx, containerCfg)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// HealthMonitor periodically checks the health of all standalone proxies.
|
||||
type HealthMonitor struct {
|
||||
store *store.Store
|
||||
eventBus *events.Bus
|
||||
|
||||
cron *cron.Cron
|
||||
mu sync.Mutex
|
||||
entryID cron.EntryID
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewHealthMonitor creates a new proxy health monitor.
|
||||
func NewHealthMonitor(st *store.Store, eventBus *events.Bus) *HealthMonitor {
|
||||
return &HealthMonitor{
|
||||
store: st,
|
||||
eventBus: eventBus,
|
||||
cron: cron.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins periodic health checks with the given interval (e.g., "5m", "1m").
|
||||
// If already running, it stops and restarts with the new interval.
|
||||
func (h *HealthMonitor) Start(interval string) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
duration, err := time.ParseDuration(interval)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse health check interval %q: %w", interval, err)
|
||||
}
|
||||
|
||||
if h.running {
|
||||
h.cron.Remove(h.entryID)
|
||||
}
|
||||
|
||||
spec := fmt.Sprintf("@every %s", duration.String())
|
||||
entryID, err := h.cron.AddFunc(spec, func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if checkErr := h.CheckAll(ctx); checkErr != nil {
|
||||
slog.Warn("proxy health monitor: check error", "error", checkErr)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("schedule proxy health monitor: %w", err)
|
||||
}
|
||||
|
||||
h.entryID = entryID
|
||||
if !h.running {
|
||||
h.cron.Start()
|
||||
}
|
||||
h.running = true
|
||||
|
||||
slog.Info("proxy health monitor started", "interval", duration.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the health monitor.
|
||||
func (h *HealthMonitor) Stop() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if h.running {
|
||||
ctx := h.cron.Stop()
|
||||
<-ctx.Done()
|
||||
h.running = false
|
||||
slog.Info("proxy health monitor stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAll performs a single health check cycle for all standalone proxies.
|
||||
func (h *HealthMonitor) CheckAll(ctx context.Context) error {
|
||||
proxies, err := h.store.ListStandaloneProxies()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list standalone proxies: %w", err)
|
||||
}
|
||||
|
||||
for _, proxy := range proxies {
|
||||
newStatus := checkProxyHealth(ctx, proxy.DestinationURL, proxy.DestinationPort)
|
||||
oldStatus := proxy.HealthStatus
|
||||
|
||||
if err := h.store.UpdateProxyHealth(proxy.ID, newStatus); err != nil {
|
||||
slog.Warn("proxy health monitor: failed to update health",
|
||||
"proxy_id", proxy.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Emit event on status change.
|
||||
if oldStatus != newStatus && oldStatus != "unknown" {
|
||||
h.emitHealthEvent(proxy, oldStatus, newStatus)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkProxyHealth performs an HTTP GET to the destination and returns the health status.
|
||||
func checkProxyHealth(ctx context.Context, host string, port int) string {
|
||||
target := fmt.Sprintf("http://%s:%d/", host, port)
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return "unhealthy"
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "unhealthy"
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
return "unhealthy"
|
||||
}
|
||||
|
||||
return "healthy"
|
||||
}
|
||||
|
||||
// emitHealthEvent persists and publishes a health status change event.
|
||||
func (h *HealthMonitor) emitHealthEvent(proxy store.StandaloneProxy, oldStatus, newStatus string) {
|
||||
severity := "info"
|
||||
if newStatus == "unhealthy" {
|
||||
severity = "warn"
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Proxy %s (%s) health changed: %s -> %s",
|
||||
proxy.Domain, proxy.ID, oldStatus, newStatus)
|
||||
|
||||
metadata, _ := json.Marshal(map[string]any{
|
||||
"proxy_id": proxy.ID,
|
||||
"domain": proxy.Domain,
|
||||
"old_status": oldStatus,
|
||||
"new_status": newStatus,
|
||||
})
|
||||
|
||||
evt, err := h.store.InsertEvent(store.EventLog{
|
||||
Source: "proxy_health",
|
||||
Severity: severity,
|
||||
Message: msg,
|
||||
Metadata: string(metadata),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("proxy health monitor: failed to persist event", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
h.eventBus.Publish(events.Event{
|
||||
Type: events.EventLog,
|
||||
Payload: events.EventLogPayload{
|
||||
ID: evt.ID,
|
||||
Source: "proxy_health",
|
||||
Severity: severity,
|
||||
Message: msg,
|
||||
Metadata: string(metadata),
|
||||
CreatedAt: evt.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// diagnosticHint returns a user-friendly suggestion for a validation failure.
|
||||
func diagnosticHint(step string, err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch step {
|
||||
case StepDNS:
|
||||
return "Domain cannot be resolved. Check DNS settings or use an IP address."
|
||||
|
||||
case StepTCP:
|
||||
return tcpHintFromError(err)
|
||||
|
||||
case StepHTTP:
|
||||
return httpHint(err.Error())
|
||||
|
||||
default:
|
||||
return "Validation failed: " + err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// tcpHintFromError returns a specific hint based on the TCP error type.
|
||||
func tcpHintFromError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
lower := strings.ToLower(opErr.Err.Error())
|
||||
switch {
|
||||
case strings.Contains(lower, "connection refused"):
|
||||
return "Port is not accepting connections. Check if the service is running and the port is correct."
|
||||
case strings.Contains(lower, "i/o timeout") || strings.Contains(lower, "timeout"):
|
||||
return "Connection timed out. Possible firewall blocking. Check network/firewall rules."
|
||||
case strings.Contains(lower, "no route to host") || strings.Contains(lower, "host is unreachable"):
|
||||
return "Host is not reachable. Verify the IP address and network connectivity."
|
||||
}
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
lower := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.Contains(lower, "connection refused"):
|
||||
return "Port is not accepting connections. Check if the service is running and the port is correct."
|
||||
case strings.Contains(lower, "timeout"):
|
||||
return "Connection timed out. Possible firewall blocking. Check network/firewall rules."
|
||||
default:
|
||||
return fmt.Sprintf("TCP connection failed: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// httpHint returns a specific hint based on the HTTP probe result.
|
||||
func httpHint(msg string) string {
|
||||
lower := strings.ToLower(msg)
|
||||
|
||||
switch {
|
||||
case strings.Contains(lower, "status"):
|
||||
return msg // Already formatted by the caller with the status code.
|
||||
case strings.Contains(lower, "timeout"):
|
||||
return "HTTP health probe timed out. The service may be slow or unresponsive."
|
||||
default:
|
||||
return "HTTP health probe failed: " + msg
|
||||
}
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/dns"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
)
|
||||
|
||||
// Manager handles the lifecycle of standalone proxy hosts.
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
provider Provider
|
||||
dnsMu sync.RWMutex
|
||||
dns dns.Provider // nil when wildcard DNS is active
|
||||
}
|
||||
|
||||
// NewManager creates a new proxy manager.
|
||||
func NewManager(st *store.Store, provider Provider) *Manager {
|
||||
return &Manager{
|
||||
store: st,
|
||||
provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDNSProvider sets the DNS provider for managing DNS records.
|
||||
func (m *Manager) SetDNSProvider(provider dns.Provider) {
|
||||
m.dnsMu.Lock()
|
||||
defer m.dnsMu.Unlock()
|
||||
m.dns = provider
|
||||
}
|
||||
|
||||
// getDNS returns the current DNS provider under read lock.
|
||||
func (m *Manager) getDNS() dns.Provider {
|
||||
m.dnsMu.RLock()
|
||||
defer m.dnsMu.RUnlock()
|
||||
return m.dns
|
||||
}
|
||||
|
||||
// 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 a proxy route via the provider, 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)
|
||||
}
|
||||
|
||||
// Create proxy route via provider.
|
||||
routeID, err := m.provider.ConfigureRoute(ctx, req.Domain, req.DestinationURL, req.DestinationPort, RouteOptions{
|
||||
SSLCertificateID: settings.SSLCertificateID,
|
||||
})
|
||||
if err != nil {
|
||||
return store.StandaloneProxy{}, fmt.Errorf("create proxy route: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("created proxy route for standalone proxy",
|
||||
"domain", req.Domain, "route_id", routeID, "provider", m.provider.Name())
|
||||
|
||||
// Save to store.
|
||||
proxy, err := m.store.CreateStandaloneProxy(store.StandaloneProxy{
|
||||
Domain: req.Domain,
|
||||
DestinationURL: req.DestinationURL,
|
||||
DestinationPort: req.DestinationPort,
|
||||
SSLCertificateID: settings.SSLCertificateID,
|
||||
HealthStatus: "unknown",
|
||||
})
|
||||
if err != nil {
|
||||
// Best effort: clean up the proxy route if store insert fails.
|
||||
if delErr := m.provider.DeleteRoute(ctx, routeID); delErr != nil {
|
||||
slog.Error("failed to clean up proxy route after store error",
|
||||
"route_id", routeID, "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 proxy route via the provider, 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 proxy route via provider (ConfigureRoute handles create-or-update).
|
||||
if _, err := m.provider.ConfigureRoute(ctx, req.Domain, req.DestinationURL, req.DestinationPort, RouteOptions{
|
||||
SSLCertificateID: settings.SSLCertificateID,
|
||||
}); err != nil {
|
||||
return store.StandaloneProxy{}, fmt.Errorf("update proxy route: %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 proxy route via the provider and deletes from the store.
|
||||
func (m *Manager) DeleteProxy(ctx context.Context, id string) error {
|
||||
p, err := m.store.GetStandaloneProxy(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get proxy: %w", err)
|
||||
}
|
||||
|
||||
// Delete proxy route via provider using the NpmProxyID as a string route ID.
|
||||
if p.NpmProxyID > 0 {
|
||||
routeID := fmt.Sprintf("%d", p.NpmProxyID)
|
||||
if err := m.provider.DeleteRoute(ctx, routeID); err != nil {
|
||||
slog.Warn("failed to delete proxy route (continuing with store deletion)",
|
||||
"route_id", routeID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove DNS record.
|
||||
m.removeDNS(ctx, p.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 a proxy route configured.
|
||||
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.ProxyRouteID == "" && 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) {
|
||||
dnsProvider := m.getDNS()
|
||||
if dnsProvider == 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 := dnsProvider.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) {
|
||||
dnsProvider := m.getDNS()
|
||||
if dnsProvider == nil {
|
||||
return
|
||||
}
|
||||
if err := dnsProvider.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)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TraefikProvider manages proxy routes via Docker labels.
|
||||
// Traefik auto-discovers containers with the appropriate labels.
|
||||
type TraefikProvider struct {
|
||||
entrypoint string
|
||||
certResolver string
|
||||
network string // Docker network for traefik.docker.network label
|
||||
apiURL string // Traefik API URL for health checks (optional)
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewTraefikProvider creates a Traefik-backed proxy provider.
|
||||
func NewTraefikProvider(entrypoint, certResolver, network, apiURL string) *TraefikProvider {
|
||||
if entrypoint == "" {
|
||||
entrypoint = "websecure"
|
||||
}
|
||||
return &TraefikProvider{
|
||||
entrypoint: entrypoint,
|
||||
certResolver: certResolver,
|
||||
network: network,
|
||||
apiURL: strings.TrimRight(apiURL, "/"),
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TraefikProvider) Name() string { return "traefik" }
|
||||
|
||||
// ConfigureRoute for Traefik is a no-op for deploy-managed containers.
|
||||
// Labels are set at container creation time via ContainerLabels().
|
||||
// Returns a route ID for tracking.
|
||||
func (t *TraefikProvider) ConfigureRoute(_ context.Context, domain, _ string, _ int, _ RouteOptions) (string, error) {
|
||||
routerName := sanitizeDomain(domain)
|
||||
return routerName, nil
|
||||
}
|
||||
|
||||
// DeleteRoute for Traefik is a no-op — removing the container removes the labels,
|
||||
// and Traefik automatically de-registers the route.
|
||||
func (t *TraefikProvider) DeleteRoute(_ context.Context, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContainerLabels returns Docker labels for Traefik auto-discovery.
|
||||
func (t *TraefikProvider) ContainerLabels(domain string, port int) map[string]string {
|
||||
name := sanitizeDomain(domain)
|
||||
labels := map[string]string{
|
||||
"traefik.enable": "true",
|
||||
fmt.Sprintf("traefik.http.routers.%s.rule", name): fmt.Sprintf("Host(`%s`)", domain),
|
||||
fmt.Sprintf("traefik.http.routers.%s.entrypoints", name): t.entrypoint,
|
||||
fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", name): fmt.Sprintf("%d", port),
|
||||
}
|
||||
if t.certResolver != "" {
|
||||
labels[fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", name)] = t.certResolver
|
||||
}
|
||||
if t.network != "" {
|
||||
labels["traefik.docker.network"] = t.network
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// Ping checks Traefik API connectivity if a URL is configured.
|
||||
func (t *TraefikProvider) Ping(ctx context.Context) error {
|
||||
if t.apiURL == "" {
|
||||
return nil // No API URL configured, skip health check.
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, t.apiURL+"/api/overview", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create traefik ping request: %w", err)
|
||||
}
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("traefik ping: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("traefik api returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeDomain converts a domain to a safe Traefik router name.
|
||||
func sanitizeDomain(domain string) string {
|
||||
r := strings.NewReplacer(".", "-", ":", "-", "*", "wildcard")
|
||||
return r.Replace(strings.ToLower(domain))
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Validation step names.
|
||||
const (
|
||||
StepSyntax = "syntax"
|
||||
StepDNS = "dns"
|
||||
StepTCP = "tcp"
|
||||
StepHTTP = "http"
|
||||
)
|
||||
|
||||
// ValidationStep holds the result of a single validation check.
|
||||
type ValidationStep struct {
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// ValidationResult holds the aggregate result of the validation pipeline.
|
||||
type ValidationResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
Steps []ValidationStep `json:"steps"`
|
||||
}
|
||||
|
||||
// ValidateDestination runs the multi-step validation pipeline against the given
|
||||
// destination host and port. It checks syntax, DNS, TCP reachability, and HTTP health.
|
||||
// The pipeline short-circuits on failure: later steps are skipped if an earlier one fails.
|
||||
func ValidateDestination(ctx context.Context, host string, port int) ValidationResult {
|
||||
result := ValidationResult{Valid: true}
|
||||
|
||||
// Step 1: Syntax validation.
|
||||
if step, ok := validateSyntax(host, port); !ok {
|
||||
result.Valid = false
|
||||
result.Steps = append(result.Steps, step)
|
||||
return result
|
||||
} else {
|
||||
result.Steps = append(result.Steps, step)
|
||||
}
|
||||
|
||||
// Step 2: DNS resolution (skip for IP addresses).
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
if step, ok := validateDNS(ctx, host); !ok {
|
||||
result.Valid = false
|
||||
result.Steps = append(result.Steps, step)
|
||||
return result
|
||||
} else {
|
||||
result.Steps = append(result.Steps, step)
|
||||
}
|
||||
} else {
|
||||
result.Steps = append(result.Steps, ValidationStep{
|
||||
Name: StepDNS,
|
||||
Passed: true,
|
||||
Message: "Skipped (IP address provided)",
|
||||
})
|
||||
}
|
||||
|
||||
// Step 3: TCP port reachability.
|
||||
if step, ok := validateTCP(ctx, host, port); !ok {
|
||||
result.Valid = false
|
||||
result.Steps = append(result.Steps, step)
|
||||
return result
|
||||
} else {
|
||||
result.Steps = append(result.Steps, step)
|
||||
}
|
||||
|
||||
// Step 4: HTTP health probe.
|
||||
step := validateHTTP(ctx, host, port)
|
||||
result.Steps = append(result.Steps, step)
|
||||
if !step.Passed {
|
||||
result.Valid = false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// validateSyntax checks that the host and port values are syntactically valid.
|
||||
func validateSyntax(host string, port int) (ValidationStep, bool) {
|
||||
if host == "" {
|
||||
return ValidationStep{
|
||||
Name: StepSyntax,
|
||||
Passed: false,
|
||||
Message: "Host is empty",
|
||||
Hint: "Provide a valid hostname or IP address.",
|
||||
}, false
|
||||
}
|
||||
|
||||
if port < 1 || port > 65535 {
|
||||
return ValidationStep{
|
||||
Name: StepSyntax,
|
||||
Passed: false,
|
||||
Message: fmt.Sprintf("Port %d is out of range (1-65535)", port),
|
||||
Hint: "Provide a valid port number between 1 and 65535.",
|
||||
}, false
|
||||
}
|
||||
|
||||
// Reject obviously invalid hostnames (but allow IPs).
|
||||
if net.ParseIP(host) == nil {
|
||||
// Basic hostname validation: must not contain spaces or schemes.
|
||||
if _, err := url.Parse("http://" + host); err != nil {
|
||||
return ValidationStep{
|
||||
Name: StepSyntax,
|
||||
Passed: false,
|
||||
Message: "Invalid hostname: " + err.Error(),
|
||||
Hint: "Provide a valid hostname without scheme (e.g., 'example.com' not 'http://example.com').",
|
||||
}, false
|
||||
}
|
||||
}
|
||||
|
||||
return ValidationStep{
|
||||
Name: StepSyntax,
|
||||
Passed: true,
|
||||
Message: fmt.Sprintf("Host %q port %d syntax OK", host, port),
|
||||
}, true
|
||||
}
|
||||
|
||||
// validateDNS performs a DNS lookup on the given host.
|
||||
func validateDNS(ctx context.Context, host string) (ValidationStep, bool) {
|
||||
resolver := net.DefaultResolver
|
||||
addrs, err := resolver.LookupHost(ctx, host)
|
||||
if err != nil {
|
||||
return ValidationStep{
|
||||
Name: StepDNS,
|
||||
Passed: false,
|
||||
Message: fmt.Sprintf("DNS resolution failed for %q: %s", host, err.Error()),
|
||||
Hint: diagnosticHint(StepDNS, err),
|
||||
}, false
|
||||
}
|
||||
|
||||
return ValidationStep{
|
||||
Name: StepDNS,
|
||||
Passed: true,
|
||||
Message: fmt.Sprintf("Resolved to %v", addrs),
|
||||
}, true
|
||||
}
|
||||
|
||||
// validateTCP attempts a TCP connection to host:port with a 5-second timeout.
|
||||
func validateTCP(ctx context.Context, host string, port int) (ValidationStep, bool) {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(dialCtx, "tcp", addr)
|
||||
if err != nil {
|
||||
return ValidationStep{
|
||||
Name: StepTCP,
|
||||
Passed: false,
|
||||
Message: fmt.Sprintf("TCP connect to %s failed: %s", addr, err.Error()),
|
||||
Hint: diagnosticHint(StepTCP, err),
|
||||
}, false
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
return ValidationStep{
|
||||
Name: StepTCP,
|
||||
Passed: true,
|
||||
Message: fmt.Sprintf("TCP connect to %s succeeded", addr),
|
||||
}, true
|
||||
}
|
||||
|
||||
// validateHTTP performs a GET request to the destination and checks for a response.
|
||||
// Non-5xx responses are considered passing (the service is responding).
|
||||
func validateHTTP(ctx context.Context, host string, port int) ValidationStep {
|
||||
target := fmt.Sprintf("http://%s:%d/", host, port)
|
||||
|
||||
httpCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(httpCtx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return ValidationStep{
|
||||
Name: StepHTTP,
|
||||
Passed: false,
|
||||
Message: fmt.Sprintf("Failed to build HTTP request: %s", err.Error()),
|
||||
Hint: diagnosticHint(StepHTTP, err),
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
// Do not follow redirects — we just want to see if the port responds to HTTP.
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ValidationStep{
|
||||
Name: StepHTTP,
|
||||
Passed: false,
|
||||
Message: fmt.Sprintf("HTTP probe to %s failed: %s", target, err.Error()),
|
||||
Hint: diagnosticHint(StepHTTP, err),
|
||||
}
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
return ValidationStep{
|
||||
Name: StepHTTP,
|
||||
Passed: false,
|
||||
Message: fmt.Sprintf("Service responded with HTTP %d. The service may not be healthy.", resp.StatusCode),
|
||||
Hint: fmt.Sprintf("Service responded with HTTP %d. The service may not be healthy.", resp.StatusCode),
|
||||
}
|
||||
}
|
||||
|
||||
return ValidationStep{
|
||||
Name: StepHTTP,
|
||||
Passed: true,
|
||||
Message: fmt.Sprintf("HTTP probe returned %d", resp.StatusCode),
|
||||
}
|
||||
}
|
||||
@@ -63,8 +63,12 @@ type Settings struct {
|
||||
DNSProvider string `json:"dns_provider"`
|
||||
CloudflareAPIToken string `json:"cloudflare_api_token"`
|
||||
CloudflareZoneID string `json:"cloudflare_zone_id"`
|
||||
ProxyProvider string `json:"proxy_provider"`
|
||||
BackupEnabled bool `json:"backup_enabled"`
|
||||
ProxyProvider string `json:"proxy_provider"`
|
||||
TraefikEntrypoint string `json:"traefik_entrypoint"`
|
||||
TraefikCertResolver string `json:"traefik_cert_resolver"`
|
||||
TraefikNetwork string `json:"traefik_network"`
|
||||
TraefikAPIURL string `json:"traefik_api_url"`
|
||||
BackupEnabled bool `json:"backup_enabled"`
|
||||
BackupIntervalHours int `json:"backup_interval_hours"`
|
||||
BackupRetentionCount int `json:"backup_retention_count"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
@@ -194,16 +198,3 @@ type EventLog struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// StandaloneProxy represents a standalone reverse proxy not tied to a project.
|
||||
type StandaloneProxy struct {
|
||||
ID string `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
DestinationURL string `json:"destination_url"`
|
||||
DestinationPort int `json:"destination_port"`
|
||||
SSLCertificateID int `json:"ssl_certificate_id"`
|
||||
NpmProxyID int `json:"npm_proxy_id"`
|
||||
HealthStatus string `json:"health_status"` // unknown, healthy, unhealthy
|
||||
HealthCheckedAt string `json:"health_checked_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ func (s *Store) GetSettings() (Settings, error) {
|
||||
allowed_volume_paths, wildcard_dns, dns_provider,
|
||||
cloudflare_api_token, cloudflare_zone_id,
|
||||
proxy_provider,
|
||||
traefik_entrypoint, traefik_cert_resolver, traefik_network, traefik_api_url,
|
||||
backup_enabled, backup_interval_hours, backup_retention_count,
|
||||
updated_at
|
||||
FROM settings WHERE id = 1`,
|
||||
@@ -24,6 +25,7 @@ func (s *Store) GetSettings() (Settings, error) {
|
||||
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
|
||||
&st.CloudflareAPIToken, &st.CloudflareZoneID,
|
||||
&st.ProxyProvider,
|
||||
&st.TraefikEntrypoint, &st.TraefikCertResolver, &st.TraefikNetwork, &st.TraefikAPIURL,
|
||||
&backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount,
|
||||
&st.UpdatedAt)
|
||||
if err != nil {
|
||||
@@ -53,6 +55,7 @@ func (s *Store) UpdateSettings(st Settings) error {
|
||||
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
|
||||
cloudflare_api_token=?, cloudflare_zone_id=?,
|
||||
proxy_provider=?,
|
||||
traefik_entrypoint=?, traefik_cert_resolver=?, traefik_network=?, traefik_api_url=?,
|
||||
backup_enabled=?, backup_interval_hours=?, backup_retention_count=?,
|
||||
updated_at=?
|
||||
WHERE id = 1`,
|
||||
@@ -62,6 +65,7 @@ func (s *Store) UpdateSettings(st Settings) error {
|
||||
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
|
||||
st.CloudflareAPIToken, st.CloudflareZoneID,
|
||||
st.ProxyProvider,
|
||||
st.TraefikEntrypoint, st.TraefikCertResolver, st.TraefikNetwork, st.TraefikAPIURL,
|
||||
backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount,
|
||||
st.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CreateStandaloneProxy inserts a new standalone proxy record.
|
||||
func (s *Store) CreateStandaloneProxy(p StandaloneProxy) (StandaloneProxy, error) {
|
||||
p.ID = uuid.New().String()
|
||||
p.CreatedAt = Now()
|
||||
p.UpdatedAt = p.CreatedAt
|
||||
|
||||
if p.HealthStatus == "" {
|
||||
p.HealthStatus = "unknown"
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO standalone_proxies (id, domain, destination_url, destination_port, ssl_certificate_id, npm_proxy_id, health_status, health_checked_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
p.ID, p.Domain, p.DestinationURL, p.DestinationPort, p.SSLCertificateID,
|
||||
p.NpmProxyID, p.HealthStatus, p.HealthCheckedAt, p.CreatedAt, p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StandaloneProxy{}, fmt.Errorf("insert standalone proxy: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetStandaloneProxy returns a standalone proxy by ID.
|
||||
func (s *Store) GetStandaloneProxy(id string) (StandaloneProxy, error) {
|
||||
var p StandaloneProxy
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, domain, destination_url, destination_port, ssl_certificate_id, npm_proxy_id, health_status, health_checked_at, created_at, updated_at
|
||||
FROM standalone_proxies WHERE id = ?`, id,
|
||||
).Scan(&p.ID, &p.Domain, &p.DestinationURL, &p.DestinationPort, &p.SSLCertificateID,
|
||||
&p.NpmProxyID, &p.HealthStatus, &p.HealthCheckedAt, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return StandaloneProxy{}, fmt.Errorf("standalone proxy %s: %w", id, ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return StandaloneProxy{}, fmt.Errorf("query standalone proxy: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ListStandaloneProxies returns all standalone proxy records ordered by creation time.
|
||||
func (s *Store) ListStandaloneProxies() ([]StandaloneProxy, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, domain, destination_url, destination_port, ssl_certificate_id, npm_proxy_id, health_status, health_checked_at, created_at, updated_at
|
||||
FROM standalone_proxies ORDER BY created_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query standalone proxies: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
proxies := []StandaloneProxy{}
|
||||
for rows.Next() {
|
||||
var p StandaloneProxy
|
||||
if err := rows.Scan(&p.ID, &p.Domain, &p.DestinationURL, &p.DestinationPort, &p.SSLCertificateID,
|
||||
&p.NpmProxyID, &p.HealthStatus, &p.HealthCheckedAt, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan standalone proxy: %w", err)
|
||||
}
|
||||
proxies = append(proxies, p)
|
||||
}
|
||||
return proxies, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateStandaloneProxy updates an existing standalone proxy's mutable fields.
|
||||
func (s *Store) UpdateStandaloneProxy(p StandaloneProxy) error {
|
||||
p.UpdatedAt = Now()
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE standalone_proxies SET domain=?, destination_url=?, destination_port=?, ssl_certificate_id=?, npm_proxy_id=?, health_status=?, health_checked_at=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
p.Domain, p.DestinationURL, p.DestinationPort, p.SSLCertificateID,
|
||||
p.NpmProxyID, p.HealthStatus, p.HealthCheckedAt, p.UpdatedAt, p.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update standalone proxy: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("standalone proxy %s: %w", p.ID, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteStandaloneProxy removes a standalone proxy by ID.
|
||||
func (s *Store) DeleteStandaloneProxy(id string) error {
|
||||
result, err := s.db.Exec(`DELETE FROM standalone_proxies WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete standalone proxy: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("standalone proxy %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProxyHealth updates the health status and check timestamp for a standalone proxy.
|
||||
func (s *Store) UpdateProxyHealth(id string, status string) error {
|
||||
ts := Now()
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE standalone_proxies SET health_status=?, health_checked_at=?, updated_at=? WHERE id=?`,
|
||||
status, ts, ts, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update proxy health: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("standalone proxy %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+11
-2
@@ -104,6 +104,11 @@ func (s *Store) runMigrations() error {
|
||||
`ALTER TABLE instances ADD COLUMN proxy_route_id TEXT NOT NULL DEFAULT ''`,
|
||||
// Add proxy_provider to settings (2026-04-04). Default to npm for backward compat.
|
||||
`ALTER TABLE settings ADD COLUMN proxy_provider TEXT NOT NULL DEFAULT 'npm'`,
|
||||
// Add Traefik provider settings (2026-04-04).
|
||||
`ALTER TABLE settings ADD COLUMN traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure'`,
|
||||
`ALTER TABLE settings ADD COLUMN traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt'`,
|
||||
`ALTER TABLE settings ADD COLUMN traefik_network TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
@@ -202,8 +207,12 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
webhook_secret TEXT NOT NULL DEFAULT '',
|
||||
polling_interval TEXT NOT NULL DEFAULT '5m',
|
||||
base_volume_path TEXT NOT NULL DEFAULT '',
|
||||
ssl_certificate_id INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
ssl_certificate_id INTEGER NOT NULL DEFAULT 0,
|
||||
traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure',
|
||||
traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt',
|
||||
traefik_network TEXT NOT NULL DEFAULT '',
|
||||
traefik_api_url TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS instances (
|
||||
|
||||
Reference in New Issue
Block a user