tabby-rdp 0.1.0

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.
@@ -0,0 +1,561 @@
1
+ #!/usr/bin/env python3
2
+ """trd-pty: a shell session that outlives its terminals and can be shown in several at once.
3
+
4
+ A session is one small process that owns a PTY running the user's login shell and listens on a Unix
5
+ socket in a private per-user directory (0700; peers must have the same uid). Terminals attach with
6
+ `trd-pty attach`:
7
+
8
+ - output from the shell goes, byte for byte, to every attached terminal;
9
+ - input from any attached terminal goes to the shell;
10
+ - the PTY size follows the *controlling* terminal: the one that attached, typed or was focused last,
11
+ so terminals of different sizes never fight over it;
12
+ - a terminal that attaches late first gets a bounded replay of recent output, then a forced redraw
13
+ (full-screen programs repaint on the size change).
14
+
15
+ Lifetime matches a plain SSH login: the shell gets SIGHUP when its last terminal detaches (set
16
+ TRD_PTY_LINGER=<seconds> to keep it around for a reattach), and the session ends when the shell exits.
17
+
18
+ trd-pty new start a session running the login shell and attach this terminal
19
+ (exit 0: the session ended; 3: no session could be started)
20
+ trd-pty attach ID attach this terminal to session ID
21
+ trd-pty ls list sessions (id, attached terminals, shell pid, cwd)
22
+ trd-pty clients ID print how many terminals are attached to session ID
23
+ """
24
+ import errno
25
+ import fcntl
26
+ import json
27
+ import os
28
+ import pty
29
+ import secrets
30
+ import selectors
31
+ import signal
32
+ import socket
33
+ import struct
34
+ import sys
35
+ import termios
36
+ import time
37
+ import tty
38
+
39
+ REPLAY_BYTES = 256 * 1024 # recent output replayed to a terminal that attaches late
40
+ MAX_FRAME = 1024 * 1024 # larger frames are treated as a broken client
41
+ MAX_BACKLOG = 8 * 1024 * 1024 # a terminal this far behind is dropped rather than stalling the others
42
+ QUIET_AFTER_REPLAY = 0.3 # seconds: drop input right after a replay (the terminal's automatic
43
+ # answers to replayed queries, e.g. cursor position reports)
44
+ HEADER = struct.Struct('!cI')
45
+ SIZE = struct.Struct('!HH')
46
+
47
+ # client -> session: h hello(rows, cols, claim) | i input | r resize(rows, cols) | c claim control | q query
48
+ # session -> client: o output | x exit(status) | q query answer (json)
49
+
50
+
51
+ def runtime_dir():
52
+ """Private per-user directory for session sockets; refuses anything another user could touch."""
53
+ uid = os.getuid()
54
+ base = os.environ.get('XDG_RUNTIME_DIR')
55
+ if not base or not os.path.isdir(base) or os.stat(base).st_uid != uid:
56
+ base = '/tmp'
57
+ path = os.path.join(base, 'trd-pty' if base != '/tmp' else f'trd-pty-{uid}')
58
+ try:
59
+ os.mkdir(path, 0o700)
60
+ except FileExistsError:
61
+ pass
62
+ st = os.lstat(path)
63
+ if not os.path.isdir(path) or os.path.islink(path) or st.st_uid != uid or st.st_mode & 0o077:
64
+ sys.exit(f'trd-pty: unsafe session directory {path}')
65
+ return path
66
+
67
+
68
+ def socket_path(session_id):
69
+ if not session_id or not all(c in '0123456789abcdef' for c in session_id):
70
+ sys.exit(f'trd-pty: bad session id {session_id!r}')
71
+ return os.path.join(runtime_dir(), session_id + '.sock')
72
+
73
+
74
+ def frame(kind, payload=b''):
75
+ return HEADER.pack(kind, len(payload)) + payload
76
+
77
+
78
+ class Frames:
79
+ """Splits a byte stream into (kind, payload) frames."""
80
+
81
+ def __init__(self):
82
+ self.buf = bytearray()
83
+
84
+ def feed(self, data):
85
+ self.buf += data
86
+ frames = []
87
+ while len(self.buf) >= HEADER.size:
88
+ kind, length = HEADER.unpack_from(self.buf)
89
+ if length > MAX_FRAME:
90
+ raise ValueError('frame too large')
91
+ if len(self.buf) < HEADER.size + length:
92
+ break
93
+ frames.append((kind, bytes(self.buf[HEADER.size:HEADER.size + length])))
94
+ del self.buf[:HEADER.size + length]
95
+ return frames
96
+
97
+
98
+ def term_size(fd):
99
+ try:
100
+ rows, cols, _, _ = struct.unpack('HHHH', fcntl.ioctl(fd, termios.TIOCGWINSZ, b'\0' * 8))
101
+ return rows or 24, cols or 80
102
+ except OSError:
103
+ return 24, 80
104
+
105
+
106
+ # ---- session owner -------------------------------------------------------------------------------
107
+
108
+ class Client:
109
+ def __init__(self, sock):
110
+ self.sock = sock
111
+ self.frames = Frames()
112
+ self.out = bytearray()
113
+ self.hello = False
114
+ self.size = (24, 80)
115
+ self.quiet_until = 0.0
116
+ self.active = time.monotonic()
117
+
118
+
119
+ class Session:
120
+ def __init__(self, session_id, path, rows, cols, cwd, env):
121
+ self.id = session_id
122
+ self.path = path
123
+ self.sel = selectors.DefaultSelector()
124
+ self.clients = {}
125
+ self.controller = None
126
+ self.replay = bytearray()
127
+ self.to_shell = bytearray()
128
+ self.size = None
129
+ self.linger = float(os.environ.get('TRD_PTY_LINGER', '0') or 0)
130
+ self.empty_since = None
131
+
132
+ shell = env.get('SHELL') or '/bin/sh'
133
+ pid, fd = pty.fork()
134
+ if pid == 0: # the shell
135
+ try:
136
+ os.chdir(cwd)
137
+ except OSError:
138
+ pass
139
+ env = dict(env, TRD_SESSION=session_id, TRD_SOCKET=path)
140
+ os.execve(shell, ['-' + os.path.basename(shell)], env) # login shell, like a fresh SSH login
141
+ self.pid, self.master = pid, fd
142
+ os.set_blocking(fd, False)
143
+ self.apply_size(rows, cols)
144
+
145
+ self.listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
146
+ old_umask = os.umask(0o177)
147
+ try:
148
+ self.listener.bind(path)
149
+ finally:
150
+ os.umask(old_umask)
151
+ self.listener.listen(8)
152
+ self.listener.setblocking(False)
153
+ self.sel.register(self.listener, selectors.EVENT_READ, 'accept')
154
+ self.sel.register(self.master, selectors.EVENT_READ, 'pty')
155
+
156
+ # -- pty
157
+
158
+ def apply_size(self, rows, cols, force=False):
159
+ if (rows, cols) == self.size and not force:
160
+ return
161
+ if force and (rows, cols) == self.size and rows > 1:
162
+ # Same size: nudge it so full-screen programs still get SIGWINCH and repaint.
163
+ fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack('HHHH', rows - 1, cols, 0, 0))
164
+ fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
165
+ self.size = (rows, cols)
166
+
167
+ def take_control(self, client, force=False):
168
+ self.controller = client
169
+ self.apply_size(*client.size, force=force)
170
+
171
+ def from_shell(self):
172
+ try:
173
+ data = os.read(self.master, 65536)
174
+ except OSError as e:
175
+ if e.errno in (errno.EAGAIN, errno.EINTR):
176
+ return True
177
+ data = b'' # EIO: the shell side is gone
178
+ if not data:
179
+ return False
180
+ self.replay += data
181
+ if len(self.replay) > REPLAY_BYTES:
182
+ del self.replay[:len(self.replay) - REPLAY_BYTES]
183
+ # Start the replay at a line boundary, not inside an escape sequence or character.
184
+ nl = self.replay.find(b'\n')
185
+ if 0 <= nl < 4096:
186
+ del self.replay[:nl + 1]
187
+ out = frame(b'o', data)
188
+ for client in list(self.clients.values()):
189
+ if client.hello:
190
+ self.send(client, out)
191
+ return True
192
+
193
+ def write_shell(self, data):
194
+ self.to_shell += data
195
+ self.flush_shell()
196
+
197
+ def flush_shell(self):
198
+ try:
199
+ while self.to_shell:
200
+ n = os.write(self.master, self.to_shell)
201
+ del self.to_shell[:n]
202
+ except OSError as e:
203
+ if e.errno not in (errno.EAGAIN, errno.EINTR):
204
+ self.to_shell.clear()
205
+ events = selectors.EVENT_READ | (selectors.EVENT_WRITE if self.to_shell else 0)
206
+ self.sel.modify(self.master, events, 'pty')
207
+
208
+ # -- clients
209
+
210
+ def accept(self):
211
+ try:
212
+ sock, _ = self.listener.accept()
213
+ except OSError:
214
+ return
215
+ if hasattr(socket, 'SO_PEERCRED'):
216
+ _, uid, _ = struct.unpack('3i', sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
217
+ if uid != os.getuid():
218
+ sock.close()
219
+ return
220
+ sock.setblocking(False)
221
+ client = Client(sock)
222
+ self.clients[sock] = client
223
+ self.sel.register(sock, selectors.EVENT_READ, client)
224
+
225
+ def send(self, client, data):
226
+ client.out += data
227
+ if len(client.out) > MAX_BACKLOG:
228
+ self.drop(client)
229
+ return
230
+ self.flush(client)
231
+
232
+ def flush(self, client):
233
+ try:
234
+ while client.out:
235
+ n = client.sock.send(client.out)
236
+ del client.out[:n]
237
+ except BlockingIOError:
238
+ pass
239
+ except OSError:
240
+ self.drop(client)
241
+ return
242
+ if client.sock in self.clients:
243
+ events = selectors.EVENT_READ | (selectors.EVENT_WRITE if client.out else 0)
244
+ self.sel.modify(client.sock, events, client)
245
+
246
+ def drop(self, client):
247
+ if self.clients.pop(client.sock, None) is None:
248
+ return
249
+ try:
250
+ self.sel.unregister(client.sock)
251
+ except (KeyError, ValueError):
252
+ pass
253
+ client.sock.close()
254
+ if self.controller is client:
255
+ self.controller = None
256
+ rest = [c for c in self.clients.values() if c.hello]
257
+ if rest:
258
+ self.take_control(max(rest, key=lambda c: c.active))
259
+ if not any(c.hello for c in self.clients.values()):
260
+ self.empty_since = time.monotonic()
261
+
262
+ def from_client(self, client):
263
+ try:
264
+ data = client.sock.recv(65536)
265
+ except BlockingIOError:
266
+ return
267
+ except OSError:
268
+ data = b''
269
+ if not data:
270
+ self.drop(client)
271
+ return
272
+ try:
273
+ frames = client.frames.feed(data)
274
+ except ValueError:
275
+ self.drop(client)
276
+ return
277
+ for kind, payload in frames:
278
+ try:
279
+ self.handle(client, kind, payload)
280
+ except (struct.error, ValueError):
281
+ self.drop(client) # malformed: this client goes, the session stays
282
+ return
283
+
284
+ def handle(self, client, kind, payload):
285
+ now = time.monotonic()
286
+ if kind == b'q':
287
+ info = {'id': self.id, 'pid': self.pid, 'clients': sum(c.hello for c in self.clients.values())}
288
+ try:
289
+ info['cwd'] = os.readlink(f'/proc/{self.pid}/cwd')
290
+ except OSError:
291
+ pass
292
+ self.send(client, frame(b'q', json.dumps(info).encode()))
293
+ return
294
+ if kind == b'h' and not client.hello:
295
+ rows, cols, claim = struct.unpack('!HHB', payload)
296
+ client.hello = True
297
+ client.size = (max(rows, 1), max(cols, 1))
298
+ client.active = now
299
+ self.empty_since = None
300
+ if self.replay:
301
+ self.send(client, frame(b'o', bytes(self.replay)))
302
+ client.quiet_until = now + QUIET_AFTER_REPLAY
303
+ if claim or self.controller is None:
304
+ self.take_control(client, force=True)
305
+ return
306
+ if not client.hello:
307
+ raise ValueError('frame before hello')
308
+ if kind == b'i':
309
+ if now < client.quiet_until:
310
+ return
311
+ client.active = now
312
+ if self.controller is not client:
313
+ self.take_control(client)
314
+ self.write_shell(payload)
315
+ elif kind == b'r':
316
+ rows, cols = SIZE.unpack(payload)
317
+ client.size = (max(rows, 1), max(cols, 1))
318
+ if self.controller is client:
319
+ self.apply_size(*client.size)
320
+ elif kind == b'c':
321
+ client.active = now
322
+ if self.controller is not client:
323
+ self.take_control(client)
324
+ else:
325
+ raise ValueError('unknown frame')
326
+
327
+ # -- main loop
328
+
329
+ def run(self):
330
+ status = 0
331
+ try:
332
+ while True:
333
+ timeout = None
334
+ if self.empty_since is not None:
335
+ left = self.linger - (time.monotonic() - self.empty_since)
336
+ if left <= 0:
337
+ os.killpg(os.getpgid(self.pid), signal.SIGHUP) # like a dropped SSH connection
338
+ self.empty_since = None
339
+ else:
340
+ timeout = left
341
+ for key, events in self.sel.select(timeout):
342
+ if key.data == 'accept':
343
+ self.accept()
344
+ elif key.data == 'pty':
345
+ if events & selectors.EVENT_WRITE:
346
+ self.flush_shell()
347
+ if events & selectors.EVENT_READ and not self.from_shell():
348
+ raise StopIteration
349
+ else:
350
+ client = key.data
351
+ if events & selectors.EVENT_WRITE:
352
+ self.flush(client)
353
+ if events & selectors.EVENT_READ and client.sock in self.clients:
354
+ self.from_client(client)
355
+ except StopIteration:
356
+ pass
357
+ finally:
358
+ try:
359
+ _, st = os.waitpid(self.pid, 0)
360
+ status = os.waitstatus_to_exitcode(st)
361
+ except ChildProcessError:
362
+ pass
363
+ try:
364
+ os.unlink(self.path)
365
+ except OSError:
366
+ pass
367
+ bye = frame(b'x', struct.pack('!i', status))
368
+ for client in list(self.clients.values()):
369
+ client.sock.setblocking(True)
370
+ try:
371
+ client.sock.sendall(client.out + bye)
372
+ except OSError:
373
+ pass
374
+ client.sock.close()
375
+
376
+
377
+ def start_session(rows, cols):
378
+ """Forks a detached session owner; returns its id once its socket accepts connections."""
379
+ session_id = secrets.token_hex(4)
380
+ path = socket_path(session_id)
381
+ cwd, env = os.getcwd(), dict(os.environ)
382
+ ready_r, ready_w = os.pipe()
383
+ if os.fork() == 0:
384
+ os.close(ready_r)
385
+ os.setsid() # not part of the SSH login: survives it like tmux does
386
+ if os.fork() != 0:
387
+ os._exit(0)
388
+ null = os.open(os.devnull, os.O_RDWR)
389
+ for fd in (0, 1, 2):
390
+ os.dup2(null, fd)
391
+ signal.signal(signal.SIGHUP, signal.SIG_IGN)
392
+ signal.signal(signal.SIGPIPE, signal.SIG_IGN)
393
+ try:
394
+ session = Session(session_id, path, rows, cols, cwd, env)
395
+ except Exception:
396
+ os._exit(1)
397
+ os.write(ready_w, b'1')
398
+ os.close(ready_w)
399
+ session.run()
400
+ os._exit(0)
401
+ os.close(ready_w)
402
+ ok = os.read(ready_r, 1) == b'1'
403
+ os.close(ready_r)
404
+ os.wait() # the intermediate child
405
+ if not ok:
406
+ sys.stderr.write('trd-pty: could not start a session\n')
407
+ sys.exit(3) # login.sh falls back to tmux / a plain shell on this
408
+ return session_id
409
+
410
+
411
+ # ---- terminal side -------------------------------------------------------------------------------
412
+
413
+ FOCUS_ON, FOCUS_OFF = b'\x1b[?1004h', b'\x1b[?1004l'
414
+ FOCUS_IN, FOCUS_OUT = b'\x1b[I', b'\x1b[O'
415
+
416
+
417
+ def attach(session_id, claim=True):
418
+ """Relays this terminal to a session until the session ends (0) or the connection breaks (1)."""
419
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
420
+ try:
421
+ sock.connect(socket_path(session_id))
422
+ except OSError as e:
423
+ sys.exit(f'trd-pty: no session {session_id} ({e.strerror})')
424
+ fd_in, fd_out = sys.stdin.fileno(), sys.stdout.fileno()
425
+ rows, cols = term_size(fd_in)
426
+ sock.sendall(frame(b'h', struct.pack('!HHB', rows, cols, 1 if claim else 0)))
427
+
428
+ winch_r, winch_w = os.pipe()
429
+ os.set_blocking(winch_w, False)
430
+ signal.set_wakeup_fd(winch_w)
431
+ signal.signal(signal.SIGWINCH, lambda *_: None)
432
+
433
+ saved = termios.tcgetattr(fd_in)
434
+ tty.setraw(fd_in)
435
+ # Ask this terminal for focus reports: focusing it makes it the controlling terminal. They are
436
+ # only forwarded to the program in the session when that program asked for them itself.
437
+ os.write(fd_out, FOCUS_ON)
438
+ app_wants_focus = False
439
+ frames = Frames()
440
+ status = 1
441
+ try:
442
+ while True:
443
+ ready = select_read([fd_in, sock.fileno(), winch_r])
444
+ if winch_r in ready:
445
+ os.read(winch_r, 64)
446
+ sock.sendall(frame(b'r', SIZE.pack(*term_size(fd_in))))
447
+ if fd_in in ready:
448
+ data = os.read(fd_in, 65536)
449
+ if not data:
450
+ break
451
+ if FOCUS_IN in data:
452
+ sock.sendall(frame(b'c'))
453
+ if not app_wants_focus:
454
+ data = data.replace(FOCUS_IN, b'').replace(FOCUS_OUT, b'')
455
+ if data:
456
+ sock.sendall(frame(b'i', data))
457
+ if sock.fileno() in ready:
458
+ data = sock.recv(65536)
459
+ if not data:
460
+ break
461
+ for kind, payload in frames.feed(data):
462
+ if kind == b'o':
463
+ on, off = payload.rfind(FOCUS_ON), payload.rfind(FOCUS_OFF)
464
+ if on != off:
465
+ app_wants_focus = on > off
466
+ write_all(fd_out, payload)
467
+ elif kind == b'x':
468
+ status = 0
469
+ return status
470
+ finally:
471
+ if not app_wants_focus:
472
+ os.write(fd_out, FOCUS_OFF)
473
+ termios.tcsetattr(fd_in, termios.TCSAFLUSH, saved)
474
+ sock.close()
475
+ return status
476
+
477
+
478
+ def select_read(fds):
479
+ sel = selectors.DefaultSelector()
480
+ for fd in fds:
481
+ sel.register(fd, selectors.EVENT_READ)
482
+ try:
483
+ return {key.fd for key, _ in sel.select()}
484
+ finally:
485
+ sel.close()
486
+
487
+
488
+ def write_all(fd, data):
489
+ view = memoryview(data)
490
+ while view:
491
+ try:
492
+ n = os.write(fd, view)
493
+ except BlockingIOError:
494
+ select_write(fd)
495
+ continue
496
+ view = view[n:]
497
+
498
+
499
+ def select_write(fd):
500
+ sel = selectors.DefaultSelector()
501
+ sel.register(fd, selectors.EVENT_WRITE)
502
+ sel.select()
503
+ sel.close()
504
+
505
+
506
+ def query(path):
507
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
508
+ sock.settimeout(2)
509
+ sock.connect(path)
510
+ sock.sendall(frame(b'q'))
511
+ frames = Frames()
512
+ while True:
513
+ data = sock.recv(65536)
514
+ if not data:
515
+ raise OSError('closed')
516
+ for kind, payload in frames.feed(data):
517
+ if kind == b'q':
518
+ sock.close()
519
+ return json.loads(payload)
520
+
521
+
522
+ def list_sessions():
523
+ base = runtime_dir()
524
+ sessions = []
525
+ for name in sorted(os.listdir(base)):
526
+ if not name.endswith('.sock'):
527
+ continue
528
+ path = os.path.join(base, name)
529
+ try:
530
+ sessions.append(query(path))
531
+ except ConnectionRefusedError:
532
+ os.unlink(path) # its owner is gone (killed, or before a reboot's tmpfs wipe)
533
+ except OSError:
534
+ pass
535
+ return sessions
536
+
537
+
538
+ def main(argv):
539
+ cmd = argv[1] if len(argv) > 1 else ''
540
+ if cmd == 'new':
541
+ if not os.isatty(0):
542
+ sys.exit('trd-pty: new needs a terminal')
543
+ return attach(start_session(*term_size(0)))
544
+ if cmd == 'attach' and len(argv) == 3:
545
+ return attach(argv[2])
546
+ if cmd == 'ls':
547
+ for s in list_sessions():
548
+ print(s['id'], s['clients'], s['pid'], s.get('cwd', ''))
549
+ return 0
550
+ if cmd == 'clients' and len(argv) == 3:
551
+ try:
552
+ print(query(socket_path(argv[2]))['clients'])
553
+ except OSError:
554
+ print(0)
555
+ return 0
556
+ sys.stderr.write(__doc__.split('\n\n', 1)[1])
557
+ return 2
558
+
559
+
560
+ if __name__ == '__main__':
561
+ sys.exit(main(sys.argv))
@@ -0,0 +1,27 @@
1
+ vendor/iron-remote-desktop*.js are built from IronRDP (https://github.com/Devolutions/IronRDP) at
2
+ commit 9b151c4c2e47c6014e1e8e55909d4180aa8bdb99 plus ironrdp/gnome-egfx.patch. IronRDP is dual-licensed MIT or Apache-2.0;
3
+ its MIT license follows.
4
+
5
+ Permission is hereby granted, free of charge, to any
6
+ person obtaining a copy of this software and associated
7
+ documentation files (the "Software"), to deal in the
8
+ Software without restriction, including without
9
+ limitation the rights to use, copy, modify, merge,
10
+ publish, distribute, sublicense, and/or sell copies of
11
+ the Software, and to permit persons to whom the Software
12
+ is furnished to do so, subject to the following
13
+ conditions:
14
+
15
+ The above copyright notice and this permission notice
16
+ shall be included in all copies or substantial portions
17
+ of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
20
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
21
+ TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
22
+ PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
23
+ SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
24
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
25
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
26
+ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
27
+ DEALINGS IN THE SOFTWARE.