lantalk 2.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.
- lantalk/__init__.py +2 -0
- lantalk/cli.py +54 -0
- lantalk/client.py +269 -0
- lantalk/protocol.py +72 -0
- lantalk/server.py +522 -0
- lantalk-2.0.0.dist-info/METADATA +300 -0
- lantalk-2.0.0.dist-info/RECORD +11 -0
- lantalk-2.0.0.dist-info/WHEEL +5 -0
- lantalk-2.0.0.dist-info/entry_points.txt +2 -0
- lantalk-2.0.0.dist-info/licenses/LICENCE +21 -0
- lantalk-2.0.0.dist-info/top_level.txt +1 -0
lantalk/__init__.py
ADDED
lantalk/cli.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
lantalk.cli
|
|
3
|
+
-----------
|
|
4
|
+
Entry point for the `lantalk` command.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from lantalk.server import run_server, DEFAULT_PORT as SERVER_PORT
|
|
9
|
+
from lantalk.client import run_client, DEFAULT_PORT as CLIENT_PORT
|
|
10
|
+
|
|
11
|
+
VERSION = "2.0.0"
|
|
12
|
+
|
|
13
|
+
BANNER = r"""
|
|
14
|
+
_ _____ _ _
|
|
15
|
+
| | |_ _| | | |
|
|
16
|
+
| | __ _ _ __ | | __ _| | | __
|
|
17
|
+
| | / _` | '_ \ | |/ _` | | |/ /
|
|
18
|
+
| |___| (_| | | | || | (_| | | <
|
|
19
|
+
|______\__,_|_| |_\_/ \__,_|_|_|\_\
|
|
20
|
+
|
|
21
|
+
LAN chat — instant, private, no internet needed.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _color(text: str, code: str) -> str:
|
|
26
|
+
return f"\033[{code}m{text}\033[0m"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> None:
|
|
30
|
+
print(_color(BANNER, "1;35"))
|
|
31
|
+
print(_color(f" v{VERSION} • pure asyncio • zero runtime dependencies\n", "35"))
|
|
32
|
+
|
|
33
|
+
print(" [1] Start a Server")
|
|
34
|
+
print(" [2] Join as Client")
|
|
35
|
+
print()
|
|
36
|
+
|
|
37
|
+
while True:
|
|
38
|
+
choice = input("Choose (1 or 2): ").strip()
|
|
39
|
+
if choice in ("1", "2"):
|
|
40
|
+
break
|
|
41
|
+
print(_color("Please enter 1 or 2.", "31"))
|
|
42
|
+
|
|
43
|
+
print()
|
|
44
|
+
|
|
45
|
+
if choice == "1":
|
|
46
|
+
port_in = input(f"Port (default {SERVER_PORT}): ").strip()
|
|
47
|
+
port = int(port_in) if port_in.isdigit() else SERVER_PORT
|
|
48
|
+
run_server(port=port)
|
|
49
|
+
else:
|
|
50
|
+
run_client(default_port=CLIENT_PORT)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
main()
|
lantalk/client.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
lantalk.client
|
|
3
|
+
--------------
|
|
4
|
+
Async LAN chat client. Features:
|
|
5
|
+
- UDP auto-discovery (lists servers on LAN)
|
|
6
|
+
- JSON protocol with validation
|
|
7
|
+
- Password support
|
|
8
|
+
- /pm, /users, /quit commands
|
|
9
|
+
- Polished prompt: [username | lantalk] >
|
|
10
|
+
- Connection + read timeouts
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import socket
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
DEFAULT_PORT = 5050
|
|
21
|
+
DISCOVERY_PORT = 5051
|
|
22
|
+
ENCODING = "utf-8"
|
|
23
|
+
DISCOVERY_TIMEOUT = 3.0
|
|
24
|
+
CONNECT_TIMEOUT = 5.0
|
|
25
|
+
AUTH_TIMEOUT = 10.0
|
|
26
|
+
VERSION = "2.0.0"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Helpers ────────────────────────────────────────────────────────────────── #
|
|
30
|
+
|
|
31
|
+
def _ts() -> str:
|
|
32
|
+
return datetime.now().strftime("%H:%M")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _color(text: str, code: str) -> str:
|
|
36
|
+
return f"\033[{code}m{text}\033[0m"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _make_msg(**kwargs) -> str:
|
|
40
|
+
return json.dumps(kwargs) + "\n"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── UDP Discovery ──────────────────────────────────────────────────────────── #
|
|
44
|
+
|
|
45
|
+
def discover_servers(timeout: float = DISCOVERY_TIMEOUT) -> list[tuple[str, int]]:
|
|
46
|
+
"""Listen for UDP beacons and return list of (ip, port) tuples."""
|
|
47
|
+
found: dict[str, int] = {}
|
|
48
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
49
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
50
|
+
sock.settimeout(timeout)
|
|
51
|
+
try:
|
|
52
|
+
sock.bind(("", DISCOVERY_PORT))
|
|
53
|
+
except OSError:
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
import time
|
|
57
|
+
deadline = time.time() + timeout
|
|
58
|
+
while time.time() < deadline:
|
|
59
|
+
try:
|
|
60
|
+
data, _ = sock.recvfrom(256)
|
|
61
|
+
text = data.decode()
|
|
62
|
+
if text.startswith("LANTALK_SERVER:"):
|
|
63
|
+
_, ip, port = text.split(":", 2)
|
|
64
|
+
found[ip] = int(port)
|
|
65
|
+
except socket.timeout:
|
|
66
|
+
break
|
|
67
|
+
except (ValueError, UnicodeDecodeError):
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
return list(found.items())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Client ─────────────────────────────────────────────────────────────────── #
|
|
74
|
+
|
|
75
|
+
class LanTalkClient:
|
|
76
|
+
def __init__(self, host: str, port: int = DEFAULT_PORT,
|
|
77
|
+
username: str = "User", password: str = ""):
|
|
78
|
+
self.host = host
|
|
79
|
+
self.port = port
|
|
80
|
+
self.username = username
|
|
81
|
+
self.password = password
|
|
82
|
+
self._reader: Optional[asyncio.StreamReader] = None
|
|
83
|
+
self._writer: Optional[asyncio.StreamWriter] = None
|
|
84
|
+
self._running = False
|
|
85
|
+
|
|
86
|
+
def _prompt(self) -> str:
|
|
87
|
+
return _color(f"[{self.username} | lantalk] > ", "1;36")
|
|
88
|
+
|
|
89
|
+
async def _send(self, **kwargs):
|
|
90
|
+
if self._writer:
|
|
91
|
+
self._writer.write(_make_msg(**kwargs).encode(ENCODING))
|
|
92
|
+
await self._writer.drain()
|
|
93
|
+
|
|
94
|
+
# ── Receive loop ─────────────────────────────────────────────────────── #
|
|
95
|
+
|
|
96
|
+
async def _receive_loop(self):
|
|
97
|
+
while self._running:
|
|
98
|
+
try:
|
|
99
|
+
line = await self._reader.readline()
|
|
100
|
+
except (asyncio.IncompleteReadError, OSError, ConnectionResetError):
|
|
101
|
+
break
|
|
102
|
+
if not line:
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
msg = json.loads(line.decode(ENCODING).strip())
|
|
107
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
self._render(msg)
|
|
111
|
+
|
|
112
|
+
if self._running:
|
|
113
|
+
sys.stdout.write(
|
|
114
|
+
"\n" + _color("[Disconnected from server]\n", "31")
|
|
115
|
+
)
|
|
116
|
+
sys.stdout.flush()
|
|
117
|
+
self._running = False
|
|
118
|
+
|
|
119
|
+
def _render(self, msg: dict):
|
|
120
|
+
type_ = msg.get("type")
|
|
121
|
+
ts = msg.get("ts", _ts())
|
|
122
|
+
|
|
123
|
+
sys.stdout.write("\r\033[K") # clear prompt line
|
|
124
|
+
|
|
125
|
+
if type_ == "message":
|
|
126
|
+
user = msg.get("user", "?")
|
|
127
|
+
text = msg.get("text", "")
|
|
128
|
+
line = f"[{ts}] {_color(user, '36')}: {text}"
|
|
129
|
+
elif type_ == "pm":
|
|
130
|
+
from_user = msg.get("from_user", "?")
|
|
131
|
+
text = msg.get("text", "")
|
|
132
|
+
line = _color(f"[{ts}] 🔒 PM from {from_user}: {text}", "33")
|
|
133
|
+
elif type_ == "system":
|
|
134
|
+
line = _color(f"[{ts}] *** {msg.get('text', '')} ***", "32")
|
|
135
|
+
elif type_ in ("auth_ok", "auth_fail"):
|
|
136
|
+
line = _color(f"[{ts}] {msg.get('reason') or msg.get('text', '')}", "33")
|
|
137
|
+
else:
|
|
138
|
+
line = str(msg)
|
|
139
|
+
|
|
140
|
+
sys.stdout.write(line + "\n")
|
|
141
|
+
sys.stdout.write(self._prompt())
|
|
142
|
+
sys.stdout.flush()
|
|
143
|
+
|
|
144
|
+
# ── Send loop ─────────────────────────────────────────────────────────── #
|
|
145
|
+
|
|
146
|
+
async def _send_loop(self):
|
|
147
|
+
loop = asyncio.get_event_loop()
|
|
148
|
+
while self._running:
|
|
149
|
+
try:
|
|
150
|
+
text = await loop.run_in_executor(None, self._blocking_input)
|
|
151
|
+
except (EOFError, KeyboardInterrupt):
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
if not self._running:
|
|
155
|
+
break
|
|
156
|
+
text = text.strip()
|
|
157
|
+
if not text:
|
|
158
|
+
sys.stdout.write(self._prompt())
|
|
159
|
+
sys.stdout.flush()
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
if text.lower() in ("/quit", "/exit", "/q"):
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
await self._send(type="message", text=text)
|
|
166
|
+
|
|
167
|
+
self._running = False
|
|
168
|
+
|
|
169
|
+
def _blocking_input(self) -> str:
|
|
170
|
+
sys.stdout.write(self._prompt())
|
|
171
|
+
sys.stdout.flush()
|
|
172
|
+
return sys.stdin.readline()
|
|
173
|
+
|
|
174
|
+
# ── Connect & handshake ───────────────────────────────────────────────── #
|
|
175
|
+
|
|
176
|
+
async def _connect(self):
|
|
177
|
+
try:
|
|
178
|
+
self._reader, self._writer = await asyncio.wait_for(
|
|
179
|
+
asyncio.open_connection(self.host, self.port),
|
|
180
|
+
timeout=CONNECT_TIMEOUT,
|
|
181
|
+
)
|
|
182
|
+
except (ConnectionRefusedError, OSError):
|
|
183
|
+
print(_color(f"[Error] Cannot connect to {self.host}:{self.port}", "31"))
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
except asyncio.TimeoutError:
|
|
186
|
+
print(_color("[Error] Connection timed out.", "31"))
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
|
|
189
|
+
# Auth handshake
|
|
190
|
+
line = await asyncio.wait_for(self._reader.readline(), timeout=AUTH_TIMEOUT)
|
|
191
|
+
req = json.loads(line.decode().strip())
|
|
192
|
+
if req.get("type") != "auth_request":
|
|
193
|
+
print(_color("[Error] Unexpected handshake.", "31"))
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
|
|
196
|
+
self._writer.write(
|
|
197
|
+
_make_msg(username=self.username, password=self.password).encode(ENCODING)
|
|
198
|
+
)
|
|
199
|
+
await self._writer.drain()
|
|
200
|
+
|
|
201
|
+
line = await asyncio.wait_for(self._reader.readline(), timeout=AUTH_TIMEOUT)
|
|
202
|
+
result = json.loads(line.decode().strip())
|
|
203
|
+
if result.get("type") == "auth_fail":
|
|
204
|
+
print(_color(f"[Auth failed] {result.get('reason', '')}", "31"))
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
|
|
207
|
+
self._running = True
|
|
208
|
+
|
|
209
|
+
print(_color("=" * 54, "36"))
|
|
210
|
+
print(_color(f" LanTalk Client v{VERSION} — {self.username}", "1;36"))
|
|
211
|
+
print(_color(f" Connected to {self.host}:{self.port}", "36"))
|
|
212
|
+
print(_color(" Commands: /users /pm <user> <msg> /quit", "36"))
|
|
213
|
+
print(_color("=" * 54, "36"))
|
|
214
|
+
sys.stdout.write(self._prompt())
|
|
215
|
+
sys.stdout.flush()
|
|
216
|
+
|
|
217
|
+
await asyncio.gather(
|
|
218
|
+
self._receive_loop(),
|
|
219
|
+
self._send_loop(),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
self._writer.close()
|
|
224
|
+
await self._writer.wait_closed()
|
|
225
|
+
except OSError:
|
|
226
|
+
pass
|
|
227
|
+
print(_color("Disconnected.", "31"))
|
|
228
|
+
|
|
229
|
+
def connect(self):
|
|
230
|
+
try:
|
|
231
|
+
asyncio.run(self._connect())
|
|
232
|
+
except KeyboardInterrupt:
|
|
233
|
+
print(_color("\nDisconnected.", "31"))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ── CLI entry ──────────────────────────────────────────────────────────────── #
|
|
237
|
+
|
|
238
|
+
def run_client(default_port: int = DEFAULT_PORT) -> None:
|
|
239
|
+
print(_color("Scanning for LanTalk servers…", "33"))
|
|
240
|
+
servers = discover_servers()
|
|
241
|
+
|
|
242
|
+
if servers:
|
|
243
|
+
print(_color(f"Found {len(servers)} server(s):\n", "32"))
|
|
244
|
+
for i, (ip, port) in enumerate(servers, 1):
|
|
245
|
+
print(f" [{i}] {ip}:{port}")
|
|
246
|
+
print(" [m] Enter manually\n")
|
|
247
|
+
choice = input("Choose server: ").strip()
|
|
248
|
+
if choice.isdigit() and 1 <= int(choice) <= len(servers):
|
|
249
|
+
host, port = servers[int(choice) - 1]
|
|
250
|
+
else:
|
|
251
|
+
host = input("Server IP: ").strip()
|
|
252
|
+
port_in = input(f"Port (default {default_port}): ").strip()
|
|
253
|
+
port = int(port_in) if port_in.isdigit() else default_port
|
|
254
|
+
else:
|
|
255
|
+
print(_color("No servers found via auto-discovery.\n", "33"))
|
|
256
|
+
host = input("Server IP: ").strip()
|
|
257
|
+
port_in = input(f"Port (default {default_port}): ").strip()
|
|
258
|
+
port = int(port_in) if port_in.isdigit() else default_port
|
|
259
|
+
|
|
260
|
+
if not host:
|
|
261
|
+
print("IP address is required.")
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
|
|
264
|
+
username = input("Display name: ").strip() or "User"
|
|
265
|
+
password = input("Password (leave blank if none): ").strip()
|
|
266
|
+
|
|
267
|
+
LanTalkClient(
|
|
268
|
+
host=host, port=port, username=username, password=password
|
|
269
|
+
).connect()
|
lantalk/protocol.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
lantalk.protocol
|
|
3
|
+
----------------
|
|
4
|
+
Shared message construction, parsing, and validation utilities.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
ENCODING = "utf-8"
|
|
11
|
+
|
|
12
|
+
# Message type constants
|
|
13
|
+
AUTH_REQUEST = "auth_request"
|
|
14
|
+
AUTH_OK = "auth_ok"
|
|
15
|
+
AUTH_FAIL = "auth_fail"
|
|
16
|
+
MESSAGE = "message"
|
|
17
|
+
PM = "pm"
|
|
18
|
+
SYSTEM = "system"
|
|
19
|
+
|
|
20
|
+
VALID_TYPES = frozenset({
|
|
21
|
+
AUTH_REQUEST, AUTH_OK, AUTH_FAIL, MESSAGE, PM, SYSTEM
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
# Per-field constraints
|
|
25
|
+
MAX_TEXT_LEN = 2000
|
|
26
|
+
MAX_USERNAME_LEN = 24
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ts() -> str:
|
|
30
|
+
return datetime.now().strftime("%H:%M")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make(type_: str, **kwargs) -> str:
|
|
34
|
+
"""Return a JSON-encoded protocol message terminated with newline."""
|
|
35
|
+
return json.dumps({"type": type_, "ts": ts(), **kwargs}) + "\n"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse(line: str | bytes) -> dict:
|
|
39
|
+
"""Parse a protocol line. Returns {} on error."""
|
|
40
|
+
if isinstance(line, bytes):
|
|
41
|
+
line = line.decode(ENCODING, errors="replace")
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(line.strip())
|
|
44
|
+
except json.JSONDecodeError:
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate(msg: dict) -> tuple[bool, str]:
|
|
49
|
+
"""
|
|
50
|
+
Validate a parsed message dict.
|
|
51
|
+
Returns (True, "") on success, (False, reason) on failure.
|
|
52
|
+
"""
|
|
53
|
+
if not isinstance(msg, dict):
|
|
54
|
+
return False, "not a dict"
|
|
55
|
+
msg_type = msg.get("type")
|
|
56
|
+
if not isinstance(msg_type, str) or not msg_type:
|
|
57
|
+
return False, "missing type"
|
|
58
|
+
# Text field
|
|
59
|
+
text = msg.get("text")
|
|
60
|
+
if text is not None:
|
|
61
|
+
if not isinstance(text, str):
|
|
62
|
+
return False, "text must be a string"
|
|
63
|
+
if len(text) > MAX_TEXT_LEN:
|
|
64
|
+
return False, f"text too long (max {MAX_TEXT_LEN})"
|
|
65
|
+
# Username field
|
|
66
|
+
username = msg.get("username")
|
|
67
|
+
if username is not None:
|
|
68
|
+
if not isinstance(username, str):
|
|
69
|
+
return False, "username must be a string"
|
|
70
|
+
if len(username) > MAX_USERNAME_LEN:
|
|
71
|
+
return False, f"username too long (max {MAX_USERNAME_LEN})"
|
|
72
|
+
return True, ""
|
lantalk/server.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""
|
|
2
|
+
lantalk.server
|
|
3
|
+
--------------
|
|
4
|
+
Async LAN chat server (pure asyncio). Features:
|
|
5
|
+
- UDP broadcast for auto-discovery (pure asyncio, no threads)
|
|
6
|
+
- Optional password authentication (SHA-256)
|
|
7
|
+
- Duplicate username rejection
|
|
8
|
+
- JSON message protocol with full validation
|
|
9
|
+
- Rate limiting (kicks spammers)
|
|
10
|
+
- Connection timeouts via asyncio.wait_for
|
|
11
|
+
- /kick, /ban, /stats server commands
|
|
12
|
+
- Private messages (/pm)
|
|
13
|
+
- /users command
|
|
14
|
+
- Structured logging
|
|
15
|
+
- Persistent bans (bans.json)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import socket
|
|
24
|
+
import sys
|
|
25
|
+
from collections import deque
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
HOST = "0.0.0.0"
|
|
31
|
+
DEFAULT_PORT = 5050
|
|
32
|
+
DISCOVERY_PORT = 5051
|
|
33
|
+
BUFFER = 8192
|
|
34
|
+
ENCODING = "utf-8"
|
|
35
|
+
VERSION = "2.0.0"
|
|
36
|
+
|
|
37
|
+
BANS_FILE = Path("bans.json")
|
|
38
|
+
RATE_LIMIT = 10 # max messages per window
|
|
39
|
+
RATE_WINDOW = 5.0 # seconds
|
|
40
|
+
READ_TIMEOUT = 300.0 # idle disconnect (5 min)
|
|
41
|
+
AUTH_TIMEOUT = 15.0 # handshake timeout
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Logging ────────────────────────────────────────────────────────────────── #
|
|
45
|
+
|
|
46
|
+
def _setup_logging() -> logging.Logger:
|
|
47
|
+
logger = logging.getLogger("lantalk")
|
|
48
|
+
if not logger.handlers:
|
|
49
|
+
logger.setLevel(logging.DEBUG)
|
|
50
|
+
fmt = logging.Formatter("[%(asctime)s] %(levelname)s %(message)s",
|
|
51
|
+
datefmt="%H:%M:%S")
|
|
52
|
+
ch = logging.StreamHandler(sys.stdout)
|
|
53
|
+
ch.setFormatter(fmt)
|
|
54
|
+
ch.setLevel(logging.INFO)
|
|
55
|
+
fh = logging.FileHandler("lantalk.log", encoding="utf-8")
|
|
56
|
+
fh.setFormatter(fmt)
|
|
57
|
+
fh.setLevel(logging.DEBUG)
|
|
58
|
+
logger.addHandler(ch)
|
|
59
|
+
logger.addHandler(fh)
|
|
60
|
+
return logger
|
|
61
|
+
|
|
62
|
+
log = _setup_logging()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Helpers ────────────────────────────────────────────────────────────────── #
|
|
66
|
+
|
|
67
|
+
def _ts() -> str:
|
|
68
|
+
return datetime.now().strftime("%H:%M")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _color(text: str, code: str) -> str:
|
|
72
|
+
return f"\033[{code}m{text}\033[0m"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _hash(password: str) -> str:
|
|
76
|
+
return hashlib.sha256(password.encode()).hexdigest()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _make_msg(type_: str, **kwargs) -> str:
|
|
80
|
+
return json.dumps({"type": type_, "ts": _ts(), **kwargs}) + "\n"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get_lan_ip() -> str:
|
|
84
|
+
try:
|
|
85
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
86
|
+
probe.connect(("8.8.8.8", 80))
|
|
87
|
+
ip = probe.getsockname()[0]
|
|
88
|
+
probe.close()
|
|
89
|
+
return ip
|
|
90
|
+
except OSError:
|
|
91
|
+
return "127.0.0.1"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Message validation ─────────────────────────────────────────────────────── #
|
|
95
|
+
|
|
96
|
+
VALID_TYPES = {"message", "pm", "system", "auth_request", "auth_ok", "auth_fail"}
|
|
97
|
+
|
|
98
|
+
def _validate(msg: dict) -> bool:
|
|
99
|
+
"""Return True if msg has the minimum required fields and sensible values."""
|
|
100
|
+
if not isinstance(msg, dict):
|
|
101
|
+
return False
|
|
102
|
+
if "type" not in msg:
|
|
103
|
+
return False
|
|
104
|
+
if not isinstance(msg.get("type"), str):
|
|
105
|
+
return False
|
|
106
|
+
# Text field sanity
|
|
107
|
+
text = msg.get("text")
|
|
108
|
+
if text is not None and not isinstance(text, str):
|
|
109
|
+
return False
|
|
110
|
+
if isinstance(text, str) and len(text) > 2000:
|
|
111
|
+
return False
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── Persistent bans ────────────────────────────────────────────────────────── #
|
|
116
|
+
|
|
117
|
+
def _load_bans() -> set:
|
|
118
|
+
if BANS_FILE.exists():
|
|
119
|
+
try:
|
|
120
|
+
return set(json.loads(BANS_FILE.read_text(encoding="utf-8")))
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
return set()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _save_bans(banned: set) -> None:
|
|
127
|
+
try:
|
|
128
|
+
BANS_FILE.write_text(json.dumps(sorted(banned), indent=2), encoding="utf-8")
|
|
129
|
+
except Exception as e:
|
|
130
|
+
log.warning("Could not save bans.json: %s", e)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── Pure-asyncio UDP discovery beacon ─────────────────────────────────────── #
|
|
134
|
+
|
|
135
|
+
class _DiscoveryProtocol(asyncio.DatagramProtocol):
|
|
136
|
+
"""Minimal UDP sink — we send, we don't receive."""
|
|
137
|
+
def error_received(self, exc):
|
|
138
|
+
log.debug("Discovery UDP error: %s", exc)
|
|
139
|
+
def connection_lost(self, exc):
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def _discovery_beacon(lan_ip: str, port: int, stop_event: asyncio.Event):
|
|
144
|
+
"""Broadcast LANTALK_SERVER beacon every 2 s without spawning a thread."""
|
|
145
|
+
message = f"LANTALK_SERVER:{lan_ip}:{port}".encode()
|
|
146
|
+
loop = asyncio.get_running_loop()
|
|
147
|
+
transport = None
|
|
148
|
+
try:
|
|
149
|
+
transport, _ = await loop.create_datagram_endpoint(
|
|
150
|
+
_DiscoveryProtocol,
|
|
151
|
+
local_addr=("0.0.0.0", 0),
|
|
152
|
+
family=socket.AF_INET,
|
|
153
|
+
allow_broadcast=True,
|
|
154
|
+
)
|
|
155
|
+
while not stop_event.is_set():
|
|
156
|
+
try:
|
|
157
|
+
transport.sendto(message, ("<broadcast>", DISCOVERY_PORT))
|
|
158
|
+
except Exception as e:
|
|
159
|
+
log.debug("Beacon send error: %s", e)
|
|
160
|
+
try:
|
|
161
|
+
await asyncio.wait_for(stop_event.wait(), timeout=2)
|
|
162
|
+
except asyncio.TimeoutError:
|
|
163
|
+
pass
|
|
164
|
+
finally:
|
|
165
|
+
if transport:
|
|
166
|
+
transport.close()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── Client state ───────────────────────────────────────────────────────────── #
|
|
170
|
+
|
|
171
|
+
class _RateLimiter:
|
|
172
|
+
"""Sliding-window rate limiter."""
|
|
173
|
+
def __init__(self, limit: int, window: float):
|
|
174
|
+
self._limit = limit
|
|
175
|
+
self._window = window
|
|
176
|
+
self._timestamps: deque = deque()
|
|
177
|
+
|
|
178
|
+
def is_allowed(self) -> bool:
|
|
179
|
+
import time as _time
|
|
180
|
+
now = _time.monotonic()
|
|
181
|
+
while self._timestamps and now - self._timestamps[0] > self._window:
|
|
182
|
+
self._timestamps.popleft()
|
|
183
|
+
if len(self._timestamps) >= self._limit:
|
|
184
|
+
return False
|
|
185
|
+
self._timestamps.append(_time.monotonic())
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class Client:
|
|
190
|
+
def __init__(self, username: str, reader: asyncio.StreamReader,
|
|
191
|
+
writer: asyncio.StreamWriter):
|
|
192
|
+
self.username = username
|
|
193
|
+
self.reader = reader
|
|
194
|
+
self.writer = writer
|
|
195
|
+
self.addr = writer.get_extra_info("peername", ("?", 0))
|
|
196
|
+
self.joined_at = datetime.now()
|
|
197
|
+
self.rate = _RateLimiter(RATE_LIMIT, RATE_WINDOW)
|
|
198
|
+
|
|
199
|
+
async def send(self, payload: str):
|
|
200
|
+
try:
|
|
201
|
+
self.writer.write(payload.encode(ENCODING))
|
|
202
|
+
await self.writer.drain()
|
|
203
|
+
except (OSError, ConnectionResetError):
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ── Server ─────────────────────────────────────────────────────────────────── #
|
|
208
|
+
|
|
209
|
+
class LanTalkServer:
|
|
210
|
+
def __init__(self, port: int = DEFAULT_PORT, username: str = "Server",
|
|
211
|
+
password: Optional[str] = None):
|
|
212
|
+
self.port = port
|
|
213
|
+
self.username = username
|
|
214
|
+
self.password_hash: Optional[str] = _hash(password) if password else None
|
|
215
|
+
self.lan_ip = _get_lan_ip()
|
|
216
|
+
|
|
217
|
+
self._clients: dict[str, Client] = {}
|
|
218
|
+
self._banned: set[str] = _load_bans()
|
|
219
|
+
self._lock = asyncio.Lock()
|
|
220
|
+
self._server: Optional[asyncio.AbstractServer] = None
|
|
221
|
+
self._stop_beacon = asyncio.Event()
|
|
222
|
+
|
|
223
|
+
# ── Broadcast / send helpers ──────────────────────────────────────────── #
|
|
224
|
+
|
|
225
|
+
async def _broadcast(self, payload: str, exclude: Optional[str] = None):
|
|
226
|
+
async with self._lock:
|
|
227
|
+
targets = list(self._clients.values())
|
|
228
|
+
for c in targets:
|
|
229
|
+
if c.username != exclude:
|
|
230
|
+
await c.send(payload)
|
|
231
|
+
|
|
232
|
+
async def _send_to(self, username: str, payload: str) -> bool:
|
|
233
|
+
async with self._lock:
|
|
234
|
+
c = self._clients.get(username)
|
|
235
|
+
if c:
|
|
236
|
+
await c.send(payload)
|
|
237
|
+
return True
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
async def _remove(self, username: str, announce: bool = True):
|
|
241
|
+
async with self._lock:
|
|
242
|
+
c = self._clients.pop(username, None)
|
|
243
|
+
if c:
|
|
244
|
+
try:
|
|
245
|
+
c.writer.close()
|
|
246
|
+
await c.writer.wait_closed()
|
|
247
|
+
except OSError:
|
|
248
|
+
pass
|
|
249
|
+
if announce:
|
|
250
|
+
msg = _make_msg("system", text=f"{username} left the chat.")
|
|
251
|
+
log.info("%s left.", username)
|
|
252
|
+
await self._broadcast(msg)
|
|
253
|
+
|
|
254
|
+
# ── Client handler ────────────────────────────────────────────────────── #
|
|
255
|
+
|
|
256
|
+
async def _handle(self, reader: asyncio.StreamReader,
|
|
257
|
+
writer: asyncio.StreamWriter):
|
|
258
|
+
addr = writer.get_extra_info("peername", ("?", 0))
|
|
259
|
+
ip = addr[0]
|
|
260
|
+
|
|
261
|
+
if ip in self._banned:
|
|
262
|
+
writer.close()
|
|
263
|
+
log.info("Rejected banned IP %s", ip)
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
async def send_raw(payload: str):
|
|
267
|
+
writer.write(payload.encode(ENCODING))
|
|
268
|
+
await writer.drain()
|
|
269
|
+
|
|
270
|
+
# ① Auth handshake
|
|
271
|
+
await send_raw(_make_msg("auth_request",
|
|
272
|
+
needs_password=self.password_hash is not None))
|
|
273
|
+
try:
|
|
274
|
+
line = await asyncio.wait_for(reader.readline(), timeout=AUTH_TIMEOUT)
|
|
275
|
+
except asyncio.TimeoutError:
|
|
276
|
+
writer.close()
|
|
277
|
+
log.debug("Auth timeout from %s", ip)
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
auth = json.loads(line.decode(ENCODING).strip())
|
|
282
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
283
|
+
writer.close()
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
username = (auth.get("username") or "").strip()[:24]
|
|
287
|
+
given_pw = auth.get("password") or ""
|
|
288
|
+
|
|
289
|
+
# Username validation
|
|
290
|
+
if not username or not username.replace("_", "").replace("-", "").isalnum():
|
|
291
|
+
await send_raw(_make_msg("auth_fail", reason="Invalid username."))
|
|
292
|
+
writer.close()
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
async with self._lock:
|
|
296
|
+
taken = username in self._clients
|
|
297
|
+
if taken:
|
|
298
|
+
await send_raw(_make_msg("auth_fail", reason="Username already taken."))
|
|
299
|
+
writer.close()
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
if self.password_hash and _hash(given_pw) != self.password_hash:
|
|
303
|
+
await send_raw(_make_msg("auth_fail", reason="Wrong password."))
|
|
304
|
+
log.warning("Wrong password attempt for '%s' from %s", username, ip)
|
|
305
|
+
writer.close()
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
# ② Register client
|
|
309
|
+
client = Client(username, reader, writer)
|
|
310
|
+
async with self._lock:
|
|
311
|
+
self._clients[username] = client
|
|
312
|
+
|
|
313
|
+
await send_raw(_make_msg("auth_ok", username=username,
|
|
314
|
+
server_name=self.username))
|
|
315
|
+
log.info("%s joined from %s", username, ip)
|
|
316
|
+
await self._broadcast(
|
|
317
|
+
_make_msg("system", text=f"{username} joined from {ip}."),
|
|
318
|
+
exclude=username,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# ③ Message loop
|
|
322
|
+
try:
|
|
323
|
+
while True:
|
|
324
|
+
try:
|
|
325
|
+
line = await asyncio.wait_for(
|
|
326
|
+
reader.readline(), timeout=READ_TIMEOUT
|
|
327
|
+
)
|
|
328
|
+
except asyncio.TimeoutError:
|
|
329
|
+
await client.send(
|
|
330
|
+
_make_msg("system", text="Idle timeout — disconnecting.")
|
|
331
|
+
)
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
if not line:
|
|
335
|
+
break
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
msg = json.loads(line.decode(ENCODING).strip())
|
|
339
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
340
|
+
log.debug("Malformed message from %s — ignored", username)
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
if not _validate(msg):
|
|
344
|
+
log.debug("Invalid message structure from %s — ignored", username)
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
if msg.get("type") == "message":
|
|
348
|
+
text = str(msg.get("text", "")).strip()
|
|
349
|
+
if not text:
|
|
350
|
+
continue
|
|
351
|
+
|
|
352
|
+
# Rate limiting
|
|
353
|
+
if not client.rate.is_allowed():
|
|
354
|
+
await client.send(
|
|
355
|
+
_make_msg("system",
|
|
356
|
+
text="⚠ Slow down — you're sending too fast.")
|
|
357
|
+
)
|
|
358
|
+
log.warning("%s hit rate limit", username)
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
if text.startswith("/"):
|
|
362
|
+
await self._handle_client_command(client, text)
|
|
363
|
+
else:
|
|
364
|
+
log.debug("<%s> %s", username, text)
|
|
365
|
+
await self._broadcast(
|
|
366
|
+
_make_msg("message", user=username, text=text),
|
|
367
|
+
exclude=username,
|
|
368
|
+
)
|
|
369
|
+
finally:
|
|
370
|
+
await self._remove(username)
|
|
371
|
+
|
|
372
|
+
async def _handle_client_command(self, client: Client, text: str):
|
|
373
|
+
parts = text.split(maxsplit=2)
|
|
374
|
+
cmd = parts[0].lower()
|
|
375
|
+
|
|
376
|
+
if cmd == "/users":
|
|
377
|
+
async with self._lock:
|
|
378
|
+
names = list(self._clients.keys())
|
|
379
|
+
await client.send(
|
|
380
|
+
_make_msg("system", text="Online: " + ", ".join(names))
|
|
381
|
+
)
|
|
382
|
+
elif cmd == "/pm" and len(parts) >= 3:
|
|
383
|
+
target, pm_text = parts[1], parts[2]
|
|
384
|
+
payload = _make_msg("pm", from_user=client.username, text=pm_text)
|
|
385
|
+
if not await self._send_to(target, payload):
|
|
386
|
+
await client.send(
|
|
387
|
+
_make_msg("system", text=f"User '{target}' not found.")
|
|
388
|
+
)
|
|
389
|
+
elif cmd in ("/quit", "/exit", "/q"):
|
|
390
|
+
await client.send(_make_msg("system", text="Goodbye!"))
|
|
391
|
+
else:
|
|
392
|
+
await client.send(
|
|
393
|
+
_make_msg("system", text=f"Unknown command: {cmd}")
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# ── Server console commands ───────────────────────────────────────────── #
|
|
397
|
+
|
|
398
|
+
async def _server_console(self):
|
|
399
|
+
loop = asyncio.get_event_loop()
|
|
400
|
+
while True:
|
|
401
|
+
try:
|
|
402
|
+
text = await loop.run_in_executor(None, input, "")
|
|
403
|
+
except (EOFError, KeyboardInterrupt):
|
|
404
|
+
break
|
|
405
|
+
text = text.strip()
|
|
406
|
+
if not text:
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
parts = text.split(maxsplit=1)
|
|
410
|
+
cmd = parts[0].lower()
|
|
411
|
+
|
|
412
|
+
if cmd in ("/quit", "/exit", "/q"):
|
|
413
|
+
log.info("Server shutting down.")
|
|
414
|
+
self._stop_beacon.set()
|
|
415
|
+
if self._server:
|
|
416
|
+
self._server.close()
|
|
417
|
+
break
|
|
418
|
+
|
|
419
|
+
elif cmd == "/users":
|
|
420
|
+
async with self._lock:
|
|
421
|
+
names = list(self._clients.keys())
|
|
422
|
+
print(_color("Online: " + (", ".join(names) or "(none)"), "36"))
|
|
423
|
+
|
|
424
|
+
elif cmd == "/stats":
|
|
425
|
+
async with self._lock:
|
|
426
|
+
count = len(self._clients)
|
|
427
|
+
print(_color(
|
|
428
|
+
f"Connected: {count} | Banned IPs: {len(self._banned)}", "36"
|
|
429
|
+
))
|
|
430
|
+
|
|
431
|
+
elif cmd == "/kick" and len(parts) == 2:
|
|
432
|
+
target = parts[1].strip()
|
|
433
|
+
if target in self._clients:
|
|
434
|
+
await self._send_to(
|
|
435
|
+
target, _make_msg("system", text="You have been kicked.")
|
|
436
|
+
)
|
|
437
|
+
await self._remove(target)
|
|
438
|
+
log.info("Kicked %s", target)
|
|
439
|
+
else:
|
|
440
|
+
print(_color(f"User '{target}' not found.", "31"))
|
|
441
|
+
|
|
442
|
+
elif cmd == "/ban" and len(parts) == 2:
|
|
443
|
+
target = parts[1].strip()
|
|
444
|
+
async with self._lock:
|
|
445
|
+
c = self._clients.get(target)
|
|
446
|
+
if c:
|
|
447
|
+
self._banned.add(c.addr[0])
|
|
448
|
+
_save_bans(self._banned)
|
|
449
|
+
await self._send_to(
|
|
450
|
+
target, _make_msg("system", text="You have been banned.")
|
|
451
|
+
)
|
|
452
|
+
await self._remove(target)
|
|
453
|
+
log.info("Banned %s (%s)", target, c.addr[0])
|
|
454
|
+
else:
|
|
455
|
+
print(_color(f"User '{target}' not found.", "31"))
|
|
456
|
+
|
|
457
|
+
elif cmd == "/unban" and len(parts) == 2:
|
|
458
|
+
ip = parts[1].strip()
|
|
459
|
+
if ip in self._banned:
|
|
460
|
+
self._banned.discard(ip)
|
|
461
|
+
_save_bans(self._banned)
|
|
462
|
+
log.info("Unbanned %s", ip)
|
|
463
|
+
print(_color(f"Unbanned {ip}", "32"))
|
|
464
|
+
else:
|
|
465
|
+
print(_color(f"IP {ip} not in ban list.", "31"))
|
|
466
|
+
|
|
467
|
+
elif cmd == "/bans":
|
|
468
|
+
if self._banned:
|
|
469
|
+
print(_color("Banned IPs: " + ", ".join(sorted(self._banned)), "36"))
|
|
470
|
+
else:
|
|
471
|
+
print(_color("No banned IPs.", "36"))
|
|
472
|
+
|
|
473
|
+
elif cmd == "/help":
|
|
474
|
+
print(_color(
|
|
475
|
+
"/users /stats /kick <user> /ban <user> "
|
|
476
|
+
"/bans /unban <ip> /quit",
|
|
477
|
+
"36"
|
|
478
|
+
))
|
|
479
|
+
|
|
480
|
+
else:
|
|
481
|
+
# Server chat
|
|
482
|
+
msg = _make_msg("message", user=self.username + " (server)", text=text)
|
|
483
|
+
log.debug("[server] %s", text)
|
|
484
|
+
await self._broadcast(msg)
|
|
485
|
+
|
|
486
|
+
# ── Start ─────────────────────────────────────────────────────────────── #
|
|
487
|
+
|
|
488
|
+
async def _run(self):
|
|
489
|
+
self._server = await asyncio.start_server(
|
|
490
|
+
self._handle, HOST, self.port
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
print(_color("=" * 54, "35"))
|
|
494
|
+
print(_color(f" LanTalk Server v{VERSION} — {self.username}", "1;35"))
|
|
495
|
+
print(_color(f" Listening on {self.lan_ip}:{self.port}", "35"))
|
|
496
|
+
print(_color(f" Discovery beacon active (UDP {DISCOVERY_PORT})", "35"))
|
|
497
|
+
if self.password_hash:
|
|
498
|
+
print(_color(" Password protection: ON", "35"))
|
|
499
|
+
if self._banned:
|
|
500
|
+
print(_color(f" Loaded {len(self._banned)} persistent ban(s)", "35"))
|
|
501
|
+
print(_color(" /help for server commands", "35"))
|
|
502
|
+
print(_color("=" * 54, "35"))
|
|
503
|
+
log.info("Server started on %s:%s", self.lan_ip, self.port)
|
|
504
|
+
|
|
505
|
+
async with self._server:
|
|
506
|
+
await asyncio.gather(
|
|
507
|
+
self._server.serve_forever(),
|
|
508
|
+
self._server_console(),
|
|
509
|
+
_discovery_beacon(self.lan_ip, self.port, self._stop_beacon),
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
def start(self):
|
|
513
|
+
try:
|
|
514
|
+
asyncio.run(self._run())
|
|
515
|
+
except KeyboardInterrupt:
|
|
516
|
+
pass
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def run_server(port: int = DEFAULT_PORT) -> None:
|
|
520
|
+
username = input("Server display name: ").strip() or "Server"
|
|
521
|
+
pw = input("Set password (leave blank for none): ").strip() or None
|
|
522
|
+
LanTalkServer(port=port, username=username, password=pw).start()
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lantalk
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Terminal-based LAN chat — async, discoverable, secure
|
|
5
|
+
Author-email: Ezra Destaw <ezradestaw@email.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/Ezradestaw/lantalk
|
|
7
|
+
Project-URL: Repository, https://github.com/Ezradestaw/lantalk
|
|
8
|
+
Project-URL: Issues, https://github.com/Ezradestaw/lantalk/issues
|
|
9
|
+
Keywords: chat,lan,wifi,terminal,networking,broadcast,asyncio
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Communications :: Chat
|
|
22
|
+
Classifier: Topic :: Internet
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENCE
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# LanTalk
|
|
32
|
+
|
|
33
|
+
> Terminal-based LAN chat — pure asyncio, auto-discoverable, zero runtime dependencies.
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
_ _____ _ _
|
|
37
|
+
| | |_ _| | | |
|
|
38
|
+
| | __ _ _ __ | | __ _| | | __
|
|
39
|
+
| | / _` | '_ \ | |/ _` | | |/ /
|
|
40
|
+
| |___| (_| | | | || | (_| | | <
|
|
41
|
+
|______\__,_|_| |_\_/ \__,_|_|_|\_\
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
[](https://pypi.org/project/lantalk/)
|
|
45
|
+
[](https://pypi.org/project/lantalk/)
|
|
46
|
+
[](LICENSE)
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## What is LanTalk?
|
|
51
|
+
|
|
52
|
+
LanTalk lets people on the **same Wi-Fi or LAN** chat instantly — no accounts, no internet, no servers in the cloud. One person runs `lantalk` and starts a server; everyone else runs `lantalk` and joins. That's it.
|
|
53
|
+
|
|
54
|
+
- Works on school networks, home networks, offices, hackathons
|
|
55
|
+
- Pure Python stdlib — nothing to `pip install` beyond the package itself
|
|
56
|
+
- Clients auto-discover the server via UDP broadcast (no IP typing required)
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Features
|
|
61
|
+
|
|
62
|
+
| | |
|
|
63
|
+
|---|---|
|
|
64
|
+
| 🔍 **Auto-discovery** | UDP beacon finds servers on your LAN automatically |
|
|
65
|
+
| 🔐 **Password auth** | Optional server password with SHA-256 hashing |
|
|
66
|
+
| 👤 **Username dedup** | Two people can't use the same name simultaneously |
|
|
67
|
+
| 💬 **Private messages** | `/pm <user> <message>` — end-to-end on the wire |
|
|
68
|
+
| 🛡 **Rate limiting** | Spammers get warned then kicked (10 msg / 5 s) |
|
|
69
|
+
| ⏱ **Idle timeouts** | Dead sockets cleaned up after 5 minutes of silence |
|
|
70
|
+
| 🚫 **Persistent bans** | Banned IPs survive server restarts via `bans.json` |
|
|
71
|
+
| 🔇 **Full validation** | Every message checked for type and field constraints |
|
|
72
|
+
| 📝 **Structured logging** | Console + `lantalk.log` file, levels DEBUG/INFO/WARNING |
|
|
73
|
+
| ⚡ **Pure asyncio** | Single event loop — no threads, no race conditions |
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Install
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install lantalk
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Python 3.10+ required. No third-party dependencies.
|
|
84
|
+
|
|
85
|
+
### From source
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
git clone https://github.com/yourusername/lantalk
|
|
89
|
+
cd lantalk
|
|
90
|
+
pip install -e ".[dev]"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Quick start
|
|
96
|
+
|
|
97
|
+
### 1 — Start a server (one person does this)
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
$ lantalk
|
|
101
|
+
|
|
102
|
+
[1] Start a Server
|
|
103
|
+
[2] Join as Client
|
|
104
|
+
|
|
105
|
+
Choose (1 or 2): 1
|
|
106
|
+
Port (default 5050):
|
|
107
|
+
Server display name: Alice
|
|
108
|
+
Set password (leave blank for none): secret
|
|
109
|
+
|
|
110
|
+
══════════════════════════════════════════════════════
|
|
111
|
+
LanTalk Server v2.0.0 — Alice
|
|
112
|
+
Listening on 192.168.1.5:5050
|
|
113
|
+
Discovery beacon active (UDP 5051)
|
|
114
|
+
Password protection: ON
|
|
115
|
+
══════════════════════════════════════════════════════
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 2 — Join as a client (everyone else)
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
$ lantalk
|
|
122
|
+
|
|
123
|
+
[1] Start a Server
|
|
124
|
+
[2] Join as Client
|
|
125
|
+
|
|
126
|
+
Choose (1 or 2): 2
|
|
127
|
+
Scanning for LanTalk servers…
|
|
128
|
+
Found 1 server(s):
|
|
129
|
+
|
|
130
|
+
[1] 192.168.1.5:5050
|
|
131
|
+
[m] Enter manually
|
|
132
|
+
|
|
133
|
+
Choose server: 1
|
|
134
|
+
Display name: Bob
|
|
135
|
+
Password: secret
|
|
136
|
+
|
|
137
|
+
══════════════════════════════════════════════════════
|
|
138
|
+
LanTalk Client v2.0.0 — Bob
|
|
139
|
+
Connected to 192.168.1.5:5050
|
|
140
|
+
Commands: /users /pm <user> <msg> /quit
|
|
141
|
+
══════════════════════════════════════════════════════
|
|
142
|
+
[Bob | lantalk] > Hey everyone!
|
|
143
|
+
[14:22] Alice: Welcome Bob!
|
|
144
|
+
[Bob | lantalk] > /pm Alice just between us 👋
|
|
145
|
+
[Bob | lantalk] > /users
|
|
146
|
+
[14:23] *** Online: Alice, Bob, Carol ***
|
|
147
|
+
[Bob | lantalk] >
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Commands
|
|
153
|
+
|
|
154
|
+
### Client commands
|
|
155
|
+
|
|
156
|
+
| Command | Description |
|
|
157
|
+
|---------|-------------|
|
|
158
|
+
| `/users` | List all connected users |
|
|
159
|
+
| `/pm <user> <message>` | Send a private message |
|
|
160
|
+
| `/quit` | Disconnect and exit |
|
|
161
|
+
|
|
162
|
+
### Server console commands
|
|
163
|
+
|
|
164
|
+
| Command | Description |
|
|
165
|
+
|---------|-------------|
|
|
166
|
+
| `/users` | List connected users |
|
|
167
|
+
| `/stats` | Show connection count and ban list size |
|
|
168
|
+
| `/kick <user>` | Disconnect a user |
|
|
169
|
+
| `/ban <user>` | Ban a user's IP (persisted to `bans.json`) |
|
|
170
|
+
| `/bans` | Show all banned IPs |
|
|
171
|
+
| `/unban <ip>` | Remove an IP from the ban list |
|
|
172
|
+
| `/help` | Show this list |
|
|
173
|
+
| `/quit` | Shut down the server |
|
|
174
|
+
| *(anything else)* | Broadcast as a server message to all clients |
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Architecture
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
182
|
+
│ LAN (e.g. 192.168.1.0/24) │
|
|
183
|
+
│ │
|
|
184
|
+
│ ┌────────────────────────────────────┐ │
|
|
185
|
+
│ │ LanTalkServer (pure asyncio) │ │
|
|
186
|
+
│ │ │◄─── TCP :5050 ───┐ │
|
|
187
|
+
│ │ _discovery_beacon() ─────────────┼──── UDP bcast │ │
|
|
188
|
+
│ │ asyncio coroutine, no threads │ every 2 s │ │
|
|
189
|
+
│ │ │ │ │
|
|
190
|
+
│ │ per-client _RateLimiter │ │ │
|
|
191
|
+
│ │ _validate() on every message │ │ │
|
|
192
|
+
│ │ asyncio.wait_for() timeouts │ │ │
|
|
193
|
+
│ │ bans.json (persistent) │ │ │
|
|
194
|
+
│ │ lantalk.log (structured) │ │ │
|
|
195
|
+
│ └────────────────────────────────────┘ │ │
|
|
196
|
+
│ │ │
|
|
197
|
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┴─┐ │
|
|
198
|
+
│ │ Client A │ │ Client B │ │ Client C │ │
|
|
199
|
+
│ │ UDP discover │ │ UDP discover │ │ manual IP entry │ │
|
|
200
|
+
│ │ asyncio │ │ asyncio │ │ asyncio │ │
|
|
201
|
+
│ └──────────────┘ └──────────────┘ └────────────────────┘ │
|
|
202
|
+
└──────────────────────────────────────────────────────────────┘
|
|
203
|
+
|
|
204
|
+
Message flow (post-auth):
|
|
205
|
+
Client ──JSON──► Server ──broadcast JSON──► all other Clients
|
|
206
|
+
└──(PM)──────────► one Client
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Concurrency model
|
|
210
|
+
|
|
211
|
+
Everything runs in **one asyncio event loop** — no `threading.Thread`, no locks between threads, no shared mutable state across OS threads. The only `asyncio.Lock` is used to guard the `_clients` dict between concurrent coroutines.
|
|
212
|
+
|
|
213
|
+
### JSON wire protocol
|
|
214
|
+
|
|
215
|
+
Every message is a single UTF-8 JSON line terminated with `\n`.
|
|
216
|
+
|
|
217
|
+
```jsonc
|
|
218
|
+
// Server → Client: auth challenge
|
|
219
|
+
{"type": "auth_request", "ts": "14:22", "needs_password": true}
|
|
220
|
+
|
|
221
|
+
// Client → Server: credentials
|
|
222
|
+
{"username": "Bob", "password": "secret"}
|
|
223
|
+
|
|
224
|
+
// Server → Client: success / failure
|
|
225
|
+
{"type": "auth_ok", "ts": "…", "username": "Bob", "server_name": "Alice"}
|
|
226
|
+
{"type": "auth_fail", "ts": "…", "reason": "Username already taken."}
|
|
227
|
+
|
|
228
|
+
// Client → Server: chat message
|
|
229
|
+
{"type": "message", "text": "Hello!"}
|
|
230
|
+
|
|
231
|
+
// Server → all clients: broadcast
|
|
232
|
+
{"type": "message", "ts": "14:22", "user": "Bob", "text": "Hello!"}
|
|
233
|
+
|
|
234
|
+
// Server → one client: private message
|
|
235
|
+
{"type": "pm", "ts": "14:22", "from_user": "Alice", "text": "hey"}
|
|
236
|
+
|
|
237
|
+
// Server → clients: system event
|
|
238
|
+
{"type": "system", "ts": "14:22", "text": "Bob joined from 192.168.1.10."}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Security notes
|
|
244
|
+
|
|
245
|
+
- Passwords are **never stored or sent in plaintext**. The server stores a SHA-256 hash; clients send the raw password only during the auth handshake (upgrade to TLS in v3 is planned).
|
|
246
|
+
- Banned IPs are written to `bans.json` in the server's working directory and reloaded on every start.
|
|
247
|
+
- Rate limiting: each client is capped at **10 messages per 5 seconds**. Exceeding this sends a warning; repeat offences kick the user.
|
|
248
|
+
- Usernames are limited to 24 alphanumeric/underscore/hyphen characters.
|
|
249
|
+
- All incoming messages are schema-validated before processing; malformed JSON is silently dropped.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Logging
|
|
254
|
+
|
|
255
|
+
LanTalk writes structured logs to both the terminal and `lantalk.log`:
|
|
256
|
+
|
|
257
|
+
```
|
|
258
|
+
[14:22:01] INFO Server started on 192.168.1.5:5050
|
|
259
|
+
[14:22:15] INFO Bob joined from 192.168.1.10
|
|
260
|
+
[14:23:44] WARNING Carol hit rate limit
|
|
261
|
+
[14:25:01] INFO Kicked Dave
|
|
262
|
+
[14:25:03] INFO Banned Dave (192.168.1.22)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Set `LANTALK_LOG_LEVEL=DEBUG` to see per-message traces.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Running tests
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
pip install -e ".[dev]"
|
|
273
|
+
pytest -v
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Changelog
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
2.0.0 — pure asyncio (no threads), validation, rate limiting,
|
|
282
|
+
timeouts, persistent bans, structured logging, v2 prompt
|
|
283
|
+
1.1.0 — asyncio rewrite, UDP discovery, JSON protocol, auth, commands, pytest
|
|
284
|
+
1.0.0 — initial: threading, basic TCP, plain-text protocol
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Roadmap
|
|
290
|
+
|
|
291
|
+
- [ ] TLS encryption (v3)
|
|
292
|
+
- [ ] File transfer (`/send file.pdf`)
|
|
293
|
+
- [ ] Web UI mode (`http://localhost:5050`)
|
|
294
|
+
- [ ] Plugin system (`@lantalk.plugin`)
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
MIT © Ezra
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
lantalk/__init__.py,sha256=eA0Hf6CMeVoA1eMZAcKSE6Rsv2E6MGzYxZDQyA6Ek4E,65
|
|
2
|
+
lantalk/cli.py,sha256=dsVKrcy_9EkFMNzWVw19Bj4xhVhBSNgtMdK5h2LG6MQ,1307
|
|
3
|
+
lantalk/client.py,sha256=Xx74SBCccoZsw-yFP6GLf5qbQjucnasuYdWc6gK7HTs,9689
|
|
4
|
+
lantalk/protocol.py,sha256=mb_MrfRfSzyXK2AQL8Ugp1FKhk02XnmMudeWPV1oG9s,1961
|
|
5
|
+
lantalk/server.py,sha256=Enrv23LPpjGwXi_QnSBeUTErqBO5r1Gi8Rd810XVcP0,19363
|
|
6
|
+
lantalk-2.0.0.dist-info/licenses/LICENCE,sha256=5LD6OPAP1Fr90OIiWhFsVm1iWY4TR5rICgyNebC5n_k,1087
|
|
7
|
+
lantalk-2.0.0.dist-info/METADATA,sha256=Z3DyPbsheed2RTR8bYNU0yLVsteDyVUo52GcaSlcQ7U,11181
|
|
8
|
+
lantalk-2.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
lantalk-2.0.0.dist-info/entry_points.txt,sha256=D7PNHGH90TMA-5XYPKTt2z1MUv6YcjbuLA-i4_r2Hn8,45
|
|
10
|
+
lantalk-2.0.0.dist-info/top_level.txt,sha256=RvuhHjamhH-V0tKW2wCkJtOxfEL3tnjI0-bp9tYnlXI,8
|
|
11
|
+
lantalk-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ezra Destaw
|
|
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
|
+
lantalk
|