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

83
internal/config/config.go Normal file
View File

@@ -0,0 +1,83 @@
package config
import (
"os"
"strconv"
"strings"
)
type Config struct {
ServerPort string
DatabasePath string
TechnitiumURL string
TechnitiumUsername string
TechnitiumPassword string
TechnitiumToken string
BaseDomain string
SpaceSubdomain string
RateLimitPerIP int
RateLimitPerToken int
TrustedProxies []string
}
func Load() *Config {
cfg := &Config{
ServerPort: getEnv("SERVER_PORT", "8080"),
DatabasePath: getEnv("DATABASE_PATH", "./dyn.db"),
TechnitiumURL: getEnv("TECHNITIUM_URL", ""),
TechnitiumUsername: getEnv("TECHNITIUM_USERNAME", ""),
TechnitiumPassword: getEnv("TECHNITIUM_PASSWORD", ""),
TechnitiumToken: getEnv("TECHNITIUM_TOKEN", ""),
BaseDomain: getEnv("BASE_DOMAIN", "dws.rip"),
SpaceSubdomain: getEnv("SPACE_SUBDOMAIN", "space"),
RateLimitPerIP: getEnvAsInt("RATE_LIMIT_PER_IP", 10),
RateLimitPerToken: getEnvAsInt("RATE_LIMIT_PER_TOKEN", 1),
TrustedProxies: getEnvAsSlice("TRUSTED_PROXIES", []string{}),
}
return cfg
}
func (c *Config) Validate() []string {
var errors []string
if c.TechnitiumURL == "" {
errors = append(errors, "TECHNITIUM_URL is required")
}
if c.TechnitiumToken == "" && (c.TechnitiumUsername == "" || c.TechnitiumPassword == "") {
errors = append(errors, "Either TECHNITIUM_TOKEN or TECHNITIUM_USERNAME/PASSWORD must be provided")
}
return errors
}
func (c *Config) GetZone() string {
if c.SpaceSubdomain == "" {
return c.BaseDomain
}
return c.SpaceSubdomain + "." + c.BaseDomain
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvAsInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intVal, err := strconv.Atoi(value); err == nil {
return intVal
}
}
return defaultValue
}
func getEnvAsSlice(key string, defaultValue []string) []string {
if value := os.Getenv(key); value != "" {
return strings.Split(value, ",")
}
return defaultValue
}

219
internal/database/db.go Normal file
View File

@@ -0,0 +1,219 @@
package database
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"time"
"git.dws.rip/DWS/dyn/internal/models"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
conn *sql.DB
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err := conn.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
db := &DB{conn: conn}
if err := db.migrate(); err != nil {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
return db, nil
}
func (db *DB) migrate() error {
schema := `
CREATE TABLE IF NOT EXISTS spaces (
token TEXT PRIMARY KEY,
subdomain TEXT UNIQUE NOT NULL,
last_ip TEXT,
updated_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_subdomain ON spaces(subdomain);
`
_, err := db.conn.Exec(schema)
return err
}
func (db *DB) Close() error {
return db.conn.Close()
}
func (db *DB) CreateSpace(ctx context.Context, subdomain string) (*models.Space, error) {
token, err := generateToken()
if err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
space := &models.Space{
Token: token,
Subdomain: subdomain,
CreatedAt: time.Now(),
}
query := `
INSERT INTO spaces (token, subdomain, last_ip, updated_at, created_at)
VALUES (?, ?, ?, ?, ?)
`
_, err = db.conn.ExecContext(ctx, query,
space.Token,
space.Subdomain,
space.LastIP,
space.UpdatedAt,
space.CreatedAt,
)
if err != nil {
if isUniqueConstraintError(err) {
return nil, fmt.Errorf("subdomain already taken")
}
return nil, fmt.Errorf("failed to create space: %w", err)
}
return space, nil
}
func (db *DB) GetSpaceByToken(ctx context.Context, token string) (*models.Space, error) {
query := `
SELECT token, subdomain, last_ip, updated_at, created_at
FROM spaces
WHERE token = ?
`
row := db.conn.QueryRowContext(ctx, query, token)
space := &models.Space{}
var updatedAt sql.NullTime
err := row.Scan(
&space.Token,
&space.Subdomain,
&space.LastIP,
&updatedAt,
&space.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get space: %w", err)
}
if updatedAt.Valid {
space.UpdatedAt = updatedAt.Time
}
return space, nil
}
func (db *DB) GetSpaceBySubdomain(ctx context.Context, subdomain string) (*models.Space, error) {
query := `
SELECT token, subdomain, last_ip, updated_at, created_at
FROM spaces
WHERE subdomain = ?
`
row := db.conn.QueryRowContext(ctx, query, subdomain)
space := &models.Space{}
var updatedAt sql.NullTime
err := row.Scan(
&space.Token,
&space.Subdomain,
&space.LastIP,
&updatedAt,
&space.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get space: %w", err)
}
if updatedAt.Valid {
space.UpdatedAt = updatedAt.Time
}
return space, nil
}
func (db *DB) UpdateSpaceIP(ctx context.Context, token string, ip string) error {
query := `
UPDATE spaces
SET last_ip = ?, updated_at = ?
WHERE token = ?
`
_, err := db.conn.ExecContext(ctx, query, ip, time.Now(), token)
if err != nil {
return fmt.Errorf("failed to update space IP: %w", err)
}
return nil
}
func (db *DB) SubdomainExists(ctx context.Context, subdomain string) (bool, error) {
query := `SELECT 1 FROM spaces WHERE subdomain = ?`
row := db.conn.QueryRowContext(ctx, query, subdomain)
var exists int
err := row.Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to check subdomain: %w", err)
}
return true, nil
}
func generateToken() (string, error) {
bytes := make([]byte, 24)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return contains(errStr, "UNIQUE constraint failed") ||
contains(errStr, "duplicate key value")
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || containsInternal(s, substr))
}
func containsInternal(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

184
internal/dns/technitium.go Normal file
View File

@@ -0,0 +1,184 @@
package dns
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type Client struct {
baseURL string
token string
username string
password string
httpClient *http.Client
}
type AddRecordRequest struct {
Domain string `json:"domain"`
Type string `json:"type"`
IPAddress string `json:"ipAddress,omitempty"`
Overwrite bool `json:"overwrite"`
TTL int `json:"ttl,omitempty"`
}
type AddRecordResponse struct {
Status string `json:"status"`
ErrorCode string `json:"errorCode,omitempty"`
Error string `json:"errorMessage,omitempty"`
}
type APIResponse struct {
Status string `json:"status"`
Response json.RawMessage `json:"response"`
Error *APIError `json:"error,omitempty"`
}
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func NewClient(baseURL, token, username, password string) *Client {
return &Client{
baseURL: baseURL,
token: token,
username: username,
password: password,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
func (c *Client) AddARecord(zone, hostname, ip string, ttl int) error {
domain := fmt.Sprintf("%s.%s", hostname, zone)
reqBody := AddRecordRequest{
Domain: domain,
Type: "A",
IPAddress: ip,
Overwrite: true,
}
if ttl > 0 {
reqBody.TTL = ttl
}
return c.addRecord(reqBody)
}
func (c *Client) AddWildcardARecord(zone, hostname, ip string, ttl int) error {
domain := fmt.Sprintf("*.%s.%s", hostname, zone)
reqBody := AddRecordRequest{
Domain: domain,
Type: "A",
IPAddress: ip,
Overwrite: true,
}
if ttl > 0 {
reqBody.TTL = ttl
}
return c.addRecord(reqBody)
}
func (c *Client) addRecord(req AddRecordRequest) error {
endpoint := fmt.Sprintf("%s/api/dns/records/add", c.baseURL)
formData := url.Values{}
formData.Set("domain", req.Domain)
formData.Set("type", req.Type)
formData.Set("ipAddress", req.IPAddress)
formData.Set("overwrite", fmt.Sprintf("%t", req.Overwrite))
if req.TTL > 0 {
formData.Set("ttl", fmt.Sprintf("%d", req.TTL))
}
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(formData.Encode()))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if c.token != "" {
httpReq.Header.Set("Authorization", "Basic "+c.token)
} else if c.username != "" && c.password != "" {
httpReq.SetBasicAuth(c.username, c.password)
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
}
var apiResp APIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if apiResp.Status != "ok" {
if apiResp.Error != nil {
return fmt.Errorf("API error: %s - %s", apiResp.Error.Code, apiResp.Error.Message)
}
return fmt.Errorf("API error: status not ok")
}
return nil
}
func (c *Client) DeleteRecord(zone, hostname, recordType string) error {
endpoint := fmt.Sprintf("%s/api/dns/records/delete", c.baseURL)
domain := fmt.Sprintf("%s.%s", hostname, zone)
if hostname == "" || hostname == "@" {
domain = zone
}
formData := url.Values{}
formData.Set("domain", domain)
formData.Set("type", recordType)
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(formData.Encode()))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if c.token != "" {
httpReq.Header.Set("Authorization", "Basic "+c.token)
} else if c.username != "" && c.password != "" {
httpReq.SetBasicAuth(c.username, c.password)
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
}
return nil
}

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
}

View File

@@ -0,0 +1,112 @@
package middleware
import (
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type RateLimiter struct {
ipLimits map[string]*RateLimitEntry
tokenLimits map[string]*RateLimitEntry
mu sync.RWMutex
limitPerIP int
limitPerToken int
}
type RateLimitEntry struct {
Count int
ResetTime time.Time
}
func NewRateLimiter(perIP, perToken int) *RateLimiter {
return &RateLimiter{
ipLimits: make(map[string]*RateLimitEntry),
tokenLimits: make(map[string]*RateLimitEntry),
limitPerIP: perIP,
limitPerToken: perToken,
}
}
func (rl *RateLimiter) RateLimitByIP() gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
rl.mu.Lock()
entry, exists := rl.ipLimits[ip]
now := time.Now()
if !exists || now.After(entry.ResetTime) {
rl.ipLimits[ip] = &RateLimitEntry{
Count: 1,
ResetTime: now.Add(time.Minute),
}
rl.mu.Unlock()
c.Next()
return
}
if entry.Count >= rl.limitPerIP {
rl.mu.Unlock()
c.String(http.StatusTooManyRequests, "rate limit exceeded")
c.Abort()
return
}
entry.Count++
rl.mu.Unlock()
c.Next()
}
}
func (rl *RateLimiter) RateLimitByToken() gin.HandlerFunc {
return func(c *gin.Context) {
token := extractToken(c)
if token == "" {
c.Next()
return
}
rl.mu.Lock()
entry, exists := rl.tokenLimits[token]
now := time.Now()
if !exists || now.After(entry.ResetTime) {
rl.tokenLimits[token] = &RateLimitEntry{
Count: 1,
ResetTime: now.Add(time.Minute),
}
rl.mu.Unlock()
c.Next()
return
}
if entry.Count >= rl.limitPerToken {
rl.mu.Unlock()
c.String(http.StatusTooManyRequests, "rate limit exceeded")
c.Abort()
return
}
entry.Count++
rl.mu.Unlock()
c.Next()
}
}
func extractToken(c *gin.Context) string {
auth := c.GetHeader("Authorization")
if auth == "" {
return ""
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Basic" {
return ""
}
return parts[1]
}

35
internal/models/space.go Normal file
View File

@@ -0,0 +1,35 @@
package models
import (
"time"
)
type Space struct {
Token string `json:"token" db:"token"`
Subdomain string `json:"subdomain" db:"subdomain"`
LastIP string `json:"last_ip" db:"last_ip"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type CreateSpaceRequest struct {
Subdomain string `json:"subdomain" binding:"required,alphanumdash,min=3,max=63"`
}
type CreateSpaceResponse struct {
Token string `json:"token"`
Subdomain string `json:"subdomain"`
FQDN string `json:"fqdn"`
CreatedAt time.Time `json:"created_at"`
}
type SpaceInfo struct {
Subdomain string `json:"subdomain"`
LastIP string `json:"last_ip"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
func (s *Space) GetFQDN(zone string) string {
return s.Subdomain + "." + zone
}