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

CapaQué haceEstado
Control planeRegistro, ACL, heartbeat, long-poll de peers, firma ED25519listo
Data planeNoise_IK, ChaCha20-Poly1305, replay window, UDPlisto
AgentIdentity, registro, heartbeat, peer-sync, TUN (Linux), ACL egresslisto
CLIregister · revoke · nodes · status · acllisto
Packaging.deb + systemd unitpendiente
NAT traversalSTUN + hole-punchingpendiente

Quickstart

Requisitos

  • Linux x86_64 con CAP_NET_ADMIN para 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_ADMIN y 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.

spice-net-ctrl
axum · ed25519 auth · NodeRegistry · AclPolicy · sled
long-poll GET /v1/nodes/:id/peers?since=N&wait=S
agent-A
identity · heartbeat · peer_sync · acl_sync
wire · UDP+TUN
agent-B
identity · heartbeat · peer_sync · acl_sync
wire · UDP+TUN
spice CLI
administración · firmado ed25519

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 nodo
  • X-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.

Lo que NO protege. Un laptop físicamente robado conserva acceso hasta que un operador corra 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ónQué hace
initAbre el socket UDP, carga la clave privada X25519
add_peerInserta un peer (pubkey, allowed_ip, endpoint)
remove_peerBorra peer y su sesión activa
handshakeDispara handshake_init hacia un peer conocido
sendCifra y envía un paquete IPv4 al peer dueño del dst_ip
pollDrena un datagrama UDP, dispatcha (init/resp/transport), descifra
statsContadores: peers, tx/rx packets, handshakes ok/failed
deinitCierra 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

FlagEnvDefault
--serverSPICE_CTRL_URLhttp://127.0.0.1:7777
--identitySPICE_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)

VariableFlagDefault / Notas
SPICE_CTRL_URL--ctrlhttp://127.0.0.1:7777
SPICE_NODE_NAME--nameobligatorio, único en el tailnet
SPICE_ACL_GROUP--acl-groupagent. Acepta [a-zA-Z0-9_-]{1,32}
SPICE_IDENTITY--identity./spice-identity.json, 0600
SPICE_EPHEMERAL--ephemeralfalse. Si true, el nodo es GC’d a los 60 s sin heartbeat
SPICE_HEARTBEAT_SECS--heartbeat-secs20
SPICE_TUN_NAME--tun-namespice0 (Linux)
SPICE_WIRE_PORT--wire-port51820
SPICE_ADVERTISE_ENDPOINT--advertise-endpointninguno. Sin él, el nodo es inbound-only
SPICE_NO_WIRE--no-wirefalse. 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

VerboRutaDescripción
GET/v1/healthLiveness probe + node count
POST/v1/nodes/registerBootstrap: entrega IP y peer list
GET/v1/nodesLista todos los nodos
DELETE/v1/nodes/:idRevoca y libera IP
GET/v1/nodes/:id/peers?since=&wait=Long-poll de la peer list
POST/v1/nodes/:id/heartbeatKeepalive + endpoint update (self-only)
GET/v1/aclPolicy actual
PUT/v1/aclReemplaza 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
    }
  ]
}
Long-poll semantics. Si pasas 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

CampoTipoDescripción
srcstring"group:<x>", "node:<name>" o "*"
dststringmisma sintaxis
prototcp|udp|anydefault any
portu16 o nullSi fijo, sólo matchea si el paquete trae ese puerto destino
actionallow|denyEfecto 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 /metrics para pelar el estado.
  • Mentat integration: registro automático de microVMs Firecracker al arrancar; revocación al destruir.