w3K 7983a3fb49 v2.0.0
# Complete Enhancement Package - Major Feature Update

Comprehensive enhancement package for docker-ddns-server including security features, modern authentication, UI/UX improvements, and production-ready deployment features.

## 🔒 Security & Authentication

### IP Blocking System
- Implemented automatic IP blocking after 3 failed authentication attempts within 72 hours
- Added 7-day block duration with automatic expiration
- Created `blocked_ips` database table for tracking blocked addresses
- Added automatic cleanup of expired blocks
- Implemented manual IP unblock capability via security dashboard

### Failed Authentication Logging
- Added comprehensive failed authentication logging system
- Created `failed_auths` database table storing IP, timestamp, username, and password
- Implemented threat intelligence features for password pattern analysis
- Added automatic cleanup of old authentication records
- Logs intentionally include passwords for single-user security analysis

### Session-Based Authentication
- Replaced HTTP Basic Auth with modern session-based authentication for admin panel
- Integrated gorilla/sessions library for secure session management
- Added configurable session secrets via `DDNS_SESSION_SECRET` environment variable
- Implemented "Remember Me" functionality with 30-day session duration
- Added proper session destruction on logout
- Session cookies configured with HttpOnly, Secure, and SameSite attributes
- Maintained HTTP Basic Auth for DynDNS API endpoints (device compatibility)

### HTTPS Enforcement
- Added intelligent HTTPS detection via multiple header checks
- Implemented automatic HTTPS redirect for admin panel when available
- Graceful HTTP fallback when HTTPS unavailable
- Supports reverse proxy configurations (nginx, Caddy, Traefik)
- Detects SSL via X-Forwarded-Proto, X-Forwarded-Ssl, X-Url-Scheme headers
- API endpoints remain HTTP-compatible for device support

## 🎨 UI/UX Enhancements

### Authentication UI
- Created modern login page with gradient background and clean design
- Added HTTPS security indicator (✓ green / ⚠ yellow)
- Implemented auto-focus on username field
- Added clear error messages for failed login attempts
- Created logout confirmation page with redirect options
- Removed browser authentication dialog popups

### Navigation & Layout
- Changed admin panel URL from `/admin` to `/@` for uniqueness
- Updated navigation with unicode icons (🏠 Dashboard, 🔒 Security, ⏏️ Logout)
- Added tooltips to all navigation icons
- Implemented sticky header that remains visible on scroll
- Enhanced responsive design for mobile/tablet access

### Logo Support
- Added automatic logo detection and display
- Supports PNG, WebP, and SVG formats
- Checks `/static/icons/` for logo files
- Graceful fallback to text title if no logo found
- Maintains aspect ratio and responsive sizing

### Security Dashboard
- Created comprehensive security overview page at `/@/security`
- Added statistics cards showing active blocks, failed attempts, and total blocks
- Implemented recent failed attempts table with sortable columns
- Added password reveal/hide functionality with confirmation prompts
- Created detailed blocked IPs management page with unblock capability
- Created detailed failed authentication logs page with full history
- Added visual indicators for security status

## 📊 Data Management

### Data Consistency & Normalization
- Implemented automatic lowercase conversion for all usernames and hostnames
- Prevents case-sensitivity issues in DNS lookups and authentication
- Ensures consistent data storage and retrieval
- Handles mixed-case legacy data gracefully

### Automatic Migration
- Added on-the-fly migration system for legacy uppercase entries
- Migration triggers automatically on first `/@/hosts` page visit
- Handles hostname conflicts by appending sequential numbers
- Provides detailed migration report in UI showing all changes
- Non-destructive migration preserves all host data
- One-time execution with persistent migration status tracking

### Validation Updates
- Reduced minimum hostname length to 1 character (allows single-letter subdomains)
- Reduced minimum username length to 1 character
- Reduced minimum password length to 6 characters
- Maintained security while improving flexibility

### Username Uniqueness
- Removed uniqueness constraint on usernames
- Allows multiple hosts to share the same username
- Supports different passwords for same username across hosts
- Enables more flexible credential management strategies

## 🛡️ Middleware & Request Handling

### IP Blocker Middleware
- Created IPBlockerMiddleware to check requests against blocked IPs
- Automatic redirect to 127.0.0.1 for blocked addresses
- Lightweight performance impact with database lookup
- Positioned early in middleware chain for efficiency

### Session Authentication Middleware
- Created SessionAuthMiddleware for admin panel protection
- Skips authentication check for /login and /logout routes
- Redirects unauthenticated users to login page
- Validates session integrity on every request
- Compatible with reverse proxy configurations

### HTTPS Redirect Middleware
- Created HTTPSRedirectMiddleware for admin panel security
- Intelligent detection of HTTPS availability
- Skips redirect for API endpoints
- Handles X-Forwarded-* headers from reverse proxies
- Graceful operation when HTTPS unavailable

## 🗄️ Database & Models

### New Tables
- Added `failed_auths` table for authentication logging
- Added `blocked_ips` table for IP block tracking
- Proper foreign key relationships and indexes
- Automatic timestamps on all records

### Cleanup Functions
- Implemented automatic cleanup of expired IP blocks
- Implemented automatic cleanup of old authentication logs
- Configurable retention periods
- Background cleanup execution

## 🔧 Technical Improvements

### Dependencies
- Added `github.com/gorilla/sessions@v1.2.2` for session management
- Updated go.mod with proper version constraints
- Maintained compatibility with existing dependencies

### Handler Architecture
- Separated security logic into dedicated handler files
- Created `security.go` for blocking logic and logging
- Created `security_dashboard.go` for UI handlers
- Created `auth.go` for login/logout and session management
- Created `session.go` for session store implementation
- Improved code organization and maintainability

### Main Application
- Updated routing to support session-based authentication
- Added session initialization on startup
- Configured route groups for admin panel and API
- Middleware ordering optimized for performance and security

## 🐳 Docker & CI/CD

### Multi-Platform Builds & Automated Releases
- Created GitHub Actions workflow (`BuildEmAll.yml`) for automated Docker builds
- Supports linux/amd64, linux/386, linux/arm/v7, and linux/arm64 platforms
- Automatic builds on push to master with dyndns/ directory changes
- Intelligent version tagging system:
  - Extracts version from commit message (e.g., "v1.2.3 Feature description")
  - Auto-increments patch version from latest git tag
  - Falls back to date-based versioning (vYY.MM.DD-HHMM) if no tags exist
- Tags images with both `:latest` and semantic version tags (`:vX.Y.Z`)
- Automatic GitHub release creation with each build
- Release includes Docker image reference and commit message as notes
- Publishes to Docker Hub (w3kllc/ddns)
- Cross-platform compatibility for ARM devices (Raspberry Pi, etc.)
- Workflow can be triggered manually via GitHub Actions UI

### Deployment
- Enhanced docker-compose.yml example with all new features
- Added documentation for environment variable configuration
- Included reverse proxy configuration examples
- Added security best practices for production deployment
- Semantic versioning with automatic release management

## 📝 Documentation

### README Enhancements
- Added comprehensive Security Features section
- Added Environment Variables reference with descriptions
- Added Admin Panel Access documentation
- Added Data Consistency & Migration guide
- Added API Endpoints documentation
- Added UI/UX Enhancements overview
- Added Reverse Proxy Configuration examples
- Added Docker Configuration best practices
- Added CI/CD & Multi-Platform Support details with versioning strategy
- Added Semantic Versioning documentation
- Added GitHub Release automation details
- Added Security Best Practices recommendations
- Added Threat Intelligence rationale
- Added Migration Guide from original project
- Added Troubleshooting section
- Added API Reference documentation
- Added Roadmap for future features
- Updated Credits section
- Added Support and Community links

## 🔄 Backward Compatibility

### Maintained Features
- DynDNS API endpoints remain unchanged (/update, /nic/update, etc.)
- HTTP Basic Auth still supported for API (device compatibility)
- Existing host configurations continue working without changes
- Database schema additions are non-breaking
- All original functionality preserved

### Breaking Changes
- Admin panel URL changed from `/admin` to `/@` (intentional, more unique)
- Admin authentication method changed (sessions vs basic auth)
- Requires `DDNS_SESSION_SECRET` environment variable for session security

##  Performance Considerations

- IP blocker checks are optimized with database indexing
- Session validation cached in memory
- Automatic cleanup runs asynchronously
- Minimal overhead on API endpoint performance
- Efficient middleware ordering

## 🎯 Testing Considerations

Recommended testing areas:
- Login/logout flow with and without HTTPS
- IP blocking after 3 failed attempts
- Session persistence with remember me
- API endpoint authentication (device compatibility)
- HTTPS redirect with reverse proxy headers
- Password reveal/hide in security dashboard
- Hostname migration for legacy uppercase entries
- Multi-platform Docker image functionality

---

**Total Changes:**
- **21 files modified**
- **20 new files created**
- **~2000+ lines of code added**
- **100+ hours of development time**

**Compatibility:**
-  Backward compatible for DynDNS API
- ⚠️ Admin panel URL changed (bookmark update needed)
-  All existing hosts continue working
-  Database schema additions are additive

**Credits:**
- Original project: dprandzioch/docker-ddns
- Web UI Fork: benjaminbear/docker-ddns-server
- Enhanced fork: w3K-one/docker-ddns-server
- Major enhancements and security features added

This represents a significant enhancement to the original project while maintaining the core DynDNS functionality and adding modern security, authentication, and user experience improvements suitable for production deployment.
2025-10-11 16:07:36 -04:00

398 lines
12 KiB
Go

package handler
import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
l "github.com/labstack/gommon/log"
"github.com/w3K-one/docker-ddns-server/dyndns/model"
"github.com/w3K-one/docker-ddns-server/dyndns/nswrapper"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
)
const (
UNAUTHORIZED = "You are not allowed to view that content"
)
// GetHost fetches a host from the database by "id".
func (h *Handler) GetHost(c echo.Context) (err error) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
host := &model.Host{}
if err = h.DB.First(host, id).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
// Display site
return c.JSON(http.StatusOK, id)
}
// ListHosts fetches all hosts from database, performs an on-the-fly migration to lowercase, and lists them on the website.
func (h *Handler) ListHosts(c echo.Context) (err error) {
var hosts []model.Host
if err = h.DB.Find(&hosts).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
var changesMade []string
needsMigration := false
// Use a map to track existing lowercase hostnames to detect conflicts
existingLowercaseHosts := make(map[string]bool)
for _, host := range hosts {
// Key for host map is a combination of hostname and domain
hostKey := fmt.Sprintf("%s.%s", host.Hostname, host.Domain)
existingLowercaseHosts[hostKey] = true
}
// Transaction to perform all updates at once for data integrity
err = h.DB.Transaction(func(tx *gorm.DB) error {
for i := range hosts {
originalHostname := hosts[i].Hostname
originalUsername := hosts[i].UserName
lowerHostname := strings.ToLower(originalHostname)
lowerUsername := strings.ToLower(originalUsername)
isHostnameLower := originalHostname == lowerHostname
isUsernameLower := originalUsername == lowerUsername
if isHostnameLower && isUsernameLower {
continue // Skip if already lowercase
}
needsMigration = true
hostToUpdate := &hosts[i]
// --- Handle Hostname Migration ---
if !isHostnameLower {
finalHostname := lowerHostname
hostKey := fmt.Sprintf("%s.%s", finalHostname, hostToUpdate.Domain)
if _, exists := existingLowercaseHosts[hostKey]; exists {
for j := 1; ; j++ {
newHostname := fmt.Sprintf("%s%d", lowerHostname, j)
newHostKey := fmt.Sprintf("%s.%s", newHostname, hostToUpdate.Domain)
if _, existsInner := existingLowercaseHosts[newHostKey]; !existsInner {
finalHostname = newHostname
break
}
}
}
hostToUpdate.Hostname = finalHostname
// Add new name to map to prevent collisions within the same run
existingLowercaseHosts[fmt.Sprintf("%s.%s", finalHostname, hostToUpdate.Domain)] = true
changesMade = append(changesMade, fmt.Sprintf("Hostname '%s' was changed to '%s'.", originalHostname, finalHostname))
}
// --- Handle Username Migration ---
if !isUsernameLower {
hostToUpdate.UserName = lowerUsername // Simply convert to lowercase
changesMade = append(changesMade, fmt.Sprintf("Username '%s' for host '%s' was changed to '%s'.", originalUsername, hostToUpdate.Hostname, lowerUsername))
}
if err := tx.Save(hostToUpdate).Error; err != nil {
return err // Rollback on error
}
}
return nil // Commit
})
if err != nil {
return c.JSON(http.StatusInternalServerError, Error{Message: "Failed to migrate database entries: " + err.Error()})
}
// If a migration happened, re-query to show the final, updated list
if needsMigration {
if err = h.DB.Find(&hosts).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
}
migrationReport := ""
if len(changesMade) > 0 {
migrationReport = strings.Join(changesMade, "\n")
}
return c.Render(http.StatusOK, "listhosts", echo.Map{
"hosts": &hosts,
"title": h.Title,
"logoPath": h.LogoPath,
"migrationReport": migrationReport,
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
})
}
// AddHost just renders the "add host" website.
func (h *Handler) AddHost(c echo.Context) (err error) {
return c.Render(http.StatusOK, "edithost", echo.Map{
"addEdit": "add",
"config": h.Config,
"title": h.Title,
"logoPath": h.LogoPath,
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
})
}
// EditHost fetches a host by "id" and renders the "edit host" website.
func (h *Handler) EditHost(c echo.Context) (err error) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
host := &model.Host{}
if err = h.DB.First(host, id).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
return c.Render(http.StatusOK, "edithost", echo.Map{
"host": host,
"addEdit": "edit",
"config": h.Config,
"title": h.Title,
"logoPath": h.LogoPath,
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
})
}
// CreateHost validates the host data from the "add host" website,
// adds the host entry to the database,
// and adds the entry to the DNS server.
func (h *Handler) CreateHost(c echo.Context) (err error) {
host := &model.Host{}
if err = c.Bind(host); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
// Enforce lowercase for new entries
host.Hostname = strings.ToLower(host.Hostname)
host.UserName = strings.ToLower(host.UserName)
if err = c.Validate(host); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
if err = h.checkUniqueHostname(host.Hostname, host.Domain); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
host.LastUpdate = time.Now()
if err = h.DB.Create(host).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
// If a ip is set create dns entry
if host.Ip != "" {
ipType := nswrapper.GetIPType(host.Ip)
if ipType == "" {
return c.JSON(http.StatusBadRequest, &Error{fmt.Sprintf("ip %s is not a valid ip", host.Ip)})
}
if err = nswrapper.UpdateRecord(host.Hostname, host.Ip, ipType, host.Domain, host.Ttl, h.AllowWildcard); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
}
return c.JSON(http.StatusOK, host)
}
// UpdateHost validates the host data from the "edit host" website,
// and compares the host data with the entry in the database by "id".
// If anything has changed the database and DNS entries for the host will be updated.
func (h *Handler) UpdateHost(c echo.Context) (err error) {
hostUpdate := &model.Host{}
if err = c.Bind(hostUpdate); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
// Enforce lowercase for updated entries
hostUpdate.Hostname = strings.ToLower(hostUpdate.Hostname)
hostUpdate.UserName = strings.ToLower(hostUpdate.UserName)
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
host := &model.Host{}
if err = h.DB.First(host, id).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
forceRecordUpdate := host.UpdateHost(hostUpdate)
if err = c.Validate(host); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
if err = h.DB.Save(host).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
// If ip or ttl changed update dns entry
if forceRecordUpdate {
ipType := nswrapper.GetIPType(host.Ip)
if ipType == "" {
return c.JSON(http.StatusBadRequest, &Error{fmt.Sprintf("ip %s is not a valid ip", host.Ip)})
}
if err = nswrapper.UpdateRecord(host.Hostname, host.Ip, ipType, host.Domain, host.Ttl, h.AllowWildcard); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
}
return c.JSON(http.StatusOK, host)
}
// DeleteHost fetches a host entry from the database by "id"
// and deletes the database and DNS server entry to it.
func (h *Handler) DeleteHost(c echo.Context) (err error) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
host := &model.Host{}
if err = h.DB.First(host, id).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
err = h.DB.Transaction(func(tx *gorm.DB) error {
if err = tx.Unscoped().Delete(host).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
if err = tx.Where(&model.Log{HostID: uint(id)}).Delete(&model.Log{}).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
if err = tx.Where(&model.CName{TargetID: uint(id)}).Delete(&model.CName{}).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
return nil
})
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
if err = nswrapper.DeleteRecord(host.Hostname, host.Domain, h.AllowWildcard); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
return c.JSON(http.StatusOK, id)
}
// UpdateIP implements the update method called by the routers.
// Hostname, IP and senders IP are validated, a log entry is created
// and finally if everything is ok, the DNS Server will be updated
func (h *Handler) UpdateIP(c echo.Context) (err error) {
host, ok := c.Get("updateHost").(*model.Host)
if !ok {
return c.String(http.StatusBadRequest, "badauth\n")
}
log := &model.Log{Status: false, Host: *host, TimeStamp: time.Now(), UserAgent: nswrapper.ShrinkUserAgent(c.Request().UserAgent())}
log.SentIP = c.QueryParam(("myip"))
// Get caller IP
log.CallerIP, _ = nswrapper.GetCallerIP(c.Request())
if log.CallerIP == "" {
log.CallerIP, _, err = net.SplitHostPort(c.Request().RemoteAddr)
if err != nil {
log.Message = "Bad Request: Unable to get caller IP"
if err = h.CreateLogEntry(log); err != nil {
l.Error(err)
}
return c.String(http.StatusBadRequest, "badrequest\n")
}
}
// Validate hostname (already lowercased during authentication)
hostname := strings.ToLower(c.QueryParam("hostname"))
if hostname == "" || hostname != host.Hostname+"."+host.Domain {
log.Message = "Hostname or combination of authenticated user and hostname is invalid"
if err = h.CreateLogEntry(log); err != nil {
l.Error(err)
}
return c.String(http.StatusBadRequest, "notfqdn\n")
}
// Get IP type
ipType := nswrapper.GetIPType(log.SentIP)
if ipType == "" {
log.SentIP = log.CallerIP
ipType = nswrapper.GetIPType(log.SentIP)
if ipType == "" {
log.Message = "Bad Request: Sent IP is invalid"
if err = h.CreateLogEntry(log); err != nil {
l.Error(err)
}
return c.String(http.StatusBadRequest, "badrequest\n")
}
}
// Add/update DNS record
if err = nswrapper.UpdateRecord(log.Host.Hostname, log.SentIP, ipType, log.Host.Domain, log.Host.Ttl, h.AllowWildcard); err != nil {
log.Message = fmt.Sprintf("DNS error: %v", err)
l.Error(log.Message)
if err = h.CreateLogEntry(log); err != nil {
l.Error(err)
}
return c.String(http.StatusBadRequest, "dnserr\n")
}
// Update DB host entry
log.Host.Ip = log.SentIP
log.Host.LastUpdate = log.TimeStamp
if err = h.DB.Save(log.Host).Error; err != nil {
return c.JSON(http.StatusBadRequest, "badrequest\n")
}
log.Status = true
log.Message = "No errors occurred"
if err = h.CreateLogEntry(log); err != nil {
l.Error(err)
}
return c.String(http.StatusOK, "good\n")
}
func (h *Handler) checkUniqueHostname(hostname, domain string) error {
hosts := new([]model.Host)
if err := h.DB.Where(&model.Host{Hostname: hostname, Domain: domain}).Find(hosts).Error; err != nil {
return err
}
if len(*hosts) > 0 {
return fmt.Errorf("hostname already exists")
}
cnames := new([]model.CName)
if err := h.DB.Preload("Target").Where(&model.CName{Hostname: hostname}).Find(cnames).Error; err != nil {
return err
}
for _, cname := range *cnames {
if cname.Target.Domain == domain {
return fmt.Errorf("hostname already exists")
}
}
return nil
}