# 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.
This commit is contained in:
w3K 2025-10-11 16:07:36 -04:00
parent 6cc20a66e5
commit 7983a3fb49
41 changed files with 3532 additions and 341 deletions

105
.github/workflows/BuildEmAll.yml vendored Normal file
View File

@ -0,0 +1,105 @@
name: Docker Multi-Platform Build, Push & Release
on:
# Trigger the workflow on pushes to the master branch that change files in the dyndns directory
push:
branches: [ "master" ]
paths:
- 'dyndns/**'
# Allow this workflow to be run manually from the Actions tab
workflow_dispatch:
# Define environment variables for the entire workflow for easy configuration
env:
DOCKER_IMAGE_NAME: w3kllc/ddns
jobs:
Build-Em-All:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Fetch all history for all tags and branches
with:
fetch-depth: 0
- name: Determine Version Tag
id: get_version
run: |
# Get the most recent commit message reliably
LATEST_COMMIT_MSG=$(git log -1 --pretty=%B)
# 1. Try to get version from commit message (e.g., "v1.2.3 Something something")
# The '|| true' ensures that if grep finds nothing, it doesn't cause the script to exit with an error.
COMMIT_MSG_VERSION=$(echo "$LATEST_COMMIT_MSG" | grep -oP '^v[0-9]+\.[0-9]+(\.[0-9]+)?' || true)
if [[ -n "$COMMIT_MSG_VERSION" ]]; then
echo "Found version in commit message: $COMMIT_MSG_VERSION"
echo "TAG=$COMMIT_MSG_VERSION" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "No version found in commit message. Checking for existing Git tags."
# 2. If no version in commit, get the latest git tag
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null)
if [[ -n "$LATEST_TAG" ]]; then
echo "Found latest tag: $LATEST_TAG"
# Increment the patch version of the tag (e.g., v1.2.3 -> v1.2.4 or v1.2 -> v1.3)
NEW_TAG=$(echo "$LATEST_TAG" | awk -F. -v OFS=. '{$NF = $NF + 1;} 1')
echo "Incremented tag to: $NEW_TAG"
echo "TAG=$NEW_TAG" >> "$GITHUB_OUTPUT"
else
# 3. If no tags exist, use a date-based version
DATE_TAG="v$(date -u +'%y.%m.%d-%H%M')"
echo "No tags found. Using date-based tag: $DATE_TAG"
echo "TAG=$DATE_TAG" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.DOCKER_IMAGE_NAME }}
tags: |
# Create a tag with the version from the previous step
type=raw,value=${{ steps.get_version.outputs.TAG }}
# Create the 'latest' tag
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: .
file: ./deployment/Dockerfile
platforms: linux/amd64,linux/386,linux/arm/v7,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Create GitHub Release
if: github.event_name != 'pull_request' # Only run on push, not PR
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.get_version.outputs.TAG }}
name: Release ${{ steps.get_version.outputs.TAG }}
body: |
Docker Image: `${{ env.DOCKER_IMAGE_NAME }}:${{ steps.get_version.outputs.TAG }}`
${{ github.event.head_commit.message }}
# The action automatically attaches source code archives (zip and tar.gz)

View File

@ -1,5 +1,6 @@
MIT License
Copyright (c) 2025 w3K LLC
Copyright (c) 2020 Benjamin Bärthlein
Copyright (c) 2016 David Prandzioch

779
README.md
View File

@ -1,106 +1,789 @@
# Dynamic DNS Server for Docker with Web UI written in Go
![Build status](https://img.shields.io/github/actions/workflow/status/benjaminbear/docker-ddns-server/build.yml)
![Build status](https://img.shields.io/github/actions/workflow/status/w3K-one/docker-ddns-server/BuildEmAll.yml)
![GitHub release (latest by date)](https://img.shields.io/github/v/release/w3K-one/docker-ddns-server)
![Go version](https://img.shields.io/github/go-mod/go-version/w3K-one/docker-ddns-server?filename=dyndns%2Fgo.mod)
![License](https://img.shields.io/github/license/w3K-one/docker-ddns-server)
![GitHub release (latest by date)](https://img.shields.io/github/v/release/benjaminbear/docker-ddns-server)
![Go version](https://img.shields.io/github/go-mod/go-version/benjaminbear/docker-ddns-server?filename=dyndns%2Fgo.mod)
![License](https://img.shields.io/github/license/benjaminbear/docker-ddns-server)
With docker-ddns-server you can set up your own dynamic DNS server. This project is inspired by https://github.com/dprandzioch/docker-ddns . In addition to the original version, you can setup and maintain your dyndns entries via simple web ui.
With docker-ddns-server you can set up your own dynamic DNS server. This project is inspired by https://github.com/dprandzioch/docker-ddns. In addition to the original version, you can setup and maintain your dyndns entries via a simple web UI with comprehensive security features, modern authentication, and threat monitoring.
<p float="left">
<img src="https://raw.githubusercontent.com/benjaminbear/docker-ddns-server/master/img/addhost.png" width="285">
<img src="https://raw.githubusercontent.com/benjaminbear/docker-ddns-server/master/img/listhosts.png" width="285">
<img src="https://raw.githubusercontent.com/benjaminbear/docker-ddns-server/master/img/listlogs.png" width="285">
<img src="https://raw.githubusercontent.com/w3K-one/docker-ddns-server/master/img/login.png" width="285">
<img src="https://raw.githubusercontent.com/w3K-one/docker-ddns-server/master/img/listhosts.png" width="285">
<img src="https://raw.githubusercontent.com/w3K-one/docker-ddns-server/master/img/addhost.png" width="285">
<img src="https://raw.githubusercontent.com/w3K-one/docker-ddns-server/master/img/listcnames.png" width="285">
<img src="https://raw.githubusercontent.com/w3K-one/docker-ddns-server/master/img/addcname.png" width="285">
<img src="https://raw.githubusercontent.com/w3K-one/docker-ddns-server/master/img/listlogs.png" width="285">
<img src="https://raw.githubusercontent.com/w3K-one/docker-ddns-server/master/img/security.png" width="285">
<img src="https://raw.githubusercontent.com/w3K-one/docker-ddns-server/master/img/logout.png" width="285">
</p>
## Installation
## ✨ Key Features
You can either take the docker image or build it on your own.
- **Web-Based Management** - Easy-to-use web interface for managing DNS entries
- **Security & IP Blocking** - Automatic protection against brute-force attacks
- **Modern Authentication** - Session-based admin login with HTTPS support
- **Security Dashboard** - Real-time monitoring of threats and blocked IPs
- **Multi-Platform Support** - Runs on amd64, arm64, arm (Raspberry Pi compatible)
- **Automatic Migration** - Handles legacy data with automatic normalization
- **Reverse Proxy Ready** - Works seamlessly with nginx, Caddy, Traefik
- **Threat Intelligence** - Comprehensive logging for attack pattern analysis
### Using the docker image
---
https://registry.hub.docker.com/r/bbaerthlein/docker-ddns-server
## 📦 Installation
Just customize this to your needs and run:
You can either use the pre-built Docker image or build it yourself.
```
### Using the Docker Image
Docker Hub: https://hub.docker.com/r/w3kllc/ddns
**Quick Start:**
```bash
docker run -it -d \
-p 8080:8080 \
-p 53:53 \
-p 53:53/udp \
-v /somefolder:/var/cache/bind \
-v /someotherfolder:/root/database \
-e DDNS_ADMIN_LOGIN=admin:123455546. \
-e DDNS_ADMIN_LOGIN=admin:$$2y$$05$$... \
-e DDNS_DOMAINS=dyndns.example.com \
-e DDNS_PARENT_NS=ns.example.com \
-e DDNS_DEFAULT_TTL=3600 \
-e DDNS_SESSION_SECRET=your-random-32-char-secret \
--name=dyndns \
bbaerthlein/docker-ddns-server:latest
w3kllc/ddns:latest
```
### Using docker-compose
### Using docker-compose (Recommended)
You can also use Docker Compose to set up this project. For an example `docker-compose.yml`, please refer to this file: https://github.com/benjaminbear/docker-ddns-server/blob/master/deployment/docker-compose.yml
For a complete setup example, see: [docker-compose.yml](https://github.com/w3K-one/docker-ddns-server/blob/master/deployment/docker-compose.yml)
### Configuration
**Example docker-compose.yml:**
```yaml
version: '3.8'
`DDNS_ADMIN_LOGIN` is a htpasswd username password combination used for the web ui. You can create one by using htpasswd:
services:
ddns:
image: w3kllc/ddns:latest
container_name: dyndns
ports:
- "8080:8080"
- "53:53"
- "53:53/udp"
volumes:
- ./bind:/var/cache/bind
- ./database:/root/database
- ./static:/app/static # Optional: for custom logo
environment:
# Required
- DDNS_ADMIN_LOGIN=admin:$$2y$$05$$hashed_password_here
- DDNS_DOMAINS=dyndns.example.com
- DDNS_PARENT_NS=ns.example.com
- DDNS_DEFAULT_TTL=3600
# Security (Recommended)
- DDNS_SESSION_SECRET=your-random-32-character-secret-key
# Optional
- DDNS_TITLE=My DynDNS Server
- DDNS_CLEAR_LOG_INTERVAL=30
- DDNS_ALLOW_WILDCARD=true
- DDNS_LOGOUT_URL=https://example.com
- DDNS_POWERED_BY=ACME Inc
- DDNS_POWERED_BY_URL=https://acme.inc
restart: unless-stopped
```
htpasswd -nb user password
---
## ⚙️ Configuration
### Environment Variables
#### Required Variables
**`DDNS_ADMIN_LOGIN`**
Admin credentials in htpasswd format for web UI access.
Generate with:
```bash
htpasswd -nb username password
```
If you want to embed this into a docker-compose.yml you have to double the dollar signs for escaping:
For docker-compose.yml (escape dollar signs):
```bash
echo $(htpasswd -nb username password) | sed -e s/\\$/\\$\\$/g
```
echo $(htpasswd -nb user password) | sed -e s/\\$/\\$\\$/g
If not set, all `/@/` routes are accessible without authentication (useful with auth proxy).
**`DDNS_DOMAINS`**
Comma-separated list of domains managed by the server.
Example: `dyndns.example.com,dyndns.example.org`
**`DDNS_PARENT_NS`**
Parent nameserver of your domain.
Example: `ns.example.com`
**`DDNS_DEFAULT_TTL`**
Default TTL (Time To Live) for DNS records in seconds.
Example: `3600` (1 hour)
#### Security Variables (Recommended)
**`DDNS_SESSION_SECRET`**
Secret key for session encryption. Should be 32+ random characters.
Generate with:
```bash
# Linux/Mac
openssl rand -base64 32
# Or using Python
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
```
If `DDNS_ADMIN_LOGIN` is not set, all /admin routes are without protection. (use case: auth proxy)
`DDNS_DOMAINS` are the domains of the webservice and the domain zones of your dyndns server (see DNS Setup) i.e. `dyndns.example.com,dyndns.example.org` (comma separated list)
⚠️ **Important:** Without this variable, sessions won't persist across container restarts.
`DDNS_PARENT_NS` is the parent name server of your domain i.e. `ns.example.com`
#### Optional Variables
`DDNS_DEFAULT_TTL` is the default TTL of your dyndns server.
**`DDNS_TITLE`**
Custom site title displayed in the web UI.
Default: `"w3K DynDNS"`
`DDNS_CLEAR_LOG_INTERVAL` optional: clear log entries automatically in days (integer) e.g. `DDNS_CLEAR_LOG_INTERVAL:30`
**`DDNS_CLEAR_LOG_INTERVAL`**
Automatically clear log entries older than specified days.
Example: `30` (keep 30 days of logs)
`DDNS_ALLOW_WILDCARD` optional: allows all `*.subdomain.dyndns.example.com` to point to your ip (boolean) e.g. `true`
**`DDNS_ALLOW_WILDCARD`**
Enable wildcard DNS resolution (e.g., `*.subdomain.dyndns.example.com`).
Values: `true` or `false`
`DDNS_LOGOUT_URL` optional: allows a logout redirect to certain url by clicking the logout button (string) e.g. `https://example.com`
**`DDNS_LOGOUT_URL`**
Redirect to this URL after logout.
Example: `https://example.com`
### DNS setup
**`DDNS_POWERED_BY`**
Show this in the footer credits.
Example: `ACME Inc`
If your parent domain is `example.com` and you want your dyndns domain to be `dyndns.example.com`,
an example domain of your dyndns server would be `blog.dyndns.example.com`.
**`DDNS_POWERED_BY_URL`**
The URL to _ACME Inc_.
Example: `https:/acme.inc`
---
## 🌐 DNS Setup
If your parent domain is `example.com` and you want your DynDNS domain to be `dyndns.example.com`, your DynDNS hosts would be like `blog.dyndns.example.com`.
Add these entries to your parent DNS server:
You have to add these entries to your parent dns server:
```
dyndns IN NS ns
ns IN A <put ipv4 of dns server here>
ns IN AAAA <optional, put ipv6 of dns server here>
ns IN A <IPv4 address of your DynDNS server>
ns IN AAAA <IPv6 address of your DynDNS server> (optional)
```
## Updating entry
**Example:**
```
dyndns IN NS ns
ns IN A 203.0.113.10
ns IN AAAA 2001:db8::10
```
After you have added a host via the web ui you can setup your router.
Example update URL:
---
## 🔐 Security Features
### IP Blocking & Threat Protection
- **Automatic IP Blocking**: IPs are blocked after 3 failed authentication attempts within 72 hours
- **7-Day Block Duration**: Blocked IPs are automatically unblocked after 7 days
- **Failed Authentication Logging**: Comprehensive logs including IP, timestamp, username, and password
- **Threat Intelligence**: Analyze attack patterns and password attempts
- **Manual Unblock**: Security dashboard allows manual IP unblocking
- **Automatic Cleanup**: Expired blocks and old logs are cleaned up automatically
### Session-Based Authentication
- **Modern Login Page**: No browser popup dialogs
- **Secure Sessions**: HttpOnly, Secure, and SameSite cookie attributes
- **Remember Me**: Optional 30-day session duration
- **Proper Logout**: Destroys sessions completely
- **HTTPS Enforcement**: Automatic redirect to HTTPS when available
- **Reverse Proxy Support**: Detects SSL via X-Forwarded-Proto headers
### Security Dashboard
Access the security dashboard at `/@/security` to:
- Monitor blocked IPs and active threats
- Review failed authentication attempts
- Analyze password patterns in attack attempts
- Manually unblock IP addresses
- View statistics and historical data
**Password Logging Rationale:**
This is a single-user system where the admin is the only legitimate user. All other login attempts are malicious by definition. Password logging enables threat intelligence analysis to determine if attackers are getting close to your actual password. Ensure your database volume is properly secured.
---
## 🖥️ Admin Panel Access
The admin panel is accessible at `/@/` (not `/admin/` - more unique, less common).
### Main Features
- 🏠 **Dashboard** (`/@/`) - Overview and quick access
- 📝 **Hosts** (`/@/hosts`) - Manage DNS hosts with automatic lowercase migration
- 🔗 **CNAMEs** (`/@/cnames`) - Manage CNAME records
- 📊 **Logs** (`/@/logs`) - View update history
- 🔒 **Security** (`/@/security`) - Monitor threats and blocked IPs
- ⏏️ **Logout** (`/@/logout`) - End session securely
### Authentication Flow
1. Navigate to `/@/` (or any admin route)
2. Redirected to `/@/login` if not authenticated
3. Enter admin credentials
4. Optionally check "Remember Me" for 30-day session
5. Access admin panel
6. Click logout icon (⏏️) when done
**HTTPS Detection:**
If running behind a reverse proxy with SSL, the system automatically detects HTTPS and enforces it for the admin panel while keeping API endpoints accessible via HTTP for device compatibility.
---
## 🔄 Updating DNS Entries
After adding a host via the web UI, configure your router or device to update its IP address.
### Update URLs
The server accepts updates on multiple endpoints:
- `/update`
- `/nic/update`
- `/v2/update`
- `/v3/update`
### With IP Address Specified
```
http://dyndns.example.com:8080/update?hostname=blog.dyndns.example.com&myip=1.2.3.4
or
```
Or with authentication in URL:
```
http://username:password@dyndns.example.com:8080/update?hostname=blog.dyndns.example.com&myip=1.2.3.4
```
this updates the host `blog.dyndns.example.com` with the IP 1.2.3.4. You have to setup basic authentication with the username and password from the web ui.
### Without IP Address (Auto-detect)
If your router doensn't support sending the ip address (OpenWRT) you don't have to set myip field:
If your router/device doesn't support sending the IP address (e.g., OpenWRT), omit the `myip` parameter:
```
http://dyndns.example.com:8080/update?hostname=blog.dyndns.example.com
or
```
Or with authentication:
```
http://username:password@dyndns.example.com:8080/update?hostname=blog.dyndns.example.com
```
The handler will also listen on:
* /nic/update
* /v2/update
* /v3/update
The server will automatically use the client's IP address from the request.
### Authentication
API endpoints use **HTTP Basic Authentication** with the username and password you set for each host in the web UI (not the admin credentials).
**Important:**
- **Admin credentials** (`DDNS_ADMIN_LOGIN`) - For web UI access at `/@/`
- **Host credentials** - For API updates, set per-host in the web UI
---
## 🎨 UI/UX Features
### Automatic Logo Detection
Place a logo file in the static directory to automatically display it:
**Supported formats:**
- `static/icons/logo.png`
- `static/icons/logo.webp`
- `static/icons/logo.svg`
If no logo is found, the system displays the text title (`DDNS_TITLE`).
**Docker volume mount for custom logo:**
```yaml
volumes:
- ./static:/app/static
```
Then place your logo at: `./static/icons/logo.png`
### Visual Improvements
- **Sticky Header**: Navigation remains visible while scrolling
- **Unicode Icons**: 🏠 Dashboard, 🔒 Security, ⏏️ Logout (with tooltips)
- **Modern Design**: Clean, professional interface
- **HTTPS Indicator**: Visual confirmation of secure connection on login page
- **Password Controls**: Hide/reveal functionality with confirmation prompts
- **Responsive Layout**: Works on desktop, tablet, and mobile
---
## 🔧 Data Management
### Automatic Hostname Normalization
All usernames and hostnames are automatically converted to lowercase to prevent case-sensitivity issues:
- Database storage is always lowercase
- Lookups are case-insensitive
- Prevents duplicate entries with different cases
### Legacy Data Migration
When accessing `/@/hosts` for the first time, the system automatically migrates any uppercase entries:
- Converts hostnames to lowercase
- Handles conflicts by appending numbers (e.g., `host-1`, `host-2`)
- Displays migration report in the UI
- One-time process, status persisted in database
- Non-destructive, preserves all host data
### Username Flexibility
- **Non-Unique Usernames**: Multiple hosts can share the same username
- Enables flexible credential management strategies
- Each host can have the same or different password
### Validation Rules
- **Hostnames**: Minimum 1 character (allows single-letter subdomains)
- **Usernames**: Minimum 1 character
- **Passwords**: Minimum 6 characters
---
## 🔀 Reverse Proxy Configuration
The application intelligently detects HTTPS availability and adjusts behavior accordingly.
### HTTPS Detection Methods
1. Direct TLS connection (`request.TLS`)
2. `X-Forwarded-Proto` header
3. `X-Forwarded-Ssl` header
4. `X-Url-Scheme` header
### Behavior
**Admin Panel (`/@/*`):**
- Auto-redirects to HTTPS when available
- Graceful HTTP fallback if HTTPS unavailable
- Session cookies use Secure flag with HTTPS
**API Endpoints (`/update`, `/nic/update`, etc.):**
- Always accept HTTP connections
- No forced HTTPS redirect (device compatibility)
- Works with devices that don't support HTTPS
### Example Nginx Configuration
```nginx
server {
listen 443 ssl;
server_name dyndns.example.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
# Recommended SSL settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Optional: HTTP to HTTPS redirect
server {
listen 80;
server_name dyndns.example.com;
return 301 https://$server_name$request_uri;
}
```
### Example Caddy Configuration
```
dyndns.example.com {
reverse_proxy localhost:8080
}
```
Caddy automatically handles SSL certificates and sets appropriate headers.
---
## 🐳 Multi-Platform Docker Support
### Automated Builds
Docker images are automatically built via GitHub Actions for multiple platforms:
**Supported Platforms:**
- `linux/amd64` - Intel/AMD 64-bit (standard servers, PCs)
- `linux/386` - Intel/AMD 32-bit (older systems)
- `linux/arm/v7` - ARM 32-bit (Raspberry Pi 2/3, older ARM devices)
- `linux/arm64` - ARM 64-bit (Raspberry Pi 4+, modern ARM servers)
### Version Tags
Docker images are tagged using semantic versioning:
**`:latest`** - Always points to the most recent stable build
**`:vX.Y.Z`** - Semantic version tags (e.g., `:v1.2.3`)
- Version from commit message (if commit starts with `vX.Y.Z`)
- OR auto-incremented from last git tag
- OR date-based tag if no version tags exist
**Example:**
```bash
# Pull latest version
docker pull w3kllc/ddns:latest
# Pull specific version
docker pull w3kllc/ddns:v1.2.3
# Pull specific platform
docker pull --platform linux/arm64 w3kllc/ddns:latest
```
### Versioning Strategy
The build system automatically determines version tags using this priority order:
1. **Commit Message Version** (Highest Priority): If your commit message title starts with `vX.Y.Z` (e.g., `v1.2.3`), that exact version is used
2. **Auto-Increment from Last Tag**: If no version in commit message, finds the latest git tag and increments the patch version (e.g., `v1.2.3``v1.2.4`)
3. **Date-Based Fallback**: If no git tags exist at all, uses timestamp format `vYY.MM.DD-HHMM` (e.g., `v25.10.11-1430`)
**Example commit messages:**
```bash
# Explicit version (workflow extracts "v1.3.0" from start of commit message)
git commit -m "v1.3.0 Add new security features"
# Auto-increment (no version found, so increments last tag: v1.2.3 → v1.2.4)
git commit -m "Fix bug in authentication"
# Date-based (no tags exist yet, uses timestamp: v25.10.11-1430)
git commit -m "Initial release"
```
**How version extraction works:**
- Workflow searches for pattern `vX.Y.Z` or `vX.Y` at the **start** of commit message
- Must begin with `v` followed by numbers and dots
- Examples that work: `v1.0.0`, `v2.1.3`, `v1.2`
- Examples that won't work: `version 1.0.0` (missing `v`), `Release v1.0.0` (doesn't start with `v`)
### GitHub Releases
Each build automatically creates a GitHub release with:
- Version tag
- Docker image reference
- Commit message as release notes
- Source code archives (zip and tar.gz)
---
## 🚀 Migration from Original Project
If migrating from `dprandzioch/docker-ddns` or older versions of this fork:
### Before Migration
1. **Backup your data:**
```bash
docker cp dyndns:/root/database ./backup-database
docker cp dyndns:/var/cache/bind ./backup-bind
```
2. **Note your current configuration** (environment variables)
### Breaking Changes
1. **Admin Panel URL**: Changed from `/admin` to `/@/`
- Update bookmarks and links
- Use `/@/login` for login page
2. **Authentication Method**: Admin panel now uses sessions
- Add `DDNS_SESSION_SECRET` environment variable
- Login via web form instead of browser popup
3. **New Recommended Variable**: `DDNS_SESSION_SECRET`
- Required for session persistence
- Generate: `openssl rand -base64 32`
### Migration Steps
1. **Update docker-compose.yml** or docker command with new variables
2. **Add `DDNS_SESSION_SECRET`** to environment
3. **Update bookmarks** from `/admin` to `/@/`
4. **Restart container** with new configuration
5. **Visit `/@/hosts`** to trigger automatic data migration
6. **Review security dashboard** for any blocked IPs
### Backward Compatibility
✅ **Fully Compatible:**
- DynDNS API endpoints unchanged
- HTTP Basic Auth still works for device updates
- Existing host configurations work without changes
- Database schema additions are non-breaking
- All original functionality preserved
⚠️ **Manual Update Required:**
- Bookmark/link updates for admin panel
- Addition of session secret (recommended)
---
## 🔍 Troubleshooting
### Login Issues
**Problem:** Login redirects back to login page
**Solution:** Ensure `DDNS_SESSION_SECRET` is set. Without it, sessions won't persist.
**Problem:** Can't remember admin password
**Solution:** Regenerate password with `htpasswd -nb username newpassword` and update `DDNS_ADMIN_LOGIN`
### HTTPS Issues
**Problem:** HTTPS redirect loop
**Solution:** Verify reverse proxy sends `X-Forwarded-Proto: https` header
**Problem:** "Not Secure" warning
**Solution:** Check SSL certificate configuration in your reverse proxy
### IP Blocking
**Problem:** Locked out after failed login attempts
**Solution:**
- Wait 7 days for automatic unblock
- OR manually remove from `blocked_ips` table in database
- OR access database with SQLite: `DELETE FROM blocked_ips WHERE ip_address='YOUR_IP';`
### API Updates
**Problem:** Device updates not working
**Solution:**
- API uses host credentials (from web UI), not admin credentials
- Check username/password for specific host in `/@/hosts`
- Verify device is sending correct Basic Auth headers
**Problem:** "nochg" response from server
**Solution:** IP address hasn't changed, this is normal behavior
### Build Issues
**Problem:** `missing go.sum entry for gorilla/sessions`
**Solution:**
```bash
go get github.com/gorilla/sessions@v1.2.2
go mod tidy
```
### Database Issues
**Problem:** Database locked errors
**Solution:** Ensure only one container instance is running
**Problem:** Lost all data after update
**Solution:** Check volume mounts are correct in docker-compose.yml
---
## 🛡️ Security Best Practices
1. **Always Set Session Secret**
Generate a strong random secret: `openssl rand -base64 32`
2. **Use HTTPS with Reverse Proxy**
Never expose the admin panel over plain HTTP in production
3. **Secure Database Volume**
Set appropriate file permissions:
```bash
chmod 700 /path/to/database
```
4. **Regular Updates**
Keep Docker image updated: `docker pull w3kllc/ddns:latest`
5. **Monitor Security Dashboard**
Check `/@/security` regularly for attack patterns
6. **Strong Admin Password**
Use a password manager to generate and store strong credentials
7. **Separate Credentials**
Use different passwords for admin and each host
8. **Firewall Configuration**
Limit access to web UI (port 8080) to trusted networks if possible
9. **Database Backups**
Regularly backup the database volume
10. **Password Logging Awareness**
Remember that failed auth logs include passwords - secure your database
---
## 📚 API Reference
### Update Endpoints
All endpoints accept the same parameters:
**Endpoints:**
- `GET /update`
- `GET /nic/update`
- `GET /v2/update`
- `GET /v3/update`
**Parameters:**
- `hostname` (required) - Fully qualified domain name to update
- `myip` (optional) - IP address to set (auto-detected if omitted)
**Authentication:**
- HTTP Basic Auth using host credentials (username/password from web UI)
**Response Codes:**
- `good <IP>` - Update successful
- `nochg <IP>` - IP address hasn't changed
- `badauth` - Authentication failed
- `notfqdn` - Hostname is not a valid FQDN
- `nohost` - Hostname doesn't exist
- `abuse` - IP address has been blocked
**Example:**
```bash
curl -u username:password \
"http://dyndns.example.com:8080/update?hostname=test.dyndns.example.com&myip=1.2.3.4"
```
---
## 🤝 Contributing
Contributions are welcome! Whether it's bug fixes, new features, documentation improvements, or reporting issues.
### How to Contribute
1. **Fork the repository**
2. **Create a feature branch** (`git checkout -b feature/amazing-feature`)
3. **Make your changes**
4. **Test thoroughly**
5. **Commit your changes** (`git commit -m 'Add amazing feature'`)
6. **Push to your fork** (`git push origin feature/amazing-feature`)
7. **Open a Pull Request**
### Development Setup
```bash
# Clone the repository
git clone https://github.com/w3K-one/docker-ddns-server.git
cd docker-ddns-server
# Build the application
cd dyndns
go build
# Run tests (if available)
go test ./...
# Build Docker image locally
cd ..
docker build -t ddns:dev -f deployment/Dockerfile .
```
### Code Style
- Follow Go conventions and best practices
- Use `gofmt` for code formatting
- Add comments for complex logic
- Write meaningful commit messages
---
## 📄 License
This project is licensed under the MIT License - see the LICENSE file for details.
---
## 🙏 Credits
**Original Project:**
[dprandzioch/docker-ddns](https://github.com/dprandzioch/docker-ddns) - Original DynDNS server implementation
**Web UI Fork:**
[benjaminbear/docker-ddns-server](https://github.com/benjaminbear/docker-ddns-server) - Added web UI for management
**Enhanced Fork:**
[w3K-one/docker-ddns-server](https://github.com/w3K-one/docker-ddns-server) - Security features, modern auth, multi-platform support
### Major Enhancements in This Fork
- 🔒 IP blocking and threat protection system
- 🔐 Session-based authentication with modern login
- 📊 Security dashboard for monitoring attacks
- 🌐 HTTPS enforcement with reverse proxy support
- 🎨 Enhanced UI/UX with logo support and sticky header
- 📦 Multi-platform Docker builds (amd64, arm64, arm, 386)
- 🔄 Automatic data migration and normalization
- 📝 Comprehensive documentation
- 🤖 Automated CI/CD with GitHub Actions
- 🏷️ Semantic versioning with automatic releases
---
## 💬 Support
- **Issues:** [GitHub Issues](https://github.com/w3K-one/docker-ddns-server/issues)
- **Discussions:** [GitHub Discussions](https://github.com/w3K-one/docker-ddns-server/discussions)
- **Docker Hub:** [w3kllc/ddns](https://hub.docker.com/r/w3kllc/ddns)
---
## 🗺️ Roadmap
Potential future enhancements:
- Email notifications for security events
- Two-factor authentication (2FA)
- API rate limiting
- Web-based configuration wizard
- DNS over HTTPS (DoH) support
- Prometheus metrics export
- Docker Swarm / Kubernetes support
- Advanced search and filtering in logs
- Bulk host management
Have an idea? [Open an issue](https://github.com/w3K-one/docker-ddns-server/issues) or start a [discussion](https://github.com/w3K-one/docker-ddns-server/discussions)!
---
**Made with ❤️ by the community**

View File

@ -1,8 +1,8 @@
version: '3'
services:
ddns:
image: bbaerthlein/docker-ddns-server:latest
restart: always
image: w3kllc/ddns:latest
restart: unless-stopped
environment:
DDNS_ADMIN_LOGIN: 'admin:$$3$$abcdefg'
DDNS_DOMAINS: 'dyndns.example.com'

View File

@ -1,10 +1,11 @@
module github.com/benjaminbear/docker-ddns-server/dyndns
module github.com/w3K-one/docker-ddns-server/dyndns
go 1.22
require (
github.com/foolin/goview v0.3.0
github.com/go-playground/validator/v10 v10.20.0
github.com/gorilla/sessions v1.2.2
github.com/labstack/echo/v4 v4.12.0
github.com/labstack/gommon v0.4.2
github.com/tg123/go-htpasswd v1.2.2
@ -18,6 +19,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/leodido/go-urn v1.4.0 // indirect

View File

@ -25,6 +25,12 @@ github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaC
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=

225
dyndns/handler/auth.go Normal file
View File

@ -0,0 +1,225 @@
package handler
import (
"crypto/rand"
"encoding/base64"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
)
// Session key constants
const (
SessionName = "ddns_session"
SessionUserKey = "user"
SessionAuthKey = "authenticated"
SessionCreatedAt = "created_at"
SessionExpiresAt = "expires_at"
)
// ShowLoginPage renders the login page
func (h *Handler) ShowLoginPage(c echo.Context) error {
// Check if already authenticated
if h.IsAuthenticated(c) {
return c.Redirect(http.StatusFound, "/@/hosts")
}
// Check if there's an error message from failed login
errorMsg := c.QueryParam("error")
return c.Render(http.StatusOK, "login", echo.Map{
"title": h.Title,
"logoPath": h.LogoPath,
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
"error": errorMsg,
})
}
// HandleLogin processes login form submission
func (h *Handler) HandleLogin(c echo.Context) error {
username := c.FormValue("username")
password := c.FormValue("password")
rememberMe := c.FormValue("remember_me") == "on"
// Get client IP for logging
clientIP := ExtractIPFromRequest(
c.Request().RemoteAddr,
c.Request().Header.Get("X-Forwarded-For"),
c.Request().Header.Get("X-Real-IP"),
)
// Validate credentials
authenticated, err := h.authByEnv(username, password)
if err != nil {
log.Error("Authentication error:", err)
h.LogFailedAuth(clientIP, c.Request().UserAgent(), c.Path(), username, password)
return c.Redirect(http.StatusFound, "/@/login?error=authentication_error")
}
if !authenticated {
log.Warnf("Failed login attempt from IP %s, username: %s", clientIP, username)
h.LogFailedAuth(clientIP, c.Request().UserAgent(), c.Path(), username, password)
return c.Redirect(http.StatusFound, "/@/login?error=invalid_credentials")
}
// Authentication successful - create session
sess, err := h.GetSession(c)
if err != nil {
log.Error("Session creation error:", err)
return c.Redirect(http.StatusFound, "/@/login?error=session_error")
}
// Set session values
sess.Values[SessionUserKey] = username
sess.Values[SessionAuthKey] = true
sess.Values[SessionCreatedAt] = time.Now().Unix()
// Set expiration based on remember me
if rememberMe {
sess.Options.MaxAge = 30 * 24 * 60 * 60 // 30 days
sess.Values[SessionExpiresAt] = time.Now().Add(30 * 24 * time.Hour).Unix()
} else {
sess.Options.MaxAge = 24 * 60 * 60 // 24 hours
sess.Values[SessionExpiresAt] = time.Now().Add(24 * time.Hour).Unix()
}
// Set secure flag if using HTTPS
if h.IsHTTPS(c) {
sess.Options.Secure = true
}
// Save session
if err := sess.Save(c.Request(), c.Response()); err != nil {
log.Error("Session save error:", err)
return c.Redirect(http.StatusFound, "/@/login?error=session_error")
}
log.Infof("Successful login from IP %s, username: %s", clientIP, username)
// Redirect to originally requested page or default to hosts
redirect := c.QueryParam("redirect")
if redirect == "" || redirect == "/@/login" {
redirect = "/@/hosts"
}
return c.Redirect(http.StatusFound, redirect)
}
// HandleLogout destroys the session and logs out the user
func (h *Handler) HandleLogout(c echo.Context) error {
sess, err := h.GetSession(c)
if err == nil {
// Get username before destroying session
username := "unknown"
if user, ok := sess.Values[SessionUserKey].(string); ok {
username = user
}
// Destroy session
sess.Options.MaxAge = -1
sess.Values = make(map[interface{}]interface{})
sess.Save(c.Request(), c.Response())
if username != "" {
log.Infof("User %s logged out", username)
}
}
// Clear session cookie
c.SetCookie(&http.Cookie{
Name: SessionName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: h.IsHTTPS(c),
SameSite: http.SameSiteStrictMode,
})
// ALWAYS render logout page (not redirect)
// Pass LogoutUrl so JavaScript can handle delayed redirect
return c.Render(http.StatusOK, "logout", echo.Map{
"title": h.Title,
"logoPath": h.LogoPath,
"logoutUrl": h.LogoutUrl, // Pass the logout URL to template
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
})
}
// IsAuthenticated checks if the current session is authenticated
func (h *Handler) IsAuthenticated(c echo.Context) bool {
if h.DisableAdminAuth {
return true
}
sess, err := h.GetSession(c)
if err != nil {
return false
}
// Check if authenticated
authenticated, ok := sess.Values[SessionAuthKey].(bool)
if !ok || !authenticated {
return false
}
// Check if session has expired
if expiresAt, ok := sess.Values[SessionExpiresAt].(int64); ok {
if time.Now().Unix() > expiresAt {
log.Info("Session expired")
return false
}
}
return true
}
// GetSession retrieves or creates a session for the request
func (h *Handler) GetSession(c echo.Context) (*Session, error) {
return h.SessionStore.Get(c.Request(), SessionName)
}
// GenerateCSRFToken generates a random CSRF token
func GenerateCSRFToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// IsHTTPS checks if the request came via HTTPS
// Checks both direct HTTPS and reverse proxy headers
func (h *Handler) IsHTTPS(c echo.Context) bool {
// Check if direct HTTPS
if c.Request().TLS != nil {
return true
}
// Check reverse proxy headers
proto := c.Request().Header.Get("X-Forwarded-Proto")
if proto == "https" {
return true
}
// Check other common headers
if c.Request().Header.Get("X-Forwarded-Ssl") == "on" {
return true
}
if c.Request().Header.Get("X-Url-Scheme") == "https" {
return true
}
return false
}
// GetHTTPSRedirectURL constructs the HTTPS version of the current URL
func (h *Handler) GetHTTPSRedirectURL(c echo.Context) string {
host := c.Request().Host
uri := c.Request().RequestURI
return "https://" + host + uri
}

View File

@ -5,18 +5,15 @@ import (
"net/http"
"strconv"
"github.com/benjaminbear/docker-ddns-server/dyndns/model"
"github.com/benjaminbear/docker-ddns-server/dyndns/nswrapper"
"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"
)
// ListCNames fetches all cnames from database and lists them on the website.
func (h *Handler) ListCNames(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
// Auth check removed - middleware handles this
cnames := new([]model.CName)
if err = h.DB.Preload("Target").Find(cnames).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
@ -25,16 +22,15 @@ func (h *Handler) ListCNames(c echo.Context) (err error) {
return c.Render(http.StatusOK, "listcnames", echo.Map{
"cnames": cnames,
"title": h.Title,
"logoPath": h.LogoPath,
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
})
}
// AddCName just renders the "add cname" website.
// Therefore all host entries from the database are being fetched.
func (h *Handler) AddCName(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
hosts := new([]model.Host)
if err = h.DB.Find(hosts).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
@ -44,6 +40,9 @@ func (h *Handler) AddCName(c echo.Context) (err error) {
"config": h.Config,
"hosts": hosts,
"title": h.Title,
"logoPath": h.LogoPath,
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
})
}
@ -51,10 +50,6 @@ func (h *Handler) AddCName(c echo.Context) (err error) {
// adds the cname entry to the database,
// and adds the entry to the DNS server.
func (h *Handler) CreateCName(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
cname := &model.CName{}
if err = c.Bind(cname); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
@ -89,10 +84,6 @@ func (h *Handler) CreateCName(c echo.Context) (err error) {
// DeleteCName fetches a cname entry from the database by "id"
// and deletes the database and DNS server entry to it.
func (h *Handler) DeleteCName(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})

View File

@ -10,7 +10,7 @@ import (
"strings"
"time"
"github.com/benjaminbear/docker-ddns-server/dyndns/model"
"github.com/w3K-one/docker-ddns-server/dyndns/model"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/tg123/go-htpasswd"
@ -28,6 +28,10 @@ type Handler struct {
ClearInterval uint64
AllowWildcard bool
LogoutUrl string
LogoPath string
SessionStore *SessionStore
PoweredBy string
PoweredByUrl string
}
type Envs struct {
@ -52,15 +56,16 @@ type Error struct {
// To gather admin rights the username password combination must match with the credentials given by the env var.
func (h *Handler) AuthenticateUpdate(username, password string, c echo.Context) (bool, error) {
h.CheckClearInterval()
reqParameter := c.QueryParam("hostname")
reqParameter := strings.ToLower(c.QueryParam("hostname"))
reqArr := strings.SplitN(reqParameter, ".", 2)
if len(reqArr) != 2 {
log.Error("Error: Something wrong with the hostname parameter")
return false, nil
}
lowerUsername := strings.ToLower(username)
host := &model.Host{}
if err := h.DB.Where(&model.Host{UserName: username, Password: password, Hostname: reqArr[0], Domain: reqArr[1]}).First(host).Error; err != nil {
if err := h.DB.Where(&model.Host{UserName: lowerUsername, Password: password, Hostname: reqArr[0], Domain: reqArr[1]}).First(host).Error; err != nil {
log.Error("Error: ", err)
return false, nil
}
@ -119,8 +124,20 @@ func (h *Handler) ParseEnvs() (adminAuth bool, err error) {
var ok bool
h.Title, ok = os.LookupEnv("DDNS_TITLE")
if !ok {
h.Title = "TheBBCloud DynDNS"
h.Title = "w3K DynDNS"
}
// ADDED: Check for logo files in the static icons directory upon startup.
logoExtensions := []string{"png", "webp", "svg"}
for _, ext := range logoExtensions {
path := fmt.Sprintf("static/icons/logo.%s", ext)
if _, err := os.Stat(path); err == nil {
h.LogoPath = "/" + path // Store the valid path if found
log.Info("Found logo at: ", h.LogoPath)
break
}
}
allowWildcard, ok := os.LookupEnv("DDNS_ALLOW_WILDCARD")
if ok {
h.AllowWildcard, err = strconv.ParseBool(allowWildcard)
@ -136,6 +153,20 @@ func (h *Handler) ParseEnvs() (adminAuth bool, err error) {
}
}
h.PoweredBy, ok = os.LookupEnv("DDNS_POWERED_BY")
if !ok || h.PoweredBy == "" {
h.PoweredBy = "w3K LLC"
} else {
log.Info("Powered by set: ", h.PoweredBy)
}
h.PoweredByUrl, ok = os.LookupEnv("DDNS_POWERED_BY_URL")
if !ok || h.PoweredByUrl == "" {
h.PoweredByUrl = "https://w3K.one/"
} else {
log.Info("Powered by URL set: ", h.PoweredByUrl)
}
clearEnv := os.Getenv("DDNS_CLEAR_LOG_INTERVAL")
clearInterval, err := strconv.ParseUint(clearEnv, 10, 32)
if err != nil {
@ -153,6 +184,11 @@ func (h *Handler) ParseEnvs() (adminAuth bool, err error) {
return adminAuth, fmt.Errorf("environment variable DDNS_DOMAINS has to be set")
}
// Initialize session store
if err := h.InitSessionStore(); err != nil {
return adminAuth, fmt.Errorf("failed to initialize session store: %v", err)
}
return adminAuth, nil
}
@ -170,7 +206,14 @@ func (h *Handler) InitDB() (err error) {
return err
}
err = h.DB.AutoMigrate(&model.Host{}, &model.CName{}, &model.Log{})
// Migrate all models including new security models
err = h.DB.AutoMigrate(
&model.Host{},
&model.CName{},
&model.Log{},
&model.FailedAuth{}, // NEW: Failed authentication tracking
&model.BlockedIP{}, // NEW: Blocked IP tracking
)
return err
}

View File

@ -5,13 +5,13 @@ import (
"net"
"net/http"
"strconv"
"strings"
"time"
l "github.com/labstack/gommon/log"
"github.com/benjaminbear/docker-ddns-server/dyndns/nswrapper"
"github.com/benjaminbear/docker-ddns-server/dyndns/model"
"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"
)
@ -22,10 +22,6 @@ const (
// GetHost fetches a host from the database by "id".
func (h *Handler) GetHost(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
@ -40,42 +36,116 @@ func (h *Handler) GetHost(c echo.Context) (err error) {
return c.JSON(http.StatusOK, id)
}
// ListHosts fetches all hosts from database and lists them on the website.
// 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) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
hosts := new([]model.Host)
if err = h.DB.Find(hosts).Error; err != nil {
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,
"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) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
return c.Render(http.StatusOK, "edithost", echo.Map{
"addEdit": "add",
"config": h.Config,
"title": h.Title,
"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) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
@ -87,10 +157,13 @@ func (h *Handler) EditHost(c echo.Context) (err error) {
}
return c.Render(http.StatusOK, "edithost", echo.Map{
"host": host,
"addEdit": "edit",
"config": h.Config,
"title": h.Title,
"host": host,
"addEdit": "edit",
"config": h.Config,
"title": h.Title,
"logoPath": h.LogoPath,
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
})
}
@ -98,15 +171,15 @@ func (h *Handler) EditHost(c echo.Context) (err error) {
// adds the host entry to the database,
// and adds the entry to the DNS server.
func (h *Handler) CreateHost(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
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()})
}
@ -138,15 +211,15 @@ func (h *Handler) CreateHost(c echo.Context) (err error) {
// 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) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
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()})
@ -184,10 +257,6 @@ func (h *Handler) UpdateHost(c echo.Context) (err error) {
// 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) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
@ -250,8 +319,8 @@ func (h *Handler) UpdateIP(c echo.Context) (err error) {
}
}
// Validate hostname
hostname := c.QueryParam("hostname")
// 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 {

View File

@ -6,7 +6,7 @@ import (
"strconv"
"time"
"github.com/benjaminbear/docker-ddns-server/dyndns/model"
"github.com/w3K-one/docker-ddns-server/dyndns/model"
"github.com/labstack/echo/v4"
)
@ -21,10 +21,6 @@ func (h *Handler) CreateLogEntry(log *model.Log) (err error) {
// ShowLogs fetches all log entries from all hosts and renders them to the website.
func (h *Handler) ShowLogs(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
logs := new([]model.Log)
if err = h.DB.Preload("Host").Limit(30).Order("created_at desc").Find(logs).Error; err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
@ -33,15 +29,14 @@ func (h *Handler) ShowLogs(c echo.Context) (err error) {
return c.Render(http.StatusOK, "listlogs", echo.Map{
"logs": logs,
"title": h.Title,
"logoPath": h.LogoPath,
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
})
}
// ShowHostLogs fetches all log entries of a specific host by "id" and renders them to the website.
func (h *Handler) ShowHostLogs(c echo.Context) (err error) {
if !h.AuthAdmin {
return c.JSON(http.StatusUnauthorized, &Error{UNAUTHORIZED})
}
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
@ -55,6 +50,9 @@ func (h *Handler) ShowHostLogs(c echo.Context) (err error) {
return c.Render(http.StatusOK, "listlogs", echo.Map{
"logs": logs,
"title": h.Title,
"logoPath": h.LogoPath,
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
})
}

View File

@ -0,0 +1,248 @@
package handler
import (
"net"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
)
// IPBlockerMiddleware checks if the requesting IP is blocked
func (h *Handler) IPBlockerMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Extract the client IP
clientIP := ExtractIPFromRequest(
c.Request().RemoteAddr,
c.Request().Header.Get("X-Forwarded-For"),
c.Request().Header.Get("X-Real-IP"),
)
// Check if IP is blocked
isBlocked, blockedIP, err := h.IsIPBlocked(clientIP)
if err != nil {
log.Errorf("Error checking blocked IP %s: %v", clientIP, err)
// Continue on error to avoid breaking the site
return next(c)
}
if isBlocked {
log.Warnf("Blocked IP %s attempted to access %s", clientIP, c.Path())
// Update last attempt time
if blockedIP != nil {
blockedIP.LastAttemptAt = time.Now()
h.DB.Save(blockedIP)
}
// Redirect to 127.0.0.1
return c.Redirect(http.StatusFound, "http://127.0.0.1")
}
return next(c)
}
}
}
// SessionAuthMiddleware checks if user is authenticated via session
func (h *Handler) SessionAuthMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Skip auth if disabled
if h.DisableAdminAuth {
return next(c)
}
// Check if authenticated
if !h.IsAuthenticated(c) {
// Store the original URL for redirect after login
originalURL := c.Request().URL.Path
if c.Request().URL.RawQuery != "" {
originalURL += "?" + c.Request().URL.RawQuery
}
// Redirect to login page
return c.Redirect(http.StatusFound, "/@/login?redirect="+originalURL)
}
return next(c)
}
}
}
// HTTPSRedirectMiddleware redirects HTTP to HTTPS for admin routes
// Only applies to admin routes (/@/*) and only if HTTPS is available
func (h *Handler) HTTPSRedirectMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Only apply to admin routes
if !strings.HasPrefix(c.Path(), "/@/") {
return next(c)
}
// Skip login page to avoid redirect loop
if c.Path() == "/@/login" {
return next(c)
}
// Check if already HTTPS
if h.IsHTTPS(c) {
return next(c)
}
// Check if HTTPS is available by checking X-Forwarded-Proto header exists
// This indicates we're behind a reverse proxy that supports HTTPS
if c.Request().Header.Get("X-Forwarded-Proto") != "" {
// Redirect to HTTPS
httpsURL := h.GetHTTPSRedirectURL(c)
return c.Redirect(http.StatusMovedPermanently, httpsURL)
}
// No HTTPS available, continue with HTTP
return next(c)
}
}
}
// UpdateAuthMiddleware wraps BasicAuth for update endpoints
// CRITICAL: Only logs failed auth when credentials are ACTUALLY WRONG, not system errors
func (h *Handler) UpdateAuthMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Extract credentials
username, password, ok := c.Request().BasicAuth()
if !ok {
// No credentials provided - this is NOT a failed auth attempt
// It's a misconfigured client or direct browser access
return c.String(http.StatusUnauthorized, "badauth\n")
}
// Attempt authentication
authenticated, authError := h.AuthenticateUpdate(username, password, c)
// If there was a system error (not wrong credentials), don't log as failed auth
if authError != nil {
log.Errorf("Authentication system error: %v", authError)
return c.String(http.StatusUnauthorized, "badauth\n")
}
// Only log failed auth if authentication explicitly failed
// This means: credentials were provided, checked, and found to be WRONG
if !authenticated {
clientIP := ExtractIPFromRequest(
c.Request().RemoteAddr,
c.Request().Header.Get("X-Forwarded-For"),
c.Request().Header.Get("X-Real-IP"),
)
log.Warnf("Failed DynDNS API authentication from IP %s, username: %s", clientIP, username)
// Log the failed attempt (but DON'T trigger IP blocking for API endpoints)
h.LogFailedAuth(clientIP, c.Request().UserAgent(), c.Path(), username, password)
return c.String(http.StatusUnauthorized, "badauth\n")
}
// Authentication successful
return next(c)
}
}
}
// CleanupMiddleware periodically cleans up expired blocks and old records
func (h *Handler) CleanupMiddleware() echo.MiddlewareFunc {
// Track last cleanup time
lastCleanup := &time.Time{}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Run cleanup once per hour
if lastCleanup.IsZero() || time.Since(*lastCleanup) > time.Hour {
go func() {
h.CleanupExpiredBlocks()
h.CleanupOldFailedAuths()
}()
now := time.Now()
lastCleanup = &now
}
return next(c)
}
}
}
// ExtractIPFromRequest safely extracts IP from various headers
func ExtractIPFromRequest(remoteAddr string, xForwardedFor string, xRealIP string) string {
// Try X-Real-IP first
if xRealIP != "" {
ip := net.ParseIP(xRealIP)
if ip != nil {
return xRealIP
}
}
// Try X-Forwarded-For
if xForwardedFor != "" {
// X-Forwarded-For can contain multiple IPs, get the first one
ips := splitAndTrim(xForwardedFor, ",")
if len(ips) > 0 {
ip := net.ParseIP(ips[0])
if ip != nil {
return ips[0]
}
}
}
// Fall back to RemoteAddr
ip, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return remoteAddr
}
return ip
}
func splitAndTrim(s string, sep string) []string {
var result []string
for _, part := range split(s, sep) {
trimmed := trim(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func split(s string, sep string) []string {
// Simple split implementation
var result []string
start := 0
for i := 0; i < len(s); i++ {
if string(s[i]) == sep {
result = append(result, s[start:i])
start = i + 1
}
}
result = append(result, s[start:])
return result
}
func trim(s string) string {
// Simple trim implementation
start := 0
end := len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return s[start:end]
}

208
dyndns/handler/security.go Normal file
View File

@ -0,0 +1,208 @@
package handler
import (
"strings"
"time"
"github.com/w3K-one/docker-ddns-server/dyndns/model"
"github.com/labstack/gommon/log"
"gorm.io/gorm"
)
const (
MaxFailedAttempts = 3
BlockDuration = 168 * time.Hour // 7 days (1 week) in hours
LookbackPeriod = 72 * time.Hour // Check failures in last 3 days
)
// LogFailedAuth records a failed authentication attempt
// WARNING: This logs passwords which is a security risk. Ensure database is properly secured.
// IMPORTANT: IP blocking only applies to admin panel attempts (/@/*), not API endpoints
func (h *Handler) LogFailedAuth(ipAddress, userAgent, path, username, password string) error {
failedAuth := &model.FailedAuth{
IPAddress: ipAddress,
UserAgent: userAgent,
Timestamp: time.Now(),
Path: path,
Username: username,
Password: password, // SECURITY WARNING: Storing attempted passwords
}
if err := h.DB.Create(failedAuth).Error; err != nil {
log.Error("Failed to log authentication failure:", err)
return err
}
// CRITICAL: Only check for IP blocking if this was an admin panel attempt
// API endpoints (like /nic/update, /update, /v2/update, /v3/update) should NOT trigger blocking
if strings.HasPrefix(path, "/@/") {
log.Infof("Admin panel failed auth from %s - checking for IP block", ipAddress)
go h.CheckAndBlockIP(ipAddress)
} else {
log.Infof("API endpoint failed auth from %s on %s - NOT checking for IP block", ipAddress, path)
}
return nil
}
// CheckAndBlockIP checks if an IP has exceeded failed attempts and blocks it
// ONLY COUNTS FAILURES TO ADMIN PANEL (/@/*), NOT API ENDPOINTS
func (h *Handler) CheckAndBlockIP(ipAddress string) error {
// Count failed attempts to ADMIN PANEL ONLY in the lookback period
var count int64
lookbackTime := time.Now().Add(-LookbackPeriod)
err := h.DB.Model(&model.FailedAuth{}).
Where("ip_address = ? AND timestamp > ? AND path LIKE '/@/%'", ipAddress, lookbackTime).
Count(&count).Error
if err != nil {
log.Error("Failed to count authentication failures:", err)
return err
}
log.Infof("IP %s has %d failed ADMIN PANEL attempts in last %v", ipAddress, count, LookbackPeriod)
// If exceeded threshold, block the IP
if count >= MaxFailedAttempts {
return h.BlockIP(ipAddress, int(count), "Exceeded maximum failed admin authentication attempts")
}
return nil
}
// BlockIP adds an IP to the blocked list
func (h *Handler) BlockIP(ipAddress string, failureCount int, reason string) error {
// Check if IP is already blocked
var existingBlock model.BlockedIP
err := h.DB.Where("ip_address = ?", ipAddress).First(&existingBlock).Error
if err == nil {
// Update existing block
existingBlock.FailureCount = failureCount
existingBlock.LastAttemptAt = time.Now()
existingBlock.BlockedUntil = time.Now().Add(BlockDuration)
existingBlock.Reason = reason
if err := h.DB.Save(&existingBlock).Error; err != nil {
log.Error("Failed to update blocked IP:", err)
return err
}
log.Warnf("Updated block for IP %s (failures: %d)", ipAddress, failureCount)
return nil
}
// Create new block
blockedIP := &model.BlockedIP{
IPAddress: ipAddress,
BlockedAt: time.Now(),
BlockedUntil: time.Now().Add(BlockDuration),
FailureCount: failureCount,
IsPermanent: false,
LastAttemptAt: time.Now(),
Reason: reason,
}
if err := h.DB.Create(blockedIP).Error; err != nil {
log.Error("Failed to block IP:", err)
return err
}
log.Warnf("Blocked IP %s for %v (failures: %d, reason: %s)",
ipAddress, BlockDuration, failureCount, reason)
return nil
}
// IsIPBlocked checks if an IP address is currently blocked
func (h *Handler) IsIPBlocked(ipAddress string) (bool, *model.BlockedIP, error) {
var blockedIP model.BlockedIP
err := h.DB.Where("ip_address = ?", ipAddress).First(&blockedIP).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return false, nil, nil
}
return false, nil, err
}
// Check if block is still active
if blockedIP.IsBlocked() {
return true, &blockedIP, nil
}
return false, nil, nil
}
// UnblockIP removes an IP from the blocked list
func (h *Handler) UnblockIP(ipAddress string) error {
result := h.DB.Where("ip_address = ?", ipAddress).Delete(&model.BlockedIP{})
if result.Error != nil {
log.Error("Failed to unblock IP:", result.Error)
return result.Error
}
log.Infof("Unblocked IP %s", ipAddress)
return nil
}
// GetClientIP extracts the real client IP from the request
func GetClientIP(r interface{}) string {
// This function can be enhanced to check X-Forwarded-For, X-Real-IP headers
// For now, we'll use a simple extraction
// You'll need to pass the Echo context here
// This is a helper that should be called from middleware
return ""
}
// CleanupExpiredBlocks removes expired blocks from the database
func (h *Handler) CleanupExpiredBlocks() error {
result := h.DB.Where("is_permanent = ? AND blocked_until < ?", false, time.Now()).
Delete(&model.BlockedIP{})
if result.Error != nil {
log.Error("Failed to cleanup expired blocks:", result.Error)
return result.Error
}
if result.RowsAffected > 0 {
log.Infof("Cleaned up %d expired IP blocks", result.RowsAffected)
}
return nil
}
// CleanupOldFailedAuths removes old failed authentication records
func (h *Handler) CleanupOldFailedAuths() error {
// Keep records for 30 days
cutoffTime := time.Now().Add(-30 * 24 * time.Hour)
result := h.DB.Where("timestamp < ?", cutoffTime).Delete(&model.FailedAuth{})
if result.Error != nil {
log.Error("Failed to cleanup old failed auths:", result.Error)
return result.Error
}
if result.RowsAffected > 0 {
log.Infof("Cleaned up %d old failed authentication records", result.RowsAffected)
}
return nil
}
// GetBlockedIPs returns all currently blocked IPs
func (h *Handler) GetBlockedIPs() ([]model.BlockedIP, error) {
var blockedIPs []model.BlockedIP
err := h.DB.Order("blocked_at DESC").Find(&blockedIPs).Error
return blockedIPs, err
}
// GetRecentFailedAuths returns recent failed authentication attempts
func (h *Handler) GetRecentFailedAuths(limit int) ([]model.FailedAuth, error) {
var failedAuths []model.FailedAuth
err := h.DB.Order("timestamp DESC").Limit(limit).Find(&failedAuths).Error
return failedAuths, err
}

View File

@ -0,0 +1,96 @@
package handler
import (
"net/http"
"net/url"
"github.com/labstack/echo/v4"
)
// ShowSecurityDashboard displays the security overview page
func (h *Handler) ShowSecurityDashboard(c echo.Context) error {
// Get recent failed auths
failedAuths, err := h.GetRecentFailedAuths(50)
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
// Get blocked IPs
blockedIPs, err := h.GetBlockedIPs()
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
// Count active blocks
activeBlocks := 0
for _, blocked := range blockedIPs {
if blocked.IsBlocked() {
activeBlocks++
}
}
return c.Render(http.StatusOK, "security_dashboard", echo.Map{
"failedAuths": failedAuths,
"blockedIPs": blockedIPs,
"activeBlocks": activeBlocks,
"title": h.Title,
"logoPath": h.LogoPath,
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
})
}
// ShowBlockedIPs displays all blocked IPs
func (h *Handler) ShowBlockedIPs(c echo.Context) error {
blockedIPs, err := h.GetBlockedIPs()
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
return c.Render(http.StatusOK, "blocked_ips", echo.Map{
"blockedIPs": blockedIPs,
"title": h.Title,
"logoPath": h.LogoPath,
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
})
}
// ShowFailedAuths displays recent failed authentication attempts
func (h *Handler) ShowFailedAuths(c echo.Context) error {
// Auth check removed - middleware handles this
failedAuths, err := h.GetRecentFailedAuths(100)
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
return c.Render(http.StatusOK, "failed_auths", echo.Map{
"failedAuths": failedAuths,
"title": h.Title,
"logoPath": h.LogoPath,
"poweredBy": h.PoweredBy,
"poweredByUrl": h.PoweredByUrl,
})
}
// UnblockIPHandler handles the unblock IP request
func (h *Handler) UnblockIPHandler(c echo.Context) error {
// Get IP from URL parameter and decode it
encodedIP := c.Param("ip")
ipAddress, err := url.QueryUnescape(encodedIP)
if err != nil {
return c.JSON(http.StatusBadRequest, &Error{"Invalid IP address format"})
}
if err := h.UnblockIP(ipAddress); err != nil {
return c.JSON(http.StatusBadRequest, &Error{err.Error()})
}
return c.JSON(http.StatusOK, echo.Map{
"message": "IP unblocked successfully",
"ip": ipAddress,
})
}

81
dyndns/handler/session.go Normal file
View File

@ -0,0 +1,81 @@
package handler
import (
"encoding/gob"
"net/http"
"os"
"github.com/gorilla/sessions"
"github.com/labstack/gommon/log"
)
// Session wraps gorilla session
type Session struct {
*sessions.Session
}
// SessionStore wraps gorilla session store
type SessionStore struct {
store *sessions.CookieStore
}
// InitSessionStore creates a new session store with a secret key
func (h *Handler) InitSessionStore() error {
// Generate or get session secret from environment
secret := []byte(h.GetSessionSecret())
// Create cookie store
store := sessions.NewCookieStore(secret)
// Configure session options
store.Options = &sessions.Options{
Path: "/",
MaxAge: 24 * 60 * 60, // 24 hours default
HttpOnly: true,
Secure: false, // Will be set to true per-request if HTTPS
SameSite: http.SameSiteStrictMode,
}
h.SessionStore = &SessionStore{store: store}
// Register types for session encoding
gob.Register(map[string]interface{}{})
gob.Register([]interface{}{})
return nil
}
// Get retrieves a session
func (s *SessionStore) Get(r *http.Request, name string) (*Session, error) {
sess, err := s.store.Get(r, name)
if err != nil {
return nil, err
}
return &Session{Session: sess}, nil
}
// GetSessionSecret returns the session secret key
// Uses environment variable or generates a random one
func (h *Handler) GetSessionSecret() string {
// Try to get from environment
secret := h.GetEnv("DDNS_SESSION_SECRET", "")
if secret != "" {
return secret
}
// If not set, generate a warning and use admin password as base
log.Warn("DDNS_SESSION_SECRET not set! Using derived key. Set this in production!")
// Use admin login hash as base for session secret
return h.Config.AdminLogin + "-session-secret-key"
}
// GetEnv gets environment variable with default
func (h *Handler) GetEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}

View File

@ -3,9 +3,10 @@ package main
import (
"html/template"
"net/http"
"strings"
"time"
"github.com/benjaminbear/docker-ddns-server/dyndns/handler"
"github.com/w3K-one/docker-ddns-server/dyndns/handler"
"github.com/foolin/goview"
"github.com/foolin/goview/supports/echoview-v4"
"github.com/go-playground/validator/v10"
@ -22,7 +23,7 @@ func main() {
e.Use(middleware.Logger())
// Set Renderer
// Set Renderer with custom template functions
e.Renderer = echoview.New(goview.Config{
Root: "views",
Master: "layouts/master",
@ -31,6 +32,24 @@ func main() {
"year": func() string {
return time.Now().Format("2006")
},
"hasPrefix": func(s, prefix string) bool {
return strings.HasPrefix(s, prefix)
},
"slice": func(s string, start, end int) string {
if start < 0 {
start = 0
}
if end > len(s) {
end = len(s)
}
if start > end {
return ""
}
return s[start:end]
},
"mod": func(i, j int) int {
return i % j
},
},
DisableCache: true,
})
@ -49,63 +68,87 @@ func main() {
e.Logger.Fatal(err)
}
// Parse environment variables and initialize session store
authAdmin, err := h.ParseEnvs()
if err != nil {
e.Logger.Fatal(err)
}
// UI Routes
groupPublic := e.Group("/")
groupPublic.GET("*", func(c echo.Context) error {
//redirect to admin
return c.Redirect(301, "./admin/")
// Apply IP blocker middleware globally
e.Use(h.IPBlockerMiddleware())
// Apply cleanup middleware
e.Use(h.CleanupMiddleware())
// Public redirect (root redirects to admin)
e.GET("/", func(c echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/@/")
})
groupAdmin := e.Group("/admin")
// Admin routes with session-based authentication and HTTPS redirect
groupAdmin := e.Group("/@")
// Apply HTTPS redirect middleware (only for admin routes)
groupAdmin.Use(h.HTTPSRedirectMiddleware())
// Login routes (no auth required)
groupAdmin.GET("/login", h.ShowLoginPage)
groupAdmin.POST("/login", h.HandleLogin)
// Logout route (no auth required - handles its own session check)
groupAdmin.GET("/logout", h.HandleLogout)
// Protected admin routes (require authentication)
if authAdmin {
groupAdmin.Use(middleware.BasicAuth(h.AuthenticateAdmin))
groupAdmin.Use(h.SessionAuthMiddleware())
}
// Main admin pages
groupAdmin.GET("/", h.ListHosts)
groupAdmin.GET("/hosts", h.ListHosts)
groupAdmin.GET("/hosts/add", h.AddHost)
groupAdmin.GET("/hosts/edit/:id", h.EditHost)
groupAdmin.GET("/hosts", h.ListHosts)
groupAdmin.GET("/cnames/add", h.AddCName)
groupAdmin.GET("/cnames", h.ListCNames)
groupAdmin.GET("/logs", h.ShowLogs)
groupAdmin.GET("/logs/host/:id", h.ShowHostLogs)
// Rest Routes
groupAdmin.POST("/hosts/add", h.CreateHost)
groupAdmin.POST("/hosts/edit/:id", h.UpdateHost)
groupAdmin.GET("/hosts/delete/:id", h.DeleteHost)
//redirect to logout
groupAdmin.GET("/logout", func(c echo.Context) error {
// either custom url
if len(h.LogoutUrl) > 0 {
return c.Redirect(302, h.LogoutUrl)
}
// or standard url
return c.Redirect(302, "../")
})
// CName routes
groupAdmin.GET("/cnames", h.ListCNames)
groupAdmin.GET("/cnames/add", h.AddCName)
groupAdmin.POST("/cnames/add", h.CreateCName)
groupAdmin.GET("/cnames/delete/:id", h.DeleteCName)
// dyndns compatible api
// (avoid breaking changes and create groups for each update endpoint)
// Log routes
groupAdmin.GET("/logs", h.ShowLogs)
groupAdmin.GET("/logs/host/:id", h.ShowHostLogs)
// Security management routes
if authAdmin {
groupAdmin.GET("/security", h.ShowSecurityDashboard)
groupAdmin.GET("/security/blocked-ips", h.ShowBlockedIPs)
groupAdmin.GET("/security/failed-auths", h.ShowFailedAuths)
groupAdmin.POST("/security/unblock/:ip", h.UnblockIPHandler)
}
// DynDNS API endpoints (HTTP allowed, BasicAuth required)
// These endpoints are used by routers/NVRs and need BasicAuth
updateRoute := e.Group("/update")
updateRoute.Use(middleware.BasicAuth(h.AuthenticateUpdate))
updateRoute.Use(h.UpdateAuthMiddleware())
updateRoute.GET("", h.UpdateIP)
nicRoute := e.Group("/nic")
nicRoute.Use(middleware.BasicAuth(h.AuthenticateUpdate))
nicRoute.Use(h.UpdateAuthMiddleware())
nicRoute.GET("/update", h.UpdateIP)
v2Route := e.Group("/v2")
v2Route.Use(middleware.BasicAuth(h.AuthenticateUpdate))
v2Route.Use(h.UpdateAuthMiddleware())
v2Route.GET("/update", h.UpdateIP)
v3Route := e.Group("/v3")
v3Route.Use(middleware.BasicAuth(h.AuthenticateUpdate))
v3Route.Use(h.UpdateAuthMiddleware())
v3Route.GET("/update", h.UpdateIP)
// health-check
// Health-check endpoint (no auth)
e.GET("/ping", func(c echo.Context) error {
u := &handler.Error{
Message: "OK",

View File

@ -7,7 +7,7 @@ import (
// CName is a dns cname entry.
type CName struct {
gorm.Model
Hostname string `gorm:"not null" form:"hostname" validate:"required,hostname"`
Hostname string `gorm:"not null" form:"hostname" validate:"required,min=1"` //Alow 1 character cnames
Target Host `validate:"required"`
TargetID uint
Ttl int `form:"ttl" validate:"required,min=20,max=86400"`

View File

@ -0,0 +1,40 @@
package model
import (
"time"
"gorm.io/gorm"
)
// FailedAuth tracks failed authentication attempts
// WARNING: This includes password logging which is a security risk.
// Passwords should be handled carefully and never displayed without authorization.
type FailedAuth struct {
gorm.Model
IPAddress string `gorm:"index;not null"`
UserAgent string
Timestamp time.Time `gorm:"index"`
Path string // The path they tried to access
Username string // Username they attempted (if provided)
Password string // Password they attempted (SECURITY RISK - handle carefully)
}
// BlockedIP represents an IP that has been blocked
type BlockedIP struct {
gorm.Model
IPAddress string `gorm:"uniqueIndex;not null"`
BlockedAt time.Time `gorm:"index"`
BlockedUntil time.Time `gorm:"index"` // For temporary blocks
FailureCount int
IsPermanent bool // Flag for permanent blocks
LastAttemptAt time.Time
Reason string
}
// IsBlocked checks if a block is still active
func (b *BlockedIP) IsBlocked() bool {
if b.IsPermanent {
return true
}
return time.Now().Before(b.BlockedUntil)
}

View File

@ -9,13 +9,13 @@ import (
// Host is a dns host entry.
type Host struct {
gorm.Model
Hostname string `gorm:"unique_index:idx_host_domain;not null" form:"hostname" validate:"required,hostname"`
Hostname string `gorm:"unique_index:idx_host_domain;not null" form:"hostname" validate:"required,min=1"` // Allow 1 character hostnames
Domain string `gorm:"unique_index:idx_host_domain;not null" form:"domain" validate:"required,fqdn"`
Ip string `form:"ip" validate:"omitempty,ipv4|ipv6"`
Ttl int `form:"ttl" validate:"required,min=20,max=86400"`
LastUpdate time.Time `form:"lastupdate"`
UserName string `gorm:"unique" form:"username" validate:"min=3"`
Password string `form:"password" validate:"min=8"`
UserName string `gorm:"not null" form:"username" validate:"min=1"` // Allow 1 character usernames
Password string `form:"password" validate:"min=6"` // Minimum 6 character passwords
}
// UpdateHost updates all fields of a host entry

View File

@ -8,7 +8,7 @@ import (
"net/http"
"strings"
"github.com/benjaminbear/docker-ddns-server/dyndns/ipparser"
"github.com/w3K-one/docker-ddns-server/dyndns/ipparser"
)
// GetIPType finds out if the IP is IPv4 or IPv6

View File

@ -76,4 +76,4 @@ body {
.jumbotron {
border-bottom: 0;
}
}
}

BIN
dyndns/static/icons/dns.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L9.87868 9.87868M20 20L14.1213 14.1213M9.87868 9.87868C9.33579 10.4216 9 11.1716 9 12C9 13.6569 10.3431 15 12 15C12.8284 15 13.5784 14.6642 14.1213 14.1213M9.87868 9.87868L14.1213 14.1213M6.76821 6.76821C4.72843 8.09899 2.96378 10.026 2 11.9998C3.74646 15.5764 8.12201 19 11.9998 19C13.7376 19 15.5753 18.3124 17.2317 17.2317M9.76138 5.34717C10.5114 5.12316 11.2649 5 12.0005 5C15.8782 5 20.2531 8.42398 22 12.0002C21.448 13.1302 20.6336 14.2449 19.6554 15.2412" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -1,148 +1,166 @@
$("button.addHost").click(function () {
location.href='/admin/hosts/add';
});
$("button.editHost").click(function () {
location.href='/admin/hosts/edit/' + $(this).attr('id');
});
$("button.deleteHost").click(function () {
$.ajax({
contentType: 'application/x-www-form-urlencoded; charset=UTF-8',
type: 'GET',
url: "/admin/hosts/delete/" + $(this).attr('id')
}).done(function(data, textStatus, jqXHR) {
location.href="/admin/hosts";
}).fail(function(jqXHR, textStatus, errorThrown) {
alert("Error: " + $.parseJSON(jqXHR.responseText).message);
location.reload()
});
});
$("button.showHostLog").click(function () {
location.href='/admin/logs/host/' + $(this).attr('id');
});
$("button.add, button.edit").click(function () {
let id = $(this).attr('id');
if (id !== "") {
id = "/"+id
}
let action;
if ($(this).hasClass("add")) {
action = "add";
}
if ($(this).hasClass("edit")) {
action = "edit";
}
let type;
if ($(this).hasClass("host")) {
type = "hosts";
}
if ($(this).hasClass("cname")) {
type = "cnames";
}
$('#domain').prop('disabled', false);
$.ajax({
contentType: 'application/x-www-form-urlencoded; charset=UTF-8',
data: $('#editHostForm').serialize(),
type: 'POST',
url: '/admin/'+type+'/'+action+id,
}).done(function(data, textStatus, jqXHR) {
location.href="/admin/"+type;
}).fail(function(jqXHR, textStatus, errorThrown) {
alert("Error: " + $.parseJSON(jqXHR.responseText).message);
// Wrap ALL event handlers in document.ready to ensure DOM is loaded
$(document).ready(function(){
// ===== HOST BUTTONS =====
$("button.addHost").click(function () {
location.href='/@/hosts/add';
});
return false;
});
$("button.editHost").click(function () {
location.href='/@/hosts/edit/' + $(this).attr('id');
});
$("#logout").click(function (){
//document.execCommand("ClearAuthenticationCache");
try {
// This is for Firefox
$.ajax({
// This can be any path on your same domain which requires HTTPAuth
url: "",
username: 'reset',
password: 'reset',
// If the return is 401, refresh the page to request new details.
statusCode: { 401: function() {
// document.location = document.location;
}
}
});
} catch (exception) {
// Firefox throws an exception since we didn't handle anything but a 401 above
// This line works only in IE
if (!document.execCommand("ClearAuthenticationCache")) {
// exeCommand returns false if it didn't work (which happens in Chrome) so as a last
// resort refresh the page providing new, invalid details.
// document.location = location.protocol+"//reset:reset@" + document.location.hostname + document.location.pathname;
}
$("button.deleteHost").click(function () {
$.ajax({
contentType: 'application/x-www-form-urlencoded; charset=UTF-8',
type: 'GET',
url: "/@/hosts/delete/" + $(this).attr('id')
}).done(function(data, textStatus, jqXHR) {
location.href="/@/hosts";
}).fail(function(jqXHR, textStatus, errorThrown) {
alert("Error: " + $.parseJSON(jqXHR.responseText).message);
location.reload()
});
});
$("button.showHostLog").click(function () {
location.href='/@/logs/host/' + $(this).attr('id');
});
// ===== CNAME BUTTONS =====
$("button.addCName").click(function () {
location.href='/@/cnames/add';
});
$("button.deleteCName").click(function () {
$.ajax({
contentType: 'application/x-www-form-urlencoded; charset=UTF-8',
type: 'GET',
url: "/@/cnames/delete/" + $(this).attr('id')
}).done(function(data, textStatus, jqXHR) {
location.href="/@/cnames";
}).fail(function(jqXHR, textStatus, errorThrown) {
alert("Error: " + $.parseJSON(jqXHR.responseText).message);
location.reload()
});
});
// ===== ADD/EDIT FORM BUTTONS =====
$("button.add, button.edit").click(function () {
let id = $(this).attr('id');
if (id !== "") {
id = "/"+id
}
console.log("first logout")
});
$("button.addCName").click(function () {
location.href='/admin/cnames/add';
});
let action;
if ($(this).hasClass("add")) {
action = "add";
}
$("button.deleteCName").click(function () {
$.ajax({
contentType: 'application/x-www-form-urlencoded; charset=UTF-8',
type: 'GET',
url: "/admin/cnames/delete/" + $(this).attr('id')
}).done(function(data, textStatus, jqXHR) {
location.href="/admin/cnames";
}).fail(function(jqXHR, textStatus, errorThrown) {
alert("Error: " + $.parseJSON(jqXHR.responseText).message);
location.reload()
if ($(this).hasClass("edit")) {
action = "edit";
}
let type;
if ($(this).hasClass("host")) {
type = "hosts";
}
if ($(this).hasClass("cname")) {
type = "cnames";
}
$('#domain').prop('disabled', false);
$.ajax({
contentType: 'application/x-www-form-urlencoded; charset=UTF-8',
data: $('#editHostForm').serialize(),
type: 'POST',
url: '/@/'+type+'/'+action+id,
}).done(function(data, textStatus, jqXHR) {
location.href="/@/"+type;
}).fail(function(jqXHR, textStatus, errorThrown) {
alert("Error: " + $.parseJSON(jqXHR.responseText).message);
});
return false;
});
// ===== LOGOUT BUTTON =====
$("#logout").click(function (){
// Note: The old HTTP Basic Auth logout code is commented out since we use sessions now
// The logout link goes to /@/logout which handles session destruction
console.log("Logout clicked - redirecting to /@/logout");
});
// ===== CLIPBOARD BUTTONS =====
$("button.copyToClipboard").click(function () {
let id;
if ($(this).hasClass('username')) {
id = "username";
} else if ($(this).hasClass('password')) {
id = "password";
}
let copyText = document.getElementById(id);
copyText.select();
copyText.setSelectionRange(0, 99999);
document.execCommand("copy");
});
$("button.copyUrlToClipboard").click(function () {
let id = $(this).attr('id');
let hostname = document.getElementById('host-hostname_'+id).innerHTML
let domain = document.getElementById('host-domain_'+id).innerHTML
let username = document.getElementById('host-username_'+id).innerHTML
let password = document.getElementById('host-password_'+id).innerHTML
let out = location.protocol + '//' +username.trim()+':'+password.trim()+'@'+ domain
out +='/update?hostname='+hostname
let dummy = document.createElement("textarea");
document.body.appendChild(dummy);
dummy.value = out;
dummy.select();
document.execCommand("copy");
document.body.removeChild(dummy);
});
// ===== GENERATE HASH BUTTONS =====
$("button.generateHash").click(function () {
let id;
if ($(this).hasClass('username')) {
id = "username";
} else if ($(this).hasClass('password')) {
id = "password";
}
let input = document.getElementById(id);
input.value = randomHash();
});
// ===== TOOLTIPS =====
$(".errorTooltip").tooltip({
track: true,
content: function () {
return $(this).prop('title');
}
});
// ===== NAVIGATION HIGHLIGHTING =====
urlPath = new URL(window.location.href).pathname.split("/")[2];
if (urlPath === "") {
urlPath = "hosts"
}
document.getElementsByClassName("nav-"+urlPath)[0].classList.add("active");
});
// ===== UTILITY FUNCTIONS (outside document.ready is OK) =====
function newTargetSelected() {
var sel = document.getElementById("target_id");
var x = sel.options[sel.selectedIndex].label.replace(sel.options[sel.selectedIndex].text, '');
document.getElementById("domain_mirror").value = x;
}
$("button.copyToClipboard").click(function () {
let id;
if ($(this).hasClass('username')) {
id = "username";
} else if ($(this).hasClass('password')) {
id = "password";
}
let copyText = document.getElementById(id);
copyText.select();
copyText.setSelectionRange(0, 99999);
document.execCommand("copy");
});
$("button.copyUrlToClipboard").click(function () {
let id = $(this).attr('id');
let hostname = document.getElementById('host-hostname_'+id).innerHTML
let domain = document.getElementById('host-domain_'+id).innerHTML
let username = document.getElementById('host-username_'+id).innerHTML
let password = document.getElementById('host-password_'+id).innerHTML
let out = location.protocol + '//' +username.trim()+':'+password.trim()+'@'+ domain
out +='/update?hostname='+hostname
let dummy = document.createElement("textarea");
document.body.appendChild(dummy);
dummy.value = out;
dummy.select();
document.execCommand("copy");
document.body.removeChild(dummy);
});
function randomHash() {
let chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
var passwordLength = 16;
@ -151,33 +169,5 @@ function randomHash() {
var randomNumber = Math.floor(Math.random() * chars.length);
password += chars.substring(randomNumber, randomNumber +1);
}
return password;
}
$("button.generateHash").click(function () {
let id;
if ($(this).hasClass('username')) {
id = "username";
} else if ($(this).hasClass('password')) {
id = "password";
}
let input = document.getElementById(id);
input.value = randomHash();
});
$(document).ready(function(){
$(".errorTooltip").tooltip({
track: true,
content: function () {
return $(this).prop('title');
}
});
urlPath = new URL(window.location.href).pathname.split("/")[2];
if (urlPath === "") {
urlPath = "hosts"
}
document.getElementsByClassName("nav-"+urlPath)[0].classList.add("active");
});

View File

@ -0,0 +1,98 @@
{{define "content"}}
<div class="container marketing">
<h3 class="text-center mb-4">Blocked IP Addresses</h3>
<div class="mb-3">
<a href="/@/security" class="btn btn-secondary">&larr; Back to Security Dashboard</a>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>IP Address</th>
<th>Blocked At</th>
<th>Blocked Until</th>
<th>Last Attempt</th>
<th>Failure Count</th>
<th>Reason</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .blockedIPs}}
<tr class="{{if .IsBlocked}}table-danger{{else}}table-secondary{{end}}">
<td><code>{{.IPAddress}}</code></td>
<td>{{.BlockedAt.Format "01/02/2006 15:04"}}</td>
<td>{{if .IsPermanent}}
<span class="badge badge-dark">Permanent</span>
{{else}}
{{.BlockedUntil.Format "01/02/2006 15:04"}}
{{end}}</td>
<td>{{.LastAttemptAt.Format "01/02/2006 15:04"}}</td>
<td><span class="badge badge-danger">{{.FailureCount}}</span></td>
<td style="max-width: 300px;">{{.Reason}}</td>
<td>
{{if .IsBlocked}}
<span class="badge badge-danger">Active</span>
{{else}}
<span class="badge badge-secondary">Expired</span>
{{end}}
</td>
<td>
{{if .IsBlocked}}
<button class="btn btn-sm btn-warning unblock-btn" data-ip="{{.IPAddress}}">Unblock</button>
{{else}}
<span class="text-muted">N/A</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{if not .blockedIPs}}
<div class="alert alert-success text-center" role="alert">
<h4 class="alert-heading">No Blocked IPs</h4>
<p>There are currently no blocked IP addresses. Your site security is doing great!</p>
</div>
{{end}}
</div>
<script>
$(document).ready(function() {
console.log('Blocked IPs page JavaScript loaded');
// Unblock button handler using event delegation
$(document).on('click', '.unblock-btn', function(e) {
e.preventDefault();
var button = $(this);
var ip = button.attr('data-ip');
console.log('Unblock clicked for IP:', ip);
if (confirm('Are you sure you want to unblock IP: ' + ip + '?')) {
// Disable button during request
button.prop('disabled', true).text('Unblocking...');
$.ajax({
url: '/@/security/unblock/' + encodeURIComponent(ip),
type: 'POST',
success: function(result) {
console.log('Unblock success:', result);
alert('IP ' + ip + ' has been unblocked successfully!');
location.reload();
},
error: function(xhr, status, error) {
console.error('Unblock failed:', xhr.responseText);
alert('Error unblocking IP: ' + (xhr.responseText || error));
button.prop('disabled', false).text('Unblock');
}
});
}
});
console.log('Found', $('.unblock-btn').length, 'unblock buttons');
});
</script>
{{end}}

View File

@ -0,0 +1,362 @@
{{define "content"}}
<div class="container marketing">
<h3 class="text-center mb-4">Failed Authentication Attempts</h3>
<div class="mb-3">
<a href="/@/security" class="btn btn-secondary">&larr; Back to Security Dashboard</a>
</div>
<div class="alert alert-warning" role="alert">
<strong>⚠️ Security Warning:</strong> This system logs attempted passwords for security analysis.
<ul class="mb-0 mt-2">
<li>Ensure this database is encrypted and access is strictly controlled</li>
<li>Never share or export this data without proper authorization</li>
<li>Be aware that users may have mistyped legitimate passwords</li>
<li>Comply with relevant data protection regulations (GDPR, etc.)</li>
</ul>
</div>
<div class="alert alert-info" role="alert">
<strong>📌 IP Blocking Policy:</strong> Only failed attempts to the admin panel (<code>/@/*</code>) count toward IP blocking.
API endpoint failures (like <code>/nic/update</code>) are logged but do NOT trigger automatic IP blocks.
</div>
<style>
/* Alternating yellow shades for API rows */
.table-warning-light {
background-color: #fff9e6 !important; /* Lighter yellow */
}
.table-warning {
background-color: #fff3cd !important; /* Standard yellow */
}
/* Tooltip container for positioning */
.tooltip-container {
position: relative;
display: inline-block;
}
/* Speech bubble tooltip */
.speech-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 10px;
padding: 8px 12px;
background: #2c3e50;
color: #fff;
border-radius: 6px;
font-size: 13px;
font-family: 'Courier New', monospace;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
/* Password tooltips: single line, auto-width */
.pwd-btn + .speech-tooltip {
white-space: nowrap;
max-width: 90vw; /* Don't exceed viewport */
}
/* User Agent tooltips: multi-line, 50% table width */
.ua-btn + .speech-tooltip,
.ua-btn .speech-tooltip {
white-space: normal;
max-width: 50%; /* 50% of container width */
min-width: 200px;
word-wrap: break-word;
text-align: left;
}
/* Arrow pointing down */
.speech-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: #2c3e50;
}
/* Show tooltip when active */
.speech-tooltip.active {
opacity: 1;
visibility: visible;
}
/* Clickable tooltip styling with cursor */
.tooltip-btn {
cursor: pointer;
transition: all 0.2s;
border: none;
background: transparent;
padding: 2px 6px;
border-radius: 3px;
position: relative;
}
.tooltip-btn:hover {
background-color: rgba(0,0,0,0.05);
}
.tooltip-btn.active {
background-color: #ffc107 !important;
}
/* User Agent button - make it look clickable */
.ua-btn {
cursor: pointer;
transition: background-color 0.2s;
}
.ua-btn:hover {
background-color: rgba(0,0,0,0.03);
}
/* Copy feedback animation */
.copy-feedback {
position: absolute;
top: -25px;
left: 50%;
transform: translateX(-50%);
background: #28a745;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
white-space: nowrap;
z-index: 2000;
animation: fadeInOut 1.5s ease-in-out;
pointer-events: none;
}
@keyframes fadeInOut {
0% { opacity: 0; transform: translateX(-50%) translateY(5px); }
20% { opacity: 1; transform: translateX(-50%) translateY(0); }
80% { opacity: 1; transform: translateX(-50%) translateY(0); }
100% { opacity: 0; transform: translateX(-50%) translateY(-5px); }
}
/* Truncated text styling */
.truncated {
/* No underline - clean look */
}
</style>
<table class="table table-striped" style="font-size: 14px">
<thead>
<tr>
<th>Timestamp</th>
<th>IP Address</th>
<th>Username</th>
<th>Password Attempted</th>
<th>Path</th>
<th>User Agent</th>
</tr>
</thead>
<tbody>
{{range $index, $auth := .failedAuths}}
{{$isAPI := not (hasPrefix $auth.Path "/@/")}}
{{$rowClass := ""}}
{{if $isAPI}}
{{if eq (mod $index 2) 0}}
{{$rowClass = "table-warning"}}
{{else}}
{{$rowClass = "table-warning-light"}}
{{end}}
{{end}}
<tr class="{{$rowClass}}">
<td>{{$auth.Timestamp.Format "01/02/2006 15:04:05"}}</td>
<td><code>{{$auth.IPAddress}}</code></td>
<td>{{if $auth.Username}}<strong>{{$auth.Username}}</strong>{{else}}<em class="text-muted">no credentials</em>{{end}}</td>
<td>
{{if $auth.Password}}
<div class="tooltip-container">
<button class="btn btn-sm btn-outline-secondary tooltip-btn pwd-btn">👁</button>
<div class="speech-tooltip">
<code style="color: #ffc107; font-weight: bold;">{{$auth.Password}}</code>
</div>
</div>
{{else}}
<em class="text-muted">no password</em>
{{end}}
</td>
<td>
<code>{{$auth.Path}}</code>
{{if hasPrefix $auth.Path "/@/"}}
<span class="badge badge-danger ml-1">Admin</span>
{{else}}
<span class="badge badge-secondary ml-1">API</span>
{{end}}
</td>
<td>
{{if $auth.UserAgent}}
{{$ua := $auth.UserAgent}}
{{if gt (len $ua) 10}}
<div class="tooltip-container">
<button class="tooltip-btn ua-btn" title="Click to copy">
<span class="truncated">{{slice $ua 0 10}}...</span>
</button>
<div class="speech-tooltip">
<span style="color: #fff;">{{$ua}}</span>
</div>
</div>
{{else}}
<span class="ua-btn" style="cursor: pointer;" title="Click to copy">{{$ua}}</span>
{{end}}
{{else}}
<em class="text-muted">none</em>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{if not .failedAuths}}
<div class="alert alert-success text-center" role="alert">
<h4 class="alert-heading">No Failed Attempts</h4>
<p>There are no recorded failed authentication attempts. Excellent!</p>
</div>
{{end}}
<div class="alert alert-info mt-4" role="alert">
<strong>📋 Note:</strong> Failed authentication records are automatically cleaned up after 30 days.
IP addresses are blocked after <strong>3 failed ADMIN PANEL attempts</strong> within a 72-hour period and blocked for 7 days.
<br><strong>API endpoint failures do NOT trigger IP blocking.</strong>
</div>
</div>
<script>
$(document).ready(function() {
console.log('Failed auths page JavaScript loaded');
// Helper function to copy text to clipboard
function copyToClipboard(text, button) {
// Modern clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
showCopyFeedback(button, 'Copied!');
}).catch(function(err) {
console.error('Failed to copy:', err);
fallbackCopy(text, button);
});
} else {
// Fallback for older browsers
fallbackCopy(text, button);
}
}
// Fallback copy method
function fallbackCopy(text, button) {
var textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
showCopyFeedback(button, 'Copied!');
} catch (err) {
console.error('Fallback copy failed:', err);
showCopyFeedback(button, 'Copy failed');
}
document.body.removeChild(textArea);
}
// Show copy feedback animation
function showCopyFeedback(button, message) {
var feedback = $('<div class="copy-feedback">' + message + '</div>');
$(button).closest('.tooltip-container, td').append(feedback);
setTimeout(function() {
feedback.remove();
}, 1500);
}
// Password tooltips: Click to toggle AND copy
$(document).on('click', '.pwd-btn', function(e) {
e.preventDefault();
e.stopPropagation();
var button = $(this);
var tooltip = button.siblings('.speech-tooltip');
var password = button.closest('.tooltip-container').find('.speech-tooltip code').text();
// Copy password to clipboard
copyToClipboard(password, button);
// Close all other tooltips first
$('.speech-tooltip').removeClass('active');
$('.tooltip-btn').removeClass('active');
// Toggle this one
if (tooltip.hasClass('active')) {
tooltip.removeClass('active');
button.removeClass('active');
} else {
tooltip.addClass('active');
button.addClass('active');
}
});
// User Agent tooltips: Hover to show, Click to copy
$(document).on('mouseenter', '.ua-btn', function() {
var tooltip = $(this).siblings('.speech-tooltip');
tooltip.addClass('active');
});
$(document).on('mouseleave', '.ua-btn', function() {
var tooltip = $(this).siblings('.speech-tooltip');
// Small delay to allow moving mouse to tooltip
setTimeout(function() {
if (!tooltip.is(':hover')) {
tooltip.removeClass('active');
}
}, 100);
});
// Click on User Agent to copy
$(document).on('click', '.ua-btn', function(e) {
e.preventDefault();
e.stopPropagation();
var button = $(this);
var userAgent = button.closest('.tooltip-container').find('.speech-tooltip span').text();
// If there's no tooltip (short UA), get text directly
if (!userAgent) {
userAgent = button.text().trim();
}
copyToClipboard(userAgent, button);
});
// Keep tooltip open if hovering over it
$(document).on('mouseenter', '.ua-btn + .speech-tooltip', function() {
$(this).addClass('active');
});
$(document).on('mouseleave', '.ua-btn + .speech-tooltip', function() {
$(this).removeClass('active');
});
// Close password tooltips when clicking anywhere else on page
$(document).on('click', function(e) {
if (!$(e.target).closest('.pwd-btn').length) {
$('.pwd-btn').removeClass('active');
$('.pwd-btn').siblings('.speech-tooltip').removeClass('active');
}
});
console.log('Found', $('.pwd-btn').length, 'password buttons');
console.log('Found', $('.ua-btn').length, 'user agent buttons');
});
</script>
{{end}}

View File

@ -8,6 +8,16 @@
<link rel="icon" href="/static/icons/favicon.ico">
<title>{{.title}}</title>
<!-- Bootstrap core JavaScript
================================================== -->
<!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
<script src="/static/js/jquery-3.4.1.min.js"></script>
<!-- popper.js@1.16.0 -->
<script src="/static/js/popper.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
<script src="/static/js/jquery-ui.min.js"></script>
<script src="/static/js/actions-1.0.0.js"></script>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="/static/css/bootstrap.min.css">
@ -17,6 +27,42 @@
<!-- Custom styles for this template -->
<link href="/static/css/narrow-jumbotron.css" rel="stylesheet">
<style>
body {
/* Add padding to the top of the body to prevent content from being hidden under the sticky header */
padding-top: 1rem;
}
.header {
/* Make the header sticky */
position: sticky;
top: 0;
/* Ensure it stays on top of other content */
z-index: 1020;
/* Give it a solid background to prevent content from showing through */
background-color: #fff;
/* Add some padding and a border for better visual separation */
padding-top: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e5e5e5;
}
.logo {
/* Control the size of the logo image */
max-height: 128px;
width: auto;
position: fixed;
margin-top: -20px;
margin-left: -40px;
}
.copyleft {
display: inline-block;
-moz-transform: scale(-1, 1);
-webkit-transform: scale(-1, 1);
-o-transform: scale(-1, 1);
-ms-transform: scale(-1, 1);
transform: scale(-1, 1);
}
</style>
</head>
<body>
@ -27,40 +73,38 @@
<nav>
<ul class="nav nav-pills float-right">
<li class="nav-item">
<a class="nav-link nav-hosts" href="/admin/hosts">Hosts</a>
<a class="nav-link nav-hosts" href="/@/hosts">Hosts</a>
</li>
<li class="nav-item">
<a class="nav-link nav-cnames" href="/admin/cnames">CNames</a>
<a class="nav-link nav-cnames" href="/@/cnames">CNames</a>
</li>
<li class="nav-item">
<a class="nav-link nav-logs" href="/admin/logs">Logs</a>
<a class="nav-link nav-logs" href="/@/logs">Logs</a>
</li>
<li class="nav-item">
<a class="nav-link nav-logout" href="/admin/logout" id="logout">Logout</a>
<a class="nav-link nav-security" href="/@/security" title="Security"> 🔒 </a>
</li>
<li class="nav-item">
<a class="nav-link nav-logout" href="/@/logout" id="logout"> ⏏️ </a>
</li>
</ul>
</nav>
<h3 class="text-muted">{{.title}}</h3>
<!-- Conditionally display logo or title text -->
{{ if .logoPath }}
<a href="/@/hosts"><img src="{{.logoPath}}" alt="{{.title}}" class="logo"></a>
{{ else }}
<h3 class="text-muted">{{.title}}</h3>
{{ end }}
</div>
<!-- Page Content -->
{{template "content" .}}
<footer class="footer">
<p>&copy; {{.title}} {{year}}</p>
<p><span class="copyleft">&copy;</span> <a href="{{.poweredByUrl}}" target="_blank">{{.poweredBy}}</a> {{year}}</p>
</footer>
</div> <!-- /container -->
<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<!-- IE10 viewport hack for Surface/desktop Windows 8 bug -->
<script src="/static/js/jquery-3.4.1.min.js"></script>
<!-- popper.js@1.16.0 -->
<script src="/static/js/popper.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
<script src="/static/js/jquery-ui.min.js"></script>
<script src="/static/js/actions-1.0.0.js"></script>
</body>
</html>

View File

@ -51,4 +51,12 @@
</tbody>
</table>
</div>
{{end}}
{{ if .migrationReport }}
<div class="alert alert-info">
<strong>Database Migration Report:</strong>
<p>The following legacy entries were automatically converted to lowercase. You can copy this for your records.</p>
<textarea class="form-control" rows="5" readonly>{{ .migrationReport }}</textarea>
</div>
{{ end }}
{{end}}

211
dyndns/views/login.html Normal file
View File

@ -0,0 +1,211 @@
{{define "content"}}
<style>
/* Hide the header/navigation on login page */
.header {
display: none !important;
}
/* Override container styles for full-page login */
.container {
max-width: 100% !important;
padding: 0 !important;
}
/* Full-page background */
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
min-height: 100vh;
}
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.login-container {
background: white;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 2rem;
width: 100%;
max-width: 400px;
}
.logo-container {
text-align: center;
margin-bottom: 2rem;
}
.llogo {
max-height: 80px;
max-width: 200px;
}
.login-title {
text-align: center;
margin-bottom: 1.5rem;
color: #333;
}
.login-icon {
font-size: 48px;
text-align: center;
margin-bottom: 1rem;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.btn-login {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
padding: 12px;
font-weight: 500;
transition: transform 0.2s;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
color: white;
}
.alert {
margin-bottom: 1.5rem;
}
.remember-me {
margin-top: 1rem;
margin-bottom: 1rem;
}
.security-note {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e5e5;
text-align: center;
font-size: 0.85rem;
color: #666;
}
.https-indicator {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
background: #d4edda;
color: #155724;
border-radius: 4px;
font-size: 0.5rem;
margin-top: 1rem;
}
.https-indicator.insecure {
background: #fff3cd;
color: #856404;
}
/* Hide footer on login page */
.footer {
display: none !important;
}
</style>
<div class="login-wrapper">
<div class="login-container">
<div class="logo-container">
{{if .logoPath}}
<img src="{{.logoPath}}" alt="{{.title}}" class="llogo">
{{else}}
<div class="login-icon">🔐</div>
<h2 class="login-title">{{.title}}</h2>
{{end}}
</div>
{{if .error}}
<div class="alert alert-danger" role="alert">
{{if eq .error "invalid_credentials"}}
<strong>Login Failed!</strong> Invalid username or password.
{{else if eq .error "authentication_error"}}
<strong>Error!</strong> Authentication system error. Please try again.
{{else if eq .error "session_error"}}
<strong>Error!</strong> Could not create session. Please try again.
{{else}}
<strong>Error!</strong> {{.error}}
{{end}}
</div>
{{end}}
<form method="POST" action="/@/login">
<div class="form-group">
<label for="username">Username</label>
<input type="text"
class="form-control"
id="username"
name="username"
required
autofocus
placeholder="Enter username"
autocomplete="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password"
class="form-control"
id="password"
name="password"
required
placeholder="Enter password"
autocomplete="current-password">
</div>
<div class="remember-me">
<div class="custom-control custom-checkbox">
<input type="checkbox"
class="custom-control-input"
id="remember_me"
name="remember_me">
<label class="custom-control-label" for="remember_me">
Remember me for 30 days
</label>
</div>
</div>
<button type="submit" class="btn btn-login btn-block">
Login
</button>
</form>
<div class="security-note">
<small>
🔒 Your credentials are protected<br>
Session expires after 24 hours (or 30 days if remembered)<br>
<div id="https-status"></div>
</small>
</div>
</div>
</div>
<script>
// Check if HTTPS is enabled
(function() {
var isHTTPS = window.location.protocol === 'https:';
var statusDiv = document.getElementById('https-status');
if (isHTTPS) {
statusDiv.innerHTML = '<div class="https-indicator">✓ Secure HTTPS Connection</div>';
} else {
statusDiv.innerHTML = '<div class="https-indicator insecure">⚠ HTTP Connection (HTTPS Recommended)</div>';
}
})();
// Focus on username field
document.getElementById('username').focus();
</script>
{{end}}

202
dyndns/views/logout.html Normal file
View File

@ -0,0 +1,202 @@
{{define "content"}}
<style>
/* Hide the header/navigation on logout page */
.header {
display: none !important;
}
/* Override container styles for full-page logout */
.container {
max-width: 100% !important;
padding: 0 !important;
}
/* Full-page background */
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
min-height: 100vh;
}
.logout-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.logout-container {
background: white;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 3rem 2rem;
width: 100%;
max-width: 400px;
text-align: center;
}
.logout-icon {
font-size: 64px;
margin-bottom: 1.5rem;
}
h1 {
color: #333;
margin-bottom: 1rem;
font-size: 1.75rem;
}
p {
color: #666;
margin-bottom: 2rem;
line-height: 1.6;
}
.btn-login {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
padding: 12px 30px;
font-weight: 500;
transition: transform 0.2s;
text-decoration: none;
display: inline-block;
border-radius: 5px;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
color: white;
text-decoration: none;
}
.llogo {
max-height: 80px;
max-width: 200px;
margin-bottom: 1rem;
}
/* Hide footer on logout page */
.footer {
display: none !important;
}
.redirect-notice {
margin-top: 1.5rem;
padding: 1rem;
background: #f0f0f0;
border-radius: 5px;
color: #666;
font-size: 0.9rem;
}
.countdown {
font-size: 2rem;
font-weight: bold;
color: #667eea;
margin: 0.5rem 0;
}
.redirect-url {
color: #667eea;
text-decoration: none;
word-break: break-all;
}
.redirect-url:hover {
text-decoration: underline;
}
</style>
<div class="logout-wrapper">
<div class="logout-container">
{{if .logoPath}}
<img src="{{.logoPath}}" alt="{{.title}}" class="llogo">
{{end}}
<div class="logout-icon">👋</div>
<h1>Logged Out Successfully</h1>
<p>Your session has been ended and you have been securely logged out.</p>
{{if .logoutUrl}}
<div class="redirect-notice">
<p style="margin-bottom: 0.5rem;">Redirecting in <span class="countdown" id="countdown">7</span> seconds...</p>
<p style="margin-bottom: 0.5rem; font-size: 0.85rem;">
to: <a href="{{.logoutUrl}}" class="redirect-url">{{.logoutUrl}}</a>
</p>
<button onclick="cancelRedirect()" class="btn btn-sm btn-outline-secondary mt-2">Cancel Auto-Redirect</button>
</div>
{{end}}
<a href="/@/login" class="btn btn-login mt-3">Log In Again</a>
</div>
</div>
<script>
(function() {
// Check if logout URL is provided
var logoutUrl = {{if .logoutUrl}}"{{.logoutUrl}}"{{else}}null{{end}};
var redirectTimer = null;
var countdownInterval = null;
var secondsLeft = 7;
if (logoutUrl) {
console.log('Auto-redirect enabled. Redirecting to:', logoutUrl, 'in', secondsLeft, 'seconds');
// Update countdown display
countdownInterval = setInterval(function() {
secondsLeft--;
var countdownElement = document.getElementById('countdown');
if (countdownElement) {
countdownElement.textContent = secondsLeft;
}
if (secondsLeft <= 0) {
clearInterval(countdownInterval);
}
}, 1000);
// Set redirect timer
redirectTimer = setTimeout(function() {
console.log('Redirecting to:', logoutUrl);
window.location.href = logoutUrl;
}, 7000); // 7 seconds
} else {
console.log('No logout URL configured. Staying on logout page.');
}
// Function to cancel redirect
window.cancelRedirect = function() {
if (redirectTimer) {
clearTimeout(redirectTimer);
redirectTimer = null;
}
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
var redirectNotice = document.querySelector('.redirect-notice');
if (redirectNotice) {
redirectNotice.innerHTML = '<p style="color: #28a745; margin: 0;">✓ Auto-redirect cancelled</p>';
}
console.log('Auto-redirect cancelled by user');
};
// Cancel redirect if user clicks "Log In Again" button
var loginButton = document.querySelector('.btn-login');
if (loginButton) {
loginButton.addEventListener('click', function() {
if (redirectTimer) {
clearTimeout(redirectTimer);
}
if (countdownInterval) {
clearInterval(countdownInterval);
}
});
}
})();
</script>
{{end}}

View File

@ -0,0 +1,433 @@
{{define "content"}}
<div class="container marketing">
<h3 class="text-center mb-4">Security Dashboard</h3>
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card text-white bg-danger mb-3">
<div class="card-header">Active Blocks</div>
<div class="card-body">
<h2 class="card-title">{{.activeBlocks}}</h2>
<p class="card-text">IP addresses currently blocked</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-white bg-warning mb-3">
<div class="card-header">Failed Attempts</div>
<div class="card-body">
<h2 class="card-title">{{len .failedAuths}}</h2>
<p class="card-text">Recent failed login attempts</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-white bg-info mb-3">
<div class="card-header">Total Blocks</div>
<div class="card-body">
<h2 class="card-title">{{len .blockedIPs}}</h2>
<p class="card-text">Total IP blocks (active + expired)</p>
</div>
</div>
</div>
</div>
<!-- Recently Blocked IPs -->
<h4 class="mb-3">Recently Blocked IPs</h4>
<table class="table table-striped">
<thead>
<tr>
<th>IP Address</th>
<th>Blocked At</th>
<th>Blocked Until</th>
<th>Failures</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .blockedIPs}}
{{if .IsBlocked}}
<tr>
<td><code>{{.IPAddress}}</code></td>
<td>{{.BlockedAt.Format "01/02/2006 15:04"}}</td>
<td>{{if .IsPermanent}}Permanent{{else}}{{.BlockedUntil.Format "01/02/2006 15:04"}}{{end}}</td>
<td><span class="badge badge-danger">{{.FailureCount}}</span></td>
<td><span class="badge badge-danger">Active</span></td>
<td>
<button class="btn btn-sm btn-warning unblock-btn" data-ip="{{.IPAddress}}">Unblock</button>
</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
<!-- Recent Failed Authentication Attempts -->
<h4 class="mb-3 mt-4">Recent Failed Authentication Attempts</h4>
<div class="alert alert-warning" role="alert">
<strong>⚠️ Security Warning:</strong> This system logs attempted passwords for security analysis. Ensure database access is strictly controlled.
</div>
<div class="alert alert-info" role="alert">
<strong>📌 IP Blocking Policy:</strong> Only failed attempts to the admin panel (<code>/@/*</code>) count toward IP blocking.
API endpoint failures are logged but do NOT trigger automatic blocks.
</div>
<style>
/* Alternating yellow shades for API rows */
.table-warning-light {
background-color: #fff9e6 !important; /* Lighter yellow */
}
.table-warning {
background-color: #fff3cd !important; /* Standard yellow */
}
/* Tooltip container for positioning */
.tooltip-container {
position: relative;
display: inline-block;
}
/* Speech bubble tooltip */
.speech-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 10px;
padding: 8px 12px;
background: #2c3e50;
color: #fff;
border-radius: 6px;
font-size: 13px;
font-family: 'Courier New', monospace;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
/* Password tooltips: single line, auto-width */
.pwd-btn + .speech-tooltip {
white-space: nowrap;
max-width: 90vw; /* Don't exceed viewport */
}
/* User Agent tooltips: multi-line, 50% table width */
.ua-btn + .speech-tooltip,
.ua-btn .speech-tooltip {
white-space: normal;
max-width: 50%; /* 50% of container width */
min-width: 200px;
word-wrap: break-word;
text-align: left;
}
/* Arrow pointing down */
.speech-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: #2c3e50;
}
/* Show tooltip when active */
.speech-tooltip.active {
opacity: 1;
visibility: visible;
}
/* Clickable tooltip styling with cursor */
.tooltip-btn {
cursor: pointer;
transition: all 0.2s;
border: none;
background: transparent;
padding: 2px 6px;
border-radius: 3px;
position: relative;
}
.tooltip-btn:hover {
background-color: rgba(0,0,0,0.05);
}
.tooltip-btn.active {
background-color: #ffc107 !important;
}
/* User Agent button - make it look clickable */
.ua-btn {
cursor: pointer;
transition: background-color 0.2s;
}
.ua-btn:hover {
background-color: rgba(0,0,0,0.03);
}
/* Copy feedback animation */
.copy-feedback {
position: absolute;
top: -25px;
left: 50%;
transform: translateX(-50%);
background: #28a745;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
white-space: nowrap;
z-index: 2000;
animation: fadeInOut 1.5s ease-in-out;
pointer-events: none;
}
@keyframes fadeInOut {
0% { opacity: 0; transform: translateX(-50%) translateY(5px); }
20% { opacity: 1; transform: translateX(-50%) translateY(0); }
80% { opacity: 1; transform: translateX(-50%) translateY(0); }
100% { opacity: 0; transform: translateX(-50%) translateY(-5px); }
}
/* Truncated text styling */
.truncated {
/* No underline - clean look */
}
</style>
<table class="table table-striped" style="font-size: 14px">
<thead>
<tr>
<th>Timestamp</th>
<th>IP Address</th>
<th>Username</th>
<th>Password</th>
<th>Path</th>
<th>User Agent</th>
</tr>
</thead>
<tbody>
{{range $index, $auth := .failedAuths}}
{{$isAPI := not (hasPrefix $auth.Path "/@/")}}
{{$rowClass := ""}}
{{if $isAPI}}
{{if eq (mod $index 2) 0}}
{{$rowClass = "table-warning"}}
{{else}}
{{$rowClass = "table-warning-light"}}
{{end}}
{{end}}
<tr class="{{$rowClass}}">
<td>{{$auth.Timestamp.Format "01/02/2006 15:04:05"}}</td>
<td><code>{{$auth.IPAddress}}</code></td>
<td>{{if $auth.Username}}{{$auth.Username}}{{else}}<em>none</em>{{end}}</td>
<td>
{{if $auth.Password}}
<div class="tooltip-container">
<button class="btn btn-sm btn-outline-secondary tooltip-btn pwd-btn">👁</button>
<div class="speech-tooltip">
<code style="color: #ffc107; font-weight: bold;">{{$auth.Password}}</code>
</div>
</div>
{{else}}
<em>none</em>
{{end}}
</td>
<td>
<code>{{$auth.Path}}</code>
{{if hasPrefix $auth.Path "/@/"}}
<span class="badge badge-danger ml-1">Admin</span>
{{else}}
<span class="badge badge-secondary ml-1">API</span>
{{end}}
</td>
<td>
{{if $auth.UserAgent}}
{{$ua := $auth.UserAgent}}
{{if gt (len $ua) 10}}
<div class="tooltip-container">
<button class="tooltip-btn ua-btn" title="Click to copy">
<span class="truncated">{{slice $ua 0 10}}...</span>
</button>
<div class="speech-tooltip">
<span style="color: #fff;">{{$ua}}</span>
</div>
</div>
{{else}}
<span class="ua-btn" style="cursor: pointer;" title="Click to copy">{{$ua}}</span>
{{end}}
{{else}}
<em>none</em>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<div class="text-center mt-4">
<a href="/@/security/blocked-ips" class="btn btn-primary">View All Blocked IPs</a>
<a href="/@/security/failed-auths" class="btn btn-secondary">View All Failed Attempts</a>
</div>
</div>
<script>
$(document).ready(function() {
console.log('Security dashboard JavaScript loaded');
// Helper function to copy text to clipboard
function copyToClipboard(text, button) {
// Modern clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
showCopyFeedback(button, 'Copied!');
}).catch(function(err) {
console.error('Failed to copy:', err);
fallbackCopy(text, button);
});
} else {
// Fallback for older browsers
fallbackCopy(text, button);
}
}
// Fallback copy method
function fallbackCopy(text, button) {
var textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
showCopyFeedback(button, 'Copied!');
} catch (err) {
console.error('Fallback copy failed:', err);
showCopyFeedback(button, 'Copy failed');
}
document.body.removeChild(textArea);
}
// Show copy feedback animation
function showCopyFeedback(button, message) {
var feedback = $('<div class="copy-feedback">' + message + '</div>');
$(button).closest('.tooltip-container, td').append(feedback);
setTimeout(function() {
feedback.remove();
}, 1500);
}
// Unblock IP button handler
$(document).on('click', '.unblock-btn', function(e) {
e.preventDefault();
var button = $(this);
var ip = button.attr('data-ip');
console.log('Unblock button clicked for IP:', ip);
if (confirm('Are you sure you want to unblock IP: ' + ip + '?')) {
$.ajax({
url: '/@/security/unblock/' + encodeURIComponent(ip),
type: 'POST',
success: function(result) {
console.log('Unblock success:', result);
alert('IP ' + ip + ' has been unblocked successfully!');
location.reload();
},
error: function(xhr, status, error) {
console.error('Unblock error:', xhr.responseText);
alert('Error unblocking IP: ' + (xhr.responseText || error));
}
});
}
});
// Password tooltips: Click to toggle AND copy
$(document).on('click', '.pwd-btn', function(e) {
e.preventDefault();
e.stopPropagation();
var button = $(this);
var tooltip = button.siblings('.speech-tooltip');
var password = button.closest('.tooltip-container').find('.speech-tooltip code').text();
// Copy password to clipboard
copyToClipboard(password, button);
// Close all other tooltips first
$('.speech-tooltip').removeClass('active');
$('.tooltip-btn').removeClass('active');
// Toggle this one
if (tooltip.hasClass('active')) {
tooltip.removeClass('active');
button.removeClass('active');
} else {
tooltip.addClass('active');
button.addClass('active');
}
});
// User Agent tooltips: Hover to show, Click to copy
$(document).on('mouseenter', '.ua-btn', function() {
var tooltip = $(this).siblings('.speech-tooltip');
tooltip.addClass('active');
});
$(document).on('mouseleave', '.ua-btn', function() {
var tooltip = $(this).siblings('.speech-tooltip');
// Small delay to allow moving mouse to tooltip
setTimeout(function() {
if (!tooltip.is(':hover')) {
tooltip.removeClass('active');
}
}, 100);
});
// Click on User Agent to copy
$(document).on('click', '.ua-btn', function(e) {
e.preventDefault();
e.stopPropagation();
var button = $(this);
var userAgent = button.closest('.tooltip-container').find('.speech-tooltip span').text();
// If there's no tooltip (short UA), get text directly
if (!userAgent) {
userAgent = button.text().trim();
}
copyToClipboard(userAgent, button);
});
// Keep tooltip open if hovering over it
$(document).on('mouseenter', '.ua-btn + .speech-tooltip', function() {
$(this).addClass('active');
});
$(document).on('mouseleave', '.ua-btn + .speech-tooltip', function() {
$(this).removeClass('active');
});
// Close password tooltips when clicking anywhere else on page
$(document).on('click', function(e) {
if (!$(e.target).closest('.pwd-btn').length) {
$('.pwd-btn').removeClass('active');
$('.pwd-btn').siblings('.speech-tooltip').removeClass('active');
}
});
console.log('Event handlers attached. Found', $('.unblock-btn').length, 'unblock buttons');
console.log('Event handlers attached. Found', $('.pwd-btn').length, 'password buttons');
console.log('Event handlers attached. Found', $('.ua-btn').length, 'user agent buttons');
});
</script>
{{end}}

BIN
img/addcname.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 135 KiB

BIN
img/listcnames.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 143 KiB

BIN
img/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

BIN
img/logout.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

BIN
img/security.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB