feat: pin leases with optional hostname — adds DNS address=/name/IP record for network-wide name resolution
This commit is contained in:
parent
2b65c25219
commit
16b5850aa6
3 changed files with 92 additions and 11 deletions
57
server.go
57
server.go
|
|
@ -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)
|
||||||
|
|
|
||||||
38
web/app.js
38
web/app.js
|
|
@ -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';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue