remote-exec-grpc 0.2.1__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.
- remote_exec/__init__.py +10 -0
- remote_exec/claude_skill.md +118 -0
- remote_exec/client.py +242 -0
- remote_exec/client_cli.py +226 -0
- remote_exec/cmdlog.py +159 -0
- remote_exec/config.py +202 -0
- remote_exec/executor.py +287 -0
- remote_exec/policy.py +420 -0
- remote_exec/security.py +178 -0
- remote_exec/server.py +292 -0
- remote_exec/server_cli.py +219 -0
- remote_exec/skill.py +39 -0
- remote_exec/v1/__init__.py +1 -0
- remote_exec/v1/remote_exec_pb2.py +57 -0
- remote_exec/v1/remote_exec_pb2.pyi +108 -0
- remote_exec/v1/remote_exec_pb2_grpc.py +142 -0
- remote_exec_grpc-0.2.1.dist-info/METADATA +539 -0
- remote_exec_grpc-0.2.1.dist-info/RECORD +20 -0
- remote_exec_grpc-0.2.1.dist-info/WHEEL +4 -0
- remote_exec_grpc-0.2.1.dist-info/entry_points.txt +3 -0
remote_exec/server.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Async gRPC server: request validation, audit logging, and lifecycle.
|
|
2
|
+
|
|
3
|
+
The servicer is a thin adapter between protobuf messages and
|
|
4
|
+
:class:`remote_exec.executor.Executor`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import getpass
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import socket
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
import grpc
|
|
23
|
+
from google.protobuf.timestamp_pb2 import Timestamp
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
from .config import DEFAULT_HOST, DEFAULT_PORT, format_address
|
|
27
|
+
from .cmdlog import CommandLog
|
|
28
|
+
from .executor import CommandSpec, Executor, Finished, Output, Started, Status, Stream
|
|
29
|
+
from .policy import PolicyProvider
|
|
30
|
+
from .security import TokenAuthInterceptor, peer_identity, server_credentials
|
|
31
|
+
from .v1 import remote_exec_pb2 as pb
|
|
32
|
+
from .v1 import remote_exec_pb2_grpc as pb_grpc
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
audit = logging.getLogger("remote_exec.audit")
|
|
36
|
+
|
|
37
|
+
MAX_COMMAND_BYTES = 256 * 1024
|
|
38
|
+
MAX_ENV_ENTRIES = 1024
|
|
39
|
+
MAX_ENV_VALUE_BYTES = 128 * 1024
|
|
40
|
+
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
41
|
+
|
|
42
|
+
# Keepalive: lets long silent commands survive NAT/firewall idle timeouts and
|
|
43
|
+
# lets the server notice vanished clients (which cancels their commands).
|
|
44
|
+
SERVER_OPTIONS = [
|
|
45
|
+
("grpc.keepalive_time_ms", 60_000),
|
|
46
|
+
("grpc.keepalive_timeout_ms", 20_000),
|
|
47
|
+
("grpc.keepalive_permit_without_calls", 1),
|
|
48
|
+
("grpc.http2.min_recv_ping_interval_without_data_ms", 10_000),
|
|
49
|
+
("grpc.http2.max_ping_strikes", 0),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_shell() -> str:
|
|
54
|
+
return "/bin/bash" if os.path.exists("/bin/bash") else "/bin/sh"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class ServerSettings:
|
|
59
|
+
host: str = DEFAULT_HOST
|
|
60
|
+
port: int = DEFAULT_PORT
|
|
61
|
+
shell: str = ""
|
|
62
|
+
max_concurrent: int = 32
|
|
63
|
+
max_timeout: float | None = None
|
|
64
|
+
kill_grace: float = 5.0
|
|
65
|
+
token: str | None = None
|
|
66
|
+
tls_cert: str | None = None
|
|
67
|
+
tls_key: str | None = None
|
|
68
|
+
tls_client_ca: str | None = None
|
|
69
|
+
tools_file: str | None = None # None: default ~/.config/remote-exec/tools.yaml if present
|
|
70
|
+
tools_required: bool = False # error if tools_file is missing (explicit --tools)
|
|
71
|
+
command_log: str | None = None # file recording commands and their output; None = off
|
|
72
|
+
command_log_max_mb: float = 10.0 # rotate to a new file beyond this size
|
|
73
|
+
command_log_keep: int = 0 # rotated files to keep; 0 = all
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def address(self) -> str:
|
|
77
|
+
return format_address(self.host, self.port)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RequestError(ValueError):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_request(req: pb.ExecuteRequest, max_timeout: float | None) -> CommandSpec:
|
|
85
|
+
command = req.command
|
|
86
|
+
if not command.strip():
|
|
87
|
+
raise RequestError("command must not be empty")
|
|
88
|
+
if len(command.encode()) > MAX_COMMAND_BYTES:
|
|
89
|
+
raise RequestError(f"command exceeds {MAX_COMMAND_BYTES} bytes")
|
|
90
|
+
if "\x00" in command:
|
|
91
|
+
raise RequestError("command must not contain NUL bytes")
|
|
92
|
+
|
|
93
|
+
cwd = None
|
|
94
|
+
if req.working_directory:
|
|
95
|
+
if "\x00" in req.working_directory:
|
|
96
|
+
raise RequestError("working_directory must not contain NUL bytes")
|
|
97
|
+
cwd = os.path.expanduser(req.working_directory)
|
|
98
|
+
if not os.path.isabs(cwd):
|
|
99
|
+
raise RequestError(f"working_directory must be an absolute path (or start with ~): {req.working_directory!r}")
|
|
100
|
+
|
|
101
|
+
env = dict(req.environment)
|
|
102
|
+
if len(env) > MAX_ENV_ENTRIES:
|
|
103
|
+
raise RequestError(f"too many environment variables (max {MAX_ENV_ENTRIES})")
|
|
104
|
+
for key, value in env.items():
|
|
105
|
+
if not _ENV_NAME_RE.match(key):
|
|
106
|
+
raise RequestError(f"invalid environment variable name: {key!r}")
|
|
107
|
+
if "\x00" in value or len(value.encode()) > MAX_ENV_VALUE_BYTES:
|
|
108
|
+
raise RequestError(f"invalid value for environment variable {key}")
|
|
109
|
+
|
|
110
|
+
timeout = req.timeout_seconds
|
|
111
|
+
if math.isnan(timeout) or math.isinf(timeout) or timeout < 0:
|
|
112
|
+
raise RequestError("timeout_seconds must be a finite number >= 0")
|
|
113
|
+
if max_timeout and (timeout == 0 or timeout > max_timeout):
|
|
114
|
+
timeout = max_timeout
|
|
115
|
+
return CommandSpec(command=command, cwd=cwd, env=env, timeout=timeout or None)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _ts(dt) -> Timestamp:
|
|
119
|
+
t = Timestamp()
|
|
120
|
+
t.FromDatetime(dt)
|
|
121
|
+
return t
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_STATUS_TO_PB = {
|
|
125
|
+
Status.EXITED: pb.Finished.EXITED,
|
|
126
|
+
Status.TIMEOUT: pb.Finished.TIMEOUT,
|
|
127
|
+
Status.ERROR: pb.Finished.ERROR,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def event_to_proto(ev, hostname: str) -> pb.ExecuteEvent:
|
|
132
|
+
if isinstance(ev, Output):
|
|
133
|
+
chunk = pb.OutputChunk(data=ev.data)
|
|
134
|
+
return pb.ExecuteEvent(stdout=chunk) if ev.stream is Stream.STDOUT else pb.ExecuteEvent(stderr=chunk)
|
|
135
|
+
if isinstance(ev, Started):
|
|
136
|
+
return pb.ExecuteEvent(started=pb.Started(
|
|
137
|
+
execution_id=ev.execution_id, pid=ev.pid, start_time=_ts(ev.start_time), hostname=hostname,
|
|
138
|
+
))
|
|
139
|
+
if isinstance(ev, Finished):
|
|
140
|
+
return pb.ExecuteEvent(finished=pb.Finished(
|
|
141
|
+
status=_STATUS_TO_PB[ev.status], exit_code=ev.exit_code, signal=ev.signal,
|
|
142
|
+
start_time=_ts(ev.start_time), end_time=_ts(ev.end_time),
|
|
143
|
+
message=ev.message, execution_id=ev.execution_id,
|
|
144
|
+
))
|
|
145
|
+
raise TypeError(f"unknown event {ev!r}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _q(value: str) -> str:
|
|
149
|
+
"""Quote a value for single-line key=value audit records."""
|
|
150
|
+
return json.dumps(value, ensure_ascii=False)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class RemoteExecutorService(pb_grpc.RemoteExecutorServicer):
|
|
154
|
+
def __init__(self, settings: ServerSettings, executor: Executor, policy: PolicyProvider,
|
|
155
|
+
command_log: CommandLog | None = None):
|
|
156
|
+
self.settings = settings
|
|
157
|
+
self.executor = executor
|
|
158
|
+
self.policy = policy
|
|
159
|
+
self.command_log = command_log
|
|
160
|
+
self.hostname = socket.gethostname()
|
|
161
|
+
self._active = 0
|
|
162
|
+
|
|
163
|
+
async def Execute(self, request: pb.ExecuteRequest, context: grpc.aio.ServicerContext):
|
|
164
|
+
client = peer_identity(context)
|
|
165
|
+
try:
|
|
166
|
+
spec = validate_request(request, self.settings.max_timeout)
|
|
167
|
+
except RequestError as exc:
|
|
168
|
+
audit.warning("rejected client=%s reason=%s", client, _q(str(exc)))
|
|
169
|
+
if self.command_log:
|
|
170
|
+
self.command_log.rejected(client, request.command, str(exc))
|
|
171
|
+
await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(exc))
|
|
172
|
+
|
|
173
|
+
if reason := self.policy.current().check(spec.command):
|
|
174
|
+
audit.warning("rejected client=%s reason=%s command=%s", client, _q(f"tools policy: {reason}"),
|
|
175
|
+
_q(spec.command))
|
|
176
|
+
if self.command_log:
|
|
177
|
+
self.command_log.rejected(client, spec.command, f"tools policy: {reason}")
|
|
178
|
+
await context.abort(grpc.StatusCode.PERMISSION_DENIED, f"blocked by the server's tools policy: {reason}")
|
|
179
|
+
|
|
180
|
+
if self._active >= self.settings.max_concurrent:
|
|
181
|
+
audit.warning("rejected client=%s reason=%s", client, _q("too many concurrent commands"))
|
|
182
|
+
if self.command_log:
|
|
183
|
+
self.command_log.rejected(client, spec.command, "too many concurrent commands")
|
|
184
|
+
await context.abort(
|
|
185
|
+
grpc.StatusCode.RESOURCE_EXHAUSTED,
|
|
186
|
+
f"server is already running {self._active} commands (max {self.settings.max_concurrent})",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
execution_id = uuid.uuid4().hex[:12]
|
|
190
|
+
audit.info(
|
|
191
|
+
"start id=%s client=%s cwd=%s timeout=%s env_keys=%s command=%s",
|
|
192
|
+
execution_id, client, _q(spec.cwd or os.getcwd()), spec.timeout or 0,
|
|
193
|
+
_q(",".join(sorted(spec.env))), _q(spec.command),
|
|
194
|
+
)
|
|
195
|
+
if self.command_log:
|
|
196
|
+
self.command_log.start(execution_id, client, spec.cwd or os.getcwd(), spec.command,
|
|
197
|
+
spec.timeout or 0, spec.env)
|
|
198
|
+
started = time.monotonic()
|
|
199
|
+
outcome = "cancelled"
|
|
200
|
+
exit_code: int | str = "-"
|
|
201
|
+
self._active += 1
|
|
202
|
+
try:
|
|
203
|
+
async with contextlib.aclosing(self.executor.run(spec, execution_id)) as events:
|
|
204
|
+
async for ev in events:
|
|
205
|
+
if isinstance(ev, Output) and self.command_log:
|
|
206
|
+
self.command_log.output(execution_id, ev.stream.value, ev.data)
|
|
207
|
+
elif isinstance(ev, Finished):
|
|
208
|
+
outcome, exit_code = ev.status.value, ev.exit_code
|
|
209
|
+
if ev.status is Status.ERROR:
|
|
210
|
+
exit_code = "-"
|
|
211
|
+
outcome = f"error message={_q(ev.message)}"
|
|
212
|
+
yield event_to_proto(ev, self.hostname)
|
|
213
|
+
finally:
|
|
214
|
+
self._active -= 1
|
|
215
|
+
duration = time.monotonic() - started
|
|
216
|
+
audit.info(
|
|
217
|
+
"end id=%s client=%s status=%s exit_code=%s duration=%.3fs",
|
|
218
|
+
execution_id, client, outcome, exit_code, duration,
|
|
219
|
+
)
|
|
220
|
+
if self.command_log:
|
|
221
|
+
self.command_log.end(execution_id, outcome, exit_code, duration)
|
|
222
|
+
|
|
223
|
+
async def GetServerInfo(self, request, context):
|
|
224
|
+
policy = self.policy.current()
|
|
225
|
+
return pb.ServerInfo(
|
|
226
|
+
hostname=self.hostname, version=__version__, user=getpass.getuser(), shell=self.executor.shell,
|
|
227
|
+
allowed_tools=policy.allow, denied_tools=policy.deny,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class RemoteExecServer:
|
|
232
|
+
"""Owns the grpc.aio server and the executor."""
|
|
233
|
+
|
|
234
|
+
def __init__(self, settings: ServerSettings):
|
|
235
|
+
self.settings = settings
|
|
236
|
+
self.executor = Executor(shell=settings.shell or default_shell(), kill_grace=settings.kill_grace)
|
|
237
|
+
self.policy = PolicyProvider(settings.tools_file, required=settings.tools_required)
|
|
238
|
+
self.command_log = CommandLog(
|
|
239
|
+
settings.command_log, int(settings.command_log_max_mb * 1024 * 1024), settings.command_log_keep,
|
|
240
|
+
) if settings.command_log else None
|
|
241
|
+
interceptors = [TokenAuthInterceptor(settings.token)] if settings.token else []
|
|
242
|
+
self._server = grpc.aio.server(interceptors=interceptors, options=SERVER_OPTIONS)
|
|
243
|
+
pb_grpc.add_RemoteExecutorServicer_to_server(
|
|
244
|
+
RemoteExecutorService(settings, self.executor, self.policy, self.command_log), self._server
|
|
245
|
+
)
|
|
246
|
+
self.bound_port: int | None = None
|
|
247
|
+
|
|
248
|
+
async def start(self) -> int:
|
|
249
|
+
s = self.settings
|
|
250
|
+
if s.tls_cert:
|
|
251
|
+
creds = server_credentials(s.tls_cert, s.tls_key, s.tls_client_ca)
|
|
252
|
+
port = self._server.add_secure_port(s.address, creds)
|
|
253
|
+
else:
|
|
254
|
+
port = self._server.add_insecure_port(s.address)
|
|
255
|
+
if not port:
|
|
256
|
+
raise OSError(f"failed to bind {s.address}")
|
|
257
|
+
await self._server.start()
|
|
258
|
+
self.bound_port = port
|
|
259
|
+
log.info(
|
|
260
|
+
"remote-exec-server %s listening on %s (tls=%s, mtls=%s, token_auth=%s, shell=%s)",
|
|
261
|
+
__version__, format_address(s.host, port), bool(s.tls_cert), bool(s.tls_client_ca),
|
|
262
|
+
bool(s.token), self.executor.shell,
|
|
263
|
+
)
|
|
264
|
+
log.info("tools policy: %s", self.policy.current().describe())
|
|
265
|
+
if self.command_log:
|
|
266
|
+
log.info("command log: %s (rotate at %g MB, keep %s)", self.command_log.path,
|
|
267
|
+
self.settings.command_log_max_mb, self.settings.command_log_keep or "all")
|
|
268
|
+
else:
|
|
269
|
+
log.info("command log: disabled")
|
|
270
|
+
return port
|
|
271
|
+
|
|
272
|
+
async def stop(self, grace: float = 5.0) -> None:
|
|
273
|
+
"""Stop accepting RPCs, cancel running ones and clean up their processes."""
|
|
274
|
+
await self._server.stop(grace)
|
|
275
|
+
await self.executor.wait_cleanups(timeout=self.settings.kill_grace + 2)
|
|
276
|
+
self.executor.kill_all()
|
|
277
|
+
if self.command_log:
|
|
278
|
+
self.command_log.close()
|
|
279
|
+
|
|
280
|
+
async def wait_for_termination(self) -> None:
|
|
281
|
+
await self._server.wait_for_termination()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def serve(settings: ServerSettings, stop_event: asyncio.Event | None = None) -> None:
|
|
285
|
+
server = RemoteExecServer(settings)
|
|
286
|
+
await server.start()
|
|
287
|
+
stop_event = stop_event or asyncio.Event()
|
|
288
|
+
try:
|
|
289
|
+
await stop_event.wait()
|
|
290
|
+
finally:
|
|
291
|
+
log.info("shutting down")
|
|
292
|
+
await server.stop()
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""``remote-exec-server`` command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .cmdlog import DEFAULT_COMMAND_LOG, DEFAULT_MAX_MB, CommandLog
|
|
14
|
+
from .config import DEFAULT_HOST, DEFAULT_PORT, ConfigError, load_server_config, split_host_port, validate_port
|
|
15
|
+
from .policy import DEFAULT_TOOLS_FILE, PolicyError, PolicyProvider
|
|
16
|
+
from .security import DEFAULT_TOKEN_FILE, SecurityError, generate_token_file, is_loopback_host, read_secret_file
|
|
17
|
+
from .server import ServerSettings, serve
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("remote_exec.server")
|
|
20
|
+
|
|
21
|
+
DEFAULTS = {
|
|
22
|
+
"host": DEFAULT_HOST,
|
|
23
|
+
"port": DEFAULT_PORT,
|
|
24
|
+
"max_concurrent": 32,
|
|
25
|
+
"max_timeout": None,
|
|
26
|
+
"shell": "",
|
|
27
|
+
"kill_grace": 5.0,
|
|
28
|
+
"log_level": "INFO",
|
|
29
|
+
"log_file": None,
|
|
30
|
+
"token_file": None,
|
|
31
|
+
"tls_cert": None,
|
|
32
|
+
"tls_key": None,
|
|
33
|
+
"tls_client_ca": None,
|
|
34
|
+
"insecure_no_auth": False,
|
|
35
|
+
"allow_root": False,
|
|
36
|
+
"listen": None,
|
|
37
|
+
"tools": None,
|
|
38
|
+
"command_log": DEFAULT_COMMAND_LOG,
|
|
39
|
+
"command_log_max_mb": DEFAULT_MAX_MB,
|
|
40
|
+
"command_log_keep": 0,
|
|
41
|
+
"no_command_log": False,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
46
|
+
p = argparse.ArgumentParser(
|
|
47
|
+
prog="remote-exec-server",
|
|
48
|
+
description="Run the remote-exec gRPC server. Executes commands requested by "
|
|
49
|
+
"remote-exec-client as the current (non-root) user.",
|
|
50
|
+
epilog="Listening on a non-loopback address requires authentication "
|
|
51
|
+
"(--token-file and/or --tls-client-ca) unless --insecure-no-auth is given.",
|
|
52
|
+
)
|
|
53
|
+
addr = p.add_argument_group("listen address")
|
|
54
|
+
addr.add_argument("--host", help=f"address to bind (default: {DEFAULT_HOST}; use 0.0.0.0 for all interfaces)")
|
|
55
|
+
addr.add_argument("--port", type=int, help=f"TCP port (default: {DEFAULT_PORT})")
|
|
56
|
+
addr.add_argument("--listen", metavar="HOST:PORT", help="shorthand for --host and --port, e.g. 0.0.0.0:50051")
|
|
57
|
+
p.add_argument("--config", metavar="FILE", help="YAML file with server settings (CLI options override it)")
|
|
58
|
+
|
|
59
|
+
sec = p.add_argument_group("security")
|
|
60
|
+
sec.add_argument("--token-file", metavar="FILE", help="file containing the shared bearer token clients must send")
|
|
61
|
+
sec.add_argument("--tls-cert", metavar="FILE", help="server TLS certificate (PEM)")
|
|
62
|
+
sec.add_argument("--tls-key", metavar="FILE", help="server TLS private key (PEM)")
|
|
63
|
+
sec.add_argument("--tls-client-ca", metavar="FILE", help="CA for client certificates; enables mTLS")
|
|
64
|
+
sec.add_argument("--insecure-no-auth", action="store_true", default=None,
|
|
65
|
+
help="allow listening on a non-loopback address without any authentication (NOT recommended)")
|
|
66
|
+
sec.add_argument("--allow-root", action="store_true", default=None,
|
|
67
|
+
help="allow running as root (NOT recommended)")
|
|
68
|
+
sec.add_argument("--generate-token", nargs="?", const=str(DEFAULT_TOKEN_FILE), metavar="FILE",
|
|
69
|
+
help=f"create a new random token file (mode 600) and exit; default FILE: {DEFAULT_TOKEN_FILE}")
|
|
70
|
+
sec.add_argument("--force", action="store_true", default=None,
|
|
71
|
+
help="with --generate-token: replace an existing token file")
|
|
72
|
+
|
|
73
|
+
ex = p.add_argument_group("execution")
|
|
74
|
+
ex.add_argument("--tools", metavar="FILE",
|
|
75
|
+
help=f"tools.yaml with allowed/denied programs (default: {DEFAULT_TOOLS_FILE} if it exists, "
|
|
76
|
+
"else allow everything except ssh); reloaded automatically when changed")
|
|
77
|
+
ex.add_argument("--shell", help="shell used as '<shell> -c <command>' (default: /bin/bash, else /bin/sh)")
|
|
78
|
+
ex.add_argument("--max-concurrent", type=int, help="max concurrently running commands (default: 32)")
|
|
79
|
+
ex.add_argument("--max-timeout", type=float, metavar="SECONDS",
|
|
80
|
+
help="upper bound for command timeouts; also applied to commands without a timeout")
|
|
81
|
+
ex.add_argument("--kill-grace", type=float, metavar="SECONDS",
|
|
82
|
+
help="seconds between SIGTERM and SIGKILL when stopping a command (default: 5)")
|
|
83
|
+
|
|
84
|
+
lg = p.add_argument_group("logging")
|
|
85
|
+
lg.add_argument("--log-level", help="DEBUG, INFO, WARNING, ERROR (default: INFO)")
|
|
86
|
+
lg.add_argument("--log-file", metavar="FILE", help="also append logs to FILE (default: stderr only)")
|
|
87
|
+
lg.add_argument("--command-log", metavar="FILE",
|
|
88
|
+
help=f"file recording every command and its output (default: ./{DEFAULT_COMMAND_LOG} "
|
|
89
|
+
"in the directory the server is started from)")
|
|
90
|
+
lg.add_argument("--command-log-max-mb", type=float, metavar="MB",
|
|
91
|
+
help=f"start a new command log file when it would exceed MB (default: {DEFAULT_MAX_MB:g})")
|
|
92
|
+
lg.add_argument("--command-log-keep", type=int, metavar="N",
|
|
93
|
+
help="keep only the N most recent rotated command logs (default: 0 = keep all)")
|
|
94
|
+
lg.add_argument("--no-command-log", action="store_true", default=None, help="do not write a command log")
|
|
95
|
+
p.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
96
|
+
return p
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve_settings(args: argparse.Namespace) -> tuple[ServerSettings, dict]:
|
|
100
|
+
if args.listen and (args.host is not None or args.port is not None):
|
|
101
|
+
raise ConfigError("--listen cannot be combined with --host/--port")
|
|
102
|
+
# Precedence: defaults < config file < command line; "listen" expands to host+port.
|
|
103
|
+
opts = dict(DEFAULTS)
|
|
104
|
+
for layer in (load_server_config(args.config) if args.config else {}, vars(args)):
|
|
105
|
+
layer = {k: v for k, v in layer.items() if v is not None and k != "config"}
|
|
106
|
+
if layer.get("listen"):
|
|
107
|
+
layer["host"], layer["port"] = split_host_port(str(layer.pop("listen")), DEFAULT_PORT)
|
|
108
|
+
opts.update(layer)
|
|
109
|
+
opts["port"] = validate_port(opts["port"])
|
|
110
|
+
if opts["max_concurrent"] < 1:
|
|
111
|
+
raise ConfigError("max_concurrent must be >= 1")
|
|
112
|
+
if opts["max_timeout"] is not None and opts["max_timeout"] <= 0:
|
|
113
|
+
raise ConfigError("max_timeout must be > 0")
|
|
114
|
+
if bool(opts["tls_cert"]) != bool(opts["tls_key"]):
|
|
115
|
+
raise ConfigError("--tls-cert and --tls-key must be given together")
|
|
116
|
+
if opts["tls_client_ca"] and not opts["tls_cert"]:
|
|
117
|
+
raise ConfigError("--tls-client-ca (mTLS) requires --tls-cert/--tls-key")
|
|
118
|
+
if float(opts["command_log_max_mb"]) < 0.001:
|
|
119
|
+
raise ConfigError("command_log_max_mb must be at least 0.001 (1 KiB)")
|
|
120
|
+
if int(opts["command_log_keep"]) < 0:
|
|
121
|
+
raise ConfigError("command_log_keep must be >= 0")
|
|
122
|
+
command_log = None
|
|
123
|
+
if not opts["no_command_log"] and opts["command_log"] not in (None, False, ""):
|
|
124
|
+
# Relative paths are relative to the directory the server is started from.
|
|
125
|
+
command_log = os.path.abspath(os.path.expanduser(str(opts["command_log"])))
|
|
126
|
+
|
|
127
|
+
token = read_secret_file(opts["token_file"]) if opts["token_file"] else None
|
|
128
|
+
settings = ServerSettings(
|
|
129
|
+
host=str(opts["host"]), port=opts["port"], shell=opts["shell"] or "",
|
|
130
|
+
max_concurrent=int(opts["max_concurrent"]), max_timeout=opts["max_timeout"],
|
|
131
|
+
kill_grace=float(opts["kill_grace"]), token=token,
|
|
132
|
+
tls_cert=opts["tls_cert"], tls_key=opts["tls_key"], tls_client_ca=opts["tls_client_ca"],
|
|
133
|
+
tools_file=opts["tools"], tools_required=bool(opts["tools"]),
|
|
134
|
+
command_log=command_log, command_log_max_mb=float(opts["command_log_max_mb"]),
|
|
135
|
+
command_log_keep=int(opts["command_log_keep"]),
|
|
136
|
+
)
|
|
137
|
+
return settings, opts
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def check_safety(settings: ServerSettings, opts: dict) -> None:
|
|
141
|
+
if hasattr(os, "geteuid") and os.geteuid() == 0 and not opts["allow_root"]:
|
|
142
|
+
raise SecurityError(
|
|
143
|
+
"refusing to run as root: commands would execute with full privileges. "
|
|
144
|
+
"Run as an unprivileged user (or pass --allow-root if you really mean it)."
|
|
145
|
+
)
|
|
146
|
+
authenticated = bool(settings.token or settings.tls_client_ca)
|
|
147
|
+
if not is_loopback_host(settings.host):
|
|
148
|
+
if not authenticated and not opts["insecure_no_auth"]:
|
|
149
|
+
raise SecurityError(
|
|
150
|
+
f"refusing to listen on non-loopback address {settings.host!r} without authentication. "
|
|
151
|
+
"Configure --token-file and/or mTLS (--tls-cert/--tls-key/--tls-client-ca), "
|
|
152
|
+
"or pass --insecure-no-auth on a fully trusted network."
|
|
153
|
+
)
|
|
154
|
+
if not authenticated:
|
|
155
|
+
log.warning("listening on %s WITHOUT authentication: anyone who can reach this port can run commands", settings.host)
|
|
156
|
+
if not settings.tls_cert:
|
|
157
|
+
log.warning("TLS is disabled: traffic (including the token) is unencrypted; use only over a trusted/encrypted network such as WireGuard/Tailscale")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def setup_logging(level: str, log_file: str | None) -> None:
|
|
161
|
+
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
|
|
162
|
+
if log_file:
|
|
163
|
+
handlers.append(logging.FileHandler(os.path.expanduser(log_file)))
|
|
164
|
+
logging.basicConfig(
|
|
165
|
+
level=getattr(logging, str(level).upper(), logging.INFO),
|
|
166
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
167
|
+
handlers=handlers,
|
|
168
|
+
force=True,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _run(settings: ServerSettings) -> None:
|
|
173
|
+
stop = asyncio.Event()
|
|
174
|
+
loop = asyncio.get_running_loop()
|
|
175
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
176
|
+
loop.add_signal_handler(sig, stop.set)
|
|
177
|
+
await serve(settings, stop)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def do_generate_token(path: str, force: bool) -> int:
|
|
181
|
+
try:
|
|
182
|
+
target = generate_token_file(path, force=force)
|
|
183
|
+
except SecurityError as exc:
|
|
184
|
+
print(f"remote-exec-server: error: {exc}", file=sys.stderr)
|
|
185
|
+
return 1
|
|
186
|
+
print(f"token written to {target} (mode 600)")
|
|
187
|
+
print("next steps:")
|
|
188
|
+
print(f" server: remote-exec-server --host 0.0.0.0 --token-file {target}")
|
|
189
|
+
print(" client: copy this file to the client machine (e.g. ~/.config/remote-exec/token, chmod 600)")
|
|
190
|
+
print(" and set 'token_file:' in the client config")
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main(argv: list[str] | None = None) -> int:
|
|
195
|
+
args = build_parser().parse_args(argv)
|
|
196
|
+
if args.generate_token:
|
|
197
|
+
return do_generate_token(args.generate_token, bool(args.force))
|
|
198
|
+
try:
|
|
199
|
+
# Logging first so config warnings (e.g. token file permissions) are shown.
|
|
200
|
+
setup_logging(args.log_level or "INFO", args.log_file)
|
|
201
|
+
settings, opts = resolve_settings(args)
|
|
202
|
+
setup_logging(opts["log_level"], opts["log_file"])
|
|
203
|
+
check_safety(settings, opts)
|
|
204
|
+
PolicyProvider(settings.tools_file, required=settings.tools_required) # validate tools.yaml early
|
|
205
|
+
if settings.command_log: # fail now, not on the first command, if the log is not writable
|
|
206
|
+
CommandLog(settings.command_log, int(settings.command_log_max_mb * 1024 * 1024)).close()
|
|
207
|
+
except (ConfigError, SecurityError, PolicyError, OSError) as exc:
|
|
208
|
+
print(f"remote-exec-server: error: {exc}", file=sys.stderr)
|
|
209
|
+
return 2
|
|
210
|
+
try:
|
|
211
|
+
asyncio.run(_run(settings))
|
|
212
|
+
except OSError as exc:
|
|
213
|
+
print(f"remote-exec-server: error: {exc}", file=sys.stderr)
|
|
214
|
+
return 1
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
sys.exit(main())
|
remote_exec/skill.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Install the Claude Code skill (custom slash command) for remote-exec-client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SKILL_NAME = "remote-exec"
|
|
9
|
+
DEFAULT_SKILL_DIR = Path("~/.claude/commands")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SkillExistsError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def skill_text() -> str:
|
|
17
|
+
return resources.files("remote_exec").joinpath("claude_skill.md").read_text(encoding="utf-8")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def install_skill(directory: str | Path | None = None, force: bool = False) -> tuple[Path, str]:
|
|
21
|
+
"""Write ``<directory>/remote-exec.md``.
|
|
22
|
+
|
|
23
|
+
Returns the path and one of ``"installed"``, ``"updated"``, ``"unchanged"``.
|
|
24
|
+
An existing file with different content (e.g. edited by the user) is only
|
|
25
|
+
replaced when ``force`` is true.
|
|
26
|
+
"""
|
|
27
|
+
target = Path(directory or DEFAULT_SKILL_DIR).expanduser() / f"{SKILL_NAME}.md"
|
|
28
|
+
text = skill_text()
|
|
29
|
+
if target.exists():
|
|
30
|
+
if target.read_text(encoding="utf-8") == text:
|
|
31
|
+
return target, "unchanged"
|
|
32
|
+
if not force:
|
|
33
|
+
raise SkillExistsError(f"{target} already exists with different content; use --force to overwrite it")
|
|
34
|
+
status = "updated"
|
|
35
|
+
else:
|
|
36
|
+
status = "installed"
|
|
37
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
target.write_text(text, encoding="utf-8")
|
|
39
|
+
return target, status
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generated protobuf / gRPC code for API version v1 (see proto/)."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# NO CHECKED-IN PROTOBUF GENCODE
|
|
4
|
+
# source: remote_exec/v1/remote_exec.proto
|
|
5
|
+
# Protobuf Python Version: 7.35.1
|
|
6
|
+
"""Generated protocol buffer code."""
|
|
7
|
+
from google.protobuf import descriptor as _descriptor
|
|
8
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
9
|
+
from google.protobuf import runtime_version as _runtime_version
|
|
10
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
11
|
+
from google.protobuf.internal import builder as _builder
|
|
12
|
+
_runtime_version.ValidateProtobufRuntimeVersion(
|
|
13
|
+
_runtime_version.Domain.PUBLIC,
|
|
14
|
+
7,
|
|
15
|
+
35,
|
|
16
|
+
1,
|
|
17
|
+
'',
|
|
18
|
+
'remote_exec/v1/remote_exec.proto'
|
|
19
|
+
)
|
|
20
|
+
# @@protoc_insertion_point(imports)
|
|
21
|
+
|
|
22
|
+
_sym_db = _symbol_database.Default()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n remote_exec/v1/remote_exec.proto\x12\x0eremote_exec.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xcf\x01\n\x0e\x45xecuteRequest\x12\x0f\n\x07\x63ommand\x18\x01 \x01(\t\x12\x19\n\x11working_directory\x18\x02 \x01(\t\x12\x44\n\x0b\x65nvironment\x18\x03 \x03(\x0b\x32/.remote_exec.v1.ExecuteRequest.EnvironmentEntry\x12\x17\n\x0ftimeout_seconds\x18\x04 \x01(\x01\x1a\x32\n\x10\x45nvironmentEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xcf\x01\n\x0c\x45xecuteEvent\x12*\n\x07started\x18\x01 \x01(\x0b\x32\x17.remote_exec.v1.StartedH\x00\x12-\n\x06stdout\x18\x02 \x01(\x0b\x32\x1b.remote_exec.v1.OutputChunkH\x00\x12-\n\x06stderr\x18\x03 \x01(\x0b\x32\x1b.remote_exec.v1.OutputChunkH\x00\x12,\n\x08\x66inished\x18\x04 \x01(\x0b\x32\x18.remote_exec.v1.FinishedH\x00\x42\x07\n\x05\x65vent\"n\n\x07Started\x12\x14\n\x0c\x65xecution_id\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\x05\x12.\n\nstart_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x10\n\x08hostname\x18\x04 \x01(\t\"\x1b\n\x0bOutputChunk\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"\xa9\x02\n\x08\x46inished\x12/\n\x06status\x18\x01 \x01(\x0e\x32\x1f.remote_exec.v1.Finished.Status\x12\x11\n\texit_code\x18\x02 \x01(\x05\x12\x0e\n\x06signal\x18\x03 \x01(\x05\x12.\n\nstart_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x08\x65nd_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07message\x18\x06 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x07 \x01(\t\"D\n\x06Status\x12\x16\n\x12STATUS_UNSPECIFIED\x10\x00\x12\n\n\x06\x45XITED\x10\x01\x12\x0b\n\x07TIMEOUT\x10\x02\x12\t\n\x05\x45RROR\x10\x03\"\x16\n\x14GetServerInfoRequest\"y\n\nServerInfo\x12\x10\n\x08hostname\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x0c\n\x04user\x18\x03 \x01(\t\x12\r\n\x05shell\x18\x04 \x01(\t\x12\x15\n\rallowed_tools\x18\x05 \x03(\t\x12\x14\n\x0c\x64\x65nied_tools\x18\x06 \x03(\t2\xae\x01\n\x0eRemoteExecutor\x12I\n\x07\x45xecute\x12\x1e.remote_exec.v1.ExecuteRequest\x1a\x1c.remote_exec.v1.ExecuteEvent0\x01\x12Q\n\rGetServerInfo\x12$.remote_exec.v1.GetServerInfoRequest\x1a\x1a.remote_exec.v1.ServerInfob\x06proto3')
|
|
29
|
+
|
|
30
|
+
_globals = globals()
|
|
31
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
32
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'remote_exec.v1.remote_exec_pb2', _globals)
|
|
33
|
+
if not _descriptor._USE_C_DESCRIPTORS:
|
|
34
|
+
DESCRIPTOR._loaded_options = None
|
|
35
|
+
_globals['_EXECUTEREQUEST_ENVIRONMENTENTRY']._loaded_options = None
|
|
36
|
+
_globals['_EXECUTEREQUEST_ENVIRONMENTENTRY']._serialized_options = b'8\001'
|
|
37
|
+
_globals['_EXECUTEREQUEST']._serialized_start=86
|
|
38
|
+
_globals['_EXECUTEREQUEST']._serialized_end=293
|
|
39
|
+
_globals['_EXECUTEREQUEST_ENVIRONMENTENTRY']._serialized_start=243
|
|
40
|
+
_globals['_EXECUTEREQUEST_ENVIRONMENTENTRY']._serialized_end=293
|
|
41
|
+
_globals['_EXECUTEEVENT']._serialized_start=296
|
|
42
|
+
_globals['_EXECUTEEVENT']._serialized_end=503
|
|
43
|
+
_globals['_STARTED']._serialized_start=505
|
|
44
|
+
_globals['_STARTED']._serialized_end=615
|
|
45
|
+
_globals['_OUTPUTCHUNK']._serialized_start=617
|
|
46
|
+
_globals['_OUTPUTCHUNK']._serialized_end=644
|
|
47
|
+
_globals['_FINISHED']._serialized_start=647
|
|
48
|
+
_globals['_FINISHED']._serialized_end=944
|
|
49
|
+
_globals['_FINISHED_STATUS']._serialized_start=876
|
|
50
|
+
_globals['_FINISHED_STATUS']._serialized_end=944
|
|
51
|
+
_globals['_GETSERVERINFOREQUEST']._serialized_start=946
|
|
52
|
+
_globals['_GETSERVERINFOREQUEST']._serialized_end=968
|
|
53
|
+
_globals['_SERVERINFO']._serialized_start=970
|
|
54
|
+
_globals['_SERVERINFO']._serialized_end=1091
|
|
55
|
+
_globals['_REMOTEEXECUTOR']._serialized_start=1094
|
|
56
|
+
_globals['_REMOTEEXECUTOR']._serialized_end=1268
|
|
57
|
+
# @@protoc_insertion_point(module_scope)
|