vnode 0.1.2__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.
- vnode/__init__.py +40 -0
- vnode/__main__.py +5 -0
- vnode/cli.py +64 -0
- vnode/config.py +161 -0
- vnode/crypto.py +103 -0
- vnode/example-node.json +34 -0
- vnode/runtime.py +516 -0
- vnode-0.1.2.dist-info/METADATA +110 -0
- vnode-0.1.2.dist-info/RECORD +12 -0
- vnode-0.1.2.dist-info/WHEEL +4 -0
- vnode-0.1.2.dist-info/entry_points.txt +3 -0
- vnode-0.1.2.dist-info/licenses/LICENSE +674 -0
vnode/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Virtual Meshtastic node package."""
|
|
2
|
+
|
|
3
|
+
from .config import (
|
|
4
|
+
BroadcastConfig,
|
|
5
|
+
ChannelConfig,
|
|
6
|
+
MeshDbConfig,
|
|
7
|
+
NodeConfig,
|
|
8
|
+
PositionConfig,
|
|
9
|
+
SecurityConfig,
|
|
10
|
+
UdpConfig,
|
|
11
|
+
)
|
|
12
|
+
from .crypto import (
|
|
13
|
+
b64_decode,
|
|
14
|
+
b64_encode,
|
|
15
|
+
decrypt_dm,
|
|
16
|
+
derive_public_key,
|
|
17
|
+
encrypt_dm,
|
|
18
|
+
generate_keypair,
|
|
19
|
+
)
|
|
20
|
+
from .runtime import VirtualNode, parse_node_id, resolve_hw_model, resolve_role
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BroadcastConfig",
|
|
24
|
+
"ChannelConfig",
|
|
25
|
+
"MeshDbConfig",
|
|
26
|
+
"NodeConfig",
|
|
27
|
+
"PositionConfig",
|
|
28
|
+
"SecurityConfig",
|
|
29
|
+
"UdpConfig",
|
|
30
|
+
"VirtualNode",
|
|
31
|
+
"b64_decode",
|
|
32
|
+
"b64_encode",
|
|
33
|
+
"decrypt_dm",
|
|
34
|
+
"derive_public_key",
|
|
35
|
+
"encrypt_dm",
|
|
36
|
+
"generate_keypair",
|
|
37
|
+
"parse_node_id",
|
|
38
|
+
"resolve_hw_model",
|
|
39
|
+
"resolve_role",
|
|
40
|
+
]
|
vnode/__main__.py
ADDED
vnode/cli.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from .runtime import VirtualNode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
10
|
+
parser = argparse.ArgumentParser(description="Virtual Meshtastic node")
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
"--vnode-file",
|
|
13
|
+
"--config",
|
|
14
|
+
dest="vnode_file",
|
|
15
|
+
default="node.json",
|
|
16
|
+
help="Path to node.json",
|
|
17
|
+
)
|
|
18
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
|
|
20
|
+
subparsers.add_parser("run", help="Run the virtual node listener and nodeinfo broadcaster")
|
|
21
|
+
|
|
22
|
+
send_text = subparsers.add_parser("send-text", help="Send a text message")
|
|
23
|
+
send_text.add_argument("--to", required=True, help="Destination node id, name, or hex suffix")
|
|
24
|
+
send_text.add_argument("--message", required=True, help="Text to send")
|
|
25
|
+
send_text.add_argument(
|
|
26
|
+
"--pki",
|
|
27
|
+
choices=("auto", "on", "off"),
|
|
28
|
+
default="auto",
|
|
29
|
+
help="PKI mode for direct messages",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
send_nodeinfo = subparsers.add_parser("send-nodeinfo", help="Broadcast or unicast nodeinfo")
|
|
33
|
+
send_nodeinfo.add_argument(
|
|
34
|
+
"--to",
|
|
35
|
+
default="!ffffffff",
|
|
36
|
+
help="Destination node id; default is broadcast",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return parser
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
43
|
+
parser = build_parser()
|
|
44
|
+
args = parser.parse_args(argv)
|
|
45
|
+
|
|
46
|
+
if args.command == "run":
|
|
47
|
+
node = VirtualNode(args.vnode_file)
|
|
48
|
+
node.run_forever()
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
if args.command == "send-text":
|
|
52
|
+
node = VirtualNode(args.vnode_file)
|
|
53
|
+
packet_id = node.send_text(args.to, args.message, pki_mode=args.pki)
|
|
54
|
+
print(packet_id)
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
if args.command == "send-nodeinfo":
|
|
58
|
+
node = VirtualNode(args.vnode_file)
|
|
59
|
+
packet_id = node.send_nodeinfo(destination=node._resolve_destination(args.to))
|
|
60
|
+
print(packet_id)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
parser.error(f"Unknown command {args.command}")
|
|
64
|
+
return 2
|
vnode/config.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class BroadcastConfig:
|
|
12
|
+
send_startup_nodeinfo: bool = True
|
|
13
|
+
nodeinfo_interval_seconds: int = 900
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PositionConfig:
|
|
18
|
+
enabled: bool = False
|
|
19
|
+
latitude: Optional[float] = None
|
|
20
|
+
longitude: Optional[float] = None
|
|
21
|
+
altitude: Optional[int] = None
|
|
22
|
+
position_interval_seconds: int = 900
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ChannelConfig:
|
|
27
|
+
name: str = "LongFast"
|
|
28
|
+
psk: str = "AQ=="
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class UdpConfig:
|
|
33
|
+
mcast_group: str = "224.0.0.69"
|
|
34
|
+
mcast_port: int = 4403
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class MeshDbConfig:
|
|
39
|
+
path: str = "./data"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class SecurityConfig:
|
|
44
|
+
public_key: str = ""
|
|
45
|
+
private_key: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class NodeConfig:
|
|
50
|
+
node_id: str = ""
|
|
51
|
+
long_name: str = "Virtual Meshtastic Node"
|
|
52
|
+
short_name: str = "VND"
|
|
53
|
+
hw_model: Union[str, int] = "ANDROID_SIM"
|
|
54
|
+
role: Union[str, int] = "CLIENT"
|
|
55
|
+
is_licensed: bool = False
|
|
56
|
+
hop_limit: int = 3
|
|
57
|
+
broadcasts: BroadcastConfig = field(default_factory=BroadcastConfig)
|
|
58
|
+
position: PositionConfig = field(default_factory=PositionConfig)
|
|
59
|
+
channel: ChannelConfig = field(default_factory=ChannelConfig)
|
|
60
|
+
udp: UdpConfig = field(default_factory=UdpConfig)
|
|
61
|
+
meshdb: MeshDbConfig = field(default_factory=MeshDbConfig)
|
|
62
|
+
security: SecurityConfig = field(default_factory=SecurityConfig)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def load(cls, path: Union[str, Path]) -> "NodeConfig":
|
|
66
|
+
config_path = Path(path)
|
|
67
|
+
cls.ensure_exists(config_path)
|
|
68
|
+
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
69
|
+
changed = cls._populate_generated_defaults(payload)
|
|
70
|
+
if changed:
|
|
71
|
+
config_path.write_text(
|
|
72
|
+
json.dumps(payload, indent=2, sort_keys=False) + "\n",
|
|
73
|
+
encoding="utf-8",
|
|
74
|
+
)
|
|
75
|
+
security_payload = dict(payload.get("security", {}))
|
|
76
|
+
security_payload.pop("enabled", None)
|
|
77
|
+
return cls(
|
|
78
|
+
node_id=str(payload.get("node_id", cls.node_id)),
|
|
79
|
+
long_name=str(payload.get("long_name", cls.long_name)),
|
|
80
|
+
short_name=str(payload.get("short_name", cls.short_name)),
|
|
81
|
+
hw_model=payload.get("hw_model", cls.hw_model),
|
|
82
|
+
role=payload.get("role", cls.role),
|
|
83
|
+
is_licensed=bool(payload.get("is_licensed", cls.is_licensed)),
|
|
84
|
+
hop_limit=int(payload.get("hop_limit", cls.hop_limit)),
|
|
85
|
+
broadcasts=BroadcastConfig(**payload.get("broadcasts", {})),
|
|
86
|
+
position=PositionConfig(**payload.get("position", {})),
|
|
87
|
+
channel=ChannelConfig(**payload.get("channel", {})),
|
|
88
|
+
udp=UdpConfig(**payload.get("udp", {})),
|
|
89
|
+
meshdb=MeshDbConfig(**payload.get("meshdb", {})),
|
|
90
|
+
security=SecurityConfig(**security_payload),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _example_config_candidates(config_path: Path) -> List[Path]:
|
|
95
|
+
package_root = Path(__file__).resolve().parent
|
|
96
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
97
|
+
return [
|
|
98
|
+
config_path.with_name("example-node.json"),
|
|
99
|
+
package_root / "example-node.json",
|
|
100
|
+
repo_root / "example-node.json",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def ensure_exists(cls, path: Union[str, Path]) -> None:
|
|
105
|
+
config_path = Path(path)
|
|
106
|
+
if config_path.exists():
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
template_path = next(
|
|
110
|
+
(candidate for candidate in cls._example_config_candidates(config_path) if candidate.exists()),
|
|
111
|
+
None,
|
|
112
|
+
)
|
|
113
|
+
if template_path is None:
|
|
114
|
+
raise FileNotFoundError(
|
|
115
|
+
f"Missing config {config_path} and could not find example-node.json to copy from"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
payload = json.loads(template_path.read_text(encoding="utf-8"))
|
|
120
|
+
cls._populate_generated_defaults(payload)
|
|
121
|
+
security = payload.setdefault("security", {})
|
|
122
|
+
security.pop("public_key", None)
|
|
123
|
+
if not str(security.get("private_key", "")).strip():
|
|
124
|
+
from .crypto import b64_encode, generate_keypair
|
|
125
|
+
|
|
126
|
+
_public_key, private_key = generate_keypair()
|
|
127
|
+
security["private_key"] = b64_encode(private_key)
|
|
128
|
+
|
|
129
|
+
config_path.write_text(
|
|
130
|
+
json.dumps(payload, indent=2, sort_keys=False) + "\n",
|
|
131
|
+
encoding="utf-8",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _generate_node_id() -> str:
|
|
136
|
+
while True:
|
|
137
|
+
value = secrets.randbits(32)
|
|
138
|
+
if value not in (0, 0xFFFFFFFF):
|
|
139
|
+
return f"!{value:08x}"
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def _populate_generated_defaults(cls, payload: Dict[str, Any]) -> bool:
|
|
143
|
+
changed = False
|
|
144
|
+
if not str(payload.get("node_id", "")).strip():
|
|
145
|
+
payload["node_id"] = cls._generate_node_id()
|
|
146
|
+
changed = True
|
|
147
|
+
return changed
|
|
148
|
+
|
|
149
|
+
def save(self, path: Union[str, Path]) -> None:
|
|
150
|
+
config_path = Path(path)
|
|
151
|
+
config_path.write_text(
|
|
152
|
+
json.dumps(self.to_dict(), indent=2, sort_keys=False) + "\n",
|
|
153
|
+
encoding="utf-8",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
157
|
+
data = asdict(self)
|
|
158
|
+
security = data.get("security")
|
|
159
|
+
if isinstance(security, dict):
|
|
160
|
+
security.pop("public_key", None)
|
|
161
|
+
return data
|
vnode/crypto.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import secrets
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
from typing import Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from cryptography.hazmat.primitives import serialization
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import (
|
|
11
|
+
X25519PrivateKey,
|
|
12
|
+
X25519PublicKey,
|
|
13
|
+
)
|
|
14
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESCCM
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def b64_encode(data: bytes) -> str:
|
|
18
|
+
return base64.b64encode(data).decode("ascii")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def b64_decode(text: str) -> bytes:
|
|
22
|
+
return base64.b64decode(text.encode("ascii"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def generate_keypair() -> Tuple[bytes, bytes]:
|
|
26
|
+
private_key = X25519PrivateKey.generate()
|
|
27
|
+
public_key = private_key.public_key().public_bytes(
|
|
28
|
+
encoding=serialization.Encoding.Raw,
|
|
29
|
+
format=serialization.PublicFormat.Raw,
|
|
30
|
+
)
|
|
31
|
+
private_bytes = private_key.private_bytes(
|
|
32
|
+
encoding=serialization.Encoding.Raw,
|
|
33
|
+
format=serialization.PrivateFormat.Raw,
|
|
34
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
35
|
+
)
|
|
36
|
+
return public_key, private_bytes
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def derive_public_key(private_key: bytes) -> bytes:
|
|
40
|
+
return X25519PrivateKey.from_private_bytes(private_key).public_key().public_bytes(
|
|
41
|
+
encoding=serialization.Encoding.Raw,
|
|
42
|
+
format=serialization.PublicFormat.Raw,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_nonce(packet_id: int, from_node: int, extra_nonce: int) -> bytes:
|
|
47
|
+
nonce = bytearray(16)
|
|
48
|
+
nonce[0:8] = int(packet_id).to_bytes(8, "little", signed=False)
|
|
49
|
+
nonce[8:12] = int(from_node).to_bytes(4, "little", signed=False)
|
|
50
|
+
if extra_nonce:
|
|
51
|
+
nonce[4:8] = int(extra_nonce).to_bytes(4, "little", signed=False)
|
|
52
|
+
return bytes(nonce[:13])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_shared_key(private_key: bytes, public_key: bytes) -> bytes:
|
|
56
|
+
shared = X25519PrivateKey.from_private_bytes(private_key).exchange(
|
|
57
|
+
X25519PublicKey.from_public_bytes(public_key)
|
|
58
|
+
)
|
|
59
|
+
return sha256(shared).digest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class PkiEnvelope:
|
|
64
|
+
ciphertext: bytes
|
|
65
|
+
tag: bytes
|
|
66
|
+
extra_nonce: int
|
|
67
|
+
|
|
68
|
+
def pack(self) -> bytes:
|
|
69
|
+
return self.ciphertext + self.tag + self.extra_nonce.to_bytes(4, "little", signed=False)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def encrypt_dm(
|
|
73
|
+
*,
|
|
74
|
+
sender_private_key: bytes,
|
|
75
|
+
receiver_public_key: bytes,
|
|
76
|
+
packet_id: int,
|
|
77
|
+
from_node: int,
|
|
78
|
+
plaintext: bytes,
|
|
79
|
+
extra_nonce: Optional[int] = None,
|
|
80
|
+
) -> bytes:
|
|
81
|
+
nonce_value = secrets.randbits(32) if extra_nonce is None else int(extra_nonce) & 0xFFFFFFFF
|
|
82
|
+
key = build_shared_key(sender_private_key, receiver_public_key)
|
|
83
|
+
nonce = build_nonce(packet_id, from_node, nonce_value)
|
|
84
|
+
encrypted = AESCCM(key, tag_length=8).encrypt(nonce, plaintext, None)
|
|
85
|
+
return PkiEnvelope(encrypted[:-8], encrypted[-8:], nonce_value).pack()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def decrypt_dm(
|
|
89
|
+
*,
|
|
90
|
+
receiver_private_key: bytes,
|
|
91
|
+
sender_public_key: bytes,
|
|
92
|
+
packet_id: int,
|
|
93
|
+
from_node: int,
|
|
94
|
+
payload: bytes,
|
|
95
|
+
) -> bytes:
|
|
96
|
+
if len(payload) < 12:
|
|
97
|
+
raise ValueError("PKI payload too short")
|
|
98
|
+
ciphertext = payload[:-12]
|
|
99
|
+
tag = payload[-12:-4]
|
|
100
|
+
extra_nonce = int.from_bytes(payload[-4:], "little", signed=False)
|
|
101
|
+
key = build_shared_key(receiver_private_key, sender_public_key)
|
|
102
|
+
nonce = build_nonce(packet_id, from_node, extra_nonce)
|
|
103
|
+
return AESCCM(key, tag_length=8).decrypt(nonce, ciphertext + tag, None)
|
vnode/example-node.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"node_id": "",
|
|
3
|
+
"long_name": "Virtual Meshtastic Node",
|
|
4
|
+
"short_name": "VND",
|
|
5
|
+
"hw_model": "ANDROID_SIM",
|
|
6
|
+
"role": "CLIENT",
|
|
7
|
+
"is_licensed": false,
|
|
8
|
+
"hop_limit": 3,
|
|
9
|
+
"broadcasts": {
|
|
10
|
+
"send_startup_nodeinfo": true,
|
|
11
|
+
"nodeinfo_interval_seconds": 900
|
|
12
|
+
},
|
|
13
|
+
"position": {
|
|
14
|
+
"enabled": false,
|
|
15
|
+
"latitude": null,
|
|
16
|
+
"longitude": null,
|
|
17
|
+
"altitude": null,
|
|
18
|
+
"position_interval_seconds": 900
|
|
19
|
+
},
|
|
20
|
+
"channel": {
|
|
21
|
+
"name": "LongFast",
|
|
22
|
+
"psk": "AQ=="
|
|
23
|
+
},
|
|
24
|
+
"udp": {
|
|
25
|
+
"mcast_group": "224.0.0.69",
|
|
26
|
+
"mcast_port": 4403
|
|
27
|
+
},
|
|
28
|
+
"meshdb": {
|
|
29
|
+
"path": "./data"
|
|
30
|
+
},
|
|
31
|
+
"security": {
|
|
32
|
+
"private_key": ""
|
|
33
|
+
}
|
|
34
|
+
}
|