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.
Files changed (48) hide show
  1. wsctl/__init__.py +7 -0
  2. wsctl/__main__.py +6 -0
  3. wsctl/cli/__init__.py +3 -0
  4. wsctl/cli/client.py +135 -0
  5. wsctl/cli/connect.py +158 -0
  6. wsctl/cli/main.py +655 -0
  7. wsctl/core/__init__.py +3 -0
  8. wsctl/core/_winpty.py +79 -0
  9. wsctl/core/config.py +221 -0
  10. wsctl/core/fs.py +58 -0
  11. wsctl/core/logging.py +36 -0
  12. wsctl/core/metrics.py +67 -0
  13. wsctl/core/net.py +30 -0
  14. wsctl/core/passwords.py +26 -0
  15. wsctl/core/pty.py +249 -0
  16. wsctl/core/ratelimit.py +69 -0
  17. wsctl/core/recording.py +73 -0
  18. wsctl/core/scrollback.py +60 -0
  19. wsctl/core/session.py +467 -0
  20. wsctl/core/ssh.py +53 -0
  21. wsctl/core/store.py +462 -0
  22. wsctl/core/tmux.py +82 -0
  23. wsctl/core/totp.py +21 -0
  24. wsctl/core/webhook.py +62 -0
  25. wsctl/py.typed +0 -0
  26. wsctl/server/__init__.py +3 -0
  27. wsctl/server/app.py +773 -0
  28. wsctl/server/client.py +82 -0
  29. wsctl/server/security.py +75 -0
  30. wsctl/server/ws.py +376 -0
  31. wsctl/static/app.css +309 -0
  32. wsctl/static/app.js +960 -0
  33. wsctl/static/index.html +125 -0
  34. wsctl/static/vendor/THIRD_PARTY_NOTICES.txt +250 -0
  35. wsctl/static/vendor/addon-fit.js +2 -0
  36. wsctl/static/vendor/addon-image.js +3 -0
  37. wsctl/static/vendor/addon-image.js.LICENSE.txt +21 -0
  38. wsctl/static/vendor/addon-web-links.js +2 -0
  39. wsctl/static/vendor/asciinema-player.css +762 -0
  40. wsctl/static/vendor/asciinema-player.min.js +3 -0
  41. wsctl/static/vendor/xterm.css +218 -0
  42. wsctl/static/vendor/xterm.js +2 -0
  43. wsctl/static/vendor/zmodem.js +1 -0
  44. wsctl-0.1.0.dist-info/METADATA +565 -0
  45. wsctl-0.1.0.dist-info/RECORD +48 -0
  46. wsctl-0.1.0.dist-info/WHEEL +4 -0
  47. wsctl-0.1.0.dist-info/entry_points.txt +2 -0
  48. wsctl-0.1.0.dist-info/licenses/LICENSE +21 -0
wsctl/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """wsctl — a modern, Python-based web terminal server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["__version__"]
wsctl/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from wsctl.cli.main import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
wsctl/cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
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)