Developers

Un recorrido por el SDK de URnetwork

14 min de lecturaView as markdown ↗

Esta es la inmersión a fondo detrás de los primeros pasos: cómo está montada cada capa de bindings, los valores por defecto que trae y los bordes afilados que conocemos. Todo corre contra la plataforma alojada de URnetwork. Tú traes credenciales, no infraestructura. El modelo de objetos (dispositivo, API, view controllers) está en /docs/overview y /docs/api.

Un solo núcleo Go implementa el cliente entero: transporte, contratos, ruta de paquetes. Cada artefacto (AAR, xcframework, wasm, biblioteca c-shared) es un binding de ese mismo núcleo, y por eso el comportamiento es idéntico entre plataformas, y por eso los artefactos son grandes (cada uno lleva el runtime de Go). El núcleo lleva también reglas de producto, no solo paquetes. Solana Pay vive aquí (CreatePaymentReference, BuildSolanaPaymentUrl) porque una vez no fue así: la app web acuñaba la referencia de pago como un uuid hexadecimal donde Solana Pay exige una pubkey base58 de 32 bytes, así que un cliente podía pagar y no ser emparejado nunca de vuelta con su cuenta, y Android llevaba codificados el importe y la dirección del comercio. Reimplementar una regla que las apps ya siguen es la clase de bug que esta capa existe para prevenir.

Móvil

DeviceLocal, y la división de procesos de app en Apple

DeviceLocal es la cosa real, el motor cliente en marcha: posee el transporte, el estado de contratos (la contabilidad de URnetwork de los bytes que una cuenta puede mover) y la ruta de paquetes. DeviceRemote es un cliente de un DeviceLocal que vive en otro proceso o en la plataforma, con la misma superficie de API: un mando a distancia del dispositivo real. En Android un solo proceso aloja la UI y el VpnService, así que la app sostiene el DeviceLocal directamente.

Apple ejecuta el manejo de paquetes de VPN en su propio proceso NetworkExtension en sandbox (toda VPN de iOS/macOS se divide así), así que el SDK se divide con él:

  • La extensión NEPacketTunnelProvider posee el SdkDeviceLocal y llama a setRpcServer(serverPem, clientCertPem, hostPort) sobre él para arrancar el listener.
  • El proceso de la app crea un SdkDeviceRemote y se acopla por una conexión device-RPC con mTLS en loopback: el setRpcServer(clientPem, serverCertPem, hostPort) en espejo, cada lado nombrando primero su propia identidad y segundo el certificado del par. Device-RPC es el protocolo propio del SDK: llamadas a métodos más flujos de suscripción sobre el net/rpc de Go, no gRPC. El TLS mutuo en loopback no es ceremonia. Localhost es alcanzable por todo proceso y usuario local, y solo quien posee los PEM correspondientes puede acoplarse.
  • La app acuña el material de claves, no la extensión. SdkGenerateDeviceRpcKeyMaterial() devuelve un par de claves de servidor y cliente autofirmado nuevo por sesión de VPN; la app pone ambos PEM y el host:port en la configuración de proveedor del NETunnelProviderProtocol, que es como la extensión los recibe cuando el túnel arranca. No hay contenedor de app group implicado.
  • Vigila RemoteChangeListener: la primera vez que remoteConnected pasa a verdadero ese material queda probado, así que persístelo como último-bueno-conocido y reaplícalo en el siguiente arranque. Eso es lo que permite a la app acoplarse a una extensión ya en marcha.

Las reconstrucciones son rutina: la extensión reconstruye su dispositivo cada vez que el túnel se reinicia, porque el usuario conmuta la VPN, el sistema reinicia la extensión, o iOS la mata por exceder el límite de memoria de extensiones. Cablea ese reacople (acuñar, arrancar el túnel, setRpcServer, reaplicar tu propio estado de UI) desde el primer día. Todo lo que puedes hacer sobre un DeviceLocal lo puedes hacer sobre un DeviceRemote; el remoto reenvía las llamadas y reproduce las suscripciones a través de las reconexiones. Dos niveles: una reconexión simple (el mismo dispositivo, el enlace RPC se cayó y volvió) restablece las suscripciones por ti; una recreación (una instancia de dispositivo nueva debajo) significa ejecutar esa secuencia otra vez. Una trampa: DeviceRecreatedListener se dispara con un cambio de generación de dispositivo, y solo la ruta RPC alojada en la plataforma estampa una, así que en loopback dirige el reacople desde tu propio ciclo de vida del túnel y RemoteChangeListener.

IoLoop (solo Android)

Sdk.newIoLoop(device, detachedFd) es la bomba de paquetes:

  • Pasa un fd desacoplado y no bloqueante (ParcelFileDescriptor.detachFd()). Tras esa llamada, Go posee el fd y lo cerrará; no vuelvas a envolverlo ni cerrarlo desde Java. Dos dueños significan doble cierre: los números de fd se reciclan de inmediato, así que un cierre extraviado desde Java puede pisar el descriptor sin relación que reciba el número después, corrupción lejos de la causa.
  • El bucle bombea ambas direcciones (tun→dispositivo y dispositivo→tun) dentro de Go, lo que evita cruces JNI y copias de búfer por paquete. Por eso la API toma un fd crudo en lugar de exponer métodos de lectura/escritura.
  • Cierra el IoLoop (no el fd) para parar; el callback de finalización se dispara cuando el bucle sale. Si se dispara sin que lo pidieras, porque el fd del tun llegó a EOF o a un error o el dispositivo se apagó, trátalo como "el túnel ya no está": termina la sesión del VpnService, y luego restablécela o muestra desconectado. El callback llega en un hilo del SDK, y Go cierra el fd a la salida; no lo toques nunca desde el gestor.

Apple no usa IoLoop: el proveedor del túnel mueve los paquetes por packetFlow, la única interfaz que ofrece NetworkExtension. Toda VPN de iOS paga ese cruce, y sus lecturas por lotes amortizan el coste.

Los modos de compartir, y por qué importa el material de claves

SetProvideMode controla si el dispositivo ofrece capacidad a la red. El valor por defecto es apagado: un dispositivo recién construido tiene el modo de compartir en "none" y no ofrece nada hasta que fijes un modo, así que incrustar el SDK nunca comparte en silencio el ancho de banda de tus usuarios. Dos modos hacen algo hoy: public, y network, que limita el compartir a otros dispositivos de la misma cuenta de URnetwork. El enum del protocolo lleva más valores, pero la plataforma resuelve todo par fuera de tu red a public, así que la elección real es apagado, mis propios dispositivos, o cualquiera.

Compartir es donde importa la persistencia del material de claves: el DeviceLocalKeyMaterial que pasas en la construcción es la identidad de proveedor del dispositivo, una semilla de clave de cliente más el certificado y la clave del TLS de compartir. Persístelo en el almacenamiento seguro de la plataforma (Keystore, Keychain) y pasa el mismo material en cada arranque, o la red ve un proveedor nuevo de fábrica cada vez y pierde el historial de fiabilidad que la selección prefiere. El material efímero (null) vale para clientes puros.

Si muestras el compartir, declara el intercambio: el tráfico de desconocidos sale por la IP del usuario, y la posición de proveedor ve IPs de destino y SNI de TLS (como un ISP), aunque por defecto no la IP real del usuario de origen. La seguridad del proveedor está diseñada en el motor. La capa ip_security de código abierto inspecciona la propia salida del proveedor y descarta el tráfico de clase DMCA y de clase CFAA antes de que salga. El veredicto es un paquete descartado, sin destino, dominio ni contenidos registrados en ninguna parte. Una coincidencia de firma de BitTorrent emite además una señal de abuso al operador que lleva solo el id de dispositivo del par y un booleano, y el operador no incluye hoy ningún gestor para ella; los descartes de cifrado opaco son silenciosos. Los proveedores participan en el protocolo UR; ur.xyz documenta las recompensas.

Notas de runtime

  • El ritmo del GC se ajusta por sistema operativo automáticamente: un factor de ritmo de 10 en iOS (los límites de memoria de las extensiones son brutales) frente a 50 en Android y 100 en el resto. No lo fijas tú.
  • memoryTargetByteCount es un mando distinto: un presupuesto de bytes que el dispositivo reparte para dimensionar sus propios búferes, dns 2 : cliente 14 : proveedor 4, con la parte del proveedor respaldando al par del cliente mientras el compartir está apagado (por defecto 20 MB). Ni un tope ni un ajuste del GC: el límite blando de huella de todo el proceso es el SetMemoryLimit aparte, y la muerte dura es el límite de extensiones del sistema. Fija el objetivo por debajo de él; mantén la extensión casi sin lógica.
  • El bind de gomobile está vigilado en la compilación, y el mecanismo importa: gobind omite en silencio lo que no puede enlazar, dejando solo un comentario // skipped en las fuentes generadas, así que la compilación hace grep de esas fuentes y falla ante cualquier omisión que no esté en una lista de permitidos explícita. Las omisiones permitidas son internas (payloads gob del RPC, la superficie proxy/plataforma, formas uint64/[][]byte que gomobile no puede expresar): deliberadas, no deriva.

JavaScript

Por qué no hay DeviceLocal en wasm

Una página de navegador no puede poseer una interfaz tun, así que un DeviceLocal en wasm no tendría nada que bombear. La capa JS trae solo las mitades del lado cliente: la superficie de API, los view controllers y DeviceRemote, un cliente de dispositivo completo cuyo dispositivo vive en otra parte. La página sostiene el plano de control (estado de conexión, ubicaciones, estadísticas, cuenta) mientras la ruta de tráfico la consume lo que pueda usar el proxy alojado. De ahí las dos fábricas: createProxyDevice es el modelo fino, que resuelve URLs de proxy alojadas y deja el tráfico a lo que las consuma (una configuración de proxy de extensión, un agente de fetch); createPlatformDeviceRemote es el modelo grueso, un DeviceRemote real con toda la superficie de listeners y view controllers sobre un dispositivo que la plataforma aloja por ti.

La autenticación signedProxyId

createPlatformDeviceRemote abre un websocket de device-RPC a wss:///device-rpc. Ese websocket no se autentica con el JWT de cuenta (byJwt, el token bearer del login de URnetwork; el "by" es una herencia de BringYour). Se autentica con signedProxyId, el auth_token que el endpoint /network/auth-client de la plataforma devuelve junto a la URL del proxy. Es separación de alcances deliberada: el id de proxy firmado autoriza exactamente un websocket y no lleva autoridad de cuenta, así que el token amplio nunca viaja por el socket del plano de datos. Trata (proxyUrl, signedProxyId) como una sola credencial. Pídelos, pásalos y refréscalos juntos, y vuelve a pedir el par cuando el proxy rechace el socket en lugar de cachear una pieza.

El patrón de listeners y los view controllers

Toda suscripción sigue la misma forma: add*ChangeListener(fn) devuelve una función de desuscripción. Consérvala y llámala en el desmontaje. En React, devuélvela:

useEffect(() => {
  const unsub = device.addConnectChangeListener(setConnectEnabled);
  return unsub;
}, [device]);

Eso sobrevive a los efectos doblemente invocados del modo estricto de React (añadir, desuscribir, añadir), dejando exactamente una suscripción viva. La superficie de view controllers (conexión, ubicaciones, dispositivos, contratos, acciones de bloqueo) también está enlazada dentro del wasm, colgando del dispositivo, como en device.openConnectViewController(), así que una app web reutiliza la misma lógica de presentación que las apps móviles en lugar de rederivar el estado desde listeners crudos.

Cómo lo usa ur.io

La superficie /app de ur.io es el consumidor de referencia: el wasm se carga en diferido solo cuando el usuario llega a una superficie de conexión, así que las páginas de aterrizaje nunca pagan el coste de ~43 MB, y hay un DeviceRemote por pestaña. Los exports son globales a la página, así que init está detrás de un singleton.

La extensión de navegador empaqueta el mismo wasm pero hoy no lo instancia: mueve la conectividad a través del proxy de la plataforma más las APIs de proxy propias del navegador, y consume el otro punto de entrada del paquete npm, @urnetwork/sdk-js/react, hooks de API basados en fetch plano y tipos generados que no necesitan wasm. Si solo quieres la superficie REST, importa eso y no llames nunca a init: el cargador resuelve la URL del wasm en tiempo de ejecución y no vía un new URL(..., import.meta.url) estático, así que los bundlers no lo emiten para los consumidores que no lo usan.

cgo

La división con el demonio

Las apps de Linux y Windows de URnetwork que se distribuyen están construidas sobre esta capa, y ambas usan el mismo patrón de dos procesos, la forma a copiar para cualquier integración cgo que toque un dispositivo tun. El SDK trae el motor y la API de C; el demonio es tuyo, con esas dos apps publicadas como referencias de código abierto:

  • Un proceso root/de servicio (unidad de systemd, servicio de Windows) ejecuta el DeviceLocal y posee la interfaz tun.
  • El proceso de UI sin privilegios ejecuta un DeviceRemote y se acopla por device-RPC con mTLS en loopback; la dirección por defecto del SDK es 127.0.0.1:12025.
  • Los PEM del mTLS se entregan a la UI por un canal que el propio sistema operativo autoriza: un socket unix comprobado con SO_PEERCRED en Linux (comprobado en el accept, antes de leer ningún frame), una tubería con nombre en Windows. Ese handshake, no el puerto TCP, es la frontera de autorización real; el mTLS en loopback solo mantiene a los demás usuarios locales fuera del puerto.

Semántica del envoltorio RAII (urnetwork_sdk.hpp)

  • Los handles se envuelven en tipos con propiedad cuyo destructor llama a urnet_release. Pero liberar no es cerrar/parar: soltar el último envoltorio no detiene un dispositivo ni cierra una conexión. Llama antes a *_close / *_stop (los .close() / .stop() del envoltorio), y deja luego que el envoltorio libere. Varios handles pueden referirse a un mismo objeto vivo, así que una salida de ámbito no debe matar jamás en silencio una sesión viva.
  • Las suscripciones vuelven como urnet::Sub; el destructor desuscribe.
  • Los errores afloran como excepciones urnet::Error que llevan el texto de out_error.

Una regla dura, lo contrario de lo que la gente espera:

sub = device.addConnectChangeListener([&](bool enabled) { /* ... */ });
sub.close();   // returns immediately; a callback may still be running
// do NOT free what that lambda captured here

Las listas de listeners son copy-on-write, así que desuscribir quita la entrada y vuelve. Es deliberado (a un callback se le permite quitarse a sí mismo desde dentro de sí mismo), pero significa que un callback puede seguir corriendo en otro hilo después de que el Sub haya desaparecido. Destruir el estado que ese listener capturó es el crash real; mantenlo vivo más allá del desmontaje con un shared_ptr o una bandera que el callback compruebe.

Para la disciplina de fugas, toma una instantánea de urnet_live_handle_count() antes de un escenario, ejecuta construir/usar/cerrar/liberar y comprueba que vuelve al valor de partida. cgo/smoke hace exactamente esto y es el patrón a copiar.

Qué está enlazado de verdad

La ABI de C se genera desde la superficie Go, y el generador escribe cgo/coverage_report.txt: cada símbolo exportado, y para cada uno que no cruzó, la razón (contextos de Go, entrañas de net.Conn, parámetros de función, llamadas con propiedad de pool, tipos gob del RPC). Lee ese archivo, no el código Go, para responder "¿esto se puede llamar desde C?". Un export nuevo de Go llega a la cabecera solo cuando corre el generador, así que la cabecera viaja junto a la biblioteca con la que se cortó. Fíjalos juntos.

Compatibilidad de protocolo

El protocolo de cable del device-RPC está fijado por versión (DeviceRpcVersion, actualmente 1) y aplicado por el local en cada sincronización. Deliberadamente no es la versión de la release: las mitades alojadas se despliegan por separado, así que atarlas rechazaría a todos los navegadores tras un despliegue del servidor. Un desajuste no lanza excepción. El remoto se queda sin sincronizar y reintenta, lo que parece idéntico a "el demonio no está en marcha". Distínguelos con GetRemoteConnected() más GetSyncError(): un error de sincronización vacío significa aún no alcanzable, mientras que "device rpc version mismatch: ..." o "device instance mismatch: ..." es un rechazo que reconectar no arreglará jamás. Distribuye el demonio y la UI de la misma release del SDK.

Transversal

Ajustes que llevan política

Estos flags sostienen decisiones de producto, no afinado, y sus valores por defecto son la postura de privacidad que reciben tus usuarios. Lo que trae un dispositivo de fábrica:

AjusteValor por defectoEfectoCoste principal
SetPerformanceProfilenil (auto)las ventanas de calidad y velocidad corren en paralelo; el tráfico sale por varios proveedores a la vez (normalmente 3–8), con afinidad por sitiofijar un WindowType estrecha a una ventana
AllowDirectdesactivadoconserva el salto anonimizador, así que ningún proveedor ve la IP real del usuarioactivado: más rendimiento, y el proveedor ve la IP real del usuario
PostQuantumEncryptionactivadosella la sesión cliente↔proveedor, así que el operador retransmite bytes que no puede leerun proveedor con el que no puede sellar se omite, no se usa sin sellar
SetRouteLocalpermitirel tráfico recurre a la ruta local cuando el túnel se caeno permitir es el kill switch: el tráfico se detiene
SetProvideModenoneel dispositivo no ofrece capacidad a la redcompartir en public o network saca el tráfico de otros por la IP del usuario

Salvedades que acompañan a la tabla:

  • AllowDirect es el ajuste de velocidad, y la IP que expone es exactamente el salto que quita. Las apps lo muestran invertido como "Anonimización fuerte" (Strong Anonymization), activada por defecto, y está forzado a desactivado en los perfiles de dispositivo alojados fije lo que fije quien llama. No lo presentes nunca a tus usuarios como velocidad gratis; preséntalo como el intercambio que es.
  • PostQuantumEncryption es la sesión cliente↔proveedor de extremo a extremo que las apps publican como "Cifrado poscuántico" (Post Quantum Encryption), activada por defecto desde el arranque y lista en los proveedores: toda compilación actual de proveedor habilita el lado que responde. Mientras está activada, el cliente es fail-closed: no lleva datos de aplicación en claro, y omite todo proveedor con el que no puede sellar en lugar de usarlo sin sellar. El coste es de disponibilidad, no de confidencialidad. Desactívala y el tráfico vuelve a poder tomar la ruta estándar.
  • SetRouteLocal es la primitiva del kill switch ("permitir tráfico local"). Desactívala, y dale a los usuarios el conmutador que tiene toda app de URnetwork, para que el tráfico se detenga en lugar de recurrir a la ruta local cuando el túnel se cae.

La ceguera del proveedor ante la identidad de tu usuario es incondicional; la ceguera del operador ante el contenido también, por defecto. Dos propiedades, ambas ciertas en un perfil de fábrica, que es lo que hace exacto decir que ninguna parte por sí sola reúne la identidad de tu usuario y su actividad. Cambia cualquiera de los dos flags y cambias la propiedad correspondiente por otra cosa, no la ganas.

Los tres modos que esos dos flags seleccionan, enunciados para los usuarios que enrutas:

ModoEl operador veEl proveedor veCómo se obtiene
Retransmitido selladoconexión de cuenta/origen, asociación con proveedores, texto cifrado y tiempo/volumentráfico de destino y un id de dispositivo/contrato, no la IP real del usuarioel valor por defecto: PostQuantumEncryption activado, AllowDirect desactivado
Retransmitido estándarconexión de cuenta/origen, asociación con proveedores, destinos interiores y bytes de los paquetestráfico de destino y un id de dispositivo/contrato, no la IP real del usuariosolo si desactivas PostQuantumEncryption
Directomenos intervención en la retransmisiónla IP real del usuario y el tráfico de destinoopcional: AllowDirect activado (forzado a desactivado en los perfiles alojados)

El valor por defecto ya trae la primera fila sin nada que fijar. La segunda exige desactivar PostQuantumEncryption: mientras está activada, el cliente omite todo proveedor con el que no puede sellar en lugar de caer a esa fila. No hay API por conexión que informe en qué fila acabó una sesión dada. El modelo de amenazas trabaja estas filas frente a adversarios con nombre, y es explícito sobre dónde falla cada una.

Reglas de hilos

  • Los objetos que no son de vista (DeviceLocal, DeviceRemote, Api, espacios de red) son seguros en concurrencia. Llámalos desde cualquier hilo.
  • Los view controllers son de un solo hilo salvo que uno documente lo contrario. Maneja cada uno desde un hilo (normalmente tu hilo de UI). El JS del navegador lo satisface gratis; muerde en Android, Apple y cgo, donde los callbacks llegan en hilos gestionados por Go.
  • Los callbacks se disparan en hilos arbitrarios gestionados por el SDK. Reencamínalos a tu hilo de UI antes de tocar la UI, y no bloquees nunca en uno: un DeviceRemote serializa sus callbacks por un único canal con búfer, así que un listener lento aplica contrapresión a todas sus suscripciones.

Tamaños de los artefactos

Planifica los presupuestos de descarga y empaquetado alrededor de estos:

ArtefactoTamaño (comprimido donde se indica)
AAR de Android~37 MB
xcframework de Apple (zip)~116 MB
wasm de JS~43.5 MB
c-shared de Linux (zip)~25 MB
c-shared de Windows (zip)~21 MB

Estabilidad de la API

Las versiones van por fecha (vYYYY.M.D-); no hay contrato SemVer todavía, y el paquete npm es beta y se republica cada noche. Trata las tres superficies como en movimiento hasta un 1.0: fija versiones exactas, lee las notas de cada versión en cada subida y mantén los artefactos emparejados (demonio y UI, extensión y app) en una misma release.