diff options
| author | Ahmed AbdelHalim <[email protected]> | 2025-09-06 23:55:19 +0200 |
|---|---|---|
| committer | Ahmed AbdelHalim <[email protected]> | 2025-09-07 02:20:46 +0200 |
| commit | 17168a489392847794de0aed15fe395230a38261 (patch) | |
| tree | 60914d9df4b9b37852e54dcf72818b0df1a958fb | |
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-- | .editorconfig | 11 | ||||
| -rw-r--r-- | .golangci.yaml | 99 | ||||
| -rw-r--r-- | .stylelintrc.json | 15 | ||||
| -rw-r--r-- | LICENSE.md | 21 | ||||
| -rw-r--r-- | Makefile | 29 | ||||
| -rw-r--r-- | README.md | 34 | ||||
| -rw-r--r-- | config.yaml | 5 | ||||
| -rw-r--r-- | eslint.config.js | 27 | ||||
| -rw-r--r-- | go.mod | 10 | ||||
| -rw-r--r-- | go.sum | 8 | ||||
| -rw-r--r-- | internal/config.go | 56 | ||||
| -rw-r--r-- | internal/wireguard.go | 172 | ||||
| -rw-r--r-- | main.go | 191 | ||||
| -rw-r--r-- | static/css/styles.css | 120 | ||||
| -rw-r--r-- | static/js/app.js | 135 | ||||
| -rw-r--r-- | templates/index.html | 39 |
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" + } + } +]; @@ -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 @@ -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 +} @@ -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> |
