Here's an idea for the geeks of us: I manage my OpenWRT DHCP (IPs) and firewall configuration from a single Org-mode table.
Why did I start with this? Well, in early 2025, I lost all of my router configuration because of a dying hardware and a personal error of not continuing my quarterly backup process for quite some time (due to router hardware switch issues).
So I had to start from scratch. Before I did this, I was thinking of getting an overview of my network devices. And combining this overview table with the configuration re-do I was thinking of generating the configuration files from that overview table. This way, I only have one single point of truth and the overview combined.
For people who do not use Org-mode yet: yes, you can keep the table in a text file or a CSV file and use the Python script as an independent script file. In this case, you need to adapt the parsing of the table data yourself. The idea and the workflow are the same. The implementation is slightly different.
The general workflow is that I maintain an Org-mode table with my devices. Furthermore, there is a babel code snippet in Python which takes this table as an input and generates the firewall and dhcp configuration files in their right format. Those files are then get copied to the router via scp (and key authentication).
So let's go though all those parts one after each other.
The Org-mode Table
Here is my table structure with following columns:
- IP: The desired IP the device should receive as a static lease. Please notice that this IP must be unique.
- Hostname: a hostname that is associated with that device. This way, I don't need to remember all the IPs. Instead, I can ping the hostname, or use its hostname for ssh targets.
- block LAN-devices: this is a yes-or-no entry with either a "x" (yes) or empty (no). The idea is that devices marked with an "x" here are not able to connect to any local device. They only are able to connect to the Internet. Unfortunately, this is much more complicated than just adding some firewall rules. To my current understanding, this requires wither VLANs or separate network segments. So far, I did not invest the effort of learning how to do this properly and implement it. Therefore, this column is for future use only.
- block WAN: This is also a yes-or-no entry. If marked with an "x", this device is not able to connect any host outside of our local network. So no Internet for those devices like smart TV, printers, IoT devices or other devices that do not need and should not get Internet connection.
- Notes: Just notes for myself.
- MAC: This holds the MAC address of the device which uniquely identifies it. Those addresses are usually printed on the device's backside or bottom.
- Lease: If I want to have a different lease time for the IP, I may enter it here. Syntax is according to dnsmasq.
Please note that the order of the columns needs to be in sync with the Python line:
ip, hostname, block_lan, block_wan, notes, mac, leasetime = device
You might want to use the source view of this article (pi-symbol in the upper right hand corner) for getting the header properly.
I randomized my IPs and the MAC addresses for this demo table. We do have more devices than that in our network but you will get the idea.
IP | Hostname | block LAN-devices | block WAN | Notes | MAC | Lease |
---|---|---|---|---|---|---|
extender1 | unused WiFi extender | 3E:8D:E9:C1:45:78 | ||||
oldprinter | not used any more | 07:C5:5F:29:9B:9B | ||||
192.168.2.3 | router1 | DE:58:E0:75:77:EB | 5w | |||
192.168.2.4 | AP1 | CF:C6:63:DC:6D:24 | 5w | |||
192.168.2.5 | AP2 | Outdoor access point | 0F:9D:79:AA:83:0F | 5w | ||
192.168.2.6 | oldphone1 | 0A:28:64:0F:3F:F3 | 1h | |||
192.168.2.7 | oldphone2 | 63:B7:BB:D9:D6:7C | 5m | |||
192.168.2.8 | myphone | 76:F2:43:4A:16:25 | ||||
192.168.2.9 | wifesphone | D3:80:BD:14:95:14 | ||||
192.168.2.20 | amazonfire | x | AF:1A:C2:FC:21:F3 | |||
192.168.2.21 | freedombox | FF:FF:FE:EB:B3:03 | ||||
192.168.2.22 | printer | x | copy machine | BB:17:68:B7:F0:59 | 7d | |
192.168.2.30 | tablet1 | A7:E4:13:54:D4:72 | ||||
192.168.2.31 | desktop1 | BE:11:4B:EC:3E:C3 | ||||
192.168.2.32 | notebook1 | AB:C7:05:9E:D5:B3 | ||||
192.168.2.33 | notebook2lan | D3:16:1F:8A:94:98 | ||||
192.168.2.34 | notebook2wifi | 66:F1:D1:7A:AC:11 | ||||
192.168.2.35 | tablet | 06:EF:A8:95:02:1E | ||||
192.168.2.36 | dock1 | BA:A9:18:BA:05:8B | ||||
192.168.2.40 | lawn | x | lawnmower | DA:CF:1E:33:02:19 | ||
192.168.2.41 | sensor1 | particle sensor | BB:BB:A8:57:90:6C |
The Python Script
Here is the code that generates the configuration files when you invoke it via C-c C-c
(by default).
Caution: please do not use its code unmodified without checking all of its content.
In particular, you'll need to modify the subnet and the PREFIX part of the configuration to match your network and your current OpenWRT configuration. Furthermore, you modify firewall_path
and dhcp_path
to your destination folder.
The output of this script overwrites all of you current dhcp and firewall configuration. Therefore, you need to check and double-check before overwriting your configuration. Otherwise, you could lock yourself out so that you'll need to reset your OpenWRT device and start from scratch.
This is the script I'm using as of 2025-04-18 and I am not planning to keep it up-to-date here. This should just give you some ideas how to do it and serve as a starting point for your own implementation.
You might want to use the source view of this article (pi-symbol in the upper right hand corner) for getting the header properly.
SUBNET='192.168.2' DEFAULT_LEASETIME='3d' WAC124_DHCP_PREFIX=''' # This file is auto-generated from Karl's Org-mode table. Modifications will be overwritten. # restart dhcp: # /etc/init.d/dnsmasq start config dnsmasq \toption domainneeded '1' \toption localise_queries '1' \toption rebind_protection '1' \toption rebind_localhost '1' \toption local '/lan/' \toption domain 'lan' \toption expandhosts '1' \toption cachesize '1000' \toption authoritative '1' \toption readethers '1' \toption leasefile '/tmp/dhcp.leases' \toption resolvfile '/tmp/resolv.conf.d/resolv.conf.auto' \toption localservice '1' \toption ednspacket_max '1232' config dhcp 'lan' \toption interface 'lan' \toption start '100' \toption limit '150' \toption leasetime '12h' \toption dhcpv4 'server' \toption dhcpv6 'server' \toption ra 'server' \tlist ra_flags 'managed-config' \tlist ra_flags 'other-config' \tlist dhcp_option '6,8.8.8.8,1.1.1.1' config dhcp 'wan' \toption interface 'wan' \toption ignore '1' config odhcpd 'odhcpd' \toption maindhcp '0' \toption leasefile '/tmp/hosts/odhcpd' \toption leasetrigger '/usr/sbin/odhcpd-update' \toption loglevel '4' ''' WAC124FIREWALL_PREFIX=''' # This file is auto-generated from Karl's Org-mode table. Modifications will be overwritten. # restart firewall: # /etc/init.d/firewall restart # This file is interpreted as shell script. # Put your custom iptables rules here, they will # be executed with each firewall (re-)start. # Internal uci firewall chains are flushed and recreated on reload, so # put custom rules into the root chains e.g. INPUT or FORWARD or into the # special user chains, e.g. input_wan_rule or postrouting_lan_rule. config defaults \toption input 'REJECT' \toption output 'ACCEPT' \toption forward 'REJECT' \toption synflood_protect '1' config zone \toption name 'lan' \tlist network 'lan' \toption input 'ACCEPT' \toption output 'ACCEPT' \toption forward 'ACCEPT' \toption masq '1' \toption mtu_fix '1' config zone \toption name 'wan' \tlist network 'wan' \tlist network 'wan6' \toption input 'REJECT' \toption output 'ACCEPT' \toption forward 'REJECT' \toption masq '1' \toption mtu_fix '1' config forwarding \toption src 'lan' \toption dest 'wan' config rule \toption name 'Allow-DHCP-Renew' \toption src 'wan' \toption proto 'udp' \toption dest_port '68' \toption target 'ACCEPT' \toption family 'ipv4' config rule \toption name 'Allow-Ping' \toption src 'wan' \toption proto 'icmp' \toption icmp_type 'echo-request' \toption family 'ipv4' \toption target 'ACCEPT' config rule \toption name 'Allow-IGMP' \toption src 'wan' \toption proto 'igmp' \toption family 'ipv4' \toption target 'ACCEPT' config rule \toption name 'Allow-DHCPv6' \toption src 'wan' \toption proto 'udp' \toption dest_port '546' \toption family 'ipv6' \toption target 'ACCEPT' config rule \toption name 'Allow-MLD' \toption src 'wan' \toption proto 'icmp' \toption src_ip 'fe80::/10' \tlist icmp_type '130/0' \tlist icmp_type '131/0' \tlist icmp_type '132/0' \tlist icmp_type '143/0' \toption family 'ipv6' \toption target 'ACCEPT' config rule \toption name 'Allow-ICMPv6-Input' \toption src 'wan' \toption proto 'icmp' \tlist icmp_type 'echo-request' \tlist icmp_type 'echo-reply' \tlist icmp_type 'destination-unreachable' \tlist icmp_type 'packet-too-big' \tlist icmp_type 'time-exceeded' \tlist icmp_type 'bad-header' \tlist icmp_type 'unknown-header-type' \tlist icmp_type 'router-solicitation' \tlist icmp_type 'neighbour-solicitation' \tlist icmp_type 'router-advertisement' \tlist icmp_type 'neighbour-advertisement' \toption limit '1000/sec' \toption family 'ipv6' \toption target 'ACCEPT' config rule \toption name 'Allow-ICMPv6-Forward' \toption src 'wan' \toption dest '*' \toption proto 'icmp' \tlist icmp_type 'echo-request' \tlist icmp_type 'echo-reply' \tlist icmp_type 'destination-unreachable' \tlist icmp_type 'packet-too-big' \tlist icmp_type 'time-exceeded' \tlist icmp_type 'bad-header' \tlist icmp_type 'unknown-header-type' \toption limit '1000/sec' \toption family 'ipv6' \toption target 'ACCEPT' config rule \toption name 'Allow-IPSec-ESP' \toption src 'wan' \toption dest 'lan' \toption proto 'esp' \toption target 'ACCEPT' config rule \toption name 'Allow-ISAKMP' \toption src 'wan' \toption dest 'lan' \toption dest_port '500' \toption proto 'udp' \toption target 'ACCEPT' ## End of header. Now for the individual hosts and stuff: ''' def format_mac_address(mac): # Remove any non-alphanumeric characters (just in case) mac = ''.join(c for c in mac if c.isalnum()) # Ensure the MAC address is in uppercase mac = mac.upper() # Format the MAC address with colons formatted_mac = ':'.join(mac[i:i+2] for i in range(0, len(mac), 2)) if len(formatted_mac) != 17: print(f'ERROR: mac accaddress {mac} has not the right size! This result won\'t work!') return formatted_mac # Function to generate the /etc/firewall.user rules def generate_firewall_rules(devices): firewall_rules = [] macs_allowed_for_lan_to_lan = [] for device in devices: ## must match order of columns in Org-mode table: ip, hostname, block_lan, block_wan, notes, mac, leasetime = device if not ip or not ip.startswith(SUBNET): continue if block_lan.lower() == "x": firewall_rules.append(f"\nconfig rule") firewall_rules.append(f"\toption name 'Block traffic to other LAN devices for {hostname} ({ip})'") firewall_rules.append(f"\toption src 'lan'") firewall_rules.append(f"\toption dest 'lan'") firewall_rules.append(f"\toption target 'REJECT'") firewall_rules.append(f"\toption src_mac '{format_mac_address(mac)}'") if block_wan.lower() == "x": firewall_rules.append(f"\nconfig rule") firewall_rules.append(f"\toption name 'Block traffic to WAN/Internet for {hostname} ({ip})'") firewall_rules.append(f"\toption src 'lan'") firewall_rules.append(f"\toption dest 'wan'") firewall_rules.append(f"\toption target 'REJECT'") firewall_rules.append(f"\toption src_mac '{format_mac_address(mac)}'") macs_allowed_for_lan_to_lan.append(mac) return "\n".join(firewall_rules) # Function to generate the /etc/config/dhcp entries def generate_dhcp_config(devices): dhcp_config = [] first_element = True for device in devices: ## must match order of columns in Org-mode table: ip, hostname, block_lan, block_wan, notes, mac, leasetime = device if first_element: ## ignore header line if ip.upper()!='IP' or hostname.upper()!='HOSTNAME' or mac.upper()!='MAC': ## yes, that might be a weird way to do it: bool + str-checks ;-) print(f'WARNING: this script assumes a header line but it might be the case that there is not a header line → check table + code') first_element = False continue ## check for errors because of *very* picky dnsmasq: if not ip: print(f'hostname {hostname} has no IP') continue elif not ip.startswith(SUBNET): print(f'hostname {hostname} has IP ({ip}) that doesn\'t start with {SUBNET}') continue dhcp_config.append(f"config host") dhcp_config.append(f"\tlist mac '{format_mac_address(mac)}'") if not leasetime: dhcp_config.append(f"\toption leasetime '{DEFAULT_LEASETIME}'") else: dhcp_config.append(f"\toption leasetime '{leasetime}'") dhcp_config.append(f"\toption name '{hostname}'") dhcp_config.append(f"\toption ip '{ip}'\n") return "\n".join(dhcp_config) def print_rules_to_stdout(): print('-' * 80) print(str(firewall_rules)) print('-' * 80) print(WAC124_DHCP_PREFIX + dhcp_config) print('-' * 80) # Generate firewall rules and DHCP config firewall_rules = generate_firewall_rules(mylist) dhcp_config = generate_dhcp_config(mylist) # Write to files firewall_path = '/home/user/hosts/router/config/wac124-firewall' dhcp_path = '/home/user/hosts/router/config/wac124-dhcp' ## enable for testing purposes: #firewall_path = '/tmp/wac124-firewall' #dhcp_path = '/tmp/wac124-dhcp' # Writing firewall rules to /etc/firewall.user with open(firewall_path, 'w') as fw_file: fw_file.write("# OpenWRT Firewall rules\n") fw_file.write(WAC124FIREWALL_PREFIX + firewall_rules) # Writing DHCP configuration to /etc/config/dhcp with open(dhcp_path, 'w') as dhcp_file: dhcp_file.write("# OpenWRT DHCP configuration\n") dhcp_file.write(WAC124_DHCP_PREFIX + dhcp_config) print("Configuration files have been generated successfully.")
It is very important to know that the script only contains a few sanity checks as for duplicate MAC addresses or IPs. Dnsmasq and firewall are very picky and doesn't start unless the input files are correct. So you need to do the sanity check yourself.
Getting the Files to the Router
After you have invoked the script, the result files gets written to the configured target directory.
I do have four shell scripts to control the further manual steps.
overwrite_router_files_with_local_files.sh
looks like that:
#!/usr/bin/env bash scp wac124-dhcp root@192.168.2.1:/etc/config/dhcp scp wac124-firewall root@192.168.2.1:/etc/config/firewall #end
Of course, you need to set up key authentication and scp first so that you can use those commands in the shell script.
I also wrote overwrite_local_files_with_files_from_router.sh
to get the current router files and overwrite the local ones:
#!/usr/bin/env bash scp root@192.168.2.1:/etc/config/dhcp ./wac124-dhcp scp root@192.168.2.1:/etc/config/firewall ./wac124-firewall #end
restart_firewall.sh
looks like that:
#!/usr/bin/env bash ssh root@192.168.2.1 "/etc/init.d/firewall restart && \ echo 'firewall restarted successfully'" #end
And finally restart_dhcp.sh
is:
#!/usr/bin/env bash ssh root@192.168.2.1 "killall dnsmasq && \ /etc/init.d/dnsmasq start && \ echo "---- checking service ..." && \ /etc/init.d/dnsmasq status && \ echo 'script finished'" #end
As a consequence, the usual workflow (without the manual checks!) looks like that:
- adapt the Org-mode table
- execute the Python babel code
- invoke
overwrite_router_files_with_local_files.sh
- invoke
restart_firewall.sh
- invoke
restart_dhcp.sh
... and your changes should be active on the router.
Future Plans
I do have many plans for improving my personal workflow here.
- Auto-generate via diff view before overwriting router configuration
files.
- Why? In case I did something on the router which should be adapted in the prefix lines as well before I overwrite it with the outdated configuration lines.
- Auto-commit the generated files in a local git repository on each
generation process.
- Just to have the whole history of configuration at hand and be able to revert to a previous setup quickly.
- Implement the "block LAN-devices" functionality probably using VLANs.
- I also want to have all unknown MAC address devices within that
"block LAN-devices" area. Therefore, guests do get the same WiFi network
as the rest but can't connect to my hosts.
- Alternatively: I need to learn how to properly set up a guest WiFi/LAN and migrate all those family members who already got their credentials to the new network.
I hope that this workflow inspires you for your own setup. Write a comment below if you do have feedback or your own stories.
If you really need to get my current source code, you can write me an email.