diff --git a/.claude/settings.json b/.claude/settings.json index 8227578..1ddf0a2 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,8 @@ "Bash(go vet:*)", "Bash(git checkout:*)", "Bash(git stash:*)", - "Bash(echo \"EXIT: $?\")" + "Bash(echo \"EXIT: $?\")", + "Bash(./scripts/dev-server.sh)" ], "additionalDirectories": [ "C:\\Users\\Alexei\\Documents\\docker-watcher\\internal", diff --git a/cmd/server/main.go b/cmd/server/main.go index 798ede4..15524ea 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -101,6 +101,14 @@ func main() { case "none": proxyProvider = proxy.NewNoneProvider() slog.Info("proxy provider: none") + case "traefik": + proxyProvider = proxy.NewTraefikProvider( + settings.TraefikEntrypoint, + settings.TraefikCertResolver, + settings.TraefikNetwork, + settings.TraefikAPIURL, + ) + slog.Info("proxy provider: traefik", "entrypoint", settings.TraefikEntrypoint) default: // Default to NPM for backward compatibility (including "npm" and empty string). npmPassword := "" @@ -164,13 +172,6 @@ func main() { slog.Warn("failed to start stale scanner", "error", err) } - // Initialize proxy manager and health monitor. - proxyManager := proxy.NewManager(db, proxyProvider) - proxyHealth := proxy.NewHealthMonitor(db, eventBus) - if err := proxyHealth.Start("5m"); err != nil { - slog.Warn("failed to start proxy health monitor", "error", err) - } - // Start daily event log pruning cron job. cronScheduler := cron.New() if _, err := cronScheduler.AddFunc("@daily", func() { @@ -220,7 +221,6 @@ func main() { dnsProvider := initDNSProvider(settings, encKey) if dnsProvider != nil { dep.SetDNSProvider(dnsProvider) - proxyManager.SetDNSProvider(dnsProvider) slog.Info("DNS provider initialized", "provider", settings.DNSProvider) } @@ -283,14 +283,12 @@ func main() { // Build API server. apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, webhookHandler, eventBus, encKey) apiServer.SetStaleScanner(staleScanner) - apiServer.SetProxyManager(proxyManager) apiServer.SetBackupEngine(backupEngine) apiServer.SetDBPath(dbPath) apiServer.SetBackupSettingsChangedCallback(scheduleAutobackup) apiServer.SetDNSProvider(dnsProvider) apiServer.SetDNSProviderChangedCallback(func(provider dns.Provider) { dep.SetDNSProvider(provider) - proxyManager.SetDNSProvider(provider) }) router := apiServer.Router() @@ -340,7 +338,6 @@ func main() { // Stop accepting new work. cronScheduler.Stop() eventBus.Unsubscribe(notifySub) - proxyHealth.Stop() staleScanner.Stop() poller.Stop() diff --git a/internal/api/dns.go b/internal/api/dns.go index c61b6bd..6a7b29e 100644 --- a/internal/api/dns.go +++ b/internal/api/dns.go @@ -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 } diff --git a/internal/api/proxy.go b/internal/api/proxy.go deleted file mode 100644 index e5dd8a0..0000000 --- a/internal/api/proxy.go +++ /dev/null @@ -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) -} diff --git a/internal/api/router.go b/internal/api/router.go index 8b7a133..ae1671b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/api/settings.go b/internal/api/settings.go index f7188b3..8804746 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -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 diff --git a/internal/deployer/bluegreen.go b/internal/deployer/bluegreen.go index 3f5a94a..4793405 100644 --- a/internal/deployer/bluegreen.go +++ b/internal/deployer/bluegreen.go @@ -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 { diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 6a317c5..bc4952c 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -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 { diff --git a/internal/proxy/health.go b/internal/proxy/health.go deleted file mode 100644 index 323d08f..0000000 --- a/internal/proxy/health.go +++ /dev/null @@ -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, - }, - }) -} diff --git a/internal/proxy/hints.go b/internal/proxy/hints.go deleted file mode 100644 index 8c1fbdc..0000000 --- a/internal/proxy/hints.go +++ /dev/null @@ -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 - } -} diff --git a/internal/proxy/manager.go b/internal/proxy/manager.go deleted file mode 100644 index e8a3eb6..0000000 --- a/internal/proxy/manager.go +++ /dev/null @@ -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) -} diff --git a/internal/proxy/traefik_provider.go b/internal/proxy/traefik_provider.go new file mode 100644 index 0000000..2a247eb --- /dev/null +++ b/internal/proxy/traefik_provider.go @@ -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)) +} diff --git a/internal/proxy/validator.go b/internal/proxy/validator.go deleted file mode 100644 index a1a5ce5..0000000 --- a/internal/proxy/validator.go +++ /dev/null @@ -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), - } -} diff --git a/internal/store/models.go b/internal/store/models.go index c81f3dd..ed67538 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -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"` -} diff --git a/internal/store/settings.go b/internal/store/settings.go index e8ae580..6f29f5d 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -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, ) diff --git a/internal/store/standalone_proxy.go b/internal/store/standalone_proxy.go deleted file mode 100644 index a1ce46e..0000000 --- a/internal/store/standalone_proxy.go +++ /dev/null @@ -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 -} diff --git a/internal/store/store.go b/internal/store/store.go index a466fac..47180ed 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0793d65..2b86f76 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -11,15 +11,12 @@ import type { NpmCertificate, Project, ProjectDetail, - ProxyView, Registry, RegistryImage, Settings, StaleContainer, Stage, StageEnv, - StandaloneProxy, - ValidationResult, Volume, VolumeScopeInfo, BrowseResult, @@ -531,43 +528,6 @@ export function fetchEventLogStats(): Promise { return get('/api/events/log/stats'); } -// ── Proxies ───────────────────────────────────────────────────────── - -export function validateProxy(host: string, port: number): Promise { - return post('/api/proxies/validate', { host, port }); -} - -export function createProxy(data: { - domain: string; - destination_url: string; - destination_port: number; -}): Promise { - return post('/api/proxies', data); -} - -export function listProxies(): Promise { - return get('/api/proxies'); -} - -export function getProxy(id: string): Promise { - return get(`/api/proxies/${id}`); -} - -export function updateProxy( - id: string, - data: { domain: string; destination_url: string; destination_port: number } -): Promise { - return put(`/api/proxies/${id}`, data); -} - -export function deleteProxy(id: string): Promise<{ deleted: string }> { - return del<{ deleted: string }>(`/api/proxies/${id}`); -} - -export function listAllProxies(): Promise { - return get('/api/proxies/all'); -} - // ── Stale Containers ──────────────────────────────────────────────── export function fetchStaleContainers(): Promise { diff --git a/web/src/lib/components/ProxyCard.svelte b/web/src/lib/components/ProxyCard.svelte deleted file mode 100644 index 2b2c9b6..0000000 --- a/web/src/lib/components/ProxyCard.svelte +++ /dev/null @@ -1,129 +0,0 @@ - - - -
- -
-
-
- - - {#if isHealthy} - - {/if} - - - - - - {proxy.domain} - - -
- - -

- {proxy.destination} -

-
- - - - {proxy.type} - -
- - -
- - {#if proxy.ssl_enabled} - - - SSL - - {/if} - - - - {healthLabel} - - - - {#if proxy.type === 'managed' && proxy.project_name} - - {proxy.project_name} - - {#if proxy.stage_name} - - {proxy.stage_name} - - {/if} - {/if} -
- - -
- {#if proxy.type === 'standalone'} - - - {$t('common.edit')} - - {:else} - - {/if} - - {#if proxy.created_at} -

- {$t('proxies.lastChecked')}: {formatTimestamp(proxy.created_at)} -

- {/if} -
-
diff --git a/web/src/lib/components/ProxyFilter.svelte b/web/src/lib/components/ProxyFilter.svelte deleted file mode 100644 index 80be70e..0000000 --- a/web/src/lib/components/ProxyFilter.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - - -
- -
- - onsearchchange(e.currentTarget.value)} - placeholder={$t('proxies.filter.search')} - class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2 pl-9 pr-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] transition-colors focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]" - /> -
- - - - - - - - - {#if hasFilters} - - {/if} -
diff --git a/web/src/lib/components/ProxyForm.svelte b/web/src/lib/components/ProxyForm.svelte deleted file mode 100644 index d5a10fa..0000000 --- a/web/src/lib/components/ProxyForm.svelte +++ /dev/null @@ -1,292 +0,0 @@ - - - -
- -

{title}

- - -
{ e.preventDefault(); handleSubmit(); }} class="space-y-4"> - - - - - - - -
- - - -
- - - {#if validationResult && !validationResult.valid} -

- Validation reported issues but you can still create the proxy. -

- {/if} - - - {#if submitError} -

{submitError}

- {/if} - - -
-
- {#if mode === 'edit'} - - {/if} -
- -
- - - -
-
- -
- - -{#if mode === 'edit'} - { deleteConfirmOpen = false; }} - /> -{/if} diff --git a/web/src/lib/components/ProxyGroup.svelte b/web/src/lib/components/ProxyGroup.svelte deleted file mode 100644 index 17cd76d..0000000 --- a/web/src/lib/components/ProxyGroup.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - - -
- - - - - {#if expanded} -
-
- {@render children()} -
-
- {/if} -
diff --git a/web/src/lib/components/SystemHealthCard.svelte b/web/src/lib/components/SystemHealthCard.svelte index d147a3a..3386a8f 100644 --- a/web/src/lib/components/SystemHealthCard.svelte +++ b/web/src/lib/components/SystemHealthCard.svelte @@ -1,16 +1,14 @@ - -{#if loading || result} -
-

- {$t('proxies.validation.title')} -

- - {#if loading && !result} -
- - {$t('proxies.validation.checking')} -
- {:else if result} -
    - {#each result.steps as step} -
  • -
    - {#if step.passed} - - - - {getStepLabel(step.name)} - {#if step.message} - — {step.message} - {/if} - {:else} - - - - {getStepLabel(step.name)} - {#if step.message} - — {step.message} - {/if} - {/if} -
    - {#if !step.passed && step.hint} -

    {step.hint}

    - {/if} -
  • - {/each} -
- {/if} -
-{/if} diff --git a/web/src/lib/components/icons/index.ts b/web/src/lib/components/icons/index.ts index 0bde807..8733fa5 100644 --- a/web/src/lib/components/icons/index.ts +++ b/web/src/lib/components/icons/index.ts @@ -45,7 +45,6 @@ export { default as IconContainer } from './IconContainer.svelte'; export { default as IconHardDrive } from './IconHardDrive.svelte'; export { default as IconWifi } from './IconWifi.svelte'; export { default as IconRefresh } from './IconRefresh.svelte'; -export { default as IconProxies } from './IconProxies.svelte'; export { default as IconEvents } from './IconEvents.svelte'; export { default as IconLogout } from './IconLogout.svelte'; export { default as IconArrowLeft } from './IconArrowLeft.svelte'; diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index f52815e..9f877e4 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -248,7 +248,17 @@ "proxyNoneDesc": "No proxy — containers are accessed directly by port", "proxyNpm": "Nginx Proxy Manager", "proxyNpmDesc": "Routes managed via NPM API (configure credentials below)", - "proxyNoneWarning": "Switching to None does not remove existing proxy routes from NPM. You can delete them manually from your NPM dashboard." + "proxyTraefik": "Traefik", + "proxyTraefikDesc": "Auto-discovery via Docker labels — no API calls needed", + "proxyNoneWarning": "Switching to None does not remove existing proxy routes. You may need to clean them up manually.", + "traefikEntrypoint": "Entrypoint", + "traefikEntrypointHelp": "Traefik entrypoint name for HTTPS routes", + "traefikCertResolver": "Cert Resolver", + "traefikCertResolverHelp": "TLS certificate resolver name (e.g., letsencrypt)", + "traefikNetwork": "Docker Network", + "traefikNetworkHelp": "Network Traefik listens on (leave empty to use global network)", + "traefikApiUrl": "Traefik API URL", + "traefikApiUrlHelp": "Optional — for health checks (e.g., http://traefik:8080)" }, "settingsGeneral": { "title": "General Settings", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 0f0cadf..cbbe6a8 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -248,7 +248,17 @@ "proxyNoneDesc": "Без прокси — контейнеры доступны напрямую по порту", "proxyNpm": "Nginx Proxy Manager", "proxyNpmDesc": "Маршруты через NPM API (настройте учётные данные ниже)", - "proxyNoneWarning": "Переключение на «Нет» не удаляет существующие прокси-маршруты из NPM. Вы можете удалить их вручную в панели NPM." + "proxyTraefik": "Traefik", + "proxyTraefikDesc": "Автообнаружение через Docker-метки — без API-вызовов", + "proxyNoneWarning": "Переключение на «Нет» не удаляет существующие прокси-маршруты. Возможно, потребуется очистить их вручную.", + "traefikEntrypoint": "Точка входа", + "traefikEntrypointHelp": "Имя точки входа Traefik для HTTPS-маршрутов", + "traefikCertResolver": "Резолвер сертификатов", + "traefikCertResolverHelp": "Имя резолвера TLS-сертификатов (напр., letsencrypt)", + "traefikNetwork": "Docker-сеть", + "traefikNetworkHelp": "Сеть, которую слушает Traefik (оставьте пустым для глобальной сети)", + "traefikApiUrl": "URL API Traefik", + "traefikApiUrlHelp": "Необязательно — для проверки состояния (напр., http://traefik:8080)" }, "settingsGeneral": { "title": "Общие настройки", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 168ac2a..626ab00 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -116,6 +116,10 @@ export interface Settings { has_cloudflare_api_token: boolean; cloudflare_zone_id: string; proxy_provider: string; + traefik_entrypoint: string; + traefik_cert_resolver: string; + traefik_network: string; + traefik_api_url: string; backup_enabled: boolean; backup_interval_hours: number; backup_retention_count: number; @@ -265,23 +269,6 @@ export interface EventLogStats { total: number; } -/** A standalone reverse proxy not tied to a project. */ -export interface StandaloneProxy { - id: string; - domain: string; - destination_url: string; - destination_port: number; - ssl_certificate_id: number; - npm_proxy_id: number; - health_status: ProxyHealthStatus; - health_checked_at: string; - created_at: string; - updated_at: string; -} - -/** Health status for a proxy. */ -export type ProxyHealthStatus = 'unknown' | 'healthy' | 'unhealthy'; - /** A container detected as stale by the backend poller. */ export interface StaleContainer { instance: { @@ -303,20 +290,6 @@ export interface StaleContainer { days_stale: number; } -/** A single step in the validation pipeline. */ -export interface ValidationStep { - name: string; - passed: boolean; - message?: string; - hint?: string; -} - -/** Result of the proxy destination validation pipeline. */ -export interface ValidationResult { - valid: boolean; - steps: ValidationStep[]; -} - /** Container CPU and memory stats from the Docker stats API. */ export interface ContainerStats { cpu_percent: number; @@ -325,16 +298,3 @@ export interface ContainerStats { memory_percent: number; } -/** Unified view of standalone + deploy-managed proxies (from /api/proxies/all). */ -export interface ProxyView { - id: string; - domain: string; - destination: string; - type: 'standalone' | 'managed'; - project_name?: string; - stage_name?: string; - health_status: ProxyHealthStatus; - ssl_enabled: boolean; - npm_proxy_id: number; - created_at: string; -} diff --git a/web/src/routes/proxies/+page.svelte b/web/src/routes/proxies/+page.svelte deleted file mode 100644 index f41be1a..0000000 --- a/web/src/routes/proxies/+page.svelte +++ /dev/null @@ -1,232 +0,0 @@ - - - - - {$t('proxies.title')} - {$t('app.name')} - - - -
-
-
- -
-
-

{$t('proxies.title')}

- {#if !loading && proxies.length > 0} -

- {proxies.length} {proxies.length === 1 ? 'proxy' : 'proxies'} -

- {/if} -
-
- - - - {$t('proxies.create')} - -
- - -{#if loading} -
- - {$t('common.loading')} -
-{:else if error} -
-

{error}

- -
-{:else if proxies.length === 0} - - -{:else} - -
- { search = v; }} - onhealthchange={(v) => { healthFilter = v; }} - ontypechange={(v) => { typeFilter = v; }} - onclear={clearFilters} - /> -
- - - {#if filtered().length === 0} -
-

{$t('proxies.noProxies')}

- -
- {:else} -
- - {#if standaloneProxies.length > 0} - - {#each standaloneProxies as proxy (proxy.id)} - - {/each} - - {/if} - - - {#if managedGroups().length > 0} - {#each managedGroups() as group (group.projectName)} - - {#each group.stages as stage (stage.stageName)} - {#if group.stages.length > 1} -
-

- {stage.stageName} -

-
- {/if} - {#each stage.proxies as proxy (proxy.id)} - - {/each} - {/each} -
- {/each} - {/if} -
- {/if} -{/if} diff --git a/web/src/routes/proxies/+page.ts b/web/src/routes/proxies/+page.ts deleted file mode 100644 index 0aef742..0000000 --- a/web/src/routes/proxies/+page.ts +++ /dev/null @@ -1 +0,0 @@ -// Client-side loading — data is fetched in the component via $effect. diff --git a/web/src/routes/proxies/[id]/edit/+page.svelte b/web/src/routes/proxies/[id]/edit/+page.svelte deleted file mode 100644 index a36ce2c..0000000 --- a/web/src/routes/proxies/[id]/edit/+page.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - - - - {$t('proxies.form.editTitle')} - {$t('app.name')} - - - - - - -
-
- -
-

{$t('proxies.form.editTitle')}

-
- -{#if loading} -
- - {$t('common.loading')} -
-{:else if error} - -{:else if proxy} - -
- -
-{/if} diff --git a/web/src/routes/proxies/[id]/edit/+page.ts b/web/src/routes/proxies/[id]/edit/+page.ts deleted file mode 100644 index 12c4939..0000000 --- a/web/src/routes/proxies/[id]/edit/+page.ts +++ /dev/null @@ -1 +0,0 @@ -// Client-side loading — proxy data is fetched in the component. diff --git a/web/src/routes/proxies/create/+page.svelte b/web/src/routes/proxies/create/+page.svelte deleted file mode 100644 index 6a8f9c0..0000000 --- a/web/src/routes/proxies/create/+page.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - - - - {$t('proxies.form.title')} - {$t('app.name')} - - - - - - -
-
- -
-

{$t('proxies.form.title')}

-
- - -
- -
diff --git a/web/src/routes/proxies/create/+page.ts b/web/src/routes/proxies/create/+page.ts deleted file mode 100644 index c480e82..0000000 --- a/web/src/routes/proxies/create/+page.ts +++ /dev/null @@ -1 +0,0 @@ -// Client-side loading — ProxyForm handles data fetching. diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 7b27fc6..5462b8c 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -26,6 +26,12 @@ // Proxy provider state. let proxyProvider = $state('npm'); + // Traefik settings state. + let traefikEntrypoint = $state('websecure'); + let traefikCertResolver = $state('letsencrypt'); + let traefikNetwork = $state(''); + let traefikApiUrl = $state(''); + // DNS settings state. let wildcardDns = $state(true); let dnsProvider = $state(''); @@ -91,6 +97,10 @@ notificationUrl = settings.notification_url ?? ''; staleThresholdDays = String(settings.stale_threshold_days ?? 7); proxyProvider = settings.proxy_provider ?? 'npm'; + traefikEntrypoint = settings.traefik_entrypoint ?? 'websecure'; + traefikCertResolver = settings.traefik_cert_resolver ?? 'letsencrypt'; + traefikNetwork = settings.traefik_network ?? ''; + traefikApiUrl = settings.traefik_api_url ?? ''; wildcardDns = settings.wildcard_dns ?? true; dnsProvider = settings.dns_provider ?? ''; hasCloudflareApiToken = settings.has_cloudflare_api_token ?? false; @@ -118,6 +128,10 @@ subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(), base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(), proxy_provider: proxyProvider, + traefik_entrypoint: traefikEntrypoint.trim() || 'websecure', + traefik_cert_resolver: traefikCertResolver.trim(), + traefik_network: traefikNetwork.trim(), + traefik_api_url: traefikApiUrl.trim(), stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7), wildcard_dns: wildcardDns, dns_provider: wildcardDns ? '' : dnsProvider, @@ -290,12 +304,27 @@

{$t('settings.proxyNpmDesc')}

+ {#if proxyProvider === 'none'}

{$t('settings.proxyNoneWarning')}

{/if} + {#if proxyProvider === 'traefik'} +
+ + + + +
+ {/if}