ptn 0.1.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.
- porterminal/__init__.py +288 -0
- porterminal/__main__.py +8 -0
- porterminal/app.py +381 -0
- porterminal/application/__init__.py +1 -0
- porterminal/application/ports/__init__.py +7 -0
- porterminal/application/ports/connection_port.py +34 -0
- porterminal/application/services/__init__.py +13 -0
- porterminal/application/services/management_service.py +279 -0
- porterminal/application/services/session_service.py +249 -0
- porterminal/application/services/tab_service.py +286 -0
- porterminal/application/services/terminal_service.py +426 -0
- porterminal/asgi.py +38 -0
- porterminal/cli/__init__.py +19 -0
- porterminal/cli/args.py +91 -0
- porterminal/cli/display.py +157 -0
- porterminal/composition.py +208 -0
- porterminal/config.py +195 -0
- porterminal/container.py +65 -0
- porterminal/domain/__init__.py +91 -0
- porterminal/domain/entities/__init__.py +16 -0
- porterminal/domain/entities/output_buffer.py +73 -0
- porterminal/domain/entities/session.py +86 -0
- porterminal/domain/entities/tab.py +71 -0
- porterminal/domain/ports/__init__.py +12 -0
- porterminal/domain/ports/pty_port.py +80 -0
- porterminal/domain/ports/session_repository.py +58 -0
- porterminal/domain/ports/tab_repository.py +75 -0
- porterminal/domain/services/__init__.py +18 -0
- porterminal/domain/services/environment_sanitizer.py +61 -0
- porterminal/domain/services/rate_limiter.py +63 -0
- porterminal/domain/services/session_limits.py +104 -0
- porterminal/domain/services/tab_limits.py +54 -0
- porterminal/domain/values/__init__.py +25 -0
- porterminal/domain/values/environment_rules.py +156 -0
- porterminal/domain/values/rate_limit_config.py +21 -0
- porterminal/domain/values/session_id.py +20 -0
- porterminal/domain/values/shell_command.py +37 -0
- porterminal/domain/values/tab_id.py +24 -0
- porterminal/domain/values/terminal_dimensions.py +45 -0
- porterminal/domain/values/user_id.py +25 -0
- porterminal/infrastructure/__init__.py +20 -0
- porterminal/infrastructure/cloudflared.py +295 -0
- porterminal/infrastructure/config/__init__.py +9 -0
- porterminal/infrastructure/config/shell_detector.py +84 -0
- porterminal/infrastructure/config/yaml_loader.py +34 -0
- porterminal/infrastructure/network.py +43 -0
- porterminal/infrastructure/registry/__init__.py +5 -0
- porterminal/infrastructure/registry/user_connection_registry.py +104 -0
- porterminal/infrastructure/repositories/__init__.py +9 -0
- porterminal/infrastructure/repositories/in_memory_session.py +70 -0
- porterminal/infrastructure/repositories/in_memory_tab.py +124 -0
- porterminal/infrastructure/server.py +161 -0
- porterminal/infrastructure/web/__init__.py +7 -0
- porterminal/infrastructure/web/websocket_adapter.py +78 -0
- porterminal/logging_setup.py +48 -0
- porterminal/pty/__init__.py +46 -0
- porterminal/pty/env.py +97 -0
- porterminal/pty/manager.py +163 -0
- porterminal/pty/protocol.py +84 -0
- porterminal/pty/unix.py +162 -0
- porterminal/pty/windows.py +131 -0
- porterminal/static/assets/app-BQiuUo6Q.css +32 -0
- porterminal/static/assets/app-YNN_jEhv.js +71 -0
- porterminal/static/icon.svg +34 -0
- porterminal/static/index.html +139 -0
- porterminal/static/manifest.json +31 -0
- porterminal/static/sw.js +66 -0
- porterminal/updater.py +257 -0
- ptn-0.1.4.dist-info/METADATA +191 -0
- ptn-0.1.4.dist-info/RECORD +73 -0
- ptn-0.1.4.dist-info/WHEEL +4 -0
- ptn-0.1.4.dist-info/entry_points.txt +2 -0
- ptn-0.1.4.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Connection port - interface for terminal network connections."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConnectionPort(Protocol):
|
|
7
|
+
"""Protocol for terminal I/O over network connection.
|
|
8
|
+
|
|
9
|
+
Presentation layer (e.g., WebSocket adapter) implements this.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
async def send_output(self, data: bytes) -> None:
|
|
13
|
+
"""Send terminal output to client."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
async def send_message(self, message: dict) -> None:
|
|
17
|
+
"""Send JSON control message to client."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
async def receive(self) -> dict | bytes:
|
|
21
|
+
"""Receive message from client (binary or JSON).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
bytes for terminal input, dict for control messages.
|
|
25
|
+
"""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
async def close(self, code: int = 1000, reason: str = "") -> None:
|
|
29
|
+
"""Close the connection."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def is_connected(self) -> bool:
|
|
33
|
+
"""Check if connection is still open."""
|
|
34
|
+
...
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Application services - use case implementations."""
|
|
2
|
+
|
|
3
|
+
from .management_service import ManagementService
|
|
4
|
+
from .session_service import SessionService
|
|
5
|
+
from .tab_service import TabService
|
|
6
|
+
from .terminal_service import TerminalService
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ManagementService",
|
|
10
|
+
"SessionService",
|
|
11
|
+
"TabService",
|
|
12
|
+
"TerminalService",
|
|
13
|
+
]
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Management service - handles tab management requests via WebSocket."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from porterminal.application.ports import ConnectionPort
|
|
7
|
+
from porterminal.application.services.session_service import SessionService
|
|
8
|
+
from porterminal.application.services.tab_service import TabService
|
|
9
|
+
from porterminal.domain import (
|
|
10
|
+
ShellCommand,
|
|
11
|
+
TerminalDimensions,
|
|
12
|
+
UserId,
|
|
13
|
+
)
|
|
14
|
+
from porterminal.infrastructure.registry import UserConnectionRegistry
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ManagementService:
|
|
20
|
+
"""Service for handling management WebSocket messages.
|
|
21
|
+
|
|
22
|
+
Handles tab creation, closure, and rename requests.
|
|
23
|
+
Broadcasts state updates to other connections.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
session_service: SessionService,
|
|
29
|
+
tab_service: TabService,
|
|
30
|
+
connection_registry: UserConnectionRegistry,
|
|
31
|
+
shell_provider: Callable[[str | None], ShellCommand | None],
|
|
32
|
+
default_dimensions: TerminalDimensions,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._session_service = session_service
|
|
35
|
+
self._tab_service = tab_service
|
|
36
|
+
self._registry = connection_registry
|
|
37
|
+
self._get_shell = shell_provider
|
|
38
|
+
self._default_dims = default_dimensions
|
|
39
|
+
|
|
40
|
+
async def handle_message(
|
|
41
|
+
self,
|
|
42
|
+
user_id: UserId,
|
|
43
|
+
connection: ConnectionPort,
|
|
44
|
+
message: dict,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Handle an incoming management message.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
user_id: User sending the message.
|
|
50
|
+
connection: Connection that received the message.
|
|
51
|
+
message: Message dict with 'type' and request data.
|
|
52
|
+
"""
|
|
53
|
+
msg_type = message.get("type")
|
|
54
|
+
|
|
55
|
+
if msg_type == "create_tab":
|
|
56
|
+
await self._handle_create_tab(user_id, connection, message)
|
|
57
|
+
elif msg_type == "close_tab":
|
|
58
|
+
await self._handle_close_tab(user_id, connection, message)
|
|
59
|
+
elif msg_type == "rename_tab":
|
|
60
|
+
await self._handle_rename_tab(user_id, connection, message)
|
|
61
|
+
elif msg_type == "ping":
|
|
62
|
+
await connection.send_message({"type": "pong"})
|
|
63
|
+
else:
|
|
64
|
+
logger.warning("Unknown management message type: %s", msg_type)
|
|
65
|
+
|
|
66
|
+
async def _handle_create_tab(
|
|
67
|
+
self,
|
|
68
|
+
user_id: UserId,
|
|
69
|
+
connection: ConnectionPort,
|
|
70
|
+
message: dict,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Handle tab creation request."""
|
|
73
|
+
request_id = message.get("request_id", "")
|
|
74
|
+
shell_id = message.get("shell_id")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# Get shell
|
|
78
|
+
shell = self._get_shell(shell_id)
|
|
79
|
+
if not shell:
|
|
80
|
+
await connection.send_message(
|
|
81
|
+
{
|
|
82
|
+
"type": "create_tab_response",
|
|
83
|
+
"request_id": request_id,
|
|
84
|
+
"success": False,
|
|
85
|
+
"error": "Invalid shell",
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
# Create session
|
|
91
|
+
session = await self._session_service.create_session(
|
|
92
|
+
user_id=user_id,
|
|
93
|
+
shell=shell,
|
|
94
|
+
dimensions=self._default_dims,
|
|
95
|
+
)
|
|
96
|
+
session.add_client()
|
|
97
|
+
|
|
98
|
+
# Create tab
|
|
99
|
+
tab = self._tab_service.create_tab(
|
|
100
|
+
user_id=user_id,
|
|
101
|
+
session_id=session.id,
|
|
102
|
+
shell_id=shell.id,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
logger.info(
|
|
106
|
+
"Management created tab user_id=%s tab_id=%s session_id=%s",
|
|
107
|
+
user_id,
|
|
108
|
+
tab.tab_id,
|
|
109
|
+
session.session_id,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Send response to requester
|
|
113
|
+
await connection.send_message(
|
|
114
|
+
{
|
|
115
|
+
"type": "create_tab_response",
|
|
116
|
+
"request_id": request_id,
|
|
117
|
+
"success": True,
|
|
118
|
+
"tab": tab.to_dict(),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Broadcast update to OTHER connections
|
|
123
|
+
await self._registry.broadcast(
|
|
124
|
+
user_id,
|
|
125
|
+
self._tab_service.build_tab_state_update("add", tab),
|
|
126
|
+
exclude=connection,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
except ValueError as e:
|
|
130
|
+
logger.warning("Tab creation failed: %s", e)
|
|
131
|
+
await connection.send_message(
|
|
132
|
+
{
|
|
133
|
+
"type": "create_tab_response",
|
|
134
|
+
"request_id": request_id,
|
|
135
|
+
"success": False,
|
|
136
|
+
"error": str(e),
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def _handle_close_tab(
|
|
141
|
+
self,
|
|
142
|
+
user_id: UserId,
|
|
143
|
+
connection: ConnectionPort,
|
|
144
|
+
message: dict,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Handle tab close request."""
|
|
147
|
+
request_id = message.get("request_id", "")
|
|
148
|
+
tab_id = message.get("tab_id")
|
|
149
|
+
|
|
150
|
+
if not tab_id:
|
|
151
|
+
await connection.send_message(
|
|
152
|
+
{
|
|
153
|
+
"type": "close_tab_response",
|
|
154
|
+
"request_id": request_id,
|
|
155
|
+
"success": False,
|
|
156
|
+
"error": "Missing tab_id",
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# Get tab and session info before closing
|
|
162
|
+
tab = self._tab_service.get_tab(tab_id)
|
|
163
|
+
if not tab:
|
|
164
|
+
await connection.send_message(
|
|
165
|
+
{
|
|
166
|
+
"type": "close_tab_response",
|
|
167
|
+
"request_id": request_id,
|
|
168
|
+
"success": False,
|
|
169
|
+
"error": "Tab not found",
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
session_id = tab.session_id
|
|
175
|
+
|
|
176
|
+
# Close the tab
|
|
177
|
+
closed_tab = self._tab_service.close_tab(tab_id, user_id)
|
|
178
|
+
if not closed_tab:
|
|
179
|
+
await connection.send_message(
|
|
180
|
+
{
|
|
181
|
+
"type": "close_tab_response",
|
|
182
|
+
"request_id": request_id,
|
|
183
|
+
"success": False,
|
|
184
|
+
"error": "Failed to close tab",
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
# Destroy the session (which will stop the PTY)
|
|
190
|
+
await self._session_service.destroy_session(session_id)
|
|
191
|
+
|
|
192
|
+
logger.info(
|
|
193
|
+
"Management closed tab user_id=%s tab_id=%s",
|
|
194
|
+
user_id,
|
|
195
|
+
tab_id,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Send response to requester
|
|
199
|
+
await connection.send_message(
|
|
200
|
+
{
|
|
201
|
+
"type": "close_tab_response",
|
|
202
|
+
"request_id": request_id,
|
|
203
|
+
"success": True,
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Broadcast update to OTHER connections
|
|
208
|
+
await self._registry.broadcast(
|
|
209
|
+
user_id,
|
|
210
|
+
self._tab_service.build_tab_state_update("remove", closed_tab, reason="user"),
|
|
211
|
+
exclude=connection,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
async def _handle_rename_tab(
|
|
215
|
+
self,
|
|
216
|
+
user_id: UserId,
|
|
217
|
+
connection: ConnectionPort,
|
|
218
|
+
message: dict,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Handle tab rename request."""
|
|
221
|
+
request_id = message.get("request_id", "")
|
|
222
|
+
tab_id = message.get("tab_id")
|
|
223
|
+
new_name = message.get("name")
|
|
224
|
+
|
|
225
|
+
if not tab_id or not new_name:
|
|
226
|
+
await connection.send_message(
|
|
227
|
+
{
|
|
228
|
+
"type": "rename_tab_response",
|
|
229
|
+
"request_id": request_id,
|
|
230
|
+
"success": False,
|
|
231
|
+
"error": "Missing tab_id or name",
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
# Rename the tab
|
|
237
|
+
tab = self._tab_service.rename_tab(tab_id, user_id, new_name)
|
|
238
|
+
if not tab:
|
|
239
|
+
await connection.send_message(
|
|
240
|
+
{
|
|
241
|
+
"type": "rename_tab_response",
|
|
242
|
+
"request_id": request_id,
|
|
243
|
+
"success": False,
|
|
244
|
+
"error": "Failed to rename tab",
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
logger.info(
|
|
250
|
+
"Management renamed tab user_id=%s tab_id=%s new_name=%s",
|
|
251
|
+
user_id,
|
|
252
|
+
tab_id,
|
|
253
|
+
new_name,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Send response to requester
|
|
257
|
+
await connection.send_message(
|
|
258
|
+
{
|
|
259
|
+
"type": "rename_tab_response",
|
|
260
|
+
"request_id": request_id,
|
|
261
|
+
"success": True,
|
|
262
|
+
"tab": tab.to_dict(),
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Broadcast update to OTHER connections
|
|
267
|
+
await self._registry.broadcast(
|
|
268
|
+
user_id,
|
|
269
|
+
self._tab_service.build_tab_state_update("update", tab),
|
|
270
|
+
exclude=connection,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def build_state_sync(self, user_id: UserId) -> dict:
|
|
274
|
+
"""Build initial state sync message for a user.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Message dict with all tabs for the user.
|
|
278
|
+
"""
|
|
279
|
+
return self._tab_service.build_tab_state_sync(user_id)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Session service - session lifecycle management."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
|
|
10
|
+
from porterminal.domain import (
|
|
11
|
+
EnvironmentRules,
|
|
12
|
+
EnvironmentSanitizer,
|
|
13
|
+
PTYPort,
|
|
14
|
+
Session,
|
|
15
|
+
SessionId,
|
|
16
|
+
SessionLimitChecker,
|
|
17
|
+
ShellCommand,
|
|
18
|
+
TerminalDimensions,
|
|
19
|
+
UserId,
|
|
20
|
+
)
|
|
21
|
+
from porterminal.domain.ports import SessionRepository
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SessionService:
|
|
27
|
+
"""Service for managing terminal sessions.
|
|
28
|
+
|
|
29
|
+
Handles session creation, reconnection, destruction, and cleanup.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
repository: SessionRepository[PTYPort],
|
|
35
|
+
pty_factory: Callable[
|
|
36
|
+
[ShellCommand, TerminalDimensions, dict[str, str], str | None], PTYPort
|
|
37
|
+
],
|
|
38
|
+
limit_checker: SessionLimitChecker | None = None,
|
|
39
|
+
environment_sanitizer: EnvironmentSanitizer | None = None,
|
|
40
|
+
working_directory: str | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._repository = repository
|
|
43
|
+
self._pty_factory = pty_factory
|
|
44
|
+
self._limit_checker = limit_checker or SessionLimitChecker()
|
|
45
|
+
self._sanitizer = environment_sanitizer or EnvironmentSanitizer(EnvironmentRules())
|
|
46
|
+
self._cwd = working_directory
|
|
47
|
+
self._running = False
|
|
48
|
+
self._cleanup_task: asyncio.Task | None = None
|
|
49
|
+
self._on_session_destroyed: Callable[[SessionId, UserId], Awaitable[None]] | None = None
|
|
50
|
+
|
|
51
|
+
def set_on_session_destroyed(
|
|
52
|
+
self, callback: Callable[[SessionId, UserId], Awaitable[None]]
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Set async callback to be invoked when a session is destroyed.
|
|
55
|
+
|
|
56
|
+
Used for cascading cleanup (e.g., closing associated tabs and broadcasting).
|
|
57
|
+
"""
|
|
58
|
+
self._on_session_destroyed = callback
|
|
59
|
+
|
|
60
|
+
async def start(self) -> None:
|
|
61
|
+
"""Start the session service (cleanup loop)."""
|
|
62
|
+
self._running = True
|
|
63
|
+
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
|
64
|
+
logger.info("Session service started")
|
|
65
|
+
|
|
66
|
+
async def stop(self) -> None:
|
|
67
|
+
"""Stop the session service and cleanup all sessions."""
|
|
68
|
+
self._running = False
|
|
69
|
+
|
|
70
|
+
if self._cleanup_task:
|
|
71
|
+
self._cleanup_task.cancel()
|
|
72
|
+
try:
|
|
73
|
+
await self._cleanup_task
|
|
74
|
+
except asyncio.CancelledError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
# Close all sessions
|
|
78
|
+
for session in self._repository.all_sessions():
|
|
79
|
+
await self.destroy_session(session.id)
|
|
80
|
+
|
|
81
|
+
logger.info("Session service stopped")
|
|
82
|
+
|
|
83
|
+
async def create_session(
|
|
84
|
+
self,
|
|
85
|
+
user_id: UserId,
|
|
86
|
+
shell: ShellCommand,
|
|
87
|
+
dimensions: TerminalDimensions,
|
|
88
|
+
) -> Session[PTYPort]:
|
|
89
|
+
"""Create a new terminal session.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
user_id: User requesting the session.
|
|
93
|
+
shell: Shell command to run.
|
|
94
|
+
dimensions: Initial terminal dimensions.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Created session.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ValueError: If session limits exceeded.
|
|
101
|
+
"""
|
|
102
|
+
# Check limits
|
|
103
|
+
limit_result = self._limit_checker.can_create_session(
|
|
104
|
+
user_id,
|
|
105
|
+
self._repository.count_for_user(user_id),
|
|
106
|
+
self._repository.count(),
|
|
107
|
+
)
|
|
108
|
+
if not limit_result.allowed:
|
|
109
|
+
raise ValueError(limit_result.reason)
|
|
110
|
+
|
|
111
|
+
# Create PTY with sanitized environment
|
|
112
|
+
env = self._sanitizer.sanitize(dict(os.environ))
|
|
113
|
+
pty = self._pty_factory(shell, dimensions, env, self._cwd)
|
|
114
|
+
|
|
115
|
+
# Create session (starts with 0 clients, caller adds via add_client())
|
|
116
|
+
now = datetime.now(UTC)
|
|
117
|
+
session = Session(
|
|
118
|
+
id=SessionId(str(uuid.uuid4())),
|
|
119
|
+
user_id=user_id,
|
|
120
|
+
shell_id=shell.id,
|
|
121
|
+
dimensions=dimensions,
|
|
122
|
+
created_at=now,
|
|
123
|
+
last_activity=now,
|
|
124
|
+
pty_handle=pty,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
self._repository.add(session)
|
|
128
|
+
logger.info(
|
|
129
|
+
"Session created session_id=%s user_id=%s shell=%s",
|
|
130
|
+
session.id,
|
|
131
|
+
user_id,
|
|
132
|
+
shell.id,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return session
|
|
136
|
+
|
|
137
|
+
async def reconnect_session(
|
|
138
|
+
self,
|
|
139
|
+
session_id: SessionId,
|
|
140
|
+
user_id: UserId,
|
|
141
|
+
) -> Session[PTYPort] | None:
|
|
142
|
+
"""Reconnect to an existing session.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
session_id: Session to reconnect to.
|
|
146
|
+
user_id: User requesting reconnection.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Session if reconnection successful, None otherwise.
|
|
150
|
+
"""
|
|
151
|
+
session = self._repository.get(session_id)
|
|
152
|
+
if not session:
|
|
153
|
+
logger.warning("Reconnect failed: session not found session_id=%s", session_id)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
# Check ownership
|
|
157
|
+
limit_result = self._limit_checker.can_reconnect(session, user_id)
|
|
158
|
+
if not limit_result.allowed:
|
|
159
|
+
logger.warning(
|
|
160
|
+
"Reconnect denied: %s session_id=%s user_id=%s",
|
|
161
|
+
limit_result.reason,
|
|
162
|
+
session_id,
|
|
163
|
+
user_id,
|
|
164
|
+
)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
# Check if PTY is still alive
|
|
168
|
+
if not session.pty_handle.is_alive():
|
|
169
|
+
logger.warning("Reconnect failed: PTY dead session_id=%s", session_id)
|
|
170
|
+
await self.destroy_session(session_id)
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
session.add_client()
|
|
174
|
+
session.touch(datetime.now(UTC))
|
|
175
|
+
logger.info("Session reconnected session_id=%s user_id=%s", session_id, user_id)
|
|
176
|
+
|
|
177
|
+
return session
|
|
178
|
+
|
|
179
|
+
def disconnect_session(self, session_id: SessionId) -> None:
|
|
180
|
+
"""Remove a client from session (but keep alive for reconnection)."""
|
|
181
|
+
session = self._repository.get(session_id)
|
|
182
|
+
if session:
|
|
183
|
+
remaining = session.remove_client()
|
|
184
|
+
session.touch(datetime.now(UTC))
|
|
185
|
+
logger.info(
|
|
186
|
+
"Client disconnected session_id=%s remaining_clients=%d",
|
|
187
|
+
session_id,
|
|
188
|
+
remaining,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def destroy_session(self, session_id: SessionId) -> None:
|
|
192
|
+
"""Destroy a session completely."""
|
|
193
|
+
session = self._repository.remove(session_id)
|
|
194
|
+
if session:
|
|
195
|
+
# Invoke cascade callback (e.g., to close associated tabs)
|
|
196
|
+
if self._on_session_destroyed:
|
|
197
|
+
try:
|
|
198
|
+
await self._on_session_destroyed(session_id, session.user_id)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.warning(
|
|
201
|
+
"Error in session destroyed callback session_id=%s: %s",
|
|
202
|
+
session_id,
|
|
203
|
+
e,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
session.pty_handle.close()
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.warning("Error closing PTY session_id=%s: %s", session_id, e)
|
|
210
|
+
|
|
211
|
+
session.clear_buffer()
|
|
212
|
+
logger.info("Session destroyed session_id=%s", session_id)
|
|
213
|
+
|
|
214
|
+
def get_session(self, session_id: str) -> Session[PTYPort] | None:
|
|
215
|
+
"""Get session by ID string."""
|
|
216
|
+
return self._repository.get_by_id_str(session_id)
|
|
217
|
+
|
|
218
|
+
def get_user_sessions(self, user_id: UserId) -> list[Session[PTYPort]]:
|
|
219
|
+
"""Get all sessions for a user."""
|
|
220
|
+
return self._repository.get_by_user(user_id)
|
|
221
|
+
|
|
222
|
+
def session_count(self) -> int:
|
|
223
|
+
"""Get total session count."""
|
|
224
|
+
return self._repository.count()
|
|
225
|
+
|
|
226
|
+
async def _cleanup_loop(self) -> None:
|
|
227
|
+
"""Background task to cleanup stale sessions."""
|
|
228
|
+
while self._running:
|
|
229
|
+
await asyncio.sleep(60)
|
|
230
|
+
await self._cleanup_stale_sessions()
|
|
231
|
+
|
|
232
|
+
async def _cleanup_stale_sessions(self) -> None:
|
|
233
|
+
"""Check and cleanup stale sessions."""
|
|
234
|
+
now = datetime.now(UTC)
|
|
235
|
+
|
|
236
|
+
for session in self._repository.all_sessions():
|
|
237
|
+
should_cleanup, reason = self._limit_checker.should_cleanup_session(
|
|
238
|
+
session,
|
|
239
|
+
now,
|
|
240
|
+
session.pty_handle.is_alive(),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if should_cleanup:
|
|
244
|
+
logger.info(
|
|
245
|
+
"Cleaning up session session_id=%s reason=%s",
|
|
246
|
+
session.id,
|
|
247
|
+
reason,
|
|
248
|
+
)
|
|
249
|
+
await self.destroy_session(session.id)
|