chore: initial commit of dhcp-lease-manager plugin
This commit is contained in:
commit
d1e2908fdc
11 changed files with 1175 additions and 0 deletions
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal 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
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module dhcp-lease-manager
|
||||
|
||||
go 1.21
|
||||
65
main.go
Normal file
65
main.go
Normal 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
55
models.go
Normal 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
308
server.go
Normal 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
69
setup.sh
Executable 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
179
web/app.js
Normal 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, '&').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();
|
||||
47
web/index.html
Normal file
47
web/index.html
Normal 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 •
|
||||
<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
173
web/style.css
Normal 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;
|
||||
}
|
||||
135
zoraxy_plugin/embed_webserver.go
Normal file
135
zoraxy_plugin/embed_webserver.go
Normal 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())
|
||||
}
|
||||
119
zoraxy_plugin/zoraxy_plugin.go
Normal file
119
zoraxy_plugin/zoraxy_plugin.go
Normal 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()
|
||||
}
|
||||
Loading…
Reference in a new issue