feat: pin leases with optional hostname — adds DNS address=/name/IP record for network-wide name resolution

This commit is contained in:
Claus Lohmar 2026-07-29 08:35:01 +00:00
parent 2b65c25219
commit 16b5850aa6
3 changed files with 92 additions and 11 deletions

View file

@ -120,41 +120,86 @@ func sanitize(s string) string {
}, s)) }, s))
} }
// pinLease appends a dhcp-host line to dnsmasq.conf. // pinLease appends a dhcp-host line to dnsmasq.conf. If hostname is provided,
// also appends an address=/hostname/IP line so the name resolves network-wide via dnsmasq DNS.
func pinLease(mac, ip, hostname string) error { func pinLease(mac, ip, hostname string) error {
line := fmt.Sprintf("dhcp-host=%s,%s,%s", sanitize(mac), sanitize(ip), sanitize(hostname)) safeMAC := sanitize(mac)
safeIP := sanitize(ip)
safeHost := sanitize(hostname)
dhcpLine := fmt.Sprintf("dhcp-host=%s,%s,%s", safeMAC, safeIP, safeHost)
f, err := os.OpenFile(confFile, os.O_APPEND|os.O_WRONLY, 0) f, err := os.OpenFile(confFile, os.O_APPEND|os.O_WRONLY, 0)
if err != nil { if err != nil {
return fmt.Errorf("open config for append: %w", err) return fmt.Errorf("open config for append: %w", err)
} }
defer f.Close() defer f.Close()
if _, err := fmt.Fprintln(f, line); err != nil { if _, err := fmt.Fprintln(f, dhcpLine); err != nil {
return fmt.Errorf("write dhcp-host line: %w", err) return fmt.Errorf("write dhcp-host line: %w", err)
} }
// If a custom hostname was given, add a DNS A record too.
if safeHost != "" {
dnsLine := fmt.Sprintf("address=/%s/%s", safeHost, safeIP)
if _, err := fmt.Fprintln(f, dnsLine); err != nil {
return fmt.Errorf("write address line: %w", err)
}
}
return nil return nil
} }
// unpinLease removes dhcp-host lines matching the given MAC from dnsmasq.conf. // addressPattern matches lines like: address=/hostname/IP
var addressPattern = regexp.MustCompile(`^address\s*=\s*/(.+)/(.+)$`)
// unpinLease removes dhcp-host and associated address= lines matching the given MAC.
func unpinLease(mac string) error { func unpinLease(mac string) error {
macLower := strings.ToLower(mac) macLower := strings.ToLower(mac)
// First pass: find the IP from the dhcp-host line for this MAC.
var targetIP string
f, err := os.Open(confFile) f, err := os.Open(confFile)
if err != nil { if err != nil {
return fmt.Errorf("open config file: %w", err) return fmt.Errorf("open config file: %w", err)
} }
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if matches := dhcpHostPattern.FindStringSubmatch(line); matches != nil {
parts := strings.Split(matches[1], ",")
if len(parts) > 1 && strings.TrimSpace(strings.ToLower(parts[0])) == macLower {
targetIP = strings.TrimSpace(parts[1])
break
}
}
}
f.Close()
// Second pass: rebuild config, removing dhcp-host line + matching address= lines.
f, err = os.Open(confFile)
if err != nil {
return fmt.Errorf("open config file: %w", err)
}
var kept []string var kept []string
removed := false removed := false
scanner := bufio.NewScanner(f) scanner = bufio.NewScanner(f)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
// Remove dhcp-host for this MAC.
if dhcpHostPattern.MatchString(trimmed) { if dhcpHostPattern.MatchString(trimmed) {
matchContent := dhcpHostPattern.FindStringSubmatch(trimmed)[1] matchContent := dhcpHostPattern.FindStringSubmatch(trimmed)[1]
parts := strings.Split(matchContent, ",") parts := strings.Split(matchContent, ",")
if len(parts) > 0 && strings.TrimSpace(strings.ToLower(parts[0])) == macLower { if len(parts) > 0 && strings.TrimSpace(strings.ToLower(parts[0])) == macLower {
removed = true removed = true
continue // skip this line continue
}
}
// Remove address= line for the same IP.
if targetIP != "" && addressPattern.MatchString(trimmed) {
addrMatches := addressPattern.FindStringSubmatch(trimmed)
if strings.TrimSpace(addrMatches[2]) == targetIP {
continue
} }
} }
kept = append(kept, line) kept = append(kept, line)

View file

@ -47,7 +47,12 @@ function renderLeases(data) {
'<td class="actions-cell">' + '<td class="actions-cell">' +
(isPermanent (isPermanent
? '<button class="btn btn-danger" data-action="unpin" data-mac="' + escAttr(lease.mac) + '" data-ip="' + escAttr(lease.ip) + '" data-hostname="' + escAttr(lease.hostname) + '">Unpin</button>' ? '<button class="btn btn-danger" 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" data-action="pin" data-mac="' + escAttr(lease.mac) + '" data-ip="' + escAttr(lease.ip) + '" data-hostname="' + escAttr(lease.hostname) + '">Pin</button>' : '<button class="btn btn-primary pin-btn" data-action="show-pin-form" data-mac="' + escAttr(lease.mac) + '" data-ip="' + escAttr(lease.ip) + '" data-hostname="' + escAttr(lease.hostname) + '">Pin</button>' +
'<span class="pin-form" style="display:none;margin-left:6px;">' +
'<input type="text" class="pin-hostname" placeholder="hostname (optional)" size="14" value="' + escAttr(lease.hostname || '') + '">' +
'<button class="btn btn-primary pin-confirm" data-mac="' + escAttr(lease.mac) + '" data-ip="' + escAttr(lease.ip) + '">OK</button>' +
'<button class="btn btn-secondary pin-cancel">✕</button>' +
'</span>'
) + ) +
'</td>' + '</td>' +
'</tr>' '</tr>'
@ -68,7 +73,7 @@ function escAttr(s) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Actions (using $.cjax = Zoraxy's CSRF-safe AJAX wrapper) // Actions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function loadLeases() { function loadLeases() {
@ -157,10 +162,33 @@ document.getElementById('leases-body').addEventListener('click', function (e) {
var ip = btn.dataset.ip; var ip = btn.dataset.ip;
var hostname = btn.dataset.hostname; var hostname = btn.dataset.hostname;
if (action === 'pin') { if (action === 'unpin') {
pinLease(mac, ip, hostname);
} else if (action === 'unpin') {
unpinLease(mac, ip, hostname); unpinLease(mac, ip, hostname);
} else if (action === 'show-pin-form') {
// Show inline form, hide the Pin button
var row = btn.closest('td');
row.querySelector('.pin-btn').style.display = 'none';
row.querySelector('.pin-form').style.display = 'inline';
row.querySelector('.pin-hostname').focus();
}
});
document.getElementById('leases-body').addEventListener('click', function (e) {
// Confirm pin
if (e.target.closest('.pin-confirm')) {
var row = e.target.closest('td');
var mac = e.target.dataset.mac;
var ip = e.target.dataset.ip;
var hostname = row.querySelector('.pin-hostname').value.trim();
row.querySelector('.pin-form').style.display = 'none';
row.querySelector('.pin-btn').style.display = 'inline';
pinLease(mac, ip, hostname);
}
// Cancel pin form
if (e.target.closest('.pin-cancel')) {
var row = e.target.closest('td');
row.querySelector('.pin-form').style.display = 'none';
row.querySelector('.pin-btn').style.display = 'inline';
} }
}); });

View file

@ -83,6 +83,14 @@ h1 {
cursor: not-allowed; cursor: not-allowed;
} }
.pin-form input[type="text"] {
padding: 4px 8px;
border: 1px solid #dcdde1;
border-radius: 3px;
font-size: 13px;
vertical-align: middle;
}
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;