""" enlace.py — Sincronizar dos Caudal: el de un servidor y otro que se enlaza con él (el del USB). Mezcla, no sustituye: lo que se haya apuntado en cada lado acaba en los dos. Quién es quién. El SERVIDOR es el Caudal que atiende en la red y pide contraseña. El otro se ENLAZA una vez (hace falta la contraseña del servidor, que no se guarda: se cambia por una credencial propia de ese equipo, que se puede retirar) y a partir de ahí sincroniza cuando se le pide. Cómo se sincroniza, en un solo viaje: 1. El enlazado envía TODO lo suyo: los registros (también los borrados, que son marcas) y los ajustes. 2. El servidor lo comprueba entero antes de tocar nada, lo mezcla con lo suyo y responde con el resultado, que es lo que queda en los dos. 3. El enlazado comprueba la respuesta y la pone en lugar de lo suyo. 4. Después se intercambian los justificantes que le falten a cada uno. Para que mezclar no pueda romper nada: · Nada se pierde: la mezcla es la unión de las dos copias; si un registro está en las dos con contenido distinto gana el más reciente, y un borrado es una marca, no una ausencia (datos.fusionar). · Dos inversiones distintas con el mismo identificador no se pisan: a la que llega se le da otro y sus operaciones la siguen (datos.choques_de_activos). · La misma línea de extracto apuntada en los dos sitios no queda duplicada (datos.unir_duplicadas). · Antes de aplicar, cada lado guarda una copia local de lo que tenía. · Si las dos copias son de versiones distintas del formato, no se mezclan. · Se puede simular: dice qué cambiaría en cada lado y no cambia nada. Si las dos copias no tienen nada en común, se avisa: serían dos carteras juntas. · El enlazado no aplica una respuesta en la que falte algo de lo que envió. Si el viaje se corta a medias no pasa nada: repetirlo lleva al mismo sitio. """ from __future__ import annotations import base64 import datetime as dt import http.client import json import socket import ssl import tempfile from pathlib import Path from urllib.parse import urlsplit from . import adjuntos, almacen from .datos import (FICHEROS, VERSION, Datos, ErrorDatos, choques_de_activos, clave, escribir, fusionar, huella_datos, renombrar_activos, unir_duplicadas) from .sincronizar import _canonico, elegir_ajustes, escribir_ajuste, leer_ajustes AJUSTES = ("bitcoin.json", "extractos.json") # los que viajan; el índice de la copia en git no es de aquí FICHERO = "enlace.json" TIEMPO = 120 TIEMPO_CONECTAR = 8 # si en este rato no hay camino, se prueba el siguiente TIEMPO_TOR = 60 # por Tor, abrir el camino tarda class ErrorEnlace(Exception): """Lo que se le dice al usuario tal cual.""" class SinCamino(ErrorEnlace): """No se llega al servidor por esa dirección (quizá sí por la otra).""" def es_onion(url: str) -> bool: return (urlsplit(url).hostname or "").endswith(".onion") class _Respuesta: def __init__(self, estado: int, cuerpo: bytes): self.status_code, self._cuerpo = estado, cuerpo def json(self): return json.loads(self._cuerpo) class _SesionTor: """Lo justo de «requests.Session» para llegar a una dirección .onion por el Tor de este equipo, sin instalar nada más (el SOCKS5 es el de electrum.py).""" def __init__(self, proxy: tuple[str, int], certificado: str = ""): self.proxy, self.certificado = proxy, certificado self.cookies: dict[str, str] = {} def post(self, url: str, json=None, headers=None, timeout=TIEMPO): from .electrum import ErrorElectrum, _socks5 import json as _json u = urlsplit(url) puerto = u.port or (443 if u.scheme == "https" else 80) try: sock = _socks5(u.hostname, puerto, self.proxy[0], self.proxy[1], TIEMPO_TOR) except ErrorElectrum as e: raise SinCamino(f"No llego al servidor por Tor: {e}.") from None try: if u.scheme == "https": # Por .onion es Tor quien garantiza con quién se habla; si además se ha dado el # certificado raíz del servidor, se comprueba también la firma (no el nombre) contexto = ssl.create_default_context(cafile=self.certificado) if self.certificado \ else ssl._create_unverified_context() contexto.check_hostname = False try: # El nombre va en el saludo (SNI) aunque no se compruebe: un servidor que reparte # varios servicios por el mismo puerto, como StartOS, lo necesita para saber cuál es sock = contexto.wrap_socket(sock, server_hostname=u.hostname) except ssl.SSLError: raise ErrorEnlace("El certificado que presenta el servidor por Tor no es de la autoridad que indicaste.") from None sock.settimeout(timeout if isinstance(timeout, (int, float)) else TIEMPO) conexion = http.client.HTTPConnection(u.hostname, puerto) conexion.sock = sock cabeceras = {"Host": u.netloc, "Content-Type": "application/json", "Connection": "close", **(headers or {})} if self.cookies: cabeceras["Cookie"] = "; ".join(f"{k}={v}" for k, v in self.cookies.items()) conexion.request("POST", u.path or "/", _json.dumps(json or {}).encode(), cabeceras) r = conexion.getresponse() for galleta in r.headers.get_all("Set-Cookie") or []: nombre, _, valor = galleta.split(";")[0].partition("=") self.cookies[nombre.strip()] = valor.strip() return _Respuesta(r.status, r.read()) except (OSError, http.client.HTTPException) as e: raise SinCamino(f"Se ha cortado la conexión por Tor ({e}).") from None finally: sock.close() NO_ENTIENDO = "No entiendo esa petición." def _ahora() -> str: return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") # ── lo que viaja ── def empaquetar(datos: Datos, ajustes: dict) -> dict: return {"version": VERSION, "datos": {n: getattr(datos, n) for n in FICHEROS}, "ajustes": {n: ajustes[n] for n in AJUSTES if isinstance(ajustes.get(n), dict)}} def desempaquetar(paquete: dict) -> tuple[Datos, dict]: """(datos, ajustes) de lo recibido, comprobado entero: si algo no vale, no vale nada.""" if not isinstance(paquete, dict) or not isinstance(paquete.get("datos"), dict): raise ErrorEnlace("Lo recibido no es una copia de Caudal.") if paquete.get("version") != VERSION: raise ErrorEnlace("Los dos Caudal no son de la misma versión: actualiza el más antiguo y vuelve a sincronizar.") tablas = {} for nombre in FICHEROS: registros = paquete["datos"].get(nombre, []) if not isinstance(registros, list) or not all(isinstance(r, dict) for r in registros): raise ErrorEnlace("Lo recibido no es una copia de Caudal.") claves = [clave(nombre, r) for r in registros if (r.get("id") or r.get("activo"))] if len(claves) != len(registros) or len(set(claves)) != len(claves): raise ErrorEnlace("Lo recibido trae registros repetidos o sin identificar: no se mezcla.") tablas[nombre] = registros datos = Datos(**tablas) with tempfile.TemporaryDirectory() as tmp: # la misma comprobación que al guardar try: escribir(Path(tmp), datos) except ErrorDatos as e: raise ErrorEnlace(f"Lo recibido tiene un dato que no vale ({e}): no se mezcla.") from None ajustes = paquete.get("ajustes") if isinstance(paquete.get("ajustes"), dict) else {} return datos, {n: ajustes[n] for n in AJUSTES if isinstance(ajustes.get(n), dict)} def _ajustes_renombrados(ajustes: dict, mapa: dict[str, str]) -> dict: """Los ajustes que nombran inversiones (monederos, cuentas recordadas), con los identificadores nuevos.""" if not mapa: return ajustes salida = json.loads(json.dumps(ajustes)) for m in (salida.get("bitcoin.json") or {}).get("monederos") or []: if m.get("activo") in mapa: m["activo"] = mapa[m["activo"]] ex = salida.get("extractos.json") or {} for campo in ("cuenta_intereses", "destinos_exchange"): if isinstance(ex.get(campo), dict): ex[campo] = {k: mapa.get(v, v) for k, v in ex[campo].items()} return salida def mezclar(mios: Datos, mis_ajustes: dict, suyos: Datos, sus_ajustes: dict, prefijos: dict) -> dict: """La mezcla de dos copias, sin tocar ninguna. Devuelve los datos y ajustes que deben quedar en las dos y el recuento de lo que cambia en cada lado.""" mapa = choques_de_activos(mios, suyos, prefijos) suyos = renombrar_activos(suyos, mapa) sus_ajustes = _ajustes_renombrados(sus_ajustes, mapa) en_comun = sum(len({clave(n, r) for r in getattr(mios, n)} & {clave(n, r) for r in getattr(suyos, n)}) for n in ("activos", "movimientos", "recurrentes")) fusionado, recuento = fusionar(mios, suyos) fusionado = json.loads(json.dumps({n: getattr(fusionado, n) for n in FICHEROS})) # copia: no tocar los originales fusionado = Datos(**fusionado) retiradas = unir_duplicadas(fusionado, _ahora()) locales = {n: mis_ajustes.get(n) for n in AJUSTES if mis_ajustes.get(n) is not None} ajustes = {n: v for n, v in elegir_ajustes(locales, sus_ajustes).items() if n in AJUSTES} vivos = lambda d: len(d.vivos("activos")) + len(d.vivos("movimientos")) # noqa: E731 return { "datos": fusionado, "ajustes": ajustes, "resumen": { # «aquí» es quien mezcla (el servidor); «alli», quien ha enviado lo suyo "llegan_aqui": recuento["de_remoto"], "cambian_aqui": recuento["actualizados_desde_remoto"], "llegan_alli": recuento["de_local"], "cambian_alli": recuento["actualizados_desde_local"], "ajustes_aqui": [n for n in AJUSTES if _canonico(ajustes.get(n)) != _canonico(mis_ajustes.get(n))], "ajustes_alli": [n for n in AJUSTES if _canonico(ajustes.get(n)) != _canonico(sus_ajustes.get(n))], "renombradas": [{"de": k, "a": v, "nombre": next((a.get("nombre", "") for a in suyos.activos if a["id"] == v), "")} for k, v in mapa.items()], "duplicadas": len(retiradas), "en_comun": en_comun, "carteras_distintas": en_comun == 0 and vivos(mios) > 0 and vivos(suyos) > 0, }, } def justificantes(carpeta: Path, datos: Datos) -> tuple[list[str], list[str]]: """(los que hay en este equipo, los que nombran los datos y faltan aquí).""" nombrados = sorted(adjuntos.referenciados(datos.movimientos)) tengo = [h for h in nombrados if adjuntos.tiene(carpeta, h)] return tengo, [h for h in nombrados if h not in tengo] # ── el lado que se enlaza (el del USB) ── class Enlace: """La dirección del servidor y la credencial de este equipo, junto a los datos.""" def __init__(self, carpeta_datos: Path): self.ruta = Path(carpeta_datos).resolve().parent / FICHERO def config(self) -> dict: try: return json.loads(almacen.leer_texto(self.ruta)) except (OSError, ValueError): return {} def guardar(self, config: dict) -> None: almacen.escribir(self.ruta, json.dumps(config, ensure_ascii=False, indent=1).encode()) def olvidar(self) -> None: self.ruta.unlink(missing_ok=True) def _proxy(self) -> tuple[str, int]: """El Tor de este equipo: el mismo que se usa para llegar al nodo de bitcoin.""" from . import btc return btc.proxy_de(btc.cargar(self.ruta.parent / "datos")) def _sesion(self, url: str): c = self.config() if es_onion(url): return _SesionTor(self._proxy(), c.get("certificado") or "") import requests s = requests.Session() s.verify = c.get("certificado") or True return s def caminos(self) -> list[str]: """Las direcciones del servidor, en el orden en que se prueban: la de casa y, si la hay, la .onion para cuando no se está en casa.""" c = self.config() return [u.rstrip("/") for u in (c.get("url"), c.get("url_tor")) if u] def _pedir(self, sesion, url: str, cuerpo: dict, cabeceras: dict): import requests try: r = sesion.post(url, json=cuerpo, headers=cabeceras, timeout=(TIEMPO_CONECTAR, TIEMPO)) except requests.exceptions.SSLError: raise ErrorEnlace("No puedo comprobar el certificado del servidor. Si tu servidor usa su propia autoridad " "(como StartOS), descarga su certificado raíz e indica aquí dónde está.") from None except requests.exceptions.RequestException: raise SinCamino("No llego al servidor: comprueba la dirección y que haya red hasta él.") from None try: datos = r.json() except ValueError: raise ErrorEnlace("Esa dirección contesta, pero no parece un Caudal.") from None if r.status_code != 200: raise ErrorEnlace(datos.get("error") or f"El servidor ha contestado con un error ({r.status_code}).") return datos def enlazar(self, url: str, clave_servidor: str, certificado: str = "", nombre: str = "") -> dict: """Entra una vez con la contraseña del servidor y la cambia por una credencial de este equipo.""" url = (url or "").strip().rstrip("/") if not url.startswith(("http://", "https://")): raise ErrorEnlace("La dirección tiene que empezar por https:// (o http:// si es dentro de tu casa o una .onion).") if certificado and not Path(certificado).is_file(): raise ErrorEnlace("No encuentro ese fichero de certificado.") self.guardar({"url": url, "certificado": certificado or ""}) try: sesion, base = self._sesion(url), url entrada = self._pedir(sesion, f"{base}/api/acceso/entrar", {"clave": clave_servidor}, {"X-Caudal": "1"}) alta = self._pedir(sesion, f"{base}/api/enlace/alta", {"nombre": nombre or "Otro Caudal"}, {"X-Token": entrada["token"]}) self._pedir(sesion, f"{base}/api/acceso/salir", {}, {"X-Caudal": "1"}) except ErrorEnlace: self.olvidar() raise self.guardar({"url": url, "certificado": certificado or "", "credencial": alta["resultado"]["credencial"], "enlazado": _ahora()}) return {"url": url} def llamar(self, orden: str, cuerpo: dict) -> dict: c = self.config() if not c.get("credencial"): raise ErrorEnlace("Este Caudal no está enlazado con ningún servidor.") return self._por_algun_camino(self.caminos(), orden, cuerpo, c["credencial"]) def _por_algun_camino(self, caminos: list[str], orden: str, cuerpo: dict, credencial: str) -> dict: fallo = None for base in caminos: try: return self._pedir(self._sesion(base), f"{base}/api/enlace", {"orden": orden, **cuerpo}, {"X-Enlace": credencial}) except SinCamino as e: fallo = e if len(caminos) > 1: raise SinCamino(f"No llego al servidor ni por la dirección de casa ni por la .onion. {fallo}") raise fallo or ErrorEnlace("Este Caudal no está enlazado con ningún servidor.") def poner_onion(self, url_tor: str) -> dict: """Añade (o quita, con «») la dirección .onion del servidor, para sincronizar fuera de casa. Antes de guardarla comprueba que por ella se llega al mismo servidor y que acepta la credencial de este equipo.""" c = self.config() if not c.get("credencial"): raise ErrorEnlace("Este Caudal no está enlazado con ningún servidor.") url_tor = (url_tor or "").strip().rstrip("/") if not url_tor: self.guardar({k: v for k, v in c.items() if k != "url_tor"}) return {"url_tor": ""} if "://" not in url_tor: url_tor = "http://" + url_tor if not url_tor.startswith(("http://", "https://")) or not es_onion(url_tor): raise ErrorEnlace("Tiene que ser una dirección .onion (la que da tu servidor para Caudal).") try: self._por_algun_camino([url_tor], "hola", {}, c["credencial"]) except SinCamino: raise except ErrorEnlace as e: # Un Caudal anterior a esta orden contesta que no la entiende: vale igual, # porque para contestar eso ya ha dado por buena la credencial if str(e) != NO_ENTIENDO: raise self.guardar({**c, "url_tor": url_tor}) return {"url_tor": url_tor} def sincronizar(app, simular: bool) -> dict: """Desde el Caudal enlazado: envía lo suyo, recibe la mezcla y la aplica (salvo que sea un simulacro).""" enlace = Enlace(app.carpeta) carpeta = app.cartera.carpeta with app.cartera._lock: mios = app.cartera.datos mis_ajustes = leer_ajustes(carpeta.parent) tengo, _ = justificantes(carpeta, mios) respuesta = enlace.llamar("simular" if simular else "mezclar", {**empaquetar(mios, mis_ajustes), "justificantes": tengo}) resumen = respuesta["resumen"] if simular: return {"simulacro": True, "resumen": resumen} fusionado, ajustes = desempaquetar(respuesta) # Lo que vuelve tiene que contener todo lo que se envió (salvo lo que el servidor renombró) renombradas = {x["de"] for x in resumen["renombradas"]} for nombre in FICHEROS: vuelven = {clave(nombre, r) for r in getattr(fusionado, nombre)} faltan = [k for k in (clave(nombre, r) for r in getattr(mios, nombre)) if k not in vuelven and k.split("|")[0] not in renombradas] if nombre in ("movimientos", "recurrentes") and faltan: raise ErrorEnlace("El servidor ha devuelto menos de lo que le envié: no aplico nada. Vuelve a intentarlo.") cambia = huella_datos(fusionado) != huella_datos(mios) cambian_ajustes = [n for n in AJUSTES if _canonico(ajustes.get(n)) != _canonico(mis_ajustes.get(n))] if cambia or cambian_ajustes: app._copia_datos() # lo que había, a la carpeta de copias locales if cambia: app.cartera.reemplazar(fusionado) for nombre in cambian_ajustes: escribir_ajuste(carpeta.parent / nombre, ajustes[nombre]) # Los justificantes, después y de uno en uno: si alguno falla, el siguiente sincronizar lo reintenta subidos = bajados = 0 for h in respuesta.get("le_faltan") or []: if adjuntos.tiene(carpeta, h): enlace.llamar("subir", {"contenido": base64.b64encode(adjuntos.leer(carpeta, h)).decode()}) subidos += 1 for h in justificantes(carpeta, app.cartera.datos)[1]: if h in (respuesta.get("tiene") or []): contenido = base64.b64decode(enlace.llamar("bajar", {"huella": h})["contenido"]) if adjuntos.guardar(carpeta, contenido) == h: bajados += 1 config = enlace.config() enlace.guardar({**config, "ultima": _ahora()}) return {"simulacro": False, "resumen": resumen, "justificantes": {"subidos": subidos, "bajados": bajados}} # ── el lado del servidor ── def atender(app, orden: str, cuerpo: dict, prefijos: dict) -> dict: """Lo que pide un Caudal enlazado. Quien llama ya ha comprobado su credencial.""" carpeta = app.cartera.carpeta if orden == "subir": try: return {"huella": adjuntos.guardar(carpeta, base64.b64decode(str(cuerpo.get("contenido") or ""), validate=True))} except (ValueError, adjuntos.ErrorAdjunto) as e: raise ErrorEnlace(f"Ese justificante no se puede guardar: {e}") from None if orden == "bajar": try: return {"contenido": base64.b64encode(adjuntos.leer(carpeta, str(cuerpo.get("huella") or ""))).decode()} except adjuntos.ErrorAdjunto as e: raise ErrorEnlace(str(e)) from None if orden == "hola": # para probar un camino sin mover nada return {"hola": True} if orden not in ("simular", "mezclar"): raise ErrorEnlace(NO_ENTIENDO) suyos, sus_ajustes = desempaquetar(cuerpo) with app.cartera._lock: mios = app.cartera.datos mis_ajustes = leer_ajustes(carpeta.parent) m = mezclar(mios, mis_ajustes, suyos, sus_ajustes, prefijos) if orden == "simular": return {"resumen": m["resumen"]} r = m["resumen"] # Se compara el resultado entero con lo que había, no los recuentos: una inversión puede «ganar» # y aun así salir distinta de la mezcla (con los lugares que se añadieron en el otro lado) cambia = huella_datos(m["datos"]) != huella_datos(mios) if cambia or r["ajustes_aqui"]: app._copia_datos() if cambia: app.cartera.reemplazar(m["datos"]) for nombre in r["ajustes_aqui"]: escribir_ajuste(carpeta.parent / nombre, m["ajustes"][nombre]) tengo, me_faltan = justificantes(carpeta, m["datos"]) return {**empaquetar(m["datos"], m["ajustes"]), "resumen": r, "tiene": tengo, "le_faltan": [h for h in me_faltan if h in (cuerpo.get("justificantes") or [])]}