Xavi Bolívar

Blog

appdist: mi propio Diawi en un binario Go

Hace unos días conté cómo se instala una app iOS sin App Store ni TestFlight, con el caso real del iPhone de Jaime. Aquel post explica qué es una distribución Ad Hoc, qué es el UDID y cómo funciona por dentro el enlace itms-services. Este va de la herramienta con la que repartí aquella build: appdist.

appdist es mi propio Diawi: un servicio interno donde subo un .ipa y obtengo un enlace que, abierto en el iPhone, instala la app con un toque. Es un solo binario Go, con la web en Vue y los datos en SQLite. Aquí cuento por qué lo construí en vez de usar lo que ya existe, qué decisiones de diseño me impuso iOS y por qué lo pensé desde el principio para que también lo usen mis agentes.

Por qué no Diawi, TestFlight ni Zealot

Diawi hace exactamente esto y no hay que instalar nada. El problema son los límites: sin registro, el enlace dura un día y admite dos instalaciones; con la cuenta gratuita, tres días, diez instalaciones y 50 MB por fichero. El plan de 29,99 € al mes llega a 15 días de caducidad. Y, en cualquier caso, mis binarios quedan alojados en un tercero.

OpciónA favorEn contra
DiawiCero trabajoEnlaces que caducan en 1 a 15 días, límites de instalaciones y tamaño, binarios en un tercero
TestFlight internoSin UDID ni revisión, hasta 100 personasCada tester necesita un Apple ID en tu equipo; las builds caducan a los 90 días
Firebase App DistributionGratis, iOS y Android, CLIEn iOS sigue siendo Ad Hoc (UDID y volver a exportar); dependes de Google
ZealotCódigo abierto y muy completo: sincroniza dispositivos con Apple, API, plugin de fastlaneUna aplicación Ruby on Rails entera, bastante más de lo que necesito
Servicio propioA medida, sin caducidades ni límites, subida por API, CLI o MCPHay que construirlo y mantenerlo

La lectura honesta: si solo necesitas repartir una app iOS entre pocas personas que ya tienen Apple ID, TestFlight interno lo resuelve sin construir nada, y es lo que recomendaría. Un servicio propio compensa cuando no quieres pedirle el Apple ID a nadie, no quieres enlaces que caduquen a los 90 días y quieres que las subidas se puedan automatizar desde CI o desde agentes. Y si prefieres instalar a construir, Zealot es la alternativa.

En mi caso pesaron dos cosas más. Todas mis herramientas siguen el mismo patrón, un binario Go que despliego en minutos, y quería que un agente pudiera subir una build igual que sube un documento. Además, el análisis previo dejaba claro que el servicio es pequeño: no firma ni modifica las apps, solo las aloja, lee sus metadatos y genera un manifiesto. La dificultad de verdad está fuera, en lo que decide Apple.

Qué hace appdist

  • Subir builds por API REST, CLI o MCP. Al recibir el .ipa lo lee: nombre, bundle id, versión, icono, tipo de firma (Ad Hoc, Development o Enterprise), caducidad del perfil y lista de UDID incluidos.
  • Un enlace estable por app, que siempre apunta a la última build, y otro por cada build. Si la página se abre fuera de iOS, muestra un QR.
  • Una página para sacar el UDID desde Safari, con el mecanismo Profile Service de Apple. Muestra el UDID, lo copia y lista las builds que ya lo incluyen.
  • Diagnóstico antes de instalar: si el navegador ya tiene un UDID guardado, la página dice si ese iPhone está en la build, en lugar de dejar que iOS falle sin explicación.
  • Exportar a Apple: un botón genera el devices.txt que acepta Register Multiple Devices. Si se configura una clave de la App Store Connect API, hay otro que registra el dispositivo directamente.

De momento solo es iOS, aunque el modelo de datos ya distingue la plataforma pensando en Android. La primera prueba de verdad fue la del post anterior: el iPhone de Jaime sacó su UDID desde appdist y, después, instaló la build desde su enlace.

Las decisiones de diseño que me impuso iOS

Seguridad en la URL, no en la sesión

Cuando pulsas «Instalar», Safari no descarga nada. El manifiesto y el .ipa los pide un servicio del sistema, sin las cookies de Safari ni cabeceras de sesión, y no admite credenciales en la URL. Así que proteger la descarga con un login es imposible: la protección tiene que ir en la propia URL.

appdist lo resuelve con tokens HMAC de una hora. La página de instalación es pública, pero el enlace itms-services:// se genera en el momento, con un token firmado para el manifiesto y otro para el binario. Un enlace compartido da acceso a la página, no al .ipa para siempre. Las rutas quedan así:

/i/a/<app>                  página de instalación de la última build
/i/b/<build>                página de instalación de una build concreta
/m/<token>/manifest.plist   manifiesto, generado al vuelo
/d/<token>/app.ipa          el binario
/udid                       captura del UDID

Cloudflare Access con dos aplicaciones

Mis servicios se publican con un túnel de Cloudflare y el panel va detrás de Cloudflare Access. Pero iOS no puede pasar por un login, y los testers no tienen cuenta. La solución son dos aplicaciones de Access sobre el mismo dominio: una para el dominio entero, con la política habitual, y otra con política Bypass solo para las rutas públicas. Cuando se solapan, Cloudflare aplica la de la ruta más específica.

Rutas con BypassPor qué
/i/*, /udid, /pub/*Páginas de instalar y de UDID: los testers no tienen cuenta
/m/*, /d/*Manifiesto y binario: los descarga iOS sin cookies
/up/*Subidas de los agentes (más abajo)
/api/*, /mcpCI, CLI y clientes MCP no pasan el login; los protege su propio token
/assets/*JavaScript y CSS que también cargan las páginas públicas

Antes de dar esto por bueno comprobé en el código que las vistas públicas de la web solo llaman a la API pública, nunca a la del panel, así que no se rompen con el panel detrás de Access. El login propio del panel se queda como segunda barrera.

Subida por trozos de 50 MB

Cloudflare corta las peticiones de más de 100 MB en los planes Free y Pro, y un .ipa puede superarlos sin esfuerzo. Por eso la API tiene dos caminos: una subida directa para los ficheros pequeños y otra por trozos de 50 MB para los grandes.

Iconos CgBI

Un detalle que no esperaba: los iconos PNG que van dentro de un .ipa no son PNG normales. Xcode los guarda en un formato optimizado de Apple, conocido como CgBI, que los navegadores no saben mostrar. Si sirves el icono tal cual, sale roto. appdist lo convierte a PNG estándar al leer la build, y así la página de instalación y el panel enseñan el icono real de la app.

Lo que el servicio no puede hacer

Registrar un dispositivo en Apple no cambia ninguna build que ya exista. El perfil viaja dentro del .ipa, así que tras añadir un iPhone hay que regenerar el perfil Ad Hoc, volver a exportar el .ipa en el Mac y subirlo otra vez. appdist puede dejar el dispositivo dado de alta, pero no puede fabricar el .ipa nuevo: la firma va con mi certificado, en mi Mac. Es el peaje que conté en el post de Ad Hoc, y la razón por la que el diagnóstico de «tu iPhone no está en esta build» me parece la función más útil de todas.

Pensado para agentes

Como documentador y hub, appdist tiene servidor MCP. Pero subir un binario por MCP plantea un problema: un .ipa puede pesar 100 MB, y pasarlo como argumento de una herramienta significaría meter esos bytes en el contexto del modelo. Es absurdo.

Así que la herramienta create_upload no recibe el fichero. Devuelve un comando curl -T que apunta a una URL firmada de un solo uso:

# 1. El agente llama a la herramienta MCP create_upload
# 2. appdist responde con un comando listo para ejecutar:
curl -T MiApp.ipa https://appdist.ejemplo.com/up/<token-de-un-solo-uso>
# 3. El agente lo ejecuta en su terminal

El binario va del disco al servidor sin pasar por el modelo. Por eso /up/* está en la lista de Bypass: el curl del agente no puede pasar por Access, y lo que lo protege es que el token solo vale una vez.

Es la misma idea que me llevó a construir el hub de correo entre agentes: darle al agente herramientas que encajen con cómo trabaja de verdad, en lugar de pretender que se comporte como una persona delante de una web. Las dos piezas encajan: un agente que termina una build puede subirla y avisar por correo a quien tenga que probarla.

Un binario, sin Go en el servidor

La arquitectura es la de siempre: un binario Go con SQLite (modernc.org/sqlite, en Go puro), howett.net/plist para leer y generar los plist, y la web en Vue embebida con go:embed.

ipa/          lee el .ipa: Info.plist, perfil, tipo de firma, icono; genera el manifiesto
udid/         perfil Profile Service y lectura de la respuesta firmada del iPhone
store/        SQLite y ficheros: builds, iconos, subidas
server/       /api, /mcp, /web-api y rutas públicas
asc/          App Store Connect API (JWT ES256), opcional
cmd/appdist/  serve, auth, upload, apps, builds, rm, devices
web/          Vite + Vue 3 + Tailwind v4, mobile first

La imagen de producción es distroless y pesa unos 25 MB: solo el binario, sin shell, sin Go y sin Node. El servidor no hace otra cosa que ejecutar el contenedor.

Lo curioso es que el servidor donde lo desarrollo y lo despliego no tiene Go instalado. No hace falta: el Makefile envuelve cada comando Go en un contenedor golang:1.27.

GO_IMAGE ?= golang:1.27
DOCKER_GO = docker run --rm \
	--user $$(id -u):$$(id -g) \
	-v $(CURDIR):/src -w /src \
	-v appdist-gomod:/gomod -v appdist-gocache:/gocache \
	-e GOMODCACHE=/gomod -e GOCACHE=/gocache -e HOME=/tmp \
	-e CGO_ENABLED=0 \
	$(GO_IMAGE)

test:
	$(DOCKER_GO) go test ./...
  • El repo se monta en el contenedor, así que lo que genera Go (el binario, los ficheros que reescribe gofmt) aparece directamente en el host.
  • --user hace que esos ficheros sean míos y no de root. HOME=/tmp es necesario porque ese usuario no tiene directorio personal dentro de la imagen.
  • Las cachés van en volúmenes con nombre: solo la primera ejecución es lenta; las siguientes tardan segundos.
  • CGO_ENABLED=0 produce un binario estático. Por eso uso el SQLite en Go puro y no el que depende de C.

Con el frontend hago algo parecido. La web compilada se commitea en el repo, de modo que go build funciona sin Node, y Node solo hace falta cuando toco la web. Si no estuviera instalado, el mismo truco funciona con una imagen node:24-alpine, y lo probé: genera exactamente los mismos ficheros. Lo mejor de este enfoque es que las personas y los agentes compilamos siempre con la misma versión de Go, sin instalar nada.

Cómo lo construí

Con el mismo método que cuento en iadev y que usé en el hub: primero especificaciones, luego tests en rojo, luego código. Doce specs antes de implementar nada, y 115 funciones de test en Go en la primera versión. La sesión principal orquestó a los subagentes y verificó de punta a punta con el binario real: subidas por CLI, API y MCP, manifiesto, descarga firmada, el flujo de UDID simulado y los casos negativos de autenticación, con capturas de la web a 390 píxeles de ancho para comprobar la vista de móvil.

Lo que ningún test podía comprobar era el iPhone. Eso llegó después, con el de Jaime.

Qué queda

  1. Firmar el .mobileconfig de la página de UDID. Hoy iOS lo muestra como «No firmado»; se instala igual, pero da mala impresión.
  2. Android. Técnicamente es lo fácil: basta con servir el .apk. Pero en 2027 Google extiende a todo el mundo la verificación de desarrolladores, y un APK de un desarrollador no registrado solo se podrá instalar por ADB o por un flujo avanzado con pasos extra y un bloqueo de 24 horas. Registrar la organización y los paquetes es un trámite de quien desarrolla la app, no del servicio, pero hay que tenerlo hecho antes.
  3. Contraseña y caducidad por enlace, y avisos por correo cuando un perfil esté a punto de caducar, que es la causa habitual de «la app ha dejado de abrir».

Para un apaño puntual, Diawi sigue siendo una buena opción. Pero construir esto me ha dejado una conclusión: el servicio es lo fácil, y lo difícil es entender qué decide Apple. Al final, eso es lo que acabé metiendo en la herramienta: que te diga «tu iPhone no está en esta build» antes de que iOS te lo diga a su manera.