command-gate 0.2.4__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.
- cgate/__init__.py +26 -0
- cgate/__main__.py +8 -0
- cgate/_version.py +24 -0
- cgate/cli/__init__.py +1 -0
- cgate/cli/_console.py +17 -0
- cgate/cli/connections.py +212 -0
- cgate/cli/history.py +191 -0
- cgate/cli/install.py +182 -0
- cgate/cli/main.py +115 -0
- cgate/cli/mcp.py +197 -0
- cgate/cli/uninstall.py +403 -0
- cgate/cli/update.py +538 -0
- cgate/cli/watch.py +20 -0
- cgate/connections/__init__.py +1 -0
- cgate/connections/auth.py +93 -0
- cgate/connections/detect.py +78 -0
- cgate/connections/store.py +88 -0
- cgate/core/__init__.py +1 -0
- cgate/core/path_env.py +218 -0
- cgate/core/paths.py +35 -0
- cgate/core/update_log.py +36 -0
- cgate/db/__init__.py +1 -0
- cgate/db/batches.py +111 -0
- cgate/db/commands.py +191 -0
- cgate/db/connection.py +104 -0
- cgate/db/mode.py +74 -0
- cgate/db/rows.py +99 -0
- cgate/db/schema.py +54 -0
- cgate/db/server_settings.py +105 -0
- cgate/db/types.py +77 -0
- cgate/executor/__init__.py +7 -0
- cgate/executor/base.py +71 -0
- cgate/executor/selector.py +61 -0
- cgate/executor/ssh.py +157 -0
- cgate/executor/winrm.py +129 -0
- cgate/helper/__init__.py +10 -0
- cgate/helper/__main__.py +112 -0
- cgate/helper/waiter.py +123 -0
- cgate/mcp_installer.py +161 -0
- cgate/mcp_server/__init__.py +6 -0
- cgate/mcp_server/__main__.py +6 -0
- cgate/mcp_server/auto_resolution.py +80 -0
- cgate/mcp_server/server.py +271 -0
- cgate/mcp_server/tools.py +351 -0
- cgate/risk.py +129 -0
- cgate/update.py +713 -0
- cgate/watch/__init__.py +7 -0
- cgate/watch/app.py +560 -0
- cgate/watch/approval.py +237 -0
- cgate/watch/command_detail_modal.py +68 -0
- cgate/watch/history_modal.py +242 -0
- cgate/watch/mode_modal.py +110 -0
- cgate/watch/queue.py +106 -0
- cgate/watch/render.py +156 -0
- cgate/watch/server_settings_modal.py +179 -0
- cgate/watch/session.py +40 -0
- cgate/watch/theme.py +32 -0
- cgate/watch/widgets.py +35 -0
- command_gate-0.2.4.dist-info/METADATA +204 -0
- command_gate-0.2.4.dist-info/RECORD +63 -0
- command_gate-0.2.4.dist-info/WHEEL +4 -0
- command_gate-0.2.4.dist-info/entry_points.txt +2 -0
- command_gate-0.2.4.dist-info/licenses/LICENSE +21 -0
cgate/db/types.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Domain types for the database layer: enums, NewType IDs, and frozen dataclasses."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import TYPE_CHECKING, NewType
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ServerType(StrEnum):
|
|
13
|
+
"""Type of a target server. Drives dialect (PowerShell vs Bash) and exec backend."""
|
|
14
|
+
|
|
15
|
+
WINDOWS = "windows"
|
|
16
|
+
LINUX = "linux"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CommandStatus(StrEnum):
|
|
20
|
+
"""Lifecycle of a single command in a batch.
|
|
21
|
+
|
|
22
|
+
Flow: PENDING -> APPROVED -> EXECUTED (or PENDING -> REJECTED / FAILED).
|
|
23
|
+
APPROVED and PENDING are non-terminal (resolved_at IS NULL).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
PENDING = "pending"
|
|
27
|
+
APPROVED = "approved"
|
|
28
|
+
REJECTED = "rejected"
|
|
29
|
+
EXECUTED = "executed"
|
|
30
|
+
FAILED = "failed"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
BatchId = NewType("BatchId", str)
|
|
34
|
+
CommandId = NewType("CommandId", str)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Batch:
|
|
39
|
+
"""A batch of related commands proposed together by one AI invocation."""
|
|
40
|
+
|
|
41
|
+
id: BatchId
|
|
42
|
+
title: str
|
|
43
|
+
description: str | None
|
|
44
|
+
requested_by_agent: str | None
|
|
45
|
+
created_at: datetime
|
|
46
|
+
resolved_at: datetime | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class Command:
|
|
51
|
+
"""A single command within a batch, addressed to one server."""
|
|
52
|
+
|
|
53
|
+
id: CommandId
|
|
54
|
+
batch_id: BatchId
|
|
55
|
+
position: int
|
|
56
|
+
server_alias: str
|
|
57
|
+
server_type: ServerType
|
|
58
|
+
command: str
|
|
59
|
+
status: CommandStatus
|
|
60
|
+
result: str | None
|
|
61
|
+
approved_by: str | None
|
|
62
|
+
created_at: datetime
|
|
63
|
+
resolved_at: datetime | None
|
|
64
|
+
reason: str | None
|
|
65
|
+
risk_label: str | None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class Connection:
|
|
70
|
+
"""A saved connection from this machine to a remote server, identified by alias."""
|
|
71
|
+
|
|
72
|
+
alias: str
|
|
73
|
+
hostname: str
|
|
74
|
+
server_type: ServerType
|
|
75
|
+
detection_ssh: bool
|
|
76
|
+
detection_winrm: bool
|
|
77
|
+
created_at: datetime
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Remote command execution: WinRM for Windows, SSH for Linux."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from cgate.executor.base import DEFAULT_TIMEOUT_SECONDS, ErrorKind, ExecutionResult
|
|
5
|
+
from cgate.executor.selector import execute_command
|
|
6
|
+
|
|
7
|
+
__all__ = ["DEFAULT_TIMEOUT_SECONDS", "ErrorKind", "ExecutionResult", "execute_command"]
|
cgate/executor/base.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Domain types and exceptions for the executor layer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import TYPE_CHECKING, Final
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
DEFAULT_TIMEOUT_SECONDS: Final = 30.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ErrorKind(StrEnum):
|
|
16
|
+
"""How a command execution failed (None on success)."""
|
|
17
|
+
|
|
18
|
+
TIMEOUT = "timeout"
|
|
19
|
+
AUTH_FAILED = "auth_failed"
|
|
20
|
+
HOST_UNREACHABLE = "host_unreachable"
|
|
21
|
+
PROTOCOL_ERROR = "protocol_error"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExecutorError(Exception):
|
|
25
|
+
"""Base for executor errors that should be surfaced as a structured result."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CommandTimeout(ExecutorError): # noqa: N818 - public name required by executor API
|
|
29
|
+
"""Execution exceeded the timeout."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthenticationFailed(ExecutorError): # noqa: N818 - public name required by executor API
|
|
33
|
+
"""Credentials were rejected by the target."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HostUnreachable(ExecutorError): # noqa: N818 - public name required by executor API
|
|
37
|
+
"""The target host refused or failed the TCP connection."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProtocolError(ExecutorError):
|
|
41
|
+
"""The remote server returned malformed output."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class ExecutionResult:
|
|
46
|
+
"""Outcome of running one command on one server.
|
|
47
|
+
|
|
48
|
+
``error_kind`` is None on any successful connection, regardless of exit code.
|
|
49
|
+
Use ``ok`` to require both a zero exit code and no executor error.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
stdout: str
|
|
53
|
+
stderr: str
|
|
54
|
+
exit_code: int
|
|
55
|
+
duration_ms: int
|
|
56
|
+
error_kind: ErrorKind | None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def ok(self) -> bool:
|
|
60
|
+
"""Return true exactly when execution succeeded with a zero exit code."""
|
|
61
|
+
return self.error_kind is None and self.exit_code == 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def measure_duration_ms() -> tuple[int, Callable[[], int]]:
|
|
65
|
+
"""Return the monotonic start in milliseconds and an elapsed-time callable."""
|
|
66
|
+
start = time.monotonic()
|
|
67
|
+
|
|
68
|
+
def elapsed() -> int:
|
|
69
|
+
return int((time.monotonic() - start) * 1000)
|
|
70
|
+
|
|
71
|
+
return int(start * 1000), elapsed
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Top-level dispatch for executing a command on a saved connection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from cgate.connections.auth import StoredCredential, get_credential, is_kerberos_available
|
|
5
|
+
from cgate.db.types import Connection, ServerType
|
|
6
|
+
from cgate.executor.base import DEFAULT_TIMEOUT_SECONDS, ErrorKind, ExecutionResult
|
|
7
|
+
from cgate.executor.ssh import execute_linux
|
|
8
|
+
from cgate.executor.winrm import execute_windows
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def execute_command(
|
|
12
|
+
connection: Connection,
|
|
13
|
+
command: str,
|
|
14
|
+
*,
|
|
15
|
+
credential: StoredCredential | None = None,
|
|
16
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
17
|
+
) -> ExecutionResult:
|
|
18
|
+
"""Execute a command on a saved connection using its selected backend."""
|
|
19
|
+
resolved_credential = credential
|
|
20
|
+
if resolved_credential is None:
|
|
21
|
+
resolved_credential = get_credential(connection.alias)
|
|
22
|
+
if resolved_credential is None:
|
|
23
|
+
return _missing_credential_result(connection)
|
|
24
|
+
|
|
25
|
+
match connection.server_type:
|
|
26
|
+
case ServerType.WINDOWS:
|
|
27
|
+
return execute_windows(
|
|
28
|
+
connection.hostname,
|
|
29
|
+
command,
|
|
30
|
+
resolved_credential,
|
|
31
|
+
timeout=timeout,
|
|
32
|
+
)
|
|
33
|
+
case ServerType.LINUX:
|
|
34
|
+
return execute_linux(
|
|
35
|
+
connection.hostname,
|
|
36
|
+
command,
|
|
37
|
+
resolved_credential,
|
|
38
|
+
timeout=timeout,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _missing_credential_result(connection: Connection) -> ExecutionResult:
|
|
43
|
+
match connection.server_type:
|
|
44
|
+
case ServerType.WINDOWS:
|
|
45
|
+
kerberos_available = is_kerberos_available()
|
|
46
|
+
case ServerType.LINUX:
|
|
47
|
+
kerberos_available = False
|
|
48
|
+
if kerberos_available:
|
|
49
|
+
message = (
|
|
50
|
+
"kerberos passthrough available, but Phase 1 doesn't ship kerberos transport; "
|
|
51
|
+
"store an NTLM credential with `cgate connections add` first."
|
|
52
|
+
)
|
|
53
|
+
else:
|
|
54
|
+
message = f"no credential for alias '{connection.alias}'"
|
|
55
|
+
return ExecutionResult(
|
|
56
|
+
stdout="",
|
|
57
|
+
stderr=message,
|
|
58
|
+
exit_code=-1,
|
|
59
|
+
duration_ms=0,
|
|
60
|
+
error_kind=ErrorKind.AUTH_FAILED,
|
|
61
|
+
)
|
cgate/executor/ssh.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Linux command execution over SSH (paramiko)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Final
|
|
7
|
+
|
|
8
|
+
import paramiko
|
|
9
|
+
from paramiko.ssh_exception import NoValidConnectionsError
|
|
10
|
+
from typing_extensions import override
|
|
11
|
+
|
|
12
|
+
from cgate.core.paths import data_dir
|
|
13
|
+
from cgate.executor.base import DEFAULT_TIMEOUT_SECONDS, ErrorKind, ExecutionResult
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from cgate.connections.auth import StoredCredential
|
|
19
|
+
|
|
20
|
+
SSH_PORT: Final = 22
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _TrustOnFirstUsePolicy(paramiko.MissingHostKeyPolicy):
|
|
24
|
+
"""Accept an unknown host key once, then pin it to ``known_hosts_path``.
|
|
25
|
+
|
|
26
|
+
Unlike ``AutoAddPolicy`` (issue #6), which trusts every connection with
|
|
27
|
+
no memory at all, paramiko only calls ``missing_host_key`` when the
|
|
28
|
+
hostname isn't already present in the client's loaded host keys --
|
|
29
|
+
if it IS present but the presented key doesn't match, paramiko raises
|
|
30
|
+
``BadHostKeyException`` on its own before this policy is ever
|
|
31
|
+
consulted. So preloading a persistent file and only auto-trusting
|
|
32
|
+
truly new hosts here gives real trust-on-first-use: a key that
|
|
33
|
+
changes after that first connection (MITM, or the box got rebuilt)
|
|
34
|
+
is rejected instead of silently accepted again.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_known_hosts_path: Path # class-level annotation required by strict mode
|
|
38
|
+
|
|
39
|
+
def __init__(self, known_hosts_path: Path) -> None:
|
|
40
|
+
"""Remember where to persist newly-trusted host keys."""
|
|
41
|
+
self._known_hosts_path = known_hosts_path
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
def missing_host_key(
|
|
45
|
+
self, client: paramiko.SSHClient, hostname: str, key: paramiko.PKey
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Trust and persist a host key seen for the first time."""
|
|
48
|
+
client.get_host_keys().add(hostname, key.get_name(), key)
|
|
49
|
+
self._known_hosts_path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
client.save_host_keys(str(self._known_hosts_path))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _known_hosts_path() -> Path:
|
|
54
|
+
return data_dir() / "known_hosts"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def execute_linux(
|
|
58
|
+
hostname: str,
|
|
59
|
+
command: str,
|
|
60
|
+
credential: StoredCredential,
|
|
61
|
+
*,
|
|
62
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
63
|
+
) -> ExecutionResult:
|
|
64
|
+
"""Run a command on a host via SSH using a key before a password."""
|
|
65
|
+
started = time.monotonic()
|
|
66
|
+
client = paramiko.SSHClient()
|
|
67
|
+
known_hosts_path = _known_hosts_path()
|
|
68
|
+
if known_hosts_path.exists():
|
|
69
|
+
client.load_host_keys(str(known_hosts_path))
|
|
70
|
+
client.set_missing_host_key_policy(_TrustOnFirstUsePolicy(known_hosts_path))
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
if credential.ssh_key:
|
|
74
|
+
client.connect(
|
|
75
|
+
hostname,
|
|
76
|
+
port=SSH_PORT,
|
|
77
|
+
username=credential.username,
|
|
78
|
+
key_filename=credential.ssh_key,
|
|
79
|
+
timeout=timeout,
|
|
80
|
+
auth_timeout=timeout,
|
|
81
|
+
banner_timeout=timeout,
|
|
82
|
+
)
|
|
83
|
+
elif credential.password:
|
|
84
|
+
client.connect(
|
|
85
|
+
hostname,
|
|
86
|
+
port=SSH_PORT,
|
|
87
|
+
username=credential.username,
|
|
88
|
+
password=credential.password,
|
|
89
|
+
timeout=timeout,
|
|
90
|
+
auth_timeout=timeout,
|
|
91
|
+
banner_timeout=timeout,
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
return _failure(
|
|
95
|
+
"credential has neither ssh_key nor password",
|
|
96
|
+
ErrorKind.AUTH_FAILED,
|
|
97
|
+
started,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
_stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
|
|
101
|
+
stdout.channel.settimeout(timeout)
|
|
102
|
+
try:
|
|
103
|
+
out_bytes = stdout.read()
|
|
104
|
+
err_bytes = stderr.read()
|
|
105
|
+
exit_code = stdout.channel.recv_exit_status()
|
|
106
|
+
except Exception as exc: # noqa: BLE001 - Paramiko streams expose generic read errors
|
|
107
|
+
return _failure(str(exc), ErrorKind.TIMEOUT, started)
|
|
108
|
+
|
|
109
|
+
return ExecutionResult(
|
|
110
|
+
stdout=_decode_output(out_bytes),
|
|
111
|
+
stderr=_decode_output(err_bytes),
|
|
112
|
+
exit_code=int(exit_code),
|
|
113
|
+
duration_ms=int((time.monotonic() - started) * 1000),
|
|
114
|
+
error_kind=None,
|
|
115
|
+
)
|
|
116
|
+
except Exception as exc: # noqa: BLE001 - Paramiko connect can surface socket subclasses
|
|
117
|
+
return _failure_from_paramiko_exception(exc, started)
|
|
118
|
+
finally:
|
|
119
|
+
with contextlib.suppress(Exception):
|
|
120
|
+
client.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _decode_output(output: str | bytes | bytearray | None) -> str:
|
|
124
|
+
if isinstance(output, (bytes, bytearray)):
|
|
125
|
+
return output.decode("utf-8", errors="replace")
|
|
126
|
+
return output or ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _failure(message: str, error_kind: ErrorKind, started: float) -> ExecutionResult:
|
|
130
|
+
return ExecutionResult(
|
|
131
|
+
stdout="",
|
|
132
|
+
stderr=message,
|
|
133
|
+
exit_code=-1,
|
|
134
|
+
duration_ms=int((time.monotonic() - started) * 1000),
|
|
135
|
+
error_kind=error_kind,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _failure_from_paramiko_exception(exc: Exception, started: float) -> ExecutionResult:
|
|
140
|
+
return _failure(str(exc), _classify_paramiko_exception(exc), started)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _classify_paramiko_exception(exc: Exception) -> ErrorKind:
|
|
144
|
+
name = type(exc).__name__.lower()
|
|
145
|
+
message = str(exc).lower()
|
|
146
|
+
if isinstance(exc, paramiko.AuthenticationException) or "auth" in name:
|
|
147
|
+
return ErrorKind.AUTH_FAILED
|
|
148
|
+
if isinstance(exc, NoValidConnectionsError):
|
|
149
|
+
return ErrorKind.HOST_UNREACHABLE
|
|
150
|
+
if any(
|
|
151
|
+
signal in message
|
|
152
|
+
for signal in ("no route to host", "connection refused", "getaddrinfo failed")
|
|
153
|
+
):
|
|
154
|
+
return ErrorKind.HOST_UNREACHABLE
|
|
155
|
+
if "timed out" in message or "timeout" in name:
|
|
156
|
+
return ErrorKind.TIMEOUT
|
|
157
|
+
return ErrorKind.PROTOCOL_ERROR
|
cgate/executor/winrm.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Windows command execution via WinRM (pywinrm)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from typing import TYPE_CHECKING, Final
|
|
6
|
+
|
|
7
|
+
import requests.exceptions
|
|
8
|
+
import winrm
|
|
9
|
+
|
|
10
|
+
from cgate.executor.base import DEFAULT_TIMEOUT_SECONDS, ErrorKind, ExecutionResult
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from cgate.connections.auth import StoredCredential
|
|
14
|
+
|
|
15
|
+
WINRM_HTTP_PORT: Final = 5985
|
|
16
|
+
WINRM_HTTPS_PORT: Final = 5986
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _endpoint(hostname: str, *, https: bool) -> str:
|
|
20
|
+
port = WINRM_HTTPS_PORT if https else WINRM_HTTP_PORT
|
|
21
|
+
scheme = "https" if https else "http"
|
|
22
|
+
return f"{scheme}://{hostname}:{port}/wsman"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def execute_windows(
|
|
26
|
+
hostname: str,
|
|
27
|
+
command: str,
|
|
28
|
+
credential: StoredCredential,
|
|
29
|
+
*,
|
|
30
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
31
|
+
https: bool = True,
|
|
32
|
+
) -> ExecutionResult:
|
|
33
|
+
"""Run a command on a host over WinRM with NTLM authentication.
|
|
34
|
+
|
|
35
|
+
Prefers the protocol requested by the caller (defaults to HTTPS for
|
|
36
|
+
the secure WinRM config) but falls back to the other protocol when
|
|
37
|
+
the primary listener is unreachable. WinRM operators commonly run
|
|
38
|
+
HTTP on port 5985 for internal/lab hosts where TLS is not wired
|
|
39
|
+
up, so a connection refused on 5986 should not be fatal when 5985
|
|
40
|
+
is available.
|
|
41
|
+
|
|
42
|
+
``server_cert_validation`` is passed explicitly (issue #7): pywinrm
|
|
43
|
+
already defaults to ``"validate"``, but pinning it in code makes the
|
|
44
|
+
guarantee explicit rather than resting on the library's default. A
|
|
45
|
+
certificate that fails validation is NOT treated as "try the other
|
|
46
|
+
protocol" like a refused/unreachable connection is -- something IS
|
|
47
|
+
listening and presenting an untrusted cert, so falling back to plain
|
|
48
|
+
HTTP there would silently downgrade a possibly-MITM'd HTTPS attempt
|
|
49
|
+
into an unauthenticated cleartext one instead of failing loudly.
|
|
50
|
+
|
|
51
|
+
Commands are executed via PowerShell (``session.run_ps``) rather
|
|
52
|
+
than the legacy cmd shell. PowerShell handles both PowerShell
|
|
53
|
+
cmdlets (``Get-ChildItem``, ``Set-Service``, ...) and classic
|
|
54
|
+
cmd-line utilities (``dir``, ``type``, ...) transparently, so this
|
|
55
|
+
covers the common cases without forcing users to wrap commands
|
|
56
|
+
manually. Per-server shell selection (or cmd-vs-ps fallback) is
|
|
57
|
+
deferred to a future iteration.
|
|
58
|
+
"""
|
|
59
|
+
started = time.monotonic()
|
|
60
|
+
last_exc: Exception | None = None
|
|
61
|
+
for attempt_https in (https, not https):
|
|
62
|
+
try:
|
|
63
|
+
session = winrm.Session(
|
|
64
|
+
_endpoint(hostname, https=attempt_https),
|
|
65
|
+
auth=(credential.username, credential.password or ""),
|
|
66
|
+
transport="ntlm",
|
|
67
|
+
server_cert_validation="validate",
|
|
68
|
+
# pywinrm requires ``read_timeout_sec > operation_timeout_sec``;
|
|
69
|
+
# otherwise it raises ``read_timeout_sec must exceed
|
|
70
|
+
# operation_timeout_sec``. The operation budget covers the
|
|
71
|
+
# round-trip + remote execution; the read budget also has
|
|
72
|
+
# to cover pulling the response back, so we add a fixed
|
|
73
|
+
# margin.
|
|
74
|
+
operation_timeout_sec=timeout,
|
|
75
|
+
read_timeout_sec=timeout + 30,
|
|
76
|
+
)
|
|
77
|
+
response = session.run_ps(command)
|
|
78
|
+
return ExecutionResult(
|
|
79
|
+
stdout=_decode_output(response.std_out),
|
|
80
|
+
stderr=_decode_output(response.std_err),
|
|
81
|
+
exit_code=int(response.status_code),
|
|
82
|
+
duration_ms=int((time.monotonic() - started) * 1000),
|
|
83
|
+
error_kind=None,
|
|
84
|
+
)
|
|
85
|
+
except requests.exceptions.SSLError as exc:
|
|
86
|
+
return _failure_from_winrm_exception(exc, started)
|
|
87
|
+
except Exception as exc: # noqa: BLE001 - SDK errors lack a closed common hierarchy
|
|
88
|
+
last_exc = exc
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
assert last_exc is not None # loop ran at least once
|
|
92
|
+
return _failure_from_winrm_exception(last_exc, started)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _decode_output(output: str | bytes | bytearray | None) -> str:
|
|
96
|
+
if isinstance(output, (bytes, bytearray)):
|
|
97
|
+
return output.decode("utf-8", errors="replace")
|
|
98
|
+
return output or ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _failure_from_winrm_exception(exc: Exception, started: float) -> ExecutionResult:
|
|
102
|
+
return ExecutionResult(
|
|
103
|
+
stdout="",
|
|
104
|
+
stderr=str(exc),
|
|
105
|
+
exit_code=-1,
|
|
106
|
+
duration_ms=int((time.monotonic() - started) * 1000),
|
|
107
|
+
error_kind=_classify_winrm_exception(exc),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _classify_winrm_exception(exc: Exception) -> ErrorKind:
|
|
112
|
+
name = type(exc).__name__.lower()
|
|
113
|
+
message = str(exc).lower()
|
|
114
|
+
if any(signal in message for signal in ("401", "unauthorized", "auth", "credential")):
|
|
115
|
+
return ErrorKind.AUTH_FAILED
|
|
116
|
+
if "timed out" in message or "timeout" in name:
|
|
117
|
+
return ErrorKind.TIMEOUT
|
|
118
|
+
if any(
|
|
119
|
+
signal in message
|
|
120
|
+
for signal in (
|
|
121
|
+
"connection refused",
|
|
122
|
+
"no route to host",
|
|
123
|
+
"getaddrinfo failed",
|
|
124
|
+
"name or service not known",
|
|
125
|
+
"network is unreachable",
|
|
126
|
+
)
|
|
127
|
+
):
|
|
128
|
+
return ErrorKind.HOST_UNREACHABLE
|
|
129
|
+
return ErrorKind.PROTOCOL_ERROR
|
cgate/helper/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Standalone Windows file-swap helper (``cgate-helper.exe``).
|
|
2
|
+
|
|
3
|
+
Built as its own PyInstaller binary, separate from the main ``cgate.exe``,
|
|
4
|
+
so it never shares an image name with the process it's replacing/deleting
|
|
5
|
+
and there is nothing to disambiguate on Windows's process list. Must not
|
|
6
|
+
import anything from ``cgate.cli`` -- keep this package's dependency graph
|
|
7
|
+
to the standard library plus ``cgate.core`` only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
cgate/helper/__main__.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Entry point for the standalone ``cgate-helper.exe`` binary.
|
|
2
|
+
|
|
3
|
+
Fully unattended: never prompts, always exits with a code and a log line.
|
|
4
|
+
Invoked by the main ``cgate.exe`` (see ``cgate.update.spawn_helper``) after
|
|
5
|
+
it has decided a self-lock swap/delete needs to happen from a process that
|
|
6
|
+
isn't ``cgate.exe`` itself.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
cgate-helper.exe replace --target PATH --source PATH --wait-pid N [...]
|
|
10
|
+
cgate-helper.exe delete --target PATH --wait-pid N [...]
|
|
11
|
+
cgate-helper.exe heal --target PATH [--wait-pid N ...]
|
|
12
|
+
|
|
13
|
+
``heal`` auto-detects ``<target>.new`` and atomically swaps it onto
|
|
14
|
+
``<target>`` -- the self-healing path used to finish a previously failed
|
|
15
|
+
``update apply`` when the next ``cgate`` invocation notices the staged
|
|
16
|
+
file and no other cgate processes are running.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from cgate.core.update_log import append_log
|
|
26
|
+
from cgate.helper.waiter import (
|
|
27
|
+
SOURCE_NAMES,
|
|
28
|
+
is_safe_target,
|
|
29
|
+
retry_delete,
|
|
30
|
+
retry_replace,
|
|
31
|
+
wait_for_pids,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_WAIT_TIMEOUT_SECONDS = 30.0
|
|
35
|
+
_RETRY_TOTAL_SECONDS = 10.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _helper_dir() -> Path:
|
|
39
|
+
"""Return the directory this helper (or its source, when unfrozen) lives in."""
|
|
40
|
+
if getattr(sys, "frozen", False):
|
|
41
|
+
return Path(sys.executable).resolve().parent
|
|
42
|
+
return Path(__file__).resolve().parent
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
46
|
+
"""Build the ``replace``/``delete`` subcommand parser."""
|
|
47
|
+
parser = argparse.ArgumentParser(prog="cgate-helper")
|
|
48
|
+
subparsers = parser.add_subparsers(dest="operation", required=True)
|
|
49
|
+
|
|
50
|
+
replace = subparsers.add_parser("replace", help="Move --source onto --target.")
|
|
51
|
+
replace.add_argument("--target", required=True, type=Path)
|
|
52
|
+
replace.add_argument("--source", required=True, type=Path)
|
|
53
|
+
replace.add_argument("--wait-pid", type=int, action="append", default=[], dest="wait_pids")
|
|
54
|
+
|
|
55
|
+
delete = subparsers.add_parser("delete", help="Delete --target.")
|
|
56
|
+
delete.add_argument("--target", required=True, type=Path)
|
|
57
|
+
delete.add_argument("--wait-pid", type=int, action="append", default=[], dest="wait_pids")
|
|
58
|
+
|
|
59
|
+
heal = subparsers.add_parser(
|
|
60
|
+
"heal",
|
|
61
|
+
help="If <target>.new exists, atomically swap it onto <target>.",
|
|
62
|
+
)
|
|
63
|
+
heal.add_argument("--target", required=True, type=Path)
|
|
64
|
+
heal.add_argument("--wait-pid", type=int, action="append", default=[], dest="wait_pids")
|
|
65
|
+
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: list[str] | None = None) -> int:
|
|
70
|
+
"""Run one wait-then-replace/delete operation. Never prompts."""
|
|
71
|
+
args = build_parser().parse_args(argv)
|
|
72
|
+
helper_dir = _helper_dir()
|
|
73
|
+
|
|
74
|
+
if not is_safe_target(args.target, helper_dir=helper_dir):
|
|
75
|
+
append_log(f"helper {args.operation}: refusing unsafe target {args.target}")
|
|
76
|
+
return 3
|
|
77
|
+
if args.operation == "replace" and not is_safe_target(
|
|
78
|
+
args.source, helper_dir=helper_dir, allowed_names=SOURCE_NAMES
|
|
79
|
+
):
|
|
80
|
+
append_log(f"helper replace: refusing unsafe source {args.source}")
|
|
81
|
+
return 3
|
|
82
|
+
|
|
83
|
+
if args.wait_pids and not wait_for_pids(args.wait_pids, timeout=_WAIT_TIMEOUT_SECONDS):
|
|
84
|
+
append_log(
|
|
85
|
+
f"helper {args.operation}: timed out after {_WAIT_TIMEOUT_SECONDS:g}s waiting for "
|
|
86
|
+
f"PID(s) {args.wait_pids} to exit; proceeding anyway"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if args.operation == "replace":
|
|
90
|
+
error = retry_replace(args.source, args.target, total_seconds=_RETRY_TOTAL_SECONDS)
|
|
91
|
+
elif args.operation == "delete":
|
|
92
|
+
error = retry_delete(args.target, total_seconds=_RETRY_TOTAL_SECONDS)
|
|
93
|
+
else:
|
|
94
|
+
staging = args.target.with_name(args.target.name + ".new")
|
|
95
|
+
if not staging.exists():
|
|
96
|
+
append_log(f"helper heal: no pending update at {staging}")
|
|
97
|
+
return 0
|
|
98
|
+
if not is_safe_target(staging, helper_dir=helper_dir, allowed_names=SOURCE_NAMES):
|
|
99
|
+
append_log(f"helper heal: refusing unsafe staging {staging}")
|
|
100
|
+
return 3
|
|
101
|
+
error = retry_replace(staging, args.target, total_seconds=_RETRY_TOTAL_SECONDS)
|
|
102
|
+
|
|
103
|
+
if error is not None:
|
|
104
|
+
append_log(f"helper {args.operation} failed for {args.target}: {error}")
|
|
105
|
+
return 2
|
|
106
|
+
|
|
107
|
+
append_log(f"helper {args.operation} succeeded for {args.target}")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
raise SystemExit(main())
|