Rebelión en la granja — investigación y laboratorio de dispositivos

Investigación sobre granjas de dispositivos y automatización completa para
levantar un laboratorio Android sobre un chasis de blades Debian.

Conclusión que ordena el repositorio: el cuello de botella nunca fue el
cómputo sino la identidad. Desde que la atestación de dispositivo pasó de
estadística a criptográfica, un servidor no puede hacerse pasar por un
teléfono — le falta una clave grabada en fábrica que ningún software produce.

Contenido:

- 01_investigacion/ — capa de identidad (verificación telefónica, eSIM,
  reputación de IP, atestación por hardware, correlación entre cuentas),
  mercado y players, stack técnico, marco legal en la UE
- 02_device_lab/ansible/ — Ansible para 16 blades: preflight de solo lectura
  que identifica el hardware por DMI, despliegue de redroid en Docker y
  control de flota por adb. Solo ansible.builtin, sin colecciones externas
- docs/ — requisitos, costes y repaso de seguridad con 11 hallazgos
- 03_charla/ — guion para HackMadrid / Hackmeeting València

Estado: la automatización está escrita pero SIN ejecutar contra hardware.
YAML validado, plantillas renderizadas en ambos modos de red y fleet.sh
comprobado con bash -n. El modelo de blade sigue sin confirmar.
This commit is contained in:
s1to 2026-08-08 19:42:14 +02:00
commit 74bfbe9437
42 changed files with 2989 additions and 0 deletions

View file

@ -0,0 +1,38 @@
---
- name: Actualizar índice de apt
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Paquetes base
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
- htop
- iproute2
- python3
- rsync
state: present
- name: Leer zona horaria actual
ansible.builtin.command:
cmd: timedatectl show -p Timezone --value
register: base_tz
changed_when: false
failed_when: false
- name: Fijar zona horaria
ansible.builtin.command:
cmd: "timedatectl set-timezone {{ granja_timezone | default('Europe/Madrid') }}"
when: base_tz.stdout | default('') != (granja_timezone | default('Europe/Madrid'))
changed_when: true
failed_when: false
- name: Sincronización horaria activa
ansible.builtin.systemd:
name: systemd-timesyncd
enabled: true
state: started
failed_when: false

View file

@ -0,0 +1,84 @@
---
# El punto donde esto se atasca en Debian.
#
# Debian 12 compila binder como módulo (CONFIG_ANDROID_BINDER_IPC=m) pero SIN
# binderfs (CONFIG_ANDROID_BINDERFS no está activado). Sin binderfs, redroid no
# puede crear los devices por sí mismo: hay que cargarlos a mano y con los TRES
# nombres que Android espera — binder, hwbinder, vndbinder.
#
# Los contenedores comparten este binder del kernel anfitrión. Si no está, no
# arranca ni una instancia.
- name: Comprobar que el kernel trae binder_linux
ansible.builtin.stat:
path: "/lib/modules/{{ ansible_kernel }}/kernel/drivers/android/binder_linux.ko"
register: binder_ko
- name: Abortar si no hay módulo binder
ansible.builtin.assert:
that: binder_ko.stat.exists
fail_msg: >-
Este kernel ({{ ansible_kernel }}) no trae binder_linux.ko.
Sin binder no hay redroid. Salidas: instalar el kernel estándar de Debian,
o el módulo DKMS de Waydroid (binder_linux-dkms), que además aporta binderfs.
- name: Cargar binder_linux en cada arranque
ansible.builtin.copy:
content: "binder_linux\n"
dest: /etc/modules-load.d/redroid.conf
owner: root
group: root
mode: "0644"
- name: Fijar los tres devices como parámetro del módulo
ansible.builtin.copy:
content: "options binder_linux devices=binder,hwbinder,vndbinder\n"
dest: /etc/modprobe.d/redroid.conf
owner: root
group: root
mode: "0644"
- name: Ver qué devices binder existen ya
ansible.builtin.stat:
path: "/dev/{{ item }}"
loop: [binder, hwbinder, vndbinder]
register: binder_devs_before
- name: ¿Falta alguno?
ansible.builtin.set_fact:
binder_needs_load: "{{ binder_devs_before.results | rejectattr('stat.exists') | list | length > 0 }}"
# Si el módulo ya está cargado pero con los parámetros equivocados, modprobe no
# lo recarga solo. Hay que descargarlo primero.
- name: Descargar binder_linux si está cargado mal
ansible.builtin.command:
cmd: modprobe -r binder_linux
when: binder_needs_load
register: binder_rmmod
failed_when: false
changed_when: binder_rmmod.rc == 0
- name: Cargar binder_linux con los tres devices
ansible.builtin.command:
cmd: modprobe binder_linux devices=binder,hwbinder,vndbinder
when: binder_needs_load
register: binder_modprobe
failed_when: false
changed_when: binder_modprobe.rc == 0
- name: Verificar los devices binder
ansible.builtin.stat:
path: "/dev/{{ item }}"
loop: [binder, hwbinder, vndbinder]
register: binder_devs_after
- name: Confirmar que los tres devices están
ansible.builtin.assert:
that: binder_devs_after.results | rejectattr('stat.exists') | list | length == 0
fail_msg: >-
Faltan devices binder tras el modprobe:
{{ binder_devs_after.results | rejectattr('stat.exists') | map(attribute='item') | join(', ') }}.
Causa habitual: el módulo estaba cargado y en uso, así que no se pudo recargar
con los parámetros nuevos. La configuración persistente YA está escrita, así que
un reinicio del blade lo arregla. Reinicia y relanza este playbook.
success_msg: "binder, hwbinder y vndbinder presentes."

View file

@ -0,0 +1,2 @@
---
coordinator_dir: /opt/granja

View file

@ -0,0 +1,61 @@
---
# El coordinador es el único punto desde el que se habla con toda la flota.
# No corre instancias Android: corre adb y los scripts.
#
# Importante en modo macvlan: un anfitrión NO puede hablar con sus propios
# contenedores macvlan. Por eso adb vive aquí, en un blade distinto — desde
# fuera sí se llega. Ver el README.
- name: Instalar herramientas de control
ansible.builtin.apt:
name:
- adb
- jq
state: present
- name: Crear directorios del coordinador
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- "{{ coordinator_dir }}"
- "{{ coordinator_dir }}/apks"
- name: Escribir el listado de dispositivos de la flota
ansible.builtin.template:
src: devices.txt.j2
dest: "{{ coordinator_dir }}/devices.txt"
owner: root
group: root
mode: "0644"
- name: Instalar el script de flota
ansible.builtin.template:
src: fleet.sh.j2
dest: "{{ coordinator_dir }}/fleet.sh"
owner: root
group: root
mode: "0755"
- name: Enlazar fleet en el PATH
ansible.builtin.file:
src: "{{ coordinator_dir }}/fleet.sh"
dest: /usr/local/bin/fleet
state: link
- name: Contar dispositivos de la flota
ansible.builtin.command:
cmd: "grep -cv '^#' {{ coordinator_dir }}/devices.txt"
register: coordinator_count
changed_when: false
failed_when: false
- name: Resumen
ansible.builtin.debug:
msg:
- "Coordinador listo en {{ inventory_hostname }}."
- "Flota declarada: {{ coordinator_count.stdout | default('0') | trim }} dispositivos."
- "Uso: fleet connect · fleet list · fleet install <apk> · fleet shell '<cmd>'"

View file

@ -0,0 +1,18 @@
# GENERADO POR ANSIBLE — no editar a mano.
# Una dirección adb por línea: <host>:<puerto>
# Regenerar con: ansible-playbook playbooks/coordinator.yml
{% for h in groups['redroid_nodes'] | sort %}
{% set hv = hostvars[h] %}
{% set count = hv.redroid_instance_count | default(
[ (((hv.ansible_memtotal_mb | int) - (redroid_host_reserve_mb | int))
/ (redroid_mem_mb | int)) | int,
redroid_max_instances | int ] | min ) %}
# --- {{ h }} ({{ count }} instancias) ---
{% for i in range(count | int) %}
{% if (hv.redroid_network_mode | default(redroid_network_mode)) == 'macvlan' %}
{{ hv.redroid_macvlan_ip_prefix | default(redroid_macvlan_ip_prefix) }}{{ (hv.redroid_macvlan_ip_offset | int) + i }}:5555
{% else %}
{{ hv.ansible_host | default(h) }}:{{ (hv.redroid_adb_base_port | default(redroid_adb_base_port) | int) + i }}
{% endif %}
{% endfor %}
{% endfor %}

View file

@ -0,0 +1,135 @@
#!/usr/bin/env bash
# GENERADO POR ANSIBLE — no editar a mano.
# Fuente: roles/coordinator/templates/fleet.sh.j2
#
# Control de la flota Android de la granja desde un único sitio.
set -euo pipefail
DEVICES_FILE="{{ coordinator_dir }}/devices.txt"
APK_DIR="{{ coordinator_dir }}/apks"
usage() {
cat <<'USAGE'
fleet — control de la flota Android
fleet connect conecta adb a toda la flota
fleet disconnect desconecta todo
fleet list dispositivos conectados y su estado
fleet install <apk> instala un APK en toda la flota
fleet uninstall <pkg> desinstala un paquete de toda la flota
fleet shell '<cmd>' ejecuta un comando de shell en toda la flota
fleet ip IP de cada dispositivo (útil para el caso de dos APKs)
fleet reboot reinicia toda la flota
El listado de dispositivos sale de devices.txt, que genera Ansible.
USAGE
}
declared_devices() {
grep -v '^#' "$DEVICES_FILE" | grep -v '^[[:space:]]*$'
}
connected_devices() {
adb devices | awk '/\tdevice$/ {print $1}'
}
require_connected() {
local n
n=$(connected_devices | wc -l)
if [[ "$n" -eq 0 ]]; then
echo "No hay dispositivos conectados. Lanza 'fleet connect' primero." >&2
exit 1
fi
}
cmd_connect() {
local ok=0 fail=0
while read -r dev; do
if adb connect "$dev" 2>&1 | grep -qE 'connected to'; then
ok=$((ok + 1))
else
echo " fallo: $dev" >&2
fail=$((fail + 1))
fi
done < <(declared_devices)
echo "conectados: $ok · fallidos: $fail"
}
cmd_disconnect() {
adb disconnect || true
}
cmd_list() {
local declared connected
declared=$(declared_devices | wc -l)
connected=$(connected_devices | wc -l)
echo "declarados: $declared · conectados: $connected"
echo
adb devices -l
}
cmd_install() {
local apk="${1:?uso: fleet install <apk>}"
[[ -f "$apk" ]] || { echo "no existe: $apk" >&2; exit 1; }
require_connected
local ok=0 fail=0
while read -r dev; do
if adb -s "$dev" install -r "$apk" >/dev/null 2>&1; then
echo " ok $dev"
ok=$((ok + 1))
else
echo " FALLO $dev" >&2
fail=$((fail + 1))
fi
done < <(connected_devices)
echo "instalado en $ok · fallos $fail"
[[ "$fail" -eq 0 ]]
}
cmd_uninstall() {
local pkg="${1:?uso: fleet uninstall <paquete>}"
require_connected
while read -r dev; do
printf ' %s: ' "$dev"
adb -s "$dev" uninstall "$pkg" || true
done < <(connected_devices)
}
cmd_shell() {
local command="${1:?uso: fleet shell '<comando>'}"
require_connected
while read -r dev; do
echo "== $dev =="
adb -s "$dev" shell "$command" || true
done < <(connected_devices)
}
cmd_ip() {
require_connected
while read -r dev; do
printf '%-24s %s\n' "$dev" \
"$(adb -s "$dev" shell ip -4 -o addr show scope global 2>/dev/null \
| awk '{print $4}' | paste -sd, - || echo '?')"
done < <(connected_devices)
}
cmd_reboot() {
require_connected
while read -r dev; do
echo " reiniciando $dev"
adb -s "$dev" reboot || true
done < <(connected_devices)
}
case "${1:-}" in
connect) cmd_connect ;;
disconnect) cmd_disconnect ;;
list) cmd_list ;;
install) shift; cmd_install "$@" ;;
uninstall) shift; cmd_uninstall "$@" ;;
shell) shift; cmd_shell "$@" ;;
ip) cmd_ip ;;
reboot) cmd_reboot ;;
*) usage; exit 1 ;;
esac

View file

@ -0,0 +1,53 @@
---
# Docker desde el repo oficial. El `docker.io` de Debian 12 va con un compose
# antiguo (v1, sintaxis `docker-compose`), y las plantillas de aquí usan
# `docker compose` (v2). Requiere que los blades lleguen a internet o a un
# mirror local de download.docker.com.
- name: Directorio de keyrings de apt
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
owner: root
group: root
mode: "0755"
- name: Clave GPG de Docker
ansible.builtin.get_url:
url: https://download.docker.com/linux/debian/gpg
dest: /etc/apt/keyrings/docker.asc
owner: root
group: root
mode: "0644"
- name: Repositorio de Docker
ansible.builtin.apt_repository:
repo: >-
deb [arch={{ 'amd64' if ansible_architecture == 'x86_64' else ansible_architecture }}
signed-by=/etc/apt/keyrings/docker.asc]
https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable
filename: docker
state: present
update_cache: true
- name: Instalar Docker Engine y el plugin de compose
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
- name: Servicio de Docker activo y arrancado
ansible.builtin.systemd:
name: docker
enabled: true
state: started
- name: Añadir el usuario de despliegue al grupo docker
ansible.builtin.user:
name: "{{ docker_user }}"
groups: docker
append: true
when: docker_user | default('') | length > 0

View file

@ -0,0 +1,75 @@
---
# Preflight es de SOLO LECTURA. No cambia nada en los blades.
# Su trabajo es contestar dos preguntas antes de gastar un minuto:
# 1. ¿Qué blade es esto exactamente?
# 2. ¿Puede correr Android x86_64, y cuántas instancias?
- name: Leer identidad DMI del blade
ansible.builtin.slurp:
src: "/sys/class/dmi/id/{{ item }}"
loop:
- sys_vendor
- product_name
register: preflight_dmi
failed_when: false
changed_when: false
- name: Registrar modelo de blade
ansible.builtin.set_fact:
preflight_vendor: "{{ (preflight_dmi.results[0].content | default('') | b64decode | trim) or 'desconocido' }}"
preflight_model: "{{ (preflight_dmi.results[1].content | default('') | b64decode | trim) or 'desconocido' }}"
- name: Leer flags de CPU
ansible.builtin.shell:
cmd: "set -o pipefail; grep -m1 '^flags' /proc/cpuinfo | cut -d: -f2"
executable: /bin/bash
register: preflight_cpuflags
changed_when: false
- name: Detectar flags de CPU que faltan
ansible.builtin.set_fact:
preflight_missing_flags: >-
{{ preflight_required_cpu_flags
| reject('in', preflight_cpuflags.stdout.split())
| list }}
- name: Comprobar módulo binder en el kernel
ansible.builtin.stat:
path: "/lib/modules/{{ ansible_kernel }}/kernel/drivers/android/binder_linux.ko"
register: preflight_binder_ko
changed_when: false
- name: Comprobar si el kernel trae binderfs
ansible.builtin.shell:
cmd: "set -o pipefail; grep -c '^CONFIG_ANDROID_BINDERFS=y' /boot/config-{{ ansible_kernel }} || true"
executable: /bin/bash
register: preflight_binderfs
changed_when: false
failed_when: false
- name: Calcular instancias que caben en este blade
ansible.builtin.set_fact:
preflight_capacity: >-
{{ [ (((ansible_memtotal_mb | int) - (redroid_host_reserve_mb | int))
/ (redroid_mem_mb | int)) | int,
redroid_max_instances | int ] | min }}
- name: Veredicto del nodo
ansible.builtin.set_fact:
preflight_ok: >-
{{ (preflight_missing_flags | length == 0)
and preflight_binder_ko.stat.exists
and (preflight_capacity | int) >= 1 }}
- name: Resumen del blade
ansible.builtin.debug:
msg:
- "modelo : {{ preflight_vendor }} {{ preflight_model }}"
- "cpu : {{ ansible_processor[2] | default('?') }} ({{ ansible_processor_vcpus }} vcpus)"
- "ram : {{ ansible_memtotal_mb }} MB"
- "kernel : {{ ansible_kernel }} / {{ ansible_distribution }} {{ ansible_distribution_version }}"
- "binder .ko : {{ 'sí' if preflight_binder_ko.stat.exists else 'NO — este blade no puede correr redroid' }}"
- "binderfs : {{ 'sí' if (preflight_binderfs.stdout | default('0') | int) > 0 else 'no (se cargan devices a mano)' }}"
- "flags CPU : {{ 'ok' if preflight_missing_flags | length == 0 else 'FALTAN ' ~ (preflight_missing_flags | join(', ')) }}"
- "capacidad : {{ preflight_capacity }} instancias"
- "veredicto : {{ 'APTO' if preflight_ok else 'NO APTO' }}"

View file

@ -0,0 +1,27 @@
---
# Defaults del rol. Lo que se toca normalmente vive en group_vars/all.yml,
# que tiene precedencia sobre esto.
redroid_image: "redroid/redroid:13.0.0-latest"
redroid_mem_mb: 2048
redroid_host_reserve_mb: 1500
redroid_instances: 0
redroid_max_instances: 8
redroid_width: 720
redroid_height: 1280
redroid_dpi: 320
redroid_gpu_mode: guest
redroid_base_dir: /opt/redroid
redroid_network_mode: portmap
redroid_adb_base_port: 5555
redroid_adb_bind_ip: "0.0.0.0"
redroid_macvlan_network: redroid-lan
redroid_macvlan_parent: ""
redroid_macvlan_subnet: ""
redroid_macvlan_gateway: ""
redroid_macvlan_ip_prefix: ""
redroid_macvlan_ip_offset: 20

View file

@ -0,0 +1,115 @@
---
# Despliega N contenedores Android en el blade. N sale de la RAM real salvo
# que se fije redroid_instances a mano.
- name: Calcular cuántas instancias caben
ansible.builtin.set_fact:
redroid_instance_count: >-
{{ (redroid_instances | int) if (redroid_instances | int) > 0
else [ (((ansible_memtotal_mb | int) - (redroid_host_reserve_mb | int))
/ (redroid_mem_mb | int)) | int,
redroid_max_instances | int ] | min }}
- name: Abortar si no cabe ni una instancia
ansible.builtin.assert:
that: (redroid_instance_count | int) >= 1
fail_msg: >-
{{ inventory_hostname }} tiene {{ ansible_memtotal_mb }} MB de RAM. Con
{{ redroid_host_reserve_mb }} MB de reserva y {{ redroid_mem_mb }} MB por
instancia no cabe ninguna. Baja redroid_mem_mb, baja la reserva, o amplía
la RAM del blade (que es la palanca buena — ver hardware_m1000e.md).
- name: Validar configuración de macvlan
ansible.builtin.assert:
that:
- redroid_macvlan_parent | length > 0
- redroid_macvlan_subnet | length > 0
- redroid_macvlan_gateway | length > 0
- redroid_macvlan_ip_prefix | length > 0
- redroid_macvlan_ip_offset is defined
fail_msg: >-
redroid_network_mode es 'macvlan' pero faltan datos de red. Necesito
redroid_macvlan_parent / _subnet / _gateway / _ip_prefix en group_vars/all.yml
y redroid_macvlan_ip_offset por host en el inventario.
when: redroid_network_mode == 'macvlan'
- name: Crear árbol de directorios
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- "{{ redroid_base_dir }}"
- "{{ redroid_base_dir }}/data"
- "{{ redroid_base_dir }}/apks"
- name: Crear el volumen /data de cada instancia
ansible.builtin.file:
path: "{{ redroid_base_dir }}/data/device-{{ '%02d' % (item + 1) }}"
state: directory
owner: root
group: root
mode: "0755"
loop: "{{ range(redroid_instance_count | int) | list }}"
# --- red macvlan -----------------------------------------------------------
- name: Listar redes docker existentes
ansible.builtin.command:
cmd: docker network ls --format '{% raw %}{{.Name}}{% endraw %}'
register: redroid_networks
changed_when: false
when: redroid_network_mode == 'macvlan'
- name: Crear la red macvlan
ansible.builtin.command:
cmd: >-
docker network create -d macvlan
--subnet={{ redroid_macvlan_subnet }}
--gateway={{ redroid_macvlan_gateway }}
-o parent={{ redroid_macvlan_parent }}
{{ redroid_macvlan_network }}
when:
- redroid_network_mode == 'macvlan'
- redroid_macvlan_network not in redroid_networks.stdout_lines
changed_when: true
# --- contenedores ----------------------------------------------------------
- name: Escribir el docker-compose del nodo
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ redroid_base_dir }}/docker-compose.yml"
owner: root
group: root
mode: "0644"
register: redroid_compose
- name: Descargar la imagen de redroid
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ redroid_base_dir }}"
register: redroid_pull
changed_when: "'Downloaded' in redroid_pull.stderr or 'Pulled' in redroid_pull.stderr"
- name: Levantar las instancias Android
ansible.builtin.command:
cmd: docker compose up -d --remove-orphans
chdir: "{{ redroid_base_dir }}"
register: redroid_up
changed_when: "'Started' in redroid_up.stderr or 'Created' in redroid_up.stderr or 'Recreated' in redroid_up.stderr"
- name: Contar contenedores en marcha
ansible.builtin.command:
cmd: docker compose ps --status running --format '{% raw %}{{.Name}}{% endraw %}'
chdir: "{{ redroid_base_dir }}"
register: redroid_running
changed_when: false
- name: Resumen del nodo
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}: {{ redroid_running.stdout_lines | length }}/{{ redroid_instance_count }}
instancias en marcha ({{ redroid_network_mode }})

View file

@ -0,0 +1,38 @@
# GENERADO POR ANSIBLE — no editar a mano.
# Fuente: roles/redroid/templates/docker-compose.yml.j2
# Nodo: {{ inventory_hostname }} · {{ ansible_memtotal_mb }} MB RAM · {{ redroid_instance_count }} instancias
name: redroid-{{ inventory_hostname }}
services:
{% for i in range(redroid_instance_count | int) %}
{% set idx = '%02d' % (i + 1) %}
device-{{ idx }}:
image: {{ redroid_image }}
container_name: redroid-{{ inventory_hostname }}-{{ idx }}
privileged: true
restart: unless-stopped
mem_limit: {{ redroid_mem_mb }}m
volumes:
- {{ redroid_base_dir }}/data/device-{{ idx }}:/data
{% if redroid_network_mode == 'macvlan' %}
networks:
redroid_lan:
ipv4_address: {{ redroid_macvlan_ip_prefix }}{{ (redroid_macvlan_ip_offset | int) + i }}
{% else %}
ports:
- "{{ redroid_adb_bind_ip }}:{{ (redroid_adb_base_port | int) + i }}:5555"
{% endif %}
command:
- androidboot.redroid_width={{ redroid_width }}
- androidboot.redroid_height={{ redroid_height }}
- androidboot.redroid_dpi={{ redroid_dpi }}
- androidboot.redroid_gpu_mode={{ redroid_gpu_mode }}
{% endfor %}
{% if redroid_network_mode == 'macvlan' %}
networks:
redroid_lan:
external: true
name: {{ redroid_macvlan_network }}
{% endif %}