Skip to content

Commit

Permalink
New feature: associate a link with each DHCP client (#16)
Browse files Browse the repository at this point in the history
This PR is adding a new column "Link" to the Current DHCP clients table, which contains the rendering of a new (optional) "link" property associated to each IP address reservation or DHCP friendly name in the configuration file.
  • Loading branch information
f18m authored Oct 29, 2024
1 parent df3ee45 commit 7a72c6a
Show file tree
Hide file tree
Showing 11 changed files with 234 additions and 32 deletions.
14 changes: 11 additions & 3 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
# as reported by the Github Action workflow 'publish.yaml', so that you can force HomeAssistant
# to use the docker image of that feature branch instead of the docker image of 'main', by pointing
# HomeAssistant to that feature branch
version: 1.4.1
slug: dnsmasq-dhcp
name: Dnsmasq-DHCP
version: beta
slug: dnsmasq-dhcp-beta
name: Dnsmasq-DHCP BETA
description: A DHCP server based on dnsmasq
url: https://github.com/f18m/ha-addon-dnsmasq-dhcp-server/tree/main
advanced: true
Expand Down Expand Up @@ -56,9 +56,15 @@ options:
- mac: aa:bb:cc:dd:ee:ff
name: "An-important-host-with-reserved-IP"
ip: 192.168.1.15
# the 'link' property accepts a basic golang template. Available variables are 'mac', 'name' and 'ip'
# e.g. "http://{{ ip }}/landing/page"
link:
dhcp_clients_friendly_names:
- mac: dd:ee:aa:dd:bb:ee
name: "This is a friendly name to label this host, even if it gets a dynamic IP"
# the 'link' property accepts a basic golang template. Available variables are 'mac', 'name' and 'ip'
# e.g. "http://{{ ip }}/landing/page/for/this/dynamic/host"
link:
log_dhcp: true
log_web_ui: false
# this addon uses "host_network: true" so the internal HTTP server will bind on the interface
Expand Down Expand Up @@ -92,9 +98,11 @@ schema:
# the name in this case must be a valid hostname as per RFC 1123 since it is passed to dnsmasq
# that will refuse to start if an invalid hostname format is used
name: match(^[a-zA-Z0-9\-.]*$)
link: "str?"
dhcp_clients_friendly_names:
- mac: match(^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$)
name: str
link: "str?"
log_dhcp: bool
log_web_ui: bool
web_ui_port: int
Expand Down
49 changes: 42 additions & 7 deletions dhcp-clients-webapp-backend/pkg/uibackend/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/netip"
"text/template"

"github.com/b0ch3nski/go-dnsmasq-utils/dnsmasq"
)
Expand All @@ -31,8 +32,14 @@ type DhcpClientData struct {
IsInsideDHCPPool bool

// Sometimes the hostname provided by the DHCP client to the DHCP server is really awkward and
// non-informative, so we allow users to override that.
// non-informative, so we allow users to override that from configuration.
// If such an override is available in config, this field gets populated.
FriendlyName string

// In the configuration file it's possible to specify a golang template that is rendered to
// produce a string which is intended to be an URL/URI to show for each DHCP client in the web UI.
// If such link template is available in config, this field gets populated.
EvaluatedLink string
}

// MarshalJSON customizes the JSON serialization for DhcpClientData
Expand All @@ -47,6 +54,7 @@ func (d DhcpClientData) MarshalJSON() ([]byte, error) {
HasStaticIP bool `json:"has_static_ip"`
IsInsideDHCPPool bool `json:"is_inside_dhcp_pool"`
FriendlyName string `json:"friendly_name"`
EvaluatedLink string `json:"evaluated_link"`
}{
Lease: struct {
Expires int64 `json:"expires"`
Expand All @@ -62,6 +70,7 @@ func (d DhcpClientData) MarshalJSON() ([]byte, error) {
HasStaticIP: d.HasStaticIP,
IsInsideDHCPPool: d.IsInsideDHCPPool,
FriendlyName: d.FriendlyName,
EvaluatedLink: d.EvaluatedLink,
})
}

Expand All @@ -77,13 +86,15 @@ type PastDhcpClientData struct {
type DhcpClientFriendlyName struct {
MacAddress net.HardwareAddr
FriendlyName string
Link template.Template
}

// IpAddressReservation represents a static IP configuration loaded from the addon configuration file
type IpAddressReservation struct {
Name string `json:"name"`
Mac string `json:"mac"`
IP string `json:"ip"`
Name string
Mac net.HardwareAddr
IP netip.Addr
Link template.Template
}

// AddonConfig is used to unmarshal HomeAssistant option file correctly
Expand Down Expand Up @@ -121,11 +132,17 @@ func (b *AddonConfig) UnmarshalJSON(data []byte) error {

// JSON parse
var cfg struct {
IpAddressReservations []IpAddressReservation `json:"ip_address_reservations"`
IpAddressReservations []struct {
Name string `json:"name"`
Mac string `json:"mac"`
IP string `json:"ip"`
Link string `json:"link"`
} `json:"ip_address_reservations"`

DhcpClientsFriendlyNames []struct {
Name string `json:"name"`
Mac string `json:"mac"`
Link string `json:"link"`
} `json:"dhcp_clients_friendly_names"`

DhcpRange struct {
Expand Down Expand Up @@ -169,12 +186,24 @@ func (b *AddonConfig) UnmarshalJSON(data []byte) error {
return fmt.Errorf("invalid MAC address found inside 'ip_address_reservations': %s", r.Mac)
}

linkTemplate, err := template.New("linkTemplate").Parse(r.Link)
if err != nil {
return fmt.Errorf("invalid golang template found inside 'link': %s", r.Link)
}

// normalize the IP and MAC address format (e.g. to lowercase)
r.IP = ipAddr.String()
r.Mac = macAddr.String()

b.ipAddressReservationsByIP[ipAddr] = r
b.ipAddressReservationsByMAC[macAddr.String()] = r
ipReservation := IpAddressReservation{
Name: r.Name,
Mac: macAddr,
IP: ipAddr,
Link: *linkTemplate,
}

b.ipAddressReservationsByIP[ipAddr] = ipReservation
b.ipAddressReservationsByMAC[macAddr.String()] = ipReservation
}

// convert friendly names to a map of DhcpClientFriendlyName instances indexed by MAC address
Expand All @@ -184,9 +213,15 @@ func (b *AddonConfig) UnmarshalJSON(data []byte) error {
return fmt.Errorf("invalid MAC address found inside 'dhcp_clients_friendly_names': %s", client.Mac)
}

linkTemplate, err := template.New("linkTemplate").Parse(client.Link)
if err != nil {
return fmt.Errorf("invalid golang template found inside 'link': %s", client.Link)
}

b.friendlyNames[macAddr.String()] = DhcpClientFriendlyName{
MacAddress: macAddr,
FriendlyName: client.Name,
Link: *linkTemplate,
}
}

Expand Down
57 changes: 55 additions & 2 deletions dhcp-clients-webapp-backend/pkg/uibackend/uibackend.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package uibackend

import (
"bytes"
"cmp"
"context"
"dhcp-clients-webapp-backend/pkg/logger"
Expand All @@ -12,11 +13,13 @@ import (
"net"
"net/http"
"net/netip"
"net/url"
"os"
"slices"
"strconv"
"strings"
"sync"
texttemplate "text/template"
"time"

"github.com/b0ch3nski/go-dnsmasq-utils/dnsmasq"
Expand Down Expand Up @@ -469,11 +472,11 @@ func (b *UIBackend) hasIpAddressReservationByIP(ip netip.Addr, macExpected net.H
if hasReservation {
// the IP address provided is a reserved one...
// check if the MAC address is the one for which that IP was intended...
if strings.EqualFold(macExpected.String(), b.cfg.ipAddressReservationsByIP[ip].Mac) {
if strings.EqualFold(macExpected.String(), b.cfg.ipAddressReservationsByIP[ip].Mac.String()) {
return true
} else {
b.logger.Warnf("the IP %s was leased to MAC address %s, but in configuration it was reserved for MAC %s\n",
ip.String(), macExpected.String(), b.cfg.ipAddressReservationsByIP[ip].Mac)
ip.String(), macExpected.String(), b.cfg.ipAddressReservationsByIP[ip].Mac.String())
}
}
return false
Expand All @@ -484,6 +487,55 @@ func (b *UIBackend) hasIpAddressReservationByMAC(mac net.HardwareAddr) bool {
return hasReservation
}

// isValidURI checks if the given string is a valid URI.
func isValidURI(uri string) bool {
parsedURL, err := url.ParseRequestURI(uri)
return err == nil && parsedURL.Scheme != "" && parsedURL.Host != ""
}

func (b *UIBackend) evaluateLink(hostname string, ip netip.Addr, mac net.HardwareAddr) string {

var theTemplate *texttemplate.Template
var friendlyName string

r, hasFriendlyName := b.cfg.friendlyNames[mac.String()]
if hasFriendlyName {
theTemplate = &r.Link
friendlyName = r.FriendlyName
} else {
r, hasReservation := b.cfg.ipAddressReservationsByIP[ip]
if hasReservation {
theTemplate = &r.Link
}
}

if theTemplate == nil {
return ""
}

// Create a buffer to capture the output
var buf bytes.Buffer

// Execute the template with the provided data
err := theTemplate.Execute(&buf, map[string]string{
"mac": mac.String(),
"ip": ip.String(),
"hostname": hostname,
"friendly_name": friendlyName,
})
if err != nil {
b.logger.Warnf("failed to render the link template [%v]", theTemplate)
return ""
}

lnk := buf.String()
if !isValidURI(lnk) {
b.logger.Warnf("rendering [%v] produced an invalid URI [%s]", theTemplate, lnk)
return ""
}
return lnk
}

// Process a slice of dnsmasq.Lease and store that into the UIBackend object
func (b *UIBackend) processLeaseUpdatesFromArray(updatedLeases []*dnsmasq.Lease) {

Expand All @@ -497,6 +549,7 @@ func (b *UIBackend) processLeaseUpdatesFromArray(updatedLeases []*dnsmasq.Lease)
d.FriendlyName = b.getFriendlyNameFor(lease.MacAddr, lease.Hostname)
d.HasStaticIP = b.hasIpAddressReservationByIP(lease.IPAddr, lease.MacAddr)
d.IsInsideDHCPPool = IpInRange(lease.IPAddr, b.cfg.dhcpStartIP, b.cfg.dhcpEndIP)
d.EvaluatedLink = b.evaluateLink(lease.Hostname, lease.IPAddr, lease.MacAddr)

// processing complete:
b.dhcpClientData = append(b.dhcpClientData, d)
Expand Down
Loading

0 comments on commit 7a72c6a

Please sign in to comment.