Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f7c4c91b4 | |||
| 4df35f496f | |||
| 8214677d03 | |||
| a01ec90b06 | |||
|
d10c3efe68
|
|||
| ae7f58240c | |||
| 9f5d45d515 | |||
|
d3cb2a6e65
|
|||
|
679ebb3465
|
|||
| de10cba76c | |||
| b993115b41 | |||
| f02579a2f2 | |||
| e458949817 | |||
| 60ba0cfe90 | |||
| cb5f459182 | |||
|
d3a067886e
|
|||
|
33e01376b1
|
|||
|
374ee146fe
|
|||
|
2380cd16e4
|
|||
| 23ddd7c233 | |||
| a6bfb3d93c | |||
| 59f32659a1 | |||
| 199b14b810 | |||
| fc971e6e6c | |||
| aab4bc279c | |||
|
7f6439d64a
|
|||
| f0921e903a | |||
| 5fed73515b | |||
| 1c092c8044 | |||
|
8860f6782e
|
|||
|
f863a0a496
|
|||
| 6bf31f0ae6 | |||
| 979f5796d5 | |||
| 8bca1cf90f | |||
| b3793d11d9 | |||
| fb4fa9b0e7 | |||
| 13a87e5b00 | |||
| 1da43d39e2 | |||
| 87c56a9ca1 | |||
| 8ff9126025 | |||
| 4e0a97d6f8 | |||
| 43c2036642 | |||
| 4d51d45f74 | |||
| fe607d3fb8 | |||
|
cd514c71b6
|
|||
|
32a483c711
|
|||
| 0426f86719 | |||
| da365501e9 | |||
| cf7c0075e7 | |||
| 973b0beb21 |
@@ -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]
|
||||||
@@ -20,7 +20,7 @@ steps:
|
|||||||
- export VAULT_TOKEN=$(cat /woodpecker/.vault_id)
|
- export VAULT_TOKEN=$(cat /woodpecker/.vault_id)
|
||||||
- bao write -format json -f /kubernetes/creds/flux-reconcile > /woodpecker/kube_credentials
|
- bao write -format json -f /kubernetes/creds/flux-reconcile > /woodpecker/kube_credentials
|
||||||
- name: Construct Kubeconfig
|
- name: Construct Kubeconfig
|
||||||
image: alpine/k8s:1.36.1
|
image: alpine/k8s:1.36.2
|
||||||
environment:
|
environment:
|
||||||
KUBECONFIG: /woodpecker/kubeconfig
|
KUBECONFIG: /woodpecker/kubeconfig
|
||||||
commands:
|
commands:
|
||||||
|
|||||||
@@ -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.222.1
|
||||||
environment:
|
environment:
|
||||||
RENOVATE_AUTODISCOVER: "true"
|
RENOVATE_AUTODISCOVER: "true"
|
||||||
RENOVATE_ENDPOINT: https://gitea.lumpiasty.xyz/api/v1
|
RENOVATE_ENDPOINT: https://gitea.lumpiasty.xyz/api/v1
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -40,16 +40,18 @@
|
|||||||
- 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
|
||||||
|
|||||||
@@ -65,6 +65,9 @@
|
|||||||
- 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: bridge1
|
- bridge: bridge1
|
||||||
interface: ether1
|
interface: ether1
|
||||||
pvid: 2
|
pvid: 2
|
||||||
@@ -152,24 +155,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 +176,9 @@
|
|||||||
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: plain forwarder with selective AAAA suppression.
|
||||||
|
# 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 +189,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). Our own zone is handled in the CoreDNS Corefile (lumpiasty.xyz
|
||||||
|
# server block, AAAA kept) which relays rcodes correctly.
|
||||||
|
# See docs/coredns.md.
|
||||||
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 +240,22 @@
|
|||||||
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
|
||||||
|
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
|
||||||
|
- 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
|
||||||
|
|
||||||
- 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 +264,21 @@
|
|||||||
default: true
|
default: true
|
||||||
values:
|
values:
|
||||||
advertise-dns: true
|
advertise-dns: true
|
||||||
|
|
||||||
|
# RDNSS (RFC 8106): advertise an IPv6 DNS server in RAs so dual-stack clients
|
||||||
|
# have an IPv6 resolver. Points at the router's per-VLAN IPv6 address; RouterOS
|
||||||
|
# DNS forwards to CoreDNS. No pref64 — NAT64 has been removed (see docs/coredns.md);
|
||||||
|
# AAAA suppression now happens in CoreDNS, no client-side translation needed.
|
||||||
|
- name: Configure IPv6 ND per-interface (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
|
||||||
|
dns: 2001:470:61a3:9:ffff:ffff:ffff:ffff
|
||||||
|
- interface: vlan5
|
||||||
|
advertise-dns: true
|
||||||
|
dns: 2001:470:61a3:a:ffff:ffff:ffff:ffff
|
||||||
|
|||||||
@@ -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,12 @@
|
|||||||
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
|
||||||
handle_absent_entries: remove
|
handle_absent_entries: remove
|
||||||
handle_entries_content: remove_as_much_as_possible
|
handle_entries_content: remove_as_much_as_possible
|
||||||
|
|||||||
@@ -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,10 @@
|
|||||||
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
|
||||||
handle_absent_entries: remove
|
handle_absent_entries: remove
|
||||||
handle_entries_content: remove_as_much_as_possible
|
handle_entries_content: remove_as_much_as_possible
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ metadata:
|
|||||||
name: gitea-shared-storage-lvmhdd
|
name: gitea-shared-storage-lvmhdd
|
||||||
namespace: openebs
|
namespace: openebs
|
||||||
spec:
|
spec:
|
||||||
capacity: 10Gi
|
capacity: "21474836480"
|
||||||
ownerNodeID: anapistula-delrosalae
|
ownerNodeID: anapistula-delrosalae
|
||||||
shared: "yes"
|
shared: "yes"
|
||||||
thinProvision: "no"
|
thinProvision: "no"
|
||||||
@@ -20,7 +20,7 @@ metadata:
|
|||||||
name: gitea-shared-storage-lvmhdd
|
name: gitea-shared-storage-lvmhdd
|
||||||
spec:
|
spec:
|
||||||
capacity:
|
capacity:
|
||||||
storage: 10Gi
|
storage: 20Gi
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
persistentVolumeReclaimPolicy: Retain
|
persistentVolumeReclaimPolicy: Retain
|
||||||
@@ -41,6 +41,6 @@ spec:
|
|||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: 10Gi
|
storage: 20Gi
|
||||||
storageClassName: hdd-lvmpv
|
storageClassName: hdd-lvmpv
|
||||||
volumeName: gitea-shared-storage-lvmhdd
|
volumeName: gitea-shared-storage-lvmhdd
|
||||||
|
|||||||
+199
-27
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -18,7 +18,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: download-whisper
|
- name: download-whisper
|
||||||
image: ghcr.io/mostlygeek/llama-swap:unified-vulkan-2026-06-03
|
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-06-03
|
image: gitea.lumpiasty.xyz/lumpiasty/llama-swap:unified-vulkan-parakeet-2026-06-12
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- llama-swap
|
- llama-swap
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: prepare-home
|
- name: prepare-home
|
||||||
image: alpine:3.23.4
|
image: alpine:3.24.1
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
# CoreDNS resolver
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the RouterOS built-in DNS forwarder with a CoreDNS container for
|
||||||
|
configurability, and suppress IPv6 (AAAA) resolution by default to keep traffic
|
||||||
|
on IPv4.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
The ISP provides no native IPv6 — only a Hurricane Electric (HE) tunnel
|
||||||
|
(`2001:470:61a3::/48`). HE addresses fall in ranges some sites flag as
|
||||||
|
datacenter/bot traffic, producing endless CAPTCHAs. The goal is to prefer IPv4
|
||||||
|
egress while keeping IPv6 available for our own services and any domain
|
||||||
|
explicitly trusted over IPv6.
|
||||||
|
|
||||||
|
## What this is NOT (and why)
|
||||||
|
|
||||||
|
An earlier iteration used **DNS64 + NAT64 (Tayga)** to force traffic through
|
||||||
|
IPv4. It was removed:
|
||||||
|
|
||||||
|
- **Performance**: Tayga is a userspace translator with no hardware offload.
|
||||||
|
Every translated packet crossed RouterOS twice (v6 in, v4 out) plus a
|
||||||
|
userspace hop, capping throughput at ~250 Mbps against a 1 Gbps line.
|
||||||
|
- **SPOF**: two containers (CoreDNS + Tayga) in the datapath of nearly all
|
||||||
|
traffic on a router whose native forwarder had been rock-solid.
|
||||||
|
- **Architectural inversion**: NAT64 exists to let IPv6-only clients reach IPv4.
|
||||||
|
We don't want IPv6 egress at all — using NAT64 to avoid IPv6 was solving the
|
||||||
|
problem backwards.
|
||||||
|
|
||||||
|
Plain AAAA suppression in CoreDNS achieves the same IPv4-preferred outcome with
|
||||||
|
zero datapath overhead — DNS is the only thing touched, packet forwarding stays
|
||||||
|
on the RouterOS fastpath at line rate.
|
||||||
|
|
||||||
|
The full account of the NAT64/IPv6-mostly attempt and why it was abandoned is in
|
||||||
|
[nat64-dns64-postmortem.md](./nat64-dns64-postmortem.md).
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
CoreDNS runs as a single container (`172.20.0.3`), reachable from RouterOS DNS
|
||||||
|
which forwards client queries to it. The [Corefile](../mikrotik/coredns/Corefile)
|
||||||
|
has three server blocks:
|
||||||
|
|
||||||
|
1. **`lumpiasty.xyz`** — our own zone. Forwards normally, keeps AAAA, so internal
|
||||||
|
services reachable over the HE prefix resolve to their real IPv6 addresses.
|
||||||
|
2. **`.` (default)** — forwards everything else, but a `template IN AAAA` block
|
||||||
|
returns empty NOERROR for all AAAA queries, so clients fall back to IPv4 and
|
||||||
|
avoid the HE tunnel's flagged egress. A records and all other types pass
|
||||||
|
through untouched.
|
||||||
|
|
||||||
|
The whitelist is implemented as a reusable `(aaaa_allowed)` snippet imported by
|
||||||
|
zones that should keep AAAA. To trust another domain over IPv6, add a server
|
||||||
|
block for it that imports `aaaa_allowed`.
|
||||||
|
|
||||||
|
### Why suppression, not NXDOMAIN
|
||||||
|
|
||||||
|
The AAAA template returns NOERROR with an empty answer (NODATA), not NXDOMAIN.
|
||||||
|
This is correct: the name exists, it just has no (advertised) AAAA. Clients
|
||||||
|
treat it as "no IPv6 address" and use the A record. Returning NXDOMAIN would
|
||||||
|
wrongly imply the name doesn't exist and break the A lookup.
|
||||||
|
|
||||||
|
## Future improvement
|
||||||
|
|
||||||
|
The current global-suppress-plus-whitelist is coarse: a domain that is genuinely
|
||||||
|
IPv6-only (no A record) and not whitelisted becomes unreachable. The intended
|
||||||
|
end state is a plugin that suppresses AAAA only when the domain also has an A
|
||||||
|
record, so IPv6-only destinations keep working without manual whitelisting. No
|
||||||
|
in-tree CoreDNS plugin does this today.
|
||||||
|
|
||||||
|
## Custom image
|
||||||
|
|
||||||
|
Built from source with a minimal plugin set (`errors`, `log`, `health`,
|
||||||
|
`template`, `cache`, `forward`, `reload`) instead of the default ~40, producing
|
||||||
|
a ~6-8 MB image. The `dns64` plugin is no longer compiled in.
|
||||||
|
|
||||||
|
Source: [`mikrotik/coredns/`](../mikrotik/coredns/). Built by Woodpecker
|
||||||
|
([`.woodpecker/coredns-build.yaml`](../.woodpecker/coredns-build.yaml)) on pushes
|
||||||
|
touching `mikrotik/coredns/**`, pushed to `gitea.lumpiasty.xyz/lumpiasty/coredns-mikrotik`.
|
||||||
|
|
||||||
|
## RouterOS integration
|
||||||
|
|
||||||
|
- `/ip/dns servers=172.20.0.3` — RouterOS forwards client queries to CoreDNS
|
||||||
|
- RDNSS in RA (`/ipv6/nd dns=...` on vlan2/vlan5) advertises an IPv6 resolver
|
||||||
|
(the router's per-VLAN address) to dual-stack clients; RouterOS DNS relays to
|
||||||
|
CoreDNS
|
||||||
|
- No DHCP option 108, no PREF64 — those belonged to the removed IPv6-mostly setup
|
||||||
|
|
||||||
|
## Pitfalls learned (kept for reference)
|
||||||
|
|
||||||
|
These were hit during the NAT64 era and the migration; some still apply:
|
||||||
|
|
||||||
|
1. **RouterOS static FWD entries corrupt NXDOMAIN.** A `type=FWD match-subdomain=yes`
|
||||||
|
entry returns NOERROR/empty instead of relaying NXDOMAIN. Combined with
|
||||||
|
`ndots:5` and kubernetes pod search domains, `getaddrinfo` stops at the first
|
||||||
|
search-suffixed NODATA candidate and never tries the absolute name — apps fail
|
||||||
|
with `ENOTFOUND` for valid hostnames while `nslookup` (absolute query) works.
|
||||||
|
Our own zone is therefore handled in the Corefile, not via a RouterOS FWD
|
||||||
|
entry. RouterOS DNS does plain forwarding only (plus the Tailscale `ts.net`
|
||||||
|
FWD, which is acceptable as its subdomains genuinely don't exist publicly).
|
||||||
|
2. **`advertise-dns=no` on new ND entries.** RouterOS creates per-interface
|
||||||
|
`ipv6 nd` entries with `advertise-dns=no`, suppressing the RDNSS option even
|
||||||
|
when a static `dns=` list is set. Must be enabled explicitly.
|
||||||
|
3. **Per-interface ND entries must be created, not modified.** Only the
|
||||||
|
`interface=all` default ships out of the box; `api_find_and_modify` matching a
|
||||||
|
specific interface silently matches nothing. Use `api_modify`.
|
||||||
|
|
||||||
|
Verification: `rdisc6` (NixOS package `ndisc6`) dumps RA contents. The CoreDNS
|
||||||
|
`log` plugin output is visible via `/log print` on the router (container
|
||||||
|
`logging=yes`) and shows the rcode CoreDNS returned — comparing it to what the
|
||||||
|
client received isolates which hop corrupts a response.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Postmortem: NAT64 / IPv6-mostly attempt
|
||||||
|
|
||||||
|
A record of an architecture that was built, run for ~2 days, and removed. Kept
|
||||||
|
so the reasoning isn't re-discovered the hard way. For the current DNS setup see
|
||||||
|
[coredns.md](./coredns.md); for network overview see [network.md](./network.md).
|
||||||
|
|
||||||
|
## The original problem
|
||||||
|
|
||||||
|
The ISP provides no native IPv6 — only a Hurricane Electric (HE) 6in4 tunnel
|
||||||
|
(`2001:470:61a3::/48`). HE address ranges are widely classified as
|
||||||
|
datacenter/hosting space, so some sites (Google, Cloudflare-fronted services,
|
||||||
|
various login flows) treat IPv6 traffic from them as bot/VPN traffic: endless
|
||||||
|
CAPTCHAs, "unusual traffic" interstitials, or outright blocks. IPv4 egress
|
||||||
|
(the ISP's residential PPPoE address) is unaffected.
|
||||||
|
|
||||||
|
The goal: keep using the network normally without IPv6 triggering these flags,
|
||||||
|
while still wanting some IPv6 (e.g. inbound to self-hosted services).
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
An **IPv6-mostly** network (RFC 8925) with **DNS64 + NAT64**, intended to push
|
||||||
|
egress onto IPv4 while presenting IPv6 to clients:
|
||||||
|
|
||||||
|
- **CoreDNS container** with the `dns64` plugin (`translate_all`): synthesized
|
||||||
|
`64:ff9b::/96` AAAA records from A records for *all* names, so even dual-stack
|
||||||
|
destinations resolved to a NAT64 address.
|
||||||
|
- **Tayga container** (`ghcr.io/apalrd/tayga-nat64`): stateless NAT64 translator.
|
||||||
|
IPv6 traffic to `64:ff9b::/96` was routed to it, translated to IPv4, and
|
||||||
|
masqueraded out the GPON PPPoE interface. So all "IPv6" egress actually left
|
||||||
|
as IPv4 on the residential address — bypassing the HE tunnel and its flagging.
|
||||||
|
- **RouterOS RA + DHCP**: DHCP option 108 (IPv6-only preferred) to make capable
|
||||||
|
clients drop IPv4, PREF64 (RFC 8781) to advertise the NAT64 prefix for CLAT,
|
||||||
|
RDNSS (RFC 8106) to hand IPv6-only clients a resolver.
|
||||||
|
- Dedicated `nat64` bridge, `fc64::/126` link, `192.168.240.0/20` Tayga pool,
|
||||||
|
static routes, and firewall rules (including NAT64-mapped RFC1918 blocks to
|
||||||
|
prevent the translator being used as a policy bypass).
|
||||||
|
|
||||||
|
## Why it was removed
|
||||||
|
|
||||||
|
### 1. Performance — the dealbreaker
|
||||||
|
|
||||||
|
Throughput collapsed from line rate (~1 Gbps) to **~200-300 Mbps**, saturating
|
||||||
|
the router CPU. Causes, all structural:
|
||||||
|
|
||||||
|
- Tayga is a **userspace** translator. Every translated packet leaves the kernel
|
||||||
|
fastpath, is copied to userspace, translated, and re-injected.
|
||||||
|
- Translated traffic crosses RouterOS **twice** — once as IPv6 (LAN → Tayga),
|
||||||
|
once as IPv4 (Tayga → WAN, with masquerade) — doubling firewall/conntrack work.
|
||||||
|
- No hardware offload or fasttrack applies to either leg.
|
||||||
|
|
||||||
|
With `translate_all`, *nearly all* internet traffic went through this path, so
|
||||||
|
the penalty hit everything, not just IPv4-only destinations.
|
||||||
|
|
||||||
|
### 2. Single point of failure
|
||||||
|
|
||||||
|
DNS (CoreDNS) and most of the datapath (Tayga) became two containers in the
|
||||||
|
critical path on a router whose built-in forwarder had been completely reliable.
|
||||||
|
Container restarts, image pulls, or a crash now took down connectivity.
|
||||||
|
|
||||||
|
### 3. Architectural inversion
|
||||||
|
|
||||||
|
NAT64 exists to let **IPv6-only** clients reach the **IPv4** internet. The actual
|
||||||
|
goal here was the opposite — *avoid* IPv6 egress entirely. Building an IPv6-only
|
||||||
|
client environment (option 108, CLAT, PREF64) and then translating all of it back
|
||||||
|
to IPv4 was solving the problem backwards. The complexity existed only to route
|
||||||
|
around a property of the HE tunnel.
|
||||||
|
|
||||||
|
### 4. Firewall complexity and a translation bypass hole
|
||||||
|
|
||||||
|
NAT64 punched a hole in the firewall model. RouterOS filters IPv4 and IPv6
|
||||||
|
independently, but NAT64 traffic enters as IPv6 and *leaves* as IPv4 after
|
||||||
|
translation — so the carefully-built IPv4 forward policy (inter-VLAN isolation,
|
||||||
|
RFC1918-to-WAN blocks) was simply bypassed for anything arriving via the
|
||||||
|
translator. A client could reach a private IPv4 range by encoding it in the
|
||||||
|
NAT64 prefix (`64:ff9b::c0a8:xxyy` = `192.168.x.y`), and the IPv4 rules would
|
||||||
|
never see it because the packet was IPv6 until Tayga rewrote it.
|
||||||
|
|
||||||
|
Plugging this required mirroring the IPv4 policy in the IPv6 chain: explicit
|
||||||
|
`reject` rules for every NAT64-mapped RFC1918 block (`64:ff9b::a00:0/104`,
|
||||||
|
`64:ff9b::ac10:0/108`, `64:ff9b::c0a8:0/112`), per-VLAN accept rules toward the
|
||||||
|
`nat64` interface, plus a separate masquerade and LB hairpin-accept for the
|
||||||
|
Tayga pool. That is a parallel, easy-to-get-wrong copy of the existing ruleset,
|
||||||
|
whose correctness depended on getting CIDR-to-prefix arithmetic right. Removing
|
||||||
|
NAT64 deleted all of it.
|
||||||
|
|
||||||
|
### 5. Operational fragility (see coredns.md for detail)
|
||||||
|
|
||||||
|
The setup had a long tail of subtle failure modes, each presenting identically
|
||||||
|
as "client can't connect":
|
||||||
|
|
||||||
|
- RouterOS static `FWD` entries return `NOERROR`/empty instead of relaying
|
||||||
|
`NXDOMAIN`, which broke `getaddrinfo` search-domain handling in Kubernetes
|
||||||
|
pods (`ENOTFOUND` for valid names).
|
||||||
|
- `translate_all` discarded real AAAA for IPv6-only internal services, and
|
||||||
|
returned empty answers for names with no A record.
|
||||||
|
- Per-interface RouterOS `ipv6 nd` entries default to `advertise-dns=no` and must
|
||||||
|
be *created* (not modified), so RDNSS/PREF64 silently never advertised.
|
||||||
|
- Dynamic `from-pool` VLAN addressing made advertised RDNSS addresses point at
|
||||||
|
nonexistent router addresses.
|
||||||
|
- Option 108 honoured by clients before the NAT64 path was verified working left
|
||||||
|
them stuck "obtaining IP address".
|
||||||
|
|
||||||
|
Each was individually fixable, but the aggregate was a brittle system whose
|
||||||
|
benefit didn't justify the surface area.
|
||||||
|
|
||||||
|
## What replaced it
|
||||||
|
|
||||||
|
Plain CoreDNS forwarder with **AAAA suppression by default** plus a whitelist for
|
||||||
|
domains that should keep IPv6 (our own zone over the HE prefix, and any explicitly
|
||||||
|
trusted domain). Clients prefer IPv4 because they simply don't receive AAAA for
|
||||||
|
most names — no translation, no extra datapath hop, packet forwarding stays on the
|
||||||
|
RouterOS fastpath at line rate. DNS is the only thing in the path. See
|
||||||
|
[coredns.md](./coredns.md).
|
||||||
|
|
||||||
|
Tradeoff accepted: a non-whitelisted IPv6-only destination (no A record) is
|
||||||
|
unreachable. In practice essentially everything on the public internet still has
|
||||||
|
an A record. The intended future refinement is a CoreDNS plugin that suppresses
|
||||||
|
AAAA only when an A record also exists, removing the need for the whitelist; no
|
||||||
|
in-tree plugin does this today.
|
||||||
|
|
||||||
|
## Lessons
|
||||||
|
|
||||||
|
- **Measure throughput before committing to an in-path translator on SOHO-class
|
||||||
|
hardware.** Userspace NAT64 (Tayga/Jool-in-container) on a MikroTik CPU is
|
||||||
|
fine for a few hundred Mbps, not for saturating a gigabit line.
|
||||||
|
- **Match the mechanism to the actual goal.** The goal was "prefer IPv4 egress",
|
||||||
|
which is a one-line DNS policy, not a transition technology.
|
||||||
|
- **Prefer solutions that stay on the fastpath.** Anything that pulls bulk
|
||||||
|
traffic into userspace or doubles the forwarding work will dominate the CPU.
|
||||||
|
- **Fewer moving parts in the critical path.** Two containers carrying all DNS
|
||||||
|
and most traffic is a worse availability story than the stock forwarder, for a
|
||||||
|
cosmetic benefit (avoiding CAPTCHAs on some sites).
|
||||||
|
- **Protocol translation breaks the firewall model.** When traffic changes L3
|
||||||
|
protocol mid-path, the two firewall policies must be kept in sync by hand, and
|
||||||
|
any gap is a silent bypass. A solution that doesn't translate keeps a single
|
||||||
|
coherent policy.
|
||||||
+6
-2
@@ -93,8 +93,8 @@ 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 containers
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -105,6 +105,10 @@ Whole network is designed to eliminate VLANs, overlays where unnecessary to keep
|
|||||||
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
|
||||||
- IPv4 port forwards from GPON PPPoE to respective services
|
- IPv4 port forwards from GPON PPPoE to respective services
|
||||||
|
|
||||||
|
## DNS and IPv6 preference
|
||||||
|
|
||||||
|
DNS is served by a CoreDNS container (`172.20.0.3`); RouterOS forwards client queries to it. CoreDNS suppresses AAAA records by default so clients prefer IPv4, avoiding the HE tunnel's datacenter-flagged egress (which triggers CAPTCHAs on some sites). Our own zone (`lumpiasty.xyz`) and any explicitly whitelisted domains keep AAAA for native IPv6. See [CoreDNS resolver](./coredns.md). An earlier NAT64/IPv6-mostly approach to the same problem was built and abandoned; see the [postmortem](./nat64-dns64-postmortem.md).
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ spec:
|
|||||||
chart:
|
chart:
|
||||||
spec:
|
spec:
|
||||||
chart: cilium
|
chart: cilium
|
||||||
version: 1.19.4
|
version: 1.19.5
|
||||||
sourceRef:
|
sourceRef:
|
||||||
kind: HelmRepository
|
kind: HelmRepository
|
||||||
name: cilium
|
name: cilium
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Mikrotik containers
|
||||||
|
|
||||||
|
RouterOS containers running on the CRS418 providing network services that
|
||||||
|
RouterOS cannot handle natively.
|
||||||
|
|
||||||
|
## CoreDNS
|
||||||
|
|
||||||
|
Replaces the built-in RouterOS DNS forwarder. Plain forwarding resolver with
|
||||||
|
selective AAAA suppression: AAAA is suppressed by default so clients prefer IPv4
|
||||||
|
(avoiding the HE tunnel's datacenter-flagged egress), while our own zone and any
|
||||||
|
whitelisted domains keep AAAA for native IPv6.
|
||||||
|
|
||||||
|
Source: [`coredns/`](coredns/). Image built by Woodpecker CI
|
||||||
|
([`.woodpecker/coredns-build.yaml`](../.woodpecker/coredns-build.yaml)), pushed to
|
||||||
|
`gitea.lumpiasty.xyz/lumpiasty/coredns-mikrotik`.
|
||||||
|
|
||||||
|
The Corefile is baked into the image — edit [`coredns/Corefile`](coredns/Corefile)
|
||||||
|
and push; the pipeline rebuilds and pushes a new image. Custom-built with a
|
||||||
|
minimal plugin set (~6-8 MB vs the official ~20 MB image) to fit the CRS flash.
|
||||||
|
|
||||||
|
See [docs/coredns.md](../docs/coredns.md) for design rationale, including why
|
||||||
|
the earlier NAT64/DNS64 approach was removed.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The router configuration (container definitions, veth interfaces, bridge ports,
|
||||||
|
DNS settings, firewall) is managed declaratively via Ansible, not by manual CLI
|
||||||
|
commands. See [`ansible/roles/routeros/`](../ansible/roles/routeros/) and run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd ansible && ansible-playbook playbooks/routeros.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Containers do not auto-start on first image pull; after the initial deploy,
|
||||||
|
start manually once (subsequent boots are handled by `start-on-boot=yes`):
|
||||||
|
|
||||||
|
```
|
||||||
|
/container/start [find name=coredns]
|
||||||
|
```
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# CoreDNS as a plain forwarding resolver with selective AAAA suppression.
|
||||||
|
#
|
||||||
|
# Background: the ISP provides no native IPv6, only a Hurricane Electric tunnel.
|
||||||
|
# HE addresses are flagged as datacenter ranges by some sites (endless CAPTCHAs,
|
||||||
|
# bot detection). To avoid this, IPv6 (AAAA) resolution is suppressed by default
|
||||||
|
# so clients use IPv4, while a whitelist keeps AAAA for domains where native
|
||||||
|
# IPv6 is wanted (our own services reachable over the HE prefix, and any domain
|
||||||
|
# explicitly trusted over IPv6).
|
||||||
|
#
|
||||||
|
# NAT64/DNS64 was tried and removed: it forced most traffic through a userspace
|
||||||
|
# Tayga translator, capping throughput at ~250 Mbps on the RB-class CPU (line
|
||||||
|
# rate is 1 Gbps) and adding two containers as a SPOF — all to avoid IPv6 egress
|
||||||
|
# we don't want in the first place. Plain AAAA suppression achieves the same
|
||||||
|
# IPv4-preferred outcome with zero datapath overhead.
|
||||||
|
#
|
||||||
|
# TODO: replace the global template suppression + whitelist with a plugin that
|
||||||
|
# suppresses AAAA only when the domain has no A record (so IPv6-only
|
||||||
|
# destinations still work). No such in-tree plugin exists yet.
|
||||||
|
|
||||||
|
# Whitelist: domains that keep AAAA resolution (native IPv6 via HE tunnel).
|
||||||
|
(aaaa_allowed) {
|
||||||
|
forward . 1.1.1.1 8.8.8.8 {
|
||||||
|
prefer_udp
|
||||||
|
}
|
||||||
|
cache 300
|
||||||
|
errors
|
||||||
|
log . {
|
||||||
|
class error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Our own zone: services have native IPv6 on the HE prefix, keep AAAA.
|
||||||
|
lumpiasty.xyz:53 {
|
||||||
|
import aaaa_allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default: forward everything, but suppress AAAA so clients use IPv4 and
|
||||||
|
# avoid the HE tunnel's datacenter-flagged egress.
|
||||||
|
.:53 {
|
||||||
|
template IN AAAA {
|
||||||
|
rcode NOERROR
|
||||||
|
}
|
||||||
|
forward . 1.1.1.1 8.8.8.8 {
|
||||||
|
prefer_udp
|
||||||
|
}
|
||||||
|
cache 300
|
||||||
|
errors
|
||||||
|
log . {
|
||||||
|
class error
|
||||||
|
}
|
||||||
|
reload
|
||||||
|
health :8080
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Stage 1: build CoreDNS with minimal plugin set
|
||||||
|
FROM golang:1.26-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"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
errors:errors
|
||||||
|
log:log
|
||||||
|
health:health
|
||||||
|
template:template
|
||||||
|
cache:cache
|
||||||
|
forward:forward
|
||||||
|
reload:reload
|
||||||
Reference in New Issue
Block a user