From 17168a489392847794de0aed15fe395230a38261 Mon Sep 17 00:00:00 2001 From: Ahmed AbdelHalim Date: Sat, 6 Sep 2025 23:55:19 +0200 Subject: 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 --- .editorconfig | 11 +++ .golangci.yaml | 99 ++++++++++++++++++++++++++ .stylelintrc.json | 15 ++++ LICENSE.md | 21 ++++++ Makefile | 29 ++++++++ README.md | 34 +++++++++ config.yaml | 5 ++ eslint.config.js | 27 +++++++ go.mod | 10 +++ go.sum | 8 +++ internal/config.go | 56 +++++++++++++++ internal/wireguard.go | 172 +++++++++++++++++++++++++++++++++++++++++++++ main.go | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++ static/css/styles.css | 120 +++++++++++++++++++++++++++++++ static/js/app.js | 135 +++++++++++++++++++++++++++++++++++ templates/index.html | 39 +++++++++++ 16 files changed, 972 insertions(+) create mode 100644 .editorconfig create mode 100644 .golangci.yaml create mode 100644 .stylelintrc.json create mode 100644 LICENSE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 config.yaml create mode 100644 eslint.config.js create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config.go create mode 100644 internal/wireguard.go create mode 100644 main.go create mode 100644 static/css/styles.css create mode 100644 static/js/app.js create mode 100644 templates/index.html 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 = ` +
${message}
+ `; + }, + // 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 => ` +
+
${conn.name}
+
+ `).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 @@ + + + + + + + + {{.Title}} + + + +
+

{{.Title}}

+

Manage WireGuard VPN connections

+
+
+
+

WireGuard Status

+
+
Loading Status...
+
+
+
+

WireGuard Connections

+
+
Loading Connections...
+
+
+
+

Notifications

+
+
No Errors Found.
+
+
+
+ + + + -- cgit v1.2.3