38 Commits

Author SHA1 Message Date
Renovate 973b0beb21 Update Helm release open-webui to v14.8.0 2026-06-03 02:03:19 +00:00
Lumpiasty 9dfa780354 add missing apps to readme
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-06-03 02:27:41 +02:00
Lumpiasty b1c616a20f add application guidelines for LLMs
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-06-03 02:08:52 +02:00
Lumpiasty fa32fdfd28 add missing patch to Makefile 2026-06-03 01:35:01 +02:00
Lumpiasty 1b66a8c230 Change Tailscale distribution
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
gitea.lumpiasty.xyz/Lumpiasty/tailscale-mikrotik allows us to move tailscale to internal flash
2026-06-02 17:29:22 +02:00
Lumpiasty af4a7fee48 go back to official llama-swap image
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-05-31 16:23:50 +02:00
Lumpiasty 6546676dd6 add llama-swap optimizations recommended by claude
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-05-31 05:18:47 +02:00
Lumpiasty 353155f7ad Enable DMA transfer queue on llama-swap
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-05-31 04:25:08 +02:00
Lumpiasty 172fbb1ded Test updated base-image llama-swap
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-05-31 03:15:44 +02:00
Lumpiasty 62f6baf948 hairpin nat cluster 2026-05-31 03:14:46 +02:00
Renovate 8c8147176d Merge pull request 'Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-05-29' (#313) from renovate/ghcr.io-mostlygeek-llama-swap-2026.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-05-30 02:02:44 +00:00
Renovate fabd6bb2e0 Merge pull request 'Update dependency kaneo to v2.7.7' (#312) from renovate/kaneo into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline is pending
2026-05-30 02:01:06 +00:00
Renovate fa85180736 Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-05-29 2026-05-30 02:01:05 +00:00
Renovate f8243da487 Update dependency kaneo to v2.7.7 2026-05-30 02:01:02 +00:00
Renovate c2e87933a1 Merge pull request 'Update Helm release woodpecker to v3.6.4' (#311) from renovate/woodpecker-3.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline is running
ci/woodpecker/cron/renovate Pipeline was successful
2026-05-29 02:03:11 +00:00
Renovate b7bdd06d75 Merge pull request 'Update Helm release authentik to v2026.5.2' (#310) from renovate/authentik-2026.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was canceled
2026-05-29 02:03:05 +00:00
Renovate 97281091f7 Update Helm release woodpecker to v3.6.4 2026-05-29 02:03:03 +00:00
Renovate d86fbf6aa1 Merge pull request 'Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-05-28' (#309) from renovate/ghcr.io-mostlygeek-llama-swap-2026.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was canceled
2026-05-29 02:02:59 +00:00
Renovate 26391c1039 Update Helm release authentik to v2026.5.2 2026-05-29 02:02:58 +00:00
Renovate eb579d2632 Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-05-28 2026-05-29 02:01:16 +00:00
Lumpiasty 5aa898e166 add privileged access to woodpecker pods
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was canceled
2026-05-29 01:04:27 +02:00
Lumpiasty c874776e6e allow woodpecker to publish docker images
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-05-29 00:46:17 +02:00
Lumpiasty 1b4c393834 Merge pull request 'Update renovate/renovate Docker tag to v43.197.0' (#302) from renovate/renovate-renovate-43.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
Reviewed-on: #302
2026-05-28 17:19:56 +00:00
Lumpiasty cd0e92379f Merge pull request 'Update ghcr.io/remsky/kokoro-fastapi-cpu Docker tag to v0.4.0' (#306) from renovate/ghcr.io-remsky-kokoro-fastapi-cpu-0.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was canceled
Reviewed-on: #306
2026-05-28 17:19:47 +00:00
Renovate 5d2ef9fd2e Update renovate/renovate Docker tag to v43.197.0 2026-05-28 02:01:22 +00:00
Renovate ab8af5b88a Merge pull request 'Update teamspeak Docker tag to v3.13.8' (#308) from renovate/teamspeak-3.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-05-28 02:01:17 +00:00
Renovate 3c31a78649 Merge pull request 'Update dependency kaneo to v2.7.5' (#307) from renovate/kaneo into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was canceled
2026-05-28 02:01:16 +00:00
Renovate 99eba374d8 Update teamspeak Docker tag to v3.13.8 2026-05-28 02:01:14 +00:00
Renovate 10863352cb Update dependency kaneo to v2.7.5 2026-05-28 02:01:08 +00:00
Lumpiasty 5b026593ce lte failover
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-05-27 23:40:33 +02:00
Lumpiasty 754c8952bc enable amdgpu runtime power management
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-05-27 01:34:19 +02:00
Lumpiasty 779bc3a071 add network documentation
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-05-27 01:33:39 +02:00
Renovate 974c2d0551 Merge pull request 'Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-05-25' (#305) from renovate/ghcr.io-mostlygeek-llama-swap-2026.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-05-26 02:02:31 +00:00
Renovate f68f2e1d38 Update ghcr.io/remsky/kokoro-fastapi-cpu Docker tag to v0.4.0 2026-05-26 02:02:30 +00:00
Renovate a2d193e87d Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-05-25 2026-05-26 02:00:45 +00:00
Lumpiasty fc58a6507b disable mlock
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-05-24 19:16:24 +02:00
Renovate f5b8e3feb6 Merge pull request 'Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-05-24' (#304) from renovate/ghcr.io-mostlygeek-llama-swap-2026.x into fresh-start
ci/woodpecker/push/flux-reconcile-source Pipeline was successful
2026-05-24 17:07:42 +00:00
Renovate 1d6a94b5b4 Update ghcr.io/mostlygeek/llama-swap Docker tag to unified-vulkan-2026-05-24 2026-05-24 17:06:03 +00:00
40 changed files with 1639 additions and 217 deletions
+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.195.1 image: renovate/renovate:43.197.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
+1
View File
@@ -15,6 +15,7 @@ gen-talos-config:
--config-patch @talos/patches/ollama.patch \ --config-patch @talos/patches/ollama.patch \
--config-patch @talos/patches/llama.patch \ --config-patch @talos/patches/llama.patch \
--config-patch @talos/patches/frigate.patch \ --config-patch @talos/patches/frigate.patch \
--config-patch @talos/patches/woodpecker.patch \
--config-patch @talos/patches/anapistula-delrosalae.patch \ --config-patch @talos/patches/anapistula-delrosalae.patch \
--output-types controlplane -o talos/generated/anapistula-delrosalae.yaml \ --output-types controlplane -o talos/generated/anapistula-delrosalae.yaml \
homelab https://kube-api.homelab.lumpiasty.xyz:6443 homelab https://kube-api.homelab.lumpiasty.xyz:6443
+3
View File
@@ -248,6 +248,8 @@ flowchart TD
| <img src="docs/assets/llama-cpp.svg" alt="LLaMA.cpp" height="50" width="50"> | LLaMA.cpp | LLM inference server running local models with GPU acceleration | | <img src="docs/assets/llama-cpp.svg" alt="LLaMA.cpp" height="50" width="50"> | LLaMA.cpp | LLM inference server running local models with GPU acceleration |
| <img src="docs/assets/llama-swap.svg" alt="llama-swap" height="50" width="50"> | llama-swap | Model swapping for LLaMA.cpp | | <img src="docs/assets/llama-swap.svg" alt="llama-swap" height="50" width="50"> | llama-swap | Model swapping for LLaMA.cpp |
| <img src="docs/assets/meridian.svg" alt="meridian" height="50" width="50"> | Meridian | Proxy that bridges Anthropic's official SDK to third-party tools | | <img src="docs/assets/meridian.svg" alt="meridian" height="50" width="50"> | Meridian | Proxy that bridges Anthropic's official SDK to third-party tools |
| | whisper.cpp | High-performance Whisper Automatic Speech Recognition inference server |
| | Kokoro-FastAPI | Kokoro-82M text-to-speech inference server |
### Applications/Services ### Applications/Services
@@ -258,6 +260,7 @@ flowchart TD
| <img src="docs/assets/teamspeak.svg" alt="iSpeak3" height="50" width="50"> | iSpeak3.pl | [ts3server://ispeak3.pl](ts3server://ispeak3.pl) | Public TeamSpeak 3 voice communication server | | <img src="docs/assets/teamspeak.svg" alt="iSpeak3" height="50" width="50"> | iSpeak3.pl | [ts3server://ispeak3.pl](ts3server://ispeak3.pl) | Public TeamSpeak 3 voice communication server |
| <img src="docs/assets/immich.svg" alt="Immich" height="50" width="50"> | Immich | https://immich.lumpiasty.xyz/ | Self-hosted photo and video backup and streaming service | | <img src="docs/assets/immich.svg" alt="Immich" height="50" width="50"> | Immich | https://immich.lumpiasty.xyz/ | Self-hosted photo and video backup and streaming service |
| <img src="docs/assets/frigate.svg" alt="Frigate" height="50" width="50"> | Frigate | https://frigate.lumpiasty.xyz/ | NVR for camera system with AI object detection and classification | | <img src="docs/assets/frigate.svg" alt="Frigate" height="50" width="50"> | Frigate | https://frigate.lumpiasty.xyz/ | NVR for camera system with AI object detection and classification |
| <img src="docs/assets/kaneo.svg" alt="Kaneo" height="50" width="50"> | Kaneo | https://kaneo.lumpiasty.xyz | Project management software |
## Development ## Development
-1
View File
@@ -39,7 +39,6 @@ Secret layout expected in OpenBao (KVv2, mount `secret`):
|---|---| |---|---|
| `routeros_api` | `username`, `password` | | `routeros_api` | `username`, `password` |
| `wan_pppoe` | `username`, `password` | | `wan_pppoe` | `username`, `password` |
| `router_tailscale` | `container_password` |
## OpenWrt dlink AP ## OpenWrt dlink AP
+3 -8
View File
@@ -39,15 +39,10 @@
engine_mount_point=openbao_kv_mount engine_mount_point=openbao_kv_mount
).secret[openbao_fields.wan_pppoe.password_key] ).secret[openbao_fields.wan_pppoe.password_key]
}} }}
routeros_tailscale_container_password: >-
{{
lookup(
'community.hashi_vault.vault_kv2_get',
openbao_fields.routeros_tailscale_container.path,
engine_mount_point=openbao_kv_mount
).secret[openbao_fields.routeros_tailscale_container.container_password_key]
}}
no_log: true no_log: true
tags:
- tailscale-script
module_defaults: module_defaults:
group/community.routeros.api: group/community.routeros.api:
+2 -4
View File
@@ -10,10 +10,6 @@ openwrt_mgmt_ip: 192.168.255.11
openwrt_mgmt_prefix: 24 openwrt_mgmt_prefix: 24
openwrt_mgmt_gateway: 192.168.255.10 openwrt_mgmt_gateway: 192.168.255.10
# DNS servers for the AP itself
openwrt_dns_servers:
- 192.168.0.1
# SSH authorised keys (list of public key strings) # SSH authorised keys (list of public key strings)
openwrt_ssh_authorized_keys: [] openwrt_ssh_authorized_keys: []
@@ -26,4 +22,6 @@ openwrt_ntp_servers:
openwrt_packages: openwrt_packages:
- usb-modeswitch # switches embedded LTE modem (Qualcomm 05c6:9008) from EDL to QMI mode on boot - usb-modeswitch # switches embedded LTE modem (Qualcomm 05c6:9008) from EDL to QMI mode on boot
- luci-proto-qmi # adds QMI protocol support to LuCI for configuring the embedded LTE modem - luci-proto-qmi # adds QMI protocol support to LuCI for configuring the embedded LTE modem
- bird2 # BGP daemon — peers with CRS for LTE failover route signalling
- bird2c # Control CLI interface for BGP daemon
+5
View File
@@ -12,3 +12,8 @@
- name: Reload wireless - name: Reload wireless
community.openwrt.command: community.openwrt.command:
cmd: wifi reload cmd: wifi reload
- name: Reload bird
community.openwrt.service:
name: bird
state: restarted
+153
View File
@@ -0,0 +1,153 @@
---
# Configures BIRD2 on the D-Link as an iBGP peer of the MikroTik CRS418.
#
# Route exchange:
# D-Link → CRS: announces 0.0.0.0/0 and 2000::/3 when wwan0 is up.
# CRS installs these at BGP distance 200 (below the GPON
# static default at distance 1 — activates only on GPON failure).
#
# CRS → D-Link: announces connected routes (VLAN subnets), static routes
# (Tailscale, GPON default), and reflects k8s BGP routes.
# BIRD2 installs all of these into the kernel at metric 10.
#
# D-Link's own routing:
# - Kernel metric 10: BGP-learned routes from CRS (preferred)
# - Kernel metric 100: wwan QMI-assigned routes (fallback)
# No static default gateway on uplink — the default comes from BGP.
# When GPON fails, CRS withdraws the BGP default; D-Link falls back to wwan.
- name: Write BIRD2 configuration
community.openwrt.copy:
dest: /etc/bird.conf
mode: '0640'
owner: root
group: root
content: |
# BIRD2 — LTE failover BGP peer for MikroTik CRS418
# iBGP session, AS 65000, peer: 192.168.6.1 (CRS vlan6)
router id 192.168.6.2;
protocol device {
# Tracks interface up/down state via netlink.
# scan time is a periodic reconciliation fallback; real events are
# netlink-driven and processed immediately.
scan time 5;
}
# Announce directly connected prefixes into BIRD2's RIB so that
# next-hop resolution works for BGP routes received from CRS.
# Without this, 192.168.6.1 (CRS uplink) is unresolvable and all
# IPv4 BGP routes appear unreachable. Same for IPv6 uplink prefix.
protocol direct {
ipv4;
ipv6;
interface "eth0.6";
}
# Install BGP-learned routes from CRS into the kernel at metric 10.
# This is lower than the wwan QMI default (metric 100), so D-Link
# prefers the CRS path for its own outbound traffic when GPON is up.
# import none: BIRD2 does not read the kernel table, preventing
# wwan kernel routes from leaking into BGP.
protocol kernel k4 {
ipv4 {
import none;
export filter {
if proto = "crs" then {
krt_metric = 10;
accept;
}
reject;
};
};
}
protocol kernel k6 {
ipv6 {
import none;
export filter {
if proto = "crs" then {
krt_metric = 10;
accept;
}
reject;
};
};
}
# LTE default routes — exist only while wwan0 is up.
# BIRD2's device protocol tracks wwan0 via netlink; when the interface
# goes down the routes become unreachable and BGP withdraws them.
# Uses interface-name routing (no explicit gateway IP) which is correct
# for QMI raw-ip POINTOPOINT NOARP interfaces.
#
# Preference 50 is below BGP's default of 100 — these routes are only
# used by BIRD2 internally as a presence signal for BGP export, NOT for
# installing into the kernel as our active default route. The kernel
# already gets the wwan default at metric 100 via netifd/qmi.sh, and
# we want the BGP-learned default via CRS (kernel metric 10) to be
# preferred for D-Link's own outbound traffic when GPON is up.
protocol static lte_default {
ipv4 {
preference 50;
};
route 0.0.0.0/0 via "wwan0";
}
protocol static lte_default6 {
ipv6 {
preference 50;
};
route 2000::/3 via "wwan0";
}
protocol bgp crs {
description "MikroTik CRS418 — LTE failover signalling";
local 192.168.6.2 as 65000;
neighbor 192.168.6.1 as 65000;
hold time 30;
keepalive time 10;
ipv4 {
# Import all prefixes CRS announces (VLAN subnets, static routes,
# k8s BGP routes reflected via RR). Installed into kernel via k4.
import all;
# Export only the wwan-sourced LTE default route.
# BGP-learned CRS routes are never re-exported (iBGP split-horizon
# applies; BIRD2 also does not import CRS routes into its RIB from
# the kernel, so they cannot appear here).
export where proto = "lte_default";
};
ipv6 {
# CRS uses Extended Next Hop (RFC 5549) for IPv6 routes, advertising
# them with the IPv4 next-hop 192.168.6.1. The Linux kernel cannot
# install IPv6 routes with IPv4 next-hops. Accept the routes from BGP
# (we negotiated ENHE via "extended next hop yes") but rewrite the
# next-hop in the import filter to the CRS's native IPv6 address on
# vlan6 before they reach the kernel.
extended next hop yes;
import filter {
gw = 2001:470:61a3:600::1;
accept;
};
# Force our own native IPv6 address as the next-hop when advertising
# to CRS, otherwise BIRD2 also uses ENHE and CRS receives a route
# with ::ffff:192.168.6.2 which it can't resolve as an IPv6 next-hop.
export filter {
if proto = "lte_default6" then {
bgp_next_hop = 2001:470:61a3:600::2;
accept;
}
reject;
};
};
}
notify: Reload bird
- name: Enable and start BIRD2 service
community.openwrt.service:
name: bird
enabled: true
state: started
+12 -2
View File
@@ -20,10 +20,11 @@
# output: ACCEPT (AP itself initiates outbound — opkg, NTP, etc.) # output: ACCEPT (AP itself initiates outbound — opkg, NTP, etc.)
# forward: REJECT (AP does not route client traffic through uplink) # forward: REJECT (AP does not route client traffic through uplink)
# #
# wwan — LTE modem uplink (Orange PL, /dev/cdc-wdm0, disabled by default) # wwan — LTE modem uplink (Orange PL, /dev/cdc-wdm0, always-on)
# input: REJECT (no inbound from LTE) # input: REJECT (no inbound from LTE)
# output: ACCEPT (AP itself uses LTE for outbound when uplink unavailable) # output: ACCEPT (AP itself uses LTE for outbound when uplink unavailable)
# forward: REJECT (no client traffic through LTE) # forward: REJECT (default; overridden by explicit uplink→wwan forwarding rule)
# masq/masq6: enabled — NAT all traffic exiting via wwan (own + forwarded)
# #
# No forwarding rules between zones — all inter-zone policy is on MikroTik. # No forwarding rules between zones — all inter-zone policy is on MikroTik.
@@ -75,6 +76,15 @@
option input 'REJECT' option input 'REJECT'
option output 'ACCEPT' option output 'ACCEPT'
option forward 'REJECT' option forward 'REJECT'
option masq '1'
option masq6 '1'
# Forward traffic from MikroTik (arriving on uplink/vlan6) out through wwan
# during LTE failover. MikroTik routes LAN/SRV/IoT traffic here when GPON
# is down and the BGP-learned default via 192.168.6.2 is active.
config forwarding
option src 'uplink'
option dest 'wwan'
config rule config rule
option name 'Allow-ICMPv6-uplink' option name 'Allow-ICMPv6-uplink'
+3
View File
@@ -18,6 +18,9 @@
- name: WWAN modem configuration - name: WWAN modem configuration
ansible.builtin.import_tasks: wwan.yml ansible.builtin.import_tasks: wwan.yml
- name: BIRD2 BGP configuration
ansible.builtin.import_tasks: bird.yml
- name: Firewall configuration - name: Firewall configuration
ansible.builtin.import_tasks: firewall.yml ansible.builtin.import_tasks: firewall.yml
+7 -3
View File
@@ -13,7 +13,7 @@
# mgmt — static 192.168.255.11/24 on eth0.1, management # mgmt — static 192.168.255.11/24 on eth0.1, management
# lan — bridge (br-lan) on eth0.2, LAN clients via LAN ports # lan — bridge (br-lan) on eth0.2, LAN clients via LAN ports
# iot — bridge (br-iot) on eth0.5, IoT clients via wifi only # iot — bridge (br-iot) on eth0.5, IoT clients via wifi only
# uplink — static 192.168.6.2/24 + 2001:470:61a3:600::2/64 on eth0.6, internet access for opkg # uplink — static 192.168.6.2/24 + 2001:470:61a3:600::2/64 on eth0.6, BGP peer link to CRS (no static gateway — default learned via BIRD2)
# wwan — QMI LTE modem (/dev/cdc-wdm0), Orange PL dual-stack failover (APNs: internet + internetipv6) # wwan — QMI LTE modem (/dev/cdc-wdm0), Orange PL dual-stack failover (APNs: internet + internetipv6)
# Manual ifup only (option auto '0'); modem-specific quirks handled in wwan.yml. # Manual ifup only (option auto '0'); modem-specific quirks handled in wwan.yml.
@@ -156,17 +156,21 @@
option pdptype 'ipv4v6' option pdptype 'ipv4v6'
option dhcp '0' option dhcp '0'
option dhcpv6 '0' option dhcpv6 '0'
option peerdns '0'
option metric '100' option metric '100'
# auto '0': netifd does not bring up wwan at boot. The modem takes
# 30-90s after boot before its QMI service responds, and netifd's
# retry/backoff handles this poorly (failed attempts leave the
# interface in 'pending' state). A separate procd service waits
# for the modem to be ready and triggers ifup wwan once.
option auto '0' option auto '0'
config interface 'uplink' config interface 'uplink'
option device 'eth0.6' option device 'eth0.6'
option proto 'static' option proto 'static'
option ipaddr '192.168.6.2/24' option ipaddr '192.168.6.2/24'
option gateway '192.168.6.1'
option dns '192.168.6.1' option dns '192.168.6.1'
option ip6addr '2001:470:61a3:600::2/64' option ip6addr '2001:470:61a3:600::2/64'
option ip6gw '2001:470:61a3:600::1'
notify: Reload network notify: Reload network
+10
View File
@@ -28,3 +28,13 @@
key: "dropbear.@dropbear[0].authorized_keys" key: "dropbear.@dropbear[0].authorized_keys"
value: "{{ openwrt_ssh_authorized_keys | join('\n') }}" value: "{{ openwrt_ssh_authorized_keys | join('\n') }}"
when: openwrt_ssh_authorized_keys | length > 0 when: openwrt_ssh_authorized_keys | length > 0
# The D-Link is a pure AP/relay — no local clients need DNS from it.
# Disable dnsmasq entirely and point the system resolver directly at the
# CRS (192.168.6.1), which is always reachable via vlan6 regardless of
# WAN state and resolves using public upstream servers (1.1.1.1 etc.).
- name: Disable dnsmasq service
community.openwrt.service:
name: dnsmasq
enabled: false
state: stopped
+135
View File
@@ -103,3 +103,138 @@
community.openwrt.command: community.openwrt.command:
cmd: uqmi -t 3000 -d /dev/cdc-wdm0 --modify-profile 3gpp,2 --apn internetipv6 --pdp-type ipv6 cmd: uqmi -t 3000 -d /dev/cdc-wdm0 --modify-profile 3gpp,2 --apn internetipv6 --pdp-type ipv6
changed_when: false changed_when: false
# On cold boot the BM806C's UIM (SIM) QMI service comes up permanently
# broken: --uim-get-sim-state returns {}, --get-imsi returns
# "UIM uninitialized", AT+CPIN? returns +CME ERROR: SIM busy, and the
# modem never converges (verified at uptime 21 min with no intervention).
# CTL/NAS/WDS do come up after ~5 min of warmup, but UIM does not.
#
# A single USB re-enumeration of the device (authorized=0 / authorized=1)
# forces the modem to redo its internal QMI service init from scratch.
# After this, UIM comes up within ~1 s and ifup wwan succeeds normally.
#
# We use authorized=0/1 rather than usb/unbind+bind because the former
# keeps qmi_wwan in the bound-drivers list and the kernel re-runs its
# bind machinery for us; the latter detaches and re-attaches drivers
# explicitly. Both work; authorized is cleaner.
#
# Full investigation, ruled-out hypotheses, and reproduction steps:
# /root/wwan-diag/boot-wedge-investigation.md on the router.
- name: Install wwan-bringup worker script
community.openwrt.copy:
dest: /usr/libexec/wwan-bringup
mode: '0755'
owner: root
group: root
content: |
#!/bin/sh
# Force-clean BM806C cold-boot UIM wedge by re-enumerating the USB
# device once, then bring up wwan. Called by /etc/init.d/wwan-bringup
# as a procd service.
DEV=/dev/cdc-wdm0
IFACE=wwan
USB_PORT=1-1
log() {
logger -t wwan-bringup "$1"
}
# Wait for cold-boot enumeration of cdc-wdm0 (<=60s).
waited=0
while [ ! -e "$DEV" ]; do
sleep 1
waited=$((waited + 1))
[ $waited -ge 60 ] && break
done
if [ ! -e "$DEV" ]; then
log "$DEV never appeared within 60s; giving up"
exit 1
fi
# Force-clean re-enumeration. The BM806C's UIM QMI service never
# comes up on cold boot without this.
log "BM806C cold-boot UIM workaround: re-authorizing $USB_PORT"
echo 0 > /sys/bus/usb/devices/$USB_PORT/authorized
sleep 3
echo 1 > /sys/bus/usb/devices/$USB_PORT/authorized
# Wait for cdc-wdm0 to return after re-enumeration (<=30s).
waited=0
while [ ! -e "$DEV" ]; do
sleep 1
waited=$((waited + 1))
[ $waited -ge 30 ] && break
done
if [ ! -e "$DEV" ]; then
log "$DEV did not return after re-auth; giving up"
exit 1
fi
# qmi.sh's own SIM-init and network-registration loops handle the
# small remaining warmup (~5-30s) gracefully now that UIM is healthy.
log "bringing up $IFACE"
ifup "$IFACE"
# qmi.sh installs an IPv6 default route with a source-specific prefix
# constraint (`default from 2a00:f44:.../64 ...`). This means only
# traffic sourced from the wwan IPv6 prefix uses it — forwarded traffic
# from internal subnets fails routing lookup with "net unreachable"
# before masquerade can rewrite the source. Add a non-source-specific
# default at a higher metric so forwarded traffic has a valid route,
# gets routed out wwan0, then masqueraded by fw4.
#
# Wait up to 90s for qmi.sh to install its source-specific default,
# then derive the gateway and add a regular default route.
waited=0
while [ $waited -lt 90 ]; do
gw6=$(ip -6 route show default dev wwan0 2>/dev/null | awk '/^default from/ {print $5; exit}')
if [ -n "$gw6" ]; then
if ip -6 route show default dev wwan0 | grep -qE "^default via "; then
log "non-source-specific IPv6 default already present"
else
log "adding non-source-specific IPv6 default via $gw6"
ip -6 route add default via "$gw6" dev wwan0 metric 1024
fi
break
fi
sleep 3
waited=$((waited + 3))
done
[ -z "$gw6" ] && log "warning: wwan IPv6 gateway never appeared, skipping default route"
- name: Install wwan-bringup init script
community.openwrt.copy:
dest: /etc/init.d/wwan-bringup
mode: '0755'
owner: root
group: root
content: |
#!/bin/sh /etc/rc.common
# Starts the wwan-bringup worker which re-enumerates the BM806C USB
# device once to clear the cold-boot UIM wedge, then triggers
# `ifup wwan`. See /usr/libexec/wwan-bringup.
START=99
USE_PROCD=1
# One-shot script: launch the worker directly without procd_open_instance
# so procd does not respawn it after successful exit.
PIDFILE=/var/run/wwan-bringup.pid
start_service() {
/usr/libexec/wwan-bringup &
echo $! > $PIDFILE
}
stop_service() {
[ -f $PIDFILE ] && kill "$(cat $PIDFILE)" 2>/dev/null
rm -f $PIDFILE
}
- name: Enable and start wwan-bringup service
community.openwrt.service:
name: wwan-bringup
enabled: true
state: started
+4 -4
View File
@@ -3,9 +3,9 @@
community.routeros.api_modify: community.routeros.api_modify:
path: ip address path: ip address
data: data:
- address: 172.17.0.1/16 - address: 172.20.0.1/24
interface: dockers interface: containers
network: 172.17.0.0 network: 172.20.0.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
@@ -44,7 +44,7 @@
from-pool: pool1 from-pool: pool1
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: dockers interface: containers
- address: 2001:470:61a3:100::1/64 - address: 2001:470:61a3:100::1/64
advertise: false advertise: false
interface: vlan4 interface: vlan4
+19 -7
View File
@@ -5,7 +5,7 @@
data: data:
- name: bridge1 - name: bridge1
vlan-filtering: true vlan-filtering: true
- name: dockers - name: containers
handle_absent_entries: remove handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
@@ -51,10 +51,10 @@
data: data:
- interface: pppoe-gpon - interface: pppoe-gpon
list: wan list: wan
- interface: lte1
list: wan
- interface: sit1 - interface: sit1
list: wan list: wan
- interface: vlan6
list: wan
handle_absent_entries: remove handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
@@ -62,8 +62,8 @@
community.routeros.api_modify: community.routeros.api_modify:
path: interface bridge port path: interface bridge port
data: data:
- bridge: dockers - bridge: containers
interface: veth1 interface: veth-tailscale
comment: Tailscale container interface comment: Tailscale container interface
- bridge: bridge1 - bridge: bridge1
interface: ether1 interface: ether1
@@ -190,6 +190,18 @@
cache-size: 20480 cache-size: 20480
servers: 1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001 servers: 1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001
- name: Configure DNS static entries
community.routeros.api_modify:
path: ip dns static
data:
- name: ts.net
type: FWD
forward-to: 100.100.100.100
match-subdomain: true
comment: Tailscale MagicDNS
handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible
- name: Configure NAT-PMP global settings - name: Configure NAT-PMP global settings
community.routeros.api_find_and_modify: community.routeros.api_find_and_modify:
ignore_dynamic: false ignore_dynamic: false
@@ -202,7 +214,7 @@
community.routeros.api_modify: community.routeros.api_modify:
path: ip nat-pmp interfaces path: ip nat-pmp interfaces
data: data:
- interface: dockers - interface: containers
type: internal type: internal
- interface: pppoe-gpon - interface: pppoe-gpon
type: external type: external
@@ -223,7 +235,7 @@
community.routeros.api_modify: community.routeros.api_modify:
path: ip upnp interfaces path: ip upnp interfaces
data: data:
- interface: dockers - interface: containers
type: internal type: internal
- interface: pppoe-gpon - interface: pppoe-gpon
type: external type: external
+10 -33
View File
@@ -5,28 +5,12 @@
path: container config path: container config
find: {} find: {}
values: values:
registry-url: https://ghcr.io tmpdir: tmp
tmpdir: /tmp1/pull
- name: Configure container env lists - name: Configure container env lists
community.routeros.api_modify: community.routeros.api_modify:
path: container envs path: container envs
data: data: []
- key: ADVERTISE_ROUTES
list: tailscale
value: 192.168.0.0/24,192.168.1.0/24,192.168.4.1/32,192.168.100.1/32,192.168.255.0/24,10.42.0.0/16,10.43.0.0/16,10.44.0.0/16,2001:470:61a3::/48
- key: CONTAINER_GATEWAY
list: tailscale
value: 172.17.0.1
- key: PASSWORD
list: tailscale
value: "{{ routeros_tailscale_container_password }}"
- key: TAILSCALE_ARGS
list: tailscale
value: --accept-routes --advertise-exit-node --snat-subnet-routes=false
- key: UPDATE_TAILSCALE
list: tailscale
value: y
handle_absent_entries: remove handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
@@ -35,11 +19,8 @@
path: container mounts path: container mounts
data: data:
- dst: /var/lib/tailscale - dst: /var/lib/tailscale
list: tailscale list: tailscale_state
src: /usb1/tailscale src: tailscale/state
- dst: /root
list: tailscale-root
src: /tmp1/tailscale-root
handle_absent_entries: remove handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
@@ -48,16 +29,12 @@
path: container path: container
data: data:
- dns: 172.17.0.1 - dns: 172.17.0.1
envlists: tailscale interface: veth-tailscale
hostname: mikrotik logging: true
interface: veth1 mountlists: tailscale_state
layer-dir: "" name: tailscale
mountlists: tailscale remote-image: gitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscale:stable
name: tailscale-mikrotik:latest root-dir: tailscale/root
remote-image: fluent-networks/tailscale-mikrotik:latest
root-dir: /usb1/containers/tailscale
start-on-boot: true start-on-boot: true
tmpfs: /tmp:67108864:01777
workdir: /
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 -27
View File
@@ -10,11 +10,6 @@
chain: forward chain: forward
comment: Allow all already established connections comment: Allow all already established connections
connection-state: established,related connection-state: established,related
- action: accept
chain: forward
comment: Allow LTE modem management (next rule forbids it otherwise)
dst-address: 192.168.8.1
out-interface: lte1
- action: reject - action: reject
chain: forward chain: forward
comment: Forbid forwarding 192.168.0.0/16 to WAN comment: Forbid forwarding 192.168.0.0/16 to WAN
@@ -48,6 +43,11 @@
comment: Allow from SRV to internet comment: Allow from SRV to internet
in-interface: vlan4 in-interface: vlan4
out-interface-list: wan out-interface-list: wan
- action: accept
chain: forward
comment: Allow from SRV to SRV
in-interface: vlan4
out-interface: vlan4
- action: accept - action: accept
chain: forward chain: forward
comment: Allow from SRV to CAM comment: Allow from SRV to CAM
@@ -65,8 +65,8 @@
out-interface-list: wan out-interface-list: wan
- action: accept - action: accept
chain: forward chain: forward
comment: Allow from dockers to everywhere comment: Allow from containers to everywhere
in-interface: dockers in-interface: containers
- action: jump - action: jump
chain: forward chain: forward
comment: Allow port forwards comment: Allow port forwards
@@ -137,14 +137,14 @@
protocol: tcp protocol: tcp
- action: accept - action: accept
chain: input chain: input
comment: Allow DNS from dockers comment: Allow DNS from containers
dst-port: 53 dst-port: 53
in-interface: dockers in-interface: containers
protocol: udp protocol: udp
- action: accept - action: accept
chain: input chain: input
dst-port: 53 dst-port: 53
in-interface: dockers in-interface: containers
protocol: tcp protocol: tcp
- action: accept - action: accept
chain: input chain: input
@@ -173,7 +173,13 @@
comment: Allow BGP from SRV comment: Allow BGP from SRV
dst-port: 179 dst-port: 179
in-interface: vlan4 in-interface: vlan4
protocol: udp protocol: tcp
- action: accept
chain: input
comment: Allow BGP from OPENWRT UPLINK
dst-port: 179
in-interface: vlan6
protocol: tcp
- action: accept - action: accept
chain: input chain: input
comment: NAT-PMP from LAN comment: NAT-PMP from LAN
@@ -182,9 +188,9 @@
protocol: udp protocol: udp
- action: accept - action: accept
chain: input chain: input
comment: NAT-PMP from dockers (for tailscale) comment: NAT-PMP from containers (for tailscale)
dst-port: 5351 dst-port: 5351
in-interface: dockers in-interface: containers
protocol: udp protocol: udp
- action: reject - action: reject
chain: input chain: input
@@ -223,8 +229,8 @@
- action: accept - action: accept
chain: allow-ports chain: allow-ports
comment: Allow anything udp to Tailscale comment: Allow anything udp to Tailscale
dst-address: 172.17.0.2 dst-address: 172.20.0.2
out-interface: dockers out-interface: containers
protocol: udp protocol: udp
- action: accept - action: accept
chain: allow-ports chain: allow-ports
@@ -243,15 +249,11 @@
- action: masquerade - action: masquerade
chain: srcnat chain: srcnat
comment: Masquerade to internet comment: Masquerade to internet
out-interface-list: wan out-interface: pppoe-gpon
- action: masquerade - action: masquerade
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: LTE Modem management
dst-address: 192.168.8.1
- action: dst-nat - action: dst-nat
chain: dstnat chain: dstnat
comment: TS3 comment: TS3
@@ -280,6 +282,11 @@
in-interface: '!pppoe-gpon' in-interface: '!pppoe-gpon'
protocol: tcp protocol: tcp
to-addresses: 128.0.70.5 to-addresses: 128.0.70.5
- action: masquerade
chain: srcnat
comment: hairpin to LoadBalancer pool (vlan4 -> vlan4)
dst-address: 10.44.0.0/16
in-interface: vlan4
- action: dst-nat - action: dst-nat
chain: dstnat chain: dstnat
comment: HTTPS comment: HTTPS
@@ -412,14 +419,14 @@
out-interface-list: wan out-interface-list: wan
- action: accept - action: accept
chain: forward chain: forward
comment: Allow from dockers to everywhere comment: Allow from containers to everywhere
in-interface: dockers in-interface: containers
- action: accept - action: accept
chain: forward chain: forward
comment: Allow from internet to dockers comment: Allow from internet to containers
dst-address: 2001:470:61a3:500::/64 dst-address: 2001:470:61a3:500::/64
in-interface-list: wan in-interface-list: wan
out-interface: dockers out-interface: containers
- action: accept - action: accept
chain: forward chain: forward
comment: Allow tcp transmission port to LAN comment: Allow tcp transmission port to LAN
@@ -478,14 +485,14 @@
protocol: tcp protocol: tcp
- action: accept - action: accept
chain: input chain: input
comment: Allow DNS from dockers comment: Allow DNS from containers
dst-port: 53 dst-port: 53
in-interface: dockers in-interface: containers
protocol: udp protocol: udp
- action: accept - action: accept
chain: input chain: input
dst-port: 53 dst-port: 53
in-interface: dockers in-interface: containers
protocol: tcp protocol: tcp
- action: accept - action: accept
chain: input chain: input
@@ -516,6 +523,13 @@
in-interface: vlan4 in-interface: vlan4
protocol: tcp protocol: tcp
src-address: 2001:470:61a3:100::/64 src-address: 2001:470:61a3:100::/64
- action: accept
chain: input
comment: Allow BGP from OPENWRT UPLINK
dst-port: 179
in-interface: vlan6
protocol: tcp
src-address: 2001:470:61a3:600::/64
- action: reject - action: reject
chain: input chain: input
comment: Reject all remaining comment: Reject all remaining
+33 -42
View File
@@ -39,52 +39,43 @@
loop_control: loop_control:
label: "{{ item.default_name }}" label: "{{ item.default_name }}"
- name: Configure LTE interface defaults # community.routeros.api_modify can't remove hardware disks
community.routeros.api_find_and_modify: # but it tries to do so with handle_absent_entries: remove
ignore_dynamic: false # Working around by manually deleting other ones
path: interface lte
find:
default-name: lte1
values:
apn-profiles: default-nodns
comment: Backup LTE WAN
- name: Configure LTE APN profiles - name: Read current disk entries
community.routeros.api_modify: community.routeros.api_info:
path: interface lte apn path: disk
data: register: routeros_disks
- add-default-route: false check_mode: false
apn: internet
comment: default but without dns and default route - name: Remove stale software-defined disk entries
ipv6-interface: lte1 community.routeros.api:
name: default-nodns path: disk
use-network-apn: true remove: "{{ item['.id'] }}"
use-peer-dns: false loop: >-
# Default APN we can't really remove yet I don't want to reconfigure it {{
- add-default-route: true routeros_disks.result
apn: internet | rejectattr('type', 'in', ['hardware', 'partition'])
authentication: none | rejectattr('slot', 'equalto', 'tmp')
default-route-distance: 2 }}
ip-type: auto loop_control:
name: default label: "{{ item.slot }}"
use-network-apn: true
use-peer-dns: true - name: Create temporary disk for containers if absent
handle_absent_entries: remove community.routeros.api:
handle_entries_content: remove_as_much_as_possible path: disk
add: "slot=tmp type=tmpfs"
when: routeros_disks.result | selectattr('slot', 'equalto', 'tmp') | list | length == 0
- name: Configure temporary disk for containers - name: Configure temporary disk for containers
community.routeros.api_modify: community.routeros.api_find_and_modify:
ignore_dynamic: false
path: disk path: disk
data: find:
- slot: tmp1 slot: tmp
type: tmpfs values:
# This is not ideal, there's no unique identifier for usb disk, type: tmpfs
# after reinstall it might be assigned to another slot
# Just adding disk with slot usb1 and not specifying anything else
# so ansible doesn't touch it
- slot: usb1
handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible
- name: Configure switch settings - name: Configure switch settings
community.routeros.api_find_and_modify: community.routeros.api_find_and_modify:
+3 -3
View File
@@ -2,12 +2,12 @@
- name: Preflight checks - name: Preflight checks
ansible.builtin.import_tasks: preflight.yml ansible.builtin.import_tasks: preflight.yml
- name: Base network configuration
ansible.builtin.import_tasks: base.yml
- name: WAN and tunnel interfaces - name: WAN and tunnel interfaces
ansible.builtin.import_tasks: wan.yml ansible.builtin.import_tasks: wan.yml
- name: Base network configuration
ansible.builtin.import_tasks: base.yml
- name: Hardware and platform tuning - name: Hardware and platform tuning
ansible.builtin.import_tasks: hardware.yml ansible.builtin.import_tasks: hardware.yml
@@ -32,15 +32,4 @@
fail_msg: "RouterOS device-mode does not report container as enabled. Payload: {{ routeros_device_mode | to_nice_json }}" fail_msg: "RouterOS device-mode does not report container as enabled. Payload: {{ routeros_device_mode | to_nice_json }}"
success_msg: "RouterOS device-mode confirms container=yes" success_msg: "RouterOS device-mode confirms container=yes"
- name: Read configured disks
community.routeros.api_info:
path: disk
register: routeros_disks
check_mode: false
- name: Assert usb1 disk is present
ansible.builtin.assert:
that:
- (routeros_disks.result | selectattr('slot', 'equalto', 'usb1') | list | length) > 0
fail_msg: "Required disk slot usb1 is not present on router."
success_msg: "Required disk usb1 is present"
+23 -10
View File
@@ -7,7 +7,7 @@
disabled: false disabled: false
distance: 1 distance: 1
dst-address: 100.64.0.0/10 dst-address: 100.64.0.0/10
gateway: 172.17.0.2 gateway: 172.20.0.2
routing-table: main routing-table: main
scope: 30 scope: 30
suppress-hw-offload: false suppress-hw-offload: false
@@ -21,15 +21,6 @@
suppress-hw-offload: false suppress-hw-offload: false
target-scope: 10 target-scope: 10
vrf-interface: pppoe-gpon vrf-interface: pppoe-gpon
- disabled: false
distance: 2
dst-address: 0.0.0.0/0
gateway: 192.168.8.1
routing-table: main
scope: 30
suppress-hw-offload: false
target-scope: 10
vrf-interface: lte1
handle_absent_entries: remove handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
@@ -93,5 +84,27 @@
remote.address: 2001:470:61a3:100::3/128 remote.address: 2001:470:61a3:100::3/128
routing-table: main routing-table: main
templates: klaster templates: klaster
- name: dlink-lte
afi: ip,ipv6
as: 65000
connect: true
disabled: false
instance: bgp-homelab
listen: true
# ibgp-rr: CRS acts as route reflector for D-Link (the RR client).
# This allows k8s routes learned from bgp1 to be reflected to D-Link
# without violating iBGP split-horizon.
local.role: ibgp-rr
remote.address: 192.168.6.2/32
routing-table: main
templates: klaster
hold-time: 30s
keepalive-time: 10s
# Redistribute connected (VLAN addresses) and static routes (Tailscale,
# GPON default) so D-Link has explicit routes to all internal subnets
# and a default route when GPON is up.
output.redistribute: connected,static
output.default-originate: if-installed
nexthop-choice: force-self
handle_absent_entries: remove handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
+95
View File
@@ -19,6 +19,101 @@
handle_absent_entries: ignore handle_absent_entries: ignore
handle_entries_content: remove_as_much_as_possible handle_entries_content: remove_as_much_as_possible
# The RouterOS API can neither store multi-line script source (newlines
# collapse into one line) nor evaluate the [/file/get ...] expression itself.
# So we fetch the update logic as a .rsc file onto the router's flash, then run
# a single-line bootstrap script (which the API CAN store) whose body RouterOS
# evaluates natively: it builds the real, browsable, multi-line named script
# from the file via [/file get ... contents]. The scheduler then runs that
# named script by name (the upstream-intended design). The update logic stays
# out of this repo entirely.
- name: Download tailscale auto-update script to router
community.routeros.api:
path: tool
cmd: >-
fetch
url=https://gitea.lumpiasty.xyz/Lumpiasty/mikrotik-tailscale/raw/branch/main/routeros/update-tailscale.rsc
dst-path=update-tailscale.rsc
mode=https
changed_when: true
tags:
- tailscale-script
- name: Build the named auto-update script from the fetched file
community.routeros.api:
path: system script
cmd: >-
add name=update-tailscale-bootstrap
source=":do { /system script remove update-tailscale } on-error={};
/system script add name=update-tailscale
comment=\"Check for mikrotik-tailscale image updates\"
source=[/file get update-tailscale.rsc contents]"
changed_when: true
tags:
- tailscale-script
- name: Find bootstrap script id
community.routeros.api:
path: system script
extended_query:
attributes: [.id, name]
where:
- attribute: name
is: "=="
value: update-tailscale-bootstrap
register: routeros_bootstrap
changed_when: false
tags:
- tailscale-script
- name: Run bootstrap to create the named auto-update script
community.routeros.api:
path: system script
cmd: "run .id={{ routeros_bootstrap.msg[0]['.id'] }}"
register: routeros_bootstrap_run
failed_when:
- routeros_bootstrap_run is failed
- "'interrupted' not in (routeros_bootstrap_run.msg | string)"
changed_when: true
tags:
- tailscale-script
- name: Verify named auto-update script exists
community.routeros.api:
path: system script
extended_query:
attributes: [.id, name]
where:
- attribute: name
is: "=="
value: update-tailscale
register: routeros_named_script
failed_when: (routeros_named_script.msg | length) == 0
changed_when: false
tags:
- tailscale-script
- name: Remove bootstrap script
community.routeros.api:
path: system script
remove: "{{ routeros_bootstrap.msg[0]['.id'] }}"
changed_when: true
tags:
- tailscale-script
- name: Configure tailscale auto-update scheduler
community.routeros.api_modify:
path: system scheduler
data:
- name: update-tailscale
interval: 1d
on-event: /system script run update-tailscale
comment: Check for mikrotik-tailscale image updates
handle_absent_entries: remove
handle_entries_content: remove_as_much_as_possible
tags:
- tailscale-script
- name: Configure service ports and service enablement - name: Configure service ports and service enablement
community.routeros.api_find_and_modify: community.routeros.api_find_and_modify:
ignore_dynamic: false ignore_dynamic: false
+3 -3
View File
@@ -29,13 +29,13 @@
community.routeros.api_modify: community.routeros.api_modify:
path: interface veth path: interface veth
data: data:
- address: 172.17.0.2/16,2001:470:61a3:500::1/64 - address: 172.20.0.2/24,2001:470:61a3:500::1/64
container-mac-address: 7E:7E:A1:B1:2A:7C container-mac-address: 7E:7E:A1:B1:2A:7C
dhcp: false dhcp: false
gateway: 172.17.0.1 gateway: 172.20.0.1
gateway6: 2001:470:61a3:500:ffff:ffff:ffff:ffff gateway6: 2001:470:61a3:500:ffff:ffff:ffff:ffff
mac-address: 7E:7E:A1:B1:2A:7B mac-address: 7E:7E:A1:B1:2A:7B
name: veth1 name: veth-tailscale
comment: Tailscale container comment: Tailscale 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 -3
View File
@@ -14,6 +14,4 @@ openbao_fields:
path: wan_pppoe path: wan_pppoe
username_key: username username_key: username
password_key: password password_key: password
routeros_tailscale_container:
path: router_tailscale
container_password_key: container_password
+1 -1
View File
@@ -18,7 +18,7 @@ spec:
chart: chart:
spec: spec:
chart: authentik chart: authentik
version: 2026.5.0 version: 2026.5.2
sourceRef: sourceRef:
kind: HelmRepository kind: HelmRepository
name: authentik name: authentik
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
spec: spec:
containers: containers:
- name: teamspeak3 - name: teamspeak3
image: teamspeak:3.13.7 image: teamspeak:3.13.8
ports: ports:
- containerPort: 9987 - containerPort: 9987
name: voice name: voice
+1 -1
View File
@@ -8,7 +8,7 @@ spec:
interval: 24h interval: 24h
url: https://github.com/usekaneo/kaneo.git url: https://github.com/usekaneo/kaneo.git
ref: ref:
tag: v2.7.4 tag: v2.7.7
ignore: | ignore: |
# exclude all # exclude all
/* /*
+2 -2
View File
@@ -4,11 +4,11 @@ logToStdout: "both" # proxy and upstream
macros: macros:
base_args: "--no-warmup --port ${PORT} --mlock --no-mmap" base_args: "--no-warmup --port ${PORT} --mlock --no-mmap"
common_args: "--fit-target 1536 --no-warmup --port ${PORT} --mlock --no-mmap" common_args: "--fit-target 256 --no-warmup --port ${PORT} --no-mmap -tb 12 -t 6"
cpu_args: "--no-warmup --port ${PORT} -ngl 0" cpu_args: "--no-warmup --port ${PORT} -ngl 0"
ctx_64k: "--ctx-size 65536" ctx_64k: "--ctx-size 65536"
ctx_128k: "--ctx-size 131072" ctx_128k: "--ctx-size 131072"
ctx_256k: "--ctx-size 262144" ctx_256k: "--ctx-size 131072"
qwen35_think_args: "--temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.00 -ctk q4_0 -ctv q4_0 --presence_penalty 1.5 --reasoning on" qwen35_think_args: "--temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.00 -ctk q4_0 -ctv q4_0 --presence_penalty 1.5 --reasoning on"
qwen35_nothink_args: "--temp 0.7 --top-p 0.80 --top-k 20 --min-p 0.00 -ctk q4_0 -ctv q4_0 --presence_penalty 1.5 --reasoning off" qwen35_nothink_args: "--temp 0.7 --top-p 0.80 --top-k 20 --min-p 0.00 -ctk q4_0 -ctv q4_0 --presence_penalty 1.5 --reasoning off"
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"
+5 -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-23 image: ghcr.io/mostlygeek/llama-swap:unified-vulkan-2026-05-31
command: command:
- sh - sh
- -c - -c
@@ -48,13 +48,16 @@ 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-23 image: ghcr.io/mostlygeek/llama-swap:unified-vulkan-2026-05-31
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
command: command:
- llama-swap - llama-swap
args: args:
- --config=/config/config.yaml - --config=/config/config.yaml
- --watch-config - --watch-config
env:
- name: RADV_EXPERIMENTAL
value: transfer_queue
ports: ports:
- containerPort: 8080 - containerPort: 8080
name: http name: http
+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.3.0 image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.4.0
ports: ports:
- containerPort: 8880 - containerPort: 8880
name: http name: http
+1 -1
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
+2 -1
View File
@@ -18,7 +18,7 @@ spec:
chart: chart:
spec: spec:
chart: woodpecker chart: woodpecker
version: 3.6.3 version: 3.6.4
sourceRef: sourceRef:
kind: HelmRepository kind: HelmRepository
name: woodpecker name: woodpecker
@@ -50,6 +50,7 @@ spec:
WOODPECKER_OPEN: "true" WOODPECKER_OPEN: "true"
# Make lumpiasty admin # Make lumpiasty admin
WOODPECKER_ADMIN: GiteaAdmin WOODPECKER_ADMIN: GiteaAdmin
WOODPECKER_PLUGINS_PRIVILEGED: woodpeckerci/plugin-docker-buildx
createAgentSecret: true createAgentSecret: true
+492
View File
@@ -0,0 +1,492 @@
# App deployment guidelines
This document summarizes current guidelines, requirements, common patterns, and standards that newly deployed apps should meet.
## Structure
Each app on cluster should be contained in its own kustomization living in subdirectory under [apps](/apps) and imported from main [apps kustomization](/apps/kustomization.yaml). Apps that provide infrastructural services belong to [infra](/infra). Few examples:
- **Open WebUI**: Web app, belongs in [apps/openwebui](/apps/openwebui/) together with its direct and unique dependencies eg. database
- **llama-swap** (llama.cpp + whisper + stablediffusion): Inference server, service used by other deployments on cluster but does not manages cluster, belongs in [apps/llama](/apps/llama/)
- **kokoro**: Text to speech inference server, also service used by other deployments, I consider it closely related to llama-swap, so due to arbitrary decision, keeping it together with llama-swap under [apps/llama](/apps/llama/)
- **crawl4ai**: Web scraper, another service used only by other apps, belongs in [apps/crawl4ai](/apps/crawl4ai/)
- **Gitea**: Code forge, despite being essential for overall architecture (holding cluster's code) is not a core cluster software, belongs in [apps/gitea](/apps/gitea/)
- **Woodpecker**: Continous Integration system, belongs in [apps/woodpecker](/apps/woodpecker/)
- **Cilium**: Kubernetes CNI, core cluster functionality, belongs in [infra/controllers/cilium.yaml](/infra/controllers/cilium.yaml)
- **Nginx Ingress Controller**: Provides ingress kubernetes functionality, belongs in [infra/controllers/nginx-ingress.yaml](/infra/controllers/nginx-ingress.yaml)
- **CloudNativePG**: Kubernetes PostgreSQL operator, belongs in [infra/controllers/cloudnative-pg.yaml](/infra/controllers/cloudnative-pg.yaml)
- **OpenBao** Secret storage and Kubernetes operator, belongs in [infra/controllers/openbao.yaml](/infra/controllers/openbao.yaml)
Kustomizations are reconciled on `git push` by flux running on cluster, triggered by [Woodpecker job](/.woodpecker/flux-reconcile-source.yaml). App Kustomization should import all resources related to app in `kustomization.yaml`:
```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- pvc.yaml
- release.yaml
```
## Namespace
Each app kustomization should have its own kubernetes namespace to contain all resources related to app in `namespace.yaml`:
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: immich
```
## Helm charts
If app is distributed via Helm chart, you can deploy it using flux HelmRepository and HelmRelease resources like in following example:
```yaml
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: secustor
namespace: immich
spec:
interval: 24h
url: https://secustor.dev/helm-charts
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: immich
namespace: immich
spec:
interval: 30m
chart:
spec:
chart: immich
version: 1.2.6
sourceRef:
kind: HelmRepository
name: secustor
values:
<values>
```
If the app does not have a helm repository, but helm chart is available in git repository directly in repository, you can make use of it using GitRepository flux source:
```yaml
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: kaneo
namespace: kaneo
spec:
interval: 24h
url: https://github.com/usekaneo/kaneo.git
ref:
tag: v2.7.5
ignore: |
# exclude all
/*
# include charts directory
!/charts/
```
You can use third-party helm charts to deploy applications, consider this possibility if:
- There is no official helm chart for the application
- The official helm chart is unmaintained
- The official helm chart is using glaring bad practices
- The official helm chart is missing configuration options for what we need
When deciding which helm chart to use, watch out for following things in particular:
- Development activity, stability, maturity
- Whether the app deployed by chart is up to date - automated updates are large bonus
- Unresolved / breaking issues
- Configurability, can we configure things we need, disable undesired features
When configuring Helm chart, keep in mind:
- Do not use bundled PVCs, bring our own one or at least configure chart to bind it to manually created `PersistentVolume` according to [Data / PVCs pattern](#data--pvcs-pattern)
- Do not use bundled Postgres database unless the chart is using CloudNativePG's Cluster resource, bring our own one using [Postgres operator](#postgres-operator)
- do not
## Bare Kubernetes deployments
If:
- the app is not packaged as a helm chart or
- it would be simpler to deploy it without package (for example custom privileged pod with access to gpu) or
- the app is so simple it doesn't make sense to make helm package it (for example, simple http proxy that alters headers or stateless single-binary app) or
- for any other reason it would make more sense to skip helm
You can deploy app skipping helm chart and just create raw Kubernetes manifests like Deployment, StatefulSet and other supporting resources like ConfigMap, Service, Ingress directly.
## Data / PVCs pattern
Data are stored on local disk of node using OpenEBS LVM LocalPV. To create a persistent volume, use following example:
```yaml
---
apiVersion: local.openebs.io/v1alpha1
kind: LVMVolume
metadata:
labels:
kubernetes.io/nodename: anapistula-delrosalae
name: immich-library-lvmhdd
namespace: openebs
spec:
capacity: 150Gi
ownerNodeID: anapistula-delrosalae
shared: "yes"
thinProvision: "no"
vgPattern: ^openebs-hdd$
volGroup: openebs-hdd
---
kind: PersistentVolume
apiVersion: v1
metadata:
name: immich-library-lvmhdd
spec:
capacity:
storage: 150Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: hdd-lvmpv
volumeMode: Filesystem
csi:
driver: local.csi.openebs.io
fsType: btrfs
volumeHandle: immich-library-lvmhdd
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: library-lvmhdd
namespace: immich
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 150Gi
storageClassName: hdd-lvmpv
volumeName: immich-library-lvmhdd
```
Create LVMVolume and PersistentVolume resources manually and **do not** rely on automatic scheduling of PVCs because we want created LVM LVs on disk to have deterministic names and be reused if already exist on disk, which scheduler does not give us. There are two LVM storage classes:
- **hdd-lvmpv**, volume group: openebs-hdd, use for bulk data, like media library
- **ssd-lvmpv**, volume group: openebs-ssd, use for small datasets that benefit from quick storage access like databases, state data etc.
When deciding the size of the volume, make minimal prediction, starting with 1GiB if you do not predict app to use much disk space.
## Vault secrets
There is OpenBao installed on cluster that manages access to secrets. The KV2 secret engine is mounted at `secret`, use it to store static secrets like API keys to external services, passwords and other entries you do not want to keep in plaintext in git repository.
To access the KV secrets on cluster, use Vault Secrets Operator installed on cluster, which provides `VaultStaticSecret` custom resource that syncs a path from OpenBao to Kubernetes `Secret` object.
```yaml
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: llama-proxy
namespace: llama
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: llama
namespace: llama
spec:
method: kubernetes
mount: kubernetes
kubernetes:
role: llama-proxy
serviceAccount: llama-proxy
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: llama-api-key
namespace: llama
spec:
type: kv-v2
mount: secret
path: ollama
destination:
create: true
name: llama-api-key
type: Opaque
transformation:
excludeRaw: true
vaultAuthRef: llama
```
To give access to specified secret for given k8s ServiceAccount, you need to create kubernetes auth role and policy. Create a kubernetes auth role named `llama-proxy`, by creating file `vault/kubernetes-auth-roles/llama-proxy.yaml`:
```yaml
bound_service_account_names:
- llama-proxy
bound_service_account_namespaces:
- llama
token_policies:
- ollama
```
Create policy named `ollama` by creating file `vault/policy/ollama.hcl`:
```hcl
path "secret/data/ollama" {
capabilities = ["read"]
}
```
Once these files are created, ask operator to reconcile OpenBao configuration and create required secret.
## Postgres operator
There is CloudNativePG operator installed on cluster that manages databases of applications running on cluster. You can create Postgres database by creating `Cluster` resource:
```yaml
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: kaneo-db
namespace: kaneo
spec:
instances: 1
storage:
pvcTemplate:
storageClassName: ssd-lvmpv
resources:
requests:
storage: 10Gi
volumeName: kaneo-db-1
```
Create a `PersistentVolume` and `LVMVol` resources manually but **do not** create `PersistentVolumeClaim`, CloudNativePG will create one on its own referencing `PersistentVolume` specified in `volumeName`. Do not replicate the database, there is only one node in the cluster currently. The `Cluster` resource will automatically create secret, use it to configure app:
```
Name: kaneo-db-app
Namespace: kaneo
Labels: app.kubernetes.io/managed-by=cloudnative-pg
cnpg.io/cluster=kaneo-db
cnpg.io/reload=true
cnpg.io/userType=app
Annotations: cnpg.io/operatorVersion: 1.29.1
Type: kubernetes.io/basic-auth
Data
====
dbname: 3 bytes
fqdn-jdbc-uri: 145 bytes
fqdn-uri: 126 bytes
host: 11 bytes
jdbc-uri: 127 bytes
password: 64 bytes
pgpass: 90 bytes
port: 4 bytes
uri: 108 bytes
user: 3 bytes
username: 3 bytes
```
## LoadBalancers
You can expose installed app to the Internet using Cilium's LoadBalancer configured on cluster:
```yaml
apiVersion: v1
kind: Service
metadata:
name: teamspeak3
namespace: ispeak3
spec:
selector:
app: teamspeak3
ports:
- name: voice
protocol: UDP
port: 9987
targetPort: 9987
- name: filetransfer
protocol: TCP
port: 30033
targetPort: 30033
type: LoadBalancer
externalTrafficPolicy: Local
ipFamilyPolicy: PreferDualStack
```
IPv6 will be directly reachable from the internet by its assigned address, for IPv4 currently you need to configure port forward on router in `ansible/roles/routeros/firewall.yml`, that step is not yet automated. The assigned internal IP will be known after manifests are applied on cluster. For this reason, there is no ExternalDNS configured yet, if you need a DNS name, ask the operator to configure DNS name for LoadBalancer. Assign names from lumpiasty.xyz subdomains (eg. kaneo.lumpiasty.xyz) unless explicitly requested. Do not use LoadBalancer for exposing HTTP applications, use Ingress instead.
## Ingress
You can expose HTTP applications using NGINX Ingress Controller:
```yaml
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
namespace: llama
name: llama
annotations:
cert-manager.io/cluster-issuer: letsencrypt
acme.cert-manager.io/http01-edit-in-place: "true"
nginx.ingress.kubernetes.io/proxy-buffering: "false"
nginx.ingress.kubernetes.io/proxy-read-timeout: 30m
nginx.ingress.kubernetes.io/proxy-body-size: 8m
spec:
ingressClassName: nginx-ingress
rules:
- host: llama.lumpiasty.xyz
http:
paths:
- backend:
service:
name: llama-proxy
port:
number: 80
path: /
pathType: Prefix
tls:
- hosts:
- llama.lumpiasty.xyz
secretName: llama-ingress
```
TLS certificates are automatically issued for subdomains of lumpiasty.xyz using cert-manager. DNS name assignment is not automatic yet, ask operator to create DNS name for ingress resources.
## Keeping app up to date
There is a Renovate job configured for this repository as [Woodpecker job](/.woodpecker/renovate.yaml) to keep applications up to date. Renovate automatically keeps track of:
- Docker images specified in Kubernetes manifests like Deployment, StatefulSet etc
- HelmRelease versions
- GitRepository tags
To make Renovate automatically update applications, always specify full versions of docker images or helm chart release. If you use ambigous tags, renovate will not have chance to update and the cluster will never download new image because this tag already existed on node. **Do not** use:
- latest (or its variants like stable, current, main, master current)
- "Sliding" versions, like 1 or 1.2 that point at 1.2.1 currently and will change image it points at when version 1.2.2 is released
As a last resort if the application does not publish stable image tags, pin digest of image.
Renovate may require custom configuration if:
- App is using non-standard versioning schema
Example app versioned by date (unified-vulkan-2026-01-01), renovate.json:
```json
{
"matchDatasources": ["docker"],
"matchPackageNames": ["ghcr.io/mostlygeek/llama-swap"],
"versioning": "regex:^unified-vulkan-(?<major>\\d{4})-(?<minor>\\d{2})-(?<patch>\\d{2})$",
"automerge": true,
"automergeType": "pr",
"platformAutomerge": true
}
```
- Docker image tag is specified in non-standard field that Renovate may not recognise automatically such as Helm values
Example app with non-standard image selected in helm values instead of image's default (which is latest in this chart):
```yaml
values:
kaneo:
image:
tag: "2.7.3" # renovate: depName=ghcr.io/usekaneo/kaneo registryUrl=https://ghcr.io
```
Renovate is configured so it automatically merges patch versions, other updates are created as pull requests to be manually reviewed and merged unless explicitly desired on per case basis.
## SSO / OIDC / Authentik
There is an Authentik running on cluster providing SSO for applications. Configure user-facing apps to utilize it correctly.
Authentik supports following protocols:
- OAuth2 / OpenID Connect
- SAML
- Radius
- LDAP
- SCIM
Currently, there is no Authentik configuration in code, ask operator to create application in the UI and save OAuth id and secret in OpenBao under `secret/authentik/<app>`. Authentik provides discovery URL for OAuth applications: `https://authentik.lumpiasty.xyz/application/o/<app slug>/.well-known/openid-configuration`.
Configure the app to disable guest access, built-in registration and automatically register unprivileged users with `user` role and privileged users with `admin` role as the app allows.
## Privileged apps
Some apps require direct access to devices, like GPU. There are no specific operators yet, apps that require access to GPU are simply launched as privileged pods, example:
```yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: llama-swap
namespace: llama
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: llama-swap
template:
metadata:
labels:
app: llama-swap
spec:
containers:
- name: llama-swap
volumeMounts:
- mountPath: /dev/kfd
name: kfd
- mountPath: /dev/dri
name: dri
securityContext:
privileged: true
volumes:
- name: kfd
hostPath:
path: /dev/kfd
type: CharDevice
- name: dri
hostPath:
path: /dev/dri
type: Directory
```
Creating of such pods is forbidden unless explicitly allowed in Talos config:
```yaml
# CSI driver requirement
cluster:
apiServer:
admissionControl:
- name: PodSecurity
configuration:
apiVersion: pod-security.admission.config.k8s.io/v1beta1
kind: PodSecurityConfiguration
exemptions:
namespaces:
- llama
```
Create the patch like this under `talos/patches/<app>.patch`, add it to `gen-talos-config` target in Makefile and ask operator to apply reconcile Talos config to allow privileged pods in specified namespace.
+13
View File
@@ -0,0 +1,13 @@
<svg width="136" height="136" viewBox="0 0 136 136" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_137_2)">
<rect width="136" height="136" fill="#141414"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M62.6855 103.724C58.5716 104.595 56.0001 103.265 56 101.66L56 70.0264C56 69.8606 56.0032 69.686 56.0088 69.5069C56.0039 69.3848 56.001 69.249 56.001 69.0977L56.001 37.9444C56.015 36.6524 56.2588 35.7449 59.2588 35.1094L73.3145 32.2764C77.4285 31.405 80 32.7365 80 34.3408L80 65.9746C80 66.1409 79.9978 66.3155 79.9922 66.4951C79.997 66.6169 79.999 66.7526 79.999 66.9033L79.999 98.0567C79.9849 99.3483 79.7408 100.256 76.7412 100.892L62.6855 103.724Z" fill="#F5F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.6855 111.723C26.5716 112.594 24.0001 111.264 24 109.659L24 78.0244C24 77.8588 24.0032 77.6848 24.0088 77.5059C24.0039 77.3838 24.001 77.248 24.001 77.0967L24.001 45.9434C24.015 44.6514 24.2588 43.7439 27.2588 43.1084L41.3145 40.2754C45.4285 39.404 48 40.7355 48 42.3399L48 73.9737C48 74.1399 47.9978 74.3146 47.9922 74.4942C47.997 74.6159 47.999 74.7517 47.999 74.9024L47.999 106.056C47.9849 107.347 47.7408 108.255 44.7412 108.891L30.6855 111.723Z" fill="#F5F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M105.314 24.2754C109.428 23.404 112 24.7355 112 26.3398V37.1845L94.0576 60.5019L111.999 82.7802V90.0576C111.985 91.3492 111.741 92.2571 108.741 92.8925L94.6855 95.7246C90.5717 96.596 88.0002 95.2654 88 93.6611V62.0254C88 61.8598 88.0032 61.6856 88.0088 61.5068C88.0039 61.3848 88.001 61.2488 88.001 61.0976V29.9433C88.0151 28.6516 88.2591 27.7438 91.2588 27.1084L105.314 24.2754Z" fill="#F5F5F5"/>
</g>
<defs>
<clipPath id="clip0_137_2">
<rect width="136" height="136" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+255
View File
@@ -0,0 +1,255 @@
# LTE Failover Design
Reference documentation of the as-built LTE failover design. For day-to-day
network overview see [network.md](./network.md); for BM806C modem firmware
workarounds see [wwan-bm806c-qmi-workaround.md](./wwan-bm806c-qmi-workaround.md).
## Summary
| Property | Value |
|---|---|
| Failover signalling | Symmetric iBGP between D-Link (BIRD2) and CRS (RouterOS) |
| BGP AS | 65000 (iBGP; CRS acts as route reflector for D-Link) |
| LTE transit path | D-Link wwan ← VLAN 6 (192.168.6.0/24) ← CRS |
| D-Link default route source | Learned from CRS via BGP (no static default gateway) |
| CRS LTE route source | Learned from D-Link via BGP at distance 200 |
| Announcement trigger | wwan interface up/down tracked by BIRD2 device protocol |
| Scope | All internet-capable VLANs (vlan2, vlan4, vlan5, vlan6) |
| IPv4 NAT | CRS masquerades on `pppoe-gpon` only; D-Link masquerades on `wwan` |
| IPv6 NAT | D-Link masquerades IPv6 on `wwan` (no inbound on LTE; outbound only) |
| wwan bringup | Triggered by `/etc/init.d/wwan-bringup` after USB re-auth (BM806C wedge fix) |
## Route exchange
### CRS announces to D-Link
| Prefix | Source | Withdrawn when |
|---|---|---|
| `0.0.0.0/0` | `output.default-originate: if-installed` (active default in main table) | GPON drops or `pppoe-gpon` route inactive |
| `2000::/3` | `output.redistribute: static` (HE tunnel default) | `sit1` interface down / HE route inactive |
| VLAN subnets (`192.168.0.0/24`, `192.168.1.0/24`, etc.) | `output.redistribute: connected` | never (CRS always reachable on vlan6) |
| `100.64.0.0/10` (Tailscale) | `output.redistribute: static` | never |
| `172.17.0.0/16` (dockers bridge) | `output.redistribute: connected` | never |
| `10.42.0.0/16`, `10.43.0.0/16`, `10.44.0.0/16` (k8s) | reflected via iBGP RR | when k8s BGP session drops |
| pod/service/LB IPv6 ranges | reflected via iBGP RR | when k8s BGP session drops |
Internal prefixes are announced regardless of GPON state. They remain
reachable via `192.168.6.1` (directly connected on vlan6) even when GPON
fails, so D-Link-originated traffic to internal subnets always routes to
CRS rather than incorrectly exiting via wwan.
The CRS route reflector role (`local.role: ibgp-rr` on the `dlink-lte`
connection) allows it to reflect routes learned from the k8s peer (`bgp1`)
to D-Link without violating iBGP split-horizon. RFC 4456 `ORIGINATOR_ID`
loop prevention is handled automatically by RouterOS — no output filter
needed.
`nexthop-choice: force-self` ensures CRS advertises `192.168.6.1` as the
next-hop for all prefixes, rather than the original route's next-hop
(which may be unreachable from D-Link, e.g. k8s peer `2001:470:61a3:100::3`).
### D-Link announces to CRS
| Prefix | Source | Withdrawn when |
|---|---|---|
| `0.0.0.0/0` | BIRD2 static `lte_default` via `wwan0` | wwan0 down (device protocol detects) |
| `2000::/3` | BIRD2 static `lte_default6` via `wwan0` | wwan0 down |
BIRD2's `protocol device` tracks wwan0 via netlink in real time; when the
interface goes down the static routes become unreachable and BGP withdraws
the announcements immediately.
The BIRD2 static routes use `preference 50` (below the BGP default of 100)
so the BGP-learned routes from CRS are preferred for kernel installation
on D-Link itself — D-Link's own outbound traffic uses the CRS path when
GPON is up. The static routes only exist as triggers for BGP export.
### D-Link kernel routing table
| Destination | Source | Kernel metric | Active when |
|---|---|---|---|
| Internal prefixes (VLANs, k8s, Tailscale) | BGP from CRS, via `192.168.6.1` | 10 (IPv4) / 32 (IPv6) | always (CRS reachable) |
| `0.0.0.0/0` | BGP from CRS | 10 | GPON up |
| `0.0.0.0/0` | wwan QMI-assigned (qmi.sh) | 100 | wwan up |
| `default via wwan IPv6 GW` (non-source-specific) | wwan-bringup script | 1024 | wwan up |
| `default from <wwan prefix>/64 via wwan IPv6 GW` (source-specific) | qmi.sh | 100 | wwan up |
D-Link's own outbound traffic prefers the BGP route (metric 10) over wwan
(metric 100). The non-source-specific IPv6 default at metric 1024 exists
because qmi.sh only installs a source-specific IPv6 default (constrained
to the wwan-assigned `/64` prefix); forwarded traffic from internal
subnets would fail routing lookup with "net unreachable" without it.
### CRS routing table
| Destination | Source | Distance | Active when |
|---|---|---|---|
| `0.0.0.0/0` | static via `pppoe-gpon` | 1 | GPON up |
| `0.0.0.0/0` | BGP from D-Link via `192.168.6.2` | 200 | wwan up on D-Link |
| `2000::/3` | static via `sit1` (HE tunnel) | 1 | sit1 active (HE tunnel works) |
| `2000::/3` | BGP from D-Link via `2001:470:61a3:600::2` | 200 | wwan up on D-Link |
RouterOS distance comparison is straightforward: distance 1 always wins
over distance 200. BGP-learned routes activate automatically when the
static route becomes inactive (e.g. GPON down → `pppoe-gpon` route
inactive → BGP route at distance 200 becomes active).
## Traffic paths
### Normal (GPON up)
```
LAN/SRV/IoT → CRS → pppoe-gpon → ISP
D-Link own → uplink → CRS → pppoe-gpon → ISP
(via BGP-learned default at kernel metric 10)
```
wwan is connected and D-Link announces the LTE default to CRS, but CRS
ignores it (distance 200 loses to distance 1). D-Link uses the
CRS-announced default (metric 10) for its own traffic, not wwan
(metric 100).
### Failover (GPON down)
```
LAN/SRV/IoT → CRS → vlan6 (→192.168.6.2) → D-Link → wwan → Orange LTE
D-Link own → wwan → Orange LTE
```
CRS distance-1 routes go inactive → distance-200 BGP routes from D-Link
activate. D-Link receives forwarded traffic on uplink, routes it via the
non-source-specific wwan default (metric 1024), fw4 masquerades the
source, packet exits via wwan. Return traffic reverses through masquerade
state and forwards back to CRS via the established connection-tracking
entry.
When CRS withdraws its BGP-announced default to D-Link (because GPON is
down and CRS has no default to announce), D-Link's kernel default at
metric 10 is removed, leaving the wwan default at metric 100 as the
preferred route for D-Link's own traffic.
### Failure detection
- **D-Link crashes / power loss** → BGP session drops after `hold-time: 30s`
→ CRS withdraws all D-Link-learned routes → internet unavailable if
GPON also down (acceptable single-point-of-failure for home network)
- **wwan modem goes down** → BIRD2 device protocol detects wwan0 down →
static `lte_default` / `lte_default6` become unreachable → BGP withdraws
announcements → CRS removes BGP-learned default
- **GPON drops** → `pppoe-gpon` interface down → CRS distance-1 default
route inactive → distance-200 BGP route activates → CRS withdraws its
default-originate announcement to D-Link (since no default is installed
any more) → D-Link's kernel default-via-CRS is removed → D-Link uses
wwan kernel default → traffic flows from CRS via vlan6 → D-Link → wwan
All transitions are automatic and driven by interface state. No active
probing (Netwatch / mwan3), no scripts toggling routes.
## NAT rules
NAT rules are always active, matched by output interface. No
failover-triggered toggling needed.
### CRS (RouterOS)
- IPv4 `masquerade` on `srcnat` chain with `out-interface: pppoe-gpon`.
Only the GPON public interface gets masqueraded — `vlan6` is internal
and never natted, `sit1` (IPv6) has its own dedicated src-nat for the
Tailscale prefix.
- IPv6 `src-nat tailnet to internet` on `srcnat` chain for Tailscale
prefix (`fd7a:115c:a1e0::/48`) to `2001:470:61a3:600::/64`, applied
on `out-interface-list: wan`. Fires regardless of whether the
egress is `sit1` or `vlan6`.
### D-Link (OpenWrt fw4)
- `wwan` zone has `option masq '1'` and `option masq6 '1'`. All traffic
exiting via wwan (own outbound + forwarded from `uplink`) is
source-NAT'd, IPv4 to the wwan-assigned CG-NAT IP, IPv6 to the
wwan-assigned `/128` from the Orange-assigned `/64` prefix.
- Forwarding rule `uplink → wwan` allows MikroTik-routed traffic to
egress via wwan during failover. Default forward policy on the wwan
zone stays REJECT.
## BGP / route reflection details
### CRS connection config
```
/routing/bgp/connection set dlink-lte \
remote.address=192.168.6.2/32 \
local.role=ibgp-rr \
nexthop-choice=force-self \
output.redistribute=connected,static \
output.default-originate=if-installed \
hold-time=30s keepalive-time=10s
```
`output.default-originate=if-installed` is required for the `0.0.0.0/0`
advertisement because RouterOS does not advertise interface-gateway
static routes (gateway=`pppoe-gpon`) via plain `output.redistribute=static`.
`default-originate` advertises a synthetic default whenever any active
default exists in the routing table, regardless of how it was installed.
### IPv6 Extended Next Hop workaround
RouterOS uses BGP Extended Next Hop Encoding (RFC 5549 / RFC 8950) for
IPv6 routes on this iBGP session, advertising them with an IPv4-mapped
next-hop (`::ffff:192.168.6.1`). The Linux kernel does not support
installing IPv6 routes with IPv4 next-hops, so BIRD2 cannot push them
directly to the kernel.
There is no way to disable ENHE on RouterOS — `local.address`,
`nexthop-choice: force-self`, and output `set gw` filters all fail to
override it. The workaround is on the BIRD2 side: an import filter on
the BGP IPv6 channel rewrites `gw` to CRS's native IPv6 address
(`2001:470:61a3:600::1`) before the route is exported to the kernel.
```
ipv6 {
extended next hop yes;
import filter {
gw = 2001:470:61a3:600::1;
accept;
};
...
};
```
The reverse direction (D-Link → CRS) was solved cleanly via BIRD2 export
filter setting `bgp_next_hop = 2001:470:61a3:600::2`, since BGP-level
attribute manipulation isn't constrained by kernel limitations.
### Direct protocol on D-Link
BIRD2 needs to know about the directly connected `192.168.6.0/24` and
`2001:470:61a3:600::/64` subnets on `eth0.6` to resolve BGP next-hops.
The `protocol direct { interface "eth0.6"; }` declaration provides this;
without it BIRD2 marks all CRS-learned routes as unreachable.
## BM806C modem cold-boot wedge
The BM806C firmware enters a permanently broken state on cold boot:
`/dev/cdc-wdm0` exists, kernel driver attaches, but uqmi commands return
`"Failed to connect to service"` indefinitely. UIM (SIM) QMI service
specifically never comes up.
Recovery requires a USB device re-enumeration. The `/etc/init.d/wwan-bringup`
service writes `0` then `1` to `/sys/bus/usb/devices/1-1/authorized` on
boot, then triggers `ifup wwan`. After re-auth the modem completes its
QMI initialization within ~1 second.
Full investigation: see [wwan-bm806c-qmi-workaround.md](./wwan-bm806c-qmi-workaround.md).
## Implementation files
| File | Role |
|---|---|
| `ansible/roles/routeros/tasks/base.yml` | `vlan6` in `wan` interface list |
| `ansible/roles/routeros/tasks/routing.yml` | BGP instance, template, `dlink-lte` connection |
| `ansible/roles/routeros/tasks/firewall.yml` | IPv4 masquerade narrowed to `pppoe-gpon`; BGP input rules for `vlan6` |
| `ansible/roles/openwrt/tasks/network.yml` | `wwan` interface (no auto bringup); `uplink` with no static gateway |
| `ansible/roles/openwrt/tasks/firewall.yml` | `wwan` zone with `masq '1'` / `masq6 '1'`; `uplink → wwan` forwarding |
| `ansible/roles/openwrt/tasks/bird.yml` | BIRD2 install + config |
| `ansible/roles/openwrt/tasks/wwan.yml` | qmi.sh patches, BM806C profiles, wwan-bringup init script |
| `ansible/roles/openwrt/defaults/main.yml` | `bird2` in `openwrt_packages` |
+137
View File
@@ -0,0 +1,137 @@
# Network topology
Network consists of 2 MikroTik routers, 1 OpenWRT router, UniFi AP, Netgear switch. Internet is connected via GPON ONU connected to MikroTik router with fallback LTE network in D-Link router. They are connected like in the diagram below below:
```mermaid
flowchart TD
crs[Router\nMikroTik CRS418-8P-8G-2S+]
hex[Router\nMikroTik hEX S]
dlink[Router\nD-Link DWR-921 C3\nOpenWRT 25.12]
unifi[Access Point\nUniFi U7 Pro]
netgear[Ethernet switch\nNetgear GS108E]
gpon[SFP+ GPON ONU\nLEOX LXT-010S-H]
isp[ISP]@{ shape: cloud }
lte[LTE Network]@{ shape: cloud }
isp --- gpon --- crs
lte ----- dlink
crs --- dlink
crs --- hex
crs --- unifi
crs --- netgear
```
Above diagram lists only active network devices, does not show passive/unmanaged network elements or nodes.
## Internal structure
Network is divided to multiple VLANs to enforce strict access control rules using stateful firewall. There are 6 VLANs:
- 1: Management network<br>
No internet access, no outbound access to other networks<br>
IP: 192.168.255.0/24<br>
Static IP configuration
- 2: General purpose LAN<br>
Access to every other network<br>
IP: 192.168.0.0/24 / 2001:470:61a3:9::/64<br>
Gateway: 192.168.0.1 / 2001:470:61a3:9:ffff:ffff:ffff:ffff<br>
DHCP / SLAAC
- 3: Cameras<br>
No internet access, no outbound access to other networks<br>
IP: 192.168.3.0/24<br>
Gateway: 192.168.3.1<br>
Static IP configuration
- 4: Server LAN (k8s cluster)<br>
Access to internet, cameras<br>
IP: 192.168.1.0/24 / 2001:470:61a3:100::/64<br>
Gateway: 192.168.1.1 / 2001:470:61a3:100::1<br>
Static IP configuration
- 5: IoT Network<br>
Internet access only<br>
IP: 192.168.5.0/24 / 2001:470:61a3:a::/64<br>
Gateway: 192.168.5.1 / 2001:470:61a3:a:ffff:ffff:ffff:ffff<br>
DHCP / SLAAC, accessible via separate WiFi network "szafa" from D-Link for absolutely untrusted Tuya and like devices
- 6: Internet access for OpenWRT<br>
Internet access only<br>
IP: 192.168.6.0/24 / 2001:470:61a3:600::/64<br>
Gateway: 192.168.6.1/24 / 2001:470:61a3:600::1/64<br>
Static IP configuration
VLANs are connected between devices like on following diagram:
```mermaid
flowchart TD
crs[Router\nMikroTik CRS418-8P-8G-2S+]
hex[Router\nMikroTik hEX S]
dlink[Router\nD-Link DWR-921 C3\nOpenWRT 25.12]
unifi[Access Point\nUniFi U7 Pro]
netgear[Ethernet switch\nNetgear GS108E]
crs -- Untagged 1\nTagged 5,6 --- dlink
crs -- Untagged 1\nTagged 2,3 --- hex
crs -- Untagged 2 --- unifi
crs -- Untagged 1\nTagged 2--- netgear
```
There are also networks, which are not VLANs, but are routed:
- Tailscale, container on CRS<br>
Access to every other network, including internet (exit node)<br>
IP: 100.64.0.0/10 / fd7a:115c:a1e0::/48<br>
Allocations managed completely by Tailscale
- Kubernetes cluster, routes exposed to CRS via BGP using Cilium<br>
Access to internet, cameras<br>
Pods: 10.42.0.0/16 (/24 subnet per node), 2001:470:61a3:200::/104 (/120 subnet per node)<br>
Service: 10.43.0.0/16, 2001:470:61a3:300::/112<br>
LoadBalancer: 10.44.0.0/16, 2001:470:61a3:400::/112<br>
Assigned by Cilium MultiPool IPAM (pods), kube-apiserver (services), Cilium LB (LoadBalancer)<br>
Native IP routing, no overlay, VXLAN etc.<br>
LoadBalancer is reachable from the internet using IPv6 directly or IPv4 port forwards, leveraging ECMP.
- GPON ONU management<br>
IP: 192.168.100.0/24<br>
Static assignment on CRS, access to factory IP of ONU
- Containers on CRS<br>
Access to every other network<br>
IP: 172.17.0.1/16, 2001:470:61a3:500::/64<br>
Static IP management
Whole network is designed to eliminate VLANs, overlays where unnecessary to keep things simple. Only NAT rules are:
- Masquerade outbound IPv4 via GPON PPPoE
- Masquerade to GPON ONT management<br>
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>
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
There is also an UPnP and NAT-PMP enabled to automatically configure port forwards from LAN.
## Uplink
Main internet connection is a fibre optics (GPON) service from my ISP, which includes static, publicly reachable IPv4 address. I'm using my own GPON ONU, which is a SFP+ module inserted to CRS, I configured it to clone ISP-provided Huawei box. I'm authenticated using PPPoE credentials and it hands out public IP address directly to the router.
One of quirks of the ISP is that it doesn't allow incoming port 53/DNS connections, which disables me from hosting DNS server, I was wanting to do to configure reverse DNS for pods IPv6. The configuration for public DNS server is still remaining cluster.
The ISP does not provide any IPv6 connectivity at all. For that purpose I'm using [tunnel broker from Hurricane Electric](https://tunnelbroker.net/), which gives /48 routed prefix that I divided to /64 networks.
The backup internet link is an LTE connection via the embedded BroadMobi BM806C modem in the D-Link router (Orange Poland, dual-stack). The SIM was previously in a USB modem attached directly to the CRS; it was moved to the D-Link to reduce rack clutter and gain access to a proper modem interface. The modem requires firmware-level workarounds — QMI data-plane bugs, a cold-boot UIM wedge that needs USB re-enumeration — documented in [LTE failover (BroadMobi BM806C / D-Link DWR-921 C1) — QMI data-plane workaround](./wwan-bm806c-qmi-workaround.md).
Failover is implemented using iBGP between the D-Link (BIRD2, AS 65000) and the CRS (`local.role: ibgp-rr` so CRS acts as route reflector for D-Link). The D-Link announces `0.0.0.0/0` and `2000::/3` to the CRS whenever its `wwan` interface is up. The CRS installs these at BGP distance 200 — below the GPON static default at distance 1 — so they only become active when GPON fails. The CRS in turn announces all its connected and static routes (VLAN subnets, Tailscale, k8s pod/service/LB prefixes via RR reflection) to the D-Link so it always has explicit routes to internal subnets regardless of WAN state. The D-Link's own default route also comes from this BGP session (no static gateway on the uplink interface); when the CRS withdraws the default on GPON failure, the D-Link falls back to its wwan kernel route at metric 100.
For full design rationale, route exchange tables, and implementation notes including the BGP Extended Next Hop workarounds, see [LTE failover design](./lte-failover-design.md).
During LTE failover, all VLANs route through `vlan6` to the D-Link, which forwards traffic out `wwan` and masquerades it (IPv4 and IPv6 via fw4 `masq`/`masq6`). IPv6 is outbound-only — the carrier enforces an inbound firewall, and there is no routed prefix large enough to cover all internal subnets without NAT.
## Configuration management
Currently, only CRS and D-Link are managed in this repository. Other devices currently have been configured manually using dedicated web interface/tools. The end goal is to have full configuration as code.
Network devices are configured using Ansible with playbooks under [ansible/playbooks](../ansible/playbooks/) subdirectory:
- [openwrt.yml](../ansible/playbooks/openwrt.yml) - Configuration of D-Link router
- [routeros.yml](../ansible/playbooks/routeros.yml) - configures CRS router
There is also one one-time initialisation playbook called [dlink-init.yml](../ansible/playbooks/dlink-init.yml) that is used to configure basic D-Link settings from scratch after configuration reset so it can be accessed from management network.
To reconcile configuration from this repository to device, execute `ansible-playbook playbooks/<playbook>` from `ansible` directory. It will automatically load necessary secrets from vault and start applying configuration. Playbooks without `-init` in their name should be idempotent.
+133 -45
View File
@@ -1,25 +1,35 @@
# LTE failover (BroadMobi BM806C / D-Link DWR-921 C1) — QMI data-plane workaround # LTE failover (BroadMobi BM806C / D-Link DWR-921 C1) — QMI data-plane workaround
Last verified: 2026-05-16, OpenWrt 25.12.2 r32802-f505120278, netifd 2026.02.26~cbb83a18-r1. Last verified: 2026-05-27, OpenWrt 25.12.2 r32802-f505120278, netifd 2026.02.26~cbb83a18-r1.
## TL;DR ## TL;DR
The embedded BroadMobi BM806C modem in the D-Link DWR-921 attaches to The embedded BroadMobi BM806C modem in the D-Link DWR-921 has **three
LTE, gets assigned IP addresses through QMI, reports `"connected"` independent bugs** in its firmware (`M1.2.0_E1.0.1_A1.1.8`, the only
but **no downlink data passes**. Every TCP SYN we send out is dropped build that has ever shipped), all of which must be worked around for a
somewhere between the modem and the host kernel, and we never see a usable LTE uplink:
SYN-ACK. After several hours of layered diagnostics we identified two
independent issues, both of which must be fixed for QMI to work on this
device:
1. **`qmi.sh` requests `802.3` framing** from the modem. 1. **Cold-boot UIM wedge.** On every cold boot, the modem's UIM (SIM)
QMI service comes up permanently broken: `--uim-get-sim-state`
returns `{}`, `--get-imsi` returns `"UIM uninitialized"`, and
`AT+CPIN?` returns `+CME ERROR: SIM busy`. The modem **never
recovers on its own** (verified at uptime 21 min). A single USB
re-enumeration (`echo 0 > /sys/.../1-1/authorized; sleep 3; echo 1
> ...`) forces the modem to redo its internal QMI init from
scratch, after which UIM comes up within ~1 s. The
`wwan-bringup` service installed by this role does the
re-enumeration unconditionally on boot, then calls `ifup wwan`.
Full investigation: `/root/wwan-diag/boot-wedge-investigation.md`
on the router.
2. **`qmi.sh` requests `802.3` framing** from the modem.
The BM806C's `802.3` firmware path is buggy on this generation of The BM806C's `802.3` firmware path is buggy on this generation of
Qualcomm silicon; raw-ip framing works correctly. The same kernel Qualcomm silicon; raw-ip framing works correctly. The same kernel
maintainer who added raw-ip support to `qmi_wwan` documents maintainer who added raw-ip support to `qmi_wwan` documents
"buggy 802.3 firmware implementation" as a known issue for the "buggy 802.3 firmware implementation" as a known issue for the
MDM9x25 family this modem is built on. MDM9x25 family this modem is built on.
2. **`qmi.sh` calls `uqmi --start-network --apn <foo>`** to bring up 3. **`qmi.sh` calls `uqmi --start-network --apn <foo>`** to bring up
the bearer. On BM806C this triggers a known firmware bug the bearer. On BM806C this triggers a known firmware bug
([OpenWrt FS#1363](https://github.com/openwrt/openwrt/issues/6295)) ([OpenWrt FS#1363](https://github.com/openwrt/openwrt/issues/6295))
that establishes a *phantom* bearer: kernel and modem agree there is that establishes a *phantom* bearer: kernel and modem agree there is
@@ -29,18 +39,48 @@ device:
<N>` against a pre-configured NVRAM profile **with the same APN** <N>` against a pre-configured NVRAM profile **with the same APN**
works perfectly. works perfectly.
Our workaround patches `qmi.sh` in two places (raw-ip + a kernel Bug 1 is the boot-time wedge; without the workaround `wwan` simply
`-EBUSY` fix), creates a second NVRAM profile in the modem for the never comes up after a reboot. Bugs 2 and 3 are about the data plane
IPv6 APN, and adds `option profile`/`option v6profile` to the UCI itself; without their workarounds, `wwan` comes up but no traffic
`wwan` interface so `qmi.sh` uses the working code path. After the flows. Our role addresses all three: it installs `wwan-bringup`
workaround, `ifup wwan` produces a fully working dual-stack IPv4 + (re-enumerates the USB device once on boot, then `ifup wwan`), patches
IPv6 LTE uplink — verified end-to-end at HTTPS layer to multiple `qmi.sh` in two places (raw-ip + a kernel `-EBUSY` fix), creates a
second NVRAM profile in the modem for the IPv6 APN, and adds
`option profile`/`option v6profile` to the UCI `wwan` interface so
`qmi.sh` uses the working code path. After all three workarounds,
cold boot to working dual-stack IPv4+IPv6 LTE uplink completes in
~2:303:30 — verified end-to-end at HTTPS layer to multiple
upstreams. upstreams.
## Symptoms ## Symptoms
When QMI is broken on this modem, all of the following are true at the ### Boot-wedge symptoms (bug 1)
same time:
When the modem boots into the UIM-wedged state, all of the following
hold simultaneously:
- `/dev/cdc-wdm0` exists, `wwan0` netdev exists, `qmi_wwan` driver is
bound to `1-1:1.4` — kernel side looks fine
- `ifup wwan` runs forever in the SIM-init loop:
`wwan: SIM in illegal state - Power-cycling SIM` repeating every ~8 s
- `uqmi -d /dev/cdc-wdm0 --uim-get-sim-state` returns `{}` (empty
body — no `card_application_state` field at all)
- `uqmi -d /dev/cdc-wdm0 --get-imsi` returns the QMI string
`"UIM uninitialized"`
- `uqmi -d /dev/cdc-wdm0 --get-pin-status` returns
`"Invalid arguments given"` (uqmi cannot allocate a UIM client
because the modem-side service has not registered)
- AT side: `AT+CFUN?` returns `+CFUN: 1` (modem firmware is alive),
`AT+CPIN?` returns `+CME ERROR: SIM busy`, and `AT+CREG?` /
`AT+CEREG?` / `AT+COPS?` all return bare `ERROR`
- This persists indefinitely; we measured no recovery at uptime
21 min
### Data-plane symptoms (bugs 2 and 3)
When the modem comes up cleanly but the qmi.sh patches are missing or
the wrong `--start-network` invocation is used, all of the following
are true at the same time:
- `ifup wwan` succeeds, `ifstatus wwan` reports `"up": true` - `ifup wwan` succeeds, `ifstatus wwan` reports `"up": true`
- `wwan0` has a valid CG-NAT IPv4 (`10.x.x.x/30`) and IPv6 - `wwan0` has a valid CG-NAT IPv4 (`10.x.x.x/30`) and IPv6
@@ -184,9 +224,13 @@ You are affected if all of these hold:
1. Your modem reports `Manufacturer: BroadMobi`, `Model: BM806C` (or 1. Your modem reports `Manufacturer: BroadMobi`, `Model: BM806C` (or
`BM806U`), `Revision: M1.2.0_E1.0.1_A1.1.8`. Check via any AT port: `BM806U`), `Revision: M1.2.0_E1.0.1_A1.1.8`. Check via any AT port:
`printf 'ATI\r' | picocom -qrx 3000 /dev/ttyUSB2`. `printf 'ATI\r' | picocom -qrx 3000 /dev/ttyUSB2`.
2. Your USB IDs (after `usb-modeswitch` runs) are 2. Your USB IDs are `2020:2033`. Check
`2020:2033`. Check `/sys/bus/usb/devices/<port>/idVendor` / `/sys/bus/usb/devices/<port>/idVendor` / `idProduct`. On the C1
`idProduct`. hardware revision the modem cold-boots directly into `2020:2033`
QMI composite mode — no `usb-modeswitch` involved (there is no
`2020:2033` entry in `/etc/usb-mode.json` on our build). Other
hardware revisions may go through an EDL `05c6:9008`
`2020:2033` modeswitch first.
3. `qmi.sh` (`/lib/netifd/proto/qmi.sh`) is the unmodified upstream 3. `qmi.sh` (`/lib/netifd/proto/qmi.sh`) is the unmodified upstream
netifd handler. Grep for `--wda-set-data-format 802.3` netifd handler. Grep for `--wda-set-data-format 802.3`
if present, you have the unpatched script. if present, you have the unpatched script.
@@ -207,11 +251,11 @@ data flowing with `--start-network --profile 1` but not with
| uqmi | 2025.07.30~7914da43-r2 | | uqmi | 2025.07.30~7914da43-r2 |
| libqmi / qmi-utils | 1.36.0-r1 | | libqmi / qmi-utils | 1.36.0-r1 |
| luci-proto-qmi | 26.133.20346~e9ebca7 | | luci-proto-qmi | 26.133.20346~e9ebca7 |
| qmi_wwan kernel driver | in-tree, kernel 6.12.74 | | qmi_wwan kernel driver | backports from Linux v6.18.7 (per dmesg) |
| LTE modem | BroadMobi BM806C (Qualcomm MDM9225) | | LTE modem | BroadMobi BM806C (Qualcomm MDM9225) |
| Modem firmware | `M1.2.0_E1.0.1_A1.1.8` | | Modem firmware | `M1.2.0_E1.0.1_A1.1.8` |
| Modem USB id (data mode) | `2020:2033` | | Modem USB id (data mode) | `2020:2033` (cold-boots directly into this) |
| Modem USB id (EDL mode) | `05c6:9008` (before `usb-modeswitch`) | | Modem USB id (EDL mode) | `05c6:9008` (not observed on C1; may apply to other revs) |
| Mobile network | Orange Poland (MCC 260 / MNC 03) | | Mobile network | Orange Poland (MCC 260 / MNC 03) |
| APN (IPv4 / dual-stack) | `internet` (auth: PAP, user/pass `internet`/`internet`) | | APN (IPv4 / dual-stack) | `internet` (auth: PAP, user/pass `internet`/`internet`) |
| APN (IPv6) | `internetipv6` (same auth) | | APN (IPv6) | `internetipv6` (same auth) |
@@ -226,9 +270,25 @@ data flowing with `--start-network --profile 1` but not with
documents the 802.3-firmware-is-buggy reality across this generation. documents the 802.3-firmware-is-buggy reality across this generation.
Search the mainline kernel for `QMI_WWAN_FLAG_RAWIP`. Search the mainline kernel for `QMI_WWAN_FLAG_RAWIP`.
- Kernel commit "net: qmi_wwan: add BroadMobi BM806U 2020:2033" - Kernel commit "net: qmi_wwan: add BroadMobi BM806U 2020:2033"
(Pawel Dembicki, 2018): adds the `qmi_wwan` entry for our exact USB (Pawel Dembicki, 2018, `6cb2669cb97f`): adds the `qmi_wwan` entry
id `2020:2033`. The BM806C and BM806U share the device id and for our exact USB id `2020:2033` as `QMI_FIXED_INTF(0x2020, 0x2033, 4)`
qmi_wwan driver path. with no quirks. The BM806C and BM806U share the device id and
qmi_wwan driver path. The entry has not been touched in mainline
through v6.18.7 (what OpenWrt 25.12.2 ships via backports).
- libqmi maintainer Aleksander Morgado on cdc-wdm port readiness
timing (libqmi-devel, Sep 2021):
<https://lists.freedesktop.org/archives/libqmi-devel/2021-September/003695.html>
— explains that cdc-wdm appearing in `/dev` is not a guarantee that
the modem-side QMI service is operational. ModemManager uses up to
45 s of warmup tolerance; we measured this modem firmware needs
~5 min before CTL is even responsive, and UIM never converges
without a USB re-enumeration.
- `CastixGitHub/re_wwan` (<https://github.com/CastixGitHub/re_wwan>):
another BM806C user, identical firmware build, identical recovery
pattern (`rmmod qmi_wwan; insmod qmi_wwan` to recover from a hung
modem; AT-side `AT+CFUN=` resets reported as not working). Useful
independent confirmation that the right primitive is module
reload / USB re-enumeration, not a soft reset.
- D-Link DWR-921 support page (firmware images, region-specific): - D-Link DWR-921 support page (firmware images, region-specific):
hardware revision C3 on the Polish site lists firmware hardware revision C3 on the Polish site lists firmware
`1.01.3.006 Generic`, `1.00B07 T-Mobile`, `1.00B06 Plus/Cyfrowy Polsat `1.01.3.006 Generic`, `1.00B07 T-Mobile`, `1.00B06 Plus/Cyfrowy Polsat
@@ -271,9 +331,16 @@ auto-start at boot. This is a deliberate failover-only setup —
human (or future failover script, e.g. `mwan3`) decides when to human (or future failover script, e.g. `mwan3`) decides when to
bring up wwan. bring up wwan.
This also sidesteps a fragile boot ordering question: the modem takes This also sidesteps a fragile boot ordering question: on cold boot the
3090 s after boot before its QMI service is responsive, and netifd modem's **UIM (SIM) QMI service comes up permanently broken** and never
would otherwise repeatedly fail and back off during that window. recovers without an explicit USB re-enumeration (`echo 0/1 >
/sys/bus/usb/devices/1-1/authorized`). Other QMI services (CTL, NAS,
WDS) do come up after ~5 min of warmup, but UIM does not — verified at
uptime 21 min with no intervention. The `wwan-bringup` service handles
the re-enumeration on boot and then calls `ifup wwan` itself; netifd
never has to deal with the wedge directly. See
`/root/wwan-diag/boot-wedge-investigation.md` on the router for the
full root-cause analysis (2026-05-27).
### IPv6 is via a second NVRAM profile, not a single dual-stack PDP ### IPv6 is via a second NVRAM profile, not a single dual-stack PDP
@@ -508,19 +575,23 @@ In rough priority order:
- The current "patch the file, reapply via Ansible" approach is the - The current "patch the file, reapply via Ansible" approach is the
simplest and most direct. It is fine as long as the role is the simplest and most direct. It is fine as long as the role is the
source of truth. source of truth.
5. **Implement actual failover.** `mwan3` is the conventional choice. 5. **Periodic session keepalive / reconnect on detach.** Now that
boot bring-up is fast and reliable (~2:303:30 from cold boot to
wwan up), the next likely failure mode is the modem getting
deactivated by the network (`+CEER: Regular deactivation`) after
long idle periods. A simple `procd` service that polls
`uqmi --get-data-status` and triggers `ifup wwan` on transition
`connected → disconnected` would close this gap. Don't pre-emptively
add it; wait until you have evidence the problem occurs in practice
with the workaround in place. If the disconnect comes with UIM
going bad (same wedge signature as cold boot), the keepalive needs
to call `wwan-bringup` (which re-authorizes the USB device) rather
than `ifup wwan` directly.
6. **Implement actual failover.** `mwan3` is the conventional choice.
Alternatively a tiny shell loop that pings a target via `uplink` Alternatively a tiny shell loop that pings a target via `uplink`
and triggers `ifup wwan` / `ifdown wwan` on transitions. Either way and triggers `ifup wwan` / `ifdown wwan` on transitions. Either way
the wwan side of the work is done; the failover orchestration is a the wwan side of the work is done; the failover orchestration is a
separate problem. separate problem.
6. **Periodic session keepalive / reconnect on detach.** Even after
our fix, the modem can still get deactivated by the network
(`+CEER: Regular deactivation`) after long idle periods. A simple
`procd` service that polls `uqmi --get-data-status` and triggers
`ifup wwan` on transition `connected → disconnected` would close
this gap. Don't pre-emptively add it; wait until you have
evidence the problem occurs in practice with the workaround in
place.
7. **Investigate `mbim` mode**. The BM806C does not currently expose 7. **Investigate `mbim` mode**. The BM806C does not currently expose
MBIM, but the modem chipset (MDM9225) supports it at the silicon MBIM, but the modem chipset (MDM9225) supports it at the silicon
level. Whether there exists a magic AT command, vendor QMI message, level. Whether there exists a magic AT command, vendor QMI message,
@@ -570,16 +641,33 @@ In rough priority order:
Always cross-reference with `+CEREG?` and `+CGACT?` to know if you Always cross-reference with `+CEREG?` and `+CGACT?` to know if you
are presently attached. are presently attached.
- `uqmi -t 5000 -d /dev/cdc-wdm0 --get-serving-system` returns - `uqmi -t 5000 -d /dev/cdc-wdm0 --get-serving-system` returns
`"Failed to connect to service"` for the first 3090 s after `"Failed to connect to service"` (or `"Unknown error"`) for the
boot. This is the QMI service inside the modem firmware not being first ~5 minutes after cold boot. CTL/NAS/WDS *do* eventually come
up yet, not a host-side problem. up (we measured `--get-versions` first OK at uptime 320 s,
serving-system at 376 s), but they flap in and out for several more
minutes. **UIM never comes up on cold boot without a USB
re-enumeration** — `--uim-get-sim-state` keeps returning `{}` and
`--get-imsi` keeps returning `"UIM uninitialized"` even at uptime
21 minutes. This is why the `wwan-bringup` worker now does an
unconditional `authorized=0/1` re-enumeration immediately after the
modem enumerates; it is not waiting for warmup, it is forcing the
modem to redo its init from scratch.
- A reliable cold-boot vs. wedged-modem discriminator from AT side:
`AT+CPIN?` returning `+CME ERROR: SIM busy` while `AT+CFUN?` returns
`+CFUN: 1` means the modem firmware is alive but UIM is stuck. If
this persists past uptime 5 minutes the modem will not recover on
its own; re-authorize the USB port.
- The diagnostic scripts we accumulated live on the router at - The diagnostic scripts we accumulated live on the router at
`/root/wwan-diag/` (created during debugging; not part of the `/root/wwan-diag/` (created during debugging; not part of the
Ansible role). The most useful ones are `at.sh` (run AT commands Ansible role). The most useful ones are `at.sh` (run AT commands
through `picocom`), `ppp-test.sh` (PPP-via-AT as a control test through `picocom`), `ppp-test.sh` (PPP-via-AT as a control test
that bypasses QMI), and `qmi-dual-profile.sh` (manual that bypasses QMI), `qmi-dual-profile.sh` (manual reproduction of
reproduction of the working `--profile`-based dual-stack flow). the working `--profile`-based dual-stack flow), and
Feel free to delete them once this is stable; they are not `boot-capture.sh` (instrumented per-service probe that maps the
cold-boot wedge timeline; every probe wrapped in `/usr/bin/timeout`
so it cannot hang). The full root-cause writeup for the boot wedge
is at `/root/wwan-diag/boot-wedge-investigation.md`. Feel free to
delete the older scripts once this is stable; they are not
load-bearing. load-bearing.
## Acknowledgements ## Acknowledgements
+10
View File
@@ -18,3 +18,13 @@ machine:
# Generated on https://factory.talos.dev/ # Generated on https://factory.talos.dev/
# amd-ucode, amdgpu and btrfs # amd-ucode, amdgpu and btrfs
image: factory.talos.dev/metal-installer/80c3a00af9a5930d1788532c6cc9e8a9b23f8e553d1bb2933b2221f92703d655:v1.12.4 image: factory.talos.dev/metal-installer/80c3a00af9a5930d1788532c6cc9e8a9b23f8e553d1bb2933b2221f92703d655:v1.12.4
# grubUseUKICmdline is incompatible with extraKernelArgs
# and there seems to not be a way around it, disabling
grubUseUKICmdline: false
# amdgpu is loaded by udev automatically at boot before Talos applies
# module parameters, so the runpm=1 from machine.kernel.modules
# arrives too late. Work around using kernel args:
extraKernelArgs:
- amdgpu.runpm=1
+14
View File
@@ -0,0 +1,14 @@
# Allow Woodpecker to run privileged containers
# Used for example to build multi-arch mikrotik-tailscale image
# which needs to register binfmt
cluster:
apiServer:
admissionControl:
- name: PodSecurity
configuration:
apiVersion: pod-security.admission.config.k8s.io/v1beta1
kind: PodSecurityConfiguration
exemptions:
namespaces:
- woodpecker
+4
View File
@@ -1,3 +1,7 @@
path "secret/data/renovate" { path "secret/data/renovate" {
capabilities = ["read"] capabilities = ["read"]
} }
path "secret/data/container-registry" {
capabilities = ["read"]
}