π

Managing Network Devices via Org-mode Table and OpenWRT Router

Show Sidebar

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:

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:

  1. adapt the Org-mode table
  2. execute the Python babel code
  3. invoke overwrite_router_files_with_local_files.sh
  4. invoke restart_firewall.sh
  5. 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.

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.


Related articles that link to this one:

Comment via email (persistent) or via Disqus (ephemeral) comments below: