wsctl 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.
- wsctl/__init__.py +7 -0
- wsctl/__main__.py +6 -0
- wsctl/cli/__init__.py +3 -0
- wsctl/cli/client.py +135 -0
- wsctl/cli/connect.py +158 -0
- wsctl/cli/main.py +655 -0
- wsctl/core/__init__.py +3 -0
- wsctl/core/_winpty.py +79 -0
- wsctl/core/config.py +221 -0
- wsctl/core/fs.py +58 -0
- wsctl/core/logging.py +36 -0
- wsctl/core/metrics.py +67 -0
- wsctl/core/net.py +30 -0
- wsctl/core/passwords.py +26 -0
- wsctl/core/pty.py +249 -0
- wsctl/core/ratelimit.py +69 -0
- wsctl/core/recording.py +73 -0
- wsctl/core/scrollback.py +60 -0
- wsctl/core/session.py +467 -0
- wsctl/core/ssh.py +53 -0
- wsctl/core/store.py +462 -0
- wsctl/core/tmux.py +82 -0
- wsctl/core/totp.py +21 -0
- wsctl/core/webhook.py +62 -0
- wsctl/py.typed +0 -0
- wsctl/server/__init__.py +3 -0
- wsctl/server/app.py +773 -0
- wsctl/server/client.py +82 -0
- wsctl/server/security.py +75 -0
- wsctl/server/ws.py +376 -0
- wsctl/static/app.css +309 -0
- wsctl/static/app.js +960 -0
- wsctl/static/index.html +125 -0
- wsctl/static/vendor/THIRD_PARTY_NOTICES.txt +250 -0
- wsctl/static/vendor/addon-fit.js +2 -0
- wsctl/static/vendor/addon-image.js +3 -0
- wsctl/static/vendor/addon-image.js.LICENSE.txt +21 -0
- wsctl/static/vendor/addon-web-links.js +2 -0
- wsctl/static/vendor/asciinema-player.css +762 -0
- wsctl/static/vendor/asciinema-player.min.js +3 -0
- wsctl/static/vendor/xterm.css +218 -0
- wsctl/static/vendor/xterm.js +2 -0
- wsctl/static/vendor/zmodem.js +1 -0
- wsctl-0.1.0.dist-info/METADATA +565 -0
- wsctl-0.1.0.dist-info/RECORD +48 -0
- wsctl-0.1.0.dist-info/WHEEL +4 -0
- wsctl-0.1.0.dist-info/entry_points.txt +2 -0
- wsctl-0.1.0.dist-info/licenses/LICENSE +21 -0
wsctl/__init__.py
ADDED
wsctl/__main__.py
ADDED
wsctl/cli/__init__.py
ADDED
wsctl/cli/client.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Minimal HTTP client for talking to a running wsctl server.
|
|
2
|
+
|
|
3
|
+
Uses only the standard library so the CLI stays dependency-free. Credentials
|
|
4
|
+
(base URL + bearer token) are cached under the config directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import http.cookiejar
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from wsctl.core.config import default_config_dir
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ApiError(RuntimeError):
|
|
22
|
+
def __init__(self, status: int, detail: str) -> None:
|
|
23
|
+
super().__init__(f"HTTP {status}: {detail}")
|
|
24
|
+
self.status = status
|
|
25
|
+
self.detail = detail
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def credentials_path() -> Path:
|
|
29
|
+
return default_config_dir() / "credentials.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_credentials() -> dict[str, Any]:
|
|
33
|
+
path = credentials_path()
|
|
34
|
+
if not path.is_file():
|
|
35
|
+
return {}
|
|
36
|
+
try:
|
|
37
|
+
data = json.loads(path.read_text("utf-8"))
|
|
38
|
+
except (OSError, json.JSONDecodeError):
|
|
39
|
+
return {}
|
|
40
|
+
return data if isinstance(data, dict) else {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_credentials(url: str, token: str) -> None:
|
|
44
|
+
path = credentials_path()
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
path.write_text(json.dumps({"url": url, "token": token}), encoding="utf-8")
|
|
47
|
+
os.chmod(path, 0o600)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def clear_credentials() -> None:
|
|
51
|
+
path = credentials_path()
|
|
52
|
+
if path.exists():
|
|
53
|
+
path.unlink()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ApiClient:
|
|
57
|
+
"""Synchronous JSON client bound to a base URL and optional bearer token."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, base_url: str, token: str | None = None, *, timeout: float = 15.0) -> None:
|
|
60
|
+
self.base_url = base_url.rstrip("/")
|
|
61
|
+
self.token = token
|
|
62
|
+
self.timeout = timeout
|
|
63
|
+
|
|
64
|
+
def request(
|
|
65
|
+
self,
|
|
66
|
+
method: str,
|
|
67
|
+
path: str,
|
|
68
|
+
body: dict[str, Any] | None = None,
|
|
69
|
+
*,
|
|
70
|
+
auth: bool = True,
|
|
71
|
+
) -> Any:
|
|
72
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
73
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
74
|
+
if auth and self.token:
|
|
75
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
76
|
+
req = urllib.request.Request(
|
|
77
|
+
f"{self.base_url}{path}", data=data, headers=headers, method=method
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
81
|
+
payload = resp.read()
|
|
82
|
+
except urllib.error.HTTPError as exc:
|
|
83
|
+
detail = exc.read().decode("utf-8", "replace")
|
|
84
|
+
try:
|
|
85
|
+
parsed = json.loads(detail)
|
|
86
|
+
detail = str(parsed.get("detail", detail))
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
pass
|
|
89
|
+
raise ApiError(exc.code, detail) from exc
|
|
90
|
+
except urllib.error.URLError as exc:
|
|
91
|
+
raise ApiError(0, f"cannot reach {self.base_url}: {exc.reason}") from exc
|
|
92
|
+
if not payload:
|
|
93
|
+
return None
|
|
94
|
+
return json.loads(payload)
|
|
95
|
+
|
|
96
|
+
def download(self, path: str) -> bytes:
|
|
97
|
+
"""Fetch a raw (non-JSON) resource such as a recording."""
|
|
98
|
+
headers = {"Accept": "*/*"}
|
|
99
|
+
if self.token:
|
|
100
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
101
|
+
req = urllib.request.Request(f"{self.base_url}{path}", headers=headers, method="GET")
|
|
102
|
+
try:
|
|
103
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
104
|
+
data: bytes = resp.read()
|
|
105
|
+
return data
|
|
106
|
+
except urllib.error.HTTPError as exc:
|
|
107
|
+
raise ApiError(exc.code, exc.read().decode("utf-8", "replace")) from exc
|
|
108
|
+
except urllib.error.URLError as exc:
|
|
109
|
+
raise ApiError(0, f"cannot reach {self.base_url}: {exc.reason}") from exc
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def login(base_url: str, username: str, password: str, *, timeout: float = 15.0) -> str:
|
|
113
|
+
"""Authenticate and return the opaque session token from the cookie."""
|
|
114
|
+
jar = http.cookiejar.CookieJar()
|
|
115
|
+
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
116
|
+
body = json.dumps({"username": username, "password": password}).encode("utf-8")
|
|
117
|
+
req = urllib.request.Request(
|
|
118
|
+
f"{base_url.rstrip('/')}/api/login",
|
|
119
|
+
data=body,
|
|
120
|
+
headers={"Content-Type": "application/json"},
|
|
121
|
+
method="POST",
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
opener.open(req, timeout=timeout)
|
|
125
|
+
except urllib.error.HTTPError as exc:
|
|
126
|
+
detail = exc.read().decode("utf-8", "replace")
|
|
127
|
+
with contextlib.suppress(json.JSONDecodeError):
|
|
128
|
+
detail = str(json.loads(detail).get("detail", detail))
|
|
129
|
+
raise ApiError(exc.code, detail) from exc
|
|
130
|
+
except urllib.error.URLError as exc:
|
|
131
|
+
raise ApiError(0, f"cannot reach {base_url}: {exc.reason}") from exc
|
|
132
|
+
for cookie in jar:
|
|
133
|
+
if cookie.name == "wsctl_session":
|
|
134
|
+
return str(cookie.value)
|
|
135
|
+
raise ApiError(0, "server did not return a session cookie")
|
wsctl/cli/connect.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""``wsctl connect`` — attach a local terminal to a remote wsctl server.
|
|
2
|
+
|
|
3
|
+
The client puts the local terminal into raw mode and bridges stdin/stdout to
|
|
4
|
+
the server's WebSocket protocol (binary frames for terminal bytes, JSON text
|
|
5
|
+
frames for control). Reconnecting is a server-side feature, so a dropped link
|
|
6
|
+
ends the local session cleanly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import signal
|
|
16
|
+
import sys
|
|
17
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
18
|
+
|
|
19
|
+
import websockets
|
|
20
|
+
from websockets.exceptions import ConnectionClosed, InvalidStatus
|
|
21
|
+
|
|
22
|
+
from wsctl.cli.client import ApiClient, ApiError, load_credentials
|
|
23
|
+
|
|
24
|
+
DEFAULT_SIZE = (80, 24)
|
|
25
|
+
READ_SIZE = 4096
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConnectError(RuntimeError):
|
|
29
|
+
"""Raised when the local client cannot establish or maintain a link."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_connect(url: str | None, session: str | None, token: str | None) -> None:
|
|
33
|
+
creds = load_credentials()
|
|
34
|
+
base = url or (str(creds["url"]) if creds.get("url") else None)
|
|
35
|
+
if not base:
|
|
36
|
+
raise ConnectError("no server URL: pass <url> or run 'wsctl login <url>' first")
|
|
37
|
+
bearer = token or (str(creds["token"]) if creds.get("token") else None)
|
|
38
|
+
with contextlib.suppress(KeyboardInterrupt):
|
|
39
|
+
asyncio.run(_run(base, session, bearer))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ws_url(base: str) -> str:
|
|
43
|
+
parsed = urlsplit(base)
|
|
44
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
45
|
+
path = parsed.path.rstrip("/") + "/ws"
|
|
46
|
+
return urlunsplit((scheme, parsed.netloc, path, "", ""))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _winsize() -> tuple[int, int]:
|
|
50
|
+
try:
|
|
51
|
+
size = os.get_terminal_size(sys.stdin.fileno())
|
|
52
|
+
return size.columns, size.lines
|
|
53
|
+
except OSError:
|
|
54
|
+
return DEFAULT_SIZE
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _create_session(base: str, token: str | None) -> str:
|
|
58
|
+
client = ApiClient(base, token)
|
|
59
|
+
try:
|
|
60
|
+
info = client.request("POST", "/api/sessions", {})
|
|
61
|
+
except ApiError as exc:
|
|
62
|
+
raise ConnectError(f"cannot create a session: {exc}") from exc
|
|
63
|
+
return str(info["id"])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _notice(message: str) -> None:
|
|
67
|
+
sys.stderr.write(f"\r\n\x1b[33m[wsctl] {message}\x1b[0m\r\n")
|
|
68
|
+
sys.stderr.flush()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _run(base: str, session: str | None, token: str | None) -> None:
|
|
72
|
+
if sys.platform == "win32":
|
|
73
|
+
raise ConnectError("wsctl connect currently requires a POSIX terminal")
|
|
74
|
+
if not sys.stdin.isatty():
|
|
75
|
+
raise ConnectError("wsctl connect requires an interactive terminal")
|
|
76
|
+
|
|
77
|
+
session_id = session or _create_session(base, token)
|
|
78
|
+
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
ws = await websockets.connect(_ws_url(base), additional_headers=headers, max_size=None)
|
|
82
|
+
except InvalidStatus as exc:
|
|
83
|
+
code = exc.response.status_code
|
|
84
|
+
hint = " (is the token valid?)" if code in (401, 403) else ""
|
|
85
|
+
raise ConnectError(f"server rejected the connection: HTTP {code}{hint}") from exc
|
|
86
|
+
except OSError as exc:
|
|
87
|
+
raise ConnectError(f"cannot reach {base}: {exc}") from exc
|
|
88
|
+
|
|
89
|
+
import termios
|
|
90
|
+
import tty
|
|
91
|
+
|
|
92
|
+
async with ws:
|
|
93
|
+
cols, rows = _winsize()
|
|
94
|
+
await ws.send(
|
|
95
|
+
json.dumps({"type": "attach", "session": session_id, "cols": cols, "rows": rows})
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
loop = asyncio.get_running_loop()
|
|
99
|
+
fd = sys.stdin.fileno()
|
|
100
|
+
saved = termios.tcgetattr(fd)
|
|
101
|
+
send_task: asyncio.Task[None] | None = None
|
|
102
|
+
notice: str | None = None
|
|
103
|
+
tty.setraw(fd)
|
|
104
|
+
try:
|
|
105
|
+
outbound: asyncio.Queue[bytes | str | None] = asyncio.Queue()
|
|
106
|
+
|
|
107
|
+
def on_stdin() -> None:
|
|
108
|
+
try:
|
|
109
|
+
data = os.read(fd, READ_SIZE)
|
|
110
|
+
except OSError:
|
|
111
|
+
data = b""
|
|
112
|
+
outbound.put_nowait(data if data else None)
|
|
113
|
+
|
|
114
|
+
def on_resize() -> None:
|
|
115
|
+
width, height = _winsize()
|
|
116
|
+
outbound.put_nowait(json.dumps({"type": "resize", "cols": width, "rows": height}))
|
|
117
|
+
|
|
118
|
+
loop.add_reader(fd, on_stdin)
|
|
119
|
+
with contextlib.suppress(NotImplementedError):
|
|
120
|
+
loop.add_signal_handler(signal.SIGWINCH, on_resize)
|
|
121
|
+
|
|
122
|
+
async def sender() -> None:
|
|
123
|
+
while True:
|
|
124
|
+
item = await outbound.get()
|
|
125
|
+
if item is None:
|
|
126
|
+
await ws.close()
|
|
127
|
+
return
|
|
128
|
+
await ws.send(item)
|
|
129
|
+
|
|
130
|
+
send_task = asyncio.create_task(sender())
|
|
131
|
+
try:
|
|
132
|
+
async for message in ws:
|
|
133
|
+
if isinstance(message, bytes):
|
|
134
|
+
os.write(sys.stdout.fileno(), message)
|
|
135
|
+
continue
|
|
136
|
+
data = json.loads(message)
|
|
137
|
+
kind = data.get("type")
|
|
138
|
+
if kind == "exit":
|
|
139
|
+
notice = f"session exited (code {data.get('code')})"
|
|
140
|
+
break
|
|
141
|
+
if kind == "error":
|
|
142
|
+
notice = str(data.get("msg"))
|
|
143
|
+
break
|
|
144
|
+
except ConnectionClosed:
|
|
145
|
+
notice = "connection closed"
|
|
146
|
+
finally:
|
|
147
|
+
if send_task is not None:
|
|
148
|
+
send_task.cancel()
|
|
149
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
150
|
+
await send_task
|
|
151
|
+
with contextlib.suppress(Exception):
|
|
152
|
+
loop.remove_reader(fd)
|
|
153
|
+
with contextlib.suppress(NotImplementedError):
|
|
154
|
+
loop.remove_signal_handler(signal.SIGWINCH)
|
|
155
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
156
|
+
|
|
157
|
+
if notice:
|
|
158
|
+
_notice(notice)
|