Files
updns/internal/server/server.go

174 lines
5.3 KiB
Go

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)
}
}