Compare commits

43 Commits

Author SHA1 Message Date
Renovate e458949817 Merge pull request 'Update Helm release cert-manager-webhook-ovh to v0.9.13' (#330) from renovate/cert-manager-webhook-ovh-0.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-06-13 02:01:22 +00:00
Renovate cb5f459182 Update Helm release cert-manager-webhook-ovh to v0.9.13 2026-06-13 02:01:17 +00:00
Lumpiasty d3a067886e coredns: fix ENOTFOUND for own zone, enable dns64 for IPv4 clients
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/push/coredns-build Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
Two Corefile changes:
- Add lumpiasty.xyz server block without dns64. Replaces the manual
  RouterOS static FWD entry (\"bypass nat64\") which returned NOERROR
  with empty answer instead of relaying NXDOMAIN. Combined with
  ndots:5 and pod search domains this made getaddrinfo stop at the
  search-suffixed candidate and fail with ENOTFOUND for valid names
  (kaneo -> authentik OAuth fetch failures). CoreDNS relays rcodes
  faithfully; internal zone keeps real AAAA for native IPv6.
- Add allow_ipv4 to dns64 (previously uncommitted): without it only
  queries arriving over IPv6 are synthesized, but all clients reach
  CoreDNS via RouterOS over IPv4, so translate_all never applied.
The RouterOS static FWD entry must be removed after deploying the new
image - ansible already declares only the ts.net entry, so a playbook
run handles it.
2026-06-13 02:45:30 +02:00
Lumpiasty 33e01376b1 Add NAT64, DNS64 to network
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/push/coredns-build Pipeline failed
2026-06-13 00:27:43 +02:00
Lumpiasty 374ee146fe update llama-swap image
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-06-12 18:17:52 +02:00
Lumpiasty 2380cd16e4 add more gemma 4 26b variants
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-06-12 18:01:27 +02:00
Renovate 23ddd7c233 Merge pull request 'Update Helm release authentik to v2026.5.3' (#329) from renovate/authentik-2026.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-06-12 02:01:25 +00:00
Renovate a6bfb3d93c Update Helm release authentik to v2026.5.3 2026-06-12 02:01:18 +00:00
Lumpiasty 59f32659a1 Merge pull request 'Update renovate/renovate Docker tag to v43.220.0' (#320) from renovate/renovate-renovate-43.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
Reviewed-on: #320
2026-06-11 21:47:26 +00:00
Lumpiasty 199b14b810 Merge pull request 'Update Helm release openebs to v4.5.0' (#322) from renovate/openebs-4.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was canceled
Reviewed-on: #322
2026-06-11 21:47:07 +00:00
Lumpiasty fc971e6e6c Merge pull request 'Update alpine Docker tag to v3.24.0' (#327) from renovate/alpine-3.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was canceled
Reviewed-on: #327
2026-06-11 21:47:00 +00:00
Lumpiasty aab4bc279c Merge pull request 'Update ghcr.io/remsky/kokoro-fastapi-cpu Docker tag to v0.5.0' (#324) from renovate/ghcr.io-remsky-kokoro-fastapi-cpu-0.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was canceled
Reviewed-on: #324
2026-06-11 21:46:39 +00:00
Lumpiasty 7f6439d64a switch gemma 4 quant, add mtp and nothink variants
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-06-11 21:06:12 +02:00
Renovate f0921e903a Update renovate/renovate Docker tag to v43.220.0 2026-06-11 02:01:22 +00:00
Renovate 5fed73515b Merge pull request 'Update Helm release cloudnative-pg to v0.28.3' (#328) from renovate/cloudnative-pg-0.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-06-11 02:01:17 +00:00
Renovate 1c092c8044 Update Helm release cloudnative-pg to v0.28.3 2026-06-11 02:01:13 +00:00
Lumpiasty 8860f6782e add converting proxy to parakeet
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-06-10 20:46:17 +02:00
Lumpiasty f863a0a496 use parakeet.cpp instead of whisper
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-06-10 20:08:14 +02:00
Renovate 6bf31f0ae6 Update alpine Docker tag to v3.24.0 2026-06-10 02:01:07 +00:00
Renovate 979f5796d5 Merge pull request 'Update Helm release cert-manager-webhook-ovh to v0.9.11' (#326) from renovate/cert-manager-webhook-ovh-0.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-06-09 15:42:31 +00:00
Renovate 8bca1cf90f Merge pull request 'Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-06-09' (#325) from renovate/ghcr.io-mostlygeek-llama-swap-2026.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline is pending
2026-06-09 15:42:26 +00:00
Renovate b3793d11d9 Update Helm release cert-manager-webhook-ovh to v0.9.11 2026-06-09 15:42:26 +00:00
Renovate fb4fa9b0e7 Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-06-09 2026-06-09 15:40:20 +00:00
Renovate 13a87e5b00 Update ghcr.io/remsky/kokoro-fastapi-cpu Docker tag to v0.5.0 2026-06-07 02:02:49 +00:00
Renovate 1da43d39e2 Merge pull request 'Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-06-06' (#323) from renovate/ghcr.io-mostlygeek-llama-swap-2026.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-06-07 02:02:49 +00:00
Renovate 87c56a9ca1 Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-06-06 2026-06-07 02:01:15 +00:00
Renovate 8ff9126025 Merge pull request 'Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-06-05' (#321) from renovate/ghcr.io-mostlygeek-llama-swap-2026.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-06-06 02:02:46 +00:00
Renovate 4e0a97d6f8 Update Helm release openebs to v4.5.0 2026-06-06 02:02:45 +00:00
Renovate 43c2036642 Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-06-05 2026-06-06 02:01:10 +00:00
Renovate 4d51d45f74 Merge pull request 'Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-06-04' (#319) from renovate/ghcr.io-mostlygeek-llama-swap-2026.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-06-05 02:02:58 +00:00
Renovate fe607d3fb8 Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-06-04 2026-06-05 02:01:18 +00:00
Lumpiasty cd514c71b6 oauth auto redirect and allowed role in openwebui
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-06-04 18:03:00 +02:00
Lumpiasty 32a483c711 update outdated readme sections 2026-06-04 17:24:17 +02:00
Lumpiasty 0426f86719 Merge pull request 'Update renovate/renovate Docker tag to v43.210.2' (#314) from renovate/renovate-renovate-43.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
Reviewed-on: #314
2026-06-04 14:40:48 +00:00
Lumpiasty da365501e9 Merge pull request 'Update Helm release open-webui to v14.8.0' (#315) from renovate/open-webui-14.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was canceled
Reviewed-on: #315
2026-06-04 14:40:42 +00:00
Renovate cf7c0075e7 Update renovate/renovate Docker tag to v43.210.2 2026-06-04 02:03:00 +00:00
Renovate 0dd86e3321 Merge pull request 'Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-06-03' (#318) from renovate/ghcr.io-mostlygeek-llama-swap-2026.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-06-04 02:02:56 +00:00
Renovate 3060950d56 Merge pull request 'Update caddy Docker tag to v2.11.4' (#317) from renovate/caddy-2.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline is pending
2026-06-04 02:01:07 +00:00
Renovate 9dd0c7eb0a Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-06-03 2026-06-04 02:01:07 +00:00
Renovate 68b480299d Update caddy Docker tag to v2.11.4 2026-06-04 02:01:05 +00:00
Renovate 973b0beb21 Update Helm release open-webui to v14.8.0 2026-06-03 02:03:19 +00:00
Renovate 57fd804712 Merge pull request 'Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-06-02' (#316) from renovate/ghcr.io-mostlygeek-llama-swap-2026.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-06-03 02:03:19 +00:00
Renovate 149e85762f Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-06-02 2026-06-03 02:01:32 +00:00
27 changed files with 1057 additions and 72 deletions
+46
View File
@@ -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]
+1 -1
View File
@@ -21,7 +21,7 @@ steps:
- bao kv get -mount secret -field RENOVATE_TOKEN renovate > /woodpecker/renovate_token - bao kv get -mount secret -field RENOVATE_TOKEN renovate > /woodpecker/renovate_token
- bao kv get -mount secret -field GITHUB_COM_TOKEN renovate > /woodpecker/github_com_token - bao kv get -mount secret -field GITHUB_COM_TOKEN renovate > /woodpecker/github_com_token
- name: Run Renovate - name: Run Renovate
image: renovate/renovate:43.197.0 image: renovate/renovate:43.220.0
environment: environment:
RENOVATE_AUTODISCOVER: "true" RENOVATE_AUTODISCOVER: "true"
RENOVATE_ENDPOINT: https://gitea.lumpiasty.xyz/api/v1 RENOVATE_ENDPOINT: https://gitea.lumpiasty.xyz/api/v1
+3 -1
View File
@@ -139,6 +139,8 @@ flowchart TD
cluster -- "Routes exported via BGP" ----- k8s cluster -- "Routes exported via BGP" ----- k8s
``` ```
More information on network are available in [Network documentation](docs/network.md)
Currently the k8s cluster consists of single node (hostname anapistula-delrosalae), which is a PC with Ryzen 5 3600, 64GB RAM, RX 580 8GB (for accelerating LLMs), 1TB NVMe SSD, 2TB and 3TB HDDs and serves both as control plane and worker node. Currently the k8s cluster consists of single node (hostname anapistula-delrosalae), which is a PC with Ryzen 5 3600, 64GB RAM, RX 580 8GB (for accelerating LLMs), 1TB NVMe SSD, 2TB and 3TB HDDs and serves both as control plane and worker node.
## Software stack ## Software stack
@@ -269,7 +271,7 @@ This repo leverages [devenv](https://devenv.sh/) for easy setup of a development
### App deployment ### App deployment
This repo is being watched by Flux running on cluster. To change config/add new app, simply commit to this repo and wait a while for cluster to reconcile changes. You can speed up this process by "notifying" Flux using `flux reconcile source git flux-system`. This repo is being watched by Flux running on cluster. To change config/add new app, simply commit to this repo and wait a while for cluster to reconcile changes. There is a Woodpecker job pushing Flux to reconcile cluster on push to this repository.
Flux watches 3 kustomizations in this repo: Flux watches 3 kustomizations in this repo:
+13 -4
View File
@@ -6,6 +6,9 @@
- address: 172.20.0.1/24 - address: 172.20.0.1/24
interface: containers interface: containers
network: 172.20.0.0 network: 172.20.0.0
- address: 192.168.239.1/30
interface: nat64
network: 192.168.239.0
- address: 192.168.4.1/24 - address: 192.168.4.1/24
interface: lo interface: lo
network: 192.168.4.0 network: 192.168.4.0
@@ -37,19 +40,25 @@
community.routeros.api_modify: community.routeros.api_modify:
path: ipv6 address path: ipv6 address
data: data:
- address: fc64::1/126
advertise: false
comment: nat64 loopback
interface: nat64
- address: 2001:470:70:dd::2/64 - address: 2001:470:70:dd::2/64
advertise: false advertise: false
interface: sit1 interface: sit1
- address: ::ffff:ffff:ffff:ffff/64 # Static instead of from-pool: pool allocation is dynamic (first free /64,
from-pool: pool1 # e.g. ...:0::/64) which made the RDNSS address advertised in ND config
# point at a nonexistent router address. HE prefix is static, so static
# per-VLAN addressing is deterministic and matches docs/network.md.
- address: 2001:470:61a3:9:ffff:ffff:ffff:ffff/64
interface: vlan2 interface: vlan2
- address: 2001:470:61a3:500:ffff:ffff:ffff:ffff/64 - address: 2001:470:61a3:500:ffff:ffff:ffff:ffff/64
interface: containers interface: containers
- address: 2001:470:61a3:100::1/64 - address: 2001:470:61a3:100::1/64
advertise: false advertise: false
interface: vlan4 interface: vlan4
- address: ::ffff:ffff:ffff:ffff/64 - address: 2001:470:61a3:a:ffff:ffff:ffff:ffff/64
from-pool: pool1
interface: vlan5 interface: vlan5
- address: 2001:470:61a3:600::1/64 - address: 2001:470:61a3:600::1/64
advertise: false advertise: false
+81 -19
View File
@@ -6,6 +6,7 @@
- name: bridge1 - name: bridge1
vlan-filtering: true vlan-filtering: true
- name: containers - name: containers
- name: nat64
handle_absent_entries: remove handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
@@ -65,6 +66,12 @@
- bridge: containers - bridge: containers
interface: veth-tailscale interface: veth-tailscale
comment: Tailscale container interface 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 - bridge: bridge1
interface: ether1 interface: ether1
pvid: 2 pvid: 2
@@ -152,24 +159,9 @@
handle_absent_entries: remove handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
- name: Configure DHCP networks # Pool is no longer referenced — vlan2/vlan5 now use static addresses
community.routeros.api_modify: # (addressing.yml) so the RDNSS addresses in ND config are deterministic.
path: ip dhcp-server network # Kept defined for one run after migration; safe to delete afterwards.
data:
- address: 192.168.0.0/24
dns-server: 192.168.0.1
gateway: 192.168.0.1
- address: 192.168.255.0/24
dns-none: true
gateway: 192.168.255.10
- address: 192.168.5.0/24
dns-server: 192.168.5.1
gateway: 192.168.5.1
handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible
# TODO: IPv6 pools are useful when we have dynamic prefix, but we don't
# We can remove it now
- name: Configure IPv6 pools - name: Configure IPv6 pools
community.routeros.api_modify: community.routeros.api_modify:
path: ipv6 pool path: ipv6 pool
@@ -188,7 +180,8 @@
values: values:
allow-remote-requests: true allow-remote-requests: true
cache-size: 20480 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 - name: Configure DNS static entries
community.routeros.api_modify: community.routeros.api_modify:
@@ -199,6 +192,12 @@
forward-to: 100.100.100.100 forward-to: 100.100.100.100
match-subdomain: true match-subdomain: true
comment: Tailscale MagicDNS comment: Tailscale MagicDNS
# Do NOT add a lumpiasty.xyz FWD entry here. RouterOS FWD entries return
# NOERROR with an empty answer instead of relaying NXDOMAIN, which breaks
# getaddrinfo search-domain processing (ENOTFOUND for valid names in k8s
# pods). The DNS64 bypass for our own zone lives in the CoreDNS Corefile
# (mikrotik/coredns/Corefile, lumpiasty.xyz server block) which relays
# rcodes correctly. See docs/coredns-nat64.md pitfall #4.
handle_absent_entries: remove handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
@@ -244,6 +243,42 @@
handle_absent_entries: remove handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
# Option 108 (IPv6-only preferred, RFC 8925). Without force=yes RouterOS only
# includes the option for clients that request code 108 in their Parameter
# Request List — i.e. RFC 8925-capable clients. Clients that receive it drop
# IPv4 and rely on CLAT/NAT64, which REQUIRES pref64 in RA (see ND tasks below).
- name: Configure DHCP server options (IPv6-only preferred, RFC 8925)
community.routeros.api_modify:
path: ip dhcp-server option
data:
# 32-bit seconds timer (V6ONLY_WAIT) — how long the client suppresses
# IPv4. Refreshed on every renewal; acts as automatic fallback if the
# DHCP server disappears. 0x00015180 = 86400 s (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 networks
community.routeros.api_modify:
path: ip dhcp-server network
data:
- address: 192.168.0.0/24
dns-server: 192.168.0.1
gateway: 192.168.0.1
dhcp-option: v6only-preferred
- address: 192.168.255.0/24
dns-none: true
gateway: 192.168.255.10
- address: 192.168.5.0/24
dns-server: 192.168.5.1
gateway: 192.168.5.1
dhcp-option: v6only-preferred
handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible
- name: Configure IPv6 ND defaults - name: Configure IPv6 ND defaults
community.routeros.api_find_and_modify: community.routeros.api_find_and_modify:
ignore_dynamic: false ignore_dynamic: false
@@ -252,3 +287,30 @@
default: true default: true
values: values:
advertise-dns: true advertise-dns: true
# Per-interface ND entries must be CREATED — only the interface=all default
# exists out of the box. The previous api_find_and_modify approach silently
# matched zero entries and never applied pref64.
#
# pref64: NAT64 prefix discovery (RFC 8781) — required by clients honouring
# DHCP option 108 to activate CLAT. Without it they go IPv6-only with no
# working translation and appear stuck while "obtaining IP address".
#
# dns: RDNSS (RFC 8106) — IPv6-only clients ignore DHCPv4 entirely, including
# its dns-server. They need an IPv6 DNS address from RA. We advertise the
# router's own per-VLAN IPv6 address; RouterOS DNS forwards to CoreDNS.
- name: Configure IPv6 ND per-interface (pref64 + RDNSS)
community.routeros.api_modify:
path: ipv6 nd
data:
# advertise-dns must be explicitly enabled — RouterOS creates new ND
# entries with advertise-dns=no, which suppresses the RDNSS option
# entirely even when a static dns= list is configured.
- interface: vlan2
advertise-dns: true
pref64: 64:ff9b::/96
dns: 2001:470:61a3:9:ffff:ffff:ffff:ffff
- interface: vlan5
advertise-dns: true
pref64: 64:ff9b::/96
dns: 2001:470:61a3:a:ffff:ffff:ffff:ffff
+18 -3
View File
@@ -20,15 +20,15 @@
data: data:
- dst: /var/lib/tailscale - dst: /var/lib/tailscale
list: tailscale_state list: tailscale_state
src: tailscale/state src: /tailscale/state
handle_absent_entries: remove handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
- name: Configure tailscale container - name: Configure containers
community.routeros.api_modify: community.routeros.api_modify:
path: container path: container
data: data:
- dns: 172.17.0.1 - dns: 172.20.0.1
interface: veth-tailscale interface: veth-tailscale
logging: true logging: true
mountlists: tailscale_state mountlists: tailscale_state
@@ -36,5 +36,20 @@
remote-image: gitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscale:stable remote-image: gitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscale:stable
root-dir: tailscale/root root-dir: tailscale/root
start-on-boot: true 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_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
+56
View File
@@ -67,6 +67,20 @@
chain: forward chain: forward
comment: Allow from containers to everywhere comment: Allow from containers to everywhere
in-interface: containers 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
# IPv6-only clients reaching internal services published on the public IP
# (e.g. authentik.lumpiasty.xyz -> 139.28.40.212 -> dst-nat -> 10.44.0.0/16)
# arrive from the Tayga pool after NAT64 translation. Without this rule
# they fall through to the final reject (hairpin via NAT64).
- action: accept
chain: forward
comment: Allow Tayga NAT64 pool to LoadBalancer (hairpin port forwards)
dst-address: 10.44.0.0/16
src-address: 192.168.240.0/20
- action: jump - action: jump
chain: forward chain: forward
comment: Allow port forwards comment: Allow port forwards
@@ -254,6 +268,11 @@
chain: srcnat chain: srcnat
comment: GPON ONT management comment: GPON ONT management
dst-address: 192.168.100.1 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 - action: dst-nat
chain: dstnat chain: dstnat
comment: TS3 comment: TS3
@@ -375,6 +394,30 @@
dst-address: 2001:470:71:dd::/64 dst-address: 2001:470:71:dd::/64
out-interface-list: wan out-interface-list: wan
reject-with: icmp-no-route 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 - action: accept
chain: forward chain: forward
comment: Allow from LAN to everywhere comment: Allow from LAN to everywhere
@@ -412,6 +455,16 @@
comment: Allow from IOT to internet only comment: Allow from IOT to internet only
in-interface: vlan5 in-interface: vlan5
out-interface-list: wan out-interface-list: wan
- action: accept
chain: forward
comment: Allow from SRV to internet via NAT64
in-interface: vlan4
out-interface: nat64
- action: accept
chain: forward
comment: Allow from IOT to internet via NAT64
in-interface: vlan5
out-interface: nat64
- action: accept - action: accept
chain: forward chain: forward
comment: Allow from OPENWRT UPLINK to internet only comment: Allow from OPENWRT UPLINK to internet only
@@ -427,6 +480,9 @@
dst-address: 2001:470:61a3:500::/64 dst-address: 2001:470:61a3:500::/64
in-interface-list: wan in-interface-list: wan
out-interface: containers 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 - action: accept
chain: forward chain: forward
comment: Allow tcp transmission port to LAN comment: Allow tcp transmission port to LAN
+16
View File
@@ -21,6 +21,15 @@
suppress-hw-offload: false suppress-hw-offload: false
target-scope: 10 target-scope: 10
vrf-interface: pppoe-gpon 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_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
@@ -41,6 +50,13 @@
pref-src: "" pref-src: ""
routing-table: main routing-table: main
suppress-hw-offload: false 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_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
+13 -1
View File
@@ -8,7 +8,8 @@
keepalive-timeout: 2 keepalive-timeout: 2
name: pppoe-gpon name: pppoe-gpon
password: "{{ routeros_pppoe_password }}" password: "{{ routeros_pppoe_password }}"
use-peer-dns: true # Using CoreDNS container with DNS64
use-peer-dns: false
user: "{{ routeros_pppoe_username }}" user: "{{ routeros_pppoe_username }}"
handle_absent_entries: remove handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
@@ -37,5 +38,16 @@
mac-address: 7E:7E:A1:B1:2A:7B mac-address: 7E:7E:A1:B1:2A:7B
name: veth-tailscale name: veth-tailscale
comment: Tailscale container 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_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
+1 -1
View File
@@ -18,7 +18,7 @@ spec:
chart: chart:
spec: spec:
chart: authentik chart: authentik
version: 2026.5.2 version: 2026.5.3
sourceRef: sourceRef:
kind: HelmRepository kind: HelmRepository
name: authentik name: authentik
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
spec: spec:
containers: containers:
- name: caddy - name: caddy
image: caddy:2.11.3-alpine image: caddy:2.11.4-alpine
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
volumeMounts: volumeMounts:
- mountPath: /etc/caddy - mountPath: /etc/caddy
+199 -27
View File
@@ -14,12 +14,13 @@ macros:
qwen35_35b_heretic_mmproj: "--mmproj-url https://huggingface.co/unsloth/Qwen3.5-35B-A3B-GGUF/resolve/main/mmproj-F16.gguf --mmproj /root/.cache/llama.cpp/unsloth_Qwen3.5-35B-A3B-GGUF_mmproj-F16.gguf" qwen35_35b_heretic_mmproj: "--mmproj-url https://huggingface.co/unsloth/Qwen3.5-35B-A3B-GGUF/resolve/main/mmproj-F16.gguf --mmproj /root/.cache/llama.cpp/unsloth_Qwen3.5-35B-A3B-GGUF_mmproj-F16.gguf"
qwen35_4b_heretic_mmproj: "--mmproj-url https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/mmproj-F16.gguf --mmproj /root/.cache/llama.cpp/unsloth_Qwen3.5-4B-GGUF_mmproj-F16.gguf" qwen35_4b_heretic_mmproj: "--mmproj-url https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/mmproj-F16.gguf --mmproj /root/.cache/llama.cpp/unsloth_Qwen3.5-4B-GGUF_mmproj-F16.gguf"
gemma4_sampling: "--temp 1.0 --top-p 0.95 --top-k 64 -ctk q4_0 -ctv q4_0" gemma4_sampling: "--temp 1.0 --top-p 0.95 --top-k 64 -ctk q4_0 -ctv q4_0"
gemma4_nothink_sampling: "--temp 1.0 --top-p 0.95 --top-k 64 -ctk q4_0 -ctv q4_0 --reasoning off"
hooks: hooks:
on_startup: on_startup:
preload: preload:
- "Qwen3.5-0.8B-GGUF-nothink:Q4_K_XL" - "Qwen3.5-0.8B-GGUF-nothink:Q4_K_XL"
- "whisper-small" - "parakeet-tdt_ctc-1.1b"
# matrix replaces groups (they are mutually exclusive). # matrix replaces groups (they are mutually exclusive).
# The small 0.8B model runs alongside any LLM. # The small 0.8B model runs alongside any LLM.
@@ -27,7 +28,7 @@ hooks:
matrix: matrix:
vars: vars:
q8: "Qwen3.5-0.8B-GGUF-nothink:Q4_K_XL" q8: "Qwen3.5-0.8B-GGUF-nothink:Q4_K_XL"
stt: "whisper-small" stt: "parakeet-tdt_ctc-1.1b"
flux: "flux2-klein-4b:Q4_K_M" flux: "flux2-klein-4b:Q4_K_M"
coder: "Qwen3-Coder-Next-GGUF:Q4_K_M" coder: "Qwen3-Coder-Next-GGUF:Q4_K_M"
q35t: "Qwen3.5-35B-A3B-GGUF:Q4_K_M" q35t: "Qwen3.5-35B-A3B-GGUF:Q4_K_M"
@@ -38,10 +39,24 @@ matrix:
q4nt: "Qwen3.5-4B-GGUF-nothink:Q4_K_M" q4nt: "Qwen3.5-4B-GGUF-nothink:Q4_K_M"
q4ht: "Qwen3.5-4B-heretic-GGUF:Q4_K_M" q4ht: "Qwen3.5-4B-heretic-GGUF:Q4_K_M"
q4hnt: "Qwen3.5-4B-heretic-GGUF-nothink:Q4_K_M" q4hnt: "Qwen3.5-4B-heretic-GGUF-nothink:Q4_K_M"
g26xl: "gemma-4-26B-A4B-it:UD-Q4_K_XL" g26xl: "gemma-4-26B-A4B-it-qat:UD-Q4_K_XL"
g26q2: "gemma-4-26B-A4B-it:UD-Q2_K_XL" g26xlnt: "gemma-4-26B-A4B-it-qat-nothink:UD-Q4_K_XL"
ge4xl: "unsloth/gemma-4-E4B-it-GGUF:UD-Q4_K_XL" g26mtp: "gemma-4-26B-A4B-it-qat-mtp:UD-Q4_K_XL"
ge2xl: "unsloth/gemma-4-E2B-it-GGUF:UD-Q4_K_XL" g26mtpnt: "gemma-4-26B-A4B-it-qat-mtp-nothink:UD-Q4_K_XL"
g26ht: "SC117/gemma-4-26B-A4B-it-qat-heretic-GGUF:UD-Q4_K_XL"
g26hnt: "SC117/gemma-4-26B-A4B-it-qat-heretic-GGUF-nothink:UD-Q4_K_XL"
g26hmtp: "SC117/gemma-4-26B-A4B-it-qat-heretic-GGUF-mtp:UD-Q4_K_XL"
g26hmnt: "SC117/gemma-4-26B-A4B-it-qat-heretic-GGUF-mtp-nothink:UD-Q4_K_XL"
ge4qat: "unsloth/gemma-4-E4B-it-qat-GGUF:UD-Q4_K_XL"
ge4qatnt: "unsloth/gemma-4-E4B-it-qat-GGUF-nothink:UD-Q4_K_XL"
ge2qat: "unsloth/gemma-4-E2B-it-qat-GGUF:UD-Q4_K_XL"
ge2qatnt: "unsloth/gemma-4-E2B-it-qat-GGUF-nothink:UD-Q4_K_XL"
ge4mtp: "unsloth/gemma-4-E4B-it-qat-GGUF-mtp:UD-Q4_K_XL"
ge4mtpnt: "unsloth/gemma-4-E4B-it-qat-GGUF-mtp-nothink:UD-Q4_K_XL"
ge4ht: "llmfan46/gemma-4-E4B-it-ultra-uncensored-heretic-GGUF:Q4_K_M"
ge4hnt: "llmfan46/gemma-4-E4B-it-ultra-uncensored-heretic-GGUF-nothink:Q4_K_M"
ge4hmtp: "llmfan46/gemma-4-E4B-it-ultra-uncensored-heretic-GGUF-mtp:Q4_K_M"
ge4hmnt: "llmfan46/gemma-4-E4B-it-ultra-uncensored-heretic-GGUF-mtp-nothink:Q4_K_M"
q36t: "unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL" q36t: "unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL"
q36nt: "unsloth/Qwen3.6-35B-A3B-GGUF-nothink:UD-Q4_K_XL" q36nt: "unsloth/Qwen3.6-35B-A3B-GGUF-nothink:UD-Q4_K_XL"
haut: "HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive:Q4_K_M" haut: "HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive:Q4_K_M"
@@ -54,10 +69,11 @@ matrix:
sets: sets:
# any LLM can run alongside the small always-on model + STT + TTS (all CPU, no VRAM cost) # any LLM can run alongside the small always-on model + STT + TTS (all CPU, no VRAM cost)
with_q8: "(coder | q35t | q35nt | q35ht | q35hnt | q4t | q4nt | q4ht | q4hnt | g26xl | g26q2 | ge4xl | ge2xl | q36t | q36nt | haut | haunt | mtpt | mtpnt) & q8 & stt" with_q8: "(coder | q35t | q35nt | q35ht | q35hnt | q4t | q4nt | q4ht | q4hnt | g26xl | g26xlnt | g26mtp | g26mtpnt | g26ht | g26hnt | g26hmtp | g26hmnt | ge4qat | ge4qatnt | ge2qat | ge2qatnt | ge4mtp | ge4mtpnt | ge4ht | ge4hnt | ge4hmtp | ge4hmnt | q36t | q36nt | haut | haunt | mtpt | mtpnt) & q8 & stt"
# FLUX runs alone — evicts everything including q8, but keeps STT for voice during image gen # FLUX runs alone — evicts everything including q8, but keeps STT for voice during image gen
image_gen: "flux & stt" image_gen: "flux & stt"
models: models:
"Qwen3-Coder-Next-GGUF:Q4_K_M": "Qwen3-Coder-Next-GGUF:Q4_K_M":
cmd: | cmd: |
@@ -151,38 +167,200 @@ models:
${qwen35_nothink_args} ${qwen35_nothink_args}
${common_args} ${common_args}
"gemma-4-26B-A4B-it:UD-Q4_K_XL": "gemma-4-26B-A4B-it-qat:UD-Q4_K_XL":
cmd: | cmd: |
llama-server llama-server
-hf unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_XL \ -hf unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL \
${ctx_256k} ${ctx_256k}
${gemma4_sampling} ${gemma4_sampling}
${common_args} ${common_args}
"gemma-4-26B-A4B-it:UD-Q2_K_XL": "gemma-4-26B-A4B-it-qat-nothink:UD-Q4_K_XL":
cmd: | cmd: |
llama-server llama-server
-hf unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q2_K_XL \ -hf unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL \
${ctx_256k}
${gemma4_nothink_sampling}
${common_args}
"gemma-4-26B-A4B-it-qat-mtp:UD-Q4_K_XL":
cmd: |
llama-server
-hf unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL \
--spec-draft-hf unsloth/gemma-4-26B-A4B-it-qat-GGUF:Q8_0-MTP \
--spec-type draft-mtp
--spec-draft-n-max 1
--swa-full
--kv-unified
--parallel 1
${ctx_256k} ${ctx_256k}
${gemma4_sampling} ${gemma4_sampling}
${common_args} ${common_args}
"unsloth/gemma-4-E4B-it-GGUF:UD-Q4_K_XL": "gemma-4-26B-A4B-it-qat-mtp-nothink:UD-Q4_K_XL":
cmd: | cmd: |
llama-server llama-server
-hf unsloth/gemma-4-E4B-it-GGUF:UD-Q4_K_XL \ -hf unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL \
--spec-draft-hf unsloth/gemma-4-26B-A4B-it-qat-GGUF:Q8_0-MTP \
--spec-type draft-mtp
--spec-draft-n-max 1
--swa-full
--kv-unified
--parallel 1
${ctx_256k}
${gemma4_nothink_sampling}
${common_args}
"SC117/gemma-4-26B-A4B-it-qat-heretic-GGUF:UD-Q4_K_XL":
cmd: |
llama-server
-hf SC117/gemma-4-26B-A4B-it-qat-heretic-GGUF:UD-Q4_K_XL \
${ctx_256k}
${gemma4_sampling}
${common_args}
"SC117/gemma-4-26B-A4B-it-qat-heretic-GGUF-nothink:UD-Q4_K_XL":
cmd: |
llama-server
-hf SC117/gemma-4-26B-A4B-it-qat-heretic-GGUF:UD-Q4_K_XL \
${ctx_256k}
${gemma4_nothink_sampling}
${common_args}
# The heretic QAT repo does not ship an MTP drafter,
# so borrow the one from the non-heretic unsloth QAT repo.
"SC117/gemma-4-26B-A4B-it-qat-heretic-GGUF-mtp:UD-Q4_K_XL":
cmd: |
llama-server
-hf SC117/gemma-4-26B-A4B-it-qat-heretic-GGUF:UD-Q4_K_XL \
--spec-draft-hf unsloth/gemma-4-26B-A4B-it-qat-GGUF:Q8_0-MTP \
--spec-type draft-mtp
--spec-draft-n-max 1
--swa-full
--kv-unified
--parallel 1
${ctx_256k}
${gemma4_sampling}
${common_args}
"SC117/gemma-4-26B-A4B-it-qat-heretic-GGUF-mtp-nothink:UD-Q4_K_XL":
cmd: |
llama-server
-hf SC117/gemma-4-26B-A4B-it-qat-heretic-GGUF:UD-Q4_K_XL \
--spec-draft-hf unsloth/gemma-4-26B-A4B-it-qat-GGUF:Q8_0-MTP \
--spec-type draft-mtp
--spec-draft-n-max 1
--swa-full
--kv-unified
--parallel 1
${ctx_256k}
${gemma4_nothink_sampling}
${common_args}
"unsloth/gemma-4-E4B-it-qat-GGUF:UD-Q4_K_XL":
cmd: |
llama-server
-hf unsloth/gemma-4-E4B-it-qat-GGUF:UD-Q4_K_XL \
${ctx_128k} ${ctx_128k}
${gemma4_sampling} ${gemma4_sampling}
${common_args} ${common_args}
"unsloth/gemma-4-E2B-it-GGUF:UD-Q4_K_XL": "unsloth/gemma-4-E4B-it-qat-GGUF-nothink:UD-Q4_K_XL":
cmd: | cmd: |
llama-server llama-server
-hf unsloth/gemma-4-E2B-it-GGUF:UD-Q4_K_XL \ -hf unsloth/gemma-4-E4B-it-qat-GGUF:UD-Q4_K_XL \
${ctx_128k}
${gemma4_nothink_sampling}
${common_args}
"unsloth/gemma-4-E2B-it-qat-GGUF:UD-Q4_K_XL":
cmd: |
llama-server
-hf unsloth/gemma-4-E2B-it-qat-GGUF:UD-Q4_K_XL \
${ctx_128k} ${ctx_128k}
${gemma4_sampling} ${gemma4_sampling}
${common_args} ${common_args}
"unsloth/gemma-4-E2B-it-qat-GGUF-nothink:UD-Q4_K_XL":
cmd: |
llama-server
-hf unsloth/gemma-4-E2B-it-qat-GGUF:UD-Q4_K_XL \
${ctx_128k}
${gemma4_nothink_sampling}
${common_args}
"unsloth/gemma-4-E4B-it-qat-GGUF-mtp:UD-Q4_K_XL":
cmd: |
llama-server
-hf unsloth/gemma-4-E4B-it-qat-GGUF:UD-Q4_K_XL \
--spec-draft-hf unsloth/gemma-4-E4B-it-qat-GGUF:Q8_0-MTP \
--spec-type draft-mtp
--spec-draft-n-max 1
--swa-full
--kv-unified
--parallel 1
${ctx_128k}
${gemma4_sampling}
${common_args}
"unsloth/gemma-4-E4B-it-qat-GGUF-mtp-nothink:UD-Q4_K_XL":
cmd: |
llama-server
-hf unsloth/gemma-4-E4B-it-qat-GGUF:UD-Q4_K_XL \
--spec-draft-hf unsloth/gemma-4-E4B-it-qat-GGUF:Q8_0-MTP \
--spec-type draft-mtp
--spec-draft-n-max 1
--swa-full
--kv-unified
--parallel 1
${ctx_128k}
${gemma4_nothink_sampling}
${common_args}
"llmfan46/gemma-4-E4B-it-ultra-uncensored-heretic-GGUF:Q4_K_M":
cmd: |
llama-server
-hf llmfan46/gemma-4-E4B-it-ultra-uncensored-heretic-GGUF:Q4_K_M \
${ctx_128k}
${gemma4_sampling}
${common_args}
"llmfan46/gemma-4-E4B-it-ultra-uncensored-heretic-GGUF-nothink:Q4_K_M":
cmd: |
llama-server
-hf llmfan46/gemma-4-E4B-it-ultra-uncensored-heretic-GGUF:Q4_K_M \
${ctx_128k}
${gemma4_nothink_sampling}
${common_args}
"llmfan46/gemma-4-E4B-it-ultra-uncensored-heretic-GGUF-mtp:Q4_K_M":
cmd: |
llama-server
-hf llmfan46/gemma-4-E4B-it-ultra-uncensored-heretic-GGUF:Q4_K_M \
--spec-draft-hf unsloth/gemma-4-E4B-it-qat-GGUF:Q8_0-MTP \
--spec-type draft-mtp
--spec-draft-n-max 1
--swa-full
--kv-unified
--parallel 1
${ctx_128k}
${gemma4_sampling}
${common_args}
"llmfan46/gemma-4-E4B-it-ultra-uncensored-heretic-GGUF-mtp-nothink:Q4_K_M":
cmd: |
llama-server
-hf llmfan46/gemma-4-E4B-it-ultra-uncensored-heretic-GGUF:Q4_K_M \
--spec-draft-hf unsloth/gemma-4-E4B-it-qat-GGUF:Q8_0-MTP \
--spec-type draft-mtp
--spec-draft-n-max 1
--swa-full
--kv-unified
--parallel 1
${ctx_128k}
${gemma4_nothink_sampling}
${common_args}
"unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL": "unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q4_K_XL":
cmd: | cmd: |
llama-server llama-server
@@ -235,20 +413,14 @@ models:
--parallel 1 --parallel 1
${common_args} ${common_args}
# STT via whisper.cpp (Vulkan GPU on RX 580, always loaded, ~600MB VRAM) # STT via parakeet-server (parakeet.cpp OpenAI-compatible server, CPU, always loaded)
# Model auto-downloaded by init container, see deployment.yaml # Model downloaded on first start and cached under /root/.cache/parakeet.cpp/models
# Note: Vulkan whisper on AMD GPUs has known quality issues on some cards; # parakeet-proxy.py sits in front to convert any audio format to WAV via ffmpeg,
# if transcriptions come out as garbage/gibberish, add --no-gpu to fall back. # since parakeet-server only accepts real WAV but browsers send Ogg/Opus.
"whisper-small": "parakeet-tdt_ctc-1.1b":
checkEndpoint: none checkEndpoint: none
cmd: | cmd: |
whisper-server env PROXY_PORT=${PORT} FFMPEG_BIN=/root/.cache/ffmpeg/ffmpeg python3 /config/parakeet-proxy.py
--port ${PORT}
-m /root/.cache/whisper/ggml-small.bin
--request-path /v1/audio
--inference-path /transcriptions
--convert
--threads 6
# Image generation via stable-diffusion.cpp (sd-server) # Image generation via stable-diffusion.cpp (sd-server)
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""
Thin reverse proxy for parakeet-server.
Accepts POST /v1/audio/transcriptions with any audio format,
converts the audio to 16 kHz mono WAV via ffmpeg, then forwards
the converted file to the real parakeet-server running on PARAKEET_PORT.
Also proxies GET /health straight through.
Usage:
PROXY_PORT=<port> PARAKEET_PORT=<upstream> python3 parakeet-proxy.py
"""
import http.server
import io
import os
import subprocess
import sys
import tempfile
import urllib.request
import urllib.error
PROXY_PORT = int(os.environ.get("PROXY_PORT", "8080"))
PARAKEET_PORT = PROXY_PORT + 1
FFMPEG = os.environ.get("FFMPEG_BIN", "ffmpeg")
MODEL = os.environ.get("PARAKEET_MODEL", "tdt_ctc-1.1b-q4_k.gguf")
CACHE_DIR = os.environ.get("PARAKEET_CACHE_DIR", "/root/.cache/parakeet.cpp/models")
def convert_to_wav(data: bytes) -> bytes:
"""Convert any audio bytes to 16 kHz mono PCM WAV via ffmpeg."""
with tempfile.NamedTemporaryFile(suffix=".input", delete=False) as inf:
inf.write(data)
inf_path = inf.name
out_path = inf_path + ".wav"
try:
subprocess.run(
[
FFMPEG, "-y",
"-i", inf_path,
"-ar", "16000",
"-ac", "1",
"-f", "wav",
out_path,
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
with open(out_path, "rb") as f:
return f.read()
finally:
os.unlink(inf_path)
if os.path.exists(out_path):
os.unlink(out_path)
def parse_multipart(content_type: str, body: bytes):
"""
Parse a multipart/form-data body.
Returns a dict of field_name -> (filename_or_None, content_type, data).
"""
import email
from email import policy as email_policy
# email.parser needs the full MIME headers to parse multipart
raw = b"Content-Type: " + content_type.encode() + b"\r\n\r\n" + body
msg = email.message_from_bytes(raw, policy=email_policy.compat32)
parts = {}
for part in msg.get_payload():
cd = part.get("Content-Disposition", "")
name = None
filename = None
for item in cd.split(";"):
item = item.strip()
if item.startswith('name='):
name = item[5:].strip('"')
elif item.startswith('filename='):
filename = item[9:].strip('"')
if name is not None:
parts[name] = (filename, part.get_content_type(), part.get_payload(decode=True))
return parts
def build_multipart(fields: dict) -> tuple[bytes, str]:
"""
Build a multipart/form-data body from fields dict:
field_name -> (filename_or_None, content_type, data_bytes)
Returns (body_bytes, content_type_header_value).
"""
boundary = b"----ParakeetProxyBoundary0xDEADBEEF"
body = b""
for name, (filename, ct, data) in fields.items():
body += b"--" + boundary + b"\r\n"
if filename:
body += (
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
).encode()
else:
body += f'Content-Disposition: form-data; name="{name}"\r\n'.encode()
body += f"Content-Type: {ct}\r\n\r\n".encode()
body += data + b"\r\n"
body += b"--" + boundary + b"--\r\n"
return body, f"multipart/form-data; boundary={boundary.decode()}"
class ProxyHandler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
print(f"[parakeet-proxy] {self.address_string()} - {fmt % args}", flush=True)
def do_GET(self):
if self.path == "/health":
self._forward_get("/health")
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
if self.path.rstrip("/") == "/v1/audio/transcriptions":
self._handle_transcription()
else:
self.send_response(404)
self.end_headers()
def _forward_get(self, path):
try:
url = f"http://127.0.0.1:{PARAKEET_PORT}{path}"
with urllib.request.urlopen(url, timeout=5) as resp:
body = resp.read()
self.send_response(resp.status)
self.send_header("Content-Type", resp.headers.get("Content-Type", "application/json"))
self.end_headers()
self.wfile.write(body)
except Exception as e:
self.send_response(502)
self.end_headers()
self.wfile.write(str(e).encode())
def _handle_transcription(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
ct = self.headers.get("Content-Type", "")
try:
fields = parse_multipart(ct, body)
except Exception as e:
self._error(400, f"failed to parse multipart: {e}")
return
if "file" not in fields:
self._error(400, "missing required field 'file'")
return
filename, file_ct, audio_data = fields["file"]
# Convert to WAV regardless of what we received
try:
wav_data = convert_to_wav(audio_data)
except subprocess.CalledProcessError:
self._error(400, "ffmpeg could not decode audio")
return
except Exception as e:
self._error(500, f"conversion error: {e}")
return
# Rebuild multipart with converted WAV, preserve other fields
new_fields = {}
for name, (fn, fct, data) in fields.items():
if name == "file":
new_fields[name] = ("recording.wav", "audio/wav", wav_data)
else:
new_fields[name] = (fn, fct, data)
new_body, new_ct = build_multipart(new_fields)
# Forward to parakeet-server
try:
url = f"http://127.0.0.1:{PARAKEET_PORT}/v1/audio/transcriptions"
req = urllib.request.Request(
url,
data=new_body,
headers={"Content-Type": new_ct},
method="POST",
)
with urllib.request.urlopen(req, timeout=300) as resp:
resp_body = resp.read()
self.send_response(resp.status)
self.send_header("Content-Type", resp.headers.get("Content-Type", "application/json"))
self.end_headers()
self.wfile.write(resp_body)
except urllib.error.HTTPError as e:
resp_body = e.read()
self.send_response(e.code)
self.send_header("Content-Type", e.headers.get("Content-Type", "application/json"))
self.end_headers()
self.wfile.write(resp_body)
except Exception as e:
self._error(502, f"upstream error: {e}")
def _error(self, code: int, msg: str):
body = f'{{"error":{{"message":"{msg}","type":"proxy_error"}}}}'.encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
proc = subprocess.Popen([
"parakeet-server",
"--host", "127.0.0.1",
"--port", str(PARAKEET_PORT),
"--model", MODEL,
"--cache-dir", CACHE_DIR,
])
print(f"[parakeet-proxy] started parakeet-server pid={proc.pid} on :{PARAKEET_PORT}", flush=True)
server = http.server.HTTPServer(("0.0.0.0", PROXY_PORT), ProxyHandler)
print(f"[parakeet-proxy] listening on :{PROXY_PORT}", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
proc.terminate()
proc.wait()
+2 -2
View File
@@ -18,7 +18,7 @@ spec:
spec: spec:
initContainers: initContainers:
- name: download-whisper - name: download-whisper
image: ghcr.io/mostlygeek/llama-swap:unified-vulkan-2026-05-31 image: gitea.lumpiasty.xyz/lumpiasty/llama-swap:unified-vulkan-parakeet-2026-06-12
command: command:
- sh - sh
- -c - -c
@@ -48,7 +48,7 @@ spec:
mountPath: /root/.cache mountPath: /root/.cache
containers: containers:
- name: llama-swap - name: llama-swap
image: ghcr.io/mostlygeek/llama-swap:unified-vulkan-2026-05-31 image: gitea.lumpiasty.xyz/lumpiasty/llama-swap:unified-vulkan-parakeet-2026-06-12
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- llama-swap - llama-swap
+1 -1
View File
@@ -21,7 +21,7 @@ spec:
# OpenAI-compatible Kokoro-FastAPI TTS server, CPU PyTorch backend. # OpenAI-compatible Kokoro-FastAPI TTS server, CPU PyTorch backend.
# Models baked into the image (no PVC needed). # Models baked into the image (no PVC needed).
# v0.3.0 includes fix for per-request voice tensor memory leak (#459). # v0.3.0 includes fix for per-request voice tensor memory leak (#459).
image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.4.0 image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.5.0
ports: ports:
- containerPort: 8880 - containerPort: 8880
name: http name: http
+1
View File
@@ -13,3 +13,4 @@ configMapGenerator:
namespace: llama namespace: llama
files: files:
- config.yaml=configs/config.yaml - config.yaml=configs/config.yaml
- parakeet-proxy.py=configs/parakeet-proxy.py
+1 -1
View File
@@ -15,7 +15,7 @@ spec:
spec: spec:
initContainers: initContainers:
- name: prepare-home - name: prepare-home
image: alpine:3.23.4 image: alpine:3.24.0
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- /bin/sh - /bin/sh
+13 -5
View File
@@ -18,7 +18,7 @@ spec:
chart: chart:
spec: spec:
chart: open-webui chart: open-webui
version: 14.6.0 version: 14.8.0
sourceRef: sourceRef:
kind: HelmRepository kind: HelmRepository
name: open-webui name: open-webui
@@ -74,7 +74,17 @@ spec:
value: "false" value: "false"
- name: OAUTH_MERGE_ACCOUNTS_BY_EMAIL - name: OAUTH_MERGE_ACCOUNTS_BY_EMAIL
value: "true" value: "true"
# STT via whisper-server (routed through llama-swap) - name: ENABLE_OAUTH_ROLE_MANAGEMENT
value: "true"
- name: OAUTH_ROLES_CLAIM
value: "groups"
- name: OAUTH_ADMIN_ROLES
value: "Admins"
- name: OAUTH_ALLOWED_ROLES
value: "Users"
- name: OAUTH_AUTO_REDIRECT
value: "true"
# STT via parakeet-server (routed through llama-swap)
- name: AUDIO_STT_ENGINE - name: AUDIO_STT_ENGINE
value: "openai" value: "openai"
- name: AUDIO_STT_OPENAI_API_BASE_URL - name: AUDIO_STT_OPENAI_API_BASE_URL
@@ -82,9 +92,7 @@ spec:
- name: AUDIO_STT_OPENAI_API_KEY - name: AUDIO_STT_OPENAI_API_KEY
value: "ignored" value: "ignored"
- name: AUDIO_STT_MODEL - name: AUDIO_STT_MODEL
value: "whisper-small" value: "parakeet-tdt_ctc-1.1b"
- name: AUDIO_STT_SUPPORTED_CONTENT_TYPES
value: "audio/wav,audio/wave"
# TTS via OuteTTS (routed through llama-swap) # TTS via OuteTTS (routed through llama-swap)
# TTS via dedicated Kokoro server (CPU-only, separate pod) # TTS via dedicated Kokoro server (CPU-only, separate pod)
- name: AUDIO_TTS_ENGINE - name: AUDIO_TTS_ENGINE
+124
View File
@@ -0,0 +1,124 @@
# 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`)
- PREF64 + RDNSS options in Router Advertisements (per-interface `ipv6 nd` entries)
- DHCP option 108 to signal IPv6-only preference to capable clients (sent only when requested)
## Client behaviour with DHCPv4 option 108
Option 108 and PREF64 work as a pair — deploying one without the other breaks clients:
- **Option 108** (RFC 8925): tells capable clients to drop IPv4. RouterOS only sends it to clients that request code 108 in their Parameter Request List (that is what the `force` flag on the option controls — we leave it unset). Legacy clients never see it.
- **PREF64 in RA** (RFC 8781): tells the now IPv6-only client the NAT64 prefix so it can activate CLAT. Without PREF64, a client that honoured option 108 has no working translation and appears stuck "obtaining IP address".
- **RDNSS in RA** (RFC 8106): IPv6-only clients ignore DHCPv4 entirely, including its `dns-server`. They need an IPv6 DNS address from RA. We advertise the router's per-VLAN IPv6 address; RouterOS DNS forwards to CoreDNS.
| Client OS | Behaviour |
|---|---|
| iOS 16+, macOS 13+ | Requests 108, drops IPv4, activates CLAT via PREF64 |
| Android 10+ | Requests 108, drops IPv4, activates CLAT via PREF64 |
| Windows 11 (preview) | Partial — CLAT support in preview as of 2026 |
| Linux (NetworkManager) | Honours option 108; CLAT requires PREF64 |
| Legacy/unaware devices | Never request 108, receive IPv4 lease normally, dual-stack |
Option 108 value is a 32-bit seconds timer (V6ONLY_WAIT, minimum 300 per RFC), refreshed on each DHCP renewal. We use 86400 (1 day) so a failed DNS64/NAT64 stack self-heals within a day by clients falling back to IPv4.
### Deployment pitfalls (learned the hard way)
Option 108 must never be deployed before the whole IPv6-only path works end to end. A client that honours it drops IPv4 immediately and depends on RA-provided PREF64 + RDNSS and a working NAT64. Each of these failure modes was hit in sequence, and every one presented identically on the phone ("stuck obtaining IP address" / "failed to connect"):
1. **ND entries silently not created.** RouterOS ships only the `interface=all` default in `/ipv6/nd`. An `api_find_and_modify` task searching for `interface=vlan2` matches zero entries and silently succeeds (`require_matches_min` defaults to 0) — PREF64 was never advertised. Use `api_modify`, which creates missing entries.
2. **RDNSS pointing at a nonexistent address.** VLAN IPv6 addresses came `from-pool`, so the actual prefix was dynamic (`:0::/64`), while the ND `dns=` advertised the documented-but-wrong `:9::/64` router address. Fixed by switching VLANs to static addressing — the HE prefix is static, the pool indirection served no purpose.
3. **`advertise-dns=no` on new ND entries.** RouterOS creates per-interface ND entries with `advertise-dns=no`, which suppresses the RDNSS option entirely — even when a static `dns=` list is configured on the entry. Must be set to `yes` explicitly.
4. **RouterOS static FWD entries corrupt NXDOMAIN.** A manually added `type=FWD match-subdomain=yes` entry for `lumpiasty.xyz` (intended to bypass DNS64 for our own zone) returned `NOERROR` with an empty answer for nonexistent subdomains instead of relaying NXDOMAIN. Combined with `ndots:5` and the `homelab-infra.lumpiasty.xyz` search domain in kubernetes pods, `getaddrinfo` received NODATA for the search-suffixed candidate (`authentik.lumpiasty.xyz.homelab-infra.lumpiasty.xyz`), concluded the name exists, stopped the search loop, and never tried the absolute name — apps failed with `ENOTFOUND` for perfectly valid hostnames while `nslookup` (absolute query) worked. The zone bypass now lives in the CoreDNS Corefile as a dedicated `lumpiasty.xyz:53` server block without `dns64`, which relays rcodes faithfully. RouterOS DNS does plain forwarding only; no FWD entries except Tailscale MagicDNS.
Verification tooling: `rdisc6` (NixOS package `ndisc6`) shows the exact RA contents — RDNSS and PREF64 must both be present. When capturing DHCP in Wireshark, do not filter by client MAC: OFFER/ACK are sent to the broadcast MAC and disappear from the capture, hiding the server side of the exchange. When diagnosing DNS, the CoreDNS `log` plugin output is visible via `/log print` on the router (container `logging=yes`) and includes the rcode CoreDNS returned — comparing it with what the client received isolates which hop corrupts responses. Beware misleading test names: `*.example.com` legitimately returns NODATA upstream, making it useless for NXDOMAIN testing.
## 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/<owner>/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.
+11 -2
View File
@@ -93,8 +93,12 @@ There are also networks, which are not VLANs, but are routed:
Static assignment on CRS, access to factory IP of ONU Static assignment on CRS, access to factory IP of ONU
- Containers on CRS<br> - Containers on CRS<br>
Access to every other network<br> Access to every other network<br>
IP: 172.17.0.1/16, 2001:470:61a3:500::/64<br> IP: 172.20.0.1/24, 2001:470:61a3:500::/64<br>
Static IP management Static IP management, hosts Tailscale and CoreDNS (DNS64) containers
- NAT64 link on CRS<br>
Dedicated bridge for the Tayga NAT64 container<br>
IP: 192.168.239.0/30, fc64::/126 (link), 192.168.240.0/20 (Tayga dynamic pool)<br>
IPv6 traffic to 64:ff9b::/96 is routed here for translation to IPv4
Whole network is designed to eliminate VLANs, overlays where unnecessary to keep things simple. Only NAT rules are: Whole network is designed to eliminate VLANs, overlays where unnecessary to keep things simple. Only NAT rules are:
@@ -103,8 +107,13 @@ Whole network is designed to eliminate VLANs, overlays where unnecessary to keep
It doesn't have a gateway configured, we want to access it from other networks so we need to talk to it as if we were in the same subnet It doesn't have a gateway configured, we want to access it from other networks so we need to talk to it as if we were in the same subnet
- src-nat tailscale IPv6 to internet<br> - src-nat tailscale IPv6 to internet<br>
Tailscale assigns IPv6 from private subnet with no way to configure it, so the assigned IPs are not routable Tailscale assigns IPv6 from private subnet with no way to configure it, so the assigned IPs are not routable
- Masquerade Tayga NAT64 dynamic pool (192.168.240.0/20) via GPON PPPoE
- IPv4 port forwards from GPON PPPoE to respective services - IPv4 port forwards from GPON PPPoE to respective services
## IPv6-mostly (NAT64/DNS64)
LAN (vlan2) and IoT (vlan5) are IPv6-mostly networks (RFC 8925): clients capable of IPv6-only operation receive DHCP option 108, drop their IPv4 address, and activate CLAT using the NAT64 prefix advertised via PREF64 in router advertisements. Legacy clients keep dual-stack. DNS64 (CoreDNS container, with `translate_all`) synthesizes 64:ff9b::/96 AAAA answers so all named traffic exits via NAT64 (Tayga container) on our IPv4 WAN — bypassing the HE tunnel for egress and avoiding datacenter-IP captcha flagging. See [CoreDNS DNS64 + NAT64 design](./coredns-nat64.md) for details and deployment pitfalls.
There is also an UPnP and NAT-PMP enabled to automatically configure port forwards from LAN. There is also an UPnP and NAT-PMP enabled to automatically configure port forwards from LAN.
## Uplink ## Uplink
@@ -18,7 +18,7 @@ spec:
chart: chart:
spec: spec:
chart: cert-manager-webhook-ovh chart: cert-manager-webhook-ovh
version: 0.9.10 version: 0.9.13
sourceRef: sourceRef:
kind: HelmRepository kind: HelmRepository
name: cert-manager-webhook-ovh name: cert-manager-webhook-ovh
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
chart: chart:
spec: spec:
chart: cloudnative-pg chart: cloudnative-pg
version: 0.28.2 version: 0.28.3
sourceRef: sourceRef:
kind: HelmRepository kind: HelmRepository
name: cnpg name: cnpg
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
chart: chart:
spec: spec:
chart: openebs chart: openebs
version: 4.4.0 version: 4.5.0
sourceRef: sourceRef:
kind: HelmRepository kind: HelmRepository
name: openebs name: openebs
+147
View File
@@ -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/<owner>/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/<owner>/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/
```
+40
View File
@@ -0,0 +1,40 @@
# Our own zone bypasses DNS64: internal services have native IPv6 (LB pool
# routed via HE prefix), so clients should get real AAAA records and connect
# directly instead of hairpinning through NAT64.
#
# This MUST live here, not as a RouterOS static FWD entry: RouterOS FWD
# entries return NOERROR with an empty answer instead of relaying NXDOMAIN,
# which breaks getaddrinfo search-domain processing (resolver stops at the
# first NODATA search candidate and never tries the absolute name -> apps
# fail with ENOTFOUND for names that exist).
lumpiasty.xyz:53 {
forward . 1.1.1.1 8.8.8.8 {
prefer_udp
}
cache 300
errors
log
}
.: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
}
+32
View File
@@ -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"]
+7
View File
@@ -0,0 +1,7 @@
errors:errors
log:log
health:health
cache:cache
dns64:dns64
forward:forward
reload:reload