diff options
| author | Ahmed AbdelHalim <[email protected]> | 2025-09-07 17:50:36 +0200 |
|---|---|---|
| committer | Ahmed AbdelHalim <[email protected]> | 2025-09-07 17:50:36 +0200 |
| commit | ff63742a2fa138c230fb2e080708d03ed888c248 (patch) | |
| tree | 10a5061a93cd6a91217d837a0a3a22a0bb17c54e | |
| parent | 17168a489392847794de0aed15fe395230a38261 (diff) | |
Add basic login and clean up config
| -rw-r--r-- | config.yaml | 5 | ||||
| -rw-r--r-- | config.yml | 4 | ||||
| -rw-r--r-- | config.yml.example | 7 | ||||
| -rw-r--r-- | internal/auth.go | 107 | ||||
| -rw-r--r-- | internal/config.go | 17 | ||||
| -rw-r--r-- | main.go | 140 | ||||
| -rw-r--r-- | static/css/styles.css | 64 | ||||
| -rw-r--r-- | static/js/app.js | 27 | ||||
| -rw-r--r-- | templates/index.html | 9 | ||||
| -rw-r--r-- | templates/login.html | 34 |
10 files changed, 373 insertions, 41 deletions
diff --git a/config.yaml b/config.yaml deleted file mode 100644 index bfd6e94..0000000 --- a/config.yaml +++ /dev/null @@ -1,5 +0,0 @@ ---- -server: - host: "0.0.0.0" - port: "8080" - title: "WireGuard Gateway Portal" diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..63d95ce --- /dev/null +++ b/config.yml @@ -0,0 +1,4 @@ +--- +host: "0.0.0.0" +port: "8080" +password_hash: "da1a51f975cd750be255d21548eaae1dbaa96ffc997283a6a204f9213a8aca71" diff --git a/config.yml.example b/config.yml.example new file mode 100644 index 0000000..1a0ddc6 --- /dev/null +++ b/config.yml.example @@ -0,0 +1,7 @@ +--- +host: "0.0.0.0" +port: "8080" + +# Example hash for password "changeme" (double SHA256) +# echo -n "changeme" | sha256sum | awk '{printf $1}' | sha256sum | awk '{print $1}' +password_hash: "96c3780287c58bd0867c8cd9b2d60c387ea070c4df3f87d2d3e3c770d3baab0b" diff --git a/internal/auth.go b/internal/auth.go new file mode 100644 index 0000000..6caa785 --- /dev/null +++ b/internal/auth.go @@ -0,0 +1,107 @@ +package internal + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "sync" + "time" +) + +type Session struct { + Expires time.Time +} + +type SessionManager struct { + sessions map[string]*Session + mutex sync.RWMutex +} + +func NewSessionManager() *SessionManager { + sm := &SessionManager{ + sessions: make(map[string]*Session), + } + // Start cleanup goroutine + go sm.cleanupExpiredSessions() + return sm +} + +// Using the same logic that powers the pi-hole authentication +// GeneratePasswordHash creates a double SHA256 hash from the password param to +// validate against config.PasswordHash +func GeneratePasswordHash(password string) string { + first := sha256.Sum256([]byte(password)) + firstHex := hex.EncodeToString(first[:]) + second := sha256.Sum256([]byte(firstHex)) + return hex.EncodeToString(second[:]) +} + +func ValidatePassword(password, hash string) bool { + return GeneratePasswordHash(password) == hash +} + +func (sm *SessionManager) CreateSession() (string, time.Time, error) { + sm.mutex.Lock() + defer sm.mutex.Unlock() + + sessionID, err := generateSecureToken() + if err != nil { + return "", time.Time{}, fmt.Errorf("failed to generate session ID: %w", err) + } + + expires := time.Now().Add(1 * time.Hour) + sm.sessions[sessionID] = &Session{ + Expires: expires, + } + + return sessionID, expires, nil +} + +func (sm *SessionManager) ValidateSession(sessionID string) (*Session, bool) { + sm.mutex.RLock() + defer sm.mutex.RUnlock() + + session, exists := sm.sessions[sessionID] + if !exists { + return nil, false + } + + if time.Now().After(session.Expires) { + return nil, false + } + + return session, true +} + +func (sm *SessionManager) DeleteSession(sessionID string) { + sm.mutex.Lock() + defer sm.mutex.Unlock() + delete(sm.sessions, sessionID) +} + +func generateSecureToken() (string, error) { + bytes := make([]byte, 32) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// cleanupExpiredSessions periodically removes expired sessions every 1 hour +func (sm *SessionManager) cleanupExpiredSessions() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + sm.mutex.Lock() + now := time.Now() + for sessionID, session := range sm.sessions { + if now.After(session.Expires) { + delete(sm.sessions, sessionID) + } + } + sm.mutex.Unlock() + } +} diff --git a/internal/config.go b/internal/config.go index 8d9d961..558630d 100644 --- a/internal/config.go +++ b/internal/config.go @@ -7,22 +7,17 @@ import ( "gopkg.in/yaml.v3" ) -type ServerConfig struct { - Host string `yaml:"host"` - Port string `yaml:"port"` - Title string `yaml:"title"` -} - type Config struct { - Server ServerConfig `yaml:"server"` + Host string `yaml:"host"` + Port string `yaml:"port"` + PasswordHash string `yaml:"password_hash"` } // Default configuration values func DefaultConfig() *Config { config := &Config{} - config.Server.Host = "0.0.0.0" - config.Server.Port = "8080" - config.Server.Title = "WireGuard Gateway Portal" + config.Host = "0.0.0.0" + config.Port = "8080" return config } @@ -52,5 +47,5 @@ func LoadConfig(configPath string) (*Config, error) { // GetAddress returns the server address in host:port format func (c *Config) GetAddress() string { - return fmt.Sprintf("%s:%s", c.Server.Host, c.Server.Port) + return fmt.Sprintf("%s:%s", c.Host, c.Port) } @@ -20,23 +20,25 @@ type APIResponse struct { // Server encapsulates our HTTP server type Server struct { - mux *http.ServeMux - templates *template.Template - config *internal.Config + mux *http.ServeMux + templates *template.Template + config *internal.Config + sessionManager *internal.SessionManager } // NewServer creates a new server instance func NewServer(config *internal.Config) (*Server, error) { // Parse templates - templates, err := template.ParseFiles("templates/index.html") + templates, err := template.ParseFiles("templates/index.html", "templates/login.html") if err != nil { return nil, fmt.Errorf("failed to parse templates: %w", err) } s := &Server{ - mux: http.NewServeMux(), - templates: templates, - config: config, + mux: http.NewServeMux(), + templates: templates, + config: config, + sessionManager: internal.NewSessionManager(), } s.setupRoutes() return s, nil @@ -44,16 +46,18 @@ func NewServer(config *internal.Config) (*Server, error) { // setupRoutes configures all HTTP routes func (s *Server) setupRoutes() { - // Serve static files + // Serve static files (no auth required) s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) - // Main page - s.mux.HandleFunc("/", s.handleHome) + // Auth routes (no auth required) + s.mux.HandleFunc("/login", s.handleLogin) + s.mux.HandleFunc("/logout", s.handleLogout) - // API endpoints - s.mux.HandleFunc("/api/connections", s.handleConnectionsAPI) - s.mux.HandleFunc("/api/connections/toggle", s.handleToggleAPI) - s.mux.HandleFunc("/api/status", s.handleStatusAPI) + // Protected routes + s.mux.HandleFunc("/", s.requireAuth(s.handleHome)) + s.mux.HandleFunc("/api/connections", s.requireAuth(s.handleConnectionsAPI)) + s.mux.HandleFunc("/api/connections/toggle", s.requireAuth(s.handleToggleAPI)) + s.mux.HandleFunc("/api/status", s.requireAuth(s.handleStatusAPI)) } // handleHome serves the main HTML page @@ -64,10 +68,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "text/html; charset=utf-8") - templateData := map[string]any{ - "Title": s.config.Server.Title, - } - if err := s.templates.ExecuteTemplate(w, "index.html", templateData); err != nil { + if err := s.templates.ExecuteTemplate(w, "index.html", nil); err != nil { log.Printf("Error rendering template: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } @@ -166,16 +167,115 @@ func (*Server) sendErrorResponse(w http.ResponseWriter, message string, statusCo }) } +// requireAuth middleware checks for valid authentication +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session_id") + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + _, valid := s.sessionManager.ValidateSession(cookie.Value) + if !valid { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + next(w, r) + } +} + +// handleLogin handles login form display and processing +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + // Show login form + w.Header().Set("Content-Type", "text/html; charset=utf-8") + templateData := map[string]any{ + "Error": "", + } + if err := s.templates.ExecuteTemplate(w, "login.html", templateData); err != nil { + log.Printf("Error rendering login template: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + + case http.MethodPost: + password := r.FormValue("password") + + // Validate credentials + if internal.ValidatePassword(password, s.config.PasswordHash) { + // Create session + sessionID, expires, err := s.sessionManager.CreateSession() + if err != nil { + log.Printf("Error creating session: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Set session cookie + cookie := &http.Cookie{ + Name: "session_id", + Value: sessionID, + Expires: expires, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + } + http.SetCookie(w, cookie) + + http.Redirect(w, r, "/", http.StatusSeeOther) + } else { + // Invalid credentials + w.Header().Set("Content-Type", "text/html; charset=utf-8") + templateData := map[string]any{ + "Error": "Wrong password", + } + if err := s.templates.ExecuteTemplate(w, "login.html", templateData); err != nil { + log.Printf("Error rendering login template: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + } + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleLogout handles user logout +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get session cookie and delete session + if cookie, err := r.Cookie("session_id"); err == nil { + s.sessionManager.DeleteSession(cookie.Value) + } + + // Clear session cookie + cookie := &http.Cookie{ + Name: "session_id", + Value: "", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + } + http.SetCookie(w, cookie) + + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + // Start starts the HTTP server func (s *Server) Start() error { addr := s.config.GetAddress() - log.Printf("%s starting on http://%s\n", s.config.Server.Title, addr) + log.Printf("Starting on http://%s\n", addr) return http.ListenAndServe(addr, s.mux) } func main() { // Load configuration - config, err := internal.LoadConfig("config.yaml") + config, err := internal.LoadConfig("config.yml") if err != nil { log.Fatalf("Failed to load config: %v", err) } diff --git a/static/css/styles.css b/static/css/styles.css index 9c26cab..af767a7 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -37,13 +37,27 @@ html { .connection.active { border-color: var(--light-green); } + + .login__form input[type=submit] { + color: var(--light-green); + } +} + +/* Handle logout on small devices */ +@media (max-width: 600px) { + .header__logout { + position: inherit !important; + } + .header__logout input[type=submit] { + width: 100% !important; + } } body { font-family: "PT Sans", "Kohinoor Bangla", sans-serif; max-width: 1024px; min-width: 375px; - margin: 0 auto; + margin: 0 2rem; float: none; font-size: 18px; } @@ -59,8 +73,19 @@ header { } } -main { - margin: 32px; +.header__logout { + position: absolute; + top: 1rem; + right: 1rem; +} + +.header__logout input[type=submit] { + border: 1px solid; + padding: 1rem; + color: var(--dark-red); + background-color: inherit; + font-family: monospace; + margin-top: 1rem; } .status { @@ -115,6 +140,39 @@ main { border-color: var(--dark-green); } +.login { + font-family: monospace; + display: flex; + width: 100%; + justify-content: center; +} + +.login__container { + min-width: 280px; +} + +.login__form { + display: grid; + font-size: 14px; + margin-top: 2rem; +} + +.login__form input[type=password] { + border: 1px solid; + padding: 1rem; + font-family: monospace; + background-color: inherit; +} + +.login__form input[type=submit] { + border: 1px solid; + padding: 1rem; + color: var(--dark-green); + background-color: inherit; + font-family: monospace; + margin-top: 1rem; +} + footer { margin: 32px; } diff --git a/static/js/app.js b/static/js/app.js index db8ed8a..dd2aa75 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -112,6 +112,8 @@ const ConnectionManager = { // Status management const StatusManager = { + intervalId: null, + // Load and display WireGuard status async loadStatus() { try { @@ -126,10 +128,35 @@ const StatusManager = { Utils.renderError(App.elements.statusArea, error.message); } }, + + startAutoRefresh() { + // Clear any existing interval first + this.stopAutoRefresh(); + // Start new interval + this.intervalId = setInterval(() => this.loadStatus(), 5000); + }, + + stopAutoRefresh() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } }; // Initialize the application document.addEventListener('DOMContentLoaded', () => { StatusManager.loadStatus(); ConnectionManager.loadConnections(); + StatusManager.startAutoRefresh(); +}); + +// Pause when tab is hidden, resume when visible +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + StatusManager.stopAutoRefresh(); + } else { + StatusManager.loadStatus(); // Immediate refresh when back + StatusManager.startAutoRefresh(); + } }); diff --git a/templates/index.html b/templates/index.html index 2e6301b..724aa6e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5,13 +5,18 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="color-scheme" content="dark light"> - <title>{{.Title}}</title> + <title>WireGuard Gateway Portal</title> <link rel="stylesheet" href="/static/css/styles.css"> </head> <body> <header> - <h1 class="header__title">{{.Title}}</h1> + <h1 class="header__title">WireGuard Gateway Portal</h1> <p class="header__subtitle">Manage WireGuard VPN connections</p> + <div class="header__logout"> + <form method="POST" action="/logout"> + <input type="submit" value="Logout"> + </form> + </div> </header> <main> <article class="status"> diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..c26f863 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="color-scheme" content="dark light"> + + <title>WireGuard Gateway Portal</title> + <link rel="stylesheet" href="/static/css/styles.css"> +</head> +<body> + <header> + <h1 class="header__title">WireGuard Gateway Portal</h1> + <p class="header__subtitle">Manage WireGuard VPN connections</p> + </header> + <main> + <div class="login"> + <div class="login__container"> + {{if .Error}} + <div id="notifications__container" class="notifications__container"> + <div class="message error">{{.Error}}</div> + </div> + {{end}} + + <form class="login__form" method="POST" action="/login"> + <input type="password" name="password" placeholder="Password" required> + <input type="submit" value="Login"> + </form> + </div> + </div> + </main> + <footer></footer> +</body> +</html> |
