zoraxy-dhcp/server.go

320 lines
8.5 KiB
Go

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 — all under Zoraxy's unified config directory.
// Override via environment: LEASE_FILE, CONF_FILE, RELOAD_CMD, RELOAD_ARGS
leasesFile = envOrDefault("LEASE_FILE", "/opt/zoraxy/conf/dhcp/dnsmasq.leases")
confFile = envOrDefault("CONF_FILE", "/opt/zoraxy/conf/dhcp/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 the dnsmasq lease file (default: /opt/zoraxy/conf/dhcp/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) {
if err := r.ParseForm(); err != nil {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid form data: " + err.Error()})
return
}
req := PinRequest{
MAC: r.FormValue("mac"),
IP: r.FormValue("ip"),
Hostname: r.FormValue("hostname"),
}
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) {
if err := r.ParseForm(); err != nil {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid form data: " + err.Error()})
return
}
req := PinRequest{
MAC: r.FormValue("mac"),
IP: r.FormValue("ip"),
Hostname: r.FormValue("hostname"),
}
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)
// Return 200 even on error — Zoraxy may intercept non-200
// responses and replace them with HTML error pages.
writeJSON(w, http.StatusOK, PinResponse{
Success: false,
Message: fmt.Sprintf("reload failed: %v", err),
})
return
}
writeJSON(w, http.StatusOK, PinResponse{Success: true, Message: "dnsmasq reloaded"})
}