Spice-net en diez minutos.
Spice-net es un coordinador de mesh WireGuard self-hosted donde la autenticación no es una contraseña ni un token bearer: es una clave ED25519 que vive sólo en el dispositivo. Si la credencial se filtra, sigue sin ser una llave maestra, porque la llave es el dispositivo.
El sistema tiene dos planos: un control plane
(spice-net-ctrl) que decide quién existe en la red, y un
data plane integrado en el agent que mueve los bytes
con el protocolo WireGuard. El operador habla con el sistema a través
de spice; cada host ejecuta spice-agent.
Estado actual
| Capa | Qué hace | Estado |
|---|---|---|
| Control plane | Registro, ACL, heartbeat, long-poll de peers, firma ED25519 | listo |
| Data plane | Noise_IK, ChaCha20-Poly1305, replay window, UDP | listo |
| Agent | Identity, registro, heartbeat, peer-sync, TUN (Linux), ACL egress | listo |
| CLI | register · revoke · nodes · status · acl | listo |
| Packaging | .deb + systemd unit | pendiente |
| NAT traversal | STUN + hole-punching | pendiente |
Quickstart
Requisitos
- Linux x86_64 con
CAP_NET_ADMINpara el agent (el ctrl corre en cualquier Linux o en Mentat sin requisitos especiales) - Puerto UDP abierto (por defecto
51820) entre los nodos del tailnet - Un endpoint HTTPS accesible para el control plane
Binarios
Spice-net se distribuye como binarios estáticos — nada de compilación, nada de toolchain. Tres ejecutables:
spice-net-ctrl— control plane (~6 MB)spice-agent— agent por nodo (~4.4 MB ELF estático)spice— CLI de administración (~5 MB)
Contacte al equipo para obtener los binarios y su licencia asociada.
Levantar el control plane
spice-net-ctrl \
--addr 0.0.0.0:7777 \
--data-dir /var/lib/spice
Persistencia en sled. Al reiniciar rehidrata el NodeRegistry y la policy ACL. Para producción se despliega en Mentat como servicio Hull con Caddy al frente (TLS automático) — ver Deploy en Mentat.
Registrar un operador
# Desde tu laptop (la identidad se guarda en ./admin.json, 0600).
spice --server https://ctrl.example.com \
--identity ./admin.json \
register --name admin --acl admin
spice --identity ./admin.json nodes
id name ip group last_seen
1 admin 100.64.0.1 admin 1776461732
Añadir un host
SPICE_CTRL_URL=https://ctrl.example.com \
SPICE_NODE_NAME=dev-laptop \
SPICE_ACL_GROUP=dev \
SPICE_IDENTITY=/etc/spice/identity.json \
SPICE_ADVERTISE_ENDPOINT=$(curl -s ifconfig.me):51820 \
sudo ./spice-net-agent
El --advertise-endpoint (o SPICE_ADVERTISE_ENDPOINT) es el
ip:puerto donde otros peers pueden alcanzar tu data plane.
Sin él, el nodo es inbound-only: responde handshakes pero no
los inicia.
Deploy en producción
Topología recomendada
- ctrl: un único host con IP pública y TLS. Low traffic (~docenas de KB por agent/día) — cualquier t3.small sirve.
-
agents: uno por cada máquina que quieres en la mesh.
Requieren
CAP_NET_ADMINy acceso UDP al endpoint del peer. -
operadores: laptops de administradores con
spice. La identidad ED25519 del operador se trata como cualquier otro nodo.
Firewall
- Abrir TCP
7777(HTTPS) en el ctrl para los agents. - Abrir UDP
51820(o el que uses en--wire-port) en cada agent saliente; el entrante se negocia vía el endpoint anunciado. - Entre dos agents detrás de NAT restrictivo, necesitas — hoy — reenviar manualmente el puerto. NAT traversal automático es parte del roadmap.
Arquitectura
Dos planos independientes. Separarlos significa que el plano de control puede reiniciarse sin cortar el tráfico entre peers, y que una compromisión del ctrl no implica automáticamente exfiltración de datos — el ctrl nunca ve claves privadas ni tráfico en vuelo.
Direccionamiento
Las IPs de la tailnet salen del rango CGNAT 100.64.0.0/10,
elegido porque no colisiona con ninguna red RFC 1918 que puedas tener en
producción. El primer IP (100.64.0.0) se reserva como
red; el IpAllocator entrega 100.64.0.1,
100.64.0.2…
Versionado + long-poll
El NodeRegistry mantiene un contador atómico
version que se incrementa en cada mutación (registro,
revocación, cambio de endpoint). Los agents hacen
GET /peers?since=N&wait=30; el server sólo responde
cuando version > N o vence el timeout. Latencia de
propagación: un round-trip.
Modelo de seguridad
Identidad
Cada nodo genera localmente un par de claves ED25519 (firma HTTP) y
un par X25519 (WireGuard). Las privadas nunca dejan el disco del
nodo. El archivo se escribe con permisos 0600 y via
rename atómico.
Revocar un nodo es
DELETE /v1/nodes/:id; la lista de peers se actualiza en
milisegundos por long-poll. Una credencial robada
queda inservible en cuanto se revoca, sin esperar a que expire
nada.
Autenticación de requests
Cada request (excepto /v1/health y /v1/nodes/register)
lleva:
X-Spice-Node-Id: id decimal del nodoX-Spice-Timestamp: unix seconds (ventana ±5 min)X-Spice-Signature: 64 bytes ED25519 en hex
El mensaje firmado es
METHOD \n PATH_AND_QUERY \n TIMESTAMP \n BODY. Modificar
cualquier byte rompe la firma. El query string firmado previene
replay con parámetros alterados (p. ej. no puedes subir
?since=0 a ?since=99999 para silenciar
updates).
Data plane
- Handshake Noise_IK con el identificador exacto del paper de WireGuard. Compatible con el dispatcher.
- MAC1 BLAKE2s-128 keyed sobre el header; se valida antes de tocar material privado.
- TAI64N de timestamp por handshake con replay detection.
- Replay window de 64 bits por sesión de transporte.
- AEAD ChaCha20-Poly1305 con counter deterministico.
ACL
El agent aplica la policy en el egress: antes de llamar a
wire.send, el paquete se clasifica (dst IP, peer, grupo)
y se dropa si la regla dice deny. First-match-wins,
default deny cuando la policy tiene al menos una regla
(política vacía = allow-all). El ingress check
(defensa en profundidad) es parte del roadmap.
spice revoke <node_id> —
eso elimina el nodo del registro (DELETE /v1/nodes/:id,
libera su IP, lo remueve del peer list de todos los demás). Pero requiere
detección del robo. Un ctrl comprometido puede entregar peer lists
falsos. Verificación por transparencia — logs firmados, staking — no
está implementada todavía.
Data plane
El agent embebe un stack WireGuard minimal (Curve25519, ChaCha20-Poly1305, BLAKE2s) junto con un dispatcher TUN/UDP. Los paquetes IPv4 salen del TUN, se clasifican contra la policy ACL local, y sólo los autorizados se cifran y entregan al peer correspondiente.
Capacidades expuestas por el runtime:
| Operación | Qué hace |
|---|---|
| init | Abre el socket UDP, carga la clave privada X25519 |
| add_peer | Inserta un peer (pubkey, allowed_ip, endpoint) |
| remove_peer | Borra peer y su sesión activa |
| handshake | Dispara handshake_init hacia un peer conocido |
| send | Cifra y envía un paquete IPv4 al peer dueño del dst_ip |
| poll | Drena un datagrama UDP, dispatcha (init/resp/transport), descifra |
| stats | Contadores: peers, tx/rx packets, handshakes ok/failed |
| deinit | Cierra el socket, libera la peer table |
Flujo interno: dos bucles Linux — tx (TUN → parse IPv4 → ACL →
send) y rx (poll → tun.write). Cada nodo mantiene sus
contadores de handshakes_ok y tx/rx packets
consultables vía spice status.
CLI spice
Flags globales
| Flag | Env | Default |
|---|---|---|
--server | SPICE_CTRL_URL | http://127.0.0.1:7777 |
--identity | SPICE_IDENTITY | ./spice-identity.json |
Subcomandos
spice register --name <n> --acl <group> [--ephemeral]
Crea (o reutiliza) identidad en --identity, envía POST /v1/nodes/register,
imprime node_id + IP asignada + peers iniciales.
spice nodes
Lista todos los nodos del tailnet ordenados por id. Requiere auth.
spice revoke <node_id>
DELETE /v1/nodes/:id. Los demás agents dejan de verlo por long-poll.
spice status
Pubkeys locales, node_id, version del tailnet, peer count.
spice acl show
Imprime la policy actual en YAML.
spice acl set <file>
Sube la policy desde YAML o JSON. Reemplaza la existente.
Ejemplo de archivo de policy (YAML):
rules:
- src: "group:dev"
dst: "group:dev"
proto: any
action: allow
- src: "group:dev"
dst: "node:oracle"
port: 1521
proto: tcp
action: allow
- src: "*"
dst: "*"
action: deny
Agent (variables de entorno)
| Variable | Flag | Default / Notas |
|---|---|---|
SPICE_CTRL_URL | --ctrl | http://127.0.0.1:7777 |
SPICE_NODE_NAME | --name | obligatorio, único en el tailnet |
SPICE_ACL_GROUP | --acl-group | agent. Acepta [a-zA-Z0-9_-]{1,32} |
SPICE_IDENTITY | --identity | ./spice-identity.json, 0600 |
SPICE_EPHEMERAL | --ephemeral | false. Si true, el nodo es GC’d a los 60 s sin heartbeat |
SPICE_HEARTBEAT_SECS | --heartbeat-secs | 20 |
SPICE_TUN_NAME | --tun-name | spice0 (Linux) |
SPICE_WIRE_PORT | --wire-port | 51820 |
SPICE_ADVERTISE_ENDPOINT | --advertise-endpoint | ninguno. Sin él, el nodo es inbound-only |
SPICE_NO_WIRE | --no-wire | false. Salta inicialización del data plane (control only) |
Logs vía tracing. Controla el nivel con
RUST_LOG=spice_net_agent=debug. Recomendado:
info en producción, debug al bringar up.
HTTP API
Autenticación
Todas las rutas excepto /v1/health y
/v1/nodes/register exigen firma ED25519. Ver
modelo de seguridad para el formato canonical.
Endpoints
| Verbo | Ruta | Descripción |
|---|---|---|
| GET | /v1/health | Liveness probe + node count |
| POST | /v1/nodes/register | Bootstrap: entrega IP y peer list |
| GET | /v1/nodes | Lista todos los nodos |
| DELETE | /v1/nodes/:id | Revoca y libera IP |
| GET | /v1/nodes/:id/peers?since=&wait= | Long-poll de la peer list |
| POST | /v1/nodes/:id/heartbeat | Keepalive + endpoint update (self-only) |
| GET | /v1/acl | Policy actual |
| PUT | /v1/acl | Reemplaza la policy |
Ejemplo de register
curl -X POST https://ctrl.example.com/v1/nodes/register \
-H 'content-type: application/json' \
-d '{
"name": "alice",
"pubkey": "01010101...",
"wg_pubkey": "02020202...",
"acl_group": "dev",
"ephemeral": false
}'
# 201 Created
{
"node_id": 3,
"assigned_ip": "100.64.0.3",
"peers": [
{
"id": 1,
"name": "admin",
"wg_pubkey": "aa...",
"ip": "100.64.0.1",
"acl_group": "admin",
"endpoint_ip": 757935361,
"endpoint_port": 51820
}
]
}
since=N y el server
todavía está en version N, el request se bloquea hasta que haya una
mutación o venza wait (clamp 0..=60 s). Los cambios de
heartbeat que no tocan endpoint no bumpean version, así que
los long-polls no se despiertan en vano.
ACL policy
La policy es una lista ordenada de reglas. Para cada paquete de salida, el agent recorre las reglas de arriba a abajo; la primera que matchea decide. Si ninguna regla matchea y la policy no está vacía, el paquete se dropa. Policy vacía = allow-all.
Regla
| Campo | Tipo | Descripción |
|---|---|---|
src | string | "group:<x>", "node:<name>" o "*" |
dst | string | misma sintaxis |
proto | tcp|udp|any | default any |
port | u16 o null | Si fijo, sólo matchea si el paquete trae ese puerto destino |
action | allow|deny | Efecto al matchear |
Ejemplo: segmento "dev puede hablar consigo mismo, nada más"
rules:
- src: "group:dev"
dst: "group:dev"
action: allow
- src: "*"
dst: "*"
action: deny
Ejemplo: "his accede sólo a oracle:1521"
rules:
- src: "group:his"
dst: "node:oracle"
port: 1521
proto: tcp
action: allow
- src: "*"
dst: "*"
action: deny
Unit de systemd (borrador)
El .deb oficial llegará cuando el primer Linux end-to-end
esté validado; mientras tanto, esto es lo que recomendamos pegar en
/etc/systemd/system/spice-net-agent.service:
[Unit]
Description=spice-net agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/spice-net-agent
EnvironmentFile=/etc/spice/agent.env
AmbientCapabilities=CAP_NET_ADMIN
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/etc/spice
RestartSec=5
Restart=on-failure
[Install]
WantedBy=multi-user.target
/etc/spice/agent.env:
SPICE_CTRL_URL=https://ctrl.example.com
SPICE_NODE_NAME=dev-laptop-rafa
SPICE_ACL_GROUP=dev
SPICE_IDENTITY=/etc/spice/identity.json
SPICE_ADVERTISE_ENDPOINT=203.0.113.42:51820
SPICE_WIRE_PORT=51820
RUST_LOG=spice_net_agent=info
Troubleshooting
handshakes_ok=0 tras minutos
Uno de los dos peers no tiene endpoint público anunciado, o el firewall
bloquea el UDP en su --wire-port. Verifica con:
# En el peer remoto
sudo nc -u -l -p 51820 & sleep 2
# En el origen
echo -n 'hello' | nc -u -w 1 <remote-ip> 51820
peer has no advertised endpoint — handshake deferred
Normal al arranque, si el peer remoto aún no hizo su primer heartbeat
con endpoint. Se resuelve solo en la siguiente iteración de
peer_sync (~1–2 s con heartbeat default).
tun open failed: Operation not permitted
El agent no tiene CAP_NET_ADMIN. Arranca con
sudo, o da la capability al binario con:
sudo setcap cap_net_admin+ep /usr/local/bin/spice-net-agent
El ctrl reinicia y los nodos "desaparecen"
Verifica el volumen de --data-dir (sled). Si era /tmp,
al reiniciar se borró. En producción siempre apunta a un path en disco
persistente (/var/lib/spice).
Roadmap
- Packaging:
.deb, systemd unit distribuible, Homebrew tap. - NAT traversal: STUN + hole-punching para nodos detrás de NAT doméstico / móvil.
- TLS relay: equivalente a DERP de Tailscale, para redes que bloquean UDP.
- Ingress ACL: defense-in-depth en el lado receptor.
- Admin UI: dashboard web (detrás de auth) para operar sin CLI.
- macOS / Windows agents: TUN nativa en cada plataforma.
- Métricas Prometheus: endpoint
/metricspara pelar el estado. - Mentat integration: registro automático de microVMs Firecracker al arrancar; revocación al destruir.