Skip to content

Instantly share code, notes, and snippets.

@c2h2
Created July 13, 2026 01:57
Show Gist options
  • Select an option

  • Save c2h2/ae07a4c818457b4cf3f087bf164059f8 to your computer and use it in GitHub Desktop.

Select an option

Save c2h2/ae07a4c818457b4cf3f087bf164059f8 to your computer and use it in GitHub Desktop.
Photonicat 2 (OpenWrt): one-shot script to enable IPv6 on a PPPoE WAN — automates https://photonicat.com/wiki/Photoincat_2_IPv6_PPPoE (WAN ipv6=auto, LAN ip6assign=60, hybrid RA/DHCPv6, docker ip6tables fix, verification)
#!/bin/sh
# pcat2_pppoe_ipv6.sh — Enable IPv6 over a PPPoE WAN on Photonicat 2 (OpenWrt)
#
# Automates the manual steps from:
# https://photonicat.com/wiki/Photoincat_2_IPv6_PPPoE
#
# What it does:
# 1. WAN : network.wan.ipv6='auto' (request IPv6 address + delegated prefix
# over the PPPoE session), disables the legacy wan6 interface
# 2. LAN : network.lan.ip6assign='60', removes ip6class (the "ULA-only" trap)
# 3. RA : dhcp.lan.ra='hybrid', dhcp.lan.dhcpv6='hybrid'
# (optional: --ra-high sets RA preference to high for multi-router LANs)
# 4. Docker (26.04+): adds "ip6tables": false to /etc/docker/daemon.json,
# keeping all other keys untouched
# 5. Verifies prefix delegation and IPv6 connectivity
#
# The firewall needs no changes: fw4 already allows LAN->WAN IPv6 forwarding
# and the required ICMPv6 by default.
#
# Usage — run AS ROOT in a terminal ON THE PHOTONICAT 2 (ssh root@192.168.8.1):
# sh pcat2_pppoe_ipv6.sh [--ra-high] [--force] [--no-ping]
#
# --ra-high also set dhcp.lan.ra_preference='high' (multi-router networks)
# --force proceed even if the WAN protocol is not 'pppoe'
# --no-ping skip the final IPv6 connectivity test
#
# Config is backed up to /tmp/pcat2_ipv6_backup_<timestamp>/ before any change.
# NOTE: applying the change briefly restarts the PPPoE session (a few seconds
# of internet downtime).
set -u
# ---------- pretty output ----------------------------------------------------
if [ -t 1 ]; then
C_G='\033[1;32m'; C_Y='\033[1;33m'; C_R='\033[1;31m'; C_B='\033[1m'; C_0='\033[0m'
else
C_G=''; C_Y=''; C_R=''; C_B=''; C_0=''
fi
ok() { printf "${C_G}[ ok ]${C_0} %s\n" "$1"; }
warn() { printf "${C_Y}[warn]${C_0} %s\n" "$1"; }
fail() { printf "${C_R}[fail]${C_0} %s\n" "$1" >&2; }
info() { printf "${C_B}[....]${C_0} %s\n" "$1"; }
die() { fail "$1"; exit 1; }
usage() {
sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
}
# ---------- options ----------------------------------------------------------
RA_HIGH=0; FORCE=0; DO_PING=1
for arg in "$@"; do
case "$arg" in
--ra-high) RA_HIGH=1 ;;
--force) FORCE=1 ;;
--no-ping) DO_PING=0 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $arg (try --help)" ;;
esac
done
# ---------- sanity checks ----------------------------------------------------
command -v uci >/dev/null 2>&1 || \
die "uci not found. Run this ON the Photonicat 2 itself (ssh root@192.168.8.1), not on your PC."
[ "$(id -u)" = "0" ] || die "must run as root"
WAN_PROTO=$(uci -q get network.wan.proto) || WAN_PROTO=""
[ -n "$WAN_PROTO" ] || die "no 'wan' interface found in /etc/config/network"
if [ "$WAN_PROTO" != "pppoe" ] && [ "$FORCE" != "1" ]; then
fail "WAN protocol is '$WAN_PROTO', not 'pppoe'."
fail "This script is written for PPPoE WAN. A DHCP WAN normally gets IPv6"
fail "through the separate 'wan6' interface, which this script disables."
die "Re-run with --force only if you know what you are doing."
fi
HAVE_JSONFILTER=0
command -v jsonfilter >/dev/null 2>&1 && HAVE_JSONFILTER=1
printf '\n'
info "Photonicat 2 — IPv6 over PPPoE setup"
info "Wiki: https://photonicat.com/wiki/Photoincat_2_IPv6_PPPoE"
info "Current config: wan.proto=$WAN_PROTO wan.ipv6=$(uci -q get network.wan.ipv6 || echo '(unset)') lan.ip6assign=$(uci -q get network.lan.ip6assign || echo '(unset)') lan.ip6class=$(uci -q get network.lan.ip6class || echo '(unset)')"
printf '\n'
# ---------- backup -----------------------------------------------------------
STAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/tmp/pcat2_ipv6_backup_$STAMP"
mkdir -p "$BACKUP_DIR" || die "cannot create backup dir $BACKUP_DIR"
cp /etc/config/network "$BACKUP_DIR/network" || die "backup of /etc/config/network failed"
cp /etc/config/dhcp "$BACKUP_DIR/dhcp" || die "backup of /etc/config/dhcp failed"
[ -f /etc/docker/daemon.json ] && cp /etc/docker/daemon.json "$BACKUP_DIR/daemon.json"
ok "Backed up config to $BACKUP_DIR"
# ---------- step 1: WAN (PPPoE) ----------------------------------------------
uci set network.wan.ipv6='auto'
ok "WAN: request IPv6 address + delegated prefix over PPPoE (network.wan.ipv6='auto')"
if uci -q get network.wan6 >/dev/null; then
uci set network.wan6.auto='0'
ok "WAN: disabled legacy wan6 interface (network.wan6.auto='0')"
fi
# ---------- step 2: LAN --------------------------------------------------------
uci set network.lan.ip6assign='60'
ok "LAN: assign a /60 from the delegated prefix (network.lan.ip6assign='60')"
if uci -q get network.lan.ip6class >/dev/null; then
uci -q delete network.lan.ip6class
ok "LAN: removed ip6class — with ip6class='local' the LAN only ever gets ULA (fd00::/8) and clients never reach the IPv6 internet"
fi
# ---------- step 3: RA / DHCPv6 (SLAAC) ----------------------------------------
uci set dhcp.lan.ra='hybrid'
uci set dhcp.lan.dhcpv6='hybrid'
ok "LAN: router advertisements + DHCPv6 in hybrid mode"
if [ "$RA_HIGH" = "1" ]; then
uci set dhcp.lan.ra_preference='high'
ok "LAN: RA preference set to high (--ra-high)"
fi
# ---------- apply ---------------------------------------------------------------
CH_NET=$(uci changes network)
CH_DHCP=$(uci changes dhcp)
if [ -z "$CH_NET" ] && [ -z "$CH_DHCP" ]; then
ok "Configuration was already correct — nothing to commit, skipping network restart"
else
if [ -n "$CH_NET" ]; then
printf '\n'; info "Applying network changes:"
printf '%s\n' "$CH_NET" | sed 's/^/ /'
uci commit network
warn "Restarting WAN — the PPPoE session will drop for a few seconds..."
ifup wan
ifup lan
fi
if [ -n "$CH_DHCP" ]; then
printf '\n'; info "Applying DHCP/RA changes:"
printf '%s\n' "$CH_DHCP" | sed 's/^/ /'
uci commit dhcp
/etc/init.d/odhcpd reload
fi
ok "Configuration committed and applied"
fi
# ---------- step 4: Docker ip6tables fix (26.04+) --------------------------------
DAEMON_JSON="/etc/docker/daemon.json"
if [ -x /etc/init.d/docker ]; then
DOCKER_CHANGED=0
if [ -f "$DAEMON_JSON" ] && grep -q '"ip6tables"' "$DAEMON_JSON"; then
ok "Docker: $DAEMON_JSON already sets \"ip6tables\" — leaving it alone"
elif [ ! -f "$DAEMON_JSON" ] || ! grep -q '"' "$DAEMON_JSON"; then
mkdir -p /etc/docker
printf '{\n "ip6tables": false\n}\n' > "$DAEMON_JSON"
DOCKER_CHANGED=1
elif command -v python3 >/dev/null 2>&1; then
# Safest path: merge the key without touching data-root or anything else
if python3 - "$DAEMON_JSON" <<'PYEOF'
import json, sys
path = sys.argv[1]
with open(path) as f:
cfg = json.load(f)
cfg["ip6tables"] = False
with open(path, "w") as f:
json.dump(cfg, f, indent=2)
f.write("\n")
PYEOF
then
DOCKER_CHANGED=1
else
[ -f "$BACKUP_DIR/daemon.json" ] && cp "$BACKUP_DIR/daemon.json" "$DAEMON_JSON"
warn "Docker: could not parse $DAEMON_JSON — restored backup."
warn "Docker: add \"ip6tables\": false to it by hand, then: /etc/init.d/docker restart"
fi
else
# No python3: insert the key right after the opening brace, keep the rest
awk '!done && sub(/\{/, "{\n \"ip6tables\": false,") { done = 1 } { print }' \
"$DAEMON_JSON" > "$DAEMON_JSON.tmp" && mv "$DAEMON_JSON.tmp" "$DAEMON_JSON"
if grep -q '"ip6tables"' "$DAEMON_JSON"; then
DOCKER_CHANGED=1
else
warn "Docker: automatic edit of $DAEMON_JSON failed."
warn "Docker: add \"ip6tables\": false to it by hand, then: /etc/init.d/docker restart"
fi
fi
if [ "$DOCKER_CHANGED" = "1" ]; then
ok "Docker: set \"ip6tables\": false in $DAEMON_JSON (other keys untouched)"
if /etc/init.d/docker running 2>/dev/null; then
info "Docker: restarting daemon..."
/etc/init.d/docker restart
else
info "Docker: daemon not running — change applies on next start"
fi
fi
else
info "Docker not installed — skipping the docker ip6tables fix"
fi
# ---------- step 5: verify -------------------------------------------------------
wan6_prefix() {
if [ "$HAVE_JSONFILTER" = "1" ]; then
ifstatus wan_6 2>/dev/null | jsonfilter -e '@["ipv6-prefix"][0].address' 2>/dev/null
else
ifstatus wan_6 2>/dev/null | sed -n '/"ipv6-prefix"/,/\]/p' | \
grep '"address"' | head -n1 | cut -d'"' -f4
fi
}
wan6_prefix_mask() {
if [ "$HAVE_JSONFILTER" = "1" ]; then
ifstatus wan_6 2>/dev/null | jsonfilter -e '@["ipv6-prefix"][0].mask' 2>/dev/null
else
ifstatus wan_6 2>/dev/null | sed -n '/"ipv6-prefix"/,/\]/p' | \
grep '"mask"' | head -n1 | tr -cd '0-9'
fi
}
lan_assigned() {
if [ "$HAVE_JSONFILTER" = "1" ]; then
ifstatus lan 2>/dev/null | jsonfilter -e '@["ipv6-prefix-assignment"][*].address' 2>/dev/null
else
ifstatus lan 2>/dev/null | grep -A20 '"ipv6-prefix-assignment"' | \
grep '"address"' | cut -d'"' -f4
fi
}
printf '\n'
info "Waiting for the ISP to delegate an IPv6 prefix (up to 60 s)..."
WAITED=0
PREFIX=""
while [ "$WAITED" -lt 60 ]; do
PREFIX=$(wan6_prefix)
[ -n "$PREFIX" ] && break
sleep 2
WAITED=$((WAITED + 2))
done
RESULT=0
if [ -n "$PREFIX" ]; then
ok "Got delegated prefix: $PREFIX/$(wan6_prefix_mask)"
else
fail "No delegated IPv6 prefix on wan_6 after 60 s."
fail "Check: is the PPPoE session up? ifstatus wan"
fail "Check: DHCPv6 solicit log: logread | grep -i -e dhcpv6 -e odhcpd"
fail "Your ISP may not offer IPv6 on this line, or may need it enabled on the account."
RESULT=1
fi
GLOBAL_ON_LAN=0
for p in $(lan_assigned); do
case "$p" in
fd*|fc*) ;; # ULA — doesn't count
*) GLOBAL_ON_LAN=1
ok "LAN (br-lan) holds global prefix: $p"
;;
esac
done
if [ "$GLOBAL_ON_LAN" = "0" ] && [ "$RESULT" = "0" ]; then
warn "LAN has no global IPv6 prefix yet (only ULA). It can take a few more"
warn "seconds — check later with: ifstatus lan | grep -A8 ipv6-prefix-assignment"
fi
if [ "$DO_PING" = "1" ] && [ "$RESULT" = "0" ]; then
info "Testing IPv6 connectivity (ping photonicat.com)..."
if ping -6 -c 3 -W 5 photonicat.com >/dev/null 2>&1 || \
ping6 -c 3 photonicat.com >/dev/null 2>&1; then
ok "IPv6 internet is reachable (photonicat.com answered over IPv6)"
elif ping -6 -c 3 -W 5 2400:3200::1 >/dev/null 2>&1 || \
ping6 -c 3 2400:3200::1 >/dev/null 2>&1; then
warn "IPv6 routing works (2400:3200::1 answers) but the DNS test target"
warn "did not — likely just missing AAAA/DNS, not a connectivity problem."
else
fail "IPv6 ping failed. Wait ~30 s and retry: ping -6 photonicat.com"
RESULT=1
fi
fi
# ---------- summary -------------------------------------------------------------
printf '\n'
if [ "$RESULT" = "0" ]; then
ok "Done. Devices on the LAN should now get global IPv6 addresses."
info "Check on a client: ip -6 addr show and ping -6 photonicat.com"
info "(Reconnect Wi-Fi clients if they don't pick up an address right away.)"
else
warn "Configuration was applied, but verification did not (yet) succeed."
warn "PPPoE + prefix delegation can take a minute; re-run this script to re-check."
fi
info "Backup kept at $BACKUP_DIR — to restore:"
info " cp $BACKUP_DIR/network /etc/config/network"
info " cp $BACKUP_DIR/dhcp /etc/config/dhcp"
[ -f "$BACKUP_DIR/daemon.json" ] && info " cp $BACKUP_DIR/daemon.json /etc/docker/daemon.json"
info " then: ifup wan; ifup lan; /etc/init.d/odhcpd reload"
exit "$RESULT"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment