summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAhmed AbdelHalim <[email protected]>2025-09-06 23:55:19 +0200
committerAhmed AbdelHalim <[email protected]>2025-09-07 02:20:46 +0200
commit17168a489392847794de0aed15fe395230a38261 (patch)
tree60914d9df4b9b37852e54dcf72818b0df1a958fb
Initial commit
This cleaned up and added linting/readme to the already created web app that I have been using to control vpn on my raspberry setup
-rw-r--r--.editorconfig11
-rw-r--r--.golangci.yaml99
-rw-r--r--.stylelintrc.json15
-rw-r--r--LICENSE.md21
-rw-r--r--Makefile29
-rw-r--r--README.md34
-rw-r--r--config.yaml5
-rw-r--r--eslint.config.js27
-rw-r--r--go.mod10
-rw-r--r--go.sum8
-rw-r--r--internal/config.go56
-rw-r--r--internal/wireguard.go172
-rw-r--r--main.go191
-rw-r--r--static/css/styles.css120
-rw-r--r--static/js/app.js135
-rw-r--r--templates/index.html39
16 files changed, 972 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ea82bb0
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,11 @@
+# See documentation at: http://editorconfig.org
+
+root = true
+
+[*]
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+indent_style = space
+indent_size = 2
diff --git a/.golangci.yaml b/.golangci.yaml
new file mode 100644
index 0000000..68de2c5
--- /dev/null
+++ b/.golangci.yaml
@@ -0,0 +1,99 @@
+---
+version: "2"
+linters:
+ default: none
+ enable:
+ - asciicheck
+ - errcheck
+ - govet
+ - ineffassign
+ - misspell
+ - nolintlint
+ - revive
+ - staticcheck
+ - unused
+ settings:
+ staticcheck:
+ checks:
+ - all
+ - '-ST1000' # disable the rule SA1000 pkg comment
+ - '-ST1003' # disable the rule SA1003 ALL_CAPS VARS
+ - '-ST1005' # disable the rule SA1005 error strings should not be capitalized
+ - '-ST1020' # Exported methods comment style
+ - '-ST1021' # Exported methods comment style
+ - '-ST1022' # Exported methods comment style
+ revive:
+ confidence: 0.8
+ severity: warning
+ enable-all-rules: true
+ rules:
+ - name: cyclomatic
+ arguments:
+ - 7
+ - name: cognitive-complexity
+ arguments:
+ - 9
+ - name: argument-limit
+ arguments:
+ - 5
+ - name: line-length-limit
+ arguments:
+ - 120
+ - name: function-length
+ arguments:
+ - 20
+ - 0
+ - name: function-result-limit
+ arguments:
+ - 3
+ - name: var-naming
+ arguments:
+ - []
+ - []
+ - - upperCaseConst: true
+ disabled: true
+ - name: add-constant
+ disabled: true
+ - name: max-public-structs
+ disabled: true
+ - name: banned-characters
+ disabled: true
+ - name: package-comments
+ disabled: true
+ - name: file-header
+ disabled: true
+ - name: exported
+ disabled: true
+ exclusions:
+ generated: lax
+ rules:
+ - linters:
+ - line-length-limit
+ - revive
+ path: _test\.go
+ - linters:
+ - revive
+ text: 'unused-parameter: parameter \S+ seems to be unused, consider removing or renaming it as _'
+ - linters:
+ - revive
+ text: 'empty-block: this block is empty, you can remove it'
+ - linters:
+ - errcheck
+ path: _test\.go
+ text: Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Unset|Set)?env)|afero.WriteFile. is not checked
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
+issues:
+ max-issues-per-linter: 0
+ max-same-issues: 0
+formatters:
+ enable:
+ - gofumpt
+ exclusions:
+ generated: lax
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
diff --git a/.stylelintrc.json b/.stylelintrc.json
new file mode 100644
index 0000000..f571002
--- /dev/null
+++ b/.stylelintrc.json
@@ -0,0 +1,15 @@
+{
+ "rules": {
+ "block-no-empty": true,
+ "color-no-invalid-hex": true,
+ "function-url-quotes": "always",
+ "no-duplicate-selectors": true,
+ "no-empty-source": true,
+ "no-invalid-double-slash-comments": true,
+ "property-no-unknown": true,
+ "selector-pseudo-class-no-unknown": true,
+ "selector-pseudo-element-no-unknown": true,
+ "string-no-newline": true,
+ "unit-no-unknown": true
+ }
+} \ No newline at end of file
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..a57826e
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,21 @@
+# The MIT License (MIT)
+
+Copyright (c) 2025- [a14m](https://sr.ht/~a14m)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..9c06e49
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,29 @@
+.PHONY: lint lint-html lint-css lint-go lint-js
+
+help:
+ @echo "Available lint commands:"
+ @echo " make lint - Run all linters"
+ @echo " make lint-html - Lint HTML files (html-validate)"
+ @echo " make lint-css - Lint CSS files (stylelint)"
+ @echo " make lint-go - Lint Go files (go vet + golangci-lint)"
+ @echo " make lint-js - Lint JavaScript files (eslint)"
+
+lint-html:
+ @echo "Linting HTML files..."
+ npx html-validate templates/*.html
+
+lint-css:
+ @echo "Linting CSS files..."
+ npx stylelint static/css/*.css
+
+lint-go:
+ @echo "Linting Go files..."
+ go vet ./...
+ golangci-lint run
+
+lint-js:
+ @echo "Linting JavaScript files..."
+ npx eslint static/js/*.js
+
+lint: lint-go lint-js lint-html lint-css
+ @echo "All linting complete!"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0770e1f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,34 @@
+# WireGuard Gateway Portal
+
+A lightweight web interface for managing WireGuard VPN connections.
+
+Ideal for managing the VPN connection running on a RaspberryPi Network Gateway.
+
+## Quick Start
+
+1. **Configure WireGuard connections** in `/etc/wireguard/*.conf`
+1. **Update configuration** in `config.yaml` (optional)
+1. **Run the application**: `go run main.go`
+1. **Open your browser** to `http://localhost:8080`
+
+## Development
+
+- **Go 1.25+** - For running the application
+- **Node.js/npx** - (optional) For frontend linting (HTML/CSS/JS)
+- **golangci-lint** - (optional) Go linting
+
+### Linting
+
+```bash
+# Individual linters
+make lint-js # JavaScript (ESLint)
+make lint-css # CSS (Stylelint)
+make lint-html # HTML (html-validate)
+make lint-go # Go (go vet + golangci-lint)
+
+# All linters
+make lint
+
+# Help
+make help
+```
diff --git a/config.yaml b/config.yaml
new file mode 100644
index 0000000..bfd6e94
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,5 @@
+---
+server:
+ host: "0.0.0.0"
+ port: "8080"
+ title: "WireGuard Gateway Portal"
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..3bfc90c
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,27 @@
+// Minimal ESLint config - zero dependencies
+export default [
+ {
+ files: ["**/*.js"],
+ languageOptions: {
+ ecmaVersion: 2022,
+ sourceType: "module",
+ globals: {
+ window: "readonly",
+ document: "readonly",
+ console: "readonly",
+ fetch: "readonly",
+ App: "readonly",
+ Utils: "readonly",
+ ConnectionManager: "readonly",
+ StatusManager: "readonly"
+ }
+ },
+ rules: {
+ "no-unused-vars": "warn",
+ "no-undef": "error",
+ "no-console": "off",
+ "no-redeclare": "error",
+ "no-unreachable": "error"
+ }
+ }
+];
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..e84e3df
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,10 @@
+module wg-portal
+
+go 1.25
+
+require (
+ github.com/samber/lo v1.51.0
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require golang.org/x/text v0.22.0 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..ded4772
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,8 @@
+github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
+github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/config.go b/internal/config.go
new file mode 100644
index 0000000..8d9d961
--- /dev/null
+++ b/internal/config.go
@@ -0,0 +1,56 @@
+package internal
+
+import (
+ "fmt"
+ "os"
+
+ "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"`
+}
+
+// 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"
+ return config
+}
+
+// LoadConfig loads configuration from file, falls back to defaults if file doesn't exist
+func LoadConfig(configPath string) (*Config, error) {
+ config := DefaultConfig()
+
+ // Check if config file exists
+ if _, err := os.Stat(configPath); os.IsNotExist(err) {
+ // Config file doesn't exist, use defaults
+ return config, nil
+ }
+
+ // Read config file
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read config file: %w", err)
+ }
+
+ // Parse YAML
+ if err := yaml.Unmarshal(data, config); err != nil {
+ return nil, fmt.Errorf("failed to parse config file: %w", err)
+ }
+
+ return config, nil
+}
+
+// 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)
+}
diff --git a/internal/wireguard.go b/internal/wireguard.go
new file mode 100644
index 0000000..ebfa0bb
--- /dev/null
+++ b/internal/wireguard.go
@@ -0,0 +1,172 @@
+package internal
+
+import (
+ "fmt"
+ "log"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strings"
+
+ "github.com/samber/lo"
+)
+
+var interfaceRegex = regexp.MustCompile(`^interface:\s+(.+)$`)
+
+type WireGuardConnection struct {
+ Name string `json:"name"`
+ Active bool `json:"active"`
+}
+
+func GetStatus() (string, error) {
+ output, err := showStatus()
+ if err != nil {
+ return "", err
+ }
+ status := lo.FilterMap(strings.Split(string(output), "\n"), func(line string, _ int) (string, bool) {
+ line = strings.TrimSpace(line)
+ if strings.Contains(line, "interface") {
+ return fmt.Sprintf("Connection: %s", strings.TrimPrefix(line, "interface:")), true
+ }
+ if strings.Contains(line, "latest handshake") {
+ return fmt.Sprintf("Latest Handshake: %s", strings.TrimPrefix(line, "latest handshake:")), true
+ }
+ if strings.Contains(line, "transfer") {
+ return fmt.Sprintf("Transfer: %s", strings.TrimPrefix(line, "transfer:")), true
+ }
+ return "", false
+ })
+ // This is a simple check on whether a connection started or not.
+ // Instead of complex logic on looping on connections and figuring which connection might be missing info.
+ // NOTE: This doesn't handle if 3x connections were started and none of them is still active.
+ // NOTE: ToggleConnection stops all active connections and activate one.
+ // to avoid issues with multiple VPNs configuring the same iptable that could happen with default wireguard configs
+ if len(status)%3 != 0 {
+ status = append(status, "Connection starting...")
+ }
+ return strings.Join(status, "\n"), nil
+}
+
+func GetConnections() ([]*WireGuardConnection, error) {
+ activeConnection, err := getActiveConnections()
+ if err != nil {
+ return nil, err
+ }
+ allConnections, err := getAllConnections()
+ if err != nil {
+ return nil, err
+ }
+
+ connections := make([]*WireGuardConnection, 0, len(allConnections))
+ for _, i := range allConnections {
+ connections = append(connections, &WireGuardConnection{
+ Name: i,
+ Active: slices.Contains(activeConnection, i),
+ })
+ }
+ return connections, nil
+}
+
+func ToggleConnection(name string) ([]byte, error) {
+ allConnections, err := GetConnections()
+ if err != nil {
+ return nil, err
+ }
+ activeConnections := lo.Filter(allConnections, func(i *WireGuardConnection, _ int) bool {
+ return i.Active
+ })
+ connection, err := getConnection(name)
+ if err != nil {
+ return nil, err
+ }
+ output, err := stopActiveConnections(activeConnections)
+ if err != nil {
+ return nil, err
+ }
+ startOutput, err := startConnection(connection)
+ if err != nil {
+ return nil, err
+ }
+ output = append(output, startOutput...)
+ return output, nil
+}
+
+func stopActiveConnections(activeConnections []*WireGuardConnection) ([]byte, error) {
+ var output []byte
+ for _, activeConnection := range activeConnections {
+ log.Printf("Stopping connection %s.", activeConnection.Name)
+ cmd := exec.Command("sudo", "wg-quick", "down", activeConnection.Name)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return nil, err
+ }
+ output = append(output, out...)
+ log.Printf("Successfully stopped connection %s.", activeConnection.Name)
+ }
+ return output, nil
+}
+
+func startConnection(connection *WireGuardConnection) ([]byte, error) {
+ if connection.Active {
+ return nil, nil
+ }
+ log.Printf("Starting connection %s.", connection.Name)
+ cmd := exec.Command("sudo", "wg-quick", "up", connection.Name)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return nil, err
+ }
+ log.Printf("Successfully started connection %s.", connection.Name)
+ return output, nil
+}
+
+// Get the list of all wireguard connections using config files
+func getAllConnections() ([]string, error) {
+ files, err := filepath.Glob("/etc/wireguard/*.conf")
+ if err != nil {
+ return nil, err
+ }
+ files = lo.Map(files, func(f string, _ int) string {
+ return strings.TrimSuffix(filepath.Base(f), filepath.Ext(f))
+ })
+ return files, nil
+}
+
+// Get the list of active wireguard connections using wg show command
+func getActiveConnections() ([]string, error) {
+ var activeConnections []string
+ status, err := showStatus()
+ if err != nil {
+ return nil, err
+ }
+ for line := range strings.SplitSeq(string(status), "\n") {
+ if matches := interfaceRegex.FindStringSubmatch(strings.TrimSpace(line)); len(matches) > 1 {
+ activeConnections = append(activeConnections, matches[1])
+ }
+ }
+ return activeConnections, nil
+}
+
+func getConnection(name string) (*WireGuardConnection, error) {
+ allConnections, err := GetConnections()
+ if err != nil {
+ return nil, err
+ }
+ connection, ok := lo.Find(allConnections, func(dev *WireGuardConnection) bool {
+ return dev.Name == name
+ })
+ if !ok {
+ return nil, fmt.Errorf("failed to find connection: %s", name)
+ }
+ return connection, nil
+}
+
+func showStatus() ([]byte, error) {
+ cmd := exec.Command("sudo", "wg", "show")
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("failed to execute wg show: %w", err)
+ }
+ return output, nil
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..764c8be
--- /dev/null
+++ b/main.go
@@ -0,0 +1,191 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "strings"
+
+ "wg-portal/internal"
+)
+
+// APIResponse represents a standard API response structure
+type APIResponse struct {
+ Success bool `json:"success"`
+ Data any `json:"data,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// Server encapsulates our HTTP server
+type Server struct {
+ mux *http.ServeMux
+ templates *template.Template
+ config *internal.Config
+}
+
+// NewServer creates a new server instance
+func NewServer(config *internal.Config) (*Server, error) {
+ // Parse templates
+ templates, err := template.ParseFiles("templates/index.html")
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse templates: %w", err)
+ }
+
+ s := &Server{
+ mux: http.NewServeMux(),
+ templates: templates,
+ config: config,
+ }
+ s.setupRoutes()
+ return s, nil
+}
+
+// setupRoutes configures all HTTP routes
+func (s *Server) setupRoutes() {
+ // Serve static files
+ s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
+
+ // Main page
+ s.mux.HandleFunc("/", s.handleHome)
+
+ // API endpoints
+ s.mux.HandleFunc("/api/connections", s.handleConnectionsAPI)
+ s.mux.HandleFunc("/api/connections/toggle", s.handleToggleAPI)
+ s.mux.HandleFunc("/api/status", s.handleStatusAPI)
+}
+
+// handleHome serves the main HTML page
+func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+
+ 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 {
+ log.Printf("Error rendering template: %v", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ }
+}
+
+// handleConnectionsAPI returns connection data as JSON
+func (s *Server) handleConnectionsAPI(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ s.sendErrorResponse(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ connections, err := internal.GetConnections()
+ if err != nil {
+ log.Printf("Error getting connections: %v", err)
+ s.sendErrorResponse(w, fmt.Sprintf("%v", err), http.StatusInternalServerError)
+ return
+ }
+
+ s.sendSuccessResponse(w, connections)
+}
+
+// handleToggleAPI handles connection toggle requests
+func (s *Server) handleToggleAPI(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ s.sendErrorResponse(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req struct {
+ Name string `json:"name"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ s.sendErrorResponse(w, "Invalid JSON", http.StatusBadRequest)
+ return
+ }
+
+ if strings.TrimSpace(req.Name) == "" {
+ s.sendErrorResponse(w, "Connection name is required", http.StatusBadRequest)
+ return
+ }
+
+ output, err := internal.ToggleConnection(req.Name)
+ if err != nil {
+ log.Printf("Error toggling connection %s: %v (output: %s)", req.Name, err, string(output))
+ s.sendErrorResponse(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ response := map[string]any{
+ "message": fmt.Sprintf("Connection %s toggled successfully", req.Name),
+ "output": string(output),
+ }
+
+ s.sendSuccessResponse(w, response)
+}
+
+// handleStatusAPI returns WireGuard status information
+func (s *Server) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ s.sendErrorResponse(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ status, err := internal.GetStatus()
+ if err != nil {
+ log.Printf("Error getting status: %v", err)
+ s.sendErrorResponse(w, fmt.Sprintf("%v", err), http.StatusInternalServerError)
+ return
+ }
+
+ response := map[string]any{
+ "status": status,
+ }
+
+ s.sendSuccessResponse(w, response)
+}
+
+// sendSuccessResponse sends a JSON success response
+func (*Server) sendSuccessResponse(w http.ResponseWriter, data any) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(APIResponse{
+ Success: true,
+ Data: data,
+ })
+}
+
+// sendErrorResponse sends a JSON error response
+func (*Server) sendErrorResponse(w http.ResponseWriter, message string, statusCode int) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(statusCode)
+ _ = json.NewEncoder(w).Encode(APIResponse{
+ Success: false,
+ Error: message,
+ })
+}
+
+// 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)
+ return http.ListenAndServe(addr, s.mux)
+}
+
+func main() {
+ // Load configuration
+ config, err := internal.LoadConfig("config.yaml")
+ if err != nil {
+ log.Fatalf("Failed to load config: %v", err)
+ }
+
+ server, err := NewServer(config)
+ if err != nil {
+ log.Fatalf("Failed to create server: %v", err)
+ }
+
+ if err := server.Start(); err != nil {
+ log.Fatalf("Server failed to start: %v", err)
+ }
+}
diff --git a/static/css/styles.css b/static/css/styles.css
new file mode 100644
index 0000000..9c26cab
--- /dev/null
+++ b/static/css/styles.css
@@ -0,0 +1,120 @@
+:root {
+ --light-white: #FFFFFF;
+ --dark-white: #EFFFFF;
+ --light-black: #181818;
+ --dark-black: #000000;
+
+ --light-green: #27AE60;
+ --dark-green: #27AE60;
+ --light-yellow: #F1C40F;
+ --dark-yellow: #F1C40F;
+ --light-red: #E74C3C;
+ --dark-red: #E74C3C;
+}
+
+html {
+ background-color: var(--light-black);
+}
+
+@media (prefers-color-scheme: light) {
+ html {
+ background-color: var(--light-white);
+ }
+ .message.loading,
+ .message.warning {
+ border-color: var(--light-yellow);
+ }
+ .message.success {
+ border-color: var(--light-green);
+ }
+ .message.error {
+ border-color: var(--light-red);
+ }
+ .connection.loading {
+ border-color: var(--light-yellow);
+ }
+
+ .connection.active {
+ border-color: var(--light-green);
+ }
+}
+
+body {
+ font-family: "PT Sans", "Kohinoor Bangla", sans-serif;
+ max-width: 1024px;
+ min-width: 375px;
+ margin: 0 auto;
+ float: none;
+ font-size: 18px;
+}
+
+header {
+ text-align: center;
+ font-style: normal;
+ .header__title {
+ font-size: 32px;
+ margin-bottom: -16px;
+ margin-top: 36px;
+ clear: right;
+ }
+}
+
+main {
+ margin: 32px;
+}
+
+.status {
+ min-height: 160px;
+}
+
+.status__header,
+.connections__header {
+}
+
+.message {
+ font-family: monospace;
+ font-size: 14px;
+ line-height: 1.5;
+ border-style: solid;
+ border-width: 1px;
+ border-left-width: 14px;
+ padding: 14px;
+ white-space: pre-line;
+}
+
+.message.loading,
+.message.warning {
+ border-color: var(--dark-yellow);
+}
+.message.success {
+ border-color: var(--dark-green);
+}
+.message.error {
+ border-color: var(--dark-red);
+}
+
+.connections__container {
+ width: 100%;
+}
+.connection {
+ font-family: monospace;
+ font-size: 14px;
+ cursor: pointer;
+ border: 1px solid;
+ padding: 1rem;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+.connection.loading {
+ border-left-width: 14px;
+ border-color: var(--dark-yellow);
+}
+
+.connection.active {
+ border-left-width: 14px;
+ border-color: var(--dark-green);
+}
+
+footer {
+ margin: 32px;
+}
diff --git a/static/js/app.js b/static/js/app.js
new file mode 100644
index 0000000..db8ed8a
--- /dev/null
+++ b/static/js/app.js
@@ -0,0 +1,135 @@
+// Application state and configuration
+const App = {
+ apiBase: "/api",
+ elements: {
+ connectionList: document.getElementById('connections__container'),
+ statusArea: document.getElementById('status__container'),
+ messageArea: document.getElementById('notifications__container')
+ }
+};
+
+// Utility functions
+const Utils = {
+ // Show message to user
+ renderError(element, message) {
+ Utils.renderMessage(element, message, "error");
+ },
+ renderWarning(element, message) {
+ Utils.renderMessage(element, message, "warning");
+ },
+ renderSuccess(element, message) {
+ Utils.renderMessage(element, message, "success");
+ },
+ renderMessage(element, message, type = '') {
+ element.innerHTML = `
+ <div class="message ${type}">${message}</div>
+ `;
+ },
+ // Make API calls with proper error handling
+ async apiCall(endpoint, options = {}) {
+ try {
+ const response = await fetch(`${App.apiBase}${endpoint}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ },
+ ...options
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || `HTTP ${response.status}`);
+ }
+
+ if (!data.success) {
+ throw new Error(data.error || 'API request failed');
+ }
+
+ return data.data;
+ } catch (error) {
+ console.error('API call failed:', error);
+ throw error;
+ }
+ }
+};
+
+// Connection management
+const ConnectionManager = {
+ // Load and display all connections
+ async loadConnections() {
+ try {
+ const connections = await Utils.apiCall('/connections');
+ this.renderConnections(connections);
+ } catch (error) {
+ Utils.renderError(App.elements.connectionList,
+ `Failed to load connections: ${error.message}`);
+ }
+ },
+
+ // Render connections to the DOM
+ renderConnections(connections) {
+ if (!connections || connections.length === 0) {
+ Utils.renderWarning(App.elements.connectionList,
+ "No WireGuard connections found in /etc/wireguard/");
+ return;
+ }
+
+ const html = connections.map(conn => `
+ <div class="connection ${conn.active ? 'active' : ''}"
+ data-connection="${conn.name}"
+ onclick="ConnectionManager.toggleConnection('${conn.name}')">
+ <div class="connection__name ${conn.active ? 'active' : ''}">${conn.name}</div>
+ </div>
+ `).join('');
+ App.elements.connectionList.innerHTML = html;
+ },
+
+ // Toggle connection state
+ async toggleConnection(name) {
+ const connection = document.querySelector(`[data-connection="${name}"]`);
+
+ try {
+ connection.disabled = true;
+ connection.textContent = 'Processing...';
+ connection.className = "connection loading"
+
+ await Utils.apiCall('/connections/toggle', {
+ method: 'POST',
+ body: JSON.stringify({ name })
+ });
+ await this.loadConnections(); // Refresh the list
+ await StatusManager.loadStatus(); // Refresh the status
+ Utils.renderSuccess(App.elements.messageArea, `No Errors Found.`);
+ } catch (error) {
+ Utils.renderError(App.elements.messageArea, `Failed to toggle ${name}: ${error.message}`);
+ connection.disabled = false;
+ connection.textContent = name;
+ connection.className = "connection"
+ }
+ }
+};
+
+// Status management
+const StatusManager = {
+ // Load and display WireGuard status
+ async loadStatus() {
+ try {
+ const statusData = await Utils.apiCall('/status');
+ const statusText = statusData.status;
+ if (statusText) {
+ Utils.renderSuccess(App.elements.statusArea, statusText);
+ } else {
+ Utils.renderWarning(App.elements.statusArea, "No active connections.");
+ }
+ } catch (error) {
+ Utils.renderError(App.elements.statusArea, error.message);
+ }
+ },
+};
+
+// Initialize the application
+document.addEventListener('DOMContentLoaded', () => {
+ StatusManager.loadStatus();
+ ConnectionManager.loadConnections();
+});
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..2e6301b
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,39 @@
+<!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>{{.Title}}</title>
+ <link rel="stylesheet" href="/static/css/styles.css">
+</head>
+<body>
+ <header>
+ <h1 class="header__title">{{.Title}}</h1>
+ <p class="header__subtitle">Manage WireGuard VPN connections</p>
+ </header>
+ <main>
+ <article class="status">
+ <h2 class="status__header">WireGuard Status</h2>
+ <div id="status__container" class="status__container">
+ <div class="message loading">Loading Status...</div>
+ </div>
+ </article>
+ <article class="connections">
+ <h2 class="connections__header">WireGuard Connections</h2>
+ <div id="connections__container" class="connections__container">
+ <div class="message loading">Loading Connections...</div>
+ </div>
+ </article>
+ <article id="notifications">
+ <h3 class="notifications__header">Notifications</h3>
+ <div id="notifications__container" class="notifications__container">
+ <div class="message success">No Errors Found.</div>
+ </div>
+ </article>
+ </main>
+ <footer></footer>
+ <script src="/static/js/app.js"></script>
+</body>
+</html>