capyy 0.3.0__tar.gz
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.
- capyy-0.3.0/LICENSE +21 -0
- capyy-0.3.0/PKG-INFO +117 -0
- capyy-0.3.0/README.md +95 -0
- capyy-0.3.0/capy/__init__.py +275 -0
- capyy-0.3.0/capy/_tls.py +139 -0
- capyy-0.3.0/capy/network.py +502 -0
- capyy-0.3.0/capy/wifi.py +112 -0
- capyy-0.3.0/capyy.egg-info/PKG-INFO +117 -0
- capyy-0.3.0/capyy.egg-info/SOURCES.txt +12 -0
- capyy-0.3.0/capyy.egg-info/dependency_links.txt +1 -0
- capyy-0.3.0/capyy.egg-info/top_level.txt +1 -0
- capyy-0.3.0/pyproject.toml +32 -0
- capyy-0.3.0/setup.cfg +4 -0
- capyy-0.3.0/tests/test_capy.py +201 -0
capyy-0.3.0/LICENSE
ADDED
|
@@ -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.
|
capyy-0.3.0/PKG-INFO
ADDED
|
@@ -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
|
capyy-0.3.0/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# capyy — tiny encrypted JSON-over-TCP APIs
|
|
2
|
+
|
|
3
|
+
Call a function in another Python program — on the same machine, on your Wi-Fi, or across the internet —
|
|
4
|
+
with one line of code. Zero dependencies, Python 3.9+, encrypted with TLS by default.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install capyy
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The package is published as **capyy**, but you import it as **`capy`**.
|
|
11
|
+
Error messages are in French (the library was written for French speakers), and the full documentation,
|
|
12
|
+
with examples, is at **[libs.zazac.link/capy](https://libs.zazac.link/capy/)**.
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
# server.py
|
|
18
|
+
import capy
|
|
19
|
+
|
|
20
|
+
api = capy.Server(4000)
|
|
21
|
+
|
|
22
|
+
@api.action("hello")
|
|
23
|
+
def hello(name="you"):
|
|
24
|
+
return {"message": f"Hello {name}!"}
|
|
25
|
+
|
|
26
|
+
api.run()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
# client.py
|
|
31
|
+
import capy
|
|
32
|
+
|
|
33
|
+
print(capy.call("localhost", 4000, "hello", name="Alice")) # {'message': 'Hello Alice!'}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- An action returns a `dict`, or JSON written as text with `capy.json('{"text": "Hello"}')`.
|
|
37
|
+
- Errors raised on the server come back as `{"erreur": "..."}` (unknown action, bad parameters, exceptions).
|
|
38
|
+
- `capy.call` raises `ConnectionError` when the server does not answer.
|
|
39
|
+
|
|
40
|
+
## Reading a response
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
reply = {"city": "Paris", "temp": "18.5", "days": [{"name": "monday"}, {"name": "tuesday"}]}
|
|
44
|
+
|
|
45
|
+
capy.get(reply, "days.1.name") # "tuesday" (paths go through dicts and lists)
|
|
46
|
+
capy.get(reply, "wind", default=0) # 0
|
|
47
|
+
capy.to_float(reply, "temp") # 18.5
|
|
48
|
+
capy.to_int("42"), capy.to_bool("yes") # 42, True
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`to_str`, `to_int`, `to_float`, `to_bool`, `to_list` and `to_dict` raise a `ValueError` that says exactly
|
|
52
|
+
what could not be converted.
|
|
53
|
+
|
|
54
|
+
## Encryption (TLS)
|
|
55
|
+
|
|
56
|
+
Everything is encrypted by default, with nothing to configure.
|
|
57
|
+
|
|
58
|
+
- Each machine running a server creates its own certificate on first launch (`~/.capy`, using `openssl`)
|
|
59
|
+
and prints its **fingerprint**.
|
|
60
|
+
- Like SSH, a client **remembers a server's fingerprint** the first time (`~/.capy/known_hosts.json`)
|
|
61
|
+
and raises `capy.SecurityError` if it ever changes.
|
|
62
|
+
- Use `tls=False` on **both** sides to talk in clear (faster on localhost).
|
|
63
|
+
|
|
64
|
+
## `capy.wifi` — find each other on the same network
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import capy.wifi
|
|
68
|
+
|
|
69
|
+
api = capy.wifi.Server("living-room") # announces itself on the LAN, port chosen automatically
|
|
70
|
+
...
|
|
71
|
+
capy.wifi.call("living-room", "light", on=False) # from any device on the same Wi-Fi
|
|
72
|
+
capy.wifi.scan() # {'living-room': ('192.168.1.42', 51234)}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## `capy.network` — across the internet, through a relay
|
|
76
|
+
|
|
77
|
+
A machine behind a home router cannot be reached from the internet, so `capy.network` goes through a
|
|
78
|
+
**relay** running on a machine with a public IP (a VPS). Servers connect to it and register a name;
|
|
79
|
+
clients call that name. Everything is encrypted, and the connection is kept open between calls.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python3 -m capy.network 3879 my-secret-key # on the VPS (open the port in its firewall)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import capy.network
|
|
87
|
+
|
|
88
|
+
capy.network.use("my-vps.example:3879", key="my-secret-key")
|
|
89
|
+
capy.network.Server("home").run() # at home, even behind a router
|
|
90
|
+
capy.network.call("home", "temperature") # from anywhere
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -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")
|
capyy-0.3.0/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 {}
|