feat: Cloudflare DNS management with automatic record sync

Add flexible DNS management to Docker Watcher. By default, wildcard DNS
is assumed (current behavior). When disabled, users can configure a
Cloudflare DNS provider with API token and zone selection. DNS A records
are automatically created/updated/deleted in sync with proxy consumers
(deployed instances and standalone proxies).

- Settings: wildcard_dns toggle, dns_provider, cloudflare credentials
- Cloudflare client: Provider interface with EnsureRecord/DeleteRecord/ListRecords
- DNS lifecycle hooks in deployer and proxy manager (best-effort)
- Settings UI: DNS config section with provider picker, zone selector, test button
- DNS Records page at /dns with filtering, sync status, reconciliation
- Records visible in both wildcard and managed modes
- Cleanup on provider change: removes old records when switching modes
This commit is contained in:
2026-04-02 14:49:21 +03:00
parent c9d4895ee3
commit c730cfaa45
46 changed files with 2429 additions and 1260 deletions
+41
View File
@@ -20,6 +20,7 @@ import (
"github.com/alexei/docker-watcher/internal/config"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/deployer"
"github.com/alexei/docker-watcher/internal/dns"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/events"
"github.com/alexei/docker-watcher/internal/health"
@@ -192,10 +193,23 @@ func main() {
}
}()
// Initialize DNS provider from settings (nil for wildcard mode).
dnsProvider := initDNSProvider(settings, encKey)
if dnsProvider != nil {
dep.SetDNSProvider(dnsProvider)
proxyManager.SetDNSProvider(dnsProvider)
slog.Info("DNS provider initialized", "provider", settings.DNSProvider)
}
// Build API server.
apiServer := api.NewServer(db, dockerClient, npmClient, dep, webhookHandler, eventBus, encKey)
apiServer.SetStaleScanner(staleScanner)
apiServer.SetProxyManager(proxyManager)
apiServer.SetDNSProvider(dnsProvider)
apiServer.SetDNSProviderChangedCallback(func(provider dns.Provider) {
dep.SetDNSProvider(provider)
proxyManager.SetDNSProvider(provider)
})
router := apiServer.Router()
// Serve embedded static files for the SPA frontend.
@@ -309,3 +323,30 @@ func ensureDefaultAdmin(db *store.Store) error {
slog.Info("default admin user created", "username", "admin")
return nil
}
// initDNSProvider creates a DNS provider from settings. Returns nil for wildcard mode.
func initDNSProvider(settings store.Settings, encKey [32]byte) dns.Provider {
if settings.WildcardDNS || settings.DNSProvider == "" {
return nil
}
token := settings.CloudflareAPIToken
if token != "" {
decrypted, err := crypto.Decrypt(encKey, token)
if err != nil {
slog.Error("dns: failed to decrypt API token", "error", err)
return nil
}
token = decrypted
}
provider, err := dns.NewProvider(settings.DNSProvider, dns.Config{
Token: token,
ZoneID: settings.CloudflareZoneID,
})
if err != nil {
slog.Error("dns: failed to create provider", "error", err)
return nil
}
return provider
}
+365
View File
@@ -0,0 +1,365 @@
package api
import (
"log/slog"
"net/http"
"strings"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/dns"
"github.com/alexei/docker-watcher/internal/store"
"github.com/go-chi/chi/v5"
)
// dnsRecordView is the response format for DNS records with consumer context.
type dnsRecordView struct {
FQDN string `json:"fqdn"`
Type string `json:"type"`
Content string `json:"content"`
ConsumerType string `json:"consumer_type"`
ConsumerName string `json:"consumer_name"`
ConsumerID string `json:"consumer_id"`
Status string `json:"status"` // "synced", "orphaned", "missing"
}
// listDNSRecords handles GET /api/dns/records.
// In managed DNS mode: merges local dns_records with actual Cloudflare records to compute sync status.
// In wildcard mode: shows all expected FQDNs from active consumers (informational, no sync status).
func (s *Server) listDNSRecords(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
consumerNames := s.buildConsumerNameMap()
// In wildcard mode, show expected records from consumers without sync status.
if settings.WildcardDNS {
expectedFQDNs := s.computeExpectedFQDNs(settings)
var views []dnsRecordView
for fqdn, consumer := range expectedFQDNs {
parts := strings.SplitN(consumer, ":", 2)
consumerType, consumerID := parts[0], ""
if len(parts) > 1 {
consumerID = parts[1]
}
name := consumerNames[consumer]
if name == "" {
name = consumerID
}
views = append(views, dnsRecordView{
FQDN: fqdn,
Type: "A",
Content: settings.ServerIP,
ConsumerType: consumerType,
ConsumerName: name,
ConsumerID: consumerID,
Status: "wildcard",
})
}
if views == nil {
views = []dnsRecordView{}
}
respondJSON(w, http.StatusOK, views)
return
}
// Managed DNS mode: full sync status computation.
// Get local tracked records.
localRecords, err := s.store.ListDNSRecords()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list local records: "+err.Error())
return
}
// Try to get actual records from the DNS provider.
var providerRecords []dns.Record
provider := s.getOrCreateDNSProvider(settings)
if provider != nil {
providerRecords, err = provider.ListRecords(r.Context())
if err != nil {
slog.Warn("dns records: failed to list provider records", "error", err)
// Continue with local-only view.
}
}
// Build a map of provider records by FQDN.
providerByFQDN := make(map[string]dns.Record, len(providerRecords))
for _, rec := range providerRecords {
providerByFQDN[rec.FQDN] = rec
}
// Build a set of local FQDNs.
localFQDNs := make(map[string]bool, len(localRecords))
for _, rec := range localRecords {
localFQDNs[rec.FQDN] = true
}
var views []dnsRecordView
// Process local records: check if they exist in provider.
for _, local := range localRecords {
status := "missing"
content := settings.ServerIP
if pRec, ok := providerByFQDN[local.FQDN]; ok {
status = "synced"
content = pRec.Content
}
name := consumerNames[local.ConsumerType+":"+local.ConsumerID]
if name == "" {
name = local.ConsumerID
}
views = append(views, dnsRecordView{
FQDN: local.FQDN,
Type: "A",
Content: content,
ConsumerType: local.ConsumerType,
ConsumerName: name,
ConsumerID: local.ConsumerID,
Status: status,
})
}
// Find orphaned records: in provider but not in local tracking.
for _, pRec := range providerRecords {
if !localFQDNs[pRec.FQDN] {
views = append(views, dnsRecordView{
FQDN: pRec.FQDN,
Type: pRec.Type,
Content: pRec.Content,
ConsumerType: "",
ConsumerName: "",
ConsumerID: "",
Status: "orphaned",
})
}
}
if views == nil {
views = []dnsRecordView{}
}
respondJSON(w, http.StatusOK, views)
}
// deleteDNSRecord handles DELETE /api/dns/records/{fqdn}.
func (s *Server) deleteDNSRecord(w http.ResponseWriter, r *http.Request) {
fqdn := chi.URLParam(r, "fqdn")
if fqdn == "" {
respondError(w, http.StatusBadRequest, "fqdn is required")
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
provider := s.getOrCreateDNSProvider(settings)
if provider != nil {
if err := provider.DeleteRecord(r.Context(), fqdn); err != nil {
respondError(w, http.StatusBadGateway, "failed to delete DNS record: "+err.Error())
return
}
}
// Remove local tracking.
if err := s.store.DeleteDNSRecord(fqdn); err != nil {
slog.Warn("delete dns tracking record", "fqdn", fqdn, "error", err)
}
respondJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// buildConsumerNameMap builds a lookup of "type:id" -> display name for DNS consumers.
func (s *Server) buildConsumerNameMap() map[string]string {
names := make(map[string]string)
// Instance consumers: "instance:id" -> "project/stage:tag"
projects, _ := s.store.GetAllProjects()
projectNames := make(map[string]string, len(projects))
for _, p := range projects {
projectNames[p.ID] = p.Name
}
for _, p := range projects {
stages, _ := s.store.GetStagesByProjectID(p.ID)
for _, st := range stages {
instances, _ := s.store.GetInstancesByStageID(st.ID)
for _, inst := range instances {
names["instance:"+inst.ID] = p.Name + "/" + st.Name + ":" + inst.ImageTag
}
}
}
// Standalone proxy consumers: "standalone:id" -> domain
proxies, _ := s.store.ListStandaloneProxies()
for _, p := range proxies {
names["standalone:"+p.ID] = p.Domain
}
return names
}
// getOrCreateDNSProvider returns the server's DNS provider, or creates a temporary one from settings.
func (s *Server) getOrCreateDNSProvider(settings store.Settings) dns.Provider {
if s.dnsProvider != nil {
return s.dnsProvider
}
if settings.WildcardDNS || settings.DNSProvider == "" || settings.CloudflareAPIToken == "" {
return nil
}
token, err := crypto.Decrypt(s.encKey, settings.CloudflareAPIToken)
if err != nil {
slog.Warn("dns: failed to decrypt token for provider creation", "error", err)
return nil
}
provider, err := dns.NewProvider(settings.DNSProvider, dns.Config{
Token: token,
ZoneID: settings.CloudflareZoneID,
})
if err != nil {
slog.Warn("dns: failed to create provider", "error", err)
return nil
}
return provider
}
// syncDNSRecords handles POST /api/dns/sync.
func (s *Server) syncDNSRecords(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
if settings.WildcardDNS {
respondError(w, http.StatusBadRequest, "DNS sync is disabled in wildcard mode")
return
}
provider := s.getOrCreateDNSProvider(settings)
if provider == nil {
respondError(w, http.StatusBadRequest, "DNS provider not configured")
return
}
// Compute expected FQDNs from active consumers.
expectedFQDNs := s.computeExpectedFQDNs(settings)
// Get actual provider records.
providerRecords, err := provider.ListRecords(r.Context())
if err != nil {
respondError(w, http.StatusBadGateway, "failed to list DNS records: "+err.Error())
return
}
providerByFQDN := make(map[string]dns.Record, len(providerRecords))
for _, rec := range providerRecords {
providerByFQDN[rec.FQDN] = rec
}
// Get local tracking records.
localRecords, err := s.store.ListDNSRecords()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list local records: "+err.Error())
return
}
localByFQDN := make(map[string]bool, len(localRecords))
for _, rec := range localRecords {
localByFQDN[rec.FQDN] = true
}
created := 0
deleted := 0
alreadySynced := 0
// Create missing records.
for fqdn, consumer := range expectedFQDNs {
if _, exists := providerByFQDN[fqdn]; exists {
alreadySynced++
continue
}
recordID, err := provider.EnsureRecord(r.Context(), fqdn, settings.ServerIP)
if err != nil {
slog.Warn("dns sync: failed to create record", "fqdn", fqdn, "error", err)
continue
}
// Track locally.
parts := strings.SplitN(consumer, ":", 2)
consumerType, consumerID := parts[0], ""
if len(parts) > 1 {
consumerID = parts[1]
}
if _, err := s.store.CreateDNSRecord(store.DNSRecord{
FQDN: fqdn,
ProviderRecordID: recordID,
ConsumerType: consumerType,
ConsumerID: consumerID,
}); err != nil {
s.store.UpdateDNSRecordProviderID(fqdn, recordID)
}
created++
}
// Delete orphaned records (in provider + tracked locally, but no active consumer).
for _, local := range localRecords {
if _, expected := expectedFQDNs[local.FQDN]; !expected {
if err := provider.DeleteRecord(r.Context(), local.FQDN); err != nil {
slog.Warn("dns sync: failed to delete orphaned record", "fqdn", local.FQDN, "error", err)
continue
}
s.store.DeleteDNSRecord(local.FQDN)
deleted++
}
}
respondJSON(w, http.StatusOK, map[string]int{
"created": created,
"deleted": deleted,
"already_synced": alreadySynced,
})
}
// computeExpectedFQDNs returns a map of FQDN -> "consumerType:consumerID" for all active DNS consumers.
func (s *Server) computeExpectedFQDNs(settings store.Settings) map[string]string {
expected := make(map[string]string)
// Instances with proxy enabled.
projects, _ := s.store.GetAllProjects()
for _, p := range projects {
stages, _ := s.store.GetStagesByProjectID(p.ID)
for _, st := range stages {
if !st.EnableProxy {
continue
}
instances, _ := s.store.GetInstancesByStageID(st.ID)
for _, inst := range instances {
if inst.NpmProxyID > 0 && inst.Subdomain != "" && inst.Status == "running" {
fqdn := inst.Subdomain + "." + settings.Domain
expected[fqdn] = "instance:" + inst.ID
}
}
}
}
// Standalone proxies.
proxies, _ := s.store.ListStandaloneProxies()
for _, p := range proxies {
if p.Domain != "" {
expected[p.Domain] = "standalone:" + p.ID
}
}
return expected
}
+25
View File
@@ -8,6 +8,7 @@ import (
"github.com/alexei/docker-watcher/internal/auth"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/dns"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/events"
"github.com/alexei/docker-watcher/internal/npm"
@@ -17,6 +18,10 @@ import (
"github.com/alexei/docker-watcher/internal/webhook"
)
// DNSProviderChangedFunc is called when DNS settings change so the caller can
// update the provider on the deployer and proxy manager.
type DNSProviderChangedFunc func(provider dns.Provider)
// Server holds all dependencies for the API layer.
type Server struct {
store *store.Store
@@ -30,6 +35,9 @@ type Server struct {
oidcProvider *auth.OIDCProvider
staleScanner *stale.Scanner
proxyManager *proxy.Manager
dnsProvider dns.Provider
onDNSProviderChanged DNSProviderChangedFunc
}
// NewServer creates a new API Server with all required dependencies.
@@ -76,6 +84,16 @@ func (s *Server) SetProxyManager(pm *proxy.Manager) {
s.proxyManager = pm
}
// SetDNSProvider sets the current DNS provider on the server.
func (s *Server) SetDNSProvider(provider dns.Provider) {
s.dnsProvider = provider
}
// SetDNSProviderChangedCallback sets the callback for when DNS settings change.
func (s *Server) SetDNSProviderChangedCallback(fn DNSProviderChangedFunc) {
s.onDNSProviderChanged = fn
}
// initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal.
func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) {
// Decrypt the OIDC client secret if it's encrypted.
@@ -251,6 +269,13 @@ func (s *Server) Router() chi.Router {
r.Put("/settings", s.updateSettings)
r.Get("/settings/webhook-url", s.getWebhookURL)
r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret)
// DNS management endpoints.
r.Post("/settings/dns/test", s.testDNSConnection)
r.Get("/settings/dns/zones", s.listDNSZones)
r.Get("/dns/records", s.listDNSRecords)
r.Post("/dns/sync", s.syncDNSRecords)
r.Delete("/dns/records/{fqdn}", s.deleteDNSRecord)
})
})
})
+204 -13
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/dns"
"github.com/alexei/docker-watcher/internal/npm"
"github.com/alexei/docker-watcher/internal/store"
"github.com/alexei/docker-watcher/internal/volume"
@@ -29,6 +30,10 @@ type settingsRequest struct {
SSLCertificateID *int `json:"ssl_certificate_id,omitempty"`
StaleThresholdDays *int `json:"stale_threshold_days,omitempty"`
AllowedVolumePaths *string `json:"allowed_volume_paths,omitempty"`
WildcardDNS *bool `json:"wildcard_dns,omitempty"`
DNSProvider *string `json:"dns_provider,omitempty"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"`
}
// getSettings handles GET /api/settings.
@@ -41,19 +46,23 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
// Return settings without sensitive fields.
respondJSON(w, http.StatusOK, map[string]any{
"domain": settings.Domain,
"server_ip": settings.ServerIP,
"network": settings.Network,
"subdomain_pattern": settings.SubdomainPattern,
"notification_url": settings.NotificationURL,
"npm_url": settings.NpmURL,
"npm_email": settings.NpmEmail,
"has_npm_password": settings.NpmPassword != "",
"polling_interval": settings.PollingInterval,
"ssl_certificate_id": settings.SSLCertificateID,
"stale_threshold_days": settings.StaleThresholdDays,
"allowed_volume_paths": settings.AllowedVolumePaths,
"updated_at": settings.UpdatedAt,
"domain": settings.Domain,
"server_ip": settings.ServerIP,
"network": settings.Network,
"subdomain_pattern": settings.SubdomainPattern,
"notification_url": settings.NotificationURL,
"npm_url": settings.NpmURL,
"npm_email": settings.NpmEmail,
"has_npm_password": settings.NpmPassword != "",
"polling_interval": settings.PollingInterval,
"ssl_certificate_id": settings.SSLCertificateID,
"stale_threshold_days": settings.StaleThresholdDays,
"allowed_volume_paths": settings.AllowedVolumePaths,
"wildcard_dns": settings.WildcardDNS,
"dns_provider": settings.DNSProvider,
"has_cloudflare_api_token": settings.CloudflareAPIToken != "",
"cloudflare_zone_id": settings.CloudflareZoneID,
"updated_at": settings.UpdatedAt,
})
}
@@ -132,6 +141,25 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
_ = paths // validated
}
// DNS settings.
if req.WildcardDNS != nil {
updated.WildcardDNS = *req.WildcardDNS
}
if req.DNSProvider != nil {
updated.DNSProvider = *req.DNSProvider
}
if req.CloudflareAPIToken != "" {
encToken, err := crypto.Encrypt(s.encKey, req.CloudflareAPIToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt cloudflare api token: "+err.Error())
return
}
updated.CloudflareAPIToken = encToken
}
if req.CloudflareZoneID != nil {
updated.CloudflareZoneID = *req.CloudflareZoneID
}
if err := s.store.UpdateSettings(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
return
@@ -142,6 +170,15 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
go s.reapplySSLToAllProxies(updated)
}
// Handle DNS provider changes.
dnsChanged := existing.WildcardDNS != updated.WildcardDNS ||
existing.DNSProvider != updated.DNSProvider ||
existing.CloudflareZoneID != updated.CloudflareZoneID ||
(req.CloudflareAPIToken != "" && req.CloudflareAPIToken != "unchanged")
if dnsChanged {
go s.handleDNSSettingsChange(existing, updated)
}
respondJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
@@ -334,3 +371,157 @@ func (s *Server) reapplySSLToAllProxies(settings store.Settings) {
slog.Info("reapply SSL: completed", "updated", updated, "total_managed", len(managedProxyIDs))
}
// handleDNSSettingsChange reacts to DNS configuration changes:
// - If switching to wildcard mode: remove all managed DNS records from the provider.
// - If switching provider or credentials: remove old records, create new provider, re-sync.
func (s *Server) handleDNSSettingsChange(oldSettings, newSettings store.Settings) {
ctx := context.Background()
// Step 1: If there was an old provider, remove all managed DNS records from it.
if !oldSettings.WildcardDNS && oldSettings.DNSProvider != "" && s.dnsProvider != nil {
records, err := s.store.ListDNSRecords()
if err != nil {
slog.Error("dns settings change: list records for cleanup", "error", err)
} else {
for _, rec := range records {
if err := s.dnsProvider.DeleteRecord(ctx, rec.FQDN); err != nil {
slog.Warn("dns settings change: delete old record", "fqdn", rec.FQDN, "error", err)
}
if err := s.store.DeleteDNSRecord(rec.FQDN); err != nil {
slog.Warn("dns settings change: remove tracking record", "fqdn", rec.FQDN, "error", err)
}
}
slog.Info("dns settings change: cleaned up old records", "count", len(records))
}
}
// Step 2: Create new provider (or nil for wildcard mode).
var newProvider dns.Provider
if !newSettings.WildcardDNS && newSettings.DNSProvider != "" {
token := newSettings.CloudflareAPIToken
if token != "" {
decrypted, err := crypto.Decrypt(s.encKey, token)
if err != nil {
slog.Error("dns settings change: decrypt token", "error", err)
return
}
token = decrypted
}
provider, err := dns.NewProvider(newSettings.DNSProvider, dns.Config{
Token: token,
ZoneID: newSettings.CloudflareZoneID,
})
if err != nil {
slog.Error("dns settings change: create provider", "error", err)
return
}
newProvider = provider
}
// Step 3: Update the server's DNS provider and notify dependents.
s.dnsProvider = newProvider
if s.onDNSProviderChanged != nil {
s.onDNSProviderChanged(newProvider)
}
slog.Info("dns settings change: provider updated",
"wildcard", newSettings.WildcardDNS,
"provider", newSettings.DNSProvider)
}
// dnsTestRequest is the expected JSON body for testing DNS provider credentials.
type dnsTestRequest struct {
Provider string `json:"provider"`
Token string `json:"token"`
ZoneID string `json:"zone_id"`
}
// testDNSConnection handles POST /api/settings/dns/test.
func (s *Server) testDNSConnection(w http.ResponseWriter, r *http.Request) {
var req dnsTestRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Provider != "cloudflare" {
respondError(w, http.StatusBadRequest, "unsupported DNS provider: "+req.Provider)
return
}
token := req.Token
// If no token provided, use the stored one.
if token == "" {
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
if settings.CloudflareAPIToken == "" {
respondError(w, http.StatusBadRequest, "no Cloudflare API token configured")
return
}
decrypted, err := crypto.Decrypt(s.encKey, settings.CloudflareAPIToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to decrypt token: "+err.Error())
return
}
token = decrypted
}
provider, err := dns.NewCloudflare(token, req.ZoneID)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid configuration: "+err.Error())
return
}
if err := provider.TestConnection(r.Context()); err != nil {
respondJSON(w, http.StatusOK, map[string]any{
"success": false,
"error": err.Error(),
})
return
}
respondJSON(w, http.StatusOK, map[string]any{
"success": true,
})
}
// listDNSZones handles GET /api/settings/dns/zones.
func (s *Server) listDNSZones(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
// If no token in query, use stored one.
if token == "" {
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
if settings.CloudflareAPIToken == "" {
respondError(w, http.StatusBadRequest, "no Cloudflare API token configured")
return
}
decrypted, err := crypto.Decrypt(s.encKey, settings.CloudflareAPIToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to decrypt token: "+err.Error())
return
}
token = decrypted
}
provider, err := dns.NewCloudflare(token, "")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid configuration: "+err.Error())
return
}
zones, err := provider.ListZones(r.Context())
if err != nil {
respondError(w, http.StatusBadGateway, "failed to list zones: "+err.Error())
return
}
respondJSON(w, http.StatusOK, zones)
}
+4
View File
@@ -156,6 +156,10 @@ func (d *Deployer) blueGreenDeploy(
inst.NpmProxyID = npmProxyID
d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info")
// Create/update DNS record for the green instance.
fqdn := subdomain + "." + settings.Domain
d.ensureDNS(ctx, fqdn, "instance", instanceID, deployID)
} else {
d.logDeploy(deployID, "Blue-green: proxy skipped (disabled for this stage)", "info")
}
+90
View File
@@ -10,6 +10,7 @@ import (
"sync/atomic"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/dns"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/events"
"github.com/alexei/docker-watcher/internal/health"
@@ -32,6 +33,7 @@ type Deployer struct {
notifier *notify.Notifier
eventBus EventPublisher
encKey [32]byte
dns dns.Provider // nil when wildcard DNS is active
// Graceful shutdown: tracks in-progress deploys.
activeWg sync.WaitGroup
@@ -64,6 +66,12 @@ func New(
}
}
// SetDNSProvider sets the DNS provider for managing DNS records during deployments.
// Pass nil to disable DNS management (wildcard DNS mode).
func (d *Deployer) SetDNSProvider(provider dns.Provider) {
d.dns = provider
}
// Drain waits for all in-progress deploys to complete. Call this during graceful shutdown.
func (d *Deployer) Drain() {
d.shuttingDown.Store(true)
@@ -357,6 +365,10 @@ func (d *Deployer) executeDeploy(
if err := d.store.UpdateInstance(inst); err != nil {
slog.Warn("update instance with proxy ID", "error", err)
}
// Create DNS record for this instance.
fqdn := subdomain + "." + settings.Domain
d.ensureDNS(ctx, fqdn, "instance", instanceID, deployID)
} else {
d.logDeploy(deployID, "Proxy creation skipped (disabled for this stage)", "info")
inst.Subdomain = subdomain
@@ -526,6 +538,12 @@ func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, sett
} else if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil {
slog.Warn("delete proxy host", "proxy_id", inst.NpmProxyID, "error", delErr)
}
// Remove DNS record for this instance.
if inst.Subdomain != "" && settings.Domain != "" {
fqdn := inst.Subdomain + "." + settings.Domain
d.removeDNS(ctx, fqdn, "")
}
}
// Delete instance record.
@@ -724,6 +742,78 @@ func (d *Deployer) publishInstanceStatus(instanceID, projectID, stageID, status
}
}
// ensureDNS creates or updates a DNS record for the given FQDN. Best-effort: logs warnings on failure.
func (d *Deployer) ensureDNS(ctx context.Context, fqdn, consumerType, consumerID, deployID string) {
if d.dns == nil {
return
}
settings, err := d.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", "fqdn", fqdn)
return
}
recordID, err := d.dns.EnsureRecord(ctx, fqdn, settings.ServerIP)
if err != nil {
msg := fmt.Sprintf("DNS: failed to create/update record for %s: %v", fqdn, err)
slog.Warn(msg)
if deployID != "" {
d.logDeploy(deployID, msg, "warn")
}
return
}
// Track the record locally.
if _, err := d.store.CreateDNSRecord(store.DNSRecord{
FQDN: fqdn,
ProviderRecordID: recordID,
ConsumerType: consumerType,
ConsumerID: consumerID,
}); err != nil {
// May already exist — try updating.
if updateErr := d.store.UpdateDNSRecordProviderID(fqdn, recordID); updateErr != nil {
slog.Warn("dns: failed to track record", "fqdn", fqdn, "error", updateErr)
}
}
logMsg := fmt.Sprintf("DNS: record ensured for %s", fqdn)
slog.Info(logMsg)
if deployID != "" {
d.logDeploy(deployID, logMsg, "info")
}
}
// removeDNS deletes a DNS record for the given FQDN. Best-effort: logs warnings on failure.
func (d *Deployer) removeDNS(ctx context.Context, fqdn, deployID string) {
if d.dns == nil {
return
}
if err := d.dns.DeleteRecord(ctx, fqdn); err != nil {
msg := fmt.Sprintf("DNS: failed to delete record for %s: %v", fqdn, err)
slog.Warn(msg)
if deployID != "" {
d.logDeploy(deployID, msg, "warn")
}
return
}
// Remove local tracking.
if err := d.store.DeleteDNSRecord(fqdn); err != nil {
slog.Warn("dns: failed to remove tracking record", "fqdn", fqdn, "error", err)
}
logMsg := fmt.Sprintf("DNS: record deleted for %s", fqdn)
slog.Info(logMsg)
if deployID != "" {
d.logDeploy(deployID, logMsg, "info")
}
}
// truncateID safely truncates a Docker ID to 12 characters for display.
func truncateID(id string) string {
if len(id) > 12 {
+12
View File
@@ -42,6 +42,18 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st
}
}
// Clean up DNS record if the instance had a subdomain.
if instanceID != "" {
inst, err := d.store.GetInstanceByID(instanceID)
if err == nil && inst.Subdomain != "" {
settings, _ := d.store.GetSettings()
if settings.Domain != "" {
fqdn := inst.Subdomain + "." + settings.Domain
d.removeDNS(ctx, fqdn, deployID)
}
}
}
// Update instance status to failed if it was created.
if instanceID != "" {
if err := d.store.UpdateInstanceStatus(instanceID, "failed"); err != nil {
+367
View File
@@ -0,0 +1,367 @@
package dns
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
const cfBaseURL = "https://api.cloudflare.com/client/v4"
// Cloudflare implements the Provider interface using the Cloudflare API v4.
type Cloudflare struct {
token string
zoneID string
client *http.Client
}
// NewCloudflare creates a new Cloudflare DNS provider.
// token is required. zoneID can be empty for ListZones/TestConnection calls.
func NewCloudflare(token, zoneID string) (*Cloudflare, error) {
if token == "" {
return nil, fmt.Errorf("cloudflare API token is required")
}
return &Cloudflare{
token: token,
zoneID: zoneID,
client: &http.Client{Timeout: 30 * time.Second},
}, nil
}
// --- Provider interface ---
// EnsureRecord creates or updates an A record for the given FQDN.
func (c *Cloudflare) EnsureRecord(ctx context.Context, fqdn, ip string) (string, error) {
if c.zoneID == "" {
return "", fmt.Errorf("zone ID is required for DNS operations")
}
// Check if a record already exists.
existing, err := c.findRecord(ctx, fqdn)
if err != nil {
return "", fmt.Errorf("find existing record: %w", err)
}
if existing != nil {
// Record exists — update if IP differs.
if existing.Content == ip {
return existing.ID, nil // already correct, no-op
}
updated, err := c.updateRecord(ctx, existing.ID, fqdn, ip)
if err != nil {
return "", fmt.Errorf("update record: %w", err)
}
return updated.ID, nil
}
// Record doesn't exist — create it.
created, err := c.createRecord(ctx, fqdn, ip)
if err != nil {
return "", fmt.Errorf("create record: %w", err)
}
return created.ID, nil
}
// DeleteRecord removes an A record by FQDN. Returns nil if not found.
func (c *Cloudflare) DeleteRecord(ctx context.Context, fqdn string) error {
if c.zoneID == "" {
return fmt.Errorf("zone ID is required for DNS operations")
}
existing, err := c.findRecord(ctx, fqdn)
if err != nil {
return fmt.Errorf("find record: %w", err)
}
if existing == nil {
return nil // doesn't exist, nothing to delete
}
endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfBaseURL, c.zoneID, existing.ID)
if _, err := c.doRequest(ctx, http.MethodDelete, endpoint, nil); err != nil {
return fmt.Errorf("delete record: %w", err)
}
return nil
}
// ListRecords returns all A records in the zone.
func (c *Cloudflare) ListRecords(ctx context.Context) ([]Record, error) {
if c.zoneID == "" {
return nil, fmt.Errorf("zone ID is required for DNS operations")
}
var allRecords []Record
page := 1
for {
endpoint := fmt.Sprintf("%s/zones/%s/dns_records?type=A&page=%d&per_page=100", cfBaseURL, c.zoneID, page)
body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("list records: %w", err)
}
var resp cfListResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode list response: %w", err)
}
for _, r := range resp.Result {
allRecords = append(allRecords, Record{
ID: r.ID,
FQDN: r.Name,
Type: r.Type,
Content: r.Content,
TTL: r.TTL,
Proxied: r.Proxied,
})
}
if page >= resp.ResultInfo.TotalPages {
break
}
page++
}
return allRecords, nil
}
// TestConnection verifies the API token is valid.
func (c *Cloudflare) TestConnection(ctx context.Context) error {
endpoint := cfBaseURL + "/user/tokens/verify"
body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("verify token: %w", err)
}
var resp cfBaseResponse
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("decode verify response: %w", err)
}
if !resp.Success {
return fmt.Errorf("token verification failed: %s", formatErrors(resp.Errors))
}
return nil
}
// --- Additional methods (not part of Provider interface) ---
// ListZones returns all zones accessible by the token.
func (c *Cloudflare) ListZones(ctx context.Context) ([]Zone, error) {
var allZones []Zone
page := 1
for {
endpoint := fmt.Sprintf("%s/zones?page=%d&per_page=50&status=active", cfBaseURL, page)
body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("list zones: %w", err)
}
var resp cfZonesResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode zones response: %w", err)
}
for _, z := range resp.Result {
allZones = append(allZones, Zone{
ID: z.ID,
Name: z.Name,
})
}
if page >= resp.ResultInfo.TotalPages {
break
}
page++
}
return allZones, nil
}
// --- Internal helpers ---
func (c *Cloudflare) findRecord(ctx context.Context, fqdn string) (*cfDNSRecord, error) {
endpoint := fmt.Sprintf("%s/zones/%s/dns_records?type=A&name=%s",
cfBaseURL, c.zoneID, url.QueryEscape(fqdn))
body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
var resp cfListResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode find response: %w", err)
}
if len(resp.Result) == 0 {
return nil, nil
}
return &resp.Result[0], nil
}
func (c *Cloudflare) createRecord(ctx context.Context, fqdn, ip string) (*cfDNSRecord, error) {
payload := cfDNSRecordRequest{
Type: "A",
Name: fqdn,
Content: ip,
TTL: 1, // auto
Proxied: false,
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal create payload: %w", err)
}
endpoint := fmt.Sprintf("%s/zones/%s/dns_records", cfBaseURL, c.zoneID)
body, err := c.doRequest(ctx, http.MethodPost, endpoint, data)
if err != nil {
return nil, err
}
var resp cfSingleResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode create response: %w", err)
}
if !resp.Success {
return nil, fmt.Errorf("create failed: %s", formatErrors(resp.Errors))
}
return &resp.Result, nil
}
func (c *Cloudflare) updateRecord(ctx context.Context, recordID, fqdn, ip string) (*cfDNSRecord, error) {
payload := cfDNSRecordRequest{
Type: "A",
Name: fqdn,
Content: ip,
TTL: 1,
Proxied: false,
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal update payload: %w", err)
}
endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfBaseURL, c.zoneID, recordID)
body, err := c.doRequest(ctx, http.MethodPut, endpoint, data)
if err != nil {
return nil, err
}
var resp cfSingleResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode update response: %w", err)
}
if !resp.Success {
return nil, fmt.Errorf("update failed: %s", formatErrors(resp.Errors))
}
return &resp.Result, nil
}
func (c *Cloudflare) doRequest(ctx context.Context, method, endpoint string, payload []byte) ([]byte, error) {
var bodyReader io.Reader
if payload != nil {
bodyReader = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 400 {
var errResp cfBaseResponse
if json.Unmarshal(body, &errResp) == nil && len(errResp.Errors) > 0 {
return nil, fmt.Errorf("cloudflare API error (%d): %s", resp.StatusCode, formatErrors(errResp.Errors))
}
return nil, fmt.Errorf("cloudflare API error (%d): %s", resp.StatusCode, string(body))
}
return body, nil
}
// --- Cloudflare API response types ---
type cfBaseResponse struct {
Success bool `json:"success"`
Errors []cfError `json:"errors"`
}
type cfError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type cfDNSRecord struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Proxied bool `json:"proxied"`
}
type cfDNSRecordRequest struct {
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Proxied bool `json:"proxied"`
}
type cfResultInfo struct {
TotalPages int `json:"total_pages"`
}
type cfListResponse struct {
cfBaseResponse
Result []cfDNSRecord `json:"result"`
ResultInfo cfResultInfo `json:"result_info"`
}
type cfSingleResponse struct {
cfBaseResponse
Result cfDNSRecord `json:"result"`
}
type cfZone struct {
ID string `json:"id"`
Name string `json:"name"`
}
type cfZonesResponse struct {
cfBaseResponse
Result []cfZone `json:"result"`
ResultInfo cfResultInfo `json:"result_info"`
}
func formatErrors(errs []cfError) string {
if len(errs) == 0 {
return "unknown error"
}
msg := errs[0].Message
for _, e := range errs[1:] {
msg += "; " + e.Message
}
return msg
}
+22
View File
@@ -0,0 +1,22 @@
package dns
import "fmt"
// Config holds configuration for creating a DNS provider.
type Config struct {
Token string
ZoneID string
}
// NewProvider creates a DNS provider by name.
// Returns nil, nil when providerName is empty (wildcard DNS mode).
func NewProvider(providerName string, cfg Config) (Provider, error) {
switch providerName {
case "":
return nil, nil
case "cloudflare":
return NewCloudflare(cfg.Token, cfg.ZoneID)
default:
return nil, fmt.Errorf("unsupported DNS provider: %s", providerName)
}
}
+34
View File
@@ -0,0 +1,34 @@
package dns
import "context"
// Record represents a DNS record from a provider.
type Record struct {
ID string `json:"id"`
FQDN string `json:"fqdn"`
Type string `json:"type"`
Content string `json:"content"` // IP address for A records
TTL int `json:"ttl"`
Proxied bool `json:"proxied"`
}
// Zone represents a DNS zone from a provider.
type Zone struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Provider is the interface for DNS record management.
type Provider interface {
// EnsureRecord creates an A record if it doesn't exist, or updates it if the IP differs.
EnsureRecord(ctx context.Context, fqdn, ip string) (recordID string, err error)
// DeleteRecord removes an A record by FQDN. No error if it doesn't exist.
DeleteRecord(ctx context.Context, fqdn string) error
// ListRecords returns all A records in the zone.
ListRecords(ctx context.Context) ([]Record, error)
// TestConnection verifies that the provider credentials are valid.
TestConnection(ctx context.Context) error
}
+69
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"github.com/alexei/docker-watcher/internal/dns"
"github.com/alexei/docker-watcher/internal/npm"
"github.com/alexei/docker-watcher/internal/store"
)
@@ -14,6 +15,7 @@ import (
type Manager struct {
store *store.Store
npm *npm.Client
dns dns.Provider // nil when wildcard DNS is active
}
// NewManager creates a new proxy manager.
@@ -24,6 +26,11 @@ func NewManager(st *store.Store, npmClient *npm.Client) *Manager {
}
}
// SetDNSProvider sets the DNS provider for managing DNS records.
func (m *Manager) SetDNSProvider(provider dns.Provider) {
m.dns = provider
}
// CreateProxyRequest is the input for creating a standalone proxy.
type CreateProxyRequest struct {
Domain string `json:"domain"`
@@ -108,6 +115,9 @@ func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (stor
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
}
@@ -160,6 +170,12 @@ func (m *Manager) UpdateProxy(ctx context.Context, id string, req UpdateProxyReq
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)
}
@@ -179,6 +195,9 @@ func (m *Manager) DeleteProxy(ctx context.Context, id string) error {
}
}
// Remove DNS record.
m.removeDNS(ctx, proxy.Domain)
if err := m.store.DeleteStandaloneProxy(id); err != nil {
return fmt.Errorf("delete standalone proxy: %w", err)
}
@@ -294,6 +313,56 @@ func (m *Manager) ListAllProxies() ([]ProxyView, error) {
return views, nil
}
// ensureDNS creates or updates a DNS record for a standalone proxy domain. Best-effort.
func (m *Manager) ensureDNS(ctx context.Context, domain, proxyID string) {
if m.dns == nil {
return
}
settings, err := m.store.GetSettings()
if err != nil {
slog.Warn("dns: get settings for server IP", "error", err)
return
}
if settings.ServerIP == "" {
slog.Warn("dns: server IP not configured, skipping DNS record creation", "domain", domain)
return
}
recordID, err := m.dns.EnsureRecord(ctx, domain, settings.ServerIP)
if err != nil {
slog.Warn("dns: failed to create/update record for standalone proxy", "domain", domain, "error", err)
return
}
if _, err := m.store.CreateDNSRecord(store.DNSRecord{
FQDN: domain,
ProviderRecordID: recordID,
ConsumerType: "standalone",
ConsumerID: proxyID,
}); err != nil {
// May already exist — try updating.
if updateErr := m.store.UpdateDNSRecordProviderID(domain, recordID); updateErr != nil {
slog.Warn("dns: failed to track record", "domain", domain, "error", updateErr)
}
}
slog.Info("dns: record ensured for standalone proxy", "domain", domain)
}
// removeDNS deletes a DNS record for a standalone proxy domain. Best-effort.
func (m *Manager) removeDNS(ctx context.Context, domain string) {
if m.dns == nil {
return
}
if err := m.dns.DeleteRecord(ctx, domain); err != nil {
slog.Warn("dns: failed to delete record for standalone proxy", "domain", domain, "error", err)
return
}
if err := m.store.DeleteDNSRecord(domain); err != nil {
slog.Warn("dns: failed to remove tracking record", "domain", domain, "error", err)
}
slog.Info("dns: record deleted for standalone proxy", "domain", domain)
}
// lastFailedStep returns the message of the last failed validation step.
func lastFailedStep(result ValidationResult) string {
for _, step := range result.Steps {
+123
View File
@@ -0,0 +1,123 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateDNSRecord inserts a new DNS record tracking entry.
func (s *Store) CreateDNSRecord(rec DNSRecord) (DNSRecord, error) {
if rec.ID == "" {
rec.ID = uuid.New().String()
}
now := Now()
rec.CreatedAt = now
rec.UpdatedAt = now
_, err := s.db.Exec(
`INSERT INTO dns_records (id, fqdn, provider_record_id, consumer_type, consumer_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
rec.ID, rec.FQDN, rec.ProviderRecordID, rec.ConsumerType, rec.ConsumerID, rec.CreatedAt, rec.UpdatedAt,
)
if err != nil {
return DNSRecord{}, fmt.Errorf("insert dns_record: %w", err)
}
return rec, nil
}
// GetDNSRecordByFQDN returns a DNS record by its FQDN.
func (s *Store) GetDNSRecordByFQDN(fqdn string) (DNSRecord, error) {
var rec DNSRecord
err := s.db.QueryRow(
`SELECT id, fqdn, provider_record_id, consumer_type, consumer_id, created_at, updated_at
FROM dns_records WHERE fqdn = ?`, fqdn,
).Scan(&rec.ID, &rec.FQDN, &rec.ProviderRecordID, &rec.ConsumerType, &rec.ConsumerID, &rec.CreatedAt, &rec.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return DNSRecord{}, fmt.Errorf("dns record %s: %w", fqdn, ErrNotFound)
}
if err != nil {
return DNSRecord{}, fmt.Errorf("query dns_record: %w", err)
}
return rec, nil
}
// ListDNSRecords returns all tracked DNS records.
func (s *Store) ListDNSRecords() ([]DNSRecord, error) {
rows, err := s.db.Query(
`SELECT id, fqdn, provider_record_id, consumer_type, consumer_id, created_at, updated_at
FROM dns_records ORDER BY fqdn`,
)
if err != nil {
return nil, fmt.Errorf("query dns_records: %w", err)
}
defer rows.Close()
var records []DNSRecord
for rows.Next() {
var rec DNSRecord
if err := rows.Scan(&rec.ID, &rec.FQDN, &rec.ProviderRecordID, &rec.ConsumerType, &rec.ConsumerID, &rec.CreatedAt, &rec.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan dns_record: %w", err)
}
records = append(records, rec)
}
return records, rows.Err()
}
// GetDNSRecordsByConsumer returns all DNS records for a specific consumer.
func (s *Store) GetDNSRecordsByConsumer(consumerType, consumerID string) ([]DNSRecord, error) {
rows, err := s.db.Query(
`SELECT id, fqdn, provider_record_id, consumer_type, consumer_id, created_at, updated_at
FROM dns_records WHERE consumer_type = ? AND consumer_id = ? ORDER BY fqdn`,
consumerType, consumerID,
)
if err != nil {
return nil, fmt.Errorf("query dns_records by consumer: %w", err)
}
defer rows.Close()
var records []DNSRecord
for rows.Next() {
var rec DNSRecord
if err := rows.Scan(&rec.ID, &rec.FQDN, &rec.ProviderRecordID, &rec.ConsumerType, &rec.ConsumerID, &rec.CreatedAt, &rec.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan dns_record: %w", err)
}
records = append(records, rec)
}
return records, rows.Err()
}
// UpdateDNSRecordProviderID updates the provider record ID for an existing DNS record.
func (s *Store) UpdateDNSRecordProviderID(fqdn, providerRecordID string) error {
_, err := s.db.Exec(
`UPDATE dns_records SET provider_record_id = ?, updated_at = ? WHERE fqdn = ?`,
providerRecordID, Now(), fqdn,
)
if err != nil {
return fmt.Errorf("update dns_record provider_id: %w", err)
}
return nil
}
// DeleteDNSRecord removes a DNS record by FQDN.
func (s *Store) DeleteDNSRecord(fqdn string) error {
_, err := s.db.Exec(`DELETE FROM dns_records WHERE fqdn = ?`, fqdn)
if err != nil {
return fmt.Errorf("delete dns_record: %w", err)
}
return nil
}
// DeleteDNSRecordsByConsumer removes all DNS records for a specific consumer.
func (s *Store) DeleteDNSRecordsByConsumer(consumerType, consumerID string) error {
_, err := s.db.Exec(
`DELETE FROM dns_records WHERE consumer_type = ? AND consumer_id = ?`,
consumerType, consumerID,
)
if err != nil {
return fmt.Errorf("delete dns_records by consumer: %w", err)
}
return nil
}
+15
View File
@@ -58,9 +58,24 @@ type Settings struct {
SSLCertificateID int `json:"ssl_certificate_id"`
StaleThresholdDays int `json:"stale_threshold_days"`
AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths
WildcardDNS bool `json:"wildcard_dns"`
DNSProvider string `json:"dns_provider"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID string `json:"cloudflare_zone_id"`
UpdatedAt string `json:"updated_at"`
}
// DNSRecord tracks a DNS record managed by the application.
type DNSRecord struct {
ID string `json:"id"`
FQDN string `json:"fqdn"`
ProviderRecordID string `json:"provider_record_id"`
ConsumerType string `json:"consumer_type"` // "instance" or "standalone"
ConsumerID string `json:"consumer_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Instance represents a running (or stopped) container for a project stage.
type Instance struct {
ID string `json:"id"`
+14 -4
View File
@@ -7,36 +7,46 @@ import (
// GetSettings returns the global settings (single-row pattern, always row id=1).
func (s *Store) GetSettings() (Settings, error) {
var st Settings
var wildcardDNS int
err := s.db.QueryRow(
`SELECT domain, server_ip, network, subdomain_pattern, notification_url,
npm_url, npm_email, npm_password, webhook_secret, polling_interval,
base_volume_path, ssl_certificate_id, stale_threshold_days,
allowed_volume_paths, updated_at
allowed_volume_paths, wildcard_dns, dns_provider,
cloudflare_api_token, cloudflare_zone_id, updated_at
FROM settings WHERE id = 1`,
).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
&st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval,
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
&st.AllowedVolumePaths, &st.UpdatedAt)
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
&st.CloudflareAPIToken, &st.CloudflareZoneID, &st.UpdatedAt)
if err != nil {
return Settings{}, fmt.Errorf("query settings: %w", err)
}
st.WildcardDNS = wildcardDNS != 0
return st, nil
}
// UpdateSettings upserts the global settings row.
func (s *Store) UpdateSettings(st Settings) error {
st.UpdatedAt = Now()
wildcardDNS := 0
if st.WildcardDNS {
wildcardDNS = 1
}
_, err := s.db.Exec(
`UPDATE settings SET
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?,
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
allowed_volume_paths=?, updated_at=?
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
cloudflare_api_token=?, cloudflare_zone_id=?, updated_at=?
WHERE id = 1`,
st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL,
st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval,
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
st.AllowedVolumePaths, st.UpdatedAt,
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
st.CloudflareAPIToken, st.CloudflareZoneID, st.UpdatedAt,
)
if err != nil {
return fmt.Errorf("update settings: %w", err)
+16
View File
@@ -90,6 +90,11 @@ func (s *Store) runMigrations() error {
`ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`,
// Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01).
`ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`,
// Add DNS management fields to settings (2026-04-02).
`ALTER TABLE settings ADD COLUMN wildcard_dns INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE settings ADD COLUMN dns_provider TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN cloudflare_api_token TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN cloudflare_zone_id TEXT NOT NULL DEFAULT ''`,
}
for _, m := range migrations {
@@ -110,6 +115,7 @@ func (s *Store) runMigrations() error {
`CREATE INDEX IF NOT EXISTS idx_event_log_severity ON event_log(severity)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_source ON event_log(source)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_dns_records_consumer ON dns_records(consumer_type, consumer_id)`,
}
for _, idx := range indexes {
if _, err := s.db.Exec(idx); err != nil {
@@ -297,6 +303,16 @@ CREATE TABLE IF NOT EXISTS standalone_proxies (
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS dns_records (
id TEXT PRIMARY KEY,
fqdn TEXT NOT NULL UNIQUE,
provider_record_id TEXT NOT NULL DEFAULT '',
consumer_type TEXT NOT NULL DEFAULT '',
consumer_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`
// Now returns the current time formatted for SQLite storage.
@@ -0,0 +1,33 @@
# Feature Context: Cloudflare DNS Management
## Configuration
- **Development mode:** Automated
- **Execution mode:** Direct
- **Strategy:** Big Bang
- **Build (Go):** `go build ./cmd/server`
- **Build (Frontend):** `cd web && npm run build`
- **Check (Frontend):** `cd web && npm run check`
- **Test:** `go test ./...`
- **Dev server:** `./scripts/dev-server.sh` (port 8090)
## Current State
Starting fresh — no implementation yet.
## Cross-Phase Dependencies
- Phase 2 depends on Phase 1 (settings fields for Cloudflare credentials)
- Phase 3 depends on Phase 2 (dns.Provider interface)
- Phase 4 depends on Phase 1 (API endpoints for settings)
- Phase 5 depends on Phase 2 + Phase 6 (client + sync logic)
- Phase 6 depends on Phase 2 (Cloudflare client) + Phase 3 (dns_records table)
## Key Architecture Decisions
- DNS provider abstraction via `internal/dns.Provider` interface
- Cloudflare API v4 via direct HTTP (no SDK) — keeps dependencies minimal
- Local `dns_records` table tracks managed records for reconciliation
- DNS operations are best-effort (log warnings, don't block deploys)
- A records only, pointing to `ServerIP` from settings
## Environment & Runtime Notes
- Encryption key from `ENCRYPTION_KEY` env var (AES-256-GCM)
- SQLite with WAL mode, auto-migration on startup
- Frontend is SvelteKit 2 + Svelte 5 + Tailwind CSS 4
+50
View File
@@ -0,0 +1,50 @@
# Feature: Cloudflare DNS Management
**Branch:** `feature/cloudflare-dns-management`
**Base branch:** `main`
**Created:** 2026-04-02
**Status:** 🟡 In Progress
**Strategy:** Big Bang
**Mode:** Automated
**Execution:** Direct
## Summary
Introduce flexible DNS management. By default, wildcard DNS is assumed (current behavior).
When disabled, the user selects a DNS provider (Cloudflare initially) and provides API
credentials. DNS A records are then automatically kept in sync with proxy consumers
(deployed instances and standalone proxies). A dedicated DNS Records page provides
visibility, filtering, and manual sync/reconciliation.
## Build & Test Commands
- **Build (Go):** `go build ./cmd/server`
- **Build (Frontend):** `cd web && npm run build`
- **Check (Frontend):** `cd web && npm run check`
- **Test (Go):** `go test ./...`
- **Dev server:** `./scripts/dev-server.sh`
## Phases
- [ ] Phase 1: Settings model & API [domain: backend] → [subplan](./phase-1-settings-model.md)
- [ ] Phase 2: Cloudflare DNS client [domain: backend] → [subplan](./phase-2-cloudflare-client.md)
- [ ] Phase 3: DNS lifecycle hooks [domain: backend] → [subplan](./phase-3-dns-hooks.md)
- [ ] Phase 4: Settings UI — DNS configuration [domain: frontend] → [subplan](./phase-4-settings-ui.md)
- [ ] Phase 5: DNS Records page [domain: fullstack] → [subplan](./phase-5-dns-records-page.md)
- [ ] Phase 6: DNS sync & reconciliation [domain: backend] → [subplan](./phase-6-dns-sync.md)
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 1: Settings model & API | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 2: Cloudflare DNS client | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 3: DNS lifecycle hooks | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 4: Settings UI — DNS config | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 5: DNS Records page | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 6: DNS sync & reconciliation | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
## Final Review
- [ ] Comprehensive code review
- [ ] Full build passes
- [ ] Full test suite passes
- [ ] Merged to `main`
@@ -0,0 +1,59 @@
# Phase 1: Settings Model & API
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Extend the Settings model and API to support DNS provider configuration.
## Tasks
- [ ] Task 1: Add new fields to `Settings` struct in `internal/store/models.go`
- `WildcardDNS` (bool, default true)
- `DNSProvider` (string, default "")
- `CloudflareAPIToken` (string, encrypted)
- `CloudflareZoneID` (string)
- [ ] Task 2: Add migration columns in `internal/store/store.go`
- `wildcard_dns` INTEGER DEFAULT 1
- `dns_provider` TEXT DEFAULT ''
- `cloudflare_api_token` TEXT DEFAULT ''
- `cloudflare_zone_id` TEXT DEFAULT ''
- [ ] Task 3: Update `GetSettings()` and `UpdateSettings()` in `internal/store/settings.go`
- Read/write new fields
- Encrypt/decrypt `cloudflare_api_token`
- [ ] Task 4: Update `GET /api/settings` handler to include new fields (mask token)
- [ ] Task 5: Update `PUT /api/settings` handler to accept new fields
- [ ] Task 6: Add `POST /api/settings/dns/test` endpoint — validate Cloudflare token + zone
- [ ] Task 7: Add `GET /api/settings/dns/zones` endpoint — list Cloudflare zones for picker
- [ ] Task 8: Register new routes in `internal/api/router.go`
## Files to Modify/Create
- `internal/store/models.go` — add fields to Settings struct
- `internal/store/store.go` — add migration columns
- `internal/store/settings.go` — update read/write queries
- `internal/api/settings.go` — update handlers, add new endpoints
- `internal/api/router.go` — register new routes
## Acceptance Criteria
- New settings fields are persisted and retrievable
- Cloudflare API token is encrypted at rest
- GET /api/settings returns new fields (token masked)
- PUT /api/settings accepts and stores new fields
- DNS test and zones endpoints registered (can return placeholder until Phase 2)
## Notes
- Token encryption uses existing `crypto.Encrypt/Decrypt`
- `has_cloudflare_api_token` bool in GET response (same pattern as npm_password)
- DNS test/zones endpoints will make real Cloudflare API calls — Phase 2 client needed
for full implementation, but can use inline HTTP calls for these two endpoints
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,61 @@
# Phase 2: Cloudflare DNS Client
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Create an `internal/dns` package with a `Provider` interface and a Cloudflare implementation
using the Cloudflare API v4 (direct HTTP, no SDK).
## Tasks
- [ ] Task 1: Define `Provider` interface in `internal/dns/provider.go`
- `EnsureRecord(ctx, fqdn, ip) error` — create or update A record
- `DeleteRecord(ctx, fqdn) error` — delete A record if exists
- `ListRecords(ctx) ([]Record, error)` — list all A records in the zone
- `Record` struct: ID, FQDN, Type, Content (IP), Proxied, TTL
- [ ] Task 2: Create `internal/dns/cloudflare.go` — Cloudflare implementation
- HTTP client with `Authorization: Bearer <token>` header
- Base URL: `https://api.cloudflare.com/client/v4`
- `EnsureRecord`: GET records by name, create if missing, update if IP differs
- `DeleteRecord`: GET record by name, DELETE if found
- `ListRecords`: GET all A records in zone
- `ListZones`: GET zones for the token (for zone picker)
- `TestConnection`: verify token works (GET /user/tokens/verify)
- [ ] Task 3: Create `internal/dns/dns.go` — factory function
- `NewProvider(providerName, config) (Provider, error)`
- Config struct with token, zoneID
- Returns `nil, nil` when providerName is empty (wildcard mode)
- [ ] Task 4: Wire DNS test/zones endpoints in `internal/api/settings.go`
- `POST /api/settings/dns/test` — create temp Cloudflare client, call TestConnection
- `GET /api/settings/dns/zones` — create temp client, call ListZones
## Files to Modify/Create
- `internal/dns/provider.go` — interface + Record type
- `internal/dns/cloudflare.go` — Cloudflare implementation
- `internal/dns/dns.go` — factory function
- `internal/api/settings.go` — wire test/zones endpoints to real client
## Acceptance Criteria
- Provider interface defined with EnsureRecord, DeleteRecord, ListRecords
- Cloudflare client makes correct API calls with proper auth headers
- EnsureRecord is idempotent (create if missing, update if changed, no-op if same)
- DeleteRecord is idempotent (no error if record doesn't exist)
- ListZones returns zone ID + name pairs
- TestConnection returns success/failure
## Notes
- Cloudflare API v4 docs: zones endpoint, dns_records endpoint
- Use `context.Context` for timeout control on all HTTP calls
- A records only (type "A"), TTL=1 (auto), proxied=false (DNS only, not CF proxy)
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,67 @@
# Phase 3: DNS Lifecycle Hooks
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Hook DNS record management into the deployer and standalone proxy manager so that DNS
records are automatically created/updated/deleted in sync with proxy consumers.
## Tasks
- [ ] Task 1: Create `dns_records` table for tracking managed records
- Columns: id, fqdn, provider_record_id, consumer_type (instance/standalone), consumer_id, created_at, updated_at
- Store queries: CreateDNSRecord, DeleteDNSRecord, GetDNSRecordByFQDN, ListDNSRecords, GetDNSRecordsByConsumer
- [ ] Task 2: Add DNS provider to `Deployer` struct
- Accept `dns.Provider` in constructor (can be nil for wildcard mode)
- Helper: `ensureDNS(ctx, fqdn, deployID)` — calls provider.EnsureRecord + saves to dns_records
- Helper: `removeDNS(ctx, fqdn, deployID)` — calls provider.DeleteRecord + removes from dns_records
- [ ] Task 3: Hook into deployer — instance creation
- After `configureProxy` succeeds in `deployer.go` and `bluegreen.go` → call `ensureDNS`
- FQDN = `subdomain + "." + settings.Domain`
- [ ] Task 4: Hook into deployer — instance removal
- In `removeInstance` after NPM proxy deletion → call `removeDNS`
- In `rollback` after NPM proxy deletion → call `removeDNS`
- [ ] Task 5: Hook into standalone proxy manager
- `CreateProxy` → after NPM host created, call `ensureDNS`
- `UpdateProxy` → if domain changed, `removeDNS(old)` + `ensureDNS(new)`
- `DeleteProxy` → call `removeDNS`
- [ ] Task 6: Wire DNS provider into main.go
- Read settings on startup, create provider if non-wildcard
- Pass provider to Deployer and proxy Manager constructors
- Handle provider being nil (wildcard mode = no DNS ops)
- [ ] Task 7: Add `DNSRecord` model to `internal/store/models.go`
## Files to Modify/Create
- `internal/store/models.go` — add DNSRecord struct
- `internal/store/store.go` — add dns_records table migration
- `internal/store/dns_records.go` — CRUD queries
- `internal/deployer/deployer.go` — add DNS hooks
- `internal/deployer/bluegreen.go` — add DNS hooks
- `internal/deployer/rollback.go` — add DNS cleanup
- `internal/proxy/manager.go` — add DNS hooks
- `cmd/server/main.go` — wire DNS provider
## Acceptance Criteria
- DNS records created when proxy consumers are created (if non-wildcard mode)
- DNS records deleted when proxy consumers are removed
- DNS records updated when standalone proxy domain changes
- All DNS operations are best-effort (log warning on failure, don't block)
- dns_records table tracks all managed records
- Wildcard mode (default) skips all DNS operations
## Notes
- DNS operations must be wrapped in error handling that logs but doesn't fail the deploy
- The dns_records table is the local source of truth for reconciliation (Phase 6)
- Provider can be nil — all hooks must check for nil before calling
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,55 @@
# Phase 4: Settings UI — DNS Configuration
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Add a "DNS Configuration" section to the Settings page with wildcard toggle, provider
selection, Cloudflare credential fields, and connection test.
## Tasks
- [ ] Task 1: Add new API functions in `web/src/lib/api.ts`
- `testDnsConnection(token, zoneId)` → POST /api/settings/dns/test
- `listDnsZones(token)` → GET /api/settings/dns/zones
- [ ] Task 2: Add i18n keys for DNS settings in locale files
- [ ] Task 3: Add DNS Configuration section to `web/src/routes/settings/+page.svelte`
- Toggle: "Wildcard DNS is configured" (checkbox/switch)
- When unchecked, show:
- DNS Provider dropdown (only "Cloudflare" option)
- API Token field (password type, show `has_cloudflare_api_token` indicator)
- Zone picker (loaded from API after token provided)
- "Test Connection" button with success/error feedback
- All DNS fields hidden when wildcard is checked
- [ ] Task 4: Wire save logic — include new fields in `handleSave`
- [ ] Task 5: Wire load logic — populate DNS fields from settings response
## Files to Modify/Create
- `web/src/lib/api.ts` — add DNS API functions
- `web/src/routes/settings/+page.svelte` — add DNS config section
- `web/src/lib/i18n/en.ts` (or equivalent locale file) — add DNS translation keys
## Acceptance Criteria
- Wildcard toggle visible and functional (default: checked)
- Unchecking reveals Cloudflare configuration form
- API token field uses password masking
- Zone picker loads zones from Cloudflare API
- Test Connection button shows success/failure
- Settings save includes DNS fields
- Settings load populates DNS fields
## Notes
- Follow existing settings page patterns (FormField, EntityPicker for zones)
- Zone picker similar to SSL certificate picker pattern
- Token field similar to NPM password field (has_token indicator)
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,65 @@
# Phase 5: DNS Records Page
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Create a dedicated DNS Records page that lists all managed DNS records with filtering,
consumer mapping, and sync status visibility.
## Tasks
- [ ] Task 1: Add backend endpoint `GET /api/dns/records`
- Returns merged view: local dns_records + Cloudflare actual records
- Each record: fqdn, type, value (IP), consumer_type, consumer_name, status (synced/orphaned/missing)
- Orphaned = exists in Cloudflare but no local consumer
- Missing = local consumer exists but no Cloudflare record
- [ ] Task 2: Add API handler in `internal/api/dns.go`
- New handler file for DNS-related endpoints
- Register routes in router.go
- [ ] Task 3: Add frontend API function `getDnsRecords()` in `api.ts`
- [ ] Task 4: Create DNS Records page at `web/src/routes/dns/+page.svelte`
- Table with columns: FQDN, Type, Value, Consumer, Status
- Consumer column shows: instance name (project/stage) or standalone proxy name
- Status badges: synced (green), orphaned (yellow), missing (red)
- Search filter (by FQDN substring)
- Filter by consumer type: all / managed / standalone
- Filter by status: all / synced / orphaned / missing
- Manual sync button (calls POST /api/dns/sync — Phase 6)
- Refresh button to re-fetch from Cloudflare
- [ ] Task 5: Add navigation link to DNS page
- Only visible when wildcard DNS is disabled
- Add to sidebar/nav under Settings or as top-level
- [ ] Task 6: Add i18n keys for DNS records page
## Files to Modify/Create
- `internal/api/dns.go` — new handler file
- `internal/api/router.go` — register DNS routes
- `web/src/lib/api.ts` — add DNS records API function
- `web/src/routes/dns/+page.svelte` — new page
- `web/src/routes/dns/+page.ts` — optional load function
- Navigation component — add DNS link
- Locale files — add i18n keys
## Acceptance Criteria
- DNS Records page accessible at /dns
- Table shows all records with correct status
- Filtering works: search text, consumer type, sync status
- Only accessible/visible when wildcard DNS is disabled
- Consumer names resolve correctly (project/stage for managed, proxy name for standalone)
## Notes
- Status computation: compare local dns_records table with Cloudflare ListRecords response
- Cache Cloudflare response for a few seconds to avoid rate limiting on page load
- Navigation link visibility tied to settings (may need a store or settings check)
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
@@ -0,0 +1,62 @@
# Phase 6: DNS Sync & Reconciliation
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement reconciliation logic that compares expected DNS records (from active consumers)
with actual Cloudflare records, and provides a sync endpoint to fix discrepancies.
## Tasks
- [ ] Task 1: Add `POST /api/dns/sync` endpoint
- Computes expected records from: active instances with proxy + standalone proxies
- Fetches actual records from Cloudflare via ListRecords
- Creates missing records (consumer exists, no CF record)
- Deletes orphaned records (CF record exists, no consumer) — only for records in dns_records table
- Updates dns_records table to reflect current state
- Returns sync report: created N, deleted N, already_synced N
- [ ] Task 2: Add helper to compute expected records
- Query all instances where npm_proxy_id > 0 and status = "running" → extract FQDN
- Query all standalone proxies → extract domain
- Return list of expected FQDNs
- [ ] Task 3: Add `DELETE /api/dns/records/{fqdn}` endpoint
- Manual deletion of a specific DNS record (for orphan cleanup)
- Calls provider.DeleteRecord + removes from dns_records
- [ ] Task 4: Wire sync endpoint in `internal/api/dns.go` and router
- [ ] Task 5: Add frontend sync button handler in DNS Records page
- Call POST /api/dns/sync
- Show sync report (toast or inline)
- Refresh records list after sync
## Files to Modify/Create
- `internal/api/dns.go` — add sync + delete endpoints
- `internal/api/router.go` — register new routes
- `internal/store/dns_records.go` — add helper queries (list consumers with FQDNs)
- `web/src/lib/api.ts` — add syncDnsRecords(), deleteDnsRecord() functions
- `web/src/routes/dns/+page.svelte` — wire sync button
## Acceptance Criteria
- POST /api/dns/sync creates missing and removes orphaned records
- Sync report returned with counts
- Manual delete endpoint works for individual records
- Frontend sync button triggers reconciliation and refreshes view
- Only records tracked in dns_records table are candidates for orphan deletion
(don't delete unrelated Cloudflare records)
## Notes
- Safety: only delete Cloudflare records that are tracked in our dns_records table
(never touch records we didn't create)
- Rate limiting: Cloudflare API has rate limits, batch operations where possible
- Expected records query needs to join instances + standalone_proxies with settings.domain
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows project conventions
- [ ] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
<!-- Filled in after completion -->
-53
View File
@@ -1,53 +0,0 @@
# Feature Context: Docker Watcher Core
## Configuration
- **Development mode:** Automated
- **Execution mode:** Orchestrator
- **Strategy:** Big Bang (with per-phase code quality reviews)
- **Build (Go):** `go build ./cmd/server/`
- **Test (Go):** `go test ./...`
- **Lint (Go):** `golangci-lint run`
- **Build (Frontend):** `cd web && npm run build`
- **Test (Frontend):** `cd web && npm test`
- **Dev server:** `go run ./cmd/server/`
## Current State
Greenfield project. Only PLAN.md exists with the architecture document.
## Temporary Workarounds
None yet.
## Cross-Phase Dependencies
- Phase 2 depends on Phase 1 (store CRUD for seed import)
- Phases 3 and 4 are independent of each other (can run in parallel)
- Phase 5 depends on Phase 1 (store for poll state)
- Phase 6 depends on Phase 3 (Docker inspect for auto-creation) and Phase 1 (store)
- Phase 7 depends on Phases 3, 4, 5 (Docker, NPM, registry clients)
- Phase 8 depends on Phases 1-7 (wires everything to HTTP)
- Phases 9 and 10 are independent of each other (can run in parallel)
- Phase 11 depends on Phases 8, 9, 10 (embeds frontend, SSE wires to API)
- Phase 12 depends on all prior phases
## Deferred Work
None yet.
## Failed Approaches
None yet.
## Review Findings Log
None yet.
## Phase Execution Log
| Phase | Agent Used | Test Writer | Parallel | Notes |
|-------|-----------|-------------|----------|-------|
| — | — | — | — | No phases executed yet |
## Environment & Runtime Notes
- Platform: Windows 10 (development), Linux (deployment target)
- Docker socket: `/var/run/docker.sock` (Linux) — development may need Docker Desktop
- Go version: TBD (will be determined in Phase 1)
## Implementation Notes
- Big Bang strategy: intermediate phases skip build/tests, code quality reviews after every phase
- Final phase (12) is the only phase where build + full test suite must pass
- Phases 3+4 and 9+10 identified for parallel execution
-108
View File
@@ -1,108 +0,0 @@
# Feature: Docker Watcher Core
**Branch:** `feature/docker-watcher-core`
**Base branch:** `main`
**Created:** 2026-03-27
**Status:** 🟡 In Progress
**Strategy:** Big Bang (with per-phase code quality reviews)
**Mode:** Automated
**Execution:** Orchestrator
## Summary
A self-hosted tool that automates Docker container deployment with Nginx Proxy Manager integration. Detects new images from Gitea/GitHub registries, deploys containers, and configures reverse proxy routing — all from a web dashboard. Supports multiple simultaneous versions of the same project.
## Build & Test Commands
- **Build (Go):** `go build ./cmd/server/`
- **Test (Go):** `go test ./...`
- **Lint (Go):** `golangci-lint run`
- **Build (Frontend):** `cd web && npm run build`
- **Test (Frontend):** `cd web && npm test`
- **Dev server:** `go run ./cmd/server/`
## Phases
- [x] Phase 1: Project Scaffold & SQLite Store [domain: backend] → [subplan](./phase-1-scaffold-store.md)
- [x] Phase 2: Crypto & Config Seed Loader [domain: backend] → [subplan](./phase-2-crypto-config.md)
- [x] Phase 3: Docker Client [domain: backend] → [subplan](./phase-3-docker-client.md)
- [x] Phase 4: NPM Client [domain: backend] → [subplan](./phase-4-npm-client.md)
- [x] Phase 5: Registry Client & Poller [domain: backend] → [subplan](./phase-5-registry-poller.md)
- [x] Phase 6: Webhook Handler [domain: backend] → [subplan](./phase-6-webhook-handler.md)
- [x] Phase 7: Deployer & Health Checker [domain: backend] → [subplan](./phase-7-deployer.md)
- [x] Phase 8: REST API Layer [domain: backend] → [subplan](./phase-8-api-layer.md)
- [x] Phase 9: SvelteKit Dashboard & Project Views [domain: frontend] → [subplan](./phase-9-dashboard.md)
- [x] Phase 10: Quick Deploy & Settings Pages [domain: frontend] → [subplan](./phase-10-settings-deploy.md)
- [x] Phase 11: Frontend Embed & Real-Time Updates [domain: fullstack] → [subplan](./phase-11-embed-sse.md)
- [x] Phase 12: Hardening [domain: backend] → [subplan](./phase-12-hardening.md)
- [x] Phase 13: Volumes & Environment [domain: fullstack] → [subplan](./phase-14-volumes-env.md)
- [x] Phase 14: Frontend Polish & Modern UI [domain: frontend] → [subplan](./phase-13-ui-polish.md)
### Parallel Execution Notes
- Phases 3 and 4 are independent (Docker client vs NPM client) — can run in parallel
- Phases 9 and 10 are independent (dashboard vs settings pages) — can run in parallel
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
| ----- | ------ | ------ | ------ | ----- | --------- |
| Phase 1: Scaffold & Store | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
| Phase 2: Crypto & Config | backend | ✅ Complete | ✅ Pass w/ notes | ⏭️ Skip (Big Bang) | ✅ |
| Phase 3: Docker Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
| Phase 4: NPM Client | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
| Phase 5: Registry & Poller | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
| Phase 6: Webhook Handler | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
| Phase 7: Deployer & Health | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
| Phase 8: API Layer | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
| Phase 9: Dashboard | frontend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ |
| Phase 10: Settings & Deploy | frontend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ |
| Phase 11: Embed & SSE | fullstack | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ |
| Phase 12: Hardening | backend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ |
| Phase 13: Volumes & Env | fullstack | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ✅ |
| Phase 14: UI Polish | frontend | ✅ Complete | ⬜ Pending | ✅ Required (Final) | ⬜ |
## Amendment Log
### Amendment 1 — 2026-03-27
**Type:** Added phase
**What changed:** Added Phase 13: Frontend Polish & Modern UI after Phase 12
**Why:** User wants modern look & feel with SVG icons and polished frontend
**Impact on existing phases:** None — Phase 13 runs after all functionality is complete. Build/tests now required on Phase 13 (final) instead of Phase 12.
### Amendment 2 — 2026-03-27
**Type:** Modified phase
**What changed:** Added Task 13 (EN/RU localization) to Phase 13: Frontend Polish & Modern UI
**Why:** User wants bilingual support (English and Russian) in the dashboard
**Impact on existing phases:** None — contained within Phase 13
### Amendment 3 — 2026-03-27
**Type:** Added phase
**What changed:** Added Phase 14: Volumes & Environment — per-project env vars with per-stage overrides, volume mounts with shared/isolated modes, encryption for sensitive values, UI editor
**Why:** Missing from feature planner phases but present in root PLAN.md Phase 4
**Impact on existing phases:** Phase 14 becomes the final phase (build/tests required). Phase 13 (UI Polish) remains but no longer the final phase for build enforcement.
### Amendment 4 — 2026-03-27
**Type:** Modified phase
**What changed:** Updated Phase 12 (Hardening) auth tasks to support two modes: Local auth (username/password in SQLite with bcrypt) and OAuth2/OIDC (Authentik or any OIDC provider with configurable discovery URL). Added auth settings UI, user management, OIDC callback flow.
**Why:** Root PLAN.md was updated to require OAuth2/OIDC support alongside local auth
**Impact on existing phases:** Phase 12 task count increased from 10 to 12. Added new files for auth module and login page.
### Amendment 5 — 2026-03-27
**Type:** Reordered phases
**What changed:** Swapped Phase 13 (UI Polish) and Phase 14 (Volumes & Env). Volumes & Env is now Phase 13, UI Polish is now Phase 14 (final).
**Why:** Volumes & Env adds new UI pages that need the polish pass. UI Polish must run last to cover all pages including auth (Phase 12) and volume/env editors (Phase 13).
**Impact on existing phases:** Execution order changed. UI Polish (now Phase 14) remains the final phase with build/test enforcement.
## Final Review
- [ ] Comprehensive code review
- [ ] Full build passes
- [ ] Full test suite passes
- [ ] Security review
- [ ] Merged to `main`
@@ -1,95 +0,0 @@
# Phase 1: Project Scaffold & SQLite Store
**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Initialize the Go project, establish the directory structure, and implement the SQLite store with schema, migrations, and CRUD operations for all entities.
## Tasks
- [x] Task 1: Initialize Go module (`go mod init`), create directory structure per PLAN.md
- [x] Task 2: Add core dependencies to go.mod (sqlite, chi, yaml, uuid, cron)
- [x] Task 3: Define SQLite schema — tables for projects, stages, registries, settings, instances, deploys, deploy_logs
- [x] Task 4: Implement store initialization with auto-migration (create tables if not exist)
- [x] Task 5: Implement projects CRUD (Create, GetByID, GetAll, Update, Delete)
- [x] Task 6: Implement stages CRUD (Create, GetByProjectID, Update, Delete)
- [x] Task 7: Implement registries CRUD (Create, GetByID, GetAll, Update, Delete)
- [x] Task 8: Implement settings Get/Update (single-row config pattern)
- [x] Task 9: Implement instances CRUD (Create, GetByStageID, GetByID, Update, Delete, UpdateStatus)
- [x] Task 10: Implement deploys CRUD (Create, GetByProjectID, GetRecent, GetByID) + deploy_logs append
- [x] Task 11: Create `cmd/server/main.go` entry point (minimal — just opens DB, defers close)
## Files to Modify/Create
- `go.mod` — module definition and dependencies
- `go.sum` — dependency checksums
- `cmd/server/main.go` — entry point
- `internal/store/store.go` — DB connection, schema, migrations
- `internal/store/projects.go` — project queries
- `internal/store/stages.go` — stage queries
- `internal/store/registries.go` — registry queries
- `internal/store/settings.go` — settings queries
- `internal/store/instances.go` — instance queries
- `internal/store/deploys.go` — deploy history queries
## Acceptance Criteria
- `go mod tidy` succeeds
- All store CRUD functions are implemented with proper error handling
- Schema covers all entities from the architecture plan
- Entry point compiles (may not fully run until later phases wire everything)
## Notes
- Use `modernc.org/sqlite` for CGo-free SQLite
- Use `go-chi/chi/v5` for routing (will be wired in Phase 8)
- Settings table uses a single-row pattern (one row, upsert on update)
- Instance status should be an enum-like string: "running", "stopped", "failed", "removing"
- Deploy status: "pending", "pulling", "starting", "configuring_proxy", "health_checking", "success", "failed", "rolled_back"
## Review Checklist
- [ ] All tasks completed
- [ ] Code follows Go conventions (gofmt, proper error returns)
- [ ] No unintended side effects
- [ ] Schema is normalized and covers all planned entities
- [ ] CRUD functions handle not-found cases properly
## Handoff to Next Phase
### What was built
- Go module initialized at `github.com/alexei/docker-watcher` with all core dependencies
- Full directory structure created: `cmd/server/`, `internal/store/`, plus empty dirs for config, docker, npm, registry, deployer, health, notify, webhook, api, crypto
- SQLite store with 7 tables: projects, stages, registries, settings, instances, deploys, deploy_logs
- Auto-migration runs on store initialization (CREATE TABLE IF NOT EXISTS)
- WAL mode, foreign keys, and busy timeout pragmas enabled
- Settings table uses single-row pattern with `INSERT OR IGNORE` seed
- Models extracted to `internal/store/models.go` for clean separation
### Key files
- `go.mod` — module definition with modernc.org/sqlite, chi, yaml, uuid, cron
- `cmd/server/main.go` — entry point that creates data dir, opens store, defers close
- `internal/store/store.go` — DB connection, pragmas, schema DDL, migration
- `internal/store/models.go` — all entity structs (Project, Stage, Registry, Settings, Instance, Deploy, DeployLog)
- `internal/store/projects.go` — full CRUD
- `internal/store/stages.go` — full CRUD with bool-to-int conversion for SQLite
- `internal/store/registries.go` — full CRUD
- `internal/store/settings.go` — Get/Update (single-row upsert)
- `internal/store/instances.go` — full CRUD + UpdateStatus
- `internal/store/deploys.go` — Create, GetByID, GetByProjectID, GetRecent, UpdateDeployStatus, SetDeployInstanceID, AppendDeployLog, GetDeployLogs
### Conventions established
- UUIDs generated via `github.com/google/uuid` on Create operations
- Timestamps stored as `datetime('now')` defaults in schema, `time.Now().UTC().Format("2006-01-02 15:04:05")` in Go code
- All query errors wrapped with `fmt.Errorf` and `%w` for unwrapping
- Not-found cases return descriptive error strings (not sentinel errors yet — can be refined)
- Boolean fields stored as INTEGER (0/1) in SQLite, converted via `boolToInt` helper
- JSON-encoded maps stored as TEXT for env and volumes fields
### What Phase 2 needs to know
- `store.New(dbPath)` returns a `*Store` that is ready to use — no additional init needed
- The `settings` table is pre-seeded with a row (id=1) so `GetSettings` always works
- Registry `token` and settings `npm_password` are stored as plain text — Phase 2 (Crypto) should add encryption/decryption around these fields
- `go.sum` does not exist yet — run `go mod tidy` after Go is available to generate it
@@ -1,56 +0,0 @@
# Phase 10: Quick Deploy & Settings Pages
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Build the Quick Deploy page (paste image, auto-inspect, one-click deploy) and all Settings pages (registries, credentials, global settings, webhook URL).
## Tasks
- [ ] Task 1: Quick Deploy page (`routes/deploy/+page.svelte`) — image URL input, inspect button
- [ ] Task 2: Quick Deploy inspect flow — call /api/deploy/inspect, display auto-filled form (project name, port, stage, subdomain)
- [ ] Task 3: Quick Deploy submit — user reviews defaults, clicks Deploy, calls /api/deploy/quick
- [ ] Task 4: Settings layout (`routes/settings/+layout.svelte`) — sub-navigation for settings sections
- [ ] Task 5: Global settings page (`routes/settings/+page.svelte`) — domain, server IP, network, subdomain pattern, polling interval
- [ ] Task 6: Registries page (`routes/settings/registries/+page.svelte`) — list, add, edit, delete, test connection
- [ ] Task 7: Credentials page (`routes/settings/credentials/+page.svelte`) — NPM credentials, registry tokens (masked display)
- [ ] Task 8: Webhook URL display and regenerate button in settings
- [ ] Task 9: Projects config page (`routes/projects/config/+page.svelte` or integrated into project detail) — add/edit/delete projects, configure stages
- [ ] Task 10: Stage configuration form — tag patterns, auto_deploy toggle, max_instances, subdomain override
- [ ] Task 11: Form validation on all input pages — required fields, URL format, port range
- [ ] Task 12: Success/error toast notifications for all form submissions
## Files to Modify/Create
- `web/src/routes/deploy/+page.svelte` — quick deploy
- `web/src/routes/settings/+layout.svelte` — settings layout
- `web/src/routes/settings/+page.svelte` — global settings
- `web/src/routes/settings/registries/+page.svelte` — registry management
- `web/src/routes/settings/credentials/+page.svelte` — credential management
- `web/src/lib/components/Toast.svelte` — toast notifications
- `web/src/lib/components/FormField.svelte` — reusable form field with validation
## Acceptance Criteria
- Quick Deploy: paste image URL → inspect → review defaults → deploy works end-to-end
- All settings are editable and saved via API
- Registry test connection shows success/failure
- Credentials are masked in display (`••••••••`)
- Webhook URL is shown with copy button and regenerate option
- Form validation prevents bad submissions
## Notes
- Quick Deploy is the zero-config entry point — should be dead simple UX
- Credential fields: show mask, edit replaces entirely (no partial edit)
- Registry test: calls POST /api/registries/:id/test, shows connection result
- Toast component: appears top-right, auto-dismiss after 5s, color-coded (green/red)
## Review Checklist
- [ ] All tasks completed
- [ ] Quick deploy flow is intuitive (minimal clicks)
- [ ] Credentials never shown in plaintext in UI
- [ ] Form validation covers required fields and formats
- [ ] Error states are handled with user-friendly messages
## Handoff to Next Phase
<!-- Filled in by the implementation agent after completing this phase. -->
@@ -1,76 +0,0 @@
# Phase 11: Frontend Embed & Real-Time Updates
**Status:** Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Build SvelteKit to static files, embed into the Go binary with `go:embed`, serve from Go, and implement SSE for real-time deploy progress and instance status updates.
## Tasks
- [x] Task 1: Configure SvelteKit static adapter to output to `web/build/` (already configured)
- [x] Task 2: Add `//go:embed web/build` directive in Go — `web.go` at project root
- [x] Task 3: Create Go handler for serving embedded SPA — `internal/api/static.go` with SPA fallback
- [x] Task 4: Implement SSE endpoint for deploy logs — `GET /api/deploys/:id/logs` (SSE + JSON fallback)
- [x] Task 5: Implement SSE endpoint for instance status — `GET /api/events` streams instance status changes
- [x] Task 6: Create event bus/broadcaster in Go — `internal/events/bus.go` with pub/sub channels
- [x] Task 7: Frontend: connect to SSE for deploy progress — `connectDeployLogs()` in `web/src/lib/sse.ts`
- [x] Task 8: Frontend: connect to SSE for instance status — global SSE in `+layout.svelte` via store
- [x] Task 9: Handle SSE reconnection in frontend — exponential backoff with jitter in `connectSSE()`
- [x] Task 10: Build script/Makefile — `make build` builds frontend then Go binary
## Files to Modify/Create
- `web/svelte.config.js` — already configured with static adapter outputting to `web/build/`
- `web.go` — root-level embed directive (`//go:embed web/build`)
- `internal/api/static.go` — embedded static file server with SPA fallback
- `internal/api/sse.go` — SSE endpoints for deploy logs and instance events
- `internal/events/bus.go` — event bus for publishing/subscribing to events
- `web/src/lib/sse.ts` — SSE client helper with auto-reconnect
- `web/src/lib/stores/instance-status.ts` — Svelte store for real-time instance status
- `web/src/routes/+layout.svelte` — wired up global SSE connection for instance status
- `Makefile` — build frontend + backend
- `cmd/server/main.go` — wired embedded static serving and event bus
- `internal/api/router.go` — added eventBus to Server, SSE routes
- `internal/api/deploys.go` — removed old JSON stub, replaced by SSE handler
- `internal/deployer/deployer.go` — added event publishing for deploy logs, status, instance status
## Acceptance Criteria
- `make build` produces a single Go binary with embedded frontend
- Go binary serves the SvelteKit SPA on all non-API routes
- Deploy progress streams in real-time via SSE
- Instance status updates appear without page refresh
- SSE reconnects automatically after network hiccups
## Notes
- `go:embed` requires the embedded directory to be relative to the Go source file
- SPA fallback: any request that doesn't match `/api/*` gets `index.html`
- Event bus: simple pub/sub with channels — no external dependency needed
- SSE format: `data: {"type": "deploy_log", "payload": {...}}\n\n`
- Keep SSE connections lightweight — use context cancellation for cleanup
- WriteTimeout on HTTP server set to 0 to support long-lived SSE connections
- Deploy logs endpoint serves both SSE (Accept: text/event-stream) and JSON (default)
## Review Checklist
- [x] All tasks completed
- [x] Single binary serves both API and frontend
- [x] SSE handles multiple concurrent clients (buffered channels, non-blocking publish)
- [x] No goroutine leaks on SSE disconnect (context cancellation + Unsubscribe)
- [x] Build process is reproducible (Makefile)
## Handoff to Next Phase
### What was implemented
- **Event bus** (`internal/events/bus.go`): In-process pub/sub with topic filtering, buffered subscriber channels (64 events), non-blocking publish. Supports `EventDeployLog`, `EventInstanceStatus`, and `EventDeployStatus` event types.
- **SSE endpoints**: `GET /api/deploys/{id}/logs` streams deploy logs with JSON fallback; `GET /api/events` streams global instance/deploy status changes.
- **Static file serving**: `web.go` at project root embeds `web/build/`, `internal/api/static.go` serves SPA with fallback. Mounted via chi's `NotFound` handler.
- **Frontend SSE client** (`web/src/lib/sse.ts`): `connectSSE()` with exponential backoff + jitter, `connectDeployLogs()` and `connectGlobalEvents()` convenience functions.
- **Instance status store** (`web/src/lib/stores/instance-status.ts`): Svelte writable store updated by global SSE connection in `+layout.svelte`.
- **Deployer integration**: `deployer.go` now publishes deploy log, deploy status, and instance status events via `EventPublisher` interface.
### Key integration points for next phase
- `events.Bus` is passed to both `api.NewServer` and `deployer.New`
- `api.NewServer` now requires an `*events.Bus` parameter (6th arg before encKey)
- `deployer.New` now requires an `EventPublisher` parameter (6th arg before encKey)
- HTTP server `WriteTimeout` is 0 to support SSE
- The `web.go` file at project root uses package name `dockerwatcher` (imported as `github.com/alexei/docker-watcher`)
@@ -1,72 +0,0 @@
# Phase 12: Hardening
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Production hardening — blue-green deploys, promote flow, dashboard auth, graceful shutdown, structured logging, and config export.
## Tasks
- [ ] Task 1: Blue-green deploys — start new container, health check, swap NPM proxy, then stop old container (zero downtime)
- [ ] Task 2: Promote flow — enforce `promote_from` for production deploys (only tags running in source stage are eligible)
- [ ] Task 3: Local auth — username/password stored in SQLite (bcrypt hashed), login endpoint, session token (JWT or cookie)
- [ ] Task 4: OAuth2/OIDC auth — integration with Authentik or any OIDC provider (configurable client ID, client secret, discovery URL)
- [ ] Task 5: Auth settings UI — settings page to choose auth mode (local/OIDC), configure OIDC provider, manage local users
- [ ] Task 6: Auth middleware — protect all /api/* routes except webhook; check session/JWT/OIDC token
- [ ] Task 7: Graceful shutdown — handle SIGTERM/SIGINT, drain in-progress deploys, close DB, stop poller
- [ ] Task 8: Structured logging — JSON logs with deploy context (project, stage, tag, instance ID)
- [ ] Task 9: Config export — download current SQLite state as YAML (reverse of seed import)
- [ ] Task 10: Dockerfile — multi-stage build (build frontend + Go, copy to minimal image)
- [ ] Task 11: docker-compose.yml — production-ready compose file with volumes, network, env
- [ ] Task 12: Final wiring review — ensure all services are properly initialized and shut down
## Files to Modify/Create
- `internal/deployer/bluegreen.go` — blue-green deploy strategy
- `internal/deployer/promote.go` — promote flow logic
- `internal/auth/local.go` — local auth (bcrypt password hashing, session tokens)
- `internal/auth/oidc.go` — OAuth2/OIDC provider integration
- `internal/auth/middleware.go` — auth middleware (session/JWT/OIDC token validation)
- `internal/auth/models.go` — user model, auth settings, session store
- `internal/api/auth.go` — auth API endpoints (login, logout, OIDC callback, user management)
- `internal/config/export.go` — config export to YAML
- `internal/logging/logger.go` — structured JSON logger
- `internal/store/users.go` — user CRUD, auth settings persistence
- `web/src/routes/login/+page.svelte` — login page
- `web/src/routes/settings/auth/+page.svelte` — auth settings UI
- `cmd/server/main.go` — graceful shutdown, structured logging, auth init
- `Dockerfile` — multi-stage build
- `docker-compose.yml` — production compose file
## Acceptance Criteria
- Blue-green: zero downtime during deploy (old container serves until new one is healthy)
- Promote: production deploy only accepts tags from the specified source stage
- Auth: unauthenticated requests to /api/* (except webhook) return 401
- Graceful shutdown: in-progress deploys complete before exit
- Logs are JSON-formatted with contextual fields
- Config export produces valid YAML that could be re-imported
- Docker image builds and runs correctly
## Notes
- Blue-green: keep old container running until new one passes health check, then swap NPM proxy and stop old
- Auth has two modes configurable via settings:
- **Local auth**: username/password in SQLite (bcrypt hashed), JWT session tokens
- **OAuth2/OIDC**: integration with Authentik or any OIDC provider (client ID, secret, discovery URL)
- First launch: create default admin user with configurable password via ADMIN_PASSWORD env var
- OIDC flow: redirect to provider → callback → create/link local user → issue session
- SIGTERM handling: use Go's `os/signal` + `context.WithCancel`
- Structured logging: use `log/slog` (Go stdlib since 1.21)
- Dockerfile: build stage with Node.js + Go, runtime stage with scratch/alpine
- Phase 13 (UI Polish) and Phase 14 (Volumes & Env) follow this phase
## Review Checklist
- [ ] All tasks completed
- [ ] Blue-green deploy handles rollback if new container fails
- [ ] Auth doesn't block webhook endpoint
- [ ] Graceful shutdown tested with concurrent deploys
- [ ] Dockerfile produces a minimal image
- [ ] docker-compose.yml matches the example in PLAN.md
## Handoff to Next Phase
<!-- This is the final phase — no handoff needed. -->
@@ -1,86 +0,0 @@
# Phase 13: Frontend Polish & Modern UI
**Status:** COMPLETED
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Enhance the web UI with a modern, polished look and feel — custom SVG icons, refined typography, consistent color palette, smooth transitions, and overall professional frontend quality.
## Tasks
- [x] Task 1: Design system foundations — CSS custom properties for color palette (light/dark), spacing scale, typography scale, border radius tokens, shadows, transitions in `web/src/lib/styles/tokens.css`
- [x] Task 2: SVG icon set — 38 Lucide-based inline SVG icon components in `web/src/lib/components/icons/` covering all UI actions (deploy, stop, start, restart, remove, settings, registry, etc.)
- [x] Task 3: Refine layout — polished sidebar with active state indicators (dot + background), smooth transitions, responsive breakpoints, collapsible sidebar on mobile with hamburger menu
- [x] Task 4: Dashboard cards — redesigned project cards with box icon, status indicators, instance count badges, hover effects (-translate-y-0.5, shadow-md), port/healthcheck chips
- [x] Task 5: Project detail view — clean card layout for instances with icon action buttons, inline status badges with pulse animation for "running", deploy history as timeline cards
- [x] Task 6: Form styling — consistent input fields with design tokens, select dropdowns, ToggleSwitch component replacing checkboxes, button hierarchy (primary brand/secondary/danger)
- [x] Task 7: Toast/notification system — slide-in toasts with Lucide icons, rounded-xl, auto-dismiss, stacking
- [x] Task 8: Loading states — Skeleton, SkeletonCard, SkeletonTable loader components with shimmer animation for data fetching, IconLoader spinner for actions
- [x] Task 9: Empty states — EmptyState component with SVG illustrations and call-to-action buttons for all empty list scenarios
- [x] Task 10: Responsive design — mobile-friendly layout with collapsible sidebar, hamburger menu, mobile top bar, touch-friendly controls, horizontal settings nav on mobile
- [x] Task 11: Micro-interactions — button press feedback (active:animate-press), status pulse animation (ping), scale-in for dialogs/forms, fade-in for overlays, slide-in for toasts
- [x] Task 12: Dark mode support — ThemeToggle component with light/dark/system modes, CSS custom properties for dark theme via [data-theme="dark"], localStorage persistence, system preference detection
- [x] Task 13: Localization (EN/RU) — i18n store with derived t() function, en.json and ru.json locale files, LocaleSwitcher component, localStorage persistence, all UI strings translated
## Files Created
- `web/src/lib/styles/tokens.css` — design tokens (colors, spacing, typography, radius, shadows, transitions, animations)
- `web/src/lib/components/icons/` — 38 Lucide icon components + index.ts barrel export
- `web/src/lib/i18n/en.json` — English locale strings
- `web/src/lib/i18n/ru.json` — Russian locale strings
- `web/src/lib/i18n/index.ts` — i18n store with t() function and locale management
- `web/src/lib/stores/theme.ts` — dark mode store with system preference detection
- `web/src/lib/components/Skeleton.svelte` — base skeleton loader
- `web/src/lib/components/SkeletonCard.svelte` — card skeleton placeholder
- `web/src/lib/components/SkeletonTable.svelte` — table skeleton placeholder
- `web/src/lib/components/EmptyState.svelte` — empty state with SVG illustrations
- `web/src/lib/components/ToggleSwitch.svelte` — toggle switch replacing checkboxes
- `web/src/lib/components/ThemeToggle.svelte` — light/dark/system theme toggle
- `web/src/lib/components/LocaleSwitcher.svelte` — EN/RU locale switcher
## Files Modified
- `web/src/app.css` — imports tokens.css, adds base styles, custom scrollbar, focus ring utility
- `web/src/routes/+layout.svelte` — polished sidebar with icons, collapsible mobile sidebar, theme/locale controls
- `web/src/routes/+page.svelte` — dashboard with stats cards, skeleton loaders, empty states, i18n
- `web/src/routes/login/+page.svelte` — polished login with design tokens and i18n
- `web/src/routes/deploy/+page.svelte` — quick deploy with icons, animations, i18n
- `web/src/routes/projects/+page.svelte` — projects list with skeleton loaders, empty states, i18n
- `web/src/routes/projects/[id]/+page.svelte` — project detail with deploy timeline, icons, i18n
- `web/src/routes/projects/[id]/env/+page.svelte` — env editor with toggle switches, icons, i18n
- `web/src/routes/projects/[id]/volumes/+page.svelte` — volume editor with icons, i18n
- `web/src/routes/settings/+layout.svelte` — settings nav with icons, responsive horizontal nav
- `web/src/routes/settings/+page.svelte` — general settings with design tokens, i18n
- `web/src/routes/settings/registries/+page.svelte` — registries with icons, empty states, i18n
- `web/src/routes/settings/credentials/+page.svelte` — credentials with design tokens, i18n
- `web/src/routes/settings/auth/+page.svelte` — auth settings with icons, empty states, i18n
- `web/src/lib/components/Toast.svelte` — slide-in toasts with Lucide icons
- `web/src/lib/components/StatusBadge.svelte` — pulse animation for running status
- `web/src/lib/components/ConfirmDialog.svelte` — fade/scale-in animation, icon
- `web/src/lib/components/FormField.svelte` — consistent styling with design tokens
- `web/src/lib/components/ProjectCard.svelte` — redesigned with hover effects, badges
- `web/src/lib/components/InstanceCard.svelte` — icon action buttons, improved layout
## Acceptance Criteria
- [x] UI looks modern and professional — not "default framework" appearance
- [x] Consistent icon language throughout the app
- [x] Smooth transitions and meaningful animations (not gratuitous)
- [x] Responsive down to mobile viewport
- [x] Loading and empty states provide good UX
- [x] Color palette works well in both light and dark contexts
- [x] All UI strings available in English and Russian, switchable via locale picker
## Review Checklist
- [x] All tasks completed
- [x] Visual consistency across all pages
- [x] No functionality regressions
- [x] Responsive on mobile/tablet/desktop
- [x] Accessible (proper contrast ratios, focus states, aria labels on icons)
## Handoff Notes
This is the FINAL phase. All 13 phases of Docker Watcher are now complete. The application has:
- Full Go backend with SQLite, Docker management, Nginx Proxy Manager integration
- SvelteKit frontend with dark mode, i18n (EN/RU), responsive design, skeleton loaders, empty states
- Real-time SSE events for deploy/instance status
- Authentication (local + OIDC), RBAC, registry management
- Environment variable overrides, volume management, config export
- Webhook-based and polling-based image detection
@@ -1,58 +0,0 @@
# Phase 14: Volumes & Environment
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Implement per-project environment variables with per-stage overrides, volume mounts with shared/isolated modes, sensitive env value encryption, and UI for managing both.
## Tasks
- [ ] Task 1: Extend store schema — add `stage_env` table for per-stage env overrides (stage_id, key, value, encrypted bool)
- [ ] Task 2: Extend store schema — add `volumes` table for volume config (project_id, source, target, mode: shared|isolated)
- [ ] Task 3: Implement store CRUD for stage env overrides (Create, GetByStageID, Update, Delete)
- [ ] Task 4: Implement store CRUD for volumes (Create, GetByProjectID, Update, Delete)
- [ ] Task 5: Encrypt sensitive env values (values marked as secret) using crypto.Encrypt before storage
- [ ] Task 6: Merge env vars during deploy — project-level env + stage-level overrides, decrypt secrets
- [ ] Task 7: Compute volume mounts during deploy — shared mode uses path as-is, isolated mode appends `/{stage}-{tag}/` to source
- [ ] Task 8: Pass merged env vars and volume mounts to Docker container creation
- [ ] Task 9: API endpoints — CRUD for stage env vars and project volumes
- [ ] Task 10: Frontend — env var editor in project/stage settings (key/value pairs, secret toggle)
- [ ] Task 11: Frontend — volume editor in project settings (source/target/mode)
- [ ] Task 12: Frontend — per-stage env override UI showing inherited vs overridden values
## Files to Modify/Create
- `internal/store/stage_env.go` — stage env CRUD
- `internal/store/volumes.go` — volume CRUD
- `internal/store/store.go` — add new tables to schema
- `internal/deployer/deployer.go` — merge env vars and compute volume mounts during deploy
- `internal/docker/container.go` — accept volume mounts in ContainerConfig
- `internal/api/stages.go` — add env var endpoints
- `internal/api/projects.go` — add volume endpoints
- `web/src/routes/projects/[id]/env/+page.svelte` — env var editor
- `web/src/routes/projects/[id]/volumes/+page.svelte` — volume editor
## Acceptance Criteria
- Project-level env vars applied to all containers
- Stage-level overrides replace project-level values for matching keys
- Sensitive env values encrypted at rest, decrypted only during deploy
- Shared volumes: all instances mount same host path
- Isolated volumes: each instance gets `{source}/{stage}-{tag}/` subdirectory
- UI allows managing env vars and volumes per project and per stage
## Notes
- Project `env` field already exists as JSON blob in the store — this phase may migrate to a proper table or keep JSON and add stage overrides separately
- Volume `mode` is either "shared" or "isolated"
- Isolated volume subdirectory is created automatically by Docker (bind mount creates parent dirs)
- Sensitive env display: masked in UI, "Change" button pattern (same as credentials page)
## Review Checklist
- [ ] All tasks completed
- [ ] Env merge logic is correct (stage overrides project)
- [ ] Secret values never appear in plaintext in API responses
- [ ] Volume paths are validated (no path traversal)
- [ ] Isolated volume subdirectory naming is deterministic
## Handoff to Next Phase
<!-- Filled in by the implementation agent after completing this phase. -->
@@ -1,61 +0,0 @@
# Phase 2: Crypto & Config Seed Loader
**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement AES-256 encryption for credential storage and the YAML seed config parser that imports into SQLite on first launch.
## Tasks
- [x] Task 1: Implement AES-256-GCM encrypt/decrypt functions using Go stdlib `crypto/aes` + `crypto/cipher`
- [x] Task 2: Key derivation from ENCRYPTION_KEY env var (SHA-256 hash to get 32 bytes)
- [x] Task 3: Define YAML config structs matching the seed format from PLAN.md
- [x] Task 4: Implement YAML parser — read and validate seed file
- [x] Task 5: Implement seed importer — checks if DB is empty, if so imports YAML into SQLite via store CRUD
- [x] Task 6: Encrypt credential fields (registry tokens, NPM password) during import
- [x] Task 7: Create `docker-watcher.example.yaml` with documented example config
- [x] Task 8: Wire seed import into `cmd/server/main.go` startup sequence
## Files to Modify/Create
- `internal/crypto/crypto.go` — AES-256-GCM encrypt/decrypt
- `internal/config/config.go` — YAML structs and parser
- `internal/config/seed.go` — seed import logic (YAML → SQLite)
- `docker-watcher.example.yaml` — example seed config
- `cmd/server/main.go` — add seed import to startup
## Acceptance Criteria
- Encrypt then decrypt round-trips correctly
- Different plaintexts produce different ciphertexts (random nonce)
- YAML parsing handles all fields from the seed format
- Seed import creates projects, stages, registries, and settings in SQLite
- Credentials are encrypted before storage
- Import is idempotent — skipped if DB already has data
## Notes
- ENCRYPTION_KEY is the only secret env var — everything else is encrypted in SQLite
- Use GCM mode for authenticated encryption (integrity + confidentiality)
- Seed import should be transactional — all or nothing
- The example YAML should have placeholder values, not real credentials
## Review Checklist
- [x] All tasks completed
- [x] Crypto uses secure practices (random nonce, GCM, no ECB)
- [x] No hardcoded keys or secrets
- [x] YAML parsing validates required fields
- [x] Import is transactional
## Handoff to Next Phase
- `crypto.Encrypt(key, plaintext)` and `crypto.Decrypt(key, ciphertextHex)` handle AES-256-GCM encryption; ciphertext is hex-encoded with prepended nonce
- `crypto.KeyFromEnv()` derives a `[32]byte` key from the `ENCRYPTION_KEY` env var via SHA-256
- `crypto.EncryptIfNotEmpty(key, value)` is a convenience wrapper that passes through empty strings unchanged
- `config.ImportSeed(db, seedPath)` is the single entry point for seed import — called from `main.go` at startup
- Import is idempotent: skipped if the DB already has projects or registries
- Import is transactional: all inserts happen within a single SQLite transaction (rollback on any failure)
- Registry `token` and settings `npm_password` are now stored encrypted in SQLite — later phases that read these fields must decrypt with `crypto.Decrypt(key, value)`
- `store.DB()` method was added to expose the underlying `*sql.DB` for transaction use
- Seed file path is configurable via `SEED_FILE` env var (default: `./docker-watcher.yaml`)
- YAML validation ensures: `global.domain` is required, every project needs `image`, project registry references must exist, stages need `tag_pattern`
- `go.sum` still does not exist — run `go mod tidy` when Go toolchain is available
@@ -1,98 +0,0 @@
# Phase 3: Docker Client
**Status:** :white_check_mark: Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement the Docker Engine API wrapper for container lifecycle management — pull images, inspect, create/start/stop/remove containers, and manage networks.
## Tasks
- [x] Task 1: Create Docker client wrapper with socket connection (`/var/run/docker.sock`)
- [x] Task 2: Implement `PullImage(ctx, image, tag, authConfig)` — pull with optional registry auth
- [x] Task 3: Implement `InspectImage(ctx, image)` — extract EXPOSE ports, HEALTHCHECK, labels
- [x] Task 4: Implement `CreateContainer(ctx, config)` — create with name, image, env, ports, network, labels
- [x] Task 5: Implement `StartContainer(ctx, containerID)`, `StopContainer(ctx, containerID, timeout)`, `RemoveContainer(ctx, containerID, force)`
- [x] Task 6: Implement `RestartContainer(ctx, containerID, timeout)`
- [x] Task 7: Implement `ListContainers(ctx, filters)` — filter by labels to find managed containers
- [x] Task 8: Implement `EnsureNetwork(ctx, networkName)` — create network if not exists
- [x] Task 9: Implement `ConnectNetwork(ctx, networkID, containerID)` — attach container to network
- [x] Task 10: Add docker-watcher labels to all managed containers (`docker-watcher.project`, `docker-watcher.stage`, `docker-watcher.instance-id`)
## Files to Modify/Create
- `internal/docker/client.go` — Docker client wrapper, connection setup
- `internal/docker/container.go` — container lifecycle operations
- `internal/docker/image.go` — pull and inspect operations
- `internal/docker/network.go` — network management
## Acceptance Criteria
- Client connects to Docker socket
- Pull handles both public and authenticated registries
- Image inspection extracts port, healthcheck, and label metadata
- Container creation applies all config (env, ports, network, labels)
- All operations return meaningful errors
- Managed containers are identifiable via labels
## Notes
- Use `github.com/docker/docker/client` SDK
- Container names should be deterministic: `dw-{project}-{stage}-{tag-sanitized}`
- All containers should be on the shared network (e.g., `staging-net`)
- Port mapping: container's EXPOSE port → random host port (Docker auto-assigns)
- Auth config for private registries will come from the store (encrypted tokens)
## Review Checklist
- [x] All tasks completed
- [x] Proper context propagation for cancellation
- [x] Resource cleanup (close client, remove failed containers)
- [x] No hardcoded values
- [x] Error messages include container/image identifiers
## Handoff to Next Phase
### Exported API surface (`internal/docker` package)
**Client lifecycle:**
- `docker.New() (*Client, error)` — creates client with env-based config and API version negotiation
- `(*Client).Close() error` — releases resources
- `(*Client).Ping(ctx) error` — checks daemon connectivity
**Image operations (`image.go`):**
- `(*Client).PullImage(ctx, imageRef, tag, authConfig) error` — pulls image; authConfig is base64-encoded JSON (use `EncodeRegistryAuth` helper)
- `(*Client).InspectImage(ctx, imageRef) (ImageInfo, error)` — returns `ImageInfo{ExposedPorts, Healthcheck, Labels}`
- `docker.EncodeRegistryAuth(username, password, serverAddress) (string, error)` — builds auth payload for `PullImage`
**Container operations (`container.go`):**
- `(*Client).CreateContainer(ctx, ContainerConfig) (containerID string, error)` — creates container with labels, env, ports, network
- `(*Client).StartContainer(ctx, containerID) error`
- `(*Client).StopContainer(ctx, containerID, timeoutSeconds) error`
- `(*Client).RemoveContainer(ctx, containerID, force) error`
- `(*Client).RestartContainer(ctx, containerID, timeoutSeconds) error`
- `(*Client).ListContainers(ctx, labelFilters) ([]ManagedContainer, error)` — always scoped to docker-watcher labels
- `(*Client).InspectContainerPort(ctx, containerID, containerPort) (uint16, error)` — gets auto-assigned host port
- `docker.ContainerName(project, stage, tag) string` — deterministic name: `dw-{project}-{stage}-{tag-sanitized}`
**Network operations (`network.go`):**
- `(*Client).EnsureNetwork(ctx, networkName) (networkID string, error)` — idempotent create-if-not-exists
- `(*Client).ConnectNetwork(ctx, networkID, containerID) error`
**Label constants:**
- `docker.LabelProject` = `"docker-watcher.project"`
- `docker.LabelStage` = `"docker-watcher.stage"`
- `docker.LabelInstanceID` = `"docker-watcher.instance-id"`
**Key types:**
- `docker.ContainerConfig` — input for `CreateContainer` (Name, Image, Env, ExposedPorts, NetworkName, NetworkID, Labels, Project, Stage, InstanceID)
- `docker.ImageInfo` — output of `InspectImage` (ExposedPorts, Healthcheck, Labels)
- `docker.ManagedContainer` — output of `ListContainers` (ID, Name, Image, Status, State, Project, Stage, InstanceID, Ports)
### Dependencies added
- `github.com/docker/docker v27.5.1+incompatible`
- `github.com/docker/go-connections v0.5.0`
- Run `go mod tidy` to resolve transitive dependencies before building
### Conventions maintained
- `context.Context` as first parameter on all methods
- Errors wrapped with `fmt.Errorf("context: %w", err)`
- Package-level constants for labels
- Immutable patterns (new maps created rather than mutating input)
@@ -1,78 +0,0 @@
# Phase 4: NPM Client
**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement the Nginx Proxy Manager API client — JWT authentication, CRUD for proxy hosts, and host lookup.
## Tasks
- [x] Task 1: Create NPM client struct with base URL, cached JWT token, and auto-refresh
- [x] Task 2: Implement `Authenticate(ctx, email, password)` — POST /api/tokens, store JWT
- [x] Task 3: Implement `CreateProxyHost(ctx, config)` — POST /api/nginx/proxy-hosts
- [x] Task 4: Implement `UpdateProxyHost(ctx, id, config)` — PUT /api/nginx/proxy-hosts/{id}
- [x] Task 5: Implement `DeleteProxyHost(ctx, id)` — DELETE /api/nginx/proxy-hosts/{id}
- [x] Task 6: Implement `ListProxyHosts(ctx)` — GET /api/nginx/proxy-hosts
- [x] Task 7: Implement `FindProxyHostByDomain(ctx, domain)` — search existing hosts by domain name
- [x] Task 8: Define proxy host config struct (domain, forward host/port, SSL settings, etc.)
- [x] Task 9: Handle JWT token expiry — re-authenticate automatically on 401
## Files to Modify/Create
- `internal/npm/client.go` — NPM API client, auth, HTTP helpers
- `internal/npm/types.go` — request/response types for proxy hosts
## Acceptance Criteria
- Client authenticates and caches JWT
- CRUD operations work for proxy hosts
- Token refresh happens transparently on expiry
- Proxy host config supports: domain, forward host, forward port, SSL (Let's Encrypt optional)
- FindByDomain enables checking if a proxy already exists before creating
## Notes
- NPM API base: typically `http://npm:81/api`
- Forward host for containers: use container name on the shared Docker network
- Forward port: the container's internal port (from EXPOSE)
- SSL: for staging, can be disabled; production may want Let's Encrypt
- NPM credentials come from settings (encrypted in SQLite, decrypted at runtime)
## Review Checklist
- [ ] All tasks completed
- [ ] JWT caching and refresh work correctly
- [ ] HTTP errors are properly handled (not just status code, but response body)
- [ ] No credentials logged or leaked in errors
- [ ] Struct types match NPM API contract
## Handoff to Next Phase
### What was built
- `internal/npm/types.go``ProxyHostConfig` (create/update input), `ProxyHost` (API response), `Meta`, auth types, and `boolInt` custom JSON type for NPM's 0/1 boolean fields.
- `internal/npm/client.go` — Full NPM API client with JWT auth, auto-refresh, and CRUD.
### Public API surface
```go
npm.New(baseURL string) *Client
(*Client).Authenticate(ctx, email, password string) error
(*Client).CreateProxyHost(ctx, config ProxyHostConfig) (ProxyHost, error)
(*Client).UpdateProxyHost(ctx, id int, config ProxyHostConfig) (ProxyHost, error)
(*Client).DeleteProxyHost(ctx, id int) error
(*Client).ListProxyHosts(ctx) ([]ProxyHost, error)
(*Client).FindProxyHostByDomain(ctx, domain string) (ProxyHost, bool, error)
```
### Key design decisions
- JWT token is cached with expiry; auto-refreshed 5 minutes before expiry or on 401.
- Credentials are stored in memory after `Authenticate` to enable transparent re-auth.
- All HTTP errors include the response body text for debugging.
- Credentials are never included in error messages.
- `boolInt` type handles NPM API's inconsistent 0/1 vs true/false for boolean fields.
- `FindProxyHostByDomain` does case-insensitive matching against all domain names.
### Dependencies for next phase
- Caller must provide decrypted NPM credentials (email + password from settings via `crypto.Decrypt`).
- `ProxyHost.ID` (int) maps to `Instance.NpmProxyID` in the store for tracking.
@@ -1,49 +0,0 @@
# Phase 5: Registry Client & Poller
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement the registry client interface with Gitea implementation, and the periodic tag polling scheduler.
## Tasks
- [ ] Task 1: Define `Registry` interface — `ListTags(ctx, image)`, `GetLatestTag(ctx, image, pattern)`
- [ ] Task 2: Implement Gitea registry client — uses Gitea API to list container image tags
- [ ] Task 3: Implement tag pattern matching — match tags against glob patterns (e.g., `dev-*`, `v*`)
- [ ] Task 4: Implement tag comparison — detect new tags since last poll (store last-seen tag per project/stage)
- [ ] Task 5: Create poller service — periodic scheduler using `robfig/cron`
- [ ] Task 6: Poller logic — for each project/stage with polling enabled, check for new tags, trigger deploy if auto_deploy
- [ ] Task 7: Add `last_polled_tag` field to instances or a new `poll_state` table in store
- [ ] Task 8: Implement registry factory — create client based on registry type (gitea, future: github, dockerhub)
## Files to Modify/Create
- `internal/registry/registry.go` — interface definition + factory
- `internal/registry/gitea.go` — Gitea registry implementation
- `internal/registry/poller.go` — polling scheduler service
- `internal/store/poll_state.go` — poll state persistence (optional, or extend existing tables)
## Acceptance Criteria
- Gitea client can list tags for a given image
- Tag pattern matching correctly filters tags (glob-style)
- Poller runs on configurable interval
- New tags are detected by comparing against stored state
- Registry factory returns correct client based on type
## Notes
- Gitea API: `GET /api/v1/packages/{owner}/container/{image}/tags` (or similar, verify against Gitea docs)
- Auth: Bearer token from registry config
- Polling interval comes from global settings
- The poller is a fallback — webhooks are the primary detection mechanism (Phase 6)
- GitHub Container Registry support is future work — just define the interface now
## Review Checklist
- [ ] All tasks completed
- [ ] Interface is clean and minimal
- [ ] Pattern matching handles edge cases (empty pattern, no tags)
- [ ] Poller doesn't leak goroutines
- [ ] Registry auth tokens handled securely
## Handoff to Next Phase
<!-- Filled in by the implementation agent after completing this phase. -->
@@ -1,78 +0,0 @@
# Phase 6: Webhook Handler
**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement the secret UUID-based webhook endpoint that receives image push notifications from CI systems, with auto-creation of unknown projects.
## Tasks
- [x] Task 1: Implement webhook HTTP handler — `POST /api/webhook/:secret-uuid`
- [x] Task 2: Validate incoming payload — extract image name and tag
- [x] Task 3: Look up project by image name in store — match against configured project images
- [x] Task 4: If known project: match tag to stage via tag patterns, determine if auto_deploy
- [x] Task 5: If unknown project: auto-create project with defaults from image inspection (EXPOSE port, labels)
- [x] Task 6: Generate and store webhook secret UUID in settings (on first launch)
- [x] Task 7: Implement webhook URL regeneration (new UUID, invalidates old one)
- [x] Task 8: Define webhook payload struct (`{"image": "registry/org/app:tag"}`)
## Files to Modify/Create
- `internal/webhook/handler.go` — webhook HTTP handler + payload parsing
- `internal/webhook/matcher.go` — project/stage matching logic
- `internal/webhook/autocreate.go` — auto-create project from unknown image
## Acceptance Criteria
- Valid webhook URL with correct UUID triggers processing
- Invalid/missing UUID returns 404 (no information leak)
- Known images are matched to projects and stages
- Unknown images trigger auto-creation with sensible defaults
- Webhook URL can be regenerated
## Notes
- Webhook URL format: `POST /api/webhook/d8f2a1e9-...`
- No authentication needed beyond the secret UUID
- Auto-created projects use: image EXPOSE port, "dev" as default stage, auto_deploy: true
- The webhook handler calls into the deployer (Phase 7) — for now, define the interface/callback
- Keep the handler thin — it matches and delegates
## Review Checklist
- [x] All tasks completed
- [x] No information leak on invalid UUIDs
- [x] Payload validation rejects malformed input
- [x] Auto-creation uses safe defaults
- [x] Handler is stateless (delegates to store/deployer)
## Handoff to Next Phase
### Exported API
- `webhook.NewHandler(store, deployer, inspector)` — creates the HTTP handler
- `webhook.Handler.Route()` — returns a `chi.Router` to mount at `/api/webhook`
- `webhook.EnsureWebhookSecret(store)` — generates UUID on first launch, returns current secret
- `webhook.RegenerateWebhookSecret(store)` — replaces secret with new UUID, invalidates old one
- `webhook.ParseImageRef(ref)` — parses `registry/owner/name:tag` into components
### Interfaces Defined
- `webhook.DeployTriggerer``TriggerDeploy(ctx, projectID, stageID, imageTag) error` (mirrors `registry.DeployTriggerer`)
- `webhook.ImageInspector``InspectImage(ctx, imageRef) (docker.ImageInfo, error)` (wraps `docker.Client`)
### Integration Points
- Mount the webhook router: `r.Mount("/api/webhook", webhookHandler.Route())`
- Call `webhook.EnsureWebhookSecret(store)` at application startup to generate the secret on first launch
- The deployer must implement `webhook.DeployTriggerer` (same signature as `registry.DeployTriggerer`)
- The Docker client (`*docker.Client`) satisfies `webhook.ImageInspector` directly
### Auto-Create Behavior
- Unknown images create a project with name from image name, port from EXPOSE, healthcheck from image metadata
- A default "dev" stage is created with `tag_pattern: "*"`, `auto_deploy: true`, `max_instances: 1`
- If image inspection fails (not pulled locally), project is created with port=0 and empty healthcheck
### Tag Matching
- Uses `path.Match` (glob semantics) — same approach as the registry poller
- Stages are checked in name-sorted order; first matching stage wins
@@ -1,54 +0,0 @@
# Phase 7: Deployer & Health Checker
**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Implement the core deployment orchestrator: pull → start container → configure NPM proxy → health check → success/rollback. Plus multi-instance support and notifications.
## Tasks
- [x] Task 1: Define deployer service struct — depends on Docker client, NPM client, store, notifier
- [x] Task 2: Implement deploy flow: pull image → create container → start → connect to network → configure proxy → health check
- [x] Task 3: Implement subdomain generation per convention: `stage-{stage}-{project}` for default, `stage-{stage}-{project}-{tag}` for specific
- [x] Task 4: Sanitize tags for DNS (dots → dashes, lowercase, truncate)
- [x] Task 5: Implement health checker — HTTP GET to `http://container:{port}{healthcheck_path}` with retries and timeout
- [x] Task 6: Implement rollback on health check failure — remove new container, delete NPM proxy host, update instance status
- [x] Task 7: Implement multi-instance support — multiple tags of same project/stage can run simultaneously
- [x] Task 8: Implement max_instances enforcement — remove oldest instance when limit reached
- [x] Task 9: Implement notification webhook — POST to configured URL on deploy success/failure
- [x] Task 10: Create deploy history records in store (status, timestamps, logs)
- [x] Task 11: Implement deploy log streaming — append log entries during deploy for real-time visibility
## Files to Modify/Create
- `internal/deployer/deployer.go` — main deploy orchestrator
- `internal/deployer/subdomain.go` — subdomain generation and DNS sanitization
- `internal/deployer/rollback.go` — rollback logic
- `internal/health/checker.go` — HTTP health checker with retries
- `internal/notify/notifier.go` — webhook notification sender
## Acceptance Criteria
- Full deploy flow works end-to-end (pull → proxy → health check)
- Failed health checks trigger automatic rollback
- Multi-instance: deploying a new tag doesn't stop existing instances
- max_instances removes oldest instance when exceeded
- Notifications fire on success and failure
- Deploy history is recorded with status and timestamps
## Notes
- Health check: 3 retries, 5s between retries, 10s timeout per attempt (configurable later)
- Subdomain pattern comes from global settings
- Notifications are fire-and-forget (don't block deploy on notification failure)
- Deploy logs should be structured entries (timestamp + message) for SSE streaming later
- The deployer is the central orchestrator — webhook handler and poller both call into it
## Review Checklist
- [ ] All tasks completed
- [ ] Rollback cleans up ALL resources (container, proxy, instance record)
- [ ] No goroutine leaks
- [ ] Error handling at every step of the deploy flow
- [ ] Subdomain generation produces valid DNS names
## Handoff to Next Phase
<!-- Filled in by the implementation agent after completing this phase. -->
@@ -1,112 +0,0 @@
# Phase 8: REST API Layer
**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Wire up all REST API endpoints using chi router, connecting the store, deployer, and other services to HTTP handlers.
## Tasks
- [x] Task 1: Set up chi router with middleware (logging, recovery, CORS, JSON content-type)
- [x] Task 2: Implement project endpoints — GET/POST /api/projects, GET/PUT/DELETE /api/projects/:id
- [x] Task 3: Implement stage endpoints — POST /api/projects/:id/stages, PUT/DELETE /api/projects/:id/stages/:stage
- [x] Task 4: Implement instance endpoints — GET /api/projects/:id/stages/:stage/instances, POST (deploy), DELETE (remove)
- [x] Task 5: Implement instance control endpoints — POST .../instances/:iid/stop, start, restart
- [x] Task 6: Implement quick deploy endpoints — POST /api/deploy/inspect, POST /api/deploy/quick
- [x] Task 7: Implement registry endpoints — GET/POST /api/registries, PUT/DELETE /api/registries/:id, POST .../test
- [x] Task 8: Implement settings endpoints — GET/PUT /api/settings, GET /api/settings/webhook-url, POST .../regenerate
- [x] Task 9: Implement deploy history endpoints — GET /api/deploys, GET /api/deploys/:id/logs (SSE stub)
- [x] Task 10: Implement registry tags endpoint — GET /api/registries/:id/tags/:image
- [x] Task 11: Wire webhook handler into router — POST /api/webhook/:secret-uuid
- [x] Task 12: Wire everything in main.go — initialize all services, start HTTP server
## Files to Modify/Create
- `internal/api/router.go` — chi router setup, middleware
- `internal/api/projects.go` — project CRUD handlers
- `internal/api/stages.go` — stage CRUD handlers
- `internal/api/instances.go` — instance lifecycle handlers
- `internal/api/deploys.go` — deploy + quick deploy handlers
- `internal/api/registries.go` — registry CRUD + test + tags handlers
- `internal/api/settings.go` — settings handlers
- `internal/api/middleware.go` — middleware (logging, CORS, recovery)
- `internal/api/response.go` — consistent API response helpers (envelope format)
- `cmd/server/main.go` — full service wiring and HTTP server start
## Acceptance Criteria
- All endpoints from the API spec in PLAN.md are implemented
- Consistent JSON envelope response format (success, data, error, metadata)
- CORS configured for frontend dev (localhost origins)
- Proper HTTP status codes (200, 201, 400, 404, 500)
- main.go starts a fully wired HTTP server
## Notes
- Response envelope: `{"success": bool, "data": any, "error": string|null, "meta": {pagination}}`
- CORS: allow all origins in dev, restrict in production (configurable later)
- SSE for deploy logs is a stub in this phase — real implementation in Phase 11
- Quick deploy: /inspect pulls and inspects image, returns defaults; /quick creates project + deploys
- All handlers should validate input and return 400 for bad requests
## Review Checklist
- [x] All tasks completed
- [x] All API endpoints from PLAN.md are covered
- [x] Consistent response format across all endpoints
- [x] Input validation on all POST/PUT handlers
- [x] No business logic in handlers (delegates to services)
## Handoff to Next Phase
### API Surface
- `api.NewServer(store, docker, deployer, webhookHandler, encKey)` creates the server
- `server.Router()` returns a `chi.Router` with all routes mounted under `/api`
- Response envelope: `{"success": bool, "data": ..., "error": "..."}`
### Endpoints Implemented
| Method | Path | Handler |
|--------|------|---------|
| GET | /api/projects | listProjects |
| POST | /api/projects | createProject |
| GET | /api/projects/{id} | getProject (includes stages) |
| PUT | /api/projects/{id} | updateProject |
| DELETE | /api/projects/{id} | deleteProject |
| POST | /api/projects/{id}/stages | createStage |
| PUT | /api/projects/{id}/stages/{stage} | updateStage |
| DELETE | /api/projects/{id}/stages/{stage} | deleteStage |
| GET | /api/projects/{id}/stages/{stage}/instances | listInstances |
| POST | /api/projects/{id}/stages/{stage}/instances | deployInstance |
| DELETE | /api/projects/{id}/stages/{stage}/instances/{iid} | removeInstance |
| POST | .../instances/{iid}/stop | stopInstance |
| POST | .../instances/{iid}/start | startInstance |
| POST | .../instances/{iid}/restart | restartInstance |
| GET | /api/deploys | listDeploys |
| GET | /api/deploys/{id}/logs | getDeployLogs (JSON stub) |
| POST | /api/deploy/inspect | inspectImage |
| POST | /api/deploy/quick | quickDeploy |
| GET | /api/registries | listRegistries |
| POST | /api/registries | createRegistry |
| PUT | /api/registries/{id} | updateRegistry |
| DELETE | /api/registries/{id} | deleteRegistry |
| POST | /api/registries/{id}/test | testRegistry |
| GET | /api/registries/{id}/tags/* | listRegistryTags |
| GET | /api/settings | getSettings |
| PUT | /api/settings | updateSettings |
| GET | /api/settings/webhook-url | getWebhookURL |
| POST | /api/settings/regenerate | regenerateWebhookSecret |
| POST | /api/webhook/{secret} | webhook handler (mounted from webhook package) |
### main.go Wiring
- All services initialized: store, docker, npm, deployer, health, notifier, webhook, poller
- HTTP server with graceful shutdown on SIGTERM/SIGINT
- Environment variables: `DATA_DIR`, `SEED_FILE`, `ENCRYPTION_KEY`, `NPM_URL`, `POLLING_INTERVAL`, `LISTEN_ADDR`
- Default listen address: `:8080`
### SSE Stub
- `GET /api/deploys/{id}/logs` returns logs as JSON array (not SSE yet)
- Real SSE streaming deferred to Phase 11
### Security Notes
- Registry tokens are encrypted before storage, decrypted on read for API calls
- Settings response strips `npm_password` and `webhook_secret`, returns `has_npm_password` boolean
- Registry list response strips tokens, returns `has_token` boolean
- CORS allows all origins (dev mode) -- restrict in Phase 12
@@ -1,99 +0,0 @@
# Phase 9: SvelteKit Dashboard & Project Views
**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Build the SvelteKit frontend with the dashboard overview and project detail views — project list, instance status, controls, and deploy history.
## Tasks
- [x] Task 1: Initialize SvelteKit project in `web/` directory with TypeScript, static adapter
- [x] Task 2: Set up Tailwind CSS v4 with @tailwindcss/vite plugin
- [x] Task 3: Create shared API client (`lib/api.ts`) — typed fetch wrapper for all backend endpoints
- [x] Task 4: Define TypeScript types (`lib/types.ts`) — Project, Stage, Instance, Deploy, Registry, Settings
- [x] Task 5: Create layout with navigation — sidebar with Dashboard, Projects, Deploy, Settings links
- [x] Task 6: Dashboard page (`routes/+page.svelte`) — project overview cards with instance counts, status indicators
- [x] Task 7: Projects list page (`routes/projects/+page.svelte`) — all projects with quick stats, "Add Project" button
- [x] Task 8: Project detail page (`routes/projects/[id]/+page.svelte`) — stages, instances per stage, controls
- [x] Task 9: Instance controls — Stop, Start, Restart, Remove buttons with confirmation dialogs
- [x] Task 10: Deploy history section in project detail — recent deploys with status, timestamp, tag
- [x] Task 11: "Deploy new version" dropdown — list available tags from registry, trigger deploy
- [x] Task 12: Create reusable components: StatusBadge, InstanceCard, ProjectCard, ConfirmDialog
## Files to Modify/Create
- `web/package.json` — SvelteKit project config
- `web/svelte.config.js` — SvelteKit config with static adapter
- `web/vite.config.ts` — Vite config with API proxy for dev
- `web/src/app.html` — base HTML
- `web/src/lib/api.ts` — API client
- `web/src/lib/types.ts` — shared TypeScript types
- `web/src/routes/+layout.svelte` — app layout with navigation
- `web/src/routes/+page.svelte` — dashboard
- `web/src/routes/projects/+page.svelte` — project list
- `web/src/routes/projects/[id]/+page.svelte` — project detail
- `web/src/lib/components/StatusBadge.svelte` — status indicator
- `web/src/lib/components/InstanceCard.svelte` — instance display
- `web/src/lib/components/ProjectCard.svelte` — project summary card
- `web/src/lib/components/ConfirmDialog.svelte` — confirmation modal
## Acceptance Criteria
- SvelteKit project builds to static output
- Dashboard shows all projects with live status
- Project detail shows stages, instances, and controls
- Instance controls trigger correct API calls
- Deploy dropdown fetches and displays available tags
- UI is responsive and clean
## Notes
- SvelteKit static adapter for embedding in Go binary
- API proxy in vite.config.ts for dev: proxy `/api` to `http://localhost:8080`
- Use SvelteKit's `fetch` for SSR-compatible data loading
- Status colors: green=running, yellow=starting, red=failed, gray=stopped
- Keep components small and reusable
## Review Checklist
- [ ] All tasks completed
- [ ] TypeScript types match backend API response format
- [ ] API client handles errors gracefully with user feedback
- [ ] No hardcoded API URLs (use relative paths)
- [ ] Components are reusable and well-structured
## Handoff to Next Phase
Phase 9 is complete. All 14 files have been created in the `web/` directory:
**Configuration files:**
- `web/package.json` — Svelte 5, SvelteKit 2, Tailwind CSS v4, static adapter, TypeScript
- `web/svelte.config.js` — Static adapter with SPA fallback (`index.html`)
- `web/vite.config.ts` — Tailwind v4 vite plugin + `/api` proxy to `localhost:8080`
- `web/tsconfig.json` — Strict TypeScript, bundler module resolution
- `web/src/app.html` — Base HTML shell
- `web/src/app.css` — Tailwind v4 import
- `web/src/routes/+layout.ts` — Disables SSR, enables prerender for static adapter
**Core library:**
- `web/src/lib/types.ts` — All TypeScript types matching Go backend models exactly (Project, Stage, Instance, Deploy, DeployLog, Registry, Settings, ApiEnvelope)
- `web/src/lib/api.ts` — Full typed API client covering all endpoints (projects, instances, deploys, registries, settings). Unwraps envelope, throws `ApiError` on failure.
**Components (Svelte 5 runes):**
- `StatusBadge.svelte` — Color-coded status pill (green/yellow/red/gray/blue)
- `ConfirmDialog.svelte` — Modal with danger/primary variants
- `InstanceCard.svelte` — Instance display with stop/start/restart/remove controls
- `ProjectCard.svelte` — Project summary card for dashboard grid
**Pages:**
- `+layout.svelte` — Sidebar navigation (Dashboard, Projects, Deploy, Settings)
- `routes/+page.svelte` — Dashboard with stats cards and project grid
- `routes/projects/+page.svelte` — Project table with inline add-project form
- `routes/projects/[id]/+page.svelte` — Full project detail: stages, instances, deploy form, deploy history
**Key decisions:**
- Used Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`) throughout
- Tailwind CSS v4 with `@tailwindcss/vite` plugin (no PostCSS config needed)
- Client-side only rendering (SSR disabled) for static adapter compatibility
- API client uses relative `/api/` paths — works in both dev (vite proxy) and prod (embedded)
- All API calls include loading spinners and error states with retry buttons
**Ready for Phase 10:** Settings pages, Quick Deploy page, and remaining UI routes. The API client already includes all endpoint wrappers needed.
+26 -1
View File
@@ -22,7 +22,9 @@ import type {
ValidationResult,
Volume,
VolumeScopeInfo,
BrowseResult
BrowseResult,
DnsZone,
DnsRecordView
} from './types';
// ── Helpers ─────────────────────────────────────────────────────────
@@ -268,6 +270,29 @@ export function listNpmCertificates(): Promise<NpmCertificate[]> {
return get<NpmCertificate[]>('/api/settings/npm-certificates');
}
// ── DNS ────────────────────────────────────────────────────────────
export function testDnsConnection(provider: string, token: string, zoneId: string): Promise<{ success: boolean; error?: string }> {
return post<{ success: boolean; error?: string }>('/api/settings/dns/test', { provider, token, zone_id: zoneId });
}
export function listDnsZones(token?: string): Promise<DnsZone[]> {
const params = token ? `?token=${encodeURIComponent(token)}` : '';
return get<DnsZone[]>(`/api/settings/dns/zones${params}`);
}
export function getDnsRecords(): Promise<DnsRecordView[]> {
return get<DnsRecordView[]>('/api/dns/records');
}
export function syncDnsRecords(): Promise<{ created: number; deleted: number; already_synced: number }> {
return post<{ created: number; deleted: number; already_synced: number }>('/api/dns/sync');
}
export function deleteDnsRecord(fqdn: string): Promise<void> {
return del<void>(`/api/dns/records/${encodeURIComponent(fqdn)}`);
}
// ── Health ──────────────────────────────────────────────────────────
export function getHealth(): Promise<{ docker: DockerHealth }> {
+59 -2
View File
@@ -16,7 +16,8 @@
"proxies": "Proxies",
"events": "Events",
"settings": "Settings",
"logout": "Log out"
"logout": "Log out",
"dns": "DNS Records"
},
"dashboard": {
"title": "Dashboard",
@@ -243,7 +244,26 @@
"noCertificate": "None (no SSL)",
"clearCertificate": "Clear",
"loadingCertificates": "Loading certificates...",
"noCertificatesFound": "No wildcard certificates found in NPM"
"noCertificatesFound": "No wildcard certificates found in NPM",
"dnsConfig": "DNS Configuration",
"wildcardDns": "Wildcard DNS is configured",
"wildcardDnsHelp": "When enabled, all subdomains resolve to your server via a wildcard DNS rule. Disable to manage DNS records per service.",
"dnsProvider": "DNS Provider",
"dnsProviderHelp": "Select a DNS provider for automatic record management",
"cloudflareApiToken": "Cloudflare API Token",
"cloudflareApiTokenHelp": "API token with DNS edit permissions for your zone",
"cloudflareApiTokenPlaceholder": "Enter Cloudflare API token",
"cloudflareApiTokenConfigured": "API token is configured",
"cloudflareZone": "Cloudflare Zone",
"cloudflareZoneHelp": "Select the DNS zone to manage records in",
"selectZone": "Select Zone",
"noZone": "No zone selected",
"loadingZones": "Loading zones...",
"noZonesFound": "No zones found for this token",
"testConnection": "Test Connection",
"testingConnection": "Testing...",
"connectionSuccess": "Connection successful",
"connectionFailed": "Connection failed"
},
"settingsRegistries": {
"title": "Container Registries",
@@ -540,6 +560,43 @@
"proxies": "Proxies",
"recentErrors": "Recent Errors"
},
"dns": {
"title": "DNS Records",
"description": "View and manage DNS records created by Docker Watcher.",
"wildcardActive": "Wildcard DNS Mode Active",
"wildcardActiveDesc": "DNS records are managed externally via wildcard DNS. Disable wildcard DNS in Settings to manage records individually.",
"refresh": "Refresh",
"syncNow": "Sync Now",
"syncing": "Syncing...",
"syncComplete": "Sync complete: {created} created, {deleted} deleted, {synced} already synced",
"syncFailed": "DNS sync failed",
"searchPlaceholder": "Search by FQDN...",
"allConsumers": "All consumers",
"managed": "Managed (instances)",
"standalone": "Standalone proxies",
"orphaned": "Orphaned",
"allStatuses": "All statuses",
"statusSynced": "Synced",
"statusMissing": "Missing",
"statusOrphaned": "Orphaned",
"columnFqdn": "FQDN",
"columnType": "Type",
"columnValue": "Value",
"columnConsumer": "Consumer",
"columnStatus": "Status",
"columnActions": "Actions",
"noConsumer": "No consumer",
"noRecords": "No DNS records found. Records will appear here when services are deployed.",
"noMatchingRecords": "No records match the current filters.",
"deleteRecord": "Delete record",
"recordDeleted": "DNS record {fqdn} deleted",
"deleteFailed": "Failed to delete DNS record",
"loadFailed": "Failed to load DNS records",
"totalRecords": "Total: {count}",
"syncedCount": "Synced: {count}",
"missingCount": "Missing: {count}",
"orphanedCount": "Orphaned: {count}"
},
"language": {
"en": "English",
"ru": "Russian"
+59 -2
View File
@@ -16,7 +16,8 @@
"proxies": "Прокси",
"events": "События",
"settings": "Настройки",
"logout": "Выйти"
"logout": "Выйти",
"dns": "DNS-записи"
},
"dashboard": {
"title": "Панель управления",
@@ -243,7 +244,26 @@
"noCertificate": "Нет (без SSL)",
"clearCertificate": "Очистить",
"loadingCertificates": "Загрузка сертификатов...",
"noCertificatesFound": "Wildcard-сертификаты в NPM не найдены"
"noCertificatesFound": "Wildcard-сертификаты в NPM не найдены",
"dnsConfig": "Настройки DNS",
"wildcardDns": "Wildcard DNS настроен",
"wildcardDnsHelp": "Когда включено, все поддомены разрешаются на ваш сервер через wildcard DNS правило. Отключите для управления DNS-записями для каждого сервиса.",
"dnsProvider": "DNS-провайдер",
"dnsProviderHelp": "Выберите DNS-провайдера для автоматического управления записями",
"cloudflareApiToken": "API-токен Cloudflare",
"cloudflareApiTokenHelp": "API-токен с правами редактирования DNS для вашей зоны",
"cloudflareApiTokenPlaceholder": "Введите API-токен Cloudflare",
"cloudflareApiTokenConfigured": "API-токен настроен",
"cloudflareZone": "Зона Cloudflare",
"cloudflareZoneHelp": "Выберите DNS-зону для управления записями",
"selectZone": "Выбрать зону",
"noZone": "Зона не выбрана",
"loadingZones": "Загрузка зон...",
"noZonesFound": "Зоны для этого токена не найдены",
"testConnection": "Проверить соединение",
"testingConnection": "Проверка...",
"connectionSuccess": "Соединение успешно",
"connectionFailed": "Ошибка соединения"
},
"settingsRegistries": {
"title": "Реестры контейнеров",
@@ -540,6 +560,43 @@
"proxies": "Прокси",
"recentErrors": "Недавние ошибки"
},
"dns": {
"title": "DNS-записи",
"description": "Просмотр и управление DNS-записями, созданными Docker Watcher.",
"wildcardActive": "Режим Wildcard DNS активен",
"wildcardActiveDesc": "DNS-записи управляются внешне через wildcard DNS. Отключите wildcard DNS в настройках для индивидуального управления записями.",
"refresh": "Обновить",
"syncNow": "Синхронизировать",
"syncing": "Синхронизация...",
"syncComplete": "Синхронизация завершена: {created} создано, {deleted} удалено, {synced} уже синхронизировано",
"syncFailed": "Ошибка синхронизации DNS",
"searchPlaceholder": "Поиск по FQDN...",
"allConsumers": "Все потребители",
"managed": "Управляемые (инстансы)",
"standalone": "Автономные прокси",
"orphaned": "Осиротевшие",
"allStatuses": "Все статусы",
"statusSynced": "Синхронизировано",
"statusMissing": "Отсутствует",
"statusOrphaned": "Осиротевшее",
"columnFqdn": "FQDN",
"columnType": "Тип",
"columnValue": "Значение",
"columnConsumer": "Потребитель",
"columnStatus": "Статус",
"columnActions": "Действия",
"noConsumer": "Нет потребителя",
"noRecords": "DNS-записи не найдены. Записи появятся здесь после развёртывания сервисов.",
"noMatchingRecords": "Нет записей, соответствующих текущим фильтрам.",
"deleteRecord": "Удалить запись",
"recordDeleted": "DNS-запись {fqdn} удалена",
"deleteFailed": "Не удалось удалить DNS-запись",
"loadFailed": "Не удалось загрузить DNS-записи",
"totalRecords": "Всего: {count}",
"syncedCount": "Синхронизировано: {count}",
"missingCount": "Отсутствует: {count}",
"orphanedCount": "Осиротевших: {count}"
},
"language": {
"en": "Английский",
"ru": "Русский"
+21
View File
@@ -108,9 +108,30 @@ export interface Settings {
ssl_certificate_id: number;
stale_threshold_days: number;
allowed_volume_paths: string;
wildcard_dns: boolean;
dns_provider: string;
has_cloudflare_api_token: boolean;
cloudflare_zone_id: string;
updated_at: string;
}
/** A DNS zone from a provider (e.g., Cloudflare). */
export interface DnsZone {
id: string;
name: string;
}
/** A DNS record view for the DNS Records page. */
export interface DnsRecordView {
fqdn: string;
type: string;
content: string;
consumer_type: string;
consumer_name: string;
consumer_id: string;
status: string;
}
/** An SSL certificate from Nginx Proxy Manager. */
export interface NpmCertificate {
id: number;
+4 -1
View File
@@ -6,7 +6,7 @@
import Toast from '$lib/components/Toast.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
import { IconDashboard, IconProjects, IconDeploy, IconProxies, IconEvents, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons';
import { IconDashboard, IconProjects, IconDeploy, IconProxies, IconEvents, IconSettings, IconMenu, IconX, IconLogout, IconGlobe } from '$lib/components/icons';
import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
import { isAuthenticated, clearAuth } from '$lib/auth';
import * as api from '$lib/api';
@@ -25,6 +25,7 @@
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
{ href: '/dns', labelKey: 'nav.dns', icon: 'globe' },
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
] as const;
@@ -170,6 +171,8 @@
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'proxies'}
<IconProxies size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'globe'}
<IconGlobe size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'events'}
<IconEvents size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'settings'}
+223
View File
@@ -0,0 +1,223 @@
<script lang="ts">
import { getDnsRecords, syncDnsRecords, deleteDnsRecord, getSettings } from '$lib/api';
import type { DnsRecordView } from '$lib/types';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconSearch, IconRefresh, IconTrash, IconLoader } from '$lib/components/icons';
import Skeleton from '$lib/components/Skeleton.svelte';
let loading = $state(true);
let records = $state<DnsRecordView[]>([]);
let wildcardDns = $state(true);
let syncing = $state(false);
// Filters
let searchQuery = $state('');
let filterConsumerType = $state('all');
let filterStatus = $state('all');
let filteredRecords = $derived.by(() => {
let result = records;
if (searchQuery) {
const q = searchQuery.toLowerCase();
result = result.filter(r => r.fqdn.toLowerCase().includes(q));
}
if (filterConsumerType !== 'all') {
if (filterConsumerType === 'orphaned') {
result = result.filter(r => r.consumer_type === '');
} else {
result = result.filter(r => r.consumer_type === filterConsumerType);
}
}
if (filterStatus !== 'all') {
result = result.filter(r => r.status === filterStatus);
}
return result;
});
async function loadRecords() {
loading = true;
try {
const settings = await getSettings();
wildcardDns = settings.wildcard_dns ?? true;
records = await getDnsRecords();
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('dns.loadFailed'));
} finally {
loading = false;
}
}
async function handleSync() {
syncing = true;
try {
const result = await syncDnsRecords();
toasts.success($t('dns.syncComplete', { created: String(result.created), deleted: String(result.deleted), synced: String(result.already_synced) }));
await loadRecords();
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('dns.syncFailed'));
} finally {
syncing = false;
}
}
async function handleDelete(fqdn: string) {
try {
await deleteDnsRecord(fqdn);
toasts.success($t('dns.recordDeleted', { fqdn }));
records = records.filter(r => r.fqdn !== fqdn);
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('dns.deleteFailed'));
}
}
async function handleRefresh() {
await loadRecords();
}
function statusColor(status: string): string {
switch (status) {
case 'synced': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
case 'missing': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
case 'orphaned': return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
case 'wildcard': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
default: return 'bg-gray-100 text-gray-700';
}
}
$effect(() => { loadRecords(); });
</script>
<svelte:head>
<title>{$t('dns.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
{#if loading}
<div class="space-y-4">
<Skeleton height="2rem" width="12rem" />
<Skeleton height="20rem" />
</div>
{:else}
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('dns.title')}</h1>
<p class="text-sm text-[var(--text-secondary)]">{$t('dns.description')}</p>
</div>
<div class="flex items-center gap-2">
<button
onclick={handleRefresh}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconRefresh size={16} />
{$t('dns.refresh')}
</button>
{#if !wildcardDns}
<button
onclick={handleSync}
disabled={syncing}
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
>
{#if syncing}<IconLoader size={16} />{/if}
{syncing ? $t('dns.syncing') : $t('dns.syncNow')}
</button>
{/if}
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3">
<div class="relative flex-1 min-w-[200px] max-w-sm">
<IconSearch size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
<input
type="text"
bind:value={searchQuery}
placeholder={$t('dns.searchPlaceholder')}
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)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
/>
</div>
<select bind:value={filterConsumerType}
class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)]">
<option value="all">{$t('dns.allConsumers')}</option>
<option value="instance">{$t('dns.managed')}</option>
<option value="standalone">{$t('dns.standalone')}</option>
<option value="orphaned">{$t('dns.orphaned')}</option>
</select>
{#if !wildcardDns}
<select bind:value={filterStatus}
class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)]">
<option value="all">{$t('dns.allStatuses')}</option>
<option value="synced">{$t('dns.statusSynced')}</option>
<option value="missing">{$t('dns.statusMissing')}</option>
<option value="orphaned">{$t('dns.statusOrphaned')}</option>
</select>
{/if}
</div>
<!-- Records Table -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)] overflow-hidden">
{#if filteredRecords.length === 0}
<div class="p-8 text-center text-sm text-[var(--text-tertiary)]">
{records.length === 0 ? $t('dns.noRecords') : $t('dns.noMatchingRecords')}
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[var(--border-primary)] bg-[var(--surface-card-hover)]">
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('dns.columnFqdn')}</th>
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('dns.columnType')}</th>
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('dns.columnValue')}</th>
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('dns.columnConsumer')}</th>
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('dns.columnStatus')}</th>
<th class="px-4 py-3 text-right font-medium text-[var(--text-secondary)]">{$t('dns.columnActions')}</th>
</tr>
</thead>
<tbody>
{#each filteredRecords as record}
<tr class="border-b border-[var(--border-primary)] last:border-b-0 hover:bg-[var(--surface-card-hover)] transition-colors">
<td class="px-4 py-3 font-mono text-xs text-[var(--text-primary)]">{record.fqdn}</td>
<td class="px-4 py-3 text-[var(--text-secondary)]">{record.type}</td>
<td class="px-4 py-3 font-mono text-xs text-[var(--text-secondary)]">{record.content}</td>
<td class="px-4 py-3">
{#if record.consumer_name}
<span class="text-[var(--text-primary)]">{record.consumer_name}</span>
<span class="ml-1 text-xs text-[var(--text-tertiary)]">({record.consumer_type})</span>
{:else}
<span class="text-[var(--text-tertiary)] italic">{$t('dns.noConsumer')}</span>
{/if}
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {statusColor(record.status)}">
{record.status}
</span>
</td>
<td class="px-4 py-3 text-right">
{#if record.status === 'orphaned' || record.status === 'missing'}
<button
onclick={() => handleDelete(record.fqdn)}
class="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors"
title={$t('dns.deleteRecord')}
>
<IconTrash size={14} />
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Summary -->
<div class="flex gap-4 text-xs text-[var(--text-tertiary)]">
<span>{$t('dns.totalRecords', { count: String(records.length) })}</span>
<span>{$t('dns.syncedCount', { count: String(records.filter(r => r.status === 'synced').length) })}</span>
<span>{$t('dns.missingCount', { count: String(records.filter(r => r.status === 'missing').length) })}</span>
<span>{$t('dns.orphanedCount', { count: String(records.filter(r => r.status === 'orphaned').length) })}</span>
</div>
{/if}
</div>
+184 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl, listNpmCertificates } from '$lib/api';
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl, listNpmCertificates, testDnsConnection, listDnsZones } from '$lib/api';
import type { EntityPickerItem } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
@@ -28,6 +28,18 @@
let certPickerItems = $state<EntityPickerItem[]>([]);
let loadingCerts = $state(false);
// DNS settings state.
let wildcardDns = $state(true);
let dnsProvider = $state('');
let cloudflareApiToken = $state('');
let hasCloudflareApiToken = $state(false);
let cloudflareZoneId = $state('');
let zonePickerOpen = $state(false);
let zonePickerItems = $state<EntityPickerItem[]>([]);
let loadingZones = $state(false);
let zoneName = $state('');
let testingDns = $state(false);
let errors = $state<Record<string, string>>({});
function validateDomain(value: string): string {
@@ -81,6 +93,10 @@
sslCertificateId = settings.ssl_certificate_id ?? 0;
notificationUrl = settings.notification_url ?? '';
staleThresholdDays = String(settings.stale_threshold_days ?? 7);
wildcardDns = settings.wildcard_dns ?? true;
dnsProvider = settings.dns_provider ?? '';
hasCloudflareApiToken = settings.has_cloudflare_api_token ?? false;
cloudflareZoneId = settings.cloudflare_zone_id ?? '';
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
} finally {
@@ -99,13 +115,20 @@
if (!validateAll()) return;
saving = true;
try {
await updateSettings({
const payload: Record<string, unknown> = {
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
ssl_certificate_id: sslCertificateId,
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7)
});
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
wildcard_dns: wildcardDns,
dns_provider: wildcardDns ? '' : dnsProvider,
cloudflare_zone_id: cloudflareZoneId
};
if (cloudflareApiToken) {
payload.cloudflare_api_token = cloudflareApiToken;
}
await updateSettings(payload as any);
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
@@ -174,9 +197,70 @@
}
}
async function openZonePicker() {
loadingZones = true;
zonePickerOpen = true;
try {
const token = cloudflareApiToken || undefined;
const zones = await listDnsZones(token);
zonePickerItems = zones.map((zone): EntityPickerItem => ({
value: zone.id,
label: zone.name,
description: zone.id
}));
if (zonePickerItems.length === 0) {
toasts.error($t('settingsGeneral.noZonesFound'));
zonePickerOpen = false;
}
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.noZonesFound'));
zonePickerOpen = false;
} finally {
loadingZones = false;
}
}
function handleZoneSelect(value: string) {
cloudflareZoneId = value;
const item = zonePickerItems.find((i) => i.value === value);
zoneName = item?.label ?? '';
zonePickerOpen = false;
}
async function handleTestDns() {
testingDns = true;
try {
const token = cloudflareApiToken || '';
const result = await testDnsConnection('cloudflare', token, cloudflareZoneId);
if (result.success) {
toasts.success($t('settingsGeneral.connectionSuccess'));
} else {
toasts.error(`${$t('settingsGeneral.connectionFailed')}: ${result.error}`);
}
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.connectionFailed'));
} finally {
testingDns = false;
}
}
async function resolveZoneName() {
if (!cloudflareZoneId) return;
try {
const zones = await listDnsZones();
const match = zones.find((z) => z.id === cloudflareZoneId);
zoneName = match?.name ?? cloudflareZoneId;
} catch {
zoneName = cloudflareZoneId;
}
}
async function init() {
await loadSettings();
await resolveCertName();
if (!wildcardDns && cloudflareZoneId) {
resolveZoneName();
}
loadWebhookUrlValue();
}
@@ -260,6 +344,93 @@
</div>
</div>
<!-- DNS Configuration -->
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
<h3 class="mb-3 text-sm font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.dnsConfig')}</h3>
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" bind:checked={wildcardDns}
class="h-4 w-4 rounded border-[var(--border-primary)] text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
<div>
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.wildcardDns')}</span>
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.wildcardDnsHelp')}</p>
</div>
</label>
{#if !wildcardDns}
<div class="mt-4 space-y-4 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-4">
<!-- DNS Provider -->
<div>
<label for="dnsProvider" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.dnsProvider')}</label>
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.dnsProviderHelp')}</p>
<select id="dnsProvider" bind:value={dnsProvider}
class="mt-1.5 w-full max-w-xs rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]">
<option value="">-- Select --</option>
<option value="cloudflare">Cloudflare</option>
</select>
</div>
{#if dnsProvider === 'cloudflare'}
<!-- Cloudflare API Token -->
<div>
<FormField
label={$t('settingsGeneral.cloudflareApiToken')}
name="cloudflareApiToken"
type="password"
bind:value={cloudflareApiToken}
placeholder={hasCloudflareApiToken ? '••••••••' : $t('settingsGeneral.cloudflareApiTokenPlaceholder')}
helpText={hasCloudflareApiToken ? $t('settingsGeneral.cloudflareApiTokenConfigured') : $t('settingsGeneral.cloudflareApiTokenHelp')}
/>
</div>
<!-- Zone Picker -->
<div>
<label for="cloudflareZoneBtn" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.cloudflareZone')}</label>
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.cloudflareZoneHelp')}</p>
<div class="mt-1.5 flex items-center gap-2">
<button
id="cloudflareZoneBtn"
type="button"
onclick={openZonePicker}
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
>
{#if loadingZones}
{$t('settingsGeneral.loadingZones')}
{:else if cloudflareZoneId && zoneName}
{zoneName}
{:else}
{$t('settingsGeneral.noZone')}
{/if}
</button>
{#if cloudflareZoneId}
<button
type="button"
onclick={() => { cloudflareZoneId = ''; zoneName = ''; }}
class="inline-flex items-center gap-1 rounded-lg border border-[var(--border-primary)] px-2 py-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card)] transition-colors"
>
<IconX size={14} />
</button>
{/if}
</div>
</div>
<!-- Test Connection -->
<div>
<button
type="button"
onclick={handleTestDns}
disabled={testingDns || (!cloudflareApiToken && !hasCloudflareApiToken)}
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-brand-600)] px-3 py-2 text-sm font-medium text-[var(--color-brand-600)] hover:bg-[var(--color-brand-50)] transition-colors disabled:opacity-50"
>
{#if testingDns}<IconLoader size={16} />{/if}
{testingDns ? $t('settingsGeneral.testingConnection') : $t('settingsGeneral.testConnection')}
</button>
</div>
{/if}
</div>
{/if}
</div>
<div class="mt-6">
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
{#if saving}<IconLoader size={16} />{/if}
@@ -314,3 +485,12 @@
onselect={handleCertSelect}
onclose={() => { certPickerOpen = false; }}
/>
<EntityPicker
bind:open={zonePickerOpen}
items={zonePickerItems}
current={cloudflareZoneId}
title={$t('settingsGeneral.selectZone')}
onselect={handleZoneSelect}
onclose={() => { zonePickerOpen = false; }}
/>