lanmess 1.0.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.
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: lanmess
3
+ Version: 1.0.0
4
+ Summary: LAN-only end-to-end encrypted messenger with a TUI
5
+ License-Expression: MIT
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Environment :: Console
8
+ Classifier: Topic :: Communications :: Chat
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: textual>=0.52.0
13
+ Requires-Dist: cryptography>=41.0.0
14
+ Dynamic: license-file
15
+
16
+ # LANMess
17
+
18
+ LAN-only end-to-end encrypted messenger with a terminal UI.
19
+
20
+ ## Install
21
+
22
+ pip install lanmess
23
+
24
+ ## Run
25
+
26
+ lanmess
@@ -0,0 +1,14 @@
1
+ lanmess-1.0.0.dist-info/licenses/LICENSE,sha256=0SJU1dVe5uk0rKC9g3BKPevyAWTYQxcqVtDVW9ORk5Y,1065
2
+ lanmess_files/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ lanmess_files/config.py,sha256=rN3aq3EeaUH2WNbi714HTR3ta8OpNUDJBDY_y0ABPcc,514
4
+ lanmess_files/crypto.py,sha256=U2r_PqF7aHa7cc0HxQuA9HgazFNfIWfT6r5oIDWIxEk,2912
5
+ lanmess_files/database.py,sha256=o11khcmcq-BrnoL-y9a5dC10wRV8GkEV1rLAosD2vqA,8224
6
+ lanmess_files/discovery.py,sha256=_4fymXCcD__3KzSR-Zdl6EEHhuWJ_er11rI82cW_F84,3188
7
+ lanmess_files/main.py,sha256=kZne8r-Fvi622aL8GKy4ocZ4Aatx4t5W6B2xtF_-s5E,104
8
+ lanmess_files/network.py,sha256=SFPSjjN3Glge3WxqpXbQxrfQNdFos_U7zn114qz7w_U,2116
9
+ lanmess_files/tui.py,sha256=EP93DpzC3Fqy7K3_xOlIZrztBWxw169seBaMCFKgapc,50138
10
+ lanmess-1.0.0.dist-info/METADATA,sha256=3t7nlEaED4oKKB21INycZEVMDECV7a9rOgxr8Wgjk8w,584
11
+ lanmess-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ lanmess-1.0.0.dist-info/entry_points.txt,sha256=A94GkXbd8m2nhKKtGrgCUTutqQRl755gMowFuUtUCkM,52
13
+ lanmess-1.0.0.dist-info/top_level.txt,sha256=sp8e6gQbW9akFqA-aLjUfeZQ98q_WB4LWkSJSpti0Fw,14
14
+ lanmess-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lanmess = lanmess_files.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Your Name
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
+ lanmess_files
File without changes
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+
3
+ APP_NAME = "LANMess"
4
+ APP_VERSION = "1.0.0"
5
+ DISCOVERY_PORT = 50505
6
+ DISCOVERY_INTERVAL = 2.0
7
+ DISCOVERY_TTL = 15.0
8
+ TCP_PORT_RANGE = (50510, 50600)
9
+ LOGIN_CODE_PERIOD = 60
10
+ LOGIN_CODE_DIGITS = 8
11
+ PBKDF2_ITERATIONS = 200_000
12
+ GROUP_SALT = b"lanmess-group-key-v1"
13
+ REQUEST_TIMEOUT = 6.0
14
+ BASE_DIR = Path.home() / ".lanmess"
15
+ DB_PATH = BASE_DIR / "lanmess.db"
16
+
17
+
18
+ def ensure_dirs():
19
+ BASE_DIR.mkdir(parents=True, exist_ok=True)
20
+ try:
21
+ BASE_DIR.chmod(0o700)
22
+ except Exception:
23
+ pass
@@ -0,0 +1,113 @@
1
+ import hashlib
2
+ import hmac
3
+ import os
4
+ import struct
5
+ import time
6
+
7
+ from cryptography.exceptions import InvalidSignature
8
+ from cryptography.hazmat.primitives import serialization
9
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
10
+ Ed25519PrivateKey,
11
+ Ed25519PublicKey,
12
+ )
13
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
14
+
15
+ from .config import (
16
+ GROUP_SALT,
17
+ LOGIN_CODE_DIGITS,
18
+ LOGIN_CODE_PERIOD,
19
+ PBKDF2_ITERATIONS,
20
+ )
21
+
22
+
23
+ def derive_group_key(group_code: str) -> bytes:
24
+ return hashlib.pbkdf2_hmac(
25
+ "sha256",
26
+ group_code.encode("utf-8"),
27
+ GROUP_SALT,
28
+ PBKDF2_ITERATIONS,
29
+ 32,
30
+ )
31
+
32
+
33
+ def group_key_hash(key: bytes) -> str:
34
+ return hashlib.sha256(key).hexdigest()
35
+
36
+
37
+ def generate_ed25519_private() -> Ed25519PrivateKey:
38
+ return Ed25519PrivateKey.generate()
39
+
40
+
41
+ def ed_private_to_bytes(sk: Ed25519PrivateKey) -> bytes:
42
+ return sk.private_bytes(
43
+ encoding=serialization.Encoding.Raw,
44
+ format=serialization.PrivateFormat.Raw,
45
+ encryption_algorithm=serialization.NoEncryption(),
46
+ )
47
+
48
+
49
+ def ed_public_to_bytes(pk: Ed25519PublicKey) -> bytes:
50
+ return pk.public_bytes(
51
+ encoding=serialization.Encoding.Raw,
52
+ format=serialization.PublicFormat.Raw,
53
+ )
54
+
55
+
56
+ def ed_private_from_bytes(raw: bytes) -> Ed25519PrivateKey:
57
+ return Ed25519PrivateKey.from_private_bytes(raw)
58
+
59
+
60
+ def ed_public_from_bytes(raw: bytes) -> Ed25519PublicKey:
61
+ return Ed25519PublicKey.from_public_bytes(raw)
62
+
63
+
64
+ def sign_bytes(sk: Ed25519PrivateKey, data: bytes) -> bytes:
65
+ return sk.sign(data)
66
+
67
+
68
+ def verify_bytes(pk: Ed25519PublicKey, sig: bytes, data: bytes) -> bool:
69
+ try:
70
+ pk.verify(sig, data)
71
+ return True
72
+ except InvalidSignature:
73
+ return False
74
+
75
+
76
+ def encrypt_aes(key: bytes, plaintext: bytes, aad: bytes = b""):
77
+ nonce = os.urandom(12)
78
+ ct = AESGCM(key).encrypt(nonce, plaintext, aad)
79
+ return nonce, ct
80
+
81
+
82
+ def decrypt_aes(key: bytes, nonce: bytes, ct: bytes, aad: bytes = b"") -> bytes:
83
+ return AESGCM(key).decrypt(nonce, ct, aad)
84
+
85
+
86
+ def generate_totp_secret() -> bytes:
87
+ return os.urandom(20)
88
+
89
+
90
+ def compute_totp(secret: bytes, offset: int = 0) -> str:
91
+ counter = int(time.time() // LOGIN_CODE_PERIOD) + offset
92
+ msg = struct.pack(">Q", counter)
93
+ digest = hmac.new(secret, msg, hashlib.sha256).digest()
94
+ off = digest[-1] & 0x0F
95
+ code = (
96
+ struct.unpack(">I", digest[off:off + 4])[0] & 0x7FFFFFFF
97
+ ) % (10 ** LOGIN_CODE_DIGITS)
98
+ return f"{code:0{LOGIN_CODE_DIGITS}d}"
99
+
100
+
101
+ def verify_totp(secret: bytes, code: str) -> bool:
102
+ for offset in (0, -1):
103
+ if hmac.compare_digest(compute_totp(secret, offset), code):
104
+ return True
105
+ return False
106
+
107
+
108
+ def totp_remaining() -> int:
109
+ return LOGIN_CODE_PERIOD - int(time.time() % LOGIN_CODE_PERIOD)
110
+
111
+
112
+ def code_tag(code: str) -> str:
113
+ return hashlib.sha256(code.encode("utf-8")).hexdigest()[:16]
@@ -0,0 +1,237 @@
1
+ import sqlite3
2
+ import threading
3
+ import time
4
+
5
+
6
+ class Database:
7
+ def __init__(self, path):
8
+ self.path = str(path)
9
+ self.lock = threading.RLock()
10
+ self.conn = sqlite3.connect(self.path, check_same_thread=False)
11
+ self.conn.row_factory = sqlite3.Row
12
+ try:
13
+ self.conn.execute("PRAGMA journal_mode=WAL")
14
+ self.conn.execute("PRAGMA synchronous=NORMAL")
15
+ self.conn.execute("PRAGMA secure_delete=ON")
16
+ except Exception:
17
+ pass
18
+ self._init()
19
+
20
+ def _init(self):
21
+ with self.lock:
22
+ self.conn.executescript(
23
+ """
24
+ CREATE TABLE IF NOT EXISTS identity (
25
+ id INTEGER PRIMARY KEY CHECK (id = 1),
26
+ name TEXT NOT NULL,
27
+ group_key_hash TEXT NOT NULL,
28
+ ed_private_hex TEXT NOT NULL,
29
+ ed_public_hex TEXT NOT NULL,
30
+ totp_secret_hex TEXT NOT NULL,
31
+ tcp_port INTEGER NOT NULL
32
+ );
33
+ CREATE TABLE IF NOT EXISTS contacts (
34
+ name TEXT PRIMARY KEY,
35
+ ip TEXT,
36
+ port INTEGER,
37
+ ed_public_hex TEXT NOT NULL,
38
+ chat_started INTEGER NOT NULL DEFAULT 0,
39
+ incoming_request INTEGER NOT NULL DEFAULT 0,
40
+ blocked INTEGER NOT NULL DEFAULT 0,
41
+ pending INTEGER NOT NULL DEFAULT 0,
42
+ clear_pending INTEGER NOT NULL DEFAULT 0,
43
+ added_at REAL NOT NULL
44
+ );
45
+ CREATE TABLE IF NOT EXISTS messages (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ contact_name TEXT NOT NULL,
48
+ direction TEXT NOT NULL,
49
+ content TEXT NOT NULL,
50
+ timestamp REAL NOT NULL
51
+ );
52
+ """
53
+ )
54
+ try:
55
+ self.conn.execute(
56
+ "ALTER TABLE contacts ADD COLUMN pending INTEGER NOT NULL DEFAULT 0"
57
+ )
58
+ except sqlite3.OperationalError:
59
+ pass
60
+ try:
61
+ self.conn.execute(
62
+ "ALTER TABLE contacts ADD COLUMN clear_pending INTEGER NOT NULL DEFAULT 0"
63
+ )
64
+ except sqlite3.OperationalError:
65
+ pass
66
+ self.conn.commit()
67
+
68
+ def get_identity(self):
69
+ with self.lock:
70
+ row = self.conn.execute("SELECT * FROM identity WHERE id = 1").fetchone()
71
+ return dict(row) if row else None
72
+
73
+ def create_identity(
74
+ self,
75
+ name,
76
+ group_key_hash,
77
+ ed_private_hex,
78
+ ed_public_hex,
79
+ totp_secret_hex,
80
+ tcp_port,
81
+ ):
82
+ with self.lock:
83
+ self.conn.execute(
84
+ "INSERT OR REPLACE INTO identity "
85
+ "(id, name, group_key_hash, ed_private_hex, ed_public_hex, "
86
+ "totp_secret_hex, tcp_port) VALUES (1, ?, ?, ?, ?, ?, ?)",
87
+ (
88
+ name,
89
+ group_key_hash,
90
+ ed_private_hex,
91
+ ed_public_hex,
92
+ totp_secret_hex,
93
+ tcp_port,
94
+ ),
95
+ )
96
+ self.conn.commit()
97
+
98
+ def update_tcp_port(self, port):
99
+ with self.lock:
100
+ self.conn.execute("UPDATE identity SET tcp_port = ? WHERE id = 1", (port,))
101
+ self.conn.commit()
102
+
103
+ def get_contacts(self):
104
+ with self.lock:
105
+ rows = self.conn.execute(
106
+ "SELECT * FROM contacts ORDER BY name"
107
+ ).fetchall()
108
+ return [dict(r) for r in rows]
109
+
110
+ def get_contact(self, name):
111
+ with self.lock:
112
+ row = self.conn.execute(
113
+ "SELECT * FROM contacts WHERE name = ?", (name,)
114
+ ).fetchone()
115
+ return dict(row) if row else None
116
+
117
+ def upsert_contact(self, name, ip, port, ed_public_hex):
118
+ with self.lock:
119
+ existing = self.conn.execute(
120
+ "SELECT name FROM contacts WHERE name = ?", (name,)
121
+ ).fetchone()
122
+ if existing:
123
+ self.conn.execute(
124
+ "UPDATE contacts SET ip=?, port=?, ed_public_hex=?, added_at=? "
125
+ "WHERE name=?",
126
+ (ip, port, ed_public_hex, time.time(), name),
127
+ )
128
+ else:
129
+ self.conn.execute(
130
+ "INSERT INTO contacts "
131
+ "(name, ip, port, ed_public_hex, chat_started, "
132
+ "incoming_request, blocked, added_at) "
133
+ "VALUES (?, ?, ?, ?, 0, 0, 0, ?)",
134
+ (name, ip, port, ed_public_hex, time.time()),
135
+ )
136
+ self.conn.commit()
137
+
138
+ def set_contact_state(
139
+ self,
140
+ name,
141
+ chat_started=None,
142
+ incoming_request=None,
143
+ blocked=None,
144
+ pending=None,
145
+ clear_pending=None,
146
+ ip=None,
147
+ port=None,
148
+ ):
149
+ with self.lock:
150
+ fields = []
151
+ values = []
152
+ if chat_started is not None:
153
+ fields.append("chat_started=?")
154
+ values.append(int(chat_started))
155
+ if incoming_request is not None:
156
+ fields.append("incoming_request=?")
157
+ values.append(int(incoming_request))
158
+ if blocked is not None:
159
+ fields.append("blocked=?")
160
+ values.append(int(blocked))
161
+ if pending is not None:
162
+ fields.append("pending=?")
163
+ values.append(int(pending))
164
+ if clear_pending is not None:
165
+ fields.append("clear_pending=?")
166
+ values.append(int(clear_pending))
167
+ if ip is not None:
168
+ fields.append("ip=?")
169
+ values.append(ip)
170
+ if port is not None:
171
+ fields.append("port=?")
172
+ values.append(port)
173
+ if not fields:
174
+ return
175
+ values.append(name)
176
+ self.conn.execute(
177
+ f"UPDATE contacts SET {', '.join(fields)} WHERE name=?", values
178
+ )
179
+ self.conn.commit()
180
+
181
+ def add_message(self, contact_name, direction, content):
182
+ with self.lock:
183
+ self.conn.execute(
184
+ "INSERT INTO messages (contact_name, direction, content, timestamp) "
185
+ "VALUES (?, ?, ?, ?)",
186
+ (contact_name, direction, content, time.time()),
187
+ )
188
+ self.conn.commit()
189
+
190
+ def get_messages(self, contact_name, limit=500):
191
+ with self.lock:
192
+ rows = self.conn.execute(
193
+ "SELECT * FROM messages WHERE contact_name = ? "
194
+ "ORDER BY id DESC LIMIT ?",
195
+ (contact_name, limit),
196
+ ).fetchall()
197
+ return [dict(r) for r in reversed(rows)]
198
+
199
+ def clear_messages(self, contact_name):
200
+ with self.lock:
201
+ self.conn.execute(
202
+ "DELETE FROM messages WHERE contact_name = ?", (contact_name,)
203
+ )
204
+ self.conn.commit()
205
+
206
+ def delete_message(self, message_id):
207
+ with self.lock:
208
+ self.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
209
+ self.conn.commit()
210
+
211
+ def delete_contact(self, contact_name):
212
+ with self.lock:
213
+ self.conn.execute(
214
+ "DELETE FROM contacts WHERE name = ?", (contact_name,)
215
+ )
216
+ self.conn.execute(
217
+ "DELETE FROM messages WHERE contact_name = ?", (contact_name,)
218
+ )
219
+ self.conn.commit()
220
+
221
+ def reset_all(self):
222
+ with self.lock:
223
+ self.conn.execute("DELETE FROM messages")
224
+ self.conn.execute("DELETE FROM contacts")
225
+ self.conn.execute("DELETE FROM identity")
226
+ self.conn.commit()
227
+ try:
228
+ self.conn.execute("VACUUM")
229
+ except Exception:
230
+ pass
231
+
232
+ def close(self):
233
+ with self.lock:
234
+ try:
235
+ self.conn.close()
236
+ except Exception:
237
+ pass
@@ -0,0 +1,107 @@
1
+ import asyncio
2
+ import json
3
+ import socket
4
+ import time
5
+
6
+ from .config import (
7
+ DISCOVERY_INTERVAL,
8
+ DISCOVERY_PORT,
9
+ DISCOVERY_TTL,
10
+ )
11
+
12
+
13
+ class _Protocol(asyncio.DatagramProtocol):
14
+ def __init__(self, on_packet):
15
+ self.on_packet = on_packet
16
+
17
+ def datagram_received(self, data, addr):
18
+ try:
19
+ msg = json.loads(data.decode("utf-8"))
20
+ except Exception:
21
+ return
22
+ if not isinstance(msg, dict):
23
+ return
24
+ if msg.get("app") != "lanmess":
25
+ return
26
+ try:
27
+ self.on_packet(addr[0], msg)
28
+ except Exception:
29
+ pass
30
+
31
+
32
+ class Discovery:
33
+ def __init__(self):
34
+ self.peers = {}
35
+ self.transport = None
36
+
37
+ async def start(self):
38
+ loop = asyncio.get_running_loop()
39
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
40
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
41
+ try:
42
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
43
+ except Exception:
44
+ pass
45
+ try:
46
+ sock.bind(("", DISCOVERY_PORT))
47
+ except Exception:
48
+ sock.bind(("", 0))
49
+ sock.setblocking(False)
50
+ self.transport, _ = await loop.create_datagram_endpoint(
51
+ lambda: _Protocol(self._on_packet), sock=sock
52
+ )
53
+
54
+ def _on_packet(self, ip, msg):
55
+ name = msg.get("name")
56
+ if not isinstance(name, str) or not name:
57
+ return
58
+ tcp_port = msg.get("tcp_port")
59
+ if not isinstance(tcp_port, int) or tcp_port <= 0:
60
+ return
61
+ self.peers[name] = {
62
+ "ip": ip,
63
+ "tcp_port": tcp_port,
64
+ "code_tag": str(msg.get("code_tag", "")),
65
+ "last_seen": time.time(),
66
+ }
67
+
68
+ def get_live_peers(self):
69
+ now = time.time()
70
+ return {
71
+ n: i
72
+ for n, i in self.peers.items()
73
+ if now - i["last_seen"] < DISCOVERY_TTL
74
+ }
75
+
76
+ def find_by_name(self, name):
77
+ return self.get_live_peers().get(name)
78
+
79
+ def find_by_code_tag(self, tag, exclude_name=None):
80
+ for name, info in self.get_live_peers().items():
81
+ if name == exclude_name:
82
+ continue
83
+ if info["code_tag"] == tag:
84
+ return name, info
85
+ return None, None
86
+
87
+ async def broadcaster(self, name, tcp_port, get_code_tag):
88
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
89
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
90
+ sock.setblocking(False)
91
+ while True:
92
+ try:
93
+ tag = get_code_tag()
94
+ payload = json.dumps(
95
+ {
96
+ "app": "lanmess",
97
+ "version": 1,
98
+ "name": name,
99
+ "tcp_port": tcp_port,
100
+ "code_tag": tag,
101
+ }
102
+ ).encode("utf-8")
103
+ sock.sendto(payload, ("255.255.255.255", DISCOVERY_PORT))
104
+ sock.sendto(payload, ("127.0.0.1", DISCOVERY_PORT))
105
+ except Exception:
106
+ pass
107
+ await asyncio.sleep(DISCOVERY_INTERVAL)
lanmess_files/main.py ADDED
@@ -0,0 +1,9 @@
1
+ from .tui import LanMessApp
2
+
3
+
4
+ def main():
5
+ LanMessApp().run()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -0,0 +1,76 @@
1
+ import asyncio
2
+ import json
3
+
4
+ from .config import REQUEST_TIMEOUT
5
+
6
+
7
+ async def send_request(ip, port, payload, timeout=REQUEST_TIMEOUT):
8
+ try:
9
+ reader, writer = await asyncio.wait_for(
10
+ asyncio.open_connection(ip, port), timeout=timeout
11
+ )
12
+ except Exception:
13
+ return None
14
+ try:
15
+ writer.write((json.dumps(payload) + "\n").encode("utf-8"))
16
+ await writer.drain()
17
+ line = await asyncio.wait_for(reader.readline(), timeout=timeout)
18
+ if not line:
19
+ return None
20
+ try:
21
+ return json.loads(line.decode("utf-8"))
22
+ except Exception:
23
+ return None
24
+ except Exception:
25
+ return None
26
+ finally:
27
+ try:
28
+ writer.close()
29
+ await writer.wait_closed()
30
+ except Exception:
31
+ pass
32
+
33
+
34
+ class Server:
35
+ def __init__(self, port, handler):
36
+ self.port = port
37
+ self.handler = handler
38
+ self.server = None
39
+
40
+ async def start(self):
41
+ self.server = await asyncio.start_server(
42
+ self._client, "0.0.0.0", self.port
43
+ )
44
+
45
+ async def _client(self, reader, writer):
46
+ try:
47
+ line = await asyncio.wait_for(reader.readline(), timeout=10.0)
48
+ if not line:
49
+ return
50
+ try:
51
+ msg = json.loads(line.decode("utf-8"))
52
+ except Exception:
53
+ return
54
+ peer = writer.get_extra_info("peername")
55
+ if peer:
56
+ msg["_peer_ip"] = peer[0]
57
+ resp = await self.handler(msg)
58
+ if resp is not None:
59
+ writer.write((json.dumps(resp) + "\n").encode("utf-8"))
60
+ await writer.drain()
61
+ except Exception:
62
+ pass
63
+ finally:
64
+ try:
65
+ writer.close()
66
+ await writer.wait_closed()
67
+ except Exception:
68
+ pass
69
+
70
+ async def stop(self):
71
+ if self.server is not None:
72
+ self.server.close()
73
+ try:
74
+ await self.server.wait_closed()
75
+ except Exception:
76
+ pass