chore: initial commit of dhcp-lease-manager plugin

This commit is contained in:
Claus Lohmar 2026-07-24 09:17:04 +00:00
commit d1e2908fdc
11 changed files with 1175 additions and 0 deletions

22
.gitignore vendored Normal file
View file

@ -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/

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module dhcp-lease-manager
go 1.21

65
main.go Normal file
View file

@ -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)
}
}

55
models.go Normal file
View file

@ -0,0 +1,55 @@
package main
import "time"
// Lease represents a single line from dnsmasq's lease file.
// Format: <timestamp> <mac> <ip> <hostname> <client-id>
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"`
}

308
server.go Normal file
View file

@ -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"})
}

69
setup.sh Executable file
View file

@ -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"

179
web/app.js Normal file
View file

@ -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 = '<tr class="loading-row"><td colspan="5">No active leases</td></tr>';
return;
}
var rows = leases.map(function (lease) {
var isPermanent = lease.status === 'permanent';
return (
'<tr>' +
'<td>' + esc(lease.hostname || '--') + '</td>' +
'<td>' + esc(lease.ip) + '</td>' +
'<td class="mac-address">' + esc(lease.mac) + '</td>' +
'<td>' +
'<span class="status-badge ' + (isPermanent ? 'status-permanent' : 'status-active') + '">' +
esc(lease.status) +
'</span>' +
'</td>' +
'<td class="actions-cell">' +
(isPermanent
? '<button class="btn btn-danger btn-sm" data-action="unpin" data-mac="' + escAttr(lease.mac) + '" data-ip="' + escAttr(lease.ip) + '" data-hostname="' + escAttr(lease.hostname) + '">Unpin</button>'
: '<button class="btn btn-primary btn-sm" data-action="pin" data-mac="' + escAttr(lease.mac) + '" data-ip="' + escAttr(lease.ip) + '" data-hostname="' + escAttr(lease.hostname) + '">Pin</button>'
) +
'</td>' +
'</tr>'
);
}).join('');
tbody.innerHTML = rows;
}
function esc(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function escAttr(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// ---------------------------------------------------------------------------
// 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();

47
web/index.html Normal file
View file

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DHCP Lease Manager</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<header>
<h1>DHCP Lease Manager</h1>
<div id="stats" class="stats-bar">
<span id="active-count">--</span> active &bull;
<span id="pinned-count">--</span> permanent
</div>
<div class="actions-bar">
<button id="reload-btn" class="btn btn-secondary" title="Reload dnsmasq to apply changes">
Reload dnsmasq
</button>
</div>
</header>
<main>
<table id="leases-table">
<thead>
<tr>
<th>Hostname</th>
<th>IP Address</th>
<th>MAC Address</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="leases-body">
<tr class="loading-row">
<td colspan="5">Loading leases...</td>
</tr>
</tbody>
</table>
</main>
<div id="toast" class="toast hidden"></div>
<meta name="csrf-token" content="{{.csrfToken}}">
<script src="./app.js"></script>
</body>
</html>

173
web/style.css Normal file
View file

@ -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;
}

View file

@ -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())
}

View file

@ -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()
}