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.
- revpty-0.5.7/PKG-INFO +63 -0
- revpty-0.5.7/README.md +54 -0
- revpty-0.5.7/pyproject.toml +16 -0
- revpty-0.5.7/revpty/__init__.py +3 -0
- revpty-0.5.7/revpty/cli/__init__.py +1 -0
- revpty-0.5.7/revpty/cli/attach.py +308 -0
- revpty-0.5.7/revpty/cli/main.py +155 -0
- revpty-0.5.7/revpty/client/__init__.py +1 -0
- revpty-0.5.7/revpty/client/agent.py +429 -0
- revpty-0.5.7/revpty/client/file_manager.py +337 -0
- revpty-0.5.7/revpty/client/mux.py +427 -0
- revpty-0.5.7/revpty/client/pty_shell.py +187 -0
- revpty-0.5.7/revpty/client/tunnel_proxy.py +130 -0
- revpty-0.5.7/revpty/protocol/__init__.py +1 -0
- revpty-0.5.7/revpty/protocol/codec.py +80 -0
- revpty-0.5.7/revpty/protocol/frame.py +106 -0
- revpty-0.5.7/revpty/server/__init__.py +1 -0
- revpty-0.5.7/revpty/server/app.py +581 -0
- revpty-0.5.7/revpty/server/router.py +23 -0
- revpty-0.5.7/revpty/server/static/app.js +912 -0
- revpty-0.5.7/revpty/server/static/index.html +103 -0
- revpty-0.5.7/revpty/server/static/style.css +88 -0
- revpty-0.5.7/revpty/server/static/utils.js +47 -0
- revpty-0.5.7/revpty/server/tunnel.py +175 -0
- revpty-0.5.7/revpty/session/__init__.py +5 -0
- revpty-0.5.7/revpty/session/buffer.py +34 -0
- revpty-0.5.7/revpty/session/manager.py +249 -0
- revpty-0.5.7/revpty.egg-info/PKG-INFO +63 -0
- revpty-0.5.7/revpty.egg-info/SOURCES.txt +38 -0
- revpty-0.5.7/revpty.egg-info/dependency_links.txt +1 -0
- revpty-0.5.7/revpty.egg-info/entry_points.txt +4 -0
- revpty-0.5.7/revpty.egg-info/requires.txt +1 -0
- revpty-0.5.7/revpty.egg-info/top_level.txt +1 -0
- revpty-0.5.7/setup.cfg +4 -0
- revpty-0.5.7/tests/test_agent.py +144 -0
- revpty-0.5.7/tests/test_attach_terminal.py +72 -0
- revpty-0.5.7/tests/test_integration_attach.py +68 -0
- revpty-0.5.7/tests/test_new_features.py +218 -0
- revpty-0.5.7/tests/test_protocol.py +42 -0
- 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 @@
|
|
|
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"""
|