lanmess 1.0.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.
- lanmess-1.0.0/LICENSE +21 -0
- lanmess-1.0.0/PKG-INFO +26 -0
- lanmess-1.0.0/README.md +11 -0
- lanmess-1.0.0/pyproject.toml +28 -0
- lanmess-1.0.0/setup.cfg +4 -0
- lanmess-1.0.0/src/lanmess.egg-info/PKG-INFO +26 -0
- lanmess-1.0.0/src/lanmess.egg-info/SOURCES.txt +17 -0
- lanmess-1.0.0/src/lanmess.egg-info/dependency_links.txt +1 -0
- lanmess-1.0.0/src/lanmess.egg-info/entry_points.txt +2 -0
- lanmess-1.0.0/src/lanmess.egg-info/requires.txt +2 -0
- lanmess-1.0.0/src/lanmess.egg-info/top_level.txt +1 -0
- lanmess-1.0.0/src/lanmess_files/__init__.py +0 -0
- lanmess-1.0.0/src/lanmess_files/config.py +23 -0
- lanmess-1.0.0/src/lanmess_files/crypto.py +113 -0
- lanmess-1.0.0/src/lanmess_files/database.py +237 -0
- lanmess-1.0.0/src/lanmess_files/discovery.py +107 -0
- lanmess-1.0.0/src/lanmess_files/main.py +9 -0
- lanmess-1.0.0/src/lanmess_files/network.py +76 -0
- lanmess-1.0.0/src/lanmess_files/tui.py +1420 -0
lanmess-1.0.0/LICENSE
ADDED
|
@@ -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.
|
lanmess-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
lanmess-1.0.0/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lanmess"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "LAN-only end-to-end encrypted messenger with a TUI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
|
|
14
|
+
dependencies = [
|
|
15
|
+
"textual>=0.52.0",
|
|
16
|
+
"cryptography>=41.0.0",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Topic :: Communications :: Chat",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
lanmess = "lanmess_files.main:main"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
lanmess-1.0.0/setup.cfg
ADDED
|
@@ -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,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/lanmess.egg-info/PKG-INFO
|
|
5
|
+
src/lanmess.egg-info/SOURCES.txt
|
|
6
|
+
src/lanmess.egg-info/dependency_links.txt
|
|
7
|
+
src/lanmess.egg-info/entry_points.txt
|
|
8
|
+
src/lanmess.egg-info/requires.txt
|
|
9
|
+
src/lanmess.egg-info/top_level.txt
|
|
10
|
+
src/lanmess_files/__init__.py
|
|
11
|
+
src/lanmess_files/config.py
|
|
12
|
+
src/lanmess_files/crypto.py
|
|
13
|
+
src/lanmess_files/database.py
|
|
14
|
+
src/lanmess_files/discovery.py
|
|
15
|
+
src/lanmess_files/main.py
|
|
16
|
+
src/lanmess_files/network.py
|
|
17
|
+
src/lanmess_files/tui.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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)
|