package server import ( "fmt" "net/http" "strings" "time" "git.cloonar.com/cloonar/updns/internal/config" pvd "git.cloonar.com/cloonar/updns/internal/provider" "git.cloonar.com/cloonar/updns/internal/provider/hetzner" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promhttp" "go.uber.org/zap" ) var ( totalUpdates = promauto.NewCounter(prometheus.CounterOpts{ Name: "updns_total_updates", Help: "Total number of update requests", }) successUpdates = promauto.NewCounter(prometheus.CounterOpts{ Name: "updns_success_updates", Help: "Number of successful updates", }) failedUpdates = promauto.NewCounter(prometheus.CounterOpts{ Name: "updns_failed_updates", Help: "Number of failed updates", }) exactAuth = promauto.NewCounter(prometheus.CounterOpts{ Name: "updns_exact_auth", Help: "Number of updates authorized via exact match", }) wildcardAuth = promauto.NewCounter(prometheus.CounterOpts{ Name: "updns_wildcard_auth", Help: "Number of updates authorized via wildcard", }) ) type updateRequest struct { Key string `json:"key" binding:"required"` Secret string `json:"secret" binding:"required"` Host string `json:"host" binding:"required"` IP string `json:"ip"` } // NewRouter constructs the HTTP handler with routes, middleware, logging and metrics. func NewRouter(cfg *config.Config) *gin.Engine { logger, _ := zap.NewProduction() r := gin.New() r.Use(gin.Recovery()) r.Use(func(c *gin.Context) { start := time.Now() c.Next() logger.Info("request", zap.String("method", c.Request.Method), zap.String("path", c.Request.URL.Path), zap.Int("status", c.Writer.Status()), zap.Duration("duration", time.Since(start)), ) }) r.GET("/metrics", gin.WrapH(promhttp.Handler())) r.POST("/update", func(c *gin.Context) { totalUpdates.Inc() var req updateRequest if err := c.ShouldBindJSON(&req); err != nil { failedUpdates.Inc() logger.Error("invalid request", zap.Error(err)) c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": err.Error()}) return } ip := req.IP if ip == "" { ip = c.ClientIP() } clientCfg, ok := cfg.Clients[req.Key] if !ok || req.Secret != clientCfg.Secret { failedUpdates.Inc() c.JSON(http.StatusUnauthorized, gin.H{"status": "error", "message": "invalid key or secret"}) return } authorized := false for _, h := range clientCfg.Exact { if req.Host == h { authorized = true exactAuth.Inc() break } } if !authorized { for _, base := range clientCfg.Wildcard { if req.Host == base || strings.HasSuffix(req.Host, "."+base) { authorized = true wildcardAuth.Inc() break } } } if !authorized { failedUpdates.Inc() c.JSON(http.StatusForbidden, gin.H{"status": "error", "message": "host not authorized"}) return } prov, err := selectProvider(cfg) if err != nil { logger.Error("provider selection failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": "provider configuration error"}) return } if err := prov.UpdateRecord(c.Request.Context(), req.Host, ip); err != nil { failedUpdates.Inc() logger.Error("update record failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": "update failed"}) return } successUpdates.Inc() c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Record updated"}) }) return r } // StartServer initializes Provider and starts the HTTP server. func StartServer(cfg *config.Config) error { // Provider selection happens within the request handler now to handle potential config errors per request // We could pre-validate the provider config here, but deferring allows checking file existence/permissions closer to use. // A simple check that the provider *name* is supported is still useful. if _, supported := configToProviderName(cfg.Upstream.Provider); !supported { return fmt.Errorf("unsupported provider name in config: %q", cfg.Upstream.Provider) } router := NewRouter(cfg) if cfg.Server.TLS.Enabled { return router.RunTLS(cfg.Server.BindAddress, cfg.Server.TLS.CertFile, cfg.Server.TLS.KeyFile) } return router.Run(cfg.Server.BindAddress) } // configToProviderName checks if a provider name from the config is known. // This is a simple check before attempting full provider initialization. func configToProviderName(providerName string) (string, bool) { switch providerName { case "hetzner": return "hetzner", true default: return "", false } } // selectProvider returns the configured Provider or an error if initialization fails. func selectProvider(cfg *config.Config) (pvd.Provider, error) { providerName, supported := configToProviderName(cfg.Upstream.Provider) if !supported { return nil, fmt.Errorf("unsupported provider: %s", cfg.Upstream.Provider) } switch providerName { case "hetzner": prov, err := hetzner.NewProvider(cfg.Upstream.Hetzner) if err != nil { return nil, fmt.Errorf("initializing hetzner provider: %w", err) } return prov, nil default: // This case should technically be unreachable due to the check above return nil, fmt.Errorf("internal error: unsupported provider %s passed initial check", providerName) } }