commit d1e2908fdcc964b5abb4fe3ab4dca9ac61e6cadf Author: cclohmar Date: Fri Jul 24 09:17:04 2026 +0000 chore: initial commit of dhcp-lease-manager plugin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83bf4ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Binary +dhcp-lease-manager + +# Go +*.exe +*.test +*.out +go.sum + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Temp +tmp/ +.tmp/ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f6fbe24 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module dhcp-lease-manager + +go 1.21 diff --git a/main.go b/main.go new file mode 100644 index 0000000..445399f --- /dev/null +++ b/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "embed" + "fmt" + "log" + "net/http" + + "dhcp-lease-manager/zoraxy_plugin" +) + +//go:embed web/* +var webFS embed.FS + +func main() { + spec := &zoraxy_plugin.IntroSpect{ + ID: "dhcp-lease-manager", + Name: "DHCP Lease Manager", + Author: "Zoraxy Community", + AuthorContact: "", + Description: "Manage dnsmasq DHCP leases — view active leases, pin/unpin permanent leases, and reload dnsmasq from the Zoraxy UI.", + URL: "", + Type: zoraxy_plugin.PluginType_Utilities, + VersionMajor: 1, + VersionMinor: 0, + VersionPatch: 0, + UIPath: "/ui", + } + + config, err := zoraxy_plugin.ServeAndRecvSpec(spec) + if err != nil { + log.Fatalf("failed to receive config: %v", err) + } + + mux := http.NewServeMux() + + // Set up the embedded web UI router. + uiRouter := zoraxy_plugin.NewPluginEmbedUIRouter( + spec.ID, + &webFS, + "web", + spec.UIPath, + ) + + // Register API endpoints under the UI path. + uiRouter.HandleFunc("/api/leases", handleListLeases, mux) + uiRouter.HandleFunc("/api/pin", handlePinLease, mux) + uiRouter.HandleFunc("/api/unpin", handleUnpinLease, mux) + uiRouter.HandleFunc("/api/reload", handleReload, mux) + + // Handle graceful shutdown. + uiRouter.RegisterTerminateHandler(func() { + log.Println("dhcp-lease-manager shutting down") + }, mux) + + uiRouter.AttachHandlerToMux(mux) + + addr := fmt.Sprintf("127.0.0.1:%d", config.Port) + log.Printf("dhcp-lease-manager v%d.%d.%d listening on %s", + spec.VersionMajor, spec.VersionMinor, spec.VersionPatch, addr) + + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..f320194 --- /dev/null +++ b/models.go @@ -0,0 +1,55 @@ +package main + +import "time" + +// Lease represents a single line from dnsmasq's lease file. +// Format: +type Lease struct { + Expiry time.Time + MAC string + IP string + Hostname string + ClientID string +} + +// PinRequest is the JSON body for pin/unpin API calls. +type PinRequest struct { + MAC string `json:"mac"` + IP string `json:"ip"` + Hostname string `json:"hostname"` +} + +// PinStatus indicates whether a lease is permanent (pinned). +type PinStatus string + +const ( + PinStatusActive PinStatus = "active" + PinStatusPermanent PinStatus = "permanent" +) + +// LeaseEntry is the combined view sent to the frontend. +type LeaseEntry struct { + Hostname string `json:"hostname"` + IP string `json:"ip"` + MAC string `json:"mac"` + Expiry time.Time `json:"expiry"` + Status PinStatus `json:"status"` +} + +// LeasesResponse is the JSON response for GET /ui/api/leases. +type LeasesResponse struct { + Leases []LeaseEntry `json:"leases"` + PinnedCount int `json:"pinned_count"` + ActiveCount int `json:"active_count"` +} + +// PinResponse is the JSON response after a pin/unpin action. +type PinResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// ErrorResponse is a generic error JSON response. +type ErrorResponse struct { + Error string `json:"error"` +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..9bf275f --- /dev/null +++ b/server.go @@ -0,0 +1,308 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// --------------------------------------------------------------------------- +// Configuration (overridable for testing / custom installs) +// --------------------------------------------------------------------------- + +var ( + // Default paths — can be changed via environment variables. + leasesFile = envOrDefault("LEASE_FILE", "/var/lib/misc/dnsmasq.leases") + confFile = envOrDefault("CONF_FILE", "/etc/dnsmasq.conf") + reloadCmd = envOrDefault("RELOAD_CMD", "sudo") + reloadArgs = strings.Fields(envOrDefault("RELOAD_ARGS", "systemctl reload dnsmasq")) +) + +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// --------------------------------------------------------------------------- +// dnsmasq interaction +// --------------------------------------------------------------------------- + +// parseLeases reads and parses /var/lib/misc/dnsmasq.leases. +func parseLeases() ([]Lease, error) { + f, err := os.Open(leasesFile) + if err != nil { + return nil, fmt.Errorf("open leases file: %w", err) + } + defer f.Close() + + var leases []Lease + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) < 3 { + continue + } + ts, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + continue + } + lease := Lease{ + Expiry: time.Unix(ts, 0), + MAC: strings.ToLower(parts[1]), + IP: parts[2], + } + if len(parts) >= 4 { + lease.Hostname = parts[3] + } + if len(parts) >= 5 { + lease.ClientID = parts[4] + } + leases = append(leases, lease) + } + return leases, scanner.Err() +} + +// dhcpHostPattern matches lines like: dhcp-host=mac,ip,hostname or dhcp-host=mac,hostname +var dhcpHostPattern = regexp.MustCompile(`^dhcp-host\s*=\s*(.+)$`) + +// parsePinnedHosts reads dnsmasq.conf and returns a set of pinned MAC addresses. +func parsePinnedHosts() (map[string]string, error) { + f, err := os.Open(confFile) + if err != nil { + return nil, fmt.Errorf("open config file: %w", err) + } + defer f.Close() + + pinned := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + matches := dhcpHostPattern.FindStringSubmatch(line) + if matches == nil { + continue + } + fields := strings.Split(matches[1], ",") + if len(fields) < 2 { + continue + } + mac := strings.TrimSpace(strings.ToLower(fields[0])) + hostname := strings.TrimSpace(fields[len(fields)-1]) + pinned[mac] = hostname + } + return pinned, scanner.Err() +} + +// pinLease appends a dhcp-host line to dnsmasq.conf. +func pinLease(mac, ip, hostname string) error { + line := fmt.Sprintf("dhcp-host=%s,%s,%s", mac, ip, hostname) + f, err := os.OpenFile(confFile, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("open config for append: %w", err) + } + defer f.Close() + + if _, err := fmt.Fprintln(f, line); err != nil { + return fmt.Errorf("write dhcp-host line: %w", err) + } + return nil +} + +// unpinLease removes dhcp-host lines matching the given MAC from dnsmasq.conf. +func unpinLease(mac string) error { + macLower := strings.ToLower(mac) + f, err := os.Open(confFile) + if err != nil { + return fmt.Errorf("open config file: %w", err) + } + + var kept []string + removed := false + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + if dhcpHostPattern.MatchString(trimmed) { + matchContent := dhcpHostPattern.FindStringSubmatch(trimmed)[1] + parts := strings.Split(matchContent, ",") + if len(parts) > 0 && strings.TrimSpace(strings.ToLower(parts[0])) == macLower { + removed = true + continue // skip this line + } + } + kept = append(kept, line) + } + f.Close() + + if err := scanner.Err(); err != nil { + return fmt.Errorf("scan config: %w", err) + } + + if !removed { + return nil // nothing to remove, not an error + } + + // Write back the filtered content. + out, err := os.Create(confFile) + if err != nil { + return fmt.Errorf("create config file: %w", err) + } + defer out.Close() + + for _, line := range kept { + if _, err := fmt.Fprintln(out, line); err != nil { + return fmt.Errorf("write config line: %w", err) + } + } + return nil +} + +// reloadDnsmasq runs the reload command (e.g. sudo systemctl reload dnsmasq). +func reloadDnsmasq() error { + cmd := exec.Command(reloadCmd, reloadArgs...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("reload failed: %w — %s", err, string(output)) + } + return nil +} + +// getAllLeases returns merged active and pinned lease info. +func getAllLeases() ([]LeaseEntry, error) { + activeLeases, err := parseLeases() + if err != nil { + return nil, fmt.Errorf("parse leases: %w", err) + } + + pinned, err := parsePinnedHosts() + if err != nil { + return nil, fmt.Errorf("parse pinned: %w", err) + } + + entries := make([]LeaseEntry, 0, len(activeLeases)) + pinnedCount := 0 + for _, lease := range activeLeases { + status := PinStatusActive + if _, ok := pinned[lease.MAC]; ok { + status = PinStatusPermanent + pinnedCount++ + } + entries = append(entries, LeaseEntry{ + Hostname: lease.Hostname, + IP: lease.IP, + MAC: lease.MAC, + Expiry: lease.Expiry, + Status: status, + }) + } + return entries, nil +} + +// --------------------------------------------------------------------------- +// HTTP handlers +// --------------------------------------------------------------------------- + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func handleListLeases(w http.ResponseWriter, r *http.Request) { + entries, err := getAllLeases() + if err != nil { + log.Printf("ERROR listing leases: %v", err) + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: err.Error()}) + return + } + + activeCount := len(entries) + pinnedCount := 0 + for _, e := range entries { + if e.Status == PinStatusPermanent { + pinnedCount++ + } + } + + writeJSON(w, http.StatusOK, LeasesResponse{ + Leases: entries, + PinnedCount: pinnedCount, + ActiveCount: activeCount, + }) +} + +func handlePinLease(w http.ResponseWriter, r *http.Request) { + var req PinRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid JSON: " + err.Error()}) + return + } + defer r.Body.Close() + + if req.MAC == "" || req.IP == "" { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "mac and ip are required"}) + return + } + + // Check if already pinned. + pinned, err := parsePinnedHosts() + if err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: err.Error()}) + return + } + if _, exists := pinned[strings.ToLower(req.MAC)]; exists { + writeJSON(w, http.StatusConflict, PinResponse{ + Success: false, + Message: fmt.Sprintf("%s is already pinned", req.MAC), + }) + return + } + + if err := pinLease(req.MAC, req.IP, req.Hostname); err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: err.Error()}) + return + } + writeJSON(w, http.StatusOK, PinResponse{Success: true, Message: "pinned successfully"}) +} + +func handleUnpinLease(w http.ResponseWriter, r *http.Request) { + var req PinRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid JSON: " + err.Error()}) + return + } + defer r.Body.Close() + + if req.MAC == "" { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "mac is required"}) + return + } + + if err := unpinLease(req.MAC); err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: err.Error()}) + return + } + writeJSON(w, http.StatusOK, PinResponse{Success: true, Message: "unpinned successfully"}) +} + +func handleReload(w http.ResponseWriter, r *http.Request) { + if err := reloadDnsmasq(); err != nil { + log.Printf("ERROR reloading dnsmasq: %v", err) + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: err.Error()}) + return + } + writeJSON(w, http.StatusOK, PinResponse{Success: true, Message: "dnsmasq reloaded"}) +} diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..be0fb05 --- /dev/null +++ b/setup.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Setup script for dhcp-lease-manager Zoraxy plugin +# Run as root to configure group permissions for dnsmasq management. + +set -euo pipefail + +ZORAXY_USER="${ZORAXY_USER:-zoraxy}" +GROUP="dnsmasq-edit" +CONF_FILE="/etc/dnsmasq.conf" +LEASES_FILE="/var/lib/misc/dnsmasq.leases" +SUDOERS_FILE="/etc/sudoers.d/dnsmasq-edit" + +echo "=== DHCP Lease Manager — Permission Setup ===" +echo "" + +# 1. Create group if it doesn't exist +if ! getent group "$GROUP" >/dev/null 2>&1; then + echo "[+] Creating group: $GROUP" + groupadd "$GROUP" +else + echo "[i] Group '$GROUP' already exists" +fi + +# 2. Add zoraxy user to group +if ! groups "$ZORAXY_USER" 2>/dev/null | grep -qw "$GROUP"; then + echo "[+] Adding user '$ZORAXY_USER' to group '$GROUP'" + usermod -a -G "$GROUP" "$ZORAXY_USER" +else + echo "[i] User '$ZORAXY_USER' already in group '$GROUP'" +fi + +# 3. Set group ownership and permissions on dnsmasq.conf +if [ -f "$CONF_FILE" ]; then + echo "[+] Setting group ownership on $CONF_FILE" + chgrp "$GROUP" "$CONF_FILE" + chmod g+w "$CONF_FILE" +else + echo "[!] WARNING: $CONF_FILE not found" +fi + +# 4. Set group read permissions on leases file +if [ -f "$LEASES_FILE" ]; then + echo "[+] Setting group ownership on $LEASES_FILE" + chgrp "$GROUP" "$LEASES_FILE" + chmod g+r "$LEASES_FILE" +else + echo "[!] WARNING: $LEASES_FILE not found" +fi + +# 5. Add sudoers entry for dnsmasq reload +if [ ! -f "$SUDOERS_FILE" ]; then + echo "[+] Creating sudoers rule: $SUDOERS_FILE" + cat > "$SUDOERS_FILE" <<'EOF' +# Allow dnsmasq-edit group to reload dnsmasq without a password +%dnsmasq-edit ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq +EOF + chmod 0440 "$SUDOERS_FILE" +else + echo "[i] Sudoers file '$SUDOERS_FILE' already exists" +fi + +echo "" +echo "=== Setup complete! ===" +echo "" +echo "Next steps:" +echo " 1. Verify Zoraxy discovers the plugin (restart Zoraxy if needed)" +echo " 2. Enable the plugin from Zoraxy's plugin manager" +echo " 3. Access UI at /plugin.ui/dhcp-lease-manager/" +echo " 4. Note: The zoraxy user must log out/in for group membership to take effect" diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..87e9a62 --- /dev/null +++ b/web/app.js @@ -0,0 +1,179 @@ +/** + * DHCP Lease Manager — client-side logic. + * All API URLs are relative to the page path, so the Zoraxy proxy handles routing. + */ + +const API_BASE = './api'; +const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function showToast(message, type) { + const toast = document.getElementById('toast'); + toast.textContent = message; + toast.className = 'toast ' + type; + void toast.offsetWidth; // force reflow for transition + toast.classList.remove('hidden'); + setTimeout(function () { toast.classList.add('hidden'); }, 3000); +} + +/** Minimal fetch wrapper that includes the CSRF token. */ +async function apiFetch(path, opts) { + opts = opts || {}; + opts.headers = opts.headers || {}; + if (csrfToken) { + opts.headers['X-Zoraxy-Csrf'] = csrfToken; + } + if (opts.body && typeof opts.body === 'object') { + opts.body = JSON.stringify(opts.body); + opts.headers['Content-Type'] = 'application/json'; + } + const res = await fetch(API_BASE + path, opts); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || data.message || 'Request failed'); + } + return data; +} + +function formatTime(t) { + var d = new Date(t); + if (isNaN(d.getTime())) return '--'; + return d.toLocaleString(); +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +function renderLeases(data) { + var tbody = document.getElementById('leases-body'); + var leases = data.leases || []; + + document.getElementById('active-count').textContent = data.active_count; + document.getElementById('pinned-count').textContent = data.pinned_count; + + if (leases.length === 0) { + tbody.innerHTML = 'No active leases'; + return; + } + + var rows = leases.map(function (lease) { + var isPermanent = lease.status === 'permanent'; + return ( + '' + + '' + esc(lease.hostname || '--') + '' + + '' + esc(lease.ip) + '' + + '' + esc(lease.mac) + '' + + '' + + '' + + esc(lease.status) + + '' + + '' + + '' + + (isPermanent + ? '' + : '' + ) + + '' + + '' + ); + }).join(''); + + tbody.innerHTML = rows; +} + +function esc(s) { + if (!s) return ''; + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function escAttr(s) { + if (!s) return ''; + return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '''); +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +async function loadLeases() { + try { + var data = await apiFetch('/leases'); + renderLeases(data); + } catch (err) { + showToast('Failed to load leases: ' + err.message, 'error'); + } +} + +async function pinLease(mac, ip, hostname) { + try { + await apiFetch('/pin', { + method: 'POST', + body: { mac: mac, ip: ip, hostname: hostname || '' } + }); + showToast('Pinned ' + (hostname || mac), 'success'); + loadLeases(); + } catch (err) { + showToast('Pin failed: ' + err.message, 'error'); + } +} + +async function unpinLease(mac, ip, hostname) { + try { + await apiFetch('/unpin', { + method: 'POST', + body: { mac: mac, ip: ip, hostname: hostname || '' } + }); + showToast('Unpinned ' + (hostname || mac), 'success'); + loadLeases(); + } catch (err) { + showToast('Unpin failed: ' + err.message, 'error'); + } +} + +async function reloadDnsmasq() { + var btn = document.getElementById('reload-btn'); + btn.disabled = true; + btn.textContent = 'Reloading...'; + try { + await apiFetch('/reload', { method: 'POST' }); + showToast('dnsmasq reloaded', 'success'); + loadLeases(); + } catch (err) { + showToast('Reload failed: ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Reload dnsmasq'; + } +} + +// --------------------------------------------------------------------------- +// Event delegation +// --------------------------------------------------------------------------- + +document.getElementById('leases-body').addEventListener('click', function (e) { + var btn = e.target.closest('button[data-action]'); + if (!btn) return; + + var action = btn.dataset.action; + var mac = btn.dataset.mac; + var ip = btn.dataset.ip; + var hostname = btn.dataset.hostname; + + if (action === 'pin') { + pinLease(mac, ip, hostname); + } else if (action === 'unpin') { + unpinLease(mac, ip, hostname); + } +}); + +document.getElementById('reload-btn').addEventListener('click', reloadDnsmasq); + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +loadLeases(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..cd1e815 --- /dev/null +++ b/web/index.html @@ -0,0 +1,47 @@ + + + + + + DHCP Lease Manager + + + +
+

DHCP Lease Manager

+
+ -- active • + -- permanent +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + +
HostnameIP AddressMAC AddressStatusActions
Loading leases...
+
+ + + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..5d87409 --- /dev/null +++ b/web/style.css @@ -0,0 +1,173 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, sans-serif; + font-size: 14px; + color: #1a1a2e; + background: #f5f6fa; + padding: 20px; + max-width: 960px; + margin: 0 auto; +} + +header { + margin-bottom: 20px; +} + +h1 { + font-size: 20px; + font-weight: 600; + margin-bottom: 8px; +} + +.stats-bar { + color: #636e72; + margin-bottom: 12px; +} + +.actions-bar { + display: flex; + gap: 8px; +} + +.btn { + padding: 6px 14px; + border: 1px solid #dcdde1; + border-radius: 4px; + background: #fff; + font-size: 13px; + cursor: pointer; + transition: background 0.15s; +} + +.btn:hover { + background: #f0f0f5; +} + +.btn-primary { + background: #0984e3; + color: #fff; + border-color: #0984e3; +} + +.btn-primary:hover { + background: #0773c5; +} + +.btn-danger { + background: #d63031; + color: #fff; + border-color: #d63031; +} + +.btn-danger:hover { + background: #b71c1c; +} + +.btn-secondary { + background: #dfe6e9; + border-color: #b2bec3; +} + +.btn-secondary:hover { + background: #b2bec3; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +table { + width: 100%; + border-collapse: collapse; + background: #fff; + border-radius: 6px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +th { + text-align: left; + padding: 10px 14px; + background: #f8f9fc; + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #636e72; + border-bottom: 1px solid #eee; +} + +td { + padding: 10px 14px; + border-bottom: 1px solid #f0f0f5; + font-size: 13px; +} + +tr:last-child td { + border-bottom: none; +} + +.loading-row td { + text-align: center; + padding: 30px; + color: #999; +} + +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; +} + +.status-active { + background: #dfe6e9; + color: #636e72; +} + +.status-permanent { + background: #00b894; + color: #fff; +} + +.mac-address { + font-family: "SF Mono", "Fira Code", monospace; + font-size: 12px; +} + +.actions-cell { + display: flex; + gap: 6px; +} + +/* Toast notification */ +.toast { + position: fixed; + bottom: 20px; + right: 20px; + padding: 10px 18px; + border-radius: 4px; + font-size: 13px; + color: #fff; + background: #2d3436; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + transition: opacity 0.25s, transform 0.25s; + z-index: 1000; +} + +.toast.success { background: #00b894; } +.toast.error { background: #d63031; } + +.toast.hidden { + opacity: 0; + transform: translateY(10px); + pointer-events: none; +} diff --git a/zoraxy_plugin/embed_webserver.go b/zoraxy_plugin/embed_webserver.go new file mode 100644 index 0000000..bb2b08a --- /dev/null +++ b/zoraxy_plugin/embed_webserver.go @@ -0,0 +1,135 @@ +package zoraxy_plugin + +import ( + "embed" + "io/fs" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// PluginUiRouter serves an embedded web UI and provides CSRF token injection. +type PluginUiRouter struct { + PluginID string + TargetFs *embed.FS + TargetFsPrefix string + HandlerPrefix string + EnableDebug bool + terminateHandler func() +} + +// NewPluginEmbedUIRouter creates a router backed by an embed.FS. +// targetFsPrefix is the root folder within the embed.FS (e.g. "/web"). +// handlerPrefix is the HTTP path prefix (e.g. "/ui"). +func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter { + if !strings.HasPrefix(targetFsPrefix, "/") { + targetFsPrefix = "/" + targetFsPrefix + } + targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/") + + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + return &PluginUiRouter{ + PluginID: pluginID, + TargetFs: targetFs, + TargetFsPrefix: targetFsPrefix, + HandlerPrefix: handlerPrefix, + } +} + +// csrfMiddleware intercepts HTML responses and injects the CSRF token. +func (p *PluginUiRouter) csrfMiddleware(r *http.Request, fsHandler http.Handler) http.Handler { + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, ".html") { + targetPath := p.TargetFsPrefix + "/" + strings.TrimPrefix(r.URL.Path, "/") + targetPath = strings.TrimPrefix(targetPath, "/") + content, err := fs.ReadFile(*p.TargetFs, targetPath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := strings.ReplaceAll(string(content), "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } + if strings.HasSuffix(r.URL.Path, "/") { + indexPath := p.TargetFsPrefix + "/" + strings.TrimPrefix(r.URL.Path, "/") + "index.html" + indexPath = strings.TrimPrefix(indexPath, "/") + content, err := fs.ReadFile(*p.TargetFs, indexPath) + if err == nil { + body := strings.ReplaceAll(string(content), "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } + } + fsHandler.ServeHTTP(w, r) + }) +} + +// Handler returns an http.Handler for the embedded UI. +func (p *PluginUiRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rewrittenURL := strings.TrimPrefix(r.RequestURI, p.HandlerPrefix) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + r.URL, _ = url.Parse(rewrittenURL) + r.RequestURI = rewrittenURL + + subFS, err := fs.Sub(*p.TargetFs, strings.TrimPrefix(p.TargetFsPrefix, "/")) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + p.csrfMiddleware(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r) + }) +} + +// RegisterTerminateHandler registers a graceful shutdown endpoint at {prefix}/term. +func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) { + p.terminateHandler = termFunc + if mux == nil { + mux = http.DefaultServeMux + } + mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) { + p.terminateHandler() + w.WriteHeader(http.StatusOK) + go func() { + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + }) +} + +// HandleFunc registers a handler under the UI path prefix. +func (p *PluginUiRouter) HandleFunc(pattern string, handler http.HandlerFunc, mux *http.ServeMux) { + if mux == nil { + mux = http.DefaultServeMux + } + if !strings.HasPrefix(pattern, p.HandlerPrefix) { + pattern = p.HandlerPrefix + pattern + } + mux.HandleFunc(pattern, handler) +} + +// AttachHandlerToMux attaches the UI file handler to the mux. +func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) { + if mux == nil { + mux = http.DefaultServeMux + } + p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/") + mux.Handle(p.HandlerPrefix+"/", p.Handler()) +} diff --git a/zoraxy_plugin/zoraxy_plugin.go b/zoraxy_plugin/zoraxy_plugin.go new file mode 100644 index 0000000..a60dbab --- /dev/null +++ b/zoraxy_plugin/zoraxy_plugin.go @@ -0,0 +1,119 @@ +// Package zoraxy_plugin provides the Zoraxy plugin interface types and helpers. +// This is a vendored copy from github.com/tobychui/zoraxy/src/mod/plugins/zoraxy_plugin/ +// Licensed under LGPL. +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +type PluginType int + +const ( + PluginType_Router PluginType = 0 + PluginType_Utilities PluginType = 1 +) + +type StaticCaptureRule struct { + CapturePath string `json:"capture_path"` +} + +type ControlStatusCode int + +const ( + ControlStatusCode_CAPTURED ControlStatusCode = 280 + ControlStatusCode_UNHANDLED ControlStatusCode = 284 + ControlStatusCode_ERROR ControlStatusCode = 580 +) + +type SubscriptionEvent struct { + EventName string `json:"event_name"` + EventSource string `json:"event_source"` + Payload string `json:"payload"` +} + +type RuntimeConstantValue struct { + ZoraxyVersion string `json:"zoraxy_version"` + ZoraxyUUID string `json:"zoraxy_uuid"` + DevelopmentBuild bool `json:"development_build"` +} + +type PermittedAPIEndpoint struct { + Method string `json:"method"` + Endpoint string `json:"endpoint"` + Reason string `json:"reason"` +} + +type IntroSpect struct { + ID string `json:"id"` + Name string `json:"name"` + Author string `json:"author"` + AuthorContact string `json:"author_contact"` + Description string `json:"description"` + URL string `json:"url"` + Type PluginType `json:"type"` + VersionMajor int `json:"version_major"` + VersionMinor int `json:"version_minor"` + VersionPatch int `json:"version_patch"` + + StaticCapturePaths []StaticCaptureRule `json:"static_capture_paths"` + StaticCaptureIngress string `json:"static_capture_ingress"` + + DynamicCaptureSniff string `json:"dynamic_capture_sniff"` + DynamicCaptureIngress string `json:"dynamic_capture_ingress"` + + UIPath string `json:"ui_path"` + + SubscriptionPath string `json:"subscription_path"` + SubscriptionsEvents map[string]string `json:"subscriptions_events"` + + PermittedAPIEndpoints []PermittedAPIEndpoint `json:"permitted_api_endpoints"` +} + +type ConfigureSpec struct { + Port int `json:"port"` + RuntimeConst RuntimeConstantValue `json:"runtime_const"` + APIKey string `json:"api_key,omitempty"` + ZoraxyPort int `json:"zoraxy_port,omitempty"` +} + +// ServeIntroSpect checks for -introspect flag and prints the spec as JSON, then exits. +func ServeIntroSpect(pluginSpect *IntroSpect) { + if len(os.Args) > 1 && os.Args[1] == "-introspect" { + jsonData, _ := json.MarshalIndent(pluginSpect, "", " ") + fmt.Println(string(jsonData)) + os.Exit(0) + } +} + +// RecvConfigureSpec reads the -configure flag from command line args. +func RecvConfigureSpec() (*ConfigureSpec, error) { + for i, arg := range os.Args { + if strings.HasPrefix(arg, "-configure=") { + var spec ConfigureSpec + if err := json.Unmarshal([]byte(arg[11:]), &spec); err != nil { + return nil, err + } + return &spec, nil + } else if arg == "-configure" { + var spec ConfigureSpec + if len(os.Args) > i+1 { + if err := json.Unmarshal([]byte(os.Args[i+1]), &spec); err != nil { + return nil, err + } + return &spec, nil + } + return nil, fmt.Errorf("no argument after -configure flag") + } + } + return nil, fmt.Errorf("-configure flag not found") +} + +// ServeAndRecvSpec serves introspection and returns config in one call. +func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) { + ServeIntroSpect(pluginSpect) + return RecvConfigureSpec() +}