<!--
CoderLegion · Article · Post 01
Serie: Software auditable (S1)
Autor: Ignacio Badenes (@yosoyignicion / IgnicionDev)
Idioma: bilingüe ES + EN · Read time: ~8 min
Tags: opensource, architecture, python, security
-->
Local-first no es una moda: es una decisión de arquitectura (4 criterios, caso real)
Serie: Software auditable — Post 01
Read time: ~8 min · Etiquetas: opensource architecture python security
El escáner de red que sube tus SSID a la nube (y por qué eso ya es una decisión de arquitectura)
Instala la típica app de "analiza tu WiFi" y lee la letra pequeña: cuenta obligatoria, sincronización en la nube, un SDK de analítica y, en el mejor de los casos, un interruptor de "privacidad" apagado por defecto. El problema no es que use la red. El problema es que no puedes auditar qué sale de tu equipo, y sin eso no hay confianza: hay fe.
Yo construí lo contrario. Aethernet es un instrumento local para auditar tu WiFi doméstico: pasivo por defecto, sin nube, sin telemetría y honesto cuando su hardware no llega. Y lo que hace que sea local-first no es un eslogan en la portada: es un conjunto de decisiones de arquitectura que se pueden verificar. En este post las reduzco a cuatro criterios que aplico antes de etiquetar cualquier proyecto como local-first. Si falla uno, no lo es.
Quién escribe esto
Soy Ignacio Badenes (IgnicionDev), técnico de sistemas e informática aplicada desde Castellón. Vengo del soporte y del control de accesos, donde el registro y la mínima exposición no son filosofía: son el trabajo. Aethernet nació de ahí —auditar tu propia red sin regalársela a nadie— y es el primero de los proyectos que uso para explicar cómo diseño software que se puede leer, ejecutar y deshacer. Si esto te suena, el post anterior sobre mi setup de agente barato es el contexto.
La tesis: "sin nube" es fácil de decir y caro de sostener
Cualquiera puede escribir privacy-first en una landing. Sostenerlo exige renunciar a atajos cómodos: el servicio que ya te da DNS, el CDN para las fuentes, el modelo en la nube para "lo difícil", la nube como excusa para no diseñar degradación. El local-first no es un interruptor que se activa al final; es una restricción que se acepta al principio y que condiciona cada capa.
Mi definición operativa cabe en cuatro criterios. No son opiniones: son cosas que puedo comprobar en el repositorio.
Los 4 criterios para decidir si un proyecto puede ser local-first
1. Sin red saliente (no outbound)
La aplicación funciona sin conexión, no como "modo demo": como modo normal. Sin llamadas a servidores de terceros, sin autenticación remota, sin telemetría. En Aethernet, la propia interfaz sirve sus fuentes e iconos desde el disco (/ae-fonts): ni siquiera pide una tipografía a Google. Si algo necesita la red, no es local-first; es cloud con buenos modales.
2. Sin claves ni dependencia de un proveedor
Si para arrancar necesitas una API_KEY o un registro, tu función principal es un servicio ajeno y tú solo eres el envoltorio. Local-first significa que el proyecto es dueño de su función: Aethernet no pide ninguna clave, sus dependencias de ejecución están vacías y lo que aporta (medir, analizar, informar) ocurre en tu máquina.
3. Degradación elegante (y honesta)
Todo es opcional y el sistema sigue vivo cuando falta algo. Sin iw no hay monitor mode; sin scapy o root, no hay ARP activo; sin nmcli, no hay escaneo. En lugar de fallar en bloque, se baja de prestaciones y se dice la verdad: si un dato no se puede medir, se marca n/d en vez de inventarlo. La honestidad también es arquitectura.
4. Datos tuyos, en rutas tuyas
Los datos viven en tu equipo, bajo tus rutas, en un formato que puedes inspeccionar. En Aethernet: config en ~/.config/aethernet/config.toml, base SQLite en ~/.local/share/aethernet/aethernet.db e informes exportables a Markdown, JSON, CSV o PDF. Sin secuestro de datos y sin lock-in.
El caso real: cómo se traduce en capas
Aethernet no es un script con botones pegados. Es una arquitectura en capas donde CLI, API local y la interfaz "AETHERNET" consumen exactamente los mismos servicios:
| Capa | Paquete | Responsabilidad |
| Core | aethernet.core | Lógica pura y testeable: parseo nmcli/iw, ARP, reglas, salud, espectro, OUI. |
| Datos | aethernet.data | SQLite con WAL, migraciones e histórico. |
| Servicio | aethernet.service | Daemon de vigilancia; nunca dibuja. |
| Alertas | aethernet.alerts | Cola, deduplicación, filtros y notify-send. |
| Informes | aethernet.report | Exportadores Markdown / JSON / CSV / PDF. |
| API | aethernet.api | FastAPI local opcional, protegida por token. |
| UI | aethernet.ui | UI NiceGUI; solo lee la base y consume servicios. |
Dos decisiones hacen que esto sea comprobable:
- Los parsers son funciones puras. Se testean con texto real de
nmcli e iw sin hardware. Por eso puedo desarrollar en cualquier máquina y confiar en la CI.
- Las dependencias pesadas son extras opcionales. El
pyproject.toml declara dependencies = []; scapy, fastapi, reportlab, nicegui se instalan solo si los quieres (pip install -e '.[lan,api,reports,ui]'). El núcleo arranca sin nada.
El flujo por defecto es pasivo: se escucha sin inyectar tráfico ni cortar tu WiFi. Lo activo (ARP con scapy) y la captura requieren root y se habilitan a propósito. El propio proyecto te da un veredicto honesto de lo que tu equipo puede y no puede hacer:
aethernet doctor # veredicto honesto de tu hardware
aethernet scan --no-lan # escaneo pasivo
aethernet analyze # motor de reglas sobre el último escaneo
aethernet networks --open
aethernet daemon start # vigilancia continua
aethernet api # API local opcional (token propio)
Y el límite ético va escrito en la arquitectura, no en las FAQs: el modo activo está pensado para tu red, y auditar redes ajenas sin permiso es, además de mala idea, ilegal.
El gate de calidad (por qué me lo creo)
Local-first sin ingeniería es un render. Así que el proyecto pasa un gate duro antes de considerarse terminado: ruff + mypy --strict (0 errores) + vulture + pytest, con más de 150 tests y CI en GitHub Actions sobre Python 3.11 y 3.12. Además hay un smoke test E2E de la UI en modo headless sobre sus rutas principales.
./scripts/ci.sh # ruff + mypy --strict + vulture + pytest
./scripts/smoke.sh # E2E UI headless
El tipado estricto no es decoración: los parsers reciben texto que puede venir de mil versiones de nmcli, y el sistema de tipos me obliga a contemplar los casos raros antes de ejecutar. Menos fe, más contrato.
Lo honesto: qué gano y qué pierdo con local-first
Gano soberanía y auditabilidad: control total de mis datos, cero telemetría, cero lock-in y la tranquilidad de un sistema que puedo leer y deshacer. Puedo medir, exportar y borrar sin pedir permiso a nadie.
Pierdo comodidad y techo de función: mantener parsers puros, degradación elegante y extras opcionales es más trabajo que llamar a un servicio. Lo que en la nube sería una fetch aquí es diseño de capas, tests y migraciones append-only. Y hay funciones que solo tienen sentido con servidor (histórico agregado, análisis comparativo entre usuarios) y que renuncio a tener.
Para mí el intercambio es ventajoso: prefiero un instrumento modesto que entiendo a una caja negra potente que no puedo auditar. Es la misma disciplina de fondo del resto de mi trabajo: si no lo puedo leer, ejecutar y probar, no me sirve.
Takeaway
El local-first no se declara en la landing; se demuestra en la arquitectura. Sin red saliente, sin claves de terceros, con degradación elegante y con los datos en tus rutas. Si tu app no puede arrancar sin conexión, no es local-first: es nube con buenos modales.
Y tú, ¿qué criterio añadirías para decidir si un proyecto puede ser local-first? ¿Cuál fue la última herramienta de red que abriste solo para ver a dónde enviaba tus datos? Cuéntamelo en los comentarios; leo y respondo a todo.
English version below · El original está en español. Si eres angloparlante, baja a la traducción y cuéntame tu criterio en la discusión. Respondo a todos.
Local-first isn't a fad: it's an architecture decision (4 criteria, a real case)
Series: Auditable software — Post 01
Read time: ~8 min · Tags: opensource architecture python security
The network scanner that uploads your SSIDs to the cloud (and why that's already an architecture decision)
Install a typical "analyze your WiFi" app and read the fine print: mandatory account, cloud sync, an analytics SDK and, at best, a "privacy" toggle that ships off by default. The problem isn't that it uses the network. The problem is that you can't audit what leaves your machine, and without that there is no trust: there is faith.
I built the opposite. Aethernet is a local instrument to audit your home WiFi: passive by default, no cloud, no telemetry and honest when its hardware can't keep up. And what makes it local-first isn't a slogan on a landing page: it's a set of architecture decisions you can verify. In this post I reduce them to four criteria I apply before labelling any project local-first. If one fails, it isn't.
Who's writing this
I'm Ignacio Badenes (IgnicionDev), an IT technician from Castellón, Spain. I come from support and access control, where logging and minimal exposure aren't philosophy: they're the job. Aethernet was born there —auditing your own network without giving it away to anyone— and it's the first of the projects I use to explain how I design software you can read, run and undo. If that resonates, my previous post about my cheap agent setup is the context.
The thesis: "no cloud" is easy to say and expensive to sustain
Anyone can write privacy-first on a landing page. Sustaining it means giving up convenient shortcuts: the service that gives you DNS, the CDN for fonts, the cloud model for "the hard part", the cloud as an excuse not to design for degradation. Local-first isn't a switch you flip at the end; it's a constraint you accept at the beginning that shapes every layer.
My working definition fits in four criteria. They aren't opinions: they're things I can check in the repository.
The 4 criteria to decide whether a project can be local-first
1. No outbound network
The application works offline, not as a "demo mode": as the normal mode. No calls to third-party servers, no remote auth, no telemetry. In Aethernet the UI itself serves its fonts and icons from disk (/ae-fonts): it doesn't even request a typeface from Google. If something needs the network, it isn't local-first; it's cloud with good manners.
2. No keys and no provider dependency
If starting up requires an API_KEY or a sign-up, your core function is someone else's service and you're just the wrapper. Local-first means the project owns its function: Aethernet asks for no key, its runtime dependencies are empty, and what it delivers (measure, analyze, report) happens on your machine.
3. Graceful degradation (and honesty)
Everything is optional and the system stays alive when something is missing. Without iw there's no monitor mode; without scapy or root, no active ARP; without nmcli, no scan. Instead of failing as a whole, it scales back and tells the truth: if a value can't be measured, it's marked n/d instead of invented. Honesty is architecture too.
4. Your data, in your paths
Data lives on your machine, under your paths, in a format you can inspect. In Aethernet: config in ~/.config/aethernet/config.toml, SQLite database in ~/.local/share/aethernet/aethernet.db and reports exportable to Markdown, JSON, CSV or PDF. No data hostage-taking and no lock-in.
The real case: what it looks like in layers
Aethernet isn't a script with glued-on buttons. It's a layered architecture where the CLI, the local API and the "AETHERNET" UI consume exactly the same services:
| Layer | Package | Responsibility |
| Core | aethernet.core | Pure, testable logic: nmcli/iw parsing, ARP, rules, health, spectrum, OUI. |
| Data | aethernet.data | SQLite with WAL, migrations and history. |
| Service | aethernet.service | Watch daemon; never draws. |
| Alerts | aethernet.alerts | Queue, dedup, filters and notify-send. |
| Reports | aethernet.report | Markdown / JSON / CSV / PDF exporters. |
| API | aethernet.api | Optional local FastAPI, token-protected. |
| UI | aethernet.ui | NiceGUI UI; only reads the DB and consumes services. |
Two decisions make this verifiable:
- Parsers are pure functions. They're tested against real
nmcli and iw output without hardware. That's why I can develop on any machine and trust CI.
- Heavy dependencies are optional extras.
pyproject.toml declares dependencies = []; scapy, fastapi, reportlab, nicegui are installed only if you want them (pip install -e '.[lan,api,reports,ui]'). The core runs with nothing.
The default flow is passive: it listens without injecting traffic or cutting your WiFi. Active ARP scanning (scapy) and capture require root and are enabled on purpose. The project itself gives you an honest verdict of what your gear can and can't do:
aethernet doctor # honest verdict of your hardware
aethernet scan --no-lan # passive scan
aethernet analyze # rule engine over the last scan
aethernet networks --open
aethernet daemon start # continuous watch
aethernet api # optional local API (its own token)
And the ethical limit is written into the architecture, not the FAQs: active mode targets your network, and auditing networks you don't own is, besides a bad idea, illegal.
The quality gate (why I trust it)
Local-first without engineering is a mockup. So the project passes a hard gate before it counts as done: ruff + mypy --strict (0 errors) + vulture + pytest, with 150+ tests and GitHub Actions CI on Python 3.11 and 3.12. There's also a headless end-to-end UI smoke test over its main routes.
./scripts/ci.sh # ruff + mypy --strict + vulture + pytest
./scripts/smoke.sh # headless E2E UI
Strict typing isn't decoration: the parsers take text that may come from a thousand versions of nmcli, and the type system forces me to handle the odd cases before running anything. Less faith, more contract.
The honest part: what I gain and what I lose with local-first
I gain sovereignty and auditability: full control of my data, zero telemetry, zero lock-in and the peace of mind of a system I can read and undo. I can measure, export and delete without asking anyone.
I lose convenience and a feature ceiling: keeping pure parsers, graceful degradation and optional extras is more work than calling a service. What would be a fetch in the cloud is here layer design, tests and append-only migrations. And some features only make sense with a server (aggregated history, cross-user comparison) and I give them up.
For me the trade is worth it: I'd rather have a modest instrument I understand than a powerful black box I can't audit. It's the same underlying discipline as the rest of my work: if I can't read it, run it and test it, it's no good to me.
Takeaway
Local-first isn't declared on a landing page; it's proven in the architecture. No outbound network, no third-party keys, graceful degradation and your data in your paths. If your app can't boot without a connection, it isn't local-first: it's cloud with good manners.
So what about you? Which criterion would you add to decide whether a project can be local-first? What was the last network tool you opened just to see where it was sending your data? Tell me in the comments; I read and reply to everyone.
Licencia de los proyectos mencionados: MIT · Sin secretos ni claves en este post. · Proyectos: github.com/yosoyignicion · Código de Aethernet: github.com/yosoyignicion/Aethernet