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 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
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
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)
@@ -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
+ }