v2.0.0
# Complete Enhancement Package - Major Feature Update Comprehensive enhancement package for docker-ddns-server including security features, modern authentication, UI/UX improvements, and production-ready deployment features. ## 🔒 Security & Authentication ### IP Blocking System - Implemented automatic IP blocking after 3 failed authentication attempts within 72 hours - Added 7-day block duration with automatic expiration - Created `blocked_ips` database table for tracking blocked addresses - Added automatic cleanup of expired blocks - Implemented manual IP unblock capability via security dashboard ### Failed Authentication Logging - Added comprehensive failed authentication logging system - Created `failed_auths` database table storing IP, timestamp, username, and password - Implemented threat intelligence features for password pattern analysis - Added automatic cleanup of old authentication records - Logs intentionally include passwords for single-user security analysis ### Session-Based Authentication - Replaced HTTP Basic Auth with modern session-based authentication for admin panel - Integrated gorilla/sessions library for secure session management - Added configurable session secrets via `DDNS_SESSION_SECRET` environment variable - Implemented "Remember Me" functionality with 30-day session duration - Added proper session destruction on logout - Session cookies configured with HttpOnly, Secure, and SameSite attributes - Maintained HTTP Basic Auth for DynDNS API endpoints (device compatibility) ### HTTPS Enforcement - Added intelligent HTTPS detection via multiple header checks - Implemented automatic HTTPS redirect for admin panel when available - Graceful HTTP fallback when HTTPS unavailable - Supports reverse proxy configurations (nginx, Caddy, Traefik) - Detects SSL via X-Forwarded-Proto, X-Forwarded-Ssl, X-Url-Scheme headers - API endpoints remain HTTP-compatible for device support ## 🎨 UI/UX Enhancements ### Authentication UI - Created modern login page with gradient background and clean design - Added HTTPS security indicator (✓ green / ⚠ yellow) - Implemented auto-focus on username field - Added clear error messages for failed login attempts - Created logout confirmation page with redirect options - Removed browser authentication dialog popups ### Navigation & Layout - Changed admin panel URL from `/admin` to `/@` for uniqueness - Updated navigation with unicode icons (🏠 Dashboard, 🔒 Security, ⏏️ Logout) - Added tooltips to all navigation icons - Implemented sticky header that remains visible on scroll - Enhanced responsive design for mobile/tablet access ### Logo Support - Added automatic logo detection and display - Supports PNG, WebP, and SVG formats - Checks `/static/icons/` for logo files - Graceful fallback to text title if no logo found - Maintains aspect ratio and responsive sizing ### Security Dashboard - Created comprehensive security overview page at `/@/security` - Added statistics cards showing active blocks, failed attempts, and total blocks - Implemented recent failed attempts table with sortable columns - Added password reveal/hide functionality with confirmation prompts - Created detailed blocked IPs management page with unblock capability - Created detailed failed authentication logs page with full history - Added visual indicators for security status ## 📊 Data Management ### Data Consistency & Normalization - Implemented automatic lowercase conversion for all usernames and hostnames - Prevents case-sensitivity issues in DNS lookups and authentication - Ensures consistent data storage and retrieval - Handles mixed-case legacy data gracefully ### Automatic Migration - Added on-the-fly migration system for legacy uppercase entries - Migration triggers automatically on first `/@/hosts` page visit - Handles hostname conflicts by appending sequential numbers - Provides detailed migration report in UI showing all changes - Non-destructive migration preserves all host data - One-time execution with persistent migration status tracking ### Validation Updates - Reduced minimum hostname length to 1 character (allows single-letter subdomains) - Reduced minimum username length to 1 character - Reduced minimum password length to 6 characters - Maintained security while improving flexibility ### Username Uniqueness - Removed uniqueness constraint on usernames - Allows multiple hosts to share the same username - Supports different passwords for same username across hosts - Enables more flexible credential management strategies ## 🛡️ Middleware & Request Handling ### IP Blocker Middleware - Created IPBlockerMiddleware to check requests against blocked IPs - Automatic redirect to 127.0.0.1 for blocked addresses - Lightweight performance impact with database lookup - Positioned early in middleware chain for efficiency ### Session Authentication Middleware - Created SessionAuthMiddleware for admin panel protection - Skips authentication check for /login and /logout routes - Redirects unauthenticated users to login page - Validates session integrity on every request - Compatible with reverse proxy configurations ### HTTPS Redirect Middleware - Created HTTPSRedirectMiddleware for admin panel security - Intelligent detection of HTTPS availability - Skips redirect for API endpoints - Handles X-Forwarded-* headers from reverse proxies - Graceful operation when HTTPS unavailable ## 🗄️ Database & Models ### New Tables - Added `failed_auths` table for authentication logging - Added `blocked_ips` table for IP block tracking - Proper foreign key relationships and indexes - Automatic timestamps on all records ### Cleanup Functions - Implemented automatic cleanup of expired IP blocks - Implemented automatic cleanup of old authentication logs - Configurable retention periods - Background cleanup execution ## 🔧 Technical Improvements ### Dependencies - Added `github.com/gorilla/sessions@v1.2.2` for session management - Updated go.mod with proper version constraints - Maintained compatibility with existing dependencies ### Handler Architecture - Separated security logic into dedicated handler files - Created `security.go` for blocking logic and logging - Created `security_dashboard.go` for UI handlers - Created `auth.go` for login/logout and session management - Created `session.go` for session store implementation - Improved code organization and maintainability ### Main Application - Updated routing to support session-based authentication - Added session initialization on startup - Configured route groups for admin panel and API - Middleware ordering optimized for performance and security ## 🐳 Docker & CI/CD ### Multi-Platform Builds & Automated Releases - Created GitHub Actions workflow (`BuildEmAll.yml`) for automated Docker builds - Supports linux/amd64, linux/386, linux/arm/v7, and linux/arm64 platforms - Automatic builds on push to master with dyndns/ directory changes - Intelligent version tagging system: - Extracts version from commit message (e.g., "v1.2.3 Feature description") - Auto-increments patch version from latest git tag - Falls back to date-based versioning (vYY.MM.DD-HHMM) if no tags exist - Tags images with both `:latest` and semantic version tags (`:vX.Y.Z`) - Automatic GitHub release creation with each build - Release includes Docker image reference and commit message as notes - Publishes to Docker Hub (w3kllc/ddns) - Cross-platform compatibility for ARM devices (Raspberry Pi, etc.) - Workflow can be triggered manually via GitHub Actions UI ### Deployment - Enhanced docker-compose.yml example with all new features - Added documentation for environment variable configuration - Included reverse proxy configuration examples - Added security best practices for production deployment - Semantic versioning with automatic release management ## 📝 Documentation ### README Enhancements - Added comprehensive Security Features section - Added Environment Variables reference with descriptions - Added Admin Panel Access documentation - Added Data Consistency & Migration guide - Added API Endpoints documentation - Added UI/UX Enhancements overview - Added Reverse Proxy Configuration examples - Added Docker Configuration best practices - Added CI/CD & Multi-Platform Support details with versioning strategy - Added Semantic Versioning documentation - Added GitHub Release automation details - Added Security Best Practices recommendations - Added Threat Intelligence rationale - Added Migration Guide from original project - Added Troubleshooting section - Added API Reference documentation - Added Roadmap for future features - Updated Credits section - Added Support and Community links ## 🔄 Backward Compatibility ### Maintained Features - DynDNS API endpoints remain unchanged (/update, /nic/update, etc.) - HTTP Basic Auth still supported for API (device compatibility) - Existing host configurations continue working without changes - Database schema additions are non-breaking - All original functionality preserved ### Breaking Changes - Admin panel URL changed from `/admin` to `/@` (intentional, more unique) - Admin authentication method changed (sessions vs basic auth) - Requires `DDNS_SESSION_SECRET` environment variable for session security ## ⚡ Performance Considerations - IP blocker checks are optimized with database indexing - Session validation cached in memory - Automatic cleanup runs asynchronously - Minimal overhead on API endpoint performance - Efficient middleware ordering ## 🎯 Testing Considerations Recommended testing areas: - Login/logout flow with and without HTTPS - IP blocking after 3 failed attempts - Session persistence with remember me - API endpoint authentication (device compatibility) - HTTPS redirect with reverse proxy headers - Password reveal/hide in security dashboard - Hostname migration for legacy uppercase entries - Multi-platform Docker image functionality --- **Total Changes:** - **21 files modified** - **20 new files created** - **~2000+ lines of code added** - **100+ hours of development time** **Compatibility:** - ✅ Backward compatible for DynDNS API - ⚠️ Admin panel URL changed (bookmark update needed) - ✅ All existing hosts continue working - ✅ Database schema additions are additive **Credits:** - Original project: dprandzioch/docker-ddns - Web UI Fork: benjaminbear/docker-ddns-server - Enhanced fork: w3K-one/docker-ddns-server - Major enhancements and security features added This represents a significant enhancement to the original project while maintaining the core DynDNS functionality and adding modern security, authentication, and user experience improvements suitable for production deployment.
This commit is contained in:
+3
-1
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+9
-18
@@ -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()})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+114
-45
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+75
-32
@@ -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",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,4 +76,4 @@ body {
|
||||
.jumbotron {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -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 |
+148
-158
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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">← 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}}
|
||||
@@ -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">← 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}}
|
||||
@@ -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>© {{.title}} {{year}}</p>
|
||||
<p><span class="copyleft">©</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>
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user