summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--config.yaml5
-rw-r--r--config.yml4
-rw-r--r--config.yml.example7
-rw-r--r--internal/auth.go107
-rw-r--r--internal/config.go17
-rw-r--r--main.go140
-rw-r--r--static/css/styles.css64
-rw-r--r--static/js/app.js27
-rw-r--r--templates/index.html9
-rw-r--r--templates/login.html34
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)
}
diff --git a/main.go b/main.go
index 764c8be..2f35a34 100644
--- a/main.go
+++ b/main.go
@@ -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>