remctl 0.1.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.
remctl/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
remctl/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from remctl.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
remctl/audit.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from logging.handlers import RotatingFileHandler
6
+
7
+ from remctl.crypto import KEY_DIR
8
+
9
+ AUDIT_LOG = KEY_DIR / "audit.log"
10
+
11
+ _logger: logging.Logger | None = None
12
+
13
+
14
+ def get_logger(max_bytes: int = 10 * 1024 * 1024, backup_count: int = 5) -> logging.Logger:
15
+ global _logger
16
+ if _logger is not None:
17
+ return _logger
18
+ KEY_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
19
+ _logger = logging.getLogger("remctl.audit")
20
+ _logger.setLevel(logging.INFO)
21
+ handler = RotatingFileHandler(
22
+ str(AUDIT_LOG),
23
+ maxBytes=max_bytes,
24
+ backupCount=backup_count,
25
+ encoding="utf-8",
26
+ )
27
+ handler.setFormatter(logging.Formatter("%(message)s"))
28
+ _logger.addHandler(handler)
29
+ _logger.propagate = False
30
+ return _logger
31
+
32
+
33
+ def log_command(
34
+ source_fingerprint: str,
35
+ command: str,
36
+ args: dict,
37
+ outcome: str,
38
+ detail: str | None = None,
39
+ ) -> None:
40
+ logger = get_logger()
41
+ record = {
42
+ "timestamp": __import__("time").time(),
43
+ "source": source_fingerprint,
44
+ "command": command,
45
+ "args": args,
46
+ "outcome": outcome,
47
+ }
48
+ if detail:
49
+ record["detail"] = detail
50
+ logger.info(json.dumps(record, default=str))
remctl/cli.py ADDED
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import sys
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from remctl import __version__
11
+
12
+ console = Console()
13
+ err_console = Console(stderr=True)
14
+
15
+ app = typer.Typer(
16
+ name="remctl",
17
+ help="Secure LAN-based remote PC control and administration",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+ node_app = typer.Typer(help="Node agent commands")
22
+ ctl_app = typer.Typer(help="Client/controller commands")
23
+ app.add_typer(node_app, name="node")
24
+ app.add_typer(ctl_app, name="ctl")
25
+
26
+
27
+ def _version_callback(value: bool):
28
+ if value:
29
+ console.print(f"remctl v{__version__}")
30
+ raise typer.Exit()
31
+
32
+
33
+ @app.callback()
34
+ def main(
35
+ ctx: typer.Context,
36
+ version: bool = typer.Option(False, "--version", "-V",
37
+ callback=_version_callback, help="Show version"),
38
+ debug: bool = typer.Option(False, "--debug", help="Enable debug logging"),
39
+ ):
40
+ if debug:
41
+ logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(name)s: %(message)s")
42
+ else:
43
+ logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
44
+
45
+
46
+ # ── node subcommands ──────────────────────────────────────────────
47
+
48
+
49
+ @node_app.command("start")
50
+ def node_start(
51
+ bind: str = typer.Option("0.0.0.0", "--bind", "-b", help="Address to bind to"),
52
+ port: int = typer.Option(8765, "--port", "-p", help="Port to listen on"),
53
+ ):
54
+ """Run the node agent in the foreground"""
55
+ from remctl.node.daemon import run_daemon
56
+
57
+ try:
58
+ asyncio.run(run_daemon(bind, port))
59
+ except KeyboardInterrupt:
60
+ console.print("\n[yellow]Node daemon stopped[/yellow]")
61
+
62
+
63
+ @node_app.command("install-service")
64
+ def node_install_service():
65
+ """Install the node as an OS service"""
66
+ if sys.platform == "win32":
67
+ from remctl.node.service_windows import install_service
68
+ install_service()
69
+ elif sys.platform == "linux":
70
+ from remctl.node.service_linux import install_service
71
+ install_service()
72
+ else:
73
+ err_console.print("[red]Service installation not supported on this platform[/red]")
74
+ raise typer.Exit(1)
75
+
76
+
77
+ @node_app.command("uninstall-service")
78
+ def node_uninstall_service():
79
+ """Uninstall the OS service"""
80
+ if sys.platform == "win32":
81
+ from remctl.node.service_windows import uninstall_service
82
+ uninstall_service()
83
+ elif sys.platform == "linux":
84
+ from remctl.node.service_linux import uninstall_service
85
+ uninstall_service()
86
+ else:
87
+ err_console.print("[red]Service uninstallation not supported on this platform[/red]")
88
+ raise typer.Exit(1)
89
+
90
+
91
+ @node_app.command("pair")
92
+ def node_pair(
93
+ show: bool = typer.Option(False, "--show", help="Show this node's fingerprint"),
94
+ ):
95
+ """Show node identity or manage pairing"""
96
+ from remctl.crypto import fingerprint, load_or_generate_keypair
97
+
98
+ _, pub = load_or_generate_keypair()
99
+ fp = fingerprint(pub)
100
+ hostname = __import__("platform").node()
101
+ console.print(f"Node fingerprint: [yellow]{fp}[/yellow]")
102
+ console.print(f"Hostname: [cyan]{hostname}[/cyan]")
103
+ console.print("\nTo pair a controller, run on the controller:")
104
+ console.print(" remctl ctl pair <node-ip>")
105
+
106
+
107
+ @node_app.command("trust-controller")
108
+ def node_trust_controller(
109
+ pubkey_source: str = typer.Argument(..., help="Public key hex string or path to .pub file"),
110
+ ):
111
+ """Trust a controller's public key"""
112
+ from remctl.ctl.pairing import trust_controller
113
+ trust_controller(pubkey_source)
114
+
115
+
116
+ @node_app.command("list-controllers")
117
+ def node_list_controllers():
118
+ """List trusted controllers"""
119
+ from remctl.ctl.pairing import list_trusted_controllers
120
+ list_trusted_controllers()
121
+
122
+
123
+ # ── ctl subcommands ────────────────────────────────────────────────
124
+
125
+
126
+ @ctl_app.command("discover")
127
+ def ctl_discover():
128
+ """Discover nodes on the LAN"""
129
+ from remctl.ctl.commands import cmd_discover
130
+ cmd_discover()
131
+
132
+
133
+ @ctl_app.command("pair")
134
+ def ctl_pair(
135
+ target: str = typer.Argument(..., help="Node hostname or IP address"),
136
+ port: int = typer.Option(8765, "--port", "-p", help="Node port"),
137
+ ):
138
+ """Pair with a node (manual fingerprint confirmation)"""
139
+ from remctl.ctl.pairing import pair_with_node
140
+ asyncio.run(pair_with_node(target, port))
141
+
142
+
143
+ @ctl_app.command("pair-show")
144
+ def ctl_pair_show():
145
+ """Show this controller's fingerprint for pairing"""
146
+ from remctl.crypto import fingerprint, load_or_generate_keypair
147
+ _, pub = load_or_generate_keypair()
148
+ fp = fingerprint(pub)
149
+ console.print(f"Controller fingerprint: [yellow]{fp}[/yellow]")
150
+ pub_path = __import__("pathlib").Path.home() / ".remctl" / "identity.pub"
151
+ if pub_path.exists():
152
+ console.print(f"Public key file: {pub_path}")
153
+
154
+
155
+ @ctl_app.command("lock")
156
+ def ctl_lock(target: str):
157
+ """Lock the remote machine"""
158
+ from remctl.ctl.commands import cmd_lock
159
+ cmd_lock(target)
160
+
161
+
162
+ @ctl_app.command("shutdown")
163
+ def ctl_shutdown(
164
+ target: str = typer.Argument(..., help="Node target (name, IP, or fingerprint)"),
165
+ delay: int = typer.Option(0, "--delay", "-d", help="Delay in seconds before shutdown"),
166
+ force: bool = typer.Option(False, "--force", "-f", help="Force shutdown"),
167
+ ):
168
+ """Shut down the remote machine"""
169
+ from remctl.ctl.commands import cmd_shutdown
170
+ cmd_shutdown(target, delay, force)
171
+
172
+
173
+ @ctl_app.command("restart")
174
+ def ctl_restart(
175
+ target: str = typer.Argument(..., help="Node target"),
176
+ delay: int = typer.Option(0, "--delay", "-d", help="Delay in seconds"),
177
+ ):
178
+ """Restart the remote machine"""
179
+ from remctl.ctl.commands import cmd_restart
180
+ cmd_restart(target, delay)
181
+
182
+
183
+ @ctl_app.command("sleep")
184
+ def ctl_sleep(target: str):
185
+ """Put the remote machine to sleep"""
186
+ from remctl.ctl.commands import cmd_sleep
187
+ cmd_sleep(target)
188
+
189
+
190
+ @ctl_app.command("logout")
191
+ def ctl_logout(target: str):
192
+ """Log out the current user on the remote machine"""
193
+ from remctl.ctl.commands import cmd_logout
194
+ cmd_logout(target)
195
+
196
+
197
+ @ctl_app.command("closefocused")
198
+ def ctl_closefocused(target: str):
199
+ """Close the active/focused window on the remote machine"""
200
+ from remctl.ctl.commands import cmd_closefocused
201
+ cmd_closefocused(target)
202
+
203
+
204
+ @ctl_app.command("cancel")
205
+ def ctl_cancel(target: str):
206
+ """Cancel a pending shutdown/restart"""
207
+ from remctl.ctl.commands import cmd_cancel
208
+ cmd_cancel(target)
209
+
210
+
211
+ @ctl_app.command("exec")
212
+ def ctl_exec(
213
+ target: str = typer.Argument(..., help="Node target"),
214
+ command: str = typer.Argument(..., help="Shell command to execute"),
215
+ timeout: int = typer.Option(60, "--timeout", "-t", help="Timeout in seconds"),
216
+ ):
217
+ """Execute a shell command on the remote machine with streamed output"""
218
+ from remctl.ctl.commands import cmd_exec
219
+ cmd_exec(target, command, timeout)
220
+
221
+
222
+ @ctl_app.command("screenshot")
223
+ def ctl_screenshot(
224
+ target: str = typer.Argument(..., help="Node target"),
225
+ output: str = typer.Option("screenshot.png", "--output", "-o", help="Output file path"),
226
+ ):
227
+ """Capture a screenshot of the remote machine"""
228
+ from remctl.ctl.commands import cmd_screenshot
229
+ cmd_screenshot(target, output)
230
+
231
+
232
+ @ctl_app.command("status")
233
+ def ctl_status(target: str):
234
+ """Show system status of the remote machine"""
235
+ from remctl.ctl.commands import cmd_status
236
+ cmd_status(target)
237
+
238
+
239
+ @ctl_app.command("push")
240
+ def ctl_push(
241
+ target: str = typer.Argument(..., help="Node target"),
242
+ source: str = typer.Argument(..., help="Local source file path"),
243
+ dest: str = typer.Argument(..., help="Remote destination path"),
244
+ ):
245
+ """Push a file to the remote machine"""
246
+ from remctl.ctl.commands import cmd_push
247
+ cmd_push(target, source, dest)
248
+
249
+
250
+ @ctl_app.command("pull")
251
+ def ctl_pull(
252
+ target: str = typer.Argument(..., help="Node target"),
253
+ remote_path: str = typer.Argument(..., help="Remote file path"),
254
+ output: str = typer.Option(None, "--output", "-o", help="Local output path (defaults to filename)"),
255
+ ):
256
+ """Pull a file from the remote machine"""
257
+ from remctl.ctl.commands import cmd_pull
258
+ cmd_pull(target, remote_path, output)
259
+
260
+
261
+ @ctl_app.command("known-nodes")
262
+ def ctl_known_nodes():
263
+ """List paired nodes"""
264
+ from remctl.ctl.commands import cmd_known_nodes
265
+ cmd_known_nodes()
remctl/config.py ADDED
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from remctl.crypto import KEY_DIR
8
+
9
+ CONFIG_DIR = KEY_DIR
10
+ NODE_CONFIG_FILE = CONFIG_DIR / "node_config.json"
11
+ KNOWN_NODES_FILE = CONFIG_DIR / "known_nodes.json"
12
+ KNOWN_CONTROLLERS_FILE = CONFIG_DIR / "known_controllers.json"
13
+
14
+
15
+ PermissionTier = str | None # "observe", "standard", "full"
16
+
17
+
18
+ class KnownPeer(BaseModel):
19
+ fingerprint: str
20
+ pubkey_hex: str
21
+ hostname: str = ""
22
+ address: str = ""
23
+ port: int = 0
24
+ tier: str = "standard"
25
+
26
+
27
+ class NodeConfig(BaseModel):
28
+ bind_address: str = "0.0.0.0"
29
+ port: int = 8765
30
+ discovery_port: int = 8766
31
+ controllers: list[str] = Field(default_factory=list)
32
+ controller_tiers: dict[str, str] = Field(default_factory=dict)
33
+ audit_max_bytes: int = 10 * 1024 * 1024
34
+ audit_backup_count: int = 5
35
+
36
+
37
+ def _ensure_config_dir() -> None:
38
+ CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
39
+
40
+
41
+ def load_node_config() -> NodeConfig:
42
+ _ensure_config_dir()
43
+ if NODE_CONFIG_FILE.exists():
44
+ data = json.loads(NODE_CONFIG_FILE.read_text())
45
+ return NodeConfig(**data)
46
+ cfg = NodeConfig()
47
+ save_node_config(cfg)
48
+ return cfg
49
+
50
+
51
+ def save_node_config(cfg: NodeConfig) -> None:
52
+ _ensure_config_dir()
53
+ NODE_CONFIG_FILE.write_text(cfg.model_dump_json(indent=2))
54
+
55
+
56
+ def load_known_nodes() -> dict[str, KnownPeer]:
57
+ if KNOWN_NODES_FILE.exists():
58
+ data = json.loads(KNOWN_NODES_FILE.read_text())
59
+ return {k: KnownPeer(**v) for k, v in data.items()}
60
+ return {}
61
+
62
+
63
+ def save_known_nodes(nodes: dict[str, KnownPeer]) -> None:
64
+ _ensure_config_dir()
65
+ data = {k: v.model_dump() for k, v in nodes.items()}
66
+ KNOWN_NODES_FILE.write_text(json.dumps(data, indent=2))
67
+
68
+
69
+ def load_known_controllers() -> dict[str, KnownPeer]:
70
+ if KNOWN_CONTROLLERS_FILE.exists():
71
+ data = json.loads(KNOWN_CONTROLLERS_FILE.read_text())
72
+ return {k: KnownPeer(**v) for k, v in data.items()}
73
+ return {}
74
+
75
+
76
+ def save_known_controllers(controllers: dict[str, KnownPeer]) -> None:
77
+ _ensure_config_dir()
78
+ data = {k: v.model_dump() for k, v in controllers.items()}
79
+ KNOWN_CONTROLLERS_FILE.write_text(json.dumps(data, indent=2))
80
+
81
+
82
+ def get_controller_tier(fingerprint: str) -> str:
83
+ cfg = load_node_config()
84
+ return cfg.controller_tiers.get(fingerprint, "standard")
remctl/crypto.py ADDED
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import secrets
6
+ from pathlib import Path
7
+
8
+ from cryptography.hazmat.primitives import hashes, serialization
9
+ from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
10
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
11
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
12
+ from typing import Union
13
+
14
+ KEY_DIR = Path.home() / ".remctl"
15
+ KEY_FILE = KEY_DIR / "identity.key"
16
+ PUB_FILE = KEY_DIR / "identity.pub"
17
+
18
+
19
+ def _ensure_key_dir() -> None:
20
+ KEY_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
21
+
22
+
23
+ def _restrict_key_permissions(path: Path) -> None:
24
+ try:
25
+ import stat
26
+ current = os.stat(path)
27
+ if current.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
28
+ os.chmod(path, current.st_mode & ~(stat.S_IRWXG | stat.S_IRWXO))
29
+ except (OSError, AttributeError):
30
+ pass
31
+
32
+
33
+ def generate_keypair() -> tuple[ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey]:
34
+ private = ed25519.Ed25519PrivateKey.generate()
35
+ public = private.public_key()
36
+ return private, public
37
+
38
+
39
+ def load_or_generate_keypair() -> tuple[ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey]:
40
+ _ensure_key_dir()
41
+ if KEY_FILE.exists() and PUB_FILE.exists():
42
+ private = load_private_key(KEY_FILE)
43
+ public = load_public_key(PUB_FILE)
44
+ return private, public
45
+ private, public = generate_keypair()
46
+ save_keypair(private, public)
47
+ return private, public
48
+
49
+
50
+ def save_keypair(private: ed25519.Ed25519PrivateKey, public: ed25519.Ed25519PublicKey) -> None:
51
+ _ensure_key_dir()
52
+ private_bytes = private.private_bytes(
53
+ encoding=serialization.Encoding.Raw,
54
+ format=serialization.PrivateFormat.Raw,
55
+ encryption_algorithm=serialization.NoEncryption(),
56
+ )
57
+ KEY_FILE.write_bytes(private_bytes)
58
+ _restrict_key_permissions(KEY_FILE)
59
+ public_bytes = public.public_bytes(
60
+ encoding=serialization.Encoding.Raw,
61
+ format=serialization.PublicFormat.Raw,
62
+ )
63
+ PUB_FILE.write_bytes(public_bytes)
64
+
65
+
66
+ def load_private_key(path: Path = KEY_FILE) -> ed25519.Ed25519PrivateKey:
67
+ data = path.read_bytes()
68
+ return ed25519.Ed25519PrivateKey.from_private_bytes(data)
69
+
70
+
71
+ def load_public_key(path: Path = PUB_FILE) -> ed25519.Ed25519PublicKey:
72
+ data = path.read_bytes()
73
+ return ed25519.Ed25519PublicKey.from_public_bytes(data)
74
+
75
+
76
+ def public_key_bytes(pub: Union[ed25519.Ed25519PublicKey, x25519.X25519PublicKey]) -> bytes:
77
+ return pub.public_bytes(
78
+ encoding=serialization.Encoding.Raw,
79
+ format=serialization.PublicFormat.Raw,
80
+ )
81
+
82
+
83
+ def fingerprint(pub: ed25519.Ed25519PublicKey) -> str:
84
+ raw = public_key_bytes(pub)
85
+ digest = hashlib.sha256(raw).digest()
86
+ return ":".join(f"{b:02x}" for b in digest)
87
+
88
+
89
+ def fingerprint_from_bytes(pub_bytes: bytes) -> str:
90
+ digest = hashlib.sha256(pub_bytes).digest()
91
+ return ":".join(f"{b:02x}" for b in digest)
92
+
93
+
94
+ def sign(data: bytes, private: ed25519.Ed25519PrivateKey) -> bytes:
95
+ return private.sign(data)
96
+
97
+
98
+ def verify(data: bytes, signature: bytes, public: ed25519.Ed25519PublicKey) -> bool:
99
+ try:
100
+ public.verify(signature, data)
101
+ return True
102
+ except Exception:
103
+ return False
104
+
105
+
106
+ def generate_ephemeral_keypair() -> tuple[x25519.X25519PrivateKey, x25519.X25519PublicKey]:
107
+ private = x25519.X25519PrivateKey.generate()
108
+ public = private.public_key()
109
+ return private, public
110
+
111
+
112
+ def derive_session_key(
113
+ private_ephemeral: x25519.X25519PrivateKey,
114
+ peer_ephemeral: x25519.X25519PublicKey,
115
+ self_long_term_pub: ed25519.Ed25519PublicKey,
116
+ peer_long_term_pub: ed25519.Ed25519PublicKey,
117
+ ) -> bytes:
118
+ shared_secret = private_ephemeral.exchange(peer_ephemeral)
119
+ self_bytes = public_key_bytes(self_long_term_pub)
120
+ peer_bytes = public_key_bytes(peer_long_term_pub)
121
+ sorted_keys = b"".join(sorted([self_bytes, peer_bytes]))
122
+ info = b"remctl-session-key-v1"
123
+ hkdf = HKDF(
124
+ algorithm=hashes.SHA256(),
125
+ length=32,
126
+ salt=None,
127
+ info=info,
128
+ )
129
+ key = hkdf.derive(shared_secret + sorted_keys)
130
+ return key
131
+
132
+
133
+ class SecureChannel:
134
+ def __init__(self, session_key: bytes) -> None:
135
+ self._aesgcm = AESGCM(session_key)
136
+ self._seq_send = 0
137
+
138
+ def encrypt(self, plaintext: bytes) -> bytes:
139
+ nonce = secrets.token_bytes(12)
140
+ ciphertext = self._aesgcm.encrypt(nonce, plaintext, None)
141
+ return nonce + ciphertext
142
+
143
+ def decrypt(self, data: bytes) -> bytes:
144
+ if len(data) < 12:
145
+ raise ValueError("data too short to contain nonce")
146
+ nonce = data[:12]
147
+ ciphertext = data[12:]
148
+ return self._aesgcm.decrypt(nonce, ciphertext, None)
remctl/ctl/__init__.py ADDED
File without changes
remctl/ctl/client.py ADDED
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import AsyncGenerator
6
+ from typing import Any
7
+
8
+ from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
9
+
10
+ from remctl.crypto import (
11
+ SecureChannel,
12
+ derive_session_key,
13
+ generate_ephemeral_keypair,
14
+ load_or_generate_keypair,
15
+ public_key_bytes,
16
+ sign,
17
+ verify,
18
+ )
19
+ from remctl.protocol import Command, CommandType, Frame, Handshake, ResponseChunk
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class RemCtlClient:
25
+ def __init__(self, host: str = "127.0.0.1", port: int = 8765) -> None:
26
+ self.host = host
27
+ self.port = port
28
+ self._private_key, self._public_key = load_or_generate_keypair()
29
+ self._channel: SecureChannel | None = None
30
+ self._seq = 0
31
+ self._reader: asyncio.StreamReader | None = None
32
+ self._writer: asyncio.StreamWriter | None = None
33
+
34
+ async def connect(self) -> None:
35
+ r, w = await asyncio.wait_for(
36
+ asyncio.open_connection(self.host, self.port), timeout=10
37
+ )
38
+ self._reader = r
39
+ self._writer = w
40
+
41
+ ephemeral_private, ephemeral_public = generate_ephemeral_keypair()
42
+
43
+ hs = Handshake(
44
+ identity_pubkey=public_key_bytes(self._public_key),
45
+ ephemeral_pubkey=public_key_bytes(ephemeral_public),
46
+ signature=sign(public_key_bytes(ephemeral_public), self._private_key),
47
+ )
48
+ request_data = hs.model_dump_json().encode("utf-8")
49
+ w.write(Frame.encode(request_data))
50
+ await w.drain()
51
+
52
+ header = await asyncio.wait_for(
53
+ r.readexactly(Frame.HEADER_SIZE), timeout=10
54
+ )
55
+ length = Frame.decode_header(header)
56
+ response_data = await asyncio.wait_for(
57
+ r.readexactly(length), timeout=10
58
+ )
59
+
60
+ response_hs = Handshake.model_validate_json(response_data)
61
+
62
+ node_identity = ed25519.Ed25519PublicKey.from_public_bytes(
63
+ response_hs.identity_pubkey
64
+ )
65
+ node_ephemeral = x25519.X25519PublicKey.from_public_bytes(
66
+ response_hs.ephemeral_pubkey
67
+ )
68
+
69
+ if not verify(
70
+ response_hs.ephemeral_pubkey, response_hs.signature, node_identity
71
+ ):
72
+ raise ValueError("node signature verification failed")
73
+
74
+ session_key = derive_session_key(
75
+ ephemeral_private,
76
+ node_ephemeral,
77
+ self._public_key,
78
+ node_identity,
79
+ )
80
+
81
+ self._channel = SecureChannel(session_key)
82
+
83
+ async def send_command(
84
+ self, cmd: CommandType, args: dict[str, Any] | None = None
85
+ ) -> AsyncGenerator[dict[str, Any], None]:
86
+ ch = self._channel
87
+ w = self._writer
88
+ r = self._reader
89
+ if ch is None or w is None or r is None:
90
+ raise RuntimeError("not connected")
91
+
92
+ self._seq += 1
93
+ command = Command(
94
+ seq=self._seq,
95
+ cmd=cmd,
96
+ args=args or {},
97
+ )
98
+ plaintext = command.model_dump_json().encode("utf-8")
99
+ encrypted = ch.encrypt(plaintext)
100
+ w.write(Frame.encode(encrypted))
101
+ await w.drain()
102
+
103
+ while True:
104
+ try:
105
+ header = await asyncio.wait_for(
106
+ r.readexactly(Frame.HEADER_SIZE), timeout=300
107
+ )
108
+ except asyncio.IncompleteReadError:
109
+ break
110
+
111
+ length = Frame.decode_header(header)
112
+ encrypted_resp = await asyncio.wait_for(
113
+ r.readexactly(length), timeout=300
114
+ )
115
+
116
+ plaintext_resp = ch.decrypt(encrypted_resp)
117
+ chunk = ResponseChunk.model_validate_json(plaintext_resp)
118
+
119
+ result: dict[str, Any] = {
120
+ "seq": chunk.seq,
121
+ "type": chunk.type,
122
+ "data": chunk.data,
123
+ }
124
+ yield result
125
+
126
+ if chunk.type == "exit" or chunk.type == "error":
127
+ break
128
+
129
+ async def close(self) -> None:
130
+ if self._writer:
131
+ try:
132
+ self._writer.close()
133
+ except Exception:
134
+ pass