CI REUSE status

Dnsmasqex adds dnsmasq DHCP and DNS servers to VintageNet interfaces. It wraps the technology that manages the interface, such as VintageNetEthernet or VintageNetWiFi in access point mode, and takes the place of VintageNet's :dhcpd and :dnsd. Unlike :dnsd, it forwards names it doesn't know, so clients can use it as their only DNS server.

To use it, add :dnsmasqex to your mix dependencies like this:

def deps do
[
{:dnsmasqex, "~> 0.1.1", targets: @all_targets}
]
end

Dnsmasqex also requires dnsmasq, which isn't in the official Nerves systems. In Buildroot, enable BR2_PACKAGE_DNSMASQ. dnsmasq needs its DHCP and script support, which its default build includes.

If dnsmasq isn't on the PATH, set its location:

config :dnsmasqex, dnsmasq: "/usr/sbin/dnsmasq"

Using

Dnsmasqex runs on an interface with a static IPv4 address. Set :type to Dnsmasqex, :technology to the technology that manages the interface, and put the dnsmasq settings under :dnsmasq. For example, to hand out addresses on a second Ethernet port:

config :vintage_net,
config: [
{"eth1",
%{
type: Dnsmasqex,
technology: VintageNetEthernet,
ipv4: %{method: :static, address: "192.168.24.1", prefix_length: 24},
dnsmasq: %{
start: "192.168.24.10",
end: "192.168.24.99",
domain: "lan",
static_leases: [{"aa:bb:cc:dd:ee:ff", "192.168.24.100", "printer"}],
records: [{"device", "192.168.24.1"}]
}
}}
]

You can also set the configuration at runtime:

iex> VintageNet.configure("eth1", %{
type: Dnsmasqex,
technology: VintageNetEthernet,
ipv4: %{method: :static, address: "192.168.24.1", prefix_length: 24},
dnsmasq: %{start: "192.168.24.10", end: "192.168.24.99"}
})
:ok

In the above, IP addresses were passed as strings for convenience, but it's also possible to pass tuples like {192, 168, 24, 1}. VintageNet internally works with tuples.

The following fields are supported:

Don't combine :dnsmasq with :dhcpd or :dnsd on the same interface. Static leases must have unique MAC and IP addresses and cannot use the server's address or the subnet's network or broadcast address.

WiFi access point example, in place of :dhcpd:

iex> VintageNet.configure("wlan0", %{
type: Dnsmasqex,
technology: VintageNetWiFi,
vintage_net_wifi: %{networks: [%{mode: :ap, ssid: "test ssid", key_mgmt: :none}]},
ipv4: %{method: :static, address: "192.168.24.1", netmask: "255.255.255.0"},
dnsmasq: %{start: "192.168.24.2", end: "192.168.24.10"}
})

Fixed addresses and names for known clients example:

dnsmasq: %{
start: "192.168.24.10",
end: "192.168.24.99",
domain: "lan",
authoritative: true,
lease_path: "/data/dnsmasq/eth1.leases",
static_leases: [
{"aa:bb:cc:dd:ee:01", "192.168.24.101", "esp32"},
%{mac: "aa:bb:cc:dd:ee:02", ip: "192.168.24.102", hostname: "camera", lease_time: 600},
%{mac: "aa:bb:cc:dd:ee:03", ignore: true}
]
}

Clients reach each other as esp32.lan and camera.lan.

Isolated network example, with no gateway and no upstream DNS:

dnsmasq: %{
start: "192.168.24.10",
end: "192.168.24.99",
domain: "lan",
options: %{router: []},
name_servers: [],
records: [{"device", "192.168.24.1"}]
}

Captive portal example, answering every name with the device's address:

dnsmasq: %{
start: "192.168.24.10",
end: "192.168.24.99",
domain_records: [{"#", "192.168.24.1"}]
}

DNS records example:

dnsmasq: %{
domain: "lan",
records: [{"device", "192.168.24.1"}, {"nas", "192.168.24.50"}],
cnames: [{"www.lan", "device.lan"}],
srv_records: [{"_http._tcp.lan", "device.lan", 80}],
txt_records: [{"device.lan", "model=rpi5"}],
domain_records: [{"*.apps.lan", "192.168.24.1"}]
}

Forwarding example, sending one domain to its own servers and the rest to public ones:

dnsmasq: %{
name_servers: ["1.1.1.1", "9.9.9.9"],
forward_domains: [{"corp.example.com", ["10.0.0.53"]}]
}

Changing leases, options and records at runtime

The static leases, DHCP options and :records can change without reconfiguring the interface or restarting dnsmasq. For example, to give a client a fixed address and later move it:

iex> VintageNet.ioctl("eth1", :add_static_lease, [{"aa:bb:cc:dd:ee:04", "192.168.24.103", "esp32"}])
:ok
iex> VintageNet.ioctl("eth1", :put_static_lease, [{"aa:bb:cc:dd:ee:04", "192.168.24.104", "esp32"}])
:ok
iex> VintageNet.ioctl("eth1", :add_static_lease, [{"aa:bb:cc:dd:ee:05", "192.168.24.104"}])
{:error, {:ip_in_use, {"aa:bb:cc:dd:ee:04", {192, 168, 24, 104}, "esp32"}}}
Command Arguments Description
:static_leases [leases] Replace the static leases
:add_static_lease [lease] Add a lease. Returns {:error, {:mac_in_use, lease}} or {:error, {:ip_in_use, lease}} with the static lease that already has the MAC or address
:put_static_lease [lease] Add a lease or replace the one with the same MAC. Returns {:error, {:ip_in_use, lease}} when another MAC has the address
:remove_static_lease [mac] Remove the lease for a MAC
:records [records] Replace the records
:add_record [{name, ips}] Add the addresses for a new name. Returns {:error, {:name_in_use, records}} when the name has records
:put_record [{name, ips}] Replace the addresses for a name
:remove_record [name] Remove the records for a name
:options [options] Replace the DHCP options
:put_option [option, value] Set one DHCP option
:delete_option [option] Remove one DHCP option
:reload [] Read the :hosts_dir files again

ips may be one address or a list. Invalid values return {:error, reason} and leave the current ones in place. Lease commands return {:error, :dhcp_disabled} when the configuration has no range, static leases or :hosts_dir.

Clients get new leases and options when they next renew. If another client has a dynamic lease on a new static address, dnsmasq refuses that client's renewal so it moves to another address, and the static client gets the address when it renews. Check the dhcpd/leases property first to see whether that will happen.

The changes are held in memory. Reconfiguring the interface or restarting its runtime server restores the configured values, including when the interface's supervision tree restarts. dnsmasq reads new files in :hosts_dir on its own, but needs :reload after one changes or is removed.

Checking dnsmasq and the kernel

dnsmasq builds differ. Dnsmasqex.capabilities/0 reports the version and the features dnsmasq was built with, and Dnsmasqex.nftables_available?/0 whether the kernel has nf_tables:

iex> Dnsmasqex.capabilities()
{:ok, %{version: "2.91", dhcp: true, scripts: true, nftset: false, dnssec: false, ...}}
iex> Dnsmasqex.nftables_available?()
false

:nftsets needs both. Its nftables tables and sets must already exist, with names that nftables accepts without quotes. Avoid reserved words such as set and counter; keyword restrictions depend on the installed nftables version and aren't checked here. VintageNet.verify_system/0 checks that dnsmasq can serve DHCP and run the script that reports events.

Properties

In addition to the common vintage_net properties for all interface types, this technology reports the following:

Property Values Description
dhcpd/leases [%{}, ...] Current leases, in the same format as VintageNet's :dhcpd. leasetime is :infinity for infinite leases
dnsmasq/event %Dnsmasqex.Event{} The latest lease or neighbor event
dnsmasq/static_leases [lease, ...] The static leases in use
dnsmasq/options %{option => value} The DHCP options in use
dnsmasq/records [{name, ip}, ...] The records in use

A lease looks like this:

%{
hostname: "esp32",
lease_mac: "e8:f6:0a:e7:1a:8a",
lease_nip: "192.168.24.100",
leasetime: 600
}

Events

dnsmasq reports "add", "old" and "del" when a lease is added, renewed or removed, and "arp-add" and "arp-del" when a client appears or disappears on the interface's subnet. It also reports every lease as "old" when it starts or reloads. An event looks like this:

%Dnsmasqex.Event{
name: "add",
mac: "e8:f6:0a:e7:1a:8a",
ip: "192.168.24.100",
hostname: "esp32",
supplied_hostname: "espressif",
client_id: "01:e8:f6:0a:e7:1a:8a",
tags: ["known", "eth0"],
time_remaining: 600,
requested_options: [1, 3, 28, 6]
}

To act on them, subscribe to the property:

iex> VintageNet.subscribe(["interface", "eth1", "dnsmasq", "event"])
:ok
iex> flush()
{VintageNet, ["interface", "eth1", "dnsmasq", "event"], nil,
%Dnsmasqex.Event{name: "add", ...}, %{}}

See Dnsmasqex.Event for all the fields. dnsmasq checks the neighbor table at most every 90 seconds, so "arp-del" can arrive minutes after a client goes away. The interface's lower_up property changes as soon as the link does.

Debugging

dnsmasq's output is logged at the :debug level. On Nerves, run RingLogger.next or log_attach from an IEx prompt, and lower the log level if needed. dnsmasq logs the lines it can't use in integer :options and skips them rather than failing.

If dnsmasq exits, Dnsmasqex logs the reason and restarts it, waiting longer each time up to 30 seconds. A common reason is another program already using port 53 or 67 on the interface's address.

Development

Run mix test, mix format --check-formatted, mix credo --strict, and mix dialyzer. Linux runs the process identity and signal tests. Installing dnsmasq also enables tests that check generated configuration with dnsmasq --test.