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))
}
// 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 {
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)
if err != nil {
return fmt.Errorf("open config for append: %w", err)
}
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)
}
// 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
}
// 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 {
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)
if err != nil {
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
removed := false
scanner := bufio.NewScanner(f)
scanner = bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
// Remove dhcp-host for this MAC.
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
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)

View file

@ -47,7 +47,12 @@ function renderLeases(data) {
'<td class="actions-cell">' +
(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-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>' +
'</tr>'
@ -68,7 +73,7 @@ function escAttr(s) {
}
// ---------------------------------------------------------------------------
// Actions (using $.cjax = Zoraxy's CSRF-safe AJAX wrapper)
// Actions
// ---------------------------------------------------------------------------
function loadLeases() {
@ -157,10 +162,33 @@ document.getElementById('leases-body').addEventListener('click', function (e) {
var ip = btn.dataset.ip;
var hostname = btn.dataset.hostname;
if (action === 'pin') {
pinLease(mac, ip, hostname);
} else if (action === 'unpin') {
if (action === 'unpin') {
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;
}
.pin-form input[type="text"] {
padding: 4px 8px;
border: 1px solid #dcdde1;
border-radius: 3px;
font-size: 13px;
vertical-align: middle;
}
table {
width: 100%;
border-collapse: collapse;