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 +1 -0
- remctl/__main__.py +4 -0
- remctl/audit.py +50 -0
- remctl/cli.py +265 -0
- remctl/config.py +84 -0
- remctl/crypto.py +148 -0
- remctl/ctl/__init__.py +0 -0
- remctl/ctl/client.py +134 -0
- remctl/ctl/commands.py +234 -0
- remctl/ctl/pairing.py +163 -0
- remctl/discovery.py +122 -0
- remctl/node/__init__.py +0 -0
- remctl/node/daemon.py +232 -0
- remctl/node/handlers.py +198 -0
- remctl/node/service_linux.py +64 -0
- remctl/node/service_windows.py +77 -0
- remctl/platform/__init__.py +27 -0
- remctl/platform/base.py +45 -0
- remctl/platform/linux.py +98 -0
- remctl/platform/macos.py +35 -0
- remctl/platform/windows.py +85 -0
- remctl/protocol.py +96 -0
- remctl-0.1.0.dist-info/METADATA +172 -0
- remctl-0.1.0.dist-info/RECORD +27 -0
- remctl-0.1.0.dist-info/WHEEL +4 -0
- remctl-0.1.0.dist-info/entry_points.txt +2 -0
- remctl-0.1.0.dist-info/licenses/LICENSE +21 -0
remctl/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
remctl/__main__.py
ADDED
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
|