diff --git a/.woodpecker/coredns-build.yaml b/.woodpecker/coredns-build.yaml new file mode 100644 index 0000000..c78545f --- /dev/null +++ b/.woodpecker/coredns-build.yaml @@ -0,0 +1,46 @@ +when: + - event: push + path: + include: + - mikrotik/coredns/** + +steps: + - name: Get registry creds from OpenBao + image: quay.io/openbao/openbao:2.5.4 + environment: + VAULT_ADDR: https://openbao.lumpiasty.xyz:8200 + ROLE_ID: + from_secret: renovate_role_id + SECRET_ID: + from_secret: renovate_secret_id + commands: + - bao write -field token auth/approle/login + role_id=$ROLE_ID + secret_id=$SECRET_ID > /woodpecker/.vault_id + - export VAULT_TOKEN=$(cat /woodpecker/.vault_id) + - 'printf "PLUGIN_USERNAME=%s\n" "$(bao kv get -mount secret -field REGISTRY_USERNAME container-registry)" > /woodpecker/registry.env' + - 'printf "PLUGIN_PASSWORD=%s\n" "$(bao kv get -mount secret -field REGISTRY_PASSWORD container-registry)" >> /woodpecker/registry.env' + + - name: Build and push + image: woodpeckerci/plugin-docker-buildx:6.1.0 + privileged: true + settings: + registry: gitea.lumpiasty.xyz + repo: gitea.lumpiasty.xyz/lumpiasty/coredns-mikrotik + platforms: linux/arm64 + tags: + - latest + - ${CI_COMMIT_SHA:0:8} + dockerfile: mikrotik/coredns/Dockerfile + context: mikrotik/coredns/ + env_file: /woodpecker/registry.env + + - name: Invalidate OpenBao token + image: quay.io/openbao/openbao:2.5.4 + environment: + VAULT_ADDR: https://openbao.lumpiasty.xyz:8200 + commands: + - export VAULT_TOKEN=$(cat /woodpecker/.vault_id) + - bao write -f auth/token/revoke-self + when: + - status: [success, failure] diff --git a/ansible/roles/routeros/tasks/addressing.yml b/ansible/roles/routeros/tasks/addressing.yml index 18a2ced..1167c11 100644 --- a/ansible/roles/routeros/tasks/addressing.yml +++ b/ansible/roles/routeros/tasks/addressing.yml @@ -6,6 +6,9 @@ - address: 172.20.0.1/24 interface: containers network: 172.20.0.0 + - address: 192.168.239.1/30 + interface: nat64 + network: 192.168.239.0 - address: 192.168.4.1/24 interface: lo network: 192.168.4.0 @@ -37,6 +40,10 @@ community.routeros.api_modify: path: ipv6 address data: + - address: fc64::1/126 + advertise: false + comment: nat64 loopback + interface: nat64 - address: 2001:470:70:dd::2/64 advertise: false interface: sit1 diff --git a/ansible/roles/routeros/tasks/base.yml b/ansible/roles/routeros/tasks/base.yml index a67fe19..a02b566 100644 --- a/ansible/roles/routeros/tasks/base.yml +++ b/ansible/roles/routeros/tasks/base.yml @@ -6,6 +6,7 @@ - name: bridge1 vlan-filtering: true - name: containers + - name: nat64 handle_absent_entries: remove handle_entries_content: remove_as_much_as_possible @@ -65,6 +66,12 @@ - bridge: containers interface: veth-tailscale comment: Tailscale container interface + - bridge: containers + interface: veth-coredns + comment: CoreDNS container interface + - bridge: nat64 + interface: veth-tayga + comment: Tayga NAT64 container interface - bridge: bridge1 interface: ether1 pvid: 2 @@ -188,7 +195,8 @@ values: allow-remote-requests: true cache-size: 20480 - servers: 1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001 + # CoreDNS container provides DNS64; it forwards upstream to 1.1.1.1/8.8.8.8. + servers: 172.20.0.3 - name: Configure DNS static entries community.routeros.api_modify: @@ -244,6 +252,46 @@ handle_absent_entries: remove handle_entries_content: remove_as_much_as_possible +- name: Configure DHCP server options (IPv6-only preferred, RFC 8925) + community.routeros.api_modify: + path: ip dhcp-server option + data: + # Option 108: 32-bit seconds timer telling capable clients to disable IPv4. + # 0x00015180 = 86400 seconds (1 day). + # Quoted to prevent YAML from parsing the hex literal as integer 86400. + - name: v6only-preferred + code: 108 + value: "0x00015180" + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + +- name: Configure DHCP server option sets + community.routeros.api_modify: + path: ip dhcp-server option sets + data: + - name: v6only-set + options: v6only-preferred + handle_absent_entries: remove + handle_entries_content: remove_as_much_as_possible + +- name: Attach option 108 to LAN DHCP server + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: ip dhcp-server + find: + name: dhcp1 + values: + dhcp-option-set: v6only-set + +- name: Attach option 108 to IOT DHCP server + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: ip dhcp-server + find: + name: dhcp3 + values: + dhcp-option-set: v6only-set + - name: Configure IPv6 ND defaults community.routeros.api_find_and_modify: ignore_dynamic: false @@ -252,3 +300,21 @@ default: true values: advertise-dns: true + +- name: Configure IPv6 ND pref64 on LAN (vlan2) + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: ipv6 nd + find: + interface: vlan2 + values: + pref64: 64:ff9b::/96 + +- name: Configure IPv6 ND pref64 on IOT (vlan5) + community.routeros.api_find_and_modify: + ignore_dynamic: false + path: ipv6 nd + find: + interface: vlan5 + values: + pref64: 64:ff9b::/96 diff --git a/ansible/roles/routeros/tasks/containers.yml b/ansible/roles/routeros/tasks/containers.yml index ca63958..c182e6d 100644 --- a/ansible/roles/routeros/tasks/containers.yml +++ b/ansible/roles/routeros/tasks/containers.yml @@ -24,11 +24,11 @@ handle_absent_entries: remove handle_entries_content: remove_as_much_as_possible -- name: Configure tailscale container +- name: Configure containers community.routeros.api_modify: path: container data: - - dns: 172.17.0.1 + - dns: 172.20.0.1 interface: veth-tailscale logging: true mountlists: tailscale_state @@ -36,5 +36,20 @@ remote-image: gitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscale:stable root-dir: tailscale/root start-on-boot: true + - dns: 172.20.0.1 + interface: veth-coredns + logging: true + name: coredns + remote-image: gitea.lumpiasty.xyz/lumpiasty/coredns-mikrotik:latest + root-dir: coredns/root + start-on-boot: true + # Tayga auto-configures from the veth addresses and routes — no env vars needed. + - interface: veth-tayga + logging: true + name: tayga + remote-image: ghcr.io/apalrd/tayga-nat64 + root-dir: tayga/root + start-on-boot: true + workdir: /app handle_absent_entries: remove handle_entries_content: remove_as_much_as_possible diff --git a/ansible/roles/routeros/tasks/firewall.yml b/ansible/roles/routeros/tasks/firewall.yml index 58f9da5..29ebcc2 100644 --- a/ansible/roles/routeros/tasks/firewall.yml +++ b/ansible/roles/routeros/tasks/firewall.yml @@ -67,6 +67,11 @@ chain: forward comment: Allow from containers to everywhere in-interface: containers + - action: accept + chain: forward + comment: Allow Tayga NAT64 pool to internet + out-interface: pppoe-gpon + src-address: 192.168.240.0/20 - action: jump chain: forward comment: Allow port forwards @@ -254,6 +259,11 @@ chain: srcnat comment: GPON ONT management dst-address: 192.168.100.1 + - action: masquerade + chain: srcnat + comment: Tayga NAT64 dynamic pool to internet + out-interface: pppoe-gpon + src-address: 192.168.240.0/20 - action: dst-nat chain: dstnat comment: TS3 @@ -375,6 +385,30 @@ dst-address: 2001:470:71:dd::/64 out-interface-list: wan reject-with: icmp-no-route + # Block NAT64-mapped RFC1918 destinations before any broad accept rules. + # Without these, NAT64 (64:ff9b::/96) could be used to reach private IPv4 + # ranges by encoding them in the prefix — bypassing IPv4 forward policy. + # 64:ff9b::a00:0/104 = 10.0.0.0/8 + # 64:ff9b::ac10:0/108 = 172.16.0.0/12 + # 64:ff9b::c0a8:0/112 = 192.168.0.0/16 + - action: reject + chain: forward + comment: Block NAT64 to RFC1918 (10/8) + dst-address: 64:ff9b::a00:0/104 + out-interface: nat64 + reject-with: icmp-no-route + - action: reject + chain: forward + comment: Block NAT64 to RFC1918 (172.16/12) + dst-address: 64:ff9b::ac10:0/108 + out-interface: nat64 + reject-with: icmp-no-route + - action: reject + chain: forward + comment: Block NAT64 to RFC1918 (192.168/16) + dst-address: 64:ff9b::c0a8:0/112 + out-interface: nat64 + reject-with: icmp-no-route - action: accept chain: forward comment: Allow from LAN to everywhere @@ -412,6 +446,11 @@ comment: Allow from IOT to internet only in-interface: vlan5 out-interface-list: wan + - action: accept + chain: forward + comment: Allow from IOT to internet via NAT64 + in-interface: vlan5 + out-interface: nat64 - action: accept chain: forward comment: Allow from OPENWRT UPLINK to internet only @@ -427,6 +466,9 @@ dst-address: 2001:470:61a3:500::/64 in-interface-list: wan out-interface: containers + # NAT64 to Tayga is now covered by the broad per-VLAN accept rules above. + # RFC1918-mapped destinations are blocked at the top of the chain before + # those broad accepts, so no separate per-source NAT64 rules are needed. - action: accept chain: forward comment: Allow tcp transmission port to LAN diff --git a/ansible/roles/routeros/tasks/routing.yml b/ansible/roles/routeros/tasks/routing.yml index 3281c6c..d8e9093 100644 --- a/ansible/roles/routeros/tasks/routing.yml +++ b/ansible/roles/routeros/tasks/routing.yml @@ -21,6 +21,15 @@ suppress-hw-offload: false target-scope: 10 vrf-interface: pppoe-gpon + - comment: Tayga NAT64 dynamic pool + disabled: false + distance: 1 + dst-address: 192.168.240.0/20 + gateway: 192.168.239.2 + routing-table: main + scope: 30 + suppress-hw-offload: false + target-scope: 10 handle_absent_entries: remove handle_entries_content: remove_as_much_as_possible @@ -41,6 +50,13 @@ pref-src: "" routing-table: main suppress-hw-offload: false + - comment: NAT64 prefix via Tayga + disabled: false + distance: 1 + dst-address: 64:ff9b::/96 + gateway: fc64::2%nat64 + routing-table: main + suppress-hw-offload: false handle_absent_entries: remove handle_entries_content: remove_as_much_as_possible diff --git a/ansible/roles/routeros/tasks/wan.yml b/ansible/roles/routeros/tasks/wan.yml index 3776174..a4f8e32 100644 --- a/ansible/roles/routeros/tasks/wan.yml +++ b/ansible/roles/routeros/tasks/wan.yml @@ -37,5 +37,16 @@ mac-address: 7E:7E:A1:B1:2A:7B name: veth-tailscale comment: Tailscale container + - address: 172.20.0.3/24 + dhcp: false + gateway: 172.20.0.1 + name: veth-coredns + comment: CoreDNS container + - address: 192.168.239.2/30,fc64::2/126 + dhcp: false + gateway: 192.168.239.1 + gateway6: fc64::1 + name: veth-tayga + comment: Tayga NAT64 container handle_absent_entries: remove handle_entries_content: remove_as_much_as_possible diff --git a/docs/coredns-nat64.md b/docs/coredns-nat64.md new file mode 100644 index 0000000..593361f --- /dev/null +++ b/docs/coredns-nat64.md @@ -0,0 +1,105 @@ +# CoreDNS DNS64 + NAT64 — design and implementation + +## Goal + +Replace the RouterOS built-in DNS forwarder with CoreDNS and implement IPv6-mostly networking (RFC 8925) using DNS64 + NAT64, allowing clients to phase out IPv4 while maintaining full connectivity to IPv4-only destinations. + +## Background + +The network uses Hurricane Electric as an IPv6 tunnel broker (`2001:470:61a3::/48`). HE assigns addresses from datacenter IP ranges, causing some websites to serve endless CAPTCHAs or flag connections as bot traffic. IPv6-mostly solves this differently: capable clients prefer IPv6 natively, and IPv4-only destinations are reached through NAT64 — using our own IPv4 WAN address rather than HE's, avoiding the datacenter flagging problem for those destinations. + +## How it works + +``` +IPv6-only client CoreDNS (DNS64) NAT64 (Tayga) + │ │ │ + │── AAAA? example.com ──────────▶│ │ + │ │── A? example.com ─────────▶ upstream + │ │◀── 93.184.216.34 ──────────│ + │◀── 64:ff9b::5db8:d822 ─────────│ (synthesized AAAA) │ + │ │ │ + │── TCP SYN to 64:ff9b::5db8:d822 ──────────────────────────▶│ + │ │ (RouterOS routes │ + │ │ 64:ff9b::/96 │ + │ │ to Tayga) │ + │ │ │── TCP SYN to 93.184.216.34 + │ │ │◀─ TCP SYN-ACK + │◀── TCP SYN-ACK (translated) ───────────────────────────────│ +``` + +For all destinations — including sites with real AAAA records — DNS64 overrides the response with a synthesized `64:ff9b::/96` address. All traffic routes through Tayga and exits on our own IPv4 WAN address, bypassing the HE tunnel broker. This eliminates the datacenter IP flagging and CAPTCHA loops that HE addresses trigger on some sites. + +## Components + +### CoreDNS (custom build) + +Built from source with 7 plugins instead of the default ~40, reducing the compressed image from ~20 MB to ~6-8 MB. This matters for fitting on the CRS internal flash. + +Plugin set: `errors`, `log`, `health`, `cache`, `dns64`, `forward`, `reload`. + +Plugin order in `plugin.cfg` determines execution order. `dns64` must come before `forward` so it can intercept AAAA responses from upstream rather than letting `forward` return them directly to the client. + +Source: [`mikrotik/coredns/`](../mikrotik/coredns/) + +The `dns64` plugin is built into CoreDNS — no external plugin needed. It performs the A→AAAA synthesis using the well-known prefix `64:ff9b::/96` (RFC 6052). + +`translate_all` and `allow_ipv4` are both set. Without `allow_ipv4`, the plugin only intercepts queries arriving over IPv6 — dual-stack clients querying CoreDNS over IPv4 (the common case, since the router forwards DNS via IPv4) would receive real AAAA records and use the HE tunnel instead of NAT64. + +| Client type | AAAA query handling | A query handling | +|---|---|---| +| IPv6-only (CLAT) | synthesized `64:ff9b::` → NAT64 path | not asked; client has no IPv4 stack | +| Dual-stack (no CLAT) | synthesized `64:ff9b::` → NAT64 path | `forward` returns real A → client uses IPv4 directly | +| IPv4-only (no IPv6) | synthesized `64:ff9b::` → client ignores it (no IPv6 stack), uses A record | `forward` returns real A → client uses IPv4 directly | + +IPv4-only clients receive synthesized AAAA records but their stack cannot use them — they fall back to A records normally. No breakage. + +### Tayga (NAT64) + +Stateless IP/ICMP translation (SIIT, RFC 7915). Receives IPv6 packets for `64:ff9b::/96`, strips the prefix to get the IPv4 destination, rewrites the packet headers, and routes it out as IPv4. Return traffic gets the inverse translation. + +RouterOS does not implement NAT64 natively (confirmed in official docs). The approach described in some blog posts of writing per-destination `/ipv6 firewall nat dst-nat` rules is not real NAT64 — it is static port forwarding and requires manually enumerating every destination. + +Official image: `ghcr.io/apalrd/tayga` — no custom build needed. + +### RouterOS + +Provides: +- Static IPv6 route `64:ff9b::/96 → Tayga` +- Masquerade of Tayga's IPv4 pool to WAN +- PREF64 option in Router Advertisements (`/ipv6/nd pref64`) +- DHCP option 108 to signal IPv6-only preference to capable clients + +## Client behaviour with DHCPv4 option 108 + +| Client OS | Behaviour | +|---|---| +| iOS 16+, macOS 13+ | Activates CLAT, drops IPv4, uses NAT64 for IPv4 literals | +| Android 10+ | Activates CLAT via PREF64, drops IPv4 | +| Windows 11 (preview) | Partial — CLAT support in preview as of 2026 | +| Linux (NetworkManager) | DHCP option 108 honoured; CLAT via NM requires PREF64 | +| Legacy/unaware devices | Ignore option 108, receive IPv4 lease normally, continue dual-stack | + +Option 108 value is a 32-bit seconds timer. Set to 28 (0x1c) for testing, 86400 for production. + +## CI/CD + +The Woodpecker pipeline at [`.woodpecker/coredns-build.yaml`](../.woodpecker/coredns-build.yaml) triggers on any push that touches `mikrotik/coredns/**`. It: + +1. Authenticates to OpenBao using the shared Renovate AppRole (`renovate_role_id` / `renovate_secret_id` Woodpecker secrets) +2. Fetches registry credentials from the `container-registry` KV secret (`REGISTRY_USERNAME` / `REGISTRY_PASSWORD`) +3. Builds the `linux/arm64` image using `docker buildx` +4. Pushes `latest` and a short-SHA tag to `gitea.lumpiasty.xyz//coredns-mikrotik` +5. Revokes the OpenBao token + +To update the CoreDNS version: change the `--branch` argument in the Dockerfile `git clone` line. + +## RouterOS deployment + +See [`mikrotik/README.md`](../mikrotik/README.md) for the full set of RouterOS commands. + +## Known limitations + +- **DNSSEC**: The `dns64` plugin does not validate DNSSEC on synthesized responses (upstream bug noted in the plugin docs). If DNSSEC is required, run a validating resolver upstream and disable synthesis for signed zones. +- **IPv4 literals**: Applications using hardcoded IPv4 addresses (e.g. `connect("1.2.3.4")`) cannot use DNS64. CLAT on the client handles this for capable OSes; legacy apps on non-CLAT clients will fail on IPv6-only VLANs. +- **Native IPv6 bypassed**: `translate_all` means no traffic uses native IPv6 directly — everything goes through Tayga. This is intentional; it trades native IPv6 performance for a consistent exit IP. If native IPv6 is ever desired for specific destinations, remove `translate_all` and handle the HE captcha problem differently (e.g. per-domain exceptions). +- **IPv6-only destinations (no A record)**: With `translate_all`, the plugin still attempts an A lookup for every AAAA query. If no A record exists, `Synthesize` produces a NOERROR with an empty answer — the real AAAA is discarded. Confirmed by reading the source: `responseShouldDNS64` returns `true` unconditionally when `TranslateAll` is set (except NXDOMAIN), and `Synthesize` only converts A records — anything without an A record yields an empty answer. In practice this only affects genuinely IPv6-only destinations with no A record, which is rare on the public internet today. diff --git a/mikrotik/README.md b/mikrotik/README.md new file mode 100644 index 0000000..7a5beeb --- /dev/null +++ b/mikrotik/README.md @@ -0,0 +1,147 @@ +# Mikrotik containers + +RouterOS containers running on the CRS418 providing network services that RouterOS cannot handle natively. + +## CoreDNS (DNS64) + +Replaces the built-in RouterOS DNS forwarder. Implements DNS64 (RFC 6147): synthesizes AAAA records from A records for IPv4-only destinations, enabling IPv6-only clients to reach them via NAT64. Native dual-stack sites keep their real AAAA records. + +Source: [`coredns/`](coredns/) +Image built by Woodpecker CI, pushed to `gitea.lumpiasty.xyz//coredns-mikrotik`. + +### Why not the official coredns/coredns image? + +The official image ships ~40 plugins and weighs ~20 MB compressed. A custom build with the 7 plugins we actually need fits in ~6-8 MB — important for the CRS internal flash. + +### Corefile + +The Corefile is baked into the image. To change DNS behaviour, edit [`coredns/Corefile`](coredns/Corefile) and push — the Woodpecker pipeline rebuilds and pushes a new image automatically. + +## Tayga (NAT64) + +Stateless NAT64 translator (RFC 7915). Receives IPv6 packets destined for `64:ff9b::/96`, rewrites them to IPv4, and returns translated responses. RouterOS does **not** implement NAT64 natively — the official docs state this explicitly. + +Official image: `ghcr.io/apalrd/tayga` — no custom build needed. + +--- + +## RouterOS setup + +The commands below wire both containers into the network. Adapt interface names and IPv6 prefix to your actual allocation. The HE tunnel broker prefix in use is `2001:470:61a3::/48`; the examples below use a dedicated /64 from the management range for container interfaces. + +### 1. Enable container mode (one-time, requires physical access) + +``` +/system/device-mode/update container=yes +``` + +### 2. Network interfaces + +``` +# CoreDNS — dedicated veth, no IPv6 needed (DNS listens on IPv4 of the veth) +/interface/veth/add name=veth-dns address=172.31.0.2/30 gateway=172.31.0.1 +/interface/bridge/add name=br-dns +/interface/bridge/port/add bridge=br-dns interface=veth-dns +/ip/address/add address=172.31.0.1/30 interface=br-dns + +# Tayga — needs both IPv4 (for its own address) and IPv6 (for the NAT64 traffic path) +/interface/veth/add name=veth-nat64 address=172.31.1.2/30 gateway=172.31.1.1 +/interface/bridge/add name=br-nat64 +/interface/bridge/port/add bridge=br-nat64 interface=veth-nat64 +/ip/address/add address=172.31.1.1/30 interface=br-nat64 +/ipv6/address/add address=2001:470:61a3:500::1/64 advertise=no interface=br-nat64 +``` + +### 3. NAT for container internet access + +``` +/ip/firewall/nat/add chain=srcnat src-address=172.31.0.0/29 action=masquerade comment="container egress" +``` + +### 4. Tayga container + +``` +/container/config/set registry-url=https://ghcr.io tmpdir=flash/tmp + +/container/envs/add list=ENV_TAYGA key=TAYGA_CONF_IPV4_ADDR value=172.31.1.2 +/container/envs/add list=ENV_TAYGA key=TAYGA_CONF_DYNAMIC_POOL value=192.0.0.0/24 +/container/envs/add list=ENV_TAYGA key=TAYGA_CONF_PREFIX value=64:ff9b::/96 +/container/envs/add list=ENV_TAYGA key=TAYGA_IPV6_ADDR value=2001:470:61a3:500::2 + +/container/add \ + remote-image=ghcr.io/apalrd/tayga:latest \ + interface=veth-nat64 \ + envlist=ENV_TAYGA \ + root-dir=flash/tayga \ + start-on-boot=yes \ + logging=yes \ + name=tayga +``` + +### 5. CoreDNS container + +``` +/container/config/set registry-url=https://gitea.lumpiasty.xyz + +/container/add \ + remote-image=gitea.lumpiasty.xyz//coredns-mikrotik:latest \ + interface=veth-dns \ + root-dir=flash/coredns \ + start-on-boot=yes \ + logging=yes \ + name=coredns +``` + +### 6. Routes + +``` +# IPv6 traffic for the NAT64 prefix goes to Tayga +/ipv6/route/add dst-address=64:ff9b::/96 gateway=2001:470:61a3:500::2 comment="NAT64 via Tayga" + +# IPv4 return traffic from Tayga's dynamic pool back to LAN clients +/ip/route/add dst-address=192.0.0.0/24 gateway=172.31.1.2 comment="Tayga dynamic pool" + +# Masquerade Tayga's IPv4 pool to WAN +/ip/firewall/nat/add chain=srcnat src-address=192.0.0.0/24 action=masquerade comment="Tayga pool egress" +``` + +### 7. Point the router's DNS resolver at CoreDNS + +``` +/ip/dns/set servers=172.31.0.2 allow-remote-requests=yes +``` + +### 8. PREF64 in Router Advertisements + +Tells CLAT-capable clients (iOS, Android, macOS) the NAT64 prefix without requiring DNS64 prefix discovery. + +``` +/ipv6/nd/set [find] pref64=64:ff9b::/96 +``` + +### 9. DHCP option 108 — IPv6-only preferred (RFC 8925) + +Signals to capable clients that they may disable IPv4 and rely on CLAT/NAT64. Clients that don't understand option 108 ignore it and continue with dual-stack. + +``` +# 0x0000001c = 28 seconds — short for testing; use 0x00015180 (86400) in production +/ip/dhcp-server/option/add name=v6only-preferred code=108 value=0x0000001c +/ip/dhcp-server/option/sets/add name=v6only-set options=v6only-preferred +# Attach the option set to your DHCP server: +/ip/dhcp-server/set [find] dhcp-option-set=v6only-set +``` + +### Verification + +From a client on the LAN (IPv6-only or dual-stack): + +```bash +# Should return a synthesized 64:ff9b::/96 AAAA for an IPv4-only host +dig AAAA ipv4.google.com @172.31.0.2 + +# Should succeed — goes via NAT64 +ping 64:ff9b::1.1.1.1 + +# Full path test from an IPv6-only client +curl -6 https://ipv4only.arpa/ +``` diff --git a/mikrotik/coredns/Corefile b/mikrotik/coredns/Corefile new file mode 100644 index 0000000..5d65c3a --- /dev/null +++ b/mikrotik/coredns/Corefile @@ -0,0 +1,21 @@ +.:53 { + # Synthesize AAAA from A records for all destinations. + # translate_all: override real AAAA records too, so all traffic exits + # via NAT64 (our IPv4 WAN) rather than the HE tunnel broker. + # This eliminates datacenter flagging and CAPTCHA loops from HE addresses. + dns64 { + prefix 64:ff9b::/96 + translate_all + allow_ipv4 + } + + forward . 1.1.1.1 8.8.8.8 { + prefer_udp + } + + cache 300 + errors + log + reload + health :8080 +} diff --git a/mikrotik/coredns/Dockerfile b/mikrotik/coredns/Dockerfile new file mode 100644 index 0000000..0d4d415 --- /dev/null +++ b/mikrotik/coredns/Dockerfile @@ -0,0 +1,32 @@ +# Stage 1: build CoreDNS with minimal plugin set +FROM golang:1.25-alpine AS build + +RUN apk add --no-cache git make bash + +WORKDIR /src +RUN git clone --depth 1 --branch v1.12.1 \ + https://github.com/coredns/coredns . + +# Overwrite plugin.cfg with our trimmed list before compilation +COPY plugin.cfg . + +RUN go generate && make + +# Stage 2: extract CA certificates from a full image +FROM debian:stable-slim AS certs +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates && rm -rf /var/lib/apt/lists/* + +# Stage 3: minimal runtime — scratch + binary + certs only +FROM scratch + +COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=build /src/coredns /coredns +COPY Corefile /Corefile + +# 53: DNS (UDP + TCP) +# 8080: health endpoint +EXPOSE 53/udp 53/tcp 8080/tcp + +# RouterOS requires root to bind port 53 — no USER directive +ENTRYPOINT ["/coredns", "-conf", "/Corefile"] diff --git a/mikrotik/coredns/plugin.cfg b/mikrotik/coredns/plugin.cfg new file mode 100644 index 0000000..8eb8f96 --- /dev/null +++ b/mikrotik/coredns/plugin.cfg @@ -0,0 +1,7 @@ +errors:errors +log:log +health:health +cache:cache +dns64:dns64 +forward:forward +reload:reload