revpty 0.5.7__tar.gz

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 (40) hide show
  1. revpty-0.5.7/PKG-INFO +63 -0
  2. revpty-0.5.7/README.md +54 -0
  3. revpty-0.5.7/pyproject.toml +16 -0
  4. revpty-0.5.7/revpty/__init__.py +3 -0
  5. revpty-0.5.7/revpty/cli/__init__.py +1 -0
  6. revpty-0.5.7/revpty/cli/attach.py +308 -0
  7. revpty-0.5.7/revpty/cli/main.py +155 -0
  8. revpty-0.5.7/revpty/client/__init__.py +1 -0
  9. revpty-0.5.7/revpty/client/agent.py +429 -0
  10. revpty-0.5.7/revpty/client/file_manager.py +337 -0
  11. revpty-0.5.7/revpty/client/mux.py +427 -0
  12. revpty-0.5.7/revpty/client/pty_shell.py +187 -0
  13. revpty-0.5.7/revpty/client/tunnel_proxy.py +130 -0
  14. revpty-0.5.7/revpty/protocol/__init__.py +1 -0
  15. revpty-0.5.7/revpty/protocol/codec.py +80 -0
  16. revpty-0.5.7/revpty/protocol/frame.py +106 -0
  17. revpty-0.5.7/revpty/server/__init__.py +1 -0
  18. revpty-0.5.7/revpty/server/app.py +581 -0
  19. revpty-0.5.7/revpty/server/router.py +23 -0
  20. revpty-0.5.7/revpty/server/static/app.js +912 -0
  21. revpty-0.5.7/revpty/server/static/index.html +103 -0
  22. revpty-0.5.7/revpty/server/static/style.css +88 -0
  23. revpty-0.5.7/revpty/server/static/utils.js +47 -0
  24. revpty-0.5.7/revpty/server/tunnel.py +175 -0
  25. revpty-0.5.7/revpty/session/__init__.py +5 -0
  26. revpty-0.5.7/revpty/session/buffer.py +34 -0
  27. revpty-0.5.7/revpty/session/manager.py +249 -0
  28. revpty-0.5.7/revpty.egg-info/PKG-INFO +63 -0
  29. revpty-0.5.7/revpty.egg-info/SOURCES.txt +38 -0
  30. revpty-0.5.7/revpty.egg-info/dependency_links.txt +1 -0
  31. revpty-0.5.7/revpty.egg-info/entry_points.txt +4 -0
  32. revpty-0.5.7/revpty.egg-info/requires.txt +1 -0
  33. revpty-0.5.7/revpty.egg-info/top_level.txt +1 -0
  34. revpty-0.5.7/setup.cfg +4 -0
  35. revpty-0.5.7/tests/test_agent.py +144 -0
  36. revpty-0.5.7/tests/test_attach_terminal.py +72 -0
  37. revpty-0.5.7/tests/test_integration_attach.py +68 -0
  38. revpty-0.5.7/tests/test_new_features.py +218 -0
  39. revpty-0.5.7/tests/test_protocol.py +42 -0
  40. revpty-0.5.7/tests/test_session_manager.py +30 -0
revpty-0.5.7/PKG-INFO ADDED
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: revpty
3
+ Version: 0.5.7
4
+ Summary: Reverse PTY shell over WebSocket
5
+ Author: ddmonster
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: aiohttp
9
+
10
+ # revpty
11
+
12
+ Reverse PTY shell over WebSocket.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install revpty
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ **1. Start server:**
23
+ ```bash
24
+ revpty-server --host 0.0.0.0 --port 8765
25
+ ```
26
+
27
+ **2. Start client (on target machine):**
28
+ ```bash
29
+ revpty-client --server ws://your-server:8765 --session my-session
30
+ ```
31
+
32
+ **3. Attach to PTY (from your machine):**
33
+ ```bash
34
+ revpty-attach --server ws://your-server:8765 --session my-session
35
+ ```
36
+
37
+ ## Cloudflare Access Authentication
38
+
39
+ If your server is protected by Cloudflare Zero Trust with Service Token authentication:
40
+
41
+ ```bash
42
+ revpty-client --server wss://your-server:8765 --session my-session \
43
+ --cf-client-id YOUR_CLIENT_ID.access \
44
+ --cf-client-secret YOUR_CLIENT_SECRET
45
+ ```
46
+
47
+ Same for attach:
48
+ ```bash
49
+ revpty-attach --server wss://your-server:8765 --session my-session \
50
+ --cf-client-id YOUR_CLIENT_ID.access \
51
+ --cf-client-secret YOUR_CLIENT_SECRET
52
+ ```
53
+
54
+ The client will send `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers during WebSocket connection.
55
+
56
+ ## Features
57
+
58
+ - WebSocket-based PTY relay
59
+ - Session-based routing
60
+ - Reconnect support
61
+ - HTTP proxy support for client
62
+ - Cloudflare Access Service Token authentication
63
+ - Minimal architecture
revpty-0.5.7/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # revpty
2
+
3
+ Reverse PTY shell over WebSocket.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install revpty
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ **1. Start server:**
14
+ ```bash
15
+ revpty-server --host 0.0.0.0 --port 8765
16
+ ```
17
+
18
+ **2. Start client (on target machine):**
19
+ ```bash
20
+ revpty-client --server ws://your-server:8765 --session my-session
21
+ ```
22
+
23
+ **3. Attach to PTY (from your machine):**
24
+ ```bash
25
+ revpty-attach --server ws://your-server:8765 --session my-session
26
+ ```
27
+
28
+ ## Cloudflare Access Authentication
29
+
30
+ If your server is protected by Cloudflare Zero Trust with Service Token authentication:
31
+
32
+ ```bash
33
+ revpty-client --server wss://your-server:8765 --session my-session \
34
+ --cf-client-id YOUR_CLIENT_ID.access \
35
+ --cf-client-secret YOUR_CLIENT_SECRET
36
+ ```
37
+
38
+ Same for attach:
39
+ ```bash
40
+ revpty-attach --server wss://your-server:8765 --session my-session \
41
+ --cf-client-id YOUR_CLIENT_ID.access \
42
+ --cf-client-secret YOUR_CLIENT_SECRET
43
+ ```
44
+
45
+ The client will send `CF-Access-Client-Id` and `CF-Access-Client-Secret` headers during WebSocket connection.
46
+
47
+ ## Features
48
+
49
+ - WebSocket-based PTY relay
50
+ - Session-based routing
51
+ - Reconnect support
52
+ - HTTP proxy support for client
53
+ - Cloudflare Access Service Token authentication
54
+ - Minimal architecture
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "revpty"
3
+ version = "0.5.7"
4
+ description = "Reverse PTY shell over WebSocket"
5
+ authors = [{name = "ddmonster"}]
6
+ readme = "README.md"
7
+ requires-python = ">=3.9"
8
+ dependencies = ["aiohttp"]
9
+
10
+ [project.scripts]
11
+ revpty-server = "revpty.cli.main:server"
12
+ revpty-client = "revpty.cli.main:client"
13
+ revpty-attach = "revpty.cli.main:attach_cmd"
14
+
15
+ [tool.setuptools.package-data]
16
+ revpty = ["server/static/*"]
@@ -0,0 +1,3 @@
1
+ """revpty: Programmable Shell Runtime over WebSocket"""
2
+
3
+ __version__ = "0.5.7"
@@ -0,0 +1 @@
1
+ """CLI entry points"""
@@ -0,0 +1,308 @@
1
+ import asyncio
2
+ import logging
3
+ import aiohttp
4
+ import os
5
+ from aiohttp import WSMsgType
6
+ import sys
7
+ import tty
8
+ import termios
9
+ import os
10
+ import random
11
+ import time
12
+ import json
13
+ import uuid
14
+ from enum import Enum
15
+ import signal
16
+ from select import select
17
+
18
+ from revpty.protocol.frame import Frame, FrameType
19
+ from revpty.protocol.codec import encode, decode
20
+
21
+
22
+ _level_name = os.getenv("LOG_LEVEL", "INFO").upper()
23
+ _level = getattr(logging, _level_name, logging.INFO)
24
+ logging.basicConfig(
25
+ level=_level,
26
+ format="%(asctime)s - %(levelname)s - %(message)s",
27
+ datefmt="%H:%M:%S",
28
+ )
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class AttachState(Enum):
33
+ INIT = "init"
34
+ ATTACH_SENT = "attach_sent"
35
+ ACTIVE = "active"
36
+ DETACHED = "detached"
37
+ CLOSED = "closed"
38
+
39
+
40
+ class InteractiveTerminal:
41
+ def __init__(self, ws, session: str, attach_id: str):
42
+ self.ws = ws
43
+ self.session = session
44
+ self.attach_id = attach_id
45
+ self._old_tty = None
46
+ self._running = True
47
+ self._detached_by_user = False
48
+ self._status_event = asyncio.Event()
49
+ self._last_output = None
50
+ self.state = AttachState.INIT
51
+ self._sigwinch_enabled = False
52
+
53
+ # ---------- TTY handling ----------
54
+
55
+ def setup_terminal(self):
56
+ if not sys.stdin.isatty():
57
+ return
58
+ self._old_tty = termios.tcgetattr(sys.stdin)
59
+ tty.setraw(sys.stdin.fileno())
60
+
61
+ def restore_terminal(self):
62
+ if self._old_tty:
63
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self._old_tty)
64
+ print()
65
+
66
+ # ---------- Protocol helpers ----------
67
+
68
+ async def send_frame(self, frame: Frame):
69
+ await self.ws.send_str(encode(frame))
70
+
71
+ # ---------- Main loop ----------
72
+
73
+ async def run(self):
74
+ self.setup_terminal()
75
+ loop = asyncio.get_running_loop()
76
+
77
+ logger.info(f"[+] Attached to session '{self.session}'")
78
+ logger.info("[*] Press Ctrl+] to detach")
79
+
80
+ try:
81
+ ws_task = asyncio.create_task(self._read_from_ws())
82
+ await self.send_frame(
83
+ Frame(
84
+ session=self.session,
85
+ role="browser",
86
+ type="attach",
87
+ )
88
+ )
89
+ self.state = AttachState.ATTACH_SENT
90
+ await self._send_resize()
91
+ if sys.stdin.isatty():
92
+ loop.add_signal_handler(signal.SIGWINCH, lambda: asyncio.create_task(self._send_resize()))
93
+ self._sigwinch_enabled = True
94
+
95
+ try:
96
+ await asyncio.wait_for(self._status_event.wait(), timeout=5)
97
+ except asyncio.TimeoutError:
98
+ logger.warning("[!] Attach status timeout")
99
+ self.state = AttachState.ACTIVE
100
+ if self._last_output is None:
101
+ await self.send_frame(Frame(
102
+ session=self.session,
103
+ role="browser",
104
+ type=FrameType.INPUT.value,
105
+ data=b"\n",
106
+ ))
107
+
108
+ stdin_task = asyncio.create_task(self._read_from_stdin())
109
+ heartbeat_task = asyncio.create_task(self._heartbeat())
110
+
111
+ done, pending = await asyncio.wait(
112
+ {ws_task, stdin_task, heartbeat_task},
113
+ return_when=asyncio.FIRST_COMPLETED,
114
+ )
115
+
116
+ for task in pending:
117
+ task.cancel()
118
+
119
+ except KeyboardInterrupt:
120
+ logger.info("\n[*] Interrupted by user")
121
+
122
+ except Exception as e:
123
+ logger.error(f"[x] Terminal error: {e}")
124
+
125
+ finally:
126
+ try:
127
+ await self.send_frame(
128
+ Frame(
129
+ session=self.session,
130
+ role="browser",
131
+ type="detach",
132
+ )
133
+ )
134
+ except Exception:
135
+ pass
136
+ self.state = AttachState.DETACHED
137
+ self.restore_terminal()
138
+ logger.info(f"[-] Detached from session '{self.session}'")
139
+ if self._sigwinch_enabled:
140
+ loop.remove_signal_handler(signal.SIGWINCH)
141
+
142
+ self.state = AttachState.CLOSED
143
+ return "user_detach" if self._detached_by_user else "ws_closed"
144
+
145
+ # ---------- WS → stdout ----------
146
+
147
+ async def _read_from_ws(self):
148
+ try:
149
+ async for msg in self.ws:
150
+ if msg.type == WSMsgType.TEXT:
151
+ frame = decode(msg.data)
152
+
153
+ if frame.type == FrameType.OUTPUT.value:
154
+ sys.stdout.buffer.write(frame.data)
155
+ sys.stdout.buffer.flush()
156
+ self._last_output = time.time()
157
+ elif frame.type == FrameType.PING.value:
158
+ await self.send_frame(Frame(
159
+ session=self.session,
160
+ role="browser",
161
+ type=FrameType.PONG.value,
162
+ ))
163
+ elif frame.type == FrameType.STATUS.value:
164
+ self._status_event.set()
165
+ try:
166
+ payload = json.loads(frame.data.decode("utf-8")) if frame.data else {}
167
+ logger.info(f"[*] Attach status: {payload}")
168
+ except Exception:
169
+ logger.info("[*] Attach status received")
170
+
171
+ elif msg.type == WSMsgType.CLOSED:
172
+ logger.warning("\r[!] Connection closed by server")
173
+ break
174
+
175
+ elif msg.type == WSMsgType.ERROR:
176
+ logger.error(f"\r[x] WebSocket error: {self.ws.exception()}")
177
+ break
178
+
179
+ except asyncio.CancelledError:
180
+ pass
181
+ except Exception as e:
182
+ logger.error(f"\r[x] WS read error: {e}")
183
+
184
+ # ---------- stdin → WS ----------
185
+
186
+ async def _read_from_stdin(self):
187
+ fd = sys.stdin.fileno()
188
+ loop = asyncio.get_running_loop()
189
+
190
+ try:
191
+ while True:
192
+ # wait for stdin readable
193
+ await loop.run_in_executor(None, select, [fd], [], [])
194
+
195
+ data = os.read(fd, 1024)
196
+ if not data:
197
+ break
198
+
199
+ # Ctrl+] detach
200
+ if b"\x1d" in data:
201
+ logger.info("\n[*] Detaching from session...")
202
+ self._detached_by_user = True
203
+ break
204
+
205
+ await self.send_frame(
206
+ Frame(
207
+ session=self.session,
208
+ role="browser",
209
+ type=FrameType.INPUT.value,
210
+ data=data,
211
+ )
212
+ )
213
+
214
+ except asyncio.CancelledError:
215
+ pass
216
+ except Exception as e:
217
+ logger.error(f"\r[x] stdin read error: {e}")
218
+
219
+ async def _send_resize(self):
220
+ if not sys.stdin.isatty():
221
+ return
222
+ size = os.get_terminal_size(sys.stdin.fileno())
223
+ await self.send_frame(Frame(
224
+ session=self.session,
225
+ role="browser",
226
+ type=FrameType.RESIZE.value,
227
+ rows=size.lines,
228
+ cols=size.columns,
229
+ ))
230
+
231
+ async def _heartbeat(self, interval: int = 20):
232
+ try:
233
+ while True:
234
+ await asyncio.sleep(interval)
235
+ await self.send_frame(Frame(
236
+ session=self.session,
237
+ role="browser",
238
+ type=FrameType.PING.value,
239
+ ))
240
+ except asyncio.CancelledError:
241
+ pass
242
+ except Exception as e:
243
+ logger.error(f"\r[x] heartbeat error: {e}")
244
+
245
+
246
+ # ---------- Public API ----------
247
+
248
+ async def attach(server: str, session: str, proxy: str | None = None, secret: str | None = None,
249
+ cf_client_id: str | None = None, cf_client_secret: str | None = None):
250
+ proxy_info = f" via {proxy}" if proxy else ""
251
+ logger.info(f"[*] Connecting to {server}{proxy_info}")
252
+ attach_id = uuid.uuid4().hex[:10]
253
+ attempt = 0
254
+ retry_delay = 0 # immediate first retry
255
+ last_connected_at = 0
256
+ metrics = {
257
+ "attach_id": attach_id,
258
+ "attempts": 0,
259
+ "failures": 0,
260
+ "start_ms": round(time.time() * 1000),
261
+ }
262
+
263
+ while True:
264
+ attempt += 1
265
+ metrics["attempts"] = attempt
266
+ try:
267
+ timeout = aiohttp.ClientTimeout(
268
+ total=30,
269
+ connect=10,
270
+ sock_connect=10,
271
+ sock_read=30
272
+ )
273
+ async with aiohttp.ClientSession(timeout=timeout) as http_session:
274
+ headers = {}
275
+ if secret:
276
+ headers["X-Revpty-Secret"] = secret
277
+ if cf_client_id and cf_client_secret:
278
+ headers["CF-Access-Client-Id"] = cf_client_id
279
+ headers["CF-Access-Client-Secret"] = cf_client_secret
280
+ if not headers:
281
+ headers = None
282
+ async with http_session.ws_connect(server, proxy=proxy, headers=headers, heartbeat=30) as ws:
283
+ last_connected_at = time.time()
284
+ term = InteractiveTerminal(ws, session, attach_id)
285
+ result = await term.run()
286
+ if result == "user_detach":
287
+ break
288
+ except aiohttp.ClientError as e:
289
+ metrics["failures"] += 1
290
+ logger.error(f"[x] Connection failed: {e}")
291
+ except Exception as e:
292
+ metrics["failures"] += 1
293
+ logger.error(f"[x] Unexpected error: {e}")
294
+ if last_connected_at > 0 and (time.time() - last_connected_at) > 60:
295
+ retry_delay = 0
296
+ last_connected_at = 0
297
+ if retry_delay > 0:
298
+ logger.info(f"[*] Reconnecting in {retry_delay:.1f}s...")
299
+ await asyncio.sleep(retry_delay)
300
+ else:
301
+ logger.info("[*] Reconnecting immediately...")
302
+ if retry_delay == 0:
303
+ retry_delay = 1
304
+ else:
305
+ retry_delay = min(retry_delay * 2, 10) + random.uniform(0, retry_delay * 0.3)
306
+
307
+ logger.info("[*] Attach session ended")
308
+ logger.info(f"[*] attach metrics: {metrics}")
@@ -0,0 +1,155 @@
1
+ import argparse
2
+ import asyncio
3
+ import os
4
+ import shlex
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from revpty.server.app import run as run_server
9
+ from revpty.client.agent import Agent
10
+ from revpty.cli.attach import attach
11
+
12
+
13
+ def convert_to_ws_url(url):
14
+ """Convert http/https URL to ws/wss"""
15
+ url = url.strip()
16
+
17
+ # If already ws:// or wss://, return as-is
18
+ if url.startswith('ws://') or url.startswith('wss://'):
19
+ return url
20
+
21
+ # Convert http:// to ws://
22
+ if url.startswith('http://'):
23
+ return url.replace('http://', 'ws://', 1)
24
+
25
+ # Convert https:// to wss://
26
+ if url.startswith('https://'):
27
+ return url.replace('https://', 'wss://', 1)
28
+
29
+ # Default to ws:// if no scheme specified
30
+ if not url.startswith(('http://', 'https://', 'ws://', 'wss://')):
31
+ return f'ws://{url}'
32
+
33
+ return url
34
+
35
+
36
+ def _resolve_executable(name: str) -> str:
37
+ path = shutil.which(name)
38
+ if path:
39
+ return path
40
+ if sys.argv[0].endswith(name):
41
+ return sys.argv[0]
42
+ return name
43
+
44
+
45
+ def _install_systemd(service_name: str, exec_args: list[str], user_mode: bool = False):
46
+ if not shutil.which("systemctl"):
47
+ raise SystemExit("systemctl not found; this command is for systemd-based Linux systems only")
48
+
49
+ if user_mode:
50
+ unit_dir = os.path.expanduser("~/.config/systemd/user")
51
+ os.makedirs(unit_dir, exist_ok=True)
52
+ unit_path = os.path.join(unit_dir, f"{service_name}.service")
53
+ wanted_by = "default.target"
54
+ else:
55
+ if os.geteuid() != 0:
56
+ raise SystemExit("run as root to install systemd service (or use --user for user-level)")
57
+ unit_path = f"/etc/systemd/system/{service_name}.service"
58
+ wanted_by = "multi-user.target"
59
+
60
+ env_lines = []
61
+ log_level = os.getenv("LOG_LEVEL")
62
+ if log_level:
63
+ env_lines.append(f"Environment=LOG_LEVEL={log_level}")
64
+ unit = "\n".join([
65
+ "[Unit]",
66
+ f"Description={service_name}",
67
+ "After=network.target",
68
+ "",
69
+ "[Service]",
70
+ "Type=simple",
71
+ *env_lines,
72
+ f"ExecStart={' '.join(shlex.quote(arg) for arg in exec_args)}",
73
+ "Restart=always",
74
+ "RestartSec=1",
75
+ "",
76
+ "[Install]",
77
+ f"WantedBy={wanted_by}",
78
+ "",
79
+ ])
80
+ with open(unit_path, "w") as f:
81
+ f.write(unit)
82
+ systemctl = ["systemctl", "--user"] if user_mode else ["systemctl"]
83
+ subprocess.run([*systemctl, "daemon-reload"], check=True)
84
+ subprocess.run([*systemctl, "enable", "--now", service_name], check=True)
85
+
86
+
87
+ def server():
88
+ p = argparse.ArgumentParser()
89
+ p.add_argument("--host", default="0.0.0.0")
90
+ p.add_argument("--port", type=int, default=8765)
91
+ p.add_argument("--secret", "--seceret", dest="secret", default=None)
92
+ p.add_argument("--install", action="store_true")
93
+ p.add_argument("--user", action="store_true", help="Install as user-level systemd service")
94
+ p.add_argument("--cache-size", type=int, default=131072, help="Output cache size in bytes (default: 131072 = 128KB)")
95
+ args = p.parse_args()
96
+ if args.install:
97
+ exe = _resolve_executable("revpty-server")
98
+ cmd = [exe, "--host", args.host, "--port", str(args.port)]
99
+ if args.secret:
100
+ cmd += ["--secret", args.secret]
101
+ if args.cache_size != 131072:
102
+ cmd += ["--cache-size", str(args.cache_size)]
103
+ _install_systemd("revpty-server", cmd, user_mode=args.user)
104
+ return
105
+ run_server(args.host, args.port, secret=args.secret, cache_size=args.cache_size)
106
+
107
+
108
+ def client():
109
+ p = argparse.ArgumentParser()
110
+ p.add_argument("--server", required=True, help="Server URL (auto-converts http/https to ws/wss)")
111
+ p.add_argument("--session", required=True, help="Session name")
112
+ p.add_argument("--proxy", default=None, help="HTTP proxy URL")
113
+ p.add_argument("--secret", "--seceret", dest="secret", default=None)
114
+ p.add_argument("--cf-client-id", dest="cf_client_id", default=None, help="Cloudflare Access Client ID")
115
+ p.add_argument("--cf-client-secret", dest="cf_client_secret", default=None, help="Cloudflare Access Client Secret")
116
+ p.add_argument("--exec", default=None, help="Command to execute (e.g. /bin/bash)")
117
+ p.add_argument("--install", action="store_true")
118
+ p.add_argument("--user", action="store_true", help="Install as user-level systemd service")
119
+ args = p.parse_args()
120
+
121
+ ws_url = convert_to_ws_url(args.server)
122
+ if args.install:
123
+ exe = _resolve_executable("revpty-client")
124
+ cmd = [exe, "--server", args.server, "--session", args.session]
125
+ if args.proxy:
126
+ cmd += ["--proxy", args.proxy]
127
+ if args.secret:
128
+ cmd += ["--secret", args.secret]
129
+ if args.cf_client_id:
130
+ cmd += ["--cf-client-id", args.cf_client_id]
131
+ if args.cf_client_secret:
132
+ cmd += ["--cf-client-secret", args.cf_client_secret]
133
+ if args.exec:
134
+ cmd += ["--exec", args.exec]
135
+ _install_systemd("revpty-client", cmd, user_mode=args.user)
136
+ return
137
+
138
+ shell = args.exec or "/bin/bash"
139
+ asyncio.run(Agent(ws_url, args.session, shell=shell, proxy=args.proxy, secret=args.secret,
140
+ cf_client_id=args.cf_client_id, cf_client_secret=args.cf_client_secret).run())
141
+
142
+
143
+ def attach_cmd():
144
+ p = argparse.ArgumentParser()
145
+ p.add_argument("--server", required=True, help="Server URL (auto-converts http/https to ws/wss)")
146
+ p.add_argument("--session", required=True, help="Session name")
147
+ p.add_argument("--proxy", default=None, help="HTTP proxy URL")
148
+ p.add_argument("--secret", "--seceret", dest="secret", default=None)
149
+ p.add_argument("--cf-client-id", dest="cf_client_id", default=None, help="Cloudflare Access Client ID")
150
+ p.add_argument("--cf-client-secret", dest="cf_client_secret", default=None, help="Cloudflare Access Client Secret")
151
+ args = p.parse_args()
152
+
153
+ ws_url = convert_to_ws_url(args.server)
154
+ asyncio.run(attach(ws_url, args.session, proxy=args.proxy, secret=args.secret,
155
+ cf_client_id=args.cf_client_id, cf_client_secret=args.cf_client_secret))
@@ -0,0 +1 @@
1
+ """Client agent for PTY management"""