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:
2026-02-01 16:37:09 -05:00
commit 2470f121e2
16 changed files with 1835 additions and 0 deletions

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