The Zyxel XGS1210-12 is the best cheap OpenWrt 2.5G switch right now

I run OpenWrt as the firmware on my core switch, a Zyxel XGS1210-12: eight gigabit ports, two 2.5GbE copper ports, and two SFP+ cages that do 10G, for around $120. If you want a cheap, multi-gig managed switch that runs open-source firmware and still does VLANs and the rest, I think it’s the best option right now. It’s on the hardware list in the OpenWrt Beginner’s Guide, which is where I’d check for current recommendations since this moves quickly.

For me the appeal is owning the hardware outright, more than any feature list. With open firmware I can see exactly what the switch runs, test a fix myself when I hit a bug, and send a patch upstream if it comes to that. Closed gear leaves you one update away from changes that suit the vendor more than you, and for something as tied to privacy and security as networking equipment, the transparency is worth it.

UniFi is the obvious alternative, and credit where it’s due: its UI and single-pane-of-glass view of a whole network are nicer, so going the OpenWrt route is hard mode. But it costs a lot more. A comparable UniFi switch with 2.5G and a 10G SFP+ uplink, the Pro Max 16, runs a few hundred dollars ($399 with PoE, less without) versus $120 here, and that adds up fast across several switches. The open-source enterprise options don’t help on price either: SONiC, DENT, and Cumulus all need $1,000-plus whitebox gear.

Under the hood it’s a Realtek RTL9302, and getting the 2.5G copper ports working was hard. The PHY spoke a proprietary Realtek protocol the Linux kernel couldn’t drive, and the fix took getting proper support upstream so it could switch modes by link speed. The whole saga is in issue #19640; the breakthrough landed in PR #19843 in August 2025, and the rev B1 board got its fix in PR #21605 in January 2026. That work came from Birger Koblitz (who started OpenWrt’s Realtek port), Tobias Schramm, Markus Stockhausen, Jan Hoffmann, Jonas Jelonek, and Sander van Heule. I’m surely forgetting people, so check the linked PRs and issue thread for the full list.

None of it came with a datasheet. Realtek doesn’t publish one, so the whole thing was reverse-engineered from GPL source dumps and a lot of register poking. Sander van Heule documents the internals at svanheule.net and even built a web tool for browsing these chips’ registers. People doing that for free, with no documentation, is the reason a $120 switch can run mainline Linux at all. I appreciate it a lot.

OpenWrt itself is worth appreciating too. With all sorts of tricks to squeeze it down, it’ll run a full, configurable network OS on devices with only a few megabytes of flash. Getting real Linux into that little space is the cool part.

A couple of things to flag. There’s no PoE on this model, so APs and cameras need injectors or a separate PoE switch. And the 2.5G work is on the snapshot builds rather than the stable release for now, so flash a recent snapshot that matches your board revision (mine’s the B1). As of writing that’s r35109-f5d928e52a. Beyond that it’s been solid for me, and for a cheap, open, multi-gig managed switch it’s the one I’d get.

Claude Code - Experimenting With Dev Containers and Permission Allowlists

Updated 2026-07-11: reworked around workspace trust, which was the real reason my allowlist wasn’t taking effect.

I run Claude Code, and its VS Code extension, inside dev containers. The reason is isolation. Editor extensions and the toolchains a project pulls in have been a real supply-chain vector lately, and a dev container keeps a compromised one off my host: the extension, the agent, and everything npm or uv installs all live inside the container, not on my machine.

That isolation changes how I think about permissions. Because the blast radius is the container and not my laptop, I’m comfortable letting the agent do far more inside it than I would on bare metal.

The container is a small file. The Claude Code dev container feature installs the CLI and the extension into the container, and I run as a non-root user. I also mount a named volume at ~/.claude so plugins and login state persist across rebuilds:

{
  "name": "project",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  "features": {
    "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
    "ghcr.io/devcontainers/features/github-cli:1": {}
  },
  "mounts": [
    "source=claude-code-shared,target=/home/vscode/.claude,type=volume"
  ],
  "remoteUser": "vscode",
  "postCreateCommand": "bash .devcontainer/post-create.sh"
}

That volume isn’t the whole story. Claude Code keeps your login in ~/.claude.json, a $HOME file that sits outside the mounted .claude directory, so a rebuild wipes it and forces a re-login. I fixed that a while back by symlinking the file into the volume:

if [ ! -L "$HOME/.claude.json" ]; then
  [ -f "$HOME/.claude.json" ] && mv -f "$HOME/.claude.json" "$HOME/.claude/.claude.json"
  ln -sf "$HOME/.claude/.claude.json" "$HOME/.claude.json"
fi

The same file holds your per-repo workspace trust, and that turned out to matter more than the login. Claude Code won’t apply a repo’s committed allow rules until you accept its workspace-trust dialog (docs); until then it reads the rules and ignores them, so an allowlisted make test still prompts. Since trust lives in the same ~/.claude.json, the symlink already persists it, so I pre-accept it for the repo and skip the dialog. Do it right after the symlink so the write lands in the volume, and key on the exact git repo root, since trusting a parent like /workspaces doesn’t count (anthropics/claude-code#72896):

REPO_ROOT="$(git -C "$PWD" rev-parse --show-toplevel)"
node -e '
  const fs = require("fs"), f = process.env.HOME + "/.claude.json", p = process.argv[1];
  const j = fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8") || "{}") : {};
  (j.projects ||= {})[p] ||= {};
  j.projects[p].hasTrustDialogAccepted = true;
  fs.writeFileSync(f, JSON.stringify(j, null, 2));
' "$REPO_ROOT"

With trust settled, the committed allowlist takes effect. I commit a wide allowlist to .claude/settings.json and turn on acceptEdits so routine file writes don’t prompt either:

{
  "permissions": {
    "defaultMode": "acceptEdits",
    "allow": [
      "Bash(make test:*)",
      "Bash(make lint:*)",
      "Bash(uv run pytest:*)",
      "Bash(npm run build:*)",
      "Bash(tail:*)",
      "Bash(head:*)",
      "Bash(grep:*)",
      "Bash(git add:*)",
      "Bash(git commit:*)"
    ],
    "deny": ["Read(.env)", "Read(**/.env)"]
  }
}

It goes in .claude/settings.json, not .claude/settings.local.json, on purpose. When you click “yes, don’t ask again”, Claude Code writes the rule to the local file, and that file gets wiped on rebuild too. Commit the allowlist and it survives rebuilds and travels with the repo. tail, head, and grep are in there because Claude appends | tail or | head to a lot of commands to keep output short, and Claude Code matches each side of a pipe on its own, so the pipe target needs its own rule.

The container isn’t a free-for-all, so I keep two guardrails inside it. It still holds my code and a token, so I don’t blanket-allow uv run or npm install: uv run runs whatever follows it, and npm install <pkg> runs package install scripts, which is how a lot of npm supply-chain attacks land. I pin the inner command (uv run pytest) and leave the runners prompting. I also keep the container’s GitHub token read-only and push from the host, so a bad suggestion can’t push or open a PR on its own.

A committed allowlist still won’t cover every command, and a new one puts you back at a prompt. The option that fits the VS Code extension is auto mode, which approves routine commands with a classifier instead of a static list. The built-in Bash sandbox is the other documented path, isolating commands at the OS level, but it’s CLI-only today (anthropics/claude-code#64061), so it doesn’t reach the extension.

None of this is a hard sandbox on its own. Permission rules are enforced by Claude Code, not the OS. I’ve always been more worried about approving the one thing I shouldn’t than about the prompts themselves, and I’m still working out how to shrink the blast radius further. This is early, and I expect parts of it to look risky in hindsight.

Notes from Google Cloud Next 2026

Work sent me to Google Cloud Next this year. Almost everything was about AI. There were even banners in the hallway saying “Put an agent on it”. The engineering talks, like the GKE one, ran in small rooms with thin crowds.

The GKE talk

My favorite talk opened with Yahoo Mail moving petabytes of data onto GCP (yes, they’re still around). They split GKE into separate clusters for ingress, mail, add-ons like contacts and calendar, and analytics, so a runaway batch job can’t take down mail delivery. They picked GCP for Spanner’s multi-region consistency and to run GKE next to that data.

Google spent the rest of the talk on autoscaling and capacity, aimed at staying fast without over-provisioning:

  • Predictive HPA forecasts demand from history and scales out a few minutes ahead, for workloads with long startups and cyclical traffic.
  • Native custom-metric HPA reads a Prometheus metric straight off the pod’s /metrics through an AutoscalingMetric CRD, skipping the Cloud Monitoring adapter and its pile of IAM setup. The demo scaled on vLLM’s KV-cache usage.
  • Compute classes let you hand GKE a prioritized list of machine types (spot first, on-demand fallback) and have it find capacity, with node-pool auto-creation you can scope per class instead of cluster-wide.
  • Capacity buffers keep spare nodes warm so critical workloads can scale out without waiting for provisioning. The speaker pitched them as an alternative to balloon pods, low-priority placeholder pods parked to hold space.
  • VPA can now resize a running pod in place instead of restarting it, so rightsizing doesn’t interrupt the service.
  • CPU Boost uses that in-place resize to give a pod extra CPU during startup and take it back once the pod is ready, so a slow-starting JVM doesn’t hold that headroom for life.

The CPU Boost config they showed:

startupBoost:
  cpu:
    type: "Factor"
    factor: 3            # 3x the CPU request and limit while starting
    durationSeconds: 10  # keep the boost 10s past readiness

BigQuery

The BigQuery talk was mostly about making it a better source of context for models:

  • Fluid Scaling drops the 60-second minimum charge on autoscaled queries, so you pay per second for bursty, agent-driven traffic.
  • ObjectRef is a column type pointing at an object in storage (an image, an audio file), so you can join unstructured data with structured rows and run AI/ML on it in one query.
  • Hybrid Search runs vector and lexical search together and merges them with a reranker, for RAG.
  • Observability gained job-level cost attribution to answer “who is burning the budget”.

They also made it easier to call LLMs from SQL. An optimized mode for AI.CLASSIFY and AI.IF has Gemini label a sample of rows, then trains a small model on those labels to handle the rest. Google says that uses 230x fewer tokens than calling Gemini on every row. It still sounds like an easy way to accidentally burn a ton of money.

The AI talks

  • Gemini Enterprise was the keynote’s centerpiece and looked like Google’s Glean plus a drag-and-drop GUI for building no-code agent flows from predefined steps.
  • BigLake is now the Cross-cloud Lakehouse, built on Apache Iceberg so you can query data sitting in other clouds without copying it.
  • AlloyDB is Google’s Postgres-compatible answer to Aurora, and it got optimized modes for its AI functions too, like ai.if.
  • Google’s ADK agent framework picked up graph-based and multi-agent workflows and Agent Skills for Python, Go, and TypeScript.

Semantic layers like LookML have been around for years, but they matter more once an LLM writes the queries. A Looker talk said the descriptions in LookML help LLM accuracy a lot. It also seemed like a pitch for running more LLM calls through Looker, where the tokens bill separately.

L’Oreal’s talk was about opening agents to non-programmers on the business side. They built their agent platform on ADK, with MCP tools that can even pull reports, and the talk showed off some Gemini Enterprise too. I like the goal, since programmers shouldn’t be the only ones who get to build useful things. The speaker was vague about what business users are allowed to do beyond sticking to the guardrails and brand guidelines. Code review never came up, though an earlier slide seemed to show an approval process. I’d want some review, mainly around exfiltration. If those agents are limited to Gemini Enterprise’s predefined steps, how much one could leak depends on whether any step can make an arbitrary HTTP request. I couldn’t tell, and the controls I’d want might exist and just not have made the talk.

A separate Wayfair talk covered the Universal Commerce Protocol, a standard they’re building with Google and other retailers so LLMs can surface products and you can check out without leaving the chat. They said their LLM-referred traffic doubled in two quarters and converts better than their usual channels, though it’s still a single-digit share of total traffic.

The expo floor

Google had the accelerators behind AI Hypercomputer out on the floor. One was an Ironwood TPU rack, where a full pod is 9,216 liquid-cooled chips sharing 1.77 petabytes of HBM over an optical fabric that steers light between fibers with tiny tilting mirrors. The other was an A4X Max rack, NVIDIA’s GB300 NVL72, with 72 Blackwell Ultra GPUs pulling around 120 kW.

Boston Dynamics had Spot patrolling a mock industrial site, and Google and Mars ran an “AI Snack Factory” where Gemini invents candy flavors and attendees vote. “Golden Latte Flambe” was winning.

Macvlan private mode for container L2 isolation

Macvlan is a Docker networking mode that gives each container its own MAC and IP on the network the host port is plugged into. The container shows up on the subnet as its own device, gets a DHCP lease from the router, and talks to the LAN directly instead of going through the Docker bridge.

I have a box with multiple Ethernet ports, each port on a different VLAN. Some containers sit on the port assigned to a guest-style VLAN that can only reach the internet, not other hosts on the LAN. That worked fine in the default bridge macvlan mode while each port only had one service on it. Once I added more services to the same port, I hit a problem I hadn’t thought about: containers sharing a macvlan parent in bridge mode can freely ARP and talk to each other inside the kernel, which defeats the VLAN-level isolation.

Macvlan has a private mode for exactly this. The kernel drops frames going between siblings on the same parent. Outbound traffic via the gateway still works. No switch cooperation, no extra VLANs.

docker network create -d macvlan \
  --subnet=192.0.2.0/24 \
  --gateway=192.0.2.1 \
  -o parent=eth0 \
  -o macvlan_mode=private \
  guest_private

Attach a container to it with a static IP:

services:
  app:
    networks:
      guest_private:
        ipv4_address: 192.0.2.16
networks:
  guest_private:
    external: true

One caveat: the host itself can’t reach its own containers over that interface. That’s true for any macvlan setup, not specific to private mode. For my case it’s fine since the containers just need internet access and to be reachable from other subnets through the router.

More on the modes underneath: ip-link(8) documents macvlan’s private, vepa, bridge, and passthru modes. Docker’s guide: Docker macvlan driver.

Python - Inline Script Dependencies With PEP 723

Distributing one-off Python scripts has gotten a lot better recently. PEP 723 added a standard way to declare dependencies and a minimum Python version inside a single .py file, and uv makes running them painless. I used this to share a script with the team and it just worked across everyone’s machines.

Before this, you’d end up writing some wrapper shell script that creates a virtualenv, makes sure the right Python version is being used, installs the dependencies, and tries to keep everything in sync. Brew-installed Python was especially bad for this because brew upgrade can swap your Python version out from under every virtualenv pointing at it. Even version managers like asdf or pyenv don’t really help because the recipient still needs the right version installed through their manager. A lot of ceremony for a single file.

PEP 723 lets you declare all of that inline:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "requests",
# ]
# ///

import requests

resp = requests.get("https://example.com/api/health", timeout=10)
print(resp.json())

The # /// script block is TOML. You don’t have to write it by hand either, uv add --script example.py 'requests' will add it for you.

The recipient just needs uv installed:

uv run check_status.py

uv reads the inline metadata and installs the dependencies in an isolated environment. If the required Python version isn’t on their machine, uv downloads it automatically. No virtualenv, no version manager, no wrapper script.

You can also lock dependencies with uv lock --script example.py for reproducible runs. For bigger tools that span multiple files, uv tool install is the next step up and can install from a private PyPI index.

Switching Open WebUI from Ollama to llama.cpp for Qwen 3.5

Update 2026-08-23: I have since swapped Qwen 3.5 for Qwen3.6-35B-A3B, a Mixture-of-Experts model, and then for Qwen3.8-27B, a dense model, both on the same 16 GB card. I now recommend the setup in the Qwen 3.8 post instead.

I finally got to a local LLM setup that feels pretty usable within a 16 GB VRAM constraint, at around 40 tokens/second. It’s Open WebUI, which as far as I can tell is still the best open source chat interface for local models, running Qwen3.5-9B at Q8_0 on a Ryzen 5 5600 box with an RTX 5060 Ti 16 GB card.

I started with Ollama because it’s the popular default. For Qwen 3.5, though, Ollama had open issues at the time with much slower inference than llama.cpp on the same model, long stalls on later turns in a conversation, and broken /no_think handling for Qwen. llama.cpp’s llama-server exposes an OpenAI-compatible API, so switching Open WebUI over to it was mostly a config swap.

The 9B feels much better than I expected for its size, and it’s the first local setup I’ve had on this machine that felt worth keeping around. For serious work I still use hosted frontier models, but this is useful for narrower jobs like redacting or cleaning text before it goes to a cloud model.

Qwen 3.5 defaults to thinking, which for my use was usually slower, often added 30-60 seconds, and sometimes got stuck in long loops without noticeably improving the answer. LLAMA_ARG_REASONING=off (--reasoning off) turns it off, with LLAMA_ARG_JINJA=1 so llama.cpp uses the chat template from the GGUF.

The 9B weights take roughly 9 GB, and the rest of the tuning spends the remaining VRAM on context. LLAMA_ARG_N_PARALLEL=1, LLAMA_ARG_FLASH_ATTN=1, and a q8_0 KV cache bring the whole setup to around 14 GB without spilling into system memory.

The commented two-container compose file:

Kubernetes - Fixing GitOps Drift in Metrics Server

I recently got a small fix merged into metrics-server, which powers kubectl top and is used for horizontal pod autoscaling in Kubernetes. It’s not exactly a core component, but most production clusters have it installed.

I’ve been using ArgoCD at work lately for deploying Helm charts through a GitOps flow, and I noticed that the metrics-server APIService resource kept showing as “OutOfSync” even though nothing had actually changed.

The issue was that the Helm template always rendered the insecureSkipTLSVerify field explicitly, but Kubernetes omits it from live resources when it’s false (the API default). This caused ArgoCD to see a constant diff.

The fix was to conditionally render the field using {{- with .Values.apiService.insecureSkipTLSVerify }} so it only appears when set to true. Same approach other projects like KEDA have used.

It’s a tiny fix, but it’s satisfying to have a change merged into something as widely deployed as metrics-server.

PR: kubernetes-sigs/metrics-server#1727

Building a Lightweight Linux Torrent Seeder for Low-End VPS Hosting

Earlier this year, I wanted to help seed Linux distribution torrents using cheap VPS servers that offer terabytes of monthly bandwidth for less than $20/year. To maximize cost efficiency, I needed something as memory-efficient as possible.

After trying qBittorrent, rTorrent, and Transmission (via Docker) on a 1GB RAM VPS, I kept running into OOM issues and configuration headaches. Those clients are great, but running them in 1GB RAM doesn’t seem to be a design goal. It’s probably still possible with enough tweaking.

I ended up building distro-seed, a lightweight Go-based BitTorrent seeder using anacrolix/torrent. It’s a Go library that handles all the BitTorrent protocol details while letting you build exactly what you need without the overhead of a full client.

So far, it’s seeded 1.25 TB of Linux distributions.

The project includes an Ansible playbook to set up a fresh Ubuntu VPS for automatic seeding. The whole setup is simple: configure your torrent sources in a YAML file, run the playbook, and let it seed.

Repo: pawl/distro-seed

Linux - Forgetting Exec

Today, I came across a bug where a Celery worker wasn’t gracefully shutting down, and it was causing some odd “Connection Refused” errors from requests within the task being run by the worker. It was also shutting down before it could send errors to Rollbar/Sentry for the team to know they need to address it.

This was happening because the entrypoint had a script that effectively did this:

echo "starting celery worker"
celery -A tasks worker

The problem with that entrypoint: it’s not using exec to run the child process. Without exec, it will not forward signals like SIGTERM to the process the entrypoint is waiting on. The Celery worker child process won’t know it needs to shut down gracefully if the SIGTERM is not forwarded to it.

The fixed example would look like this:

echo "starting celery worker"
exec celery -A tasks worker

Linux - Dig's +short Option

I learned Linux’s dig has a “+short” option that returns only the IP address for a hostname:

$ dig +short google.com
142.251.32.174