capyy 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- capy/__init__.py +275 -0
- capy/_tls.py +139 -0
- capy/network.py +502 -0
- capy/wifi.py +112 -0
- capyy-0.3.0.dist-info/METADATA +117 -0
- capyy-0.3.0.dist-info/RECORD +9 -0
- capyy-0.3.0.dist-info/WHEEL +5 -0
- capyy-0.3.0.dist-info/licenses/LICENSE +21 -0
- capyy-0.3.0.dist-info/top_level.txt +1 -0
capy/__init__.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# capy : mini-API en TCP, une ligne JSON par requête / réponse, chiffrée (TLS)
|
|
2
|
+
|
|
3
|
+
import json as jsonlib # "json" est pris par capy.json()
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from . import _tls
|
|
8
|
+
from ._tls import SecurityError # noqa: F401 (capy.SecurityError)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Serveur : @server.action("nom") pour déclarer une action, server.run() pour lancer
|
|
12
|
+
# Le port se donne à Server(port) ou à run(port), au choix
|
|
13
|
+
# Chiffré (TLS) par défaut ; tls=False pour parler en clair (le client aussi doit mettre tls=False)
|
|
14
|
+
class Server:
|
|
15
|
+
def __init__(self, port=None, tls=True):
|
|
16
|
+
self.port, self.tls = port, tls
|
|
17
|
+
self.actions = {}
|
|
18
|
+
self.listening = False # True une fois le serveur prêt
|
|
19
|
+
self._context = None
|
|
20
|
+
|
|
21
|
+
def action(self, name):
|
|
22
|
+
def decorator(function):
|
|
23
|
+
self.actions[name] = function
|
|
24
|
+
return function
|
|
25
|
+
return decorator
|
|
26
|
+
|
|
27
|
+
# {"action": "get", "id": 2} -> get(id=2)
|
|
28
|
+
def handle(self, line):
|
|
29
|
+
try:
|
|
30
|
+
request = jsonlib.loads(line)
|
|
31
|
+
function = self.actions[request.pop("action")]
|
|
32
|
+
except (ValueError, AttributeError, TypeError): # pas du JSON, ou pas un objet {...}
|
|
33
|
+
return {"erreur": "JSON invalide"}
|
|
34
|
+
except KeyError:
|
|
35
|
+
return {"erreur": "action inconnue"}
|
|
36
|
+
try:
|
|
37
|
+
return function(**request)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
return {"erreur": str(e)}
|
|
40
|
+
|
|
41
|
+
def client(self, conn, addr):
|
|
42
|
+
if self._context:
|
|
43
|
+
conn = _tls.accept(self._context, conn)
|
|
44
|
+
if conn is None: # Le client ne parle pas en chiffré -> on raccroche
|
|
45
|
+
return
|
|
46
|
+
else:
|
|
47
|
+
conn.settimeout(10)
|
|
48
|
+
with conn:
|
|
49
|
+
try:
|
|
50
|
+
line = conn.makefile(encoding="utf-8").readline()
|
|
51
|
+
if not line.strip(): # Connexion ouverte puis fermée sans rien demander
|
|
52
|
+
return
|
|
53
|
+
conn.sendall((jsonlib.dumps(self.handle(line), ensure_ascii=False) + "\n").encode())
|
|
54
|
+
except (OSError, ValueError):
|
|
55
|
+
return
|
|
56
|
+
print(f"{addr[0]} → {line.strip()}")
|
|
57
|
+
|
|
58
|
+
# port 0 -> le système choisit un port libre
|
|
59
|
+
def run(self, port=None):
|
|
60
|
+
self.port = self.port if port is None else port
|
|
61
|
+
if self.port is None:
|
|
62
|
+
raise ValueError("capy: aucun port, utilise Server(port) ou run(port)")
|
|
63
|
+
if self.tls:
|
|
64
|
+
self._context, empreinte = _tls.server_context("server")
|
|
65
|
+
with socket.create_server(("", self.port)) as server:
|
|
66
|
+
self.port = server.getsockname()[1]
|
|
67
|
+
self.listening = True
|
|
68
|
+
if self.tls:
|
|
69
|
+
print(f"API en écoute sur le port {self.port}, chiffrée (TLS) · empreinte : {empreinte}")
|
|
70
|
+
else:
|
|
71
|
+
print(f"API en écoute sur le port {self.port} (pas chiffrée)")
|
|
72
|
+
while True:
|
|
73
|
+
conn, addr = server.accept()
|
|
74
|
+
threading.Thread(target=self.client, args=(conn, addr), daemon=True).start()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Renvoyer du JSON écrit en texte depuis une action
|
|
78
|
+
# Ex : return capy.json('{"text": "Hello"}')
|
|
79
|
+
def json(text):
|
|
80
|
+
try:
|
|
81
|
+
return jsonlib.loads(text)
|
|
82
|
+
except ValueError as e:
|
|
83
|
+
raise ValueError(f"capy.json : JSON invalide ({e})") from None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ============================================================
|
|
87
|
+
# EXTRAIRE UNE VALEUR
|
|
88
|
+
# ============================================================
|
|
89
|
+
|
|
90
|
+
_NOTHING = object() # Pour savoir si default a été donné (None est une vraie valeur)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Extraire une valeur d'un JSON (dict, liste, ou texte JSON) avec un chemin "a.b.0.c"
|
|
94
|
+
# Ex : get('{"text": "Hello"}', "text") -> "Hello"
|
|
95
|
+
# get(reponse, "users.0.name") -> "Izac" (0 = 1er élément d'une liste)
|
|
96
|
+
# get(reponse, "age", default=0) -> 0 si "age" n'existe pas (au lieu d'une erreur)
|
|
97
|
+
def get(data, path, default=_NOTHING):
|
|
98
|
+
value = json(data) if isinstance(data, str) else data
|
|
99
|
+
for key in str(path).split("."):
|
|
100
|
+
if isinstance(value, dict) and key in value:
|
|
101
|
+
value = value[key]
|
|
102
|
+
elif isinstance(value, list) and key.lstrip("-").isdigit() and -len(value) <= int(key) < len(value):
|
|
103
|
+
value = value[int(key)]
|
|
104
|
+
elif default is not _NOTHING:
|
|
105
|
+
return default
|
|
106
|
+
elif isinstance(value, dict):
|
|
107
|
+
raise ValueError(f"capy.get : clé {key!r} introuvable dans {path!r} (clés : {', '.join(map(str, value)) or 'aucune'})")
|
|
108
|
+
elif isinstance(value, list):
|
|
109
|
+
raise ValueError(f"capy.get : {key!r} pas valable dans {path!r} (liste de {len(value)} éléments, 0 à {len(value) - 1})")
|
|
110
|
+
else:
|
|
111
|
+
raise ValueError(f"capy.get : impossible d'aller dans {key!r} de {path!r}, la valeur est un {type(value).__name__}")
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ============================================================
|
|
116
|
+
# CONVERSIONS
|
|
117
|
+
# ============================================================
|
|
118
|
+
# Pour transformer une valeur reçue (réponse de capy.call, paramètre d'une action...)
|
|
119
|
+
# Si ça ne marche pas -> ValueError avec un message clair
|
|
120
|
+
# (dans une action, le client reçoit alors {"erreur": "..."})
|
|
121
|
+
# key (optionnel) : extraire d'abord une valeur, comme get() -> to_int(reponse, "age")
|
|
122
|
+
|
|
123
|
+
# Erreur commune : "capy.to_int : impossible de transformer 'abc' (str) en int"
|
|
124
|
+
def _error(name, data, target):
|
|
125
|
+
text = repr(data)
|
|
126
|
+
if len(text) > 60:
|
|
127
|
+
text = text[:60] + "…"
|
|
128
|
+
return ValueError(f"capy.{name} : impossible de transformer {text} ({type(data).__name__}) en {target}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Du JSON (dict, liste...) -> texte JSON sur une ligne (un texte reste tel quel)
|
|
132
|
+
# Ex : to_str({"text": "Hello"}) -> '{"text": "Hello"}'
|
|
133
|
+
# to_str('{"text": "Hello"}', "text") -> "Hello"
|
|
134
|
+
def to_str(data, key=None):
|
|
135
|
+
if key is not None:
|
|
136
|
+
data = get(data, key)
|
|
137
|
+
if isinstance(data, str):
|
|
138
|
+
return data
|
|
139
|
+
try:
|
|
140
|
+
return jsonlib.dumps(data, ensure_ascii=False)
|
|
141
|
+
except (TypeError, ValueError):
|
|
142
|
+
raise _error("to_str", data, "texte JSON") from None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# -> entier. Accepte 42, 42.0, "42", " 42 "
|
|
146
|
+
# Ex : to_int("42") -> 42 / to_int("abc") -> ValueError
|
|
147
|
+
def to_int(data, key=None):
|
|
148
|
+
if key is not None:
|
|
149
|
+
data = get(data, key)
|
|
150
|
+
if isinstance(data, bool): # True est un int pour Python, mais pas pour nous
|
|
151
|
+
raise _error("to_int", data, "int")
|
|
152
|
+
if isinstance(data, int):
|
|
153
|
+
return data
|
|
154
|
+
if isinstance(data, float) and data.is_integer():
|
|
155
|
+
return int(data)
|
|
156
|
+
if isinstance(data, str):
|
|
157
|
+
try:
|
|
158
|
+
return to_int(float(data)) if "." in data else int(data)
|
|
159
|
+
except ValueError:
|
|
160
|
+
pass
|
|
161
|
+
raise _error("to_int", data, "int")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# -> nombre à virgule. Accepte 4.5, 4, "4.5"
|
|
165
|
+
# Ex : to_float("4.5") -> 4.5
|
|
166
|
+
def to_float(data, key=None):
|
|
167
|
+
if key is not None:
|
|
168
|
+
data = get(data, key)
|
|
169
|
+
if not isinstance(data, bool) and isinstance(data, (int, float, str)):
|
|
170
|
+
try:
|
|
171
|
+
return float(data)
|
|
172
|
+
except ValueError:
|
|
173
|
+
pass
|
|
174
|
+
raise _error("to_float", data, "float")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# -> True / False. Accepte true/false, 1/0, "true"/"false", "oui"/"non", "1"/"0"
|
|
178
|
+
# Ex : to_bool("oui") -> True
|
|
179
|
+
def to_bool(data, key=None):
|
|
180
|
+
if key is not None:
|
|
181
|
+
data = get(data, key)
|
|
182
|
+
if isinstance(data, bool):
|
|
183
|
+
return data
|
|
184
|
+
if data in (0, 1):
|
|
185
|
+
return bool(data)
|
|
186
|
+
if isinstance(data, str):
|
|
187
|
+
text = data.strip().lower()
|
|
188
|
+
if text in ("true", "oui", "yes", "1"):
|
|
189
|
+
return True
|
|
190
|
+
if text in ("false", "non", "no", "0"):
|
|
191
|
+
return False
|
|
192
|
+
raise _error("to_bool", data, "bool")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# -> liste. Accepte une liste, un tuple, ou du texte JSON '[1, 2]'
|
|
196
|
+
# Ex : to_list("[1, 2]") -> [1, 2]
|
|
197
|
+
def to_list(data, key=None):
|
|
198
|
+
if key is not None:
|
|
199
|
+
data = get(data, key)
|
|
200
|
+
if isinstance(data, (list, tuple)):
|
|
201
|
+
return list(data)
|
|
202
|
+
if isinstance(data, str):
|
|
203
|
+
try:
|
|
204
|
+
value = jsonlib.loads(data)
|
|
205
|
+
except ValueError:
|
|
206
|
+
value = None
|
|
207
|
+
if isinstance(value, list):
|
|
208
|
+
return value
|
|
209
|
+
raise _error("to_list", data, "list")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# -> dict. Accepte un dict, ou du texte JSON '{"a": 1}'
|
|
213
|
+
# Ex : to_dict('{"a": 1}') -> {"a": 1}
|
|
214
|
+
def to_dict(data, key=None):
|
|
215
|
+
if key is not None:
|
|
216
|
+
data = get(data, key)
|
|
217
|
+
if isinstance(data, dict):
|
|
218
|
+
return data
|
|
219
|
+
if isinstance(data, str):
|
|
220
|
+
try:
|
|
221
|
+
value = jsonlib.loads(data)
|
|
222
|
+
except ValueError:
|
|
223
|
+
value = None
|
|
224
|
+
if isinstance(value, dict):
|
|
225
|
+
return value
|
|
226
|
+
raise _error("to_dict", data, "dict")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Client : capy.call("127.0.0.1", 4000, "get", id=2) -> dict
|
|
230
|
+
# Lève ConnectionError si le serveur ne répond pas, SecurityError si ce n'est pas le bon serveur
|
|
231
|
+
# tls=False : pour un serveur pas chiffré (Server(..., tls=False))
|
|
232
|
+
def call(host, port, action, /, tls=True, **params):
|
|
233
|
+
return _call(host, port, action, params, tls, label=f"{host}:{port}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# Envoyer une demande et lire la réponse
|
|
237
|
+
# label : sous quel nom retenir l'empreinte du serveur ("ip:port", "wifi:salon"...)
|
|
238
|
+
def _call(host, port, action, params, tls, label, who="capy"):
|
|
239
|
+
if not isinstance(host, str) or isinstance(port, bool) or not isinstance(port, int):
|
|
240
|
+
raise TypeError(
|
|
241
|
+
f"capy.call : l'ordre est call(host, port, action), ex : call(\"localhost\", 4000, \"get\")"
|
|
242
|
+
f" -> reçu call({host!r}, {port!r}, {action!r})"
|
|
243
|
+
)
|
|
244
|
+
if "://" in host: # capy parle en TCP, pas en HTTP
|
|
245
|
+
raise ValueError(f"capy.call : host est un nom ou une IP, pas une URL -> {host.split('://', 1)[1].strip('/')!r}")
|
|
246
|
+
connected = False
|
|
247
|
+
hint = " — il est peut-être chiffré : enlève tls=False" if not tls else ""
|
|
248
|
+
try:
|
|
249
|
+
if tls:
|
|
250
|
+
conn = _tls.connect(host, port, label, timeout=3, who=who)
|
|
251
|
+
else:
|
|
252
|
+
conn = socket.create_connection((host, port), timeout=3)
|
|
253
|
+
connected = True
|
|
254
|
+
with conn:
|
|
255
|
+
conn.sendall((jsonlib.dumps({"action": action, **params}) + "\n").encode())
|
|
256
|
+
line = conn.makefile(encoding="utf-8").readline()
|
|
257
|
+
if not line:
|
|
258
|
+
raise ConnectionError(f"{who} : le serveur {host}:{port} n'a rien répondu{hint}")
|
|
259
|
+
return jsonlib.loads(line)
|
|
260
|
+
except ConnectionError as e:
|
|
261
|
+
if str(e).startswith(who): # Message déjà clair (ex : chiffrement impossible)
|
|
262
|
+
raise
|
|
263
|
+
error = e
|
|
264
|
+
except (OSError, ValueError) as e:
|
|
265
|
+
error = e
|
|
266
|
+
if connected: # Il a décroché puis coupé : souvent un serveur chiffré appelé en clair
|
|
267
|
+
raise ConnectionError(f"{who} : le serveur {host}:{port} a coupé la connexion sans répondre{hint}") from None
|
|
268
|
+
raise ConnectionError(f"{who} : le serveur {host}:{port} ne répond pas ({type(error).__name__})") from None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# Empreinte du certificat des serveurs capy de cette machine (à donner aux clients qui veulent vérifier)
|
|
272
|
+
# Ex : capy.fingerprint() -> "9dfa7b98…"
|
|
273
|
+
def fingerprint():
|
|
274
|
+
_tls.server_context("server") # Crée le certificat s'il n'existe pas encore
|
|
275
|
+
return _tls.my_fingerprint("server")
|
capy/_tls.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# capy._tls : le chiffrement (TLS) commun à capy, capy.wifi et capy.network
|
|
2
|
+
#
|
|
3
|
+
# Chaque machine qui fait tourner un serveur crée son certificat tout seul (dans ~/.capy, avec openssl).
|
|
4
|
+
# Comme SSH, un client retient l'empreinte d'un serveur à la 1re connexion (~/.capy/known_hosts.json) ;
|
|
5
|
+
# si elle change ensuite -> SecurityError (quelqu'un se fait peut-être passer pour le serveur).
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import pathlib as pth
|
|
12
|
+
import socket
|
|
13
|
+
import ssl
|
|
14
|
+
import subprocess
|
|
15
|
+
import threading
|
|
16
|
+
|
|
17
|
+
CAPY_DIR = pth.Path.home() / ".capy"
|
|
18
|
+
KNOWN_FILE = CAPY_DIR / "known_hosts.json"
|
|
19
|
+
LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1")
|
|
20
|
+
_lock = threading.Lock()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# L'empreinte d'un serveur ne correspond pas : on refuse de lui parler
|
|
24
|
+
class SecurityError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Empreinte d'un certificat (64 caractères)
|
|
29
|
+
def fingerprint(der):
|
|
30
|
+
return hashlib.sha256(der).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Certificat de cette machine : créé une fois avec openssl, puis réutilisé
|
|
34
|
+
# kind : "server" (capy, capy.wifi) ou "relay" (capy.network)
|
|
35
|
+
def _certificate(kind):
|
|
36
|
+
cert, key = CAPY_DIR / f"{kind}-cert.pem", CAPY_DIR / f"{kind}-key.pem"
|
|
37
|
+
with _lock:
|
|
38
|
+
if not (cert.exists() and key.exists()):
|
|
39
|
+
CAPY_DIR.mkdir(exist_ok=True)
|
|
40
|
+
try:
|
|
41
|
+
subprocess.run(
|
|
42
|
+
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "3650",
|
|
43
|
+
"-subj", f"/CN=capy-{kind}", "-keyout", str(key), "-out", str(cert)],
|
|
44
|
+
check=True, capture_output=True,
|
|
45
|
+
)
|
|
46
|
+
except (OSError, subprocess.CalledProcessError) as e:
|
|
47
|
+
raise RuntimeError(f"capy : impossible de créer le certificat avec openssl ({e})") from None
|
|
48
|
+
os.chmod(key, 0o600) # Clé privée lisible par toi seul
|
|
49
|
+
return cert, key
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Empreinte du certificat de cette machine (None s'il n'existe pas encore)
|
|
53
|
+
def my_fingerprint(kind="server"):
|
|
54
|
+
cert = CAPY_DIR / f"{kind}-cert.pem"
|
|
55
|
+
try:
|
|
56
|
+
return fingerprint(ssl.PEM_cert_to_DER_cert(cert.read_text()))
|
|
57
|
+
except (OSError, ValueError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Contexte TLS côté serveur -> (contexte, empreinte)
|
|
62
|
+
def server_context(kind="server"):
|
|
63
|
+
cert, key = _certificate(kind)
|
|
64
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
65
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
66
|
+
context.load_cert_chain(cert, key)
|
|
67
|
+
return context, my_fingerprint(kind)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Chiffrer une connexion qui arrive sur un serveur (None si le client ne parle pas en TLS)
|
|
71
|
+
def accept(context, raw, timeout=10):
|
|
72
|
+
try:
|
|
73
|
+
raw.settimeout(timeout)
|
|
74
|
+
conn = context.wrap_socket(raw, server_side=True)
|
|
75
|
+
conn.settimeout(timeout)
|
|
76
|
+
return conn
|
|
77
|
+
except (OSError, ValueError):
|
|
78
|
+
raw.close()
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Se connecter en chiffré, et vérifier que c'est bien le bon serveur (empreinte)
|
|
83
|
+
# label : sous quel nom retenir l'empreinte ("ip:port", "wifi:salon"...)
|
|
84
|
+
# pinned : empreinte attendue (sinon : celle retenue la 1re fois)
|
|
85
|
+
def connect(host, port, label, timeout=10, pinned=None, who="capy", kind="serveur"):
|
|
86
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
87
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
88
|
+
context.check_hostname = False # Certificat fait maison :
|
|
89
|
+
context.verify_mode = ssl.CERT_NONE # on le vérifie nous-mêmes avec l'empreinte juste après
|
|
90
|
+
raw = socket.create_connection((host, port), timeout=timeout)
|
|
91
|
+
try:
|
|
92
|
+
conn = context.wrap_socket(raw)
|
|
93
|
+
except (ssl.SSLError, OSError):
|
|
94
|
+
raw.close()
|
|
95
|
+
raise ConnectionError(
|
|
96
|
+
f"{who} : connexion chiffrée impossible avec {host}:{port}"
|
|
97
|
+
f" — le {kind} n'est peut-être pas chiffré (tls=False des deux côtés pour s'en passer)"
|
|
98
|
+
) from None
|
|
99
|
+
try:
|
|
100
|
+
check(label, fingerprint(conn.getpeercert(binary_form=True)), pinned, who, kind, local=host in LOCAL_HOSTS)
|
|
101
|
+
except SecurityError:
|
|
102
|
+
conn.close()
|
|
103
|
+
raise
|
|
104
|
+
return conn
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# Comparer une empreinte avec celle attendue, ou celle retenue la 1re fois
|
|
108
|
+
def check(label, seen, pinned=None, who="capy", kind="serveur", local=False):
|
|
109
|
+
wanted = (pinned or "").replace(":", "").lower()
|
|
110
|
+
if wanted:
|
|
111
|
+
if not hmac.compare_digest(wanted, seen):
|
|
112
|
+
raise SecurityError(f"{who} : l'empreinte du {kind} {label} n'est pas celle attendue (reçu {seen})")
|
|
113
|
+
return
|
|
114
|
+
if local and seen == my_fingerprint("server"): # Ce Mac lui-même : rien à vérifier
|
|
115
|
+
return
|
|
116
|
+
with _lock:
|
|
117
|
+
known = _known()
|
|
118
|
+
if label not in known:
|
|
119
|
+
CAPY_DIR.mkdir(exist_ok=True)
|
|
120
|
+
known[label] = seen
|
|
121
|
+
KNOWN_FILE.write_text(json.dumps(known, indent=4))
|
|
122
|
+
print(f"{who} : nouveau {kind} {label}, empreinte retenue {seen}", flush=True)
|
|
123
|
+
return
|
|
124
|
+
if not hmac.compare_digest(known[label], seen):
|
|
125
|
+
raise SecurityError(
|
|
126
|
+
f"{who} : l'empreinte du {kind} {label} a CHANGÉ ! Quelqu'un se fait peut-être passer pour lui.\n"
|
|
127
|
+
f" Si tu as réinstallé le {kind} (normal), supprime sa ligne dans {KNOWN_FILE}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Les empreintes retenues (reprend l'ancien fichier known_relays.json s'il existe)
|
|
132
|
+
def _known():
|
|
133
|
+
old = CAPY_DIR / "known_relays.json"
|
|
134
|
+
if not KNOWN_FILE.exists() and old.exists():
|
|
135
|
+
old.rename(KNOWN_FILE)
|
|
136
|
+
try:
|
|
137
|
+
return json.loads(KNOWN_FILE.read_text())
|
|
138
|
+
except (OSError, ValueError):
|
|
139
|
+
return {}
|
capy/network.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
# capy.network : capy à travers tout internet, grâce à un relais, le tout chiffré (TLS)
|
|
2
|
+
# Le relais tourne sur une machine avec une IP publique (un VPS).
|
|
3
|
+
# Les serveurs s'y connectent (même derrière une box) et s'enregistrent sous un nom,
|
|
4
|
+
# les clients appellent un nom : le relais fait passer la demande et la réponse.
|
|
5
|
+
#
|
|
6
|
+
# Sur le VPS : python3 -m capy.network 3879 motdepasse
|
|
7
|
+
# Partout ailleurs :
|
|
8
|
+
# capy.network.use("mon-vps.fr:3879", key="motdepasse")
|
|
9
|
+
# capy.network.Server("maison").run() / capy.network.call("maison", "test")
|
|
10
|
+
#
|
|
11
|
+
# Chiffrement : le relais crée son certificat tout seul (dans ~/.capy).
|
|
12
|
+
# Comme SSH, la 1re connexion retient son empreinte (~/.capy/known_hosts.json) ;
|
|
13
|
+
# si elle change ensuite -> SecurityError (quelqu'un se fait peut-être passer pour le relais).
|
|
14
|
+
|
|
15
|
+
import hmac
|
|
16
|
+
import json as jsonlib
|
|
17
|
+
import queue
|
|
18
|
+
import select
|
|
19
|
+
import socket
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
import capy
|
|
25
|
+
from capy import _tls
|
|
26
|
+
from capy._tls import SecurityError # noqa: F401 (capy.network.SecurityError)
|
|
27
|
+
|
|
28
|
+
PING_EVERY = 25 # Secondes sans rien avant un "je suis toujours là" (sinon la box coupe la connexion)
|
|
29
|
+
_config = {"relay": None, "key": None, "fingerprint": None}
|
|
30
|
+
_print_lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# print() qui ne se mélange pas entre threads
|
|
34
|
+
def _log(text):
|
|
35
|
+
with _print_lock:
|
|
36
|
+
print(text, flush=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Choisir le relais une fois pour toutes (pour Server et call)
|
|
40
|
+
# fingerprint (optionnel) : l'empreinte affichée par le relais au démarrage, pour être sûr dès la 1re fois
|
|
41
|
+
# Ex : use("mon-vps.fr:3879", key="motdepasse")
|
|
42
|
+
def use(relay, key=None, fingerprint=None):
|
|
43
|
+
_config.update(relay=relay, key=key, fingerprint=fingerprint)
|
|
44
|
+
_close_pipes()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# "mon-vps.fr:3879" -> ("mon-vps.fr", 3879)
|
|
48
|
+
def _address(relay):
|
|
49
|
+
relay = relay or _config["relay"]
|
|
50
|
+
if not relay:
|
|
51
|
+
raise ValueError('capy.network : aucun relais, utilise capy.network.use("ip:port")')
|
|
52
|
+
host, _, port = str(relay).rpartition(":")
|
|
53
|
+
if not host or not port.isdigit():
|
|
54
|
+
raise ValueError(f'capy.network : relais invalide {relay!r}, attendu "ip:port" (ex : "mon-vps.fr:3879")')
|
|
55
|
+
return host, int(port)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ============================================================
|
|
59
|
+
# ENVOYER / RECEVOIR
|
|
60
|
+
# ============================================================
|
|
61
|
+
|
|
62
|
+
def _encode(message):
|
|
63
|
+
return (jsonlib.dumps(message, ensure_ascii=False) + "\n").encode()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Envoyer un dict sur une ligne JSON
|
|
67
|
+
def _send(sock, message):
|
|
68
|
+
sock.sendall(_encode(message))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Lire une ligne -> (ligne, ce qui a été reçu après)
|
|
72
|
+
def _recv_line(sock, buffer=b""):
|
|
73
|
+
while b"\n" not in buffer:
|
|
74
|
+
data = sock.recv(65536)
|
|
75
|
+
if not data:
|
|
76
|
+
raise ConnectionError("connexion fermée")
|
|
77
|
+
buffer += data
|
|
78
|
+
line, rest = buffer.split(b"\n", 1)
|
|
79
|
+
return line, rest
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Connexion qui reste ouverte (serveur <-> relais)
|
|
83
|
+
# OpenSSL ne supporte pas 2 threads sur la même connexion chiffrée :
|
|
84
|
+
# un seul thread lit ET écrit, les autres déposent leurs messages dans une file
|
|
85
|
+
class _Channel:
|
|
86
|
+
def __init__(self, sock, on_message, buffer=b""):
|
|
87
|
+
self.sock, self.on_message, self.buffer = sock, on_message, buffer
|
|
88
|
+
self.outbox = queue.Queue()
|
|
89
|
+
self.wake_r, self.wake_w = socket.socketpair() # Pour réveiller le thread quand il y a un message à envoyer
|
|
90
|
+
self.closing = False
|
|
91
|
+
|
|
92
|
+
# Envoyer un message (depuis n'importe quel thread)
|
|
93
|
+
def send(self, message):
|
|
94
|
+
if self.closing:
|
|
95
|
+
raise ConnectionError("connexion fermée")
|
|
96
|
+
self.outbox.put(_encode(message))
|
|
97
|
+
self._wake()
|
|
98
|
+
|
|
99
|
+
# Fermer (depuis n'importe quel thread)
|
|
100
|
+
def close(self):
|
|
101
|
+
self.closing = True
|
|
102
|
+
self._wake()
|
|
103
|
+
|
|
104
|
+
def _wake(self):
|
|
105
|
+
try:
|
|
106
|
+
self.wake_w.send(b"!")
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
# Boucle du thread qui lit et écrit (rend la main quand la connexion se ferme)
|
|
111
|
+
def run(self, on_idle=None):
|
|
112
|
+
try:
|
|
113
|
+
self._lines() # Ce qui était déjà arrivé avec le "bonjour"
|
|
114
|
+
while not self.closing:
|
|
115
|
+
ready = [self.sock] if self.sock.pending() else select.select([self.sock, self.wake_r], [], [], PING_EVERY)[0]
|
|
116
|
+
if not ready and on_idle:
|
|
117
|
+
on_idle()
|
|
118
|
+
if self.wake_r in ready:
|
|
119
|
+
self.wake_r.recv(4096)
|
|
120
|
+
while not self.outbox.empty():
|
|
121
|
+
self.sock.sendall(self.outbox.get_nowait())
|
|
122
|
+
if self.sock in ready:
|
|
123
|
+
data = self.sock.recv(65536)
|
|
124
|
+
if not data:
|
|
125
|
+
break
|
|
126
|
+
self.buffer += data
|
|
127
|
+
self._lines()
|
|
128
|
+
except (OSError, ValueError):
|
|
129
|
+
pass
|
|
130
|
+
finally:
|
|
131
|
+
self.closing = True
|
|
132
|
+
for s in (self.sock, self.wake_r, self.wake_w):
|
|
133
|
+
s.close()
|
|
134
|
+
|
|
135
|
+
def _lines(self):
|
|
136
|
+
while b"\n" in self.buffer:
|
|
137
|
+
line, self.buffer = self.buffer.split(b"\n", 1)
|
|
138
|
+
try:
|
|
139
|
+
self.on_message(jsonlib.loads(line))
|
|
140
|
+
except (ValueError, AttributeError):
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ============================================================
|
|
145
|
+
# CHIFFREMENT (code commun dans capy/_tls.py)
|
|
146
|
+
# ============================================================
|
|
147
|
+
|
|
148
|
+
# Certificat du relais (le sien, différent de celui des serveurs capy de la même machine)
|
|
149
|
+
def _relay_context():
|
|
150
|
+
return _tls.server_context("relay")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Se connecter au relais en chiffré, et vérifier que c'est bien lui (empreinte)
|
|
154
|
+
def _connect(host, port, timeout=10):
|
|
155
|
+
return _tls.connect(host, port, f"{host}:{port}", timeout, pinned=_config["fingerprint"], who="capy.network", kind="relais")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ============================================================
|
|
159
|
+
# RELAIS (sur le VPS)
|
|
160
|
+
# ============================================================
|
|
161
|
+
|
|
162
|
+
# Un serveur connecté au relais
|
|
163
|
+
class _Link:
|
|
164
|
+
def __init__(self, name):
|
|
165
|
+
self.name = name
|
|
166
|
+
self.channel = None
|
|
167
|
+
self.lock = threading.Lock()
|
|
168
|
+
self.waiting = {} # id de la demande -> [Event, réponse]
|
|
169
|
+
self.next_id = 0
|
|
170
|
+
|
|
171
|
+
# Faire passer une demande au serveur et attendre sa réponse
|
|
172
|
+
def ask(self, request, timeout):
|
|
173
|
+
done = threading.Event()
|
|
174
|
+
with self.lock:
|
|
175
|
+
self.next_id += 1
|
|
176
|
+
ask_id = self.next_id
|
|
177
|
+
self.waiting[ask_id] = [done, None]
|
|
178
|
+
self.channel.send({"id": ask_id, "request": request})
|
|
179
|
+
if not done.wait(timeout):
|
|
180
|
+
self.waiting.pop(ask_id, None)
|
|
181
|
+
raise TimeoutError(f"{self.name!r} n'a pas répondu en {timeout} s")
|
|
182
|
+
return self.waiting.pop(ask_id)[1]
|
|
183
|
+
|
|
184
|
+
# Une réponse du serveur arrive
|
|
185
|
+
def receive(self, message):
|
|
186
|
+
slot = self.waiting.get(message.get("id"))
|
|
187
|
+
if slot:
|
|
188
|
+
slot[1] = message.get("response")
|
|
189
|
+
slot[0].set()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Relais : à lancer sur une machine joignable depuis internet
|
|
193
|
+
# Ex : capy.network.Relay(3879, key="motdepasse").run()
|
|
194
|
+
class Relay:
|
|
195
|
+
def __init__(self, port, key=None):
|
|
196
|
+
self.port, self.key = port, key
|
|
197
|
+
self.links = {} # nom -> _Link
|
|
198
|
+
|
|
199
|
+
def _key_ok(self, key):
|
|
200
|
+
return self.key is None or hmac.compare_digest(str(key or ""), self.key)
|
|
201
|
+
|
|
202
|
+
def _client(self, raw, addr):
|
|
203
|
+
try:
|
|
204
|
+
raw.settimeout(10)
|
|
205
|
+
conn = self.context.wrap_socket(raw, server_side=True)
|
|
206
|
+
line, rest = _recv_line(conn)
|
|
207
|
+
hello = jsonlib.loads(line)
|
|
208
|
+
kind = hello.get("capy")
|
|
209
|
+
except (OSError, ValueError, AttributeError):
|
|
210
|
+
raw.close()
|
|
211
|
+
return
|
|
212
|
+
with conn:
|
|
213
|
+
if kind == "call": # La clé est vérifiée à chaque demande
|
|
214
|
+
self._calls(hello, conn, rest)
|
|
215
|
+
elif not self._key_ok(hello.get("key")):
|
|
216
|
+
_send(conn, {"capy": "erreur", "message": "capy.network : mauvaise clé pour ce relais"})
|
|
217
|
+
elif kind == "register":
|
|
218
|
+
self._register(hello.get("name"), conn, rest, addr)
|
|
219
|
+
|
|
220
|
+
# Un serveur s'enregistre : on garde sa connexion ouverte
|
|
221
|
+
def _register(self, name, conn, rest, addr):
|
|
222
|
+
if not isinstance(name, str) or not name:
|
|
223
|
+
_send(conn, {"capy": "erreur", "message": "capy.network : nom invalide"})
|
|
224
|
+
return
|
|
225
|
+
old = self.links.get(name)
|
|
226
|
+
if old and self._alive(old): # Nom pris par un serveur qui répond encore -> refusé
|
|
227
|
+
_send(conn, {"capy": "erreur", "message": f"capy.network : nom déjà utilisé, un autre serveur est connecté au relais sous le nom {name!r}"})
|
|
228
|
+
_log(f"! {name} refusé ({addr[0]}) : nom déjà utilisé")
|
|
229
|
+
return
|
|
230
|
+
_send(conn, {"capy": "ok"})
|
|
231
|
+
conn.settimeout(None)
|
|
232
|
+
link = _Link(name)
|
|
233
|
+
link.channel = _Channel(conn, link.receive, rest)
|
|
234
|
+
self.links[name] = link # L'ancien ne répondait plus (connexion morte) -> le nouveau le remplace
|
|
235
|
+
if old:
|
|
236
|
+
old.channel.close()
|
|
237
|
+
_log(f"+ {name} connecté ({addr[0]})")
|
|
238
|
+
link.channel.run()
|
|
239
|
+
if self.links.get(name) is link:
|
|
240
|
+
del self.links[name]
|
|
241
|
+
for slot in list(link.waiting.values()): # Demandes en cours -> erreur
|
|
242
|
+
slot[1] = {"erreur": f"capy.network : {name!r} s'est déconnecté"}
|
|
243
|
+
slot[0].set()
|
|
244
|
+
_log(f"- {name} déconnecté")
|
|
245
|
+
|
|
246
|
+
# Le serveur connecté sous ce nom répond-il encore ? (3 s max, sinon sa connexion est morte)
|
|
247
|
+
def _alive(self, link):
|
|
248
|
+
try:
|
|
249
|
+
link.ask({"action": "__capy_ping__"}, timeout=3)
|
|
250
|
+
except (TimeoutError, OSError):
|
|
251
|
+
return False
|
|
252
|
+
return not link.channel.closing
|
|
253
|
+
|
|
254
|
+
# Un client appelle des serveurs par leur nom
|
|
255
|
+
# La connexion reste ouverte : il peut envoyer d'autres demandes (en parallèle, chacune avec son id)
|
|
256
|
+
def _calls(self, hello, conn, rest):
|
|
257
|
+
conn.settimeout(None)
|
|
258
|
+
idle = []
|
|
259
|
+
|
|
260
|
+
def on_message(message):
|
|
261
|
+
idle.clear()
|
|
262
|
+
self._call_async(channel, message)
|
|
263
|
+
|
|
264
|
+
def on_idle(): # Client parti sans prévenir (plus de "je suis toujours là") -> on ferme
|
|
265
|
+
idle.append(1)
|
|
266
|
+
if len(idle) >= 3:
|
|
267
|
+
channel.close()
|
|
268
|
+
|
|
269
|
+
channel = _Channel(conn, on_message, rest)
|
|
270
|
+
self._call_async(channel, hello)
|
|
271
|
+
channel.run(on_idle=on_idle)
|
|
272
|
+
|
|
273
|
+
# Chaque demande dans un thread à part (pour ne pas bloquer les autres)
|
|
274
|
+
def _call_async(self, channel, message):
|
|
275
|
+
if message.get("capy") == "call":
|
|
276
|
+
threading.Thread(target=self._call, args=(channel, message), daemon=True).start()
|
|
277
|
+
|
|
278
|
+
# Faire passer une demande au serveur, et renvoyer sa réponse au client (avec l'id de la demande)
|
|
279
|
+
def _call(self, channel, message):
|
|
280
|
+
name, request = message.get("to"), message.get("request")
|
|
281
|
+
reply = {"capy": "erreur", "id": message.get("id")}
|
|
282
|
+
link = self.links.get(name)
|
|
283
|
+
if not self._key_ok(message.get("key")):
|
|
284
|
+
reply["message"] = "capy.network : mauvaise clé pour ce relais"
|
|
285
|
+
elif link is None:
|
|
286
|
+
connected = ", ".join(self.links) or "aucun"
|
|
287
|
+
reply["message"] = f"capy.network : aucun serveur {name!r} sur le relais (connectés : {connected})"
|
|
288
|
+
else:
|
|
289
|
+
try:
|
|
290
|
+
reply.update(capy="ok", response=link.ask(request, timeout=10))
|
|
291
|
+
except (TimeoutError, OSError) as e:
|
|
292
|
+
reply["message"] = f"capy.network : {e}"
|
|
293
|
+
try:
|
|
294
|
+
channel.send(reply)
|
|
295
|
+
except ConnectionError:
|
|
296
|
+
return
|
|
297
|
+
if reply["capy"] == "ok":
|
|
298
|
+
_log(f" {name} ← {jsonlib.dumps(request, ensure_ascii=False)}")
|
|
299
|
+
|
|
300
|
+
def run(self):
|
|
301
|
+
self.context, fingerprint = _relay_context()
|
|
302
|
+
with socket.create_server(("", self.port)) as server:
|
|
303
|
+
_log(f"Relais capy.network en écoute sur le port {self.port}, chiffré (TLS)"
|
|
304
|
+
+ (", avec clé" if self.key else ", SANS clé : ouvert à tous"))
|
|
305
|
+
_log(f"Empreinte : {fingerprint}")
|
|
306
|
+
try:
|
|
307
|
+
while True:
|
|
308
|
+
conn, addr = server.accept()
|
|
309
|
+
threading.Thread(target=self._client, args=(conn, addr), daemon=True).start()
|
|
310
|
+
except KeyboardInterrupt:
|
|
311
|
+
_log("\nRelais arrêté")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ============================================================
|
|
315
|
+
# SERVEUR (n'importe où, même derrière une box)
|
|
316
|
+
# ============================================================
|
|
317
|
+
|
|
318
|
+
# Serveur joignable depuis tout internet sous un nom, via le relais
|
|
319
|
+
# Ex : api = capy.network.Server("maison") puis api.run()
|
|
320
|
+
class Server(capy.Server):
|
|
321
|
+
def __init__(self, name, relay=None, key=None):
|
|
322
|
+
super().__init__()
|
|
323
|
+
self.name, self.relay, self.key = name, relay, key
|
|
324
|
+
|
|
325
|
+
# Se connecter au relais et répondre aux demandes (se reconnecte tout seul si ça coupe)
|
|
326
|
+
def run(self):
|
|
327
|
+
host, port = _address(self.relay)
|
|
328
|
+
key = self.key if self.key is not None else _config["key"]
|
|
329
|
+
while True:
|
|
330
|
+
try:
|
|
331
|
+
conn = _connect(host, port)
|
|
332
|
+
_send(conn, {"capy": "register", "name": self.name, "key": key})
|
|
333
|
+
line, rest = _recv_line(conn)
|
|
334
|
+
hello = jsonlib.loads(line)
|
|
335
|
+
if hello.get("capy") != "ok":
|
|
336
|
+
conn.close()
|
|
337
|
+
raise PermissionError(hello.get("message", "capy.network : le relais a refusé la connexion"))
|
|
338
|
+
conn.settimeout(None)
|
|
339
|
+
_log(f"Connecté au relais {host}:{port} sous le nom {self.name!r} (chiffré)")
|
|
340
|
+
channel = _Channel(conn, lambda message: self._request(channel, message), rest)
|
|
341
|
+
channel.run(on_idle=lambda: channel.send({"ping": True}))
|
|
342
|
+
_log("Connexion au relais perdue, reconnexion dans 3 s")
|
|
343
|
+
except PermissionError:
|
|
344
|
+
raise
|
|
345
|
+
except (OSError, ValueError) as e:
|
|
346
|
+
_log(f"Relais {host}:{port} injoignable ({e}), nouvel essai dans 3 s")
|
|
347
|
+
except KeyboardInterrupt:
|
|
348
|
+
_log("\nServeur arrêté")
|
|
349
|
+
return
|
|
350
|
+
time.sleep(3)
|
|
351
|
+
|
|
352
|
+
# Une demande arrive : on y répond dans un thread à part (pour ne pas bloquer les autres)
|
|
353
|
+
def _request(self, channel, message):
|
|
354
|
+
if (message.get("request") or {}).get("action") == "__capy_ping__": # Le relais vérifie qu'on est vivant
|
|
355
|
+
channel.send({"id": message.get("id"), "response": {"pong": True}})
|
|
356
|
+
elif "request" in message:
|
|
357
|
+
threading.Thread(target=self._answer, args=(channel, message), daemon=True).start()
|
|
358
|
+
|
|
359
|
+
def _answer(self, channel, message):
|
|
360
|
+
response = self.handle(jsonlib.dumps(message["request"]))
|
|
361
|
+
try:
|
|
362
|
+
jsonlib.dumps(response)
|
|
363
|
+
except (TypeError, ValueError) as e:
|
|
364
|
+
response = {"erreur": f"réponse pas en JSON : {e}"}
|
|
365
|
+
try:
|
|
366
|
+
channel.send({"id": message.get("id"), "response": response})
|
|
367
|
+
except ConnectionError:
|
|
368
|
+
return
|
|
369
|
+
_log(f"réseau → {jsonlib.dumps(message['request'], ensure_ascii=False)}")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ============================================================
|
|
373
|
+
# CLIENT (n'importe où)
|
|
374
|
+
# ============================================================
|
|
375
|
+
|
|
376
|
+
_pipes = {} # (host, port) -> _Pipe : une connexion gardée ouverte par relais
|
|
377
|
+
_pipes_lock = threading.Lock()
|
|
378
|
+
_old_relays = set() # Relais d'une ancienne version : une seule demande par connexion
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# "le relais ne répond pas", sauf si l'erreur a déjà un message clair (ex : TLS impossible)
|
|
382
|
+
def _unreachable(host, port, error):
|
|
383
|
+
if isinstance(error, ConnectionError) and str(error).startswith("capy.network"):
|
|
384
|
+
return error
|
|
385
|
+
return ConnectionError(f"capy.network : le relais {host}:{port} ne répond pas")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# Connexion client <-> relais gardée ouverte entre les appels (évite TCP + TLS à chaque fois)
|
|
389
|
+
# Chaque demande porte un id : plusieurs appels en parallèle passent par la même connexion
|
|
390
|
+
class _Pipe:
|
|
391
|
+
def __init__(self, host, port):
|
|
392
|
+
conn = _connect(host, port, timeout=12)
|
|
393
|
+
conn.settimeout(None)
|
|
394
|
+
self.address = (host, port)
|
|
395
|
+
self.lock = threading.Lock()
|
|
396
|
+
self.waiting = {} # id de la demande -> [Event, réponse]
|
|
397
|
+
self.next_id = 0
|
|
398
|
+
self.closed = False
|
|
399
|
+
self.channel = _Channel(conn, self._receive)
|
|
400
|
+
threading.Thread(target=self._run, daemon=True).start()
|
|
401
|
+
|
|
402
|
+
def _run(self):
|
|
403
|
+
self.channel.run(on_idle=lambda: self.channel.send({"ping": True}))
|
|
404
|
+
with self.lock:
|
|
405
|
+
self.closed = True
|
|
406
|
+
slots = list(self.waiting.values())
|
|
407
|
+
for slot in slots: # Demandes en cours -> réponse None (connexion perdue)
|
|
408
|
+
slot[0].set()
|
|
409
|
+
|
|
410
|
+
# Envoyer une demande et attendre la réponse du relais (None si la connexion a coupé avant)
|
|
411
|
+
def ask(self, message, timeout):
|
|
412
|
+
done = threading.Event()
|
|
413
|
+
with self.lock:
|
|
414
|
+
if self.closed:
|
|
415
|
+
return None
|
|
416
|
+
self.next_id += 1
|
|
417
|
+
ask_id = self.next_id
|
|
418
|
+
self.waiting[ask_id] = [done, None]
|
|
419
|
+
try: # Sous le verrou : les demandes partent dans l'ordre de leurs id
|
|
420
|
+
self.channel.send({**message, "id": ask_id})
|
|
421
|
+
except ConnectionError:
|
|
422
|
+
pass # _run va libérer la demande
|
|
423
|
+
answered = done.wait(timeout)
|
|
424
|
+
with self.lock:
|
|
425
|
+
slot = self.waiting.pop(ask_id, [done, None])
|
|
426
|
+
if not answered:
|
|
427
|
+
self.channel.close() # Le relais ne répond plus : la prochaine fois on se reconnecte
|
|
428
|
+
raise _unreachable(*self.address, None)
|
|
429
|
+
return slot[1]
|
|
430
|
+
|
|
431
|
+
# Une réponse du relais arrive
|
|
432
|
+
def _receive(self, message):
|
|
433
|
+
with self.lock:
|
|
434
|
+
if "id" in message:
|
|
435
|
+
slot = self.waiting.get(message["id"])
|
|
436
|
+
else: # Ancien relais : il répond sans id, à la 1re demande seulement, puis ferme
|
|
437
|
+
_old_relays.add(self.address)
|
|
438
|
+
slot = self.waiting.get(min(self.waiting, default=None))
|
|
439
|
+
if slot:
|
|
440
|
+
slot[1] = message
|
|
441
|
+
slot[0].set()
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# La connexion gardée ouverte vers ce relais (on la crée si besoin)
|
|
445
|
+
def _pipe(host, port):
|
|
446
|
+
with _pipes_lock:
|
|
447
|
+
pipe = _pipes.get((host, port))
|
|
448
|
+
if pipe is None or pipe.channel.closing:
|
|
449
|
+
try:
|
|
450
|
+
pipe = _pipes[(host, port)] = _Pipe(host, port)
|
|
451
|
+
except (OSError, ValueError) as e:
|
|
452
|
+
raise _unreachable(host, port, e) from None
|
|
453
|
+
return pipe
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# Fermer les connexions gardées ouvertes (use() a changé de relais, de clé ou d'empreinte)
|
|
457
|
+
def _close_pipes():
|
|
458
|
+
with _pipes_lock:
|
|
459
|
+
for pipe in _pipes.values():
|
|
460
|
+
pipe.channel.close()
|
|
461
|
+
_pipes.clear()
|
|
462
|
+
_old_relays.clear()
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# Une demande sur une connexion neuve, fermée juste après (pour les anciens relais)
|
|
466
|
+
def _call_once(host, port, message):
|
|
467
|
+
try:
|
|
468
|
+
with _connect(host, port, timeout=12) as conn:
|
|
469
|
+
_send(conn, message)
|
|
470
|
+
line, _ = _recv_line(conn)
|
|
471
|
+
return jsonlib.loads(line)
|
|
472
|
+
except (OSError, ValueError) as e:
|
|
473
|
+
raise _unreachable(host, port, e) from None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# Appeler une action d'un serveur par son nom, via le relais (comme capy.call)
|
|
477
|
+
# La connexion chiffrée reste ouverte : seul le 1er appel paie la connexion TLS
|
|
478
|
+
# Ex : capy.network.call("maison", "test") -> {"text": "Hello"}
|
|
479
|
+
def call(name, action, /, **params):
|
|
480
|
+
host, port = _address(None)
|
|
481
|
+
message = {"capy": "call", "to": name, "key": _config["key"], "request": {"action": action, **params}}
|
|
482
|
+
reply = None
|
|
483
|
+
for _ in range(2): # Connexion morte (relais relancé) -> on se reconnecte une fois
|
|
484
|
+
if (host, port) in _old_relays:
|
|
485
|
+
reply = _call_once(host, port, message)
|
|
486
|
+
else:
|
|
487
|
+
reply = _pipe(host, port).ask(message, timeout=15)
|
|
488
|
+
if reply is not None:
|
|
489
|
+
break
|
|
490
|
+
if reply is None:
|
|
491
|
+
raise _unreachable(host, port, None)
|
|
492
|
+
if reply.get("capy") != "ok":
|
|
493
|
+
raise ConnectionError(reply.get("message", "capy.network : erreur du relais"))
|
|
494
|
+
return reply["response"]
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# Lancer un relais depuis le terminal : python3 -m capy.network 3879 motdepasse
|
|
498
|
+
if __name__ == "__main__":
|
|
499
|
+
if len(sys.argv) < 2 or not sys.argv[1].isdigit():
|
|
500
|
+
print("Usage : python3 -m capy.network PORT [CLÉ]")
|
|
501
|
+
sys.exit(1)
|
|
502
|
+
Relay(int(sys.argv[1]), key=sys.argv[2] if len(sys.argv) > 2 else None).run()
|
capy/wifi.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# capy.wifi : capy entre appareils du même wifi, sans connaître les IP
|
|
2
|
+
# Le serveur s'annonce sous un nom, le client l'appelle par ce nom.
|
|
3
|
+
# Chiffré (TLS) comme capy : l'empreinte de chaque serveur est retenue sous "wifi:<nom>".
|
|
4
|
+
|
|
5
|
+
import json as jsonlib
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import capy
|
|
11
|
+
|
|
12
|
+
DISCOVERY_PORT = 38790 # Port UDP où les serveurs répondent "je suis là"
|
|
13
|
+
_found = {} # Serveurs déjà trouvés : nom -> (ip, port)
|
|
14
|
+
_secure = {} # Serveurs trouvés : nom -> chiffré ou pas
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Serveur visible sur le wifi sous un nom (port choisi tout seul si on n'en donne pas)
|
|
18
|
+
# Ex : api = capy.wifi.Server("maison") puis api.run()
|
|
19
|
+
class Server(capy.Server):
|
|
20
|
+
def __init__(self, name, port=0, tls=True):
|
|
21
|
+
super().__init__(port, tls)
|
|
22
|
+
self.name = name
|
|
23
|
+
|
|
24
|
+
# Lancer le serveur, et répondre aux scans du wifi pour être trouvé
|
|
25
|
+
def run(self, port=None):
|
|
26
|
+
threading.Thread(target=self._announce, daemon=True).start()
|
|
27
|
+
print(f"Visible sur le wifi sous le nom {self.name!r} ({_local_ip()})")
|
|
28
|
+
super().run(port)
|
|
29
|
+
|
|
30
|
+
# Répondre "je suis là" aux scans des clients
|
|
31
|
+
def _announce(self):
|
|
32
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
33
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
34
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) # Plusieurs serveurs sur le même appareil
|
|
35
|
+
s.bind(("", DISCOVERY_PORT))
|
|
36
|
+
while True:
|
|
37
|
+
data, addr = s.recvfrom(1024)
|
|
38
|
+
if data == b"capy?" and self.listening:
|
|
39
|
+
s.sendto(jsonlib.dumps({"name": self.name, "port": self.port, "tls": self.tls}).encode(), addr)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Tous les serveurs capy.wifi du réseau
|
|
43
|
+
# Ex : scan() -> {"maison": ("192.168.1.42", 51234), "salon": ("192.168.1.50", 3879)}
|
|
44
|
+
def scan(timeout=1, _wanted=None):
|
|
45
|
+
found, me = {}, _local_ip()
|
|
46
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
47
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
48
|
+
end = time.monotonic() + timeout
|
|
49
|
+
next_ask = 0
|
|
50
|
+
while time.monotonic() < end:
|
|
51
|
+
# On redemande 3 fois : en wifi, un message UDP peut se perdre
|
|
52
|
+
if time.monotonic() >= next_ask:
|
|
53
|
+
for target in ("255.255.255.255", "127.0.0.1"): # Tout le wifi + cet appareil (même sans wifi)
|
|
54
|
+
try:
|
|
55
|
+
s.sendto(b"capy?", (target, DISCOVERY_PORT))
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
next_ask = time.monotonic() + timeout / 3
|
|
59
|
+
s.settimeout(max(min(end, next_ask) - time.monotonic(), 0.001))
|
|
60
|
+
try:
|
|
61
|
+
data, (ip, _) = s.recvfrom(1024)
|
|
62
|
+
info = jsonlib.loads(data)
|
|
63
|
+
name, port = info["name"], int(info["port"])
|
|
64
|
+
except socket.timeout:
|
|
65
|
+
continue
|
|
66
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
67
|
+
continue
|
|
68
|
+
# Serveur sur cet appareil -> 127.0.0.1 (20x plus rapide que passer par l'IP du wifi)
|
|
69
|
+
found.setdefault(name, ("127.0.0.1" if ip == me else ip, port))
|
|
70
|
+
_secure.setdefault(name, info.get("tls") is True)
|
|
71
|
+
if name == _wanted: # find() : on a ce qu'on cherche, inutile d'attendre
|
|
72
|
+
break
|
|
73
|
+
_found.update(found)
|
|
74
|
+
return found
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Trouver un serveur par son nom -> (ip, port)
|
|
78
|
+
# Ex : find("maison") -> ("192.168.1.42", 51234)
|
|
79
|
+
def find(name, timeout=1):
|
|
80
|
+
if name not in _found:
|
|
81
|
+
found = scan(timeout, _wanted=name)
|
|
82
|
+
if name not in found:
|
|
83
|
+
raise ConnectionError(f"capy.wifi : aucun serveur {name!r} sur le wifi (trouvés : {', '.join(found) or 'aucun'})")
|
|
84
|
+
return _found[name]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Appeler une action d'un serveur par son nom (comme capy.call, sans IP ni port)
|
|
88
|
+
# tls=False : pour un serveur pas chiffré (Server(..., tls=False))
|
|
89
|
+
# Ex : capy.wifi.call("maison", "test") -> {"text": "Hello"}
|
|
90
|
+
def call(name, action, /, tls=True, **params):
|
|
91
|
+
for attempt in range(2):
|
|
92
|
+
host, port = find(name)
|
|
93
|
+
if _secure.get(name, True) != tls:
|
|
94
|
+
state = "n'est pas chiffré : mets tls=False" if tls else "est chiffré : enlève tls=False"
|
|
95
|
+
raise ConnectionError(f"capy.wifi : le serveur {name!r} {state} dans capy.wifi.call()")
|
|
96
|
+
try:
|
|
97
|
+
return capy._call(host, port, action, params, tls, label=f"wifi:{name}", who="capy.wifi")
|
|
98
|
+
except ConnectionError as e:
|
|
99
|
+
if attempt or "chiffr" in str(e):
|
|
100
|
+
raise
|
|
101
|
+
_found.pop(name, None) # Serveur relancé (autre port, autre IP) -> on le recherche une fois
|
|
102
|
+
_secure.pop(name, None)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# IP de cet appareil sur le wifi (ex : "192.168.1.42")
|
|
106
|
+
def _local_ip():
|
|
107
|
+
try:
|
|
108
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
109
|
+
s.connect(("1.1.1.1", 80)) # Rien n'est envoyé, ça sert juste à savoir quelle IP on utilise
|
|
110
|
+
return s.getsockname()[0]
|
|
111
|
+
except OSError:
|
|
112
|
+
return "127.0.0.1"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: capyy
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Tiny encrypted JSON-over-TCP APIs: call a function in another program, on your Wi-Fi or across the internet
|
|
5
|
+
Author: Izac
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://libs.zazac.link/capy/
|
|
8
|
+
Project-URL: Documentation, https://libs.zazac.link/capy/
|
|
9
|
+
Keywords: rpc,api,tcp,json,tls,lan,discovery,relay,nat
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Natural Language :: French
|
|
15
|
+
Classifier: Topic :: Internet
|
|
16
|
+
Classifier: Topic :: System :: Networking
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# capyy — tiny encrypted JSON-over-TCP APIs
|
|
24
|
+
|
|
25
|
+
Call a function in another Python program — on the same machine, on your Wi-Fi, or across the internet —
|
|
26
|
+
with one line of code. Zero dependencies, Python 3.9+, encrypted with TLS by default.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install capyy
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The package is published as **capyy**, but you import it as **`capy`**.
|
|
33
|
+
Error messages are in French (the library was written for French speakers), and the full documentation,
|
|
34
|
+
with examples, is at **[libs.zazac.link/capy](https://libs.zazac.link/capy/)**.
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# server.py
|
|
40
|
+
import capy
|
|
41
|
+
|
|
42
|
+
api = capy.Server(4000)
|
|
43
|
+
|
|
44
|
+
@api.action("hello")
|
|
45
|
+
def hello(name="you"):
|
|
46
|
+
return {"message": f"Hello {name}!"}
|
|
47
|
+
|
|
48
|
+
api.run()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
# client.py
|
|
53
|
+
import capy
|
|
54
|
+
|
|
55
|
+
print(capy.call("localhost", 4000, "hello", name="Alice")) # {'message': 'Hello Alice!'}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- An action returns a `dict`, or JSON written as text with `capy.json('{"text": "Hello"}')`.
|
|
59
|
+
- Errors raised on the server come back as `{"erreur": "..."}` (unknown action, bad parameters, exceptions).
|
|
60
|
+
- `capy.call` raises `ConnectionError` when the server does not answer.
|
|
61
|
+
|
|
62
|
+
## Reading a response
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
reply = {"city": "Paris", "temp": "18.5", "days": [{"name": "monday"}, {"name": "tuesday"}]}
|
|
66
|
+
|
|
67
|
+
capy.get(reply, "days.1.name") # "tuesday" (paths go through dicts and lists)
|
|
68
|
+
capy.get(reply, "wind", default=0) # 0
|
|
69
|
+
capy.to_float(reply, "temp") # 18.5
|
|
70
|
+
capy.to_int("42"), capy.to_bool("yes") # 42, True
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`to_str`, `to_int`, `to_float`, `to_bool`, `to_list` and `to_dict` raise a `ValueError` that says exactly
|
|
74
|
+
what could not be converted.
|
|
75
|
+
|
|
76
|
+
## Encryption (TLS)
|
|
77
|
+
|
|
78
|
+
Everything is encrypted by default, with nothing to configure.
|
|
79
|
+
|
|
80
|
+
- Each machine running a server creates its own certificate on first launch (`~/.capy`, using `openssl`)
|
|
81
|
+
and prints its **fingerprint**.
|
|
82
|
+
- Like SSH, a client **remembers a server's fingerprint** the first time (`~/.capy/known_hosts.json`)
|
|
83
|
+
and raises `capy.SecurityError` if it ever changes.
|
|
84
|
+
- Use `tls=False` on **both** sides to talk in clear (faster on localhost).
|
|
85
|
+
|
|
86
|
+
## `capy.wifi` — find each other on the same network
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import capy.wifi
|
|
90
|
+
|
|
91
|
+
api = capy.wifi.Server("living-room") # announces itself on the LAN, port chosen automatically
|
|
92
|
+
...
|
|
93
|
+
capy.wifi.call("living-room", "light", on=False) # from any device on the same Wi-Fi
|
|
94
|
+
capy.wifi.scan() # {'living-room': ('192.168.1.42', 51234)}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## `capy.network` — across the internet, through a relay
|
|
98
|
+
|
|
99
|
+
A machine behind a home router cannot be reached from the internet, so `capy.network` goes through a
|
|
100
|
+
**relay** running on a machine with a public IP (a VPS). Servers connect to it and register a name;
|
|
101
|
+
clients call that name. Everything is encrypted, and the connection is kept open between calls.
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python3 -m capy.network 3879 my-secret-key # on the VPS (open the port in its firewall)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
import capy.network
|
|
109
|
+
|
|
110
|
+
capy.network.use("my-vps.example:3879", key="my-secret-key")
|
|
111
|
+
capy.network.Server("home").run() # at home, even behind a router
|
|
112
|
+
capy.network.call("home", "temperature") # from anywhere
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
capy/__init__.py,sha256=_8GyTkQ-1v0sZNuDMxOsIFczgdr6lB1V9V8CnWMZ-Z4,10964
|
|
2
|
+
capy/_tls.py,sha256=LGLxUSUkQ2GkVIgxu0j0KHl_9eC8CsaDG_0Pmpt6W4U,5427
|
|
3
|
+
capy/network.py,sha256=llT4BiZ3s2TWkYly47mn4kDklLh2Z9zkzPUFNOsuaRY,20364
|
|
4
|
+
capy/wifi.py,sha256=5xVv69F02M47j1dsE9CFA5VMq5UXebJ8Y-fWzaahGHc,5006
|
|
5
|
+
capyy-0.3.0.dist-info/licenses/LICENSE,sha256=tGjTKp9sFPwxUZXQBjbeBRrFZtWmGiPz6RXCfVV9NIg,1061
|
|
6
|
+
capyy-0.3.0.dist-info/METADATA,sha256=anKV38ur_jGG9LA-3ivdmOWjWFEqkK_eoCAyVxC4EOk,4004
|
|
7
|
+
capyy-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
capyy-0.3.0.dist-info/top_level.txt,sha256=ORaAIIZzMv8hrF-iSAV250cVyIpfuaDS8JPYnJw8BU4,5
|
|
9
|
+
capyy-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Izac
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
capy
|