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, ok := selectProvider(cfg) if !ok { c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": "provider not configured"}) 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 { prov, ok := selectProvider(cfg) if !ok { return fmt.Errorf("unsupported provider: %s", cfg.Upstream.Provider) } // drop unused to avoid compile error _ = prov 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) } // selectProvider returns the configured Provider or false if unsupported. func selectProvider(cfg *config.Config) (pvd.Provider, bool) { switch cfg.Upstream.Provider { case "hetzner": return hetzner.NewProvider(cfg.Upstream.Hetzner.APIToken), true default: return nil, false } }