Initial commit: DDNS service with NIC V2 protocol support
Features: - Token-based subdomain claiming - NIC V2 (DynDNS2) protocol implementation - Technitium DNS integration - Rate limiting (10 req/min IP, 1 req/min token) - Web UI for space claiming - Docker/Docker Compose support - Compatible with UniFi, pfSense, EdgeRouter Module: git.dws.rip/DWS/dyn
This commit is contained in:
229
internal/handlers/handlers.go
Normal file
229
internal/handlers/handlers.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.dws.rip/DWS/dyn/internal/config"
|
||||
"git.dws.rip/DWS/dyn/internal/database"
|
||||
"git.dws.rip/DWS/dyn/internal/dns"
|
||||
"git.dws.rip/DWS/dyn/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WebHandler struct {
|
||||
db *database.DB
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewWebHandler(db *database.DB, cfg *config.Config) *WebHandler {
|
||||
return &WebHandler{
|
||||
db: db,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebHandler) Index(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"baseDomain": h.config.BaseDomain,
|
||||
"spaceSubdomain": h.config.SpaceSubdomain,
|
||||
"zone": h.config.GetZone(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebHandler) ClaimSpace(c *gin.Context) {
|
||||
var req models.CreateSpaceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
subdomain := strings.ToLower(strings.TrimSpace(req.Subdomain))
|
||||
|
||||
if !isValidSubdomain(subdomain) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subdomain format"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
exists, err := h.db.SubdomainExists(ctx, subdomain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check subdomain availability"})
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Subdomain already taken"})
|
||||
return
|
||||
}
|
||||
|
||||
space, err := h.db.CreateSpace(ctx, subdomain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create space"})
|
||||
return
|
||||
}
|
||||
|
||||
resp := models.CreateSpaceResponse{
|
||||
Token: space.Token,
|
||||
Subdomain: space.Subdomain,
|
||||
FQDN: space.GetFQDN(h.config.GetZone()),
|
||||
CreatedAt: space.CreatedAt,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func (h *WebHandler) CheckSubdomain(c *gin.Context) {
|
||||
subdomain := strings.ToLower(strings.TrimSpace(c.Query("subdomain")))
|
||||
|
||||
if subdomain == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Subdomain parameter required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
exists, err := h.db.SubdomainExists(ctx, subdomain)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check subdomain"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"available": !exists,
|
||||
"subdomain": subdomain,
|
||||
})
|
||||
}
|
||||
|
||||
func isValidSubdomain(subdomain string) bool {
|
||||
if len(subdomain) < 3 || len(subdomain) > 63 {
|
||||
return false
|
||||
}
|
||||
|
||||
if subdomain[0] == '-' || subdomain[len(subdomain)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, ch := range subdomain {
|
||||
if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
type DynDNSHandler struct {
|
||||
db *database.DB
|
||||
dns *dns.Client
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewDynDNSHandler(db *database.DB, dnsClient *dns.Client, cfg *config.Config) *DynDNSHandler {
|
||||
return &DynDNSHandler{
|
||||
db: db,
|
||||
dns: dnsClient,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DynDNSHandler) Update(c *gin.Context) {
|
||||
token, err := extractBasicAuthPassword(c)
|
||||
if err != nil {
|
||||
c.String(http.StatusUnauthorized, "badauth")
|
||||
return
|
||||
}
|
||||
|
||||
hostname := c.Query("hostname")
|
||||
myip := c.Query("myip")
|
||||
|
||||
if hostname == "" {
|
||||
c.String(http.StatusBadRequest, "nohost")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
space, err := h.db.GetSpaceByToken(ctx, token)
|
||||
if err != nil {
|
||||
c.String(http.StatusServiceUnavailable, "911")
|
||||
return
|
||||
}
|
||||
if space == nil {
|
||||
c.String(http.StatusUnauthorized, "badauth")
|
||||
return
|
||||
}
|
||||
|
||||
expectedFQDN := space.GetFQDN(h.config.GetZone())
|
||||
if hostname != expectedFQDN {
|
||||
c.String(http.StatusBadRequest, "nohost")
|
||||
return
|
||||
}
|
||||
|
||||
if myip == "" {
|
||||
myip = c.ClientIP()
|
||||
}
|
||||
|
||||
if net.ParseIP(myip) == nil {
|
||||
c.String(http.StatusBadRequest, "dnserr")
|
||||
return
|
||||
}
|
||||
|
||||
zone := h.config.GetZone()
|
||||
|
||||
err = h.dns.AddARecord(zone, space.Subdomain, myip, 300)
|
||||
if err != nil {
|
||||
c.String(http.StatusServiceUnavailable, "911")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.dns.AddWildcardARecord(zone, space.Subdomain, myip, 300)
|
||||
if err != nil {
|
||||
c.String(http.StatusServiceUnavailable, "911")
|
||||
return
|
||||
}
|
||||
|
||||
if space.LastIP == myip {
|
||||
c.String(http.StatusOK, "nochg %s", myip)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.db.UpdateSpaceIP(ctx, token, myip)
|
||||
if err != nil {
|
||||
c.String(http.StatusServiceUnavailable, "911")
|
||||
return
|
||||
}
|
||||
|
||||
c.String(http.StatusOK, "good %s", myip)
|
||||
}
|
||||
|
||||
func extractBasicAuthPassword(c *gin.Context) (string, error) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
const prefix = "Basic "
|
||||
if !strings.HasPrefix(auth, prefix) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return parts[1], nil
|
||||
}
|
||||
Reference in New Issue
Block a user