LAN port isolation (port-based VLAN) on ASUS RT-AX88U with Asuswrt-Merlin 384.16

Recently I upgraded my 4-years-old AC68P to the 802.11ax (or WiFi 6, but I prefer the former) supported AX88U. Like my old AC68P, I flashed Asuswrt-Merlin on AX88U since day one.

However, one issue with Asuswrt (either the stock firmware or Merlin) is no built-in GUI support on VLANs. I really miss the good old days with OpenWrt, as you can configure VLANs in GUI. But OpenWrt won’t support AX88U in a foreseeable future (due to Broadcom’s closed source driver1), CLI seems to be the only solution.

Requirement

Pretty straightforward. There are 8 LAN ports on AX88U and I want to isolate LAN 2 from the remaining 7 ports. LAN 1 and LAN 3 through 8 will be in VLAN 1, while LAN 2 will be isolated into VLAN 2 (guest network).

The traffic between two VLANs should be forbidden. But ideally, I want to allow one way traffic from VLAN 1 to VLAN 2. So I can still access the cascading router (in VLAN 2) from my computer (in VLAN 1), in case I need to reconfigure the secondary router.

In addition, I prefer everything to be configured automatically. So for clients in VLAN 2, the only thing is to plug in the ethernet cable and get connected to the Internet.

About AX88U

No robocfg?

The first problem is that robocfg is no more provided on AX88U (Broadcom’s HND platform). An alternative to robocfg on HND platform seems to be vlanctl2. However, after several hours of searching, trying and error, I believe vlanctl can only create tagged VLAN, which unfortunately can’t satisfy my need.

But in case someone else has special demand on tagged VLAN, I recommend to read (in Chinese): 上海电信 TL-EP110 + RT-AC86U 实现观看 4K IPTV 无卡顿 (2019-10). This post describes how to set tagged VLANs up properly on AC86U (also a HND platform router).

Interface to Physical Port Mapping

Somehow I didn’t find it in the Internet. After playing with an ethernet cable on different ports, I figured out:

Interface Physical Port
eth0 WAN
eth1 LAN 4
eth2 LAN 3
eth3 LAN 2
eth4 LAN 1
eth5 Bridge of LAN 5 - 8
eth6 2.4 GHz Radio
eth7 5 GHz Radio

Note that eth5 seems to be a hardware bridge (BCM53134) of LAN 5 - 8. You may be able to ungroup it with ethctl or ethswctl, but I didn’t spend much time on them. My solution can only isolate LAN ports 1 - 4.

By default, eth1 ~ eth7 are grouped in bridge br0.

Note: My WAN Connection Type is Automatic IP. If you are using other types like PPPoE, you may need to replace eth0 to ppp0 in the below accordingly.

One-liner with ebtables

If you don’t care about separating ports into different subnets, there actually exists an one-line solution:

# eth3 maps to LAN port 2 on AX88U 
ebtables -A FORWARD -i eth3 -o br0 -j DROP

Here eth3 maps to LAN 2. The layer 2 firewall ebtables will essentially block access between eth3 and remaining interfaces in br0.

Since eth3 is still in the same subnet as br0, there is no need to worry about iptables or DHCP. Everything will just be fine.

Better Approach with Separate Bridge

However, my goal is to associate two VLANs with two subnets. Say VLAN 1 with 192.168.50.0/24 and VLAN 2 with 192.168.150.0/24.

Create Bridge br1

First, remove eth3 from br0 and create a new bridge br1 with eth3:

# Delete those interfaces that we want to isolate from br0
brctl delif br0 eth3

# Create a new bridge br1 for our isolated interfaces
brctl addbr br1
brctl stp br1 on # STP to prevent bridge loops
brctl addif br1 eth3

Here we use bridge br1 for easier management. If you want to add isolate other LAN ports, you can simply add the corresponding interface to br1. All rules on br1 will automatically apply to that port.

Now run brctl show to verify the settings for br1:

[email protected]:/# brctl show
bridge name     bridge id               STP enabled     interfaces
br0             8000.a85e45fakeid       yes             eth1
                                                        eth2
                                                        eth4
                                                        eth5
                                                        eth6
                                                        eth7
br1             8000.a85e45fakeid       yes             eth3

By default, br0 is assigned with 192.168.50.1/24 on AX88U, so we don’t have to worry about it

Then, assign 192.168.150.1/24 to br1 and bring it up:

# Set up the IPv4 address for br1 and bring it up
# Here we set the subnet to be 192.168.150.0/24
# IPv6 link local address will be assigned automatically
ifconfig br1 192.168.150.1 netmask 255.255.255.0
ifconfig br1 allmulti up

Finally, run ifconfig br1 to see if br1 is up:

[email protected]:/# ifconfig br1
br1       Link encap:Ethernet  HWaddr A8:5E:45:00:FA:KE
          inet addr:192.168.150.1  Bcast:192.168.150.255  Mask:255.255.255.0
          inet6 addr: fe80::aa5e:45ff:fe00:fake/64 Scope:Link
          UP BROADCAST RUNNING ALLMULTI MULTICAST  MTU:1500  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

The IPv6 link local address is automatically generated by EUI-64.

Tip: Complete script can be found in Scripts & Configs: /jffs/scripts/services-start.

Add iptables Rules

First, allow new incoming connections from br1 to the router:

# Allow new incoming connections from br1
iptables -I INPUT -i br1 -m state --state NEW -j ACCEPT

But forbid accessing web UI and SSH from br1:

# Only forbid br1 access the web UI and SSH of the main router
iptables -I INPUT -i br1 -p tcp --dport 80 -j DROP
iptables -I INPUT -i br1 -p tcp --dport 22 -j DROP
ip6tables -I INPUT -i br1 -p tcp --dport 80 -j DROP
ip6tables -I INPUT -i br1 -p tcp --dport 22 -j DROP

Then, drop all forwarding packets from br1:

# Forbid packets from br1 to be forwarded to other interfaces
iptables -I FORWARD -i br1 -j DROP

But allow packet forwarding inside br1:

# But allow packet forwarding inside br1
iptables -I FORWARD -i br1 -o br1 -j ACCEPT

… also allow packet forwarding between br1 and eth0 (WAN):

# Allow packet forwarding between br1 and eth0 (WAN)
iptables -I FORWARD -i br1 -o eth0 -j ACCEPT

… also allow one-way traffic from br0 to br1:

# Allow one-way traffic from br0 to br1
iptables -I FORWARD -i br0 -o br1 -j ACCEPT
iptables -I FORWARD -i br1 -o br0 -m state \
  --state RELATED,ESTABLISHED -j ACCEPT

Finally, set up NAT inside 192.168.150.0/24 on br1:

iptables -t nat -A POSTROUTING -s 192.168.150.0/24 -d 192.168.150.0/24 \
  -o br1 -j MASQUERADE

Now run iptables -S INPUT, iptables -S FORWARD and iptables -t nat -S POSTROUTING to verify that rules have been added:

[email protected]:/# iptables -S INPUT
-P INPUT ACCEPT
-A INPUT -i br1 -p tcp -m tcp --dport 22 -j DROP
-A INPUT -i br1 -p tcp -m tcp --dport 80 -j DROP
-A INPUT -i br1 -m state --state NEW -j ACCEPT
# ... output omitted ...
-A INPUT -j DROP

[email protected]:/# iptables -S FORWARD
-P FORWARD DROP
-A FORWARD -i br1 -o br0 -m state --state RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -i br0 -o br1 -j ACCEPT
-A FORWARD -i br1 -o eth0 -j ACCEPT
-A FORWARD -i br1 -o br1 -j ACCEPT
-A FORWARD -i br1 -j DROP
# ... output omitted ...

[email protected]:/# iptables -t nat -S POSTROUTING
-P POSTROUTING ACCEPT
# ... output omitted ...
-A POSTROUTING -s 192.168.50.0/24 -d 192.168.50.0/24 -o br0 -j MASQUERADE
-A POSTROUTING -s 192.168.150.0/24 -d 192.168.150.0/24 -o br1 -j MASQUERADE

Tip: Complete scripts can be found below.

Configure dnsmasq for DHCPv4

Create an additional configuration file dnsmasq.conf.add in /jffs/configs/:

touch /jffs/configs/dnsmasq.conf.add

Similar to br0, set up DHCPv4 ranges and options on br1:

cat <<EOF >> /jffs/configs/dnsmasq.conf.add
interface=br1
# DHCPv4 range: 192.168.150.2 - 192.168.150.254, netmask: 255.255.255.0
# DHCPv4 lease time: 86400s (1 day)
dhcp-range=br1,192.168.150.2,192.168.150.254,255.255.255.0,86400s
# DHCPv4 router (option 3): 192.168.150.1
dhcp-option=br1,3,192.168.150.1
EOF

Restart dnsmasq to apply the config:

service restart_dnsmasq

Now run tail /tmp/syslog.log -n 50 to see if dnsmasq.conf.add is loaded:

[email protected]:/# tail /tmp/syslog.log -n 50
# ... output omitted ...
Apr 14 20:49:22 rc_service: service 15995:notify_rc restart_dnsmasq
Apr 14 20:49:22 dnsmasq[1149]: exiting on receipt of SIGTERM
Apr 14 20:49:22 custom_config: Appending content of /jffs/configs/dnsmasq.conf.add.
Apr 14 20:49:22 dnsmasq[15998]: started, version 2.81rc4-33-g7558f2b cachesize 1500
Apr 14 20:49:22 dnsmasq[15998]: asynchronous logging enabled, queue limit is 5 messages
Apr 14 20:49:22 dnsmasq-dhcp[15998]: DHCP, IP range 192.168.150.2 -- 192.168.150.254, lease time 1d
Apr 14 20:49:22 dnsmasq-dhcp[15998]: DHCP, IP range 192.168.50.2 -- 192.168.50.254, lease time 1d
# ... output omitted ...

What about IPv6?

Even in the scenario of stateless DHCPv6, hosts’ IPv6 addresses are still generated via SLAAC3 according to RFC 3736:

A node that uses stateless DHCP must have obtained its IPv6 addresses through some other mechanism, typically stateless address autoconfiguration (or SLAAC).

Since SLAAC uses EUI-64 algorithm, the subnet prefix is required to be shorter than /64. That means to do subnetting with stateless DHCPv6, the IPv6 LAN prefix allocated from your ISP must be at least of /63.

Prefix Hint for Shorter Subnet Prefix

Some ISPs allow you send prefix length hint to get a prefix shorter than /64 (fortunately Charter Spectrum does assign /56). If you’re not sure about this, you can try the following steps.

By the way, my AX88U’s IPv6 configuration is:

Name Value
Basic Config  
Connection type Native
DHCP-PD Enable
IPv6 LAN Setting  
Auto Configuration Setting Stateless
IPv6 DNS Setting  
Connect to DNS Server automatically Disable
IPv6 DNS Server 1 2606:4700:4700::1111
IPv6 DNS Server 2 2606:4700:4700::1001
IPv6 DNS Server 3 <leave blank>
Auto Configuration Setting  
Enable Router Advertisement Enable

By default, the DHCPv6 client odhcp6c on AX88U wouldn’t send prefix hint.
To force it do so4, first run

ps | sed -e 's/^.*\odhcp6c \(.*\)$/\1/;t;d' | head -n 1

to get the arguments of odhcp6c:

-df -R -s /tmp/dhcp6c -N try -c <duid> -FP 0:<iaid> eth0

<duid> and <iaid> are linked to your router’s MAC address. Depends on your IPv6 configuration, there may be additional arguments like -r23 and -r24, don’t forget to append them too.

So in the next two commands, replace them with the output you got:

# Kill existing odhcp6c.
killall odhcp6c
# Re-run odhcp6c with prefix hint 56.
# Replace `-c` and `-FP` arguments with the `ps` output you got.
odhcp6c -df -P56 -R -s /tmp/dhcp6c -N try -c <duid> -FP 0:<iaid> eth0

… and -P56 determines which prefix hint length to send.

Now you can run ifconfig br0 to verify if /56 subnet is assigned:

[email protected]:/# ifconfig br0
br0       Link encap:Ethernet  HWaddr A8:5E:45:00:FA:KE
          inet addr:192.168.50.1  Bcast:192.168.50.255  Mask:255.255.255.0
          inet6 addr: 2600:6c51:fake:n00b::1/56 Scope:Global
          inet6 addr: fe80::aa5e:45ff:fe00:fake/64 Scope:Link
          UP BROADCAST RUNNING ALLMULTI MULTICAST  MTU:1500  Metric:1
          RX packets:4241 errors:0 dropped:0 overruns:0 frame:0
          TX packets:3714 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:829133 (809.7 KiB)  TX bytes:1787334 (1.7 MiB)

If something similiar is printed to your screen, Congrulations! You can proceed to the next section. Otherwise, you may have to stop here and try to set up stateful DHCPv6 server on br1, which I didn’t try.

Tip: Complete script for re-running odhcp6c automatically after reboot can be found in Scripts & Configs: /jffs/scripts/wan-event.

Add ip6tables Rules

Similiar to IPv4 solution, some necessary ip6tables rules have to be added. But no need to deal with NAT (It’s IPv6!).

For INPUT chain:

# Allow new incoming connections from br1
ip6tables -I INPUT -i br1 -j ACCEPT # Same rule as br0 by default
ip6tables -I INPUT -i br1 -m state --state NEW -j ACCEPT

# Only forbid br1 access the web UI and SSH of the main router
ip6tables -I INPUT -i br1 -p tcp --dport 80 -j DROP
ip6tables -I INPUT -i br1 -p tcp --dport 22 -j DROP

For FORWARD chain:

# Forbid packets from br1 to be forwarded to other interfaces
ip6tables -I FORWARD -i br1 -j DROP

# But allow packet forwarding inside br1
ip6tables -I FORWARD -i br1 -o br1 -j ACCEPT

# Allow packet forwarding between br1 and eth0 (WAN)
ip6tables -I FORWARD -i br1 -o eth0 -j ACCEPT

# Allow one-way traffic from br0 to br1
ip6tables -I FORWARD -i br0 -o br1 -j ACCEPT
ip6tables -I FORWARD -i br1 -o br0 -m state \
  --state RELATED,ESTABLISHED -j ACCEPT

Tip: Complete script can be found in Scripts & Configs: /jffs/scripts/firewall-start.

Configure DHCPv6

By default, br0 will take the whole /56 subnet. We need to change it to /64 for subnetting on br1.

First, check what prefix br0 has gotten from ISP with ip -6 addr show br0 scope global:

[email protected]:/# ip -6 addr show br0 scope global
22: br0: <BROADCAST,MULTICAST,ALLMULTI,UP,LOWER_UP> mtu 1500
    inet6 2600:6c51:fake:n00b::1/56 scope global
       valid_lft forever preferred_lft forever

You IPv6 address is different so remember to replace 2600:6c51:fake:n00b::1 with yours in following commands.

Then, reassign br0 with a /64 subnet:

# Remove /56 subnet. Replace 2600:6c51:fake:n00b::1 with yours.
ip -6 addr del 2600:6c51:fake:n00b::1/56 dev br0
# Assign /64 subnet. Replace 2600:6c51:fake:n00b::1 with yours.
ip -6 addr add 2600:6c51:fake:n00b::1/64 dev br0

Of course you can make a shorter subnet prefix for br0, but I feel comfortable enough with /64.

Next, run ip -6 addr show br0 scope global again to check br0’s new perfix:

[email protected]:/# ip -6 addr show br0 scope global
22: br0: <BROADCAST,MULTICAST,ALLMULTI,UP,LOWER_UP> mtu 1500
    inet6 2600:6c51:fake:n00b::1/64 scope global
       valid_lft forever preferred_lft forever

The subnet for br1 is a little bit tricky, because dnsmasq doesn’t support DHCPv6 Prefix Delegation (PD), quoting from dnsmasq’s author5:

You’re right, though this (dnsmasq does not support replying to IA_PD (prefix delegation) requests) may change in the future.

The code of dnsmasq shipped in Merlin6 also proves no support of DHCPv6 prefix delegation (option 25 and 267).

Without Prefix Delegation

If you have no cascading routers or don’t care about PD, dnsmasq can be used as DHCPv6 server.

First, assign br1 with the sibling /64 subnet of br0:

# Assign the sibling /64 subnet of br0 to br1.
# For example, if br0 is on aaaa:bbbb:cccc:dddd::1/64,
# then br1 should on aaaa:bbbb:cccc:ddde::1/64.
# Replace 2600:6c51:fake:n00c::1 with yours.
ip -6 addr add 2600:6c51:fake:n00c::1/64 dev br1

Then, set up DHCPv6 ranges and options on br1:

cat <<EOF >> /jffs/configs/dnsmasq.conf.add
# DHCPv6 RA interval: 10s, router lifetime: 600s
ra-param=br1,10,600
# DHCPv6 range: whole subnet, constructing from br1's prefix
# DHCPv6 prefix length: 64, mode: Stateless DHCPv6
# DHCPv6 lease time: 600s (10 minutes)
dhcp-range=br1,::,constructor:br1,ra-stateless,64,600
# DHCPv6 DNS (option 23): inherit from the router
dhcp-option=br1,option6:23,[::]
EOF

Restart dnsmasq to apply the config:

service restart_dnsmasq

Now run tail /tmp/syslog.log -n 50 to see if dnsmasq.conf.add is loaded:

[email protected]:/# tail /tmp/syslog.log -n 50
# ... output omitted ...
Apr 14 20:49:22 rc_service: service 15995:notify_rc restart_dnsmasq
Apr 14 20:49:22 dnsmasq[1149]: exiting on receipt of SIGTERM
Apr 14 20:49:22 custom_config: Appending content of /jffs/configs/dnsmasq.conf.add.
Apr 14 20:49:22 dnsmasq[15998]: started, version 2.81rc4-33-g7558f2b cachesize 1500
Apr 14 20:49:22 dnsmasq[15998]: asynchronous logging enabled, queue limit is 5 messages
Apr 15 18:09:01 dnsmasq-dhcp[18800]: DHCP, IP range 192.168.150.2 -- 192.168.150.254, lease time 1d
Apr 15 18:09:01 dnsmasq-dhcp[18800]: DHCP, IP range 192.168.50.2 -- 192.168.50.254, lease time 1d
Apr 15 18:09:01 dnsmasq-dhcp[18800]: DHCPv6 stateless on br0
Apr 15 18:09:01 dnsmasq-dhcp[18800]: router advertisement on br0
Apr 15 18:09:01 dnsmasq-dhcp[18800]: DHCPv6 stateless on 2600:6c51:fake:n00b::, constructed for br0
Apr 15 18:09:01 dnsmasq-dhcp[18800]: router advertisement on 2600:6c51:fake:n00b::, constructed for br0
Apr 15 18:09:01 dnsmasq-dhcp[18800]: DHCPv6 stateless on br1
Apr 15 18:09:01 dnsmasq-dhcp[18800]: router advertisement on br1
Apr 15 18:09:01 dnsmasq-dhcp[18800]: DHCPv6 stateless on 2600:6c51:fake:n00b::, constructed for br1
Apr 15 18:09:01 dnsmasq-dhcp[18800]: router advertisement on 2600:6c51:fake:n00c::, constructed for br1
Apr 15 18:09:01 dnsmasq-dhcp[18800]: IPv6 router advertisement enabled
# ... output omitted ...

With Prefix Delegation

Otherwise, if you want cascading router to get IPv6 prefix automatically through PD, 6relayd is required as a minimalistic DHCPv6 PD server.

Note: Don’t add DHCPv6 range and options to /jffs/configs/dnsmasq.conf.add if you wish to use 6relayd.

In the sever mode of 6realyd8:

If there are non-local addresses assigned to the slave interface when a router solicitation is received, said prefixes are announced automatically for stateless autoconfiguration and also offered via stateful DHCPv6. If all prefixes are bigger than /64 all but the first /64 of these prefixes is offered via DHCPv6-PD to downstream routers.

… that is what we want. We’ll assign a /63 subnet for br1, and 6relayd will delegate a /64 subnet for the cascading router.

First, assign a 63 subnet for br1 (here we use the sibling /63 subnet of br0):

# Assign the sibling /63 subnet of br0 to br1.
# Use a IPv6 subnet tool for the `/63` subnet range on `br0`.
# Replace 2600:6c51:fake:n00c::1 with yours.
ip -6 addr add 2600:6c51:fake:n00c::1/63 dev br1

Then, run 6relayd -v -d -S . br1 for DHCPv6 PD server:

# Automatic DHCPv6 server to delegate prefix on br1 in daemon
6relayd -v -d -S . br1

If there are incoming DHCPv6 requests, system log (cat /tmp/syslog.log | grep 6relayd) should show:

# [email protected]:/# cat /tmp/syslog.log | grep 6relayd
Apr 15 18:20:00 6relayd[1765]: Got DHCPv6 request
Apr 15 18:20:01 6relayd[1765]: Got DHCPv6 request

… which means 6relayd is working properly.

Now check the System Log -> IPv6 page in the cascading router (here my old AC68P is the cascading one):

IPv6 Connection Type Native with DHCP-PD
WAN IPv6 Address 2600:6c51:fake:n00c::fake
WAN IPv6 Gateway fe80::aa5e:45ff:fe00:fake
LAN IPv6 Address 2600:6c51:fake:n00d::1/64
LAN IPv6 Link-Local Address fe80::a62:66ff:fe01:fake/64
DHCP-PD Enabled
LAN IPv6 Prefix 2600:6c51:fake:n00d::/64
DNS Address 2600:6c51:fake:n00c::1

If everything is good, LAN IPv6 Prefix should show 2600:6c51:fake:n00d::/64.

Tip: Automatic script for setting up DHCPv6 PD can be found in Scripts & Configs: /jffs/scripts/dhcpc-event.

Scripts & Configs

In order to apply all settings after reboot, save the following scripts and configs into corresponding folders.

Note: Don’t forget set Enable JFFS custom scripts and configs to Yes in Administration -> System.

/jffs/scripts/services-start

#!/bin/sh

# Make sure the script is indeed invoked
touch /tmp/000-services-start

# Physical port to interface map:
# eth0   WAN
# eth1   LAN 4
# eth2   LAN 3
# eth3   LAN 2
# eth4   LAN 1
# eth5   Bridge of LAN 5, LAN 6, LAN 7, LAN 8
# eth6   2.4 GHz Radio
# eth7   5 GHz Radio

# Delete those interfaces that we want to isolate from br0
logger -t "isolate_port" "services-start: deleting LAN 2 (eth3) from br0"
brctl delif br0 eth3

# Create a new bridge br1 for isolated interfaces
logger -t "isolate_port" "services-start: creating br1 with LAN 2 (eth3)"
brctl addbr br1
brctl stp br1 on # STP to prevent bridge loops
brctl addif br1 eth3

# Set up the IPv4 address for br1
# Here we set the subnet to be 192.168.150.0/24
# IPv6 link local address will be assigned automatically
logger -t "isolate_port" "services-start: setting up IPv4 address for br1"
ifconfig br1 192.168.150.1 netmask 255.255.255.0
ifconfig br1 allmulti up

logger -t "isolate_port" "services-start: all done"
date >> /tmp/000-services-start

/jffs/scripts/firewall-start

#!/bin/sh

# Make sure the script is indeed invoked
touch /tmp/000-firewall-start
logger -t "isolate_port" "firewall-start: applying INPUT rules for br1"

# Allow new incoming connections from br1
iptables -I INPUT -i br1 -m state --state NEW -j ACCEPT
ip6tables -I INPUT -i br1 -j ACCEPT # Same rule as br0 by default
ip6tables -I INPUT -i br1 -m state --state NEW -j ACCEPT

# Only forbid br1 access the web UI and SSH of the main router
iptables -I INPUT -i br1 -p tcp --dport 80 -j DROP
iptables -I INPUT -i br1 -p tcp --dport 22 -j DROP
ip6tables -I INPUT -i br1 -p tcp --dport 80 -j DROP
ip6tables -I INPUT -i br1 -p tcp --dport 22 -j DROP

logger -t "isolate_port" "firewall-start: applying FORWARD rules for br1"

# Forbid packets from br1 to be forwarded to other interfaces
iptables -I FORWARD -i br1 -j DROP
ip6tables -I FORWARD -i br1 -j DROP

# But allow packet forwarding inside br1
iptables -I FORWARD -i br1 -o br1 -j ACCEPT
ip6tables -I FORWARD -i br1 -o br1 -j ACCEPT

# Allow packet forwarding between br1 and eth0 (WAN)
iptables -I FORWARD -i br1 -o eth0 -j ACCEPT
ip6tables -I FORWARD -i br1 -o eth0 -j ACCEPT

# Allow one-way traffic from br0 to br1
iptables -I FORWARD -i br0 -o br1 -j ACCEPT
iptables -I FORWARD -i br1 -o br0 -m state \
  --state RELATED,ESTABLISHED -j ACCEPT
ip6tables -I FORWARD -i br0 -o br1 -j ACCEPT
ip6tables -I FORWARD -i br1 -o br0 -m state \
  --state RELATED,ESTABLISHED -j ACCEPT

logger -t "isolate_port" "firewall-start: all done"
date >> /tmp/000-firewall-start

/jffs/scripts/nat-start

#!/bin/sh

# Make sure the script is indeed invoked
touch /tmp/000-nat-start
logger -t "isolate_port" "nat-start: applying POSTROUTING rules for br1"

# NAT inside 192.168.150.0/24 on br1
iptables -t nat -A POSTROUTING -s 192.168.150.0/24 -d 192.168.150.0/24 \
  -o br1 -j MASQUERADE

logger -t "isolate_port" "nat-start: all done"
date >> /tmp/000-nat-start

/jffs/scripts/wan-event

This script is only for IPv6.

#!/bin/sh

PD_PREFIX=56 # Change to which works for your ISP
RETRY=10

# Only run for connected event (the old wan-start)
if [ ! "$2" = "connected" ]; then
  exit 0
fi

# Make sure the script is indeed invoked
touch /tmp/000-wan-event-connected

# ipv6_service = dhcp6 means Connection Type: Native, 
# also we require DHCP PD to be enabled
if [ "$(nvram get ipv6_service)" = "dhcp6" ] && \
   [ "$(nvram get ipv6_dhcp_pd)" = "1" ]; then

  # Try to find odhcp6c
  i=0;
  while [ $i -lt $RETRY ]; do
    # Get odhcp6c's pid. If there are multiple instances (unlikely), 
    # we use the smallest one.
    PID=$(pidof odhcp6c | tr ' ' '\n' | head -n 1)
    CMDLINE=/proc/$PID/cmdline

    # Found odhcp6c?
    if [ ! -z "$PID" ] && [ -f "$CMDLINE" ]; then
      COMMAND="$(tr '\0' ' ' < /proc/$PID/cmdline)"
      logger -t "isolate_port" "wan-event[connected]:" \
        "found odhcp6c, PID: $PID, command: $COMMAND"

      # The first 11 chars should be "odhcp6c -df"
      PREFIX=$(echo $COMMAND | cut -c1-11)

      if [ "$PREFIX" = "odhcp6c -df" ]; then
        # There is a space between "odhcp6c -df" and remaining arguments.
        # So we start from the 13rd char.
        ARGS=$(echo $COMMAND | cut -c13-)

        # Check if arguments start with -P$PD_PREFIX
        ARG1=$(echo $ARGS | cut -c1-$(expr length "$PD_PREFIX" + 2))
        if [ ! "$ARG1" = "-P$PD_PREFIX" ]; then
          # Prefix length (-P$PD_PREFIX)
          COMMAND="odhcp6c -df -P$PD_PREFIX $ARGS"
          logger -t "isolate_port" "wan-event[connected]:" \
            "re-run odhcp6c with prefix hint $PD_PREFIX: $COMMAND"

          killall odhcp6c
          eval $COMMAND
        else
          logger -t "isolate_port" "wan-event[connected]:" \
            "odhcp6c already started with prefix hint $PD_PREFIX"
        fi
      else
        logger -t "isolate_port" "wan-event[connected]:" \
          "odhcp6c command prefix mismatch!" \
          "found '$PREFIX', expects 'odhcp6c -df'"
      fi

      # We break from here once found the `odhcp6c` process
      break
    else
      i=$(($i+1))
      logger -t "isolate_port" "wan-event[connected]:" \
        "odhcp6c not found ($i/$RETRY)"

      # Hope we're lucky next time
      sleep 1
    fi
  done
else
  logger -t "isolate_port" "wan-event[connected]: DHCPv6 PD not enabled"
fi

logger -t "isolate_port" "wan-event[connected]: all done"
date >> /tmp/000-wan-event-connected

/jffs/scripts/dhcpc-event

This script is only for IPv6.

#!/bin/sh

link_local_ipv6() {
  # Get IPv6 link local address from br0 (with the prefix length part)
  echo $(ip -6 addr show br0 scope link |\
    sed -e's/^.*inet6 \([^ ]*\).*$/\1/;t;d')
}

setup_br1() {
  # ipv6_service = dhcp6 means Connection Type: Native, 
  # also we require DHCP PD to be enabled
  if [ "$(nvram get ipv6_service)" = "dhcp6" ] && \
     [ "$(nvram get ipv6_dhcp_pd)" = "1" ]; then
    
    # Wait for udhcpc (udhcpc.c: bound6 -> add_ip6_lanaddr). 
    # We need to the updated ipv6 prefix from nvram.
    sleep 2

    # Allocated IPv6 prefix from ISP
    PREFIX=$(nvram get ipv6_prefix)

    if [ -z "$PREFIX" ]; then
      logger -t "isolate_port" "dhcpc-event: empty IPv6 prefix"
    else
      PD_PREFIX=$(nvram get ipv6_prefix_length)
      if [ "$PD_PREFIX" -ge 63 ]; then
        # We need at least 3 /64 subnets, /63 only gives 2 /64 subnet
        logger -t "isolate_port" "dhcpc-event: IPv6 prefix" \
          "(/$PD_PREFIX) is too long, at least /62 requried"
      else
        if [ "$PD_PREFIX" -le 48 ]; then
          # Shorter than /49? 
          # br1 will on a:b:c:2::/63 (br0 is on a:b:c:0::/64)
          BR1_PREFIX="$(echo $PREFIX | cut -d':' -f1-3):2::"
        else
          # /49 to /62? 
          # br1 will on a:b:c:dddf::/63 (br0 is on a:b:c:dddd::/64)
          BR0_NET_ID=$(echo $PREFIX | cut -d':' -f4)
          BR1_NET_ID=$(printf "%x" $((0x$BR0_NET_ID + 0x2)))
          BR1_PREFIX="$(echo $PREFIX | cut -d':' -f1-3):${BR1_NET_ID}::"
        fi

        # Clean up br0
        ip -6 route flush dev br0
        ip -6 addr flush dev br0 scope global

        # Assign br0 with the first /64 subnet (instead the /56 one)
        # ipv6_rtr_addr is the default router's IPv6 address 
        logger -t "isolate_port" "dhcpc-event: set prefix" \
          "$(nvram get ipv6_rtr_addr)/64 to br0"
        ip -6 addr add "$(nvram get ipv6_rtr_addr)/64" dev br0

        # Clean up br1 (note we also remove link local address)
        ip -6 route flush dev br1
        ip -6 addr flush dev br1

        # Re-add link local address
        # So route to fe80::/64 will be added back
        # Note this is VERY important for 6relayd
        # Otherwise 6relayd will throw network unreachable error
        # Because route to fe80::/64 doesn't exist
        ip -6 addr add $(link_local_ipv6) dev br1

        # Assign br1 with a /63 subnet, so the cascading router 
        # will be on the first /64 subnet and it will also get
        # the second /64 prefix via DHCPv6-PD by 6relayd
        logger -t "isolate_port" "dhcpc-event: set prefix" \
          "${BR1_PREFIX}1/63 to br1"
        ip -6 addr add "${BR1_PREFIX}1/63" dev br1

        # Re-run 6relayd
        logger -t "isolate_port" "dhcpc-event: re-run 6relayd on br1"
        killall 6relayd

        # Automatic DHCPv6 server to delegate prefix on br1 in daemon
        6relayd -v -d -S . br1
      fi
    fi
  fi
}

teardown_br1() {
  # ipv6_service = dhcp6 means Connection Type: Native,
  # also we require DHCP PD to be enabled
  if [ "$(nvram get ipv6_service)" = "dhcp6" ] && \
     [ "$(nvram get ipv6_dhcp_pd)" = "1" ]; then
     
    logger -t "isolate_port" "dhcpc-event:" \
      "flush IPv6 route and IPv6 address on br1"

    # br0 will be handled by udhcpc, 
    # we only need to care about br1
    # Note we also remove link local address
    ip -6 route flush dev br1
    ip -6 addr flush dev br1

    # Re-add link local address
    # So route to fe80::/64 will be added back
    # Note this is VERY important for 6relayd
    # Otherwise 6relayd will throw network unreachable error
    # Because route to fe80::/64 doesn't exist
    ip -6 addr add $(link_local_ipv6) dev br1
  fi
}

# Make sure the script is indeed invoked
touch /tmp/000-dhcpc-event

# Adapted from odhcp6c-example-script.sh
(
  flock 9
  case "$1" in
    bound)
      teardown_br1
      setup_br1
    ;;
    informed|updated|rebound|ra-updated)
      setup_br1
    ;;
    stopped|unbound)
      teardown_br1
    ;;
    started)
      teardown_br1
    ;;
  esac
) 9>/tmp/odhcp6c.lock.br1
rm -f /tmp/odhcp6c.lock.br1

date >> /tmp/000-dhcpc-event

/jffs/configs/dnsmasq.conf.add

If you need DHCPv6-PD (with 6relayd) for cascading router, or if you don’t care about IPv6:

interface=br1
# DHCPv4 range: 192.168.150.2 - 192.168.150.254, netmask: 255.255.255.0
# DHCPv4 lease time: 86400s (1 day)
dhcp-range=br1,192.168.150.2,192.168.150.254,255.255.255.0,86400s
# DHCPv4 router (option 3): 192.168.150.1
dhcp-option=br1,3,192.168.150.1

Otherwise, DHCPv6 is done by dnsmasq on br1:

interface=br1
# DHCPv4 range: 192.168.150.2 - 192.168.150.254, netmask: 255.255.255.0
# DHCPv4 lease time: 86400s (1 day)
dhcp-range=br1,192.168.150.2,192.168.150.254,255.255.255.0,86400s
# DHCPv4 router (option 3): 192.168.150.1
dhcp-option=br1,3,192.168.150.1
# DHCPv6 RA interval: 10s, router lifetime: 600s
ra-param=br1,10,600
# DHCPv6 range: whole subnet, constructing from br1's prefix
# DHCPv6 prefix length: 64, mode: Stateless DHCPv6
# DHCPv6 lease time: 600s (10 minutes)
dhcp-range=br1,::,constructor:br1,ra-stateless,64,600
# DHCPv6 DNS (option 23): inherit from the router
dhcp-option=br1,option6:23,[::]

References