-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathssh.py
More file actions
726 lines (637 loc) · 23.8 KB
/
Copy pathssh.py
File metadata and controls
726 lines (637 loc) · 23.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
from __future__ import annotations
import os
import re
import time
import hmac
import asyncio
import argparse
import traceback
from pathlib import Path
from dataclasses import dataclass
from contextlib import asynccontextmanager
from typing import AnyStr, Callable, TypeAlias, ContextManager, AsyncIterator
from enum import Enum, auto
from concurrent.futures import ThreadPoolExecutor
import asyncssh
import structlog
from asyncssh import (
SFTPServerFactory,
SSHReader,
SSHServerConnection,
SSHServerProcess,
SSHAcceptor,
SSHServer,
SSHServerProcessFactory,
)
from asyncssh.channel import SSHChannel
from .run import run
from .colors import ColorMode
from .file_input import console_input_from_file_context
from .input_getter import BaseInputGetter
from .keyboard_input import (
console_input_from_x11_keyboard_context,
console_input_from_keyboard_protocol_context,
MESSAGE_SUGGESTING_KITTY_SUPPORT,
)
from .main import (
add_base_arguments,
add_input_file_arguments,
add_tuning_arguments,
AppConfig,
)
from .console import Console, GameboyColor
from .remote_terminal import (
KeyboardSupport,
RemoteTerminal,
user_directory_name,
KeyboardSupportDetection,
FrontendCallback,
)
from .ssh_app_session import process_to_terminal
logger = structlog.get_logger()
Writer: TypeAlias = Callable[[str], None]
CommandParser: TypeAlias = Callable[
[str, argparse.Namespace, Writer], argparse.Namespace
]
CPR_PATTERN = re.compile(r"\x1b\[(\d+);(\d+)R")
async def _read_cpr_response(
reader: SSHReader[str],
) -> tuple[int, int] | None:
buf = ""
while True:
try:
data = await reader.read(1)
except UnicodeDecodeError:
return None
if not data:
return None
buf += data
if buf.endswith("R"):
match = CPR_PATTERN.search(buf)
if match:
return (int(match.group(1)), int(match.group(2)))
async def get_cursor_position(
process: SSHServerProcess[str],
timeout: float = 1.0,
) -> tuple[int, int] | None:
# Send Device Status Report request
process.stdout.write("\x1b[6n")
await process.stdout.drain()
# Read response: ESC [ row ; col R
try:
return await asyncio.wait_for(_read_cpr_response(process.stdin), timeout)
except asyncio.TimeoutError:
return None
async def do_robot_check(
process: SSHServerProcess[str],
timeout: float = 1.0,
) -> tuple[bool, float]:
start1 = time.perf_counter()
pos1 = await get_cursor_position(process, timeout)
if pos1 is None:
return False, 0.0
delta1 = time.perf_counter() - start1
# Write test character
process.stdout.write(" ")
await process.stdout.drain()
start2 = time.perf_counter()
pos2 = await get_cursor_position(process, timeout)
if pos2 is None:
return False, 0.0
delta2 = time.perf_counter() - start2
# Clear the test character
process.stdout.write("\b")
await process.stdout.drain()
_, x1 = pos1
_, x2 = pos2
delta = (delta1 + delta2) / 2
return x2 - x1 == 1, delta
class InputSource(Enum):
INPUT_FILE = auto()
KEYBOARD_PROTOCOL = auto()
X11 = auto()
def detect_input_source(
app_config: AppConfig,
keyboard_support_detection: KeyboardSupportDetection,
timeout: float = 3.0,
) -> InputSource | None:
if app_config.input_file is not None:
return InputSource.INPUT_FILE
keyboard_support = keyboard_support_detection.get(timeout)
if keyboard_support == KeyboardSupport.KEYBOARD_PROTOCOL:
return InputSource.KEYBOARD_PROTOCOL
if keyboard_support == KeyboardSupport.X11:
return InputSource.X11
return None
async def safe_ssh_process_handler(process: SSHServerProcess[str]) -> None:
try:
result = await ssh_process_handler(process)
except (KeyboardInterrupt, EOFError):
result = 0
except SystemExit as e:
if isinstance(e.code, int):
result = e.code
else:
result = 1 if e.code else 0
except BrokenPipeError:
result = 1
except BaseException:
traceback.print_exc()
result = 1
return process.exit(result or 0)
async def ssh_process_handler(process: SSHServerProcess[str]) -> int:
console_cls: type[Console] = process.get_extra_info("console_cls")
namespace: argparse.Namespace = process.get_extra_info("namespace")
command_parser: CommandParser = process.get_extra_info("command_parser")
users_directory: Path = process.get_extra_info("users_directory")
executor: ThreadPoolExecutor = process.get_extra_info("executor")
robot_check: bool = process.get_extra_info("robot_check")
display = process.channel.get_x11_display()
command = process.channel.get_command()
terminal_type = process.get_terminal_type()
connection = process.get_extra_info("connection")
username = process.get_extra_info("username")
peername, port = connection.get_extra_info("peername")
session_logger = logger.bind(username=username, peer=f"{peername}:{port}")
session_logger.info("User connected")
frontend = connection.get_extra_info("frontend")
# Copy namespace before mutating
namespace = argparse.Namespace(**vars(namespace))
# Check command
if command is not None:
namespace = command_parser(
command,
namespace,
lambda data: print(data.replace("\n", "\r\n"), end="", file=process.stdout),
)
# Convert namespace to AppConfig
app_config = AppConfig.from_namespace(namespace)
# Check terminal
if terminal_type is None:
print(
"Please use a terminal to access the interactive interface.",
"Use `-t` to force pseudo-terminal allocation if a command is provided.",
sep="\r\n",
end="\r\n",
file=process.stdout,
)
session_logger.warning("User did not use an interactive terminal")
return 1
# Robot check
if robot_check:
session_logger.info("Perform robot check")
passed, round_trip = await do_robot_check(process)
if not passed:
print(
"Your terminal does not seem to support cursor postion request (CPR).",
end="\r\n",
file=process.stdout,
)
session_logger.warning("Terminal did not pass the robot check")
return 1
session_logger.info("Robot check passed", round_trip=round_trip)
return await process_to_terminal(
process,
executor,
lambda terminal: ssh_terminal_handler(
terminal,
console_cls,
app_config,
display,
username,
terminal_type,
executor,
users_directory,
session_logger,
frontend=frontend,
),
terminal_type=terminal_type,
)
def ssh_terminal_handler(
terminal: RemoteTerminal,
console_cls: type[Console],
app_config: AppConfig,
display: str | None,
username: str,
terminal_type: str,
executor: ThreadPoolExecutor,
users_directory: Path,
session_logger: structlog.BoundLogger,
frontend: FrontendCallback | None = None,
) -> int:
keyboard_support_detection = KeyboardSupportDetection(terminal, display, executor)
if frontend is not None:
try:
app_config = frontend(terminal, app_config, keyboard_support_detection)
except (KeyboardInterrupt, EOFError):
return 0
# Manage save directory — hash username to prevent path traversal
app_config.save_directory = (
None
if app_config.input_file is not None
else users_directory / user_directory_name(username)
)
if app_config.save_directory is not None:
app_config.save_directory.mkdir(parents=True, exist_ok=True)
(app_config.save_directory / "username").write_text(username)
# Now is a good time to instantiate the console
# (it might fail if the ROM does not exist for instance)
console = console_cls.from_app_config(app_config)
input_source = detect_input_source(app_config, keyboard_support_detection)
console_input_context: ContextManager[BaseInputGetter]
if input_source is None:
message = (
MESSAGE_SUGGESTING_KITTY_SUPPORT
+ "\n\n"
+ """\
Alternatively, X11 forwarding can be used in order to give the gambaterm-ssh
server access to your keyboard, eg. `ssh -Y -p 8022 localhost`.
===============================[ WARNING ]=====================================
Enabling X11 forwarding while connecting to an untrusted server can greatly
endanger your machine. Please only do so if you are running the X11 server in a
sandbox. More information here: https://security.stackexchange.com/a/7496
===============================[ WARNING ]=====================================
"""
)
terminal.stream.write(message)
terminal.stream.flush()
session_logger.warning(
"User did not support keyboard protocol nor enable X11 forwarding"
)
return 1
elif input_source == InputSource.INPUT_FILE:
assert app_config.input_file is not None
console_input_context = console_input_from_file_context(
console, terminal, app_config.input_file, app_config.skip_inputs
)
elif input_source == InputSource.KEYBOARD_PROTOCOL:
console_input_context = console_input_from_keyboard_protocol_context(
console,
terminal,
)
elif input_source == InputSource.X11:
console_input_context = console_input_from_x11_keyboard_context(
console, terminal, display
)
else:
assert False
# It is possible, here, to probe XTGETTCAP which helps correct terminal.number_of_colors using
# 'RGB' and 'colors', and some special attributes like blink, underline et al., but since they
# are not used by gambaterm, it is not called unless we find better reason otherwise.
# terminal.probe_xtgettcap(timeout=1.0)
# In practice kitty keyboard protocol pretty well implies 24-bit color support already,
color_mode = app_config.color_mode or ColorMode.HAS_24_BIT_COLOR
session_logger.info(
"Terminal info",
term=terminal_type,
input_source=str(input_source),
cols=terminal.width,
rows=terminal.height,
)
try:
# Prepare alternate screen
terminal.stream.write(
terminal.enter_fullscreen + terminal.clear + terminal.hide_cursor
)
terminal.stream.flush()
with console_input_context as get_console_input:
# Run the emulator
run(
console,
input_getter=get_console_input,
term=terminal,
frame_advance=app_config.frame_advance,
color_mode=color_mode,
break_after=app_config.break_after,
speed=app_config.speed,
use_cpr_sync=app_config.cpr_sync,
)
return 0
finally:
# Wait for CPR
time.sleep(0.1)
# Clear alternate screen
terminal.stream.write(
terminal.clear + terminal.exit_fullscreen + terminal.normal_cursor
)
# Flush if the connection is still active
try:
terminal.stream.flush()
except BrokenPipeError:
pass
@dataclass
class PasswordAndPublicKeyAuthentication:
password: str
@dataclass
class PublicKeyAuthentication:
pass
@dataclass
class NoAuthentication:
pass
AuthenticationMethod: TypeAlias = (
PasswordAndPublicKeyAuthentication | PublicKeyAuthentication | NoAuthentication
)
class GambatermSSHServerProcess(SSHServerProcess[str]):
def __init__(
self,
process_factory: SSHServerProcessFactory[str],
sftp_factory: SFTPServerFactory | None,
sftp_version: int,
allow_scp: bool,
active_sessions: set[GambatermSSHServerProcess],
):
super().__init__(process_factory, sftp_factory, sftp_version, allow_scp)
self._gambaterm_active_sessions = active_sessions
def connection_made(self, chan: SSHChannel[AnyStr]) -> None:
self._gambaterm_active_sessions.add(self)
return super().connection_made(chan)
def connection_lost(self, exc: Exception | None) -> None:
self._gambaterm_active_sessions.discard(self)
return super().connection_lost(exc)
class GambatermSSHServer(SSHServer):
def __init__(
self,
authentication: AuthenticationMethod,
robot_check: bool,
console_cls: type[Console],
namespace: argparse.Namespace,
command_parser: CommandParser,
users_directory: Path,
executor: ThreadPoolExecutor,
active_connections: dict[GambatermSSHServer, SSHServerConnection],
frontend: Callable[
[RemoteTerminal, AppConfig, KeyboardSupportDetection], AppConfig
]
| None = None,
):
self._gambaterm_console_cls = console_cls
self._gambaterm_namespace = namespace
self._gambaterm_command_parser = command_parser
self._gambaterm_users_directory = users_directory
self._gambaterm_executor = executor
self._gambaterm_authentication = authentication
self._gambaterm_robot_check = robot_check
self._gambaterm_active_connections = active_connections
self._gambaterm_active_sessions: set[GambatermSSHServerProcess] = set()
self._gambaterm_frontend = frontend
def connection_made(self, conn: SSHServerConnection) -> None:
self._conn = conn
conn.set_extra_info(console_cls=self._gambaterm_console_cls)
conn.set_extra_info(executor=self._gambaterm_executor)
conn.set_extra_info(namespace=self._gambaterm_namespace)
conn.set_extra_info(command_parser=self._gambaterm_command_parser)
conn.set_extra_info(users_directory=self._gambaterm_users_directory)
conn.set_extra_info(frontend=self._gambaterm_frontend)
conn.set_extra_info(robot_check=self._gambaterm_robot_check)
self._gambaterm_active_connections[self] = conn
def connection_lost(self, exc: Exception | None) -> None:
self._gambaterm_active_connections.pop(self)
def begin_auth(self, username: str) -> bool:
return not isinstance(self._gambaterm_authentication, NoAuthentication)
def session_requested(self) -> SSHServerProcess[str]:
return GambatermSSHServerProcess(
safe_ssh_process_handler,
sftp_factory=None,
sftp_version=3,
allow_scp=False,
active_sessions=self._gambaterm_active_sessions,
)
def password_auth_supported(self) -> bool:
return isinstance(
self._gambaterm_authentication, (PasswordAndPublicKeyAuthentication,)
)
def validate_password(self, username: str, password: str) -> bool:
assert isinstance(
self._gambaterm_authentication, PasswordAndPublicKeyAuthentication
)
is_valid = hmac.compare_digest(
password, self._gambaterm_authentication.password
)
if not is_valid:
conn = getattr(self, "_conn", None)
peername = conn.get_extra_info("peername") if conn else None
logger.warning(
"Failed password authentication",
username=username,
password=password,
peer=f"{peername[0]}:{peername[1]}" if peername else None,
)
return is_valid
@asynccontextmanager
async def run_ssh_server(
bind: str,
port: int,
authentication: AuthenticationMethod,
robot_check: bool,
console_cls: type[Console],
namespace: argparse.Namespace,
command_parser: CommandParser,
users_directory: Path,
executor: ThreadPoolExecutor,
frontend: FrontendCallback | None = None,
) -> AsyncIterator[SSHAcceptor]:
# Gambaterm configuration
gambaterm_config_dir = Path(
os.environ.get("GAMBATERM_CONFIG_DIR", "~/.config/gambaterm")
).expanduser()
server_host_key = gambaterm_config_dir / "ssh_host_key"
config_authorized_keys = gambaterm_config_dir / "authorized_keys"
# User SSH public keys (for authentication)
user_ssh_dir = Path(os.environ.get("GAMBATERM_USER_SSH_DIR", "~/.ssh")).expanduser()
user_authorized_keys = user_ssh_dir / "authorized_keys"
# Generate host key if it does not exist
if not server_host_key.exists():
logger.info("Generating SSH host key", path=str(server_host_key))
server_host_key.parent.mkdir(parents=True, exist_ok=True)
key = asyncssh.generate_private_key("ssh-ed25519")
server_host_key.write_bytes(key.export_private_key())
server_host_key.chmod(0o600)
server_host_keys = [str(server_host_key)]
# Collect authorized client keys for public key authentication
authorized_client_keys = []
if isinstance(
authentication, (PublicKeyAuthentication, PasswordAndPublicKeyAuthentication)
):
for key_type in ["rsa", "ed25519", "ecdsa"]:
user_public_key = user_ssh_dir / f"id_{key_type}.pub"
if user_public_key.exists():
authorized_client_keys.append(str(user_public_key))
if user_authorized_keys.exists():
authorized_client_keys.append(str(user_authorized_keys))
if config_authorized_keys.exists():
authorized_client_keys.append(str(config_authorized_keys))
if not authorized_client_keys and isinstance(
authentication, PublicKeyAuthentication
):
raise SystemExit(
f"Public key authentication is enabled, but no authorized keys were found.\n"
f"Please add the public keys of allowed clients to {config_authorized_keys}."
)
# Remove chacha20 from encryption_algs because it's a bit too expensive
encryption_algs = [
# "chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
]
active_connections: dict[GambatermSSHServer, SSHServerConnection] = {}
server = await asyncssh.create_server(
lambda: GambatermSSHServer(
authentication,
robot_check,
console_cls,
namespace,
command_parser,
users_directory,
executor,
active_connections,
frontend,
),
bind,
port,
server_host_keys=server_host_keys,
authorized_client_keys=authorized_client_keys,
x11_forwarding=True,
encryption_algs=encryption_algs,
line_editor=False,
reuse_address=True,
)
match authentication:
case NoAuthentication():
logger.info("Authentication disabled (no password nor public key required)")
case PasswordAndPublicKeyAuthentication():
logger.info(
"Authentication methods configured",
password=True,
keys=[str(kp) for kp in authorized_client_keys],
)
case PublicKeyAuthentication():
logger.info(
"Authentication methods configured",
password=False,
keys=[str(kp) for kp in authorized_client_keys],
)
bind, port = server.sockets[0].getsockname()
logger.info("Running SSH server", bind=bind, port=port)
try:
yield server
finally:
# Stop listening for new connections
server.close()
# Freeze active connections
for ssh_server, connection in list(active_connections.items()):
# Freeze active sessions
for session in list(ssh_server._gambaterm_active_sessions):
# Graceful teardown
# This is important to make sure the client receives the cleanup data
session.eof_received()
await session.wait_closed()
# Close the connection
# This is important for clients stuck in authentication phase for instance
connection.close()
# Now nothing should keep the server from closing
await server.wait_closed()
def main(
parser_args: tuple[str, ...] | None = None,
console_cls: type[Console] = GameboyColor,
) -> None:
parser = argparse.ArgumentParser(description="Gambatte terminal front-end over ssh")
add_base_arguments(parser)
add_input_file_arguments(parser)
add_tuning_arguments(parser)
console_cls.add_console_arguments(parser)
parser.add_argument(
"--bind",
"-b",
type=str,
default="127.0.0.1",
help="Bind address of the SSH server, "
"use `0.0.0.0` for all interfaces (default is localhost)",
)
parser.add_argument(
"--port",
"-p",
type=int,
default=8022,
help="Port of the SSH server (default is 8022)",
)
parser.add_argument(
"--password",
"--pw",
type=str,
default=None,
help="Enable password authentication with the given global password",
)
parser.add_argument(
"--no-auth",
action="store_true",
help="Disable authentication altogether (no password nor public key required)",
)
parser.add_argument(
"--robot-check",
action="store_true",
default=False,
help="reject bots by checking if client responds to cursor position requests",
)
parser.add_argument(
"--users-directory",
type=Path,
default=Path("users_save"),
help="Directory containing one save directory per user (default is ./users_save)",
)
# Parse arguments
namespace = parser.parse_args(parser_args)
bind: str = namespace.__dict__.pop("bind")
port: int = namespace.__dict__.pop("port")
password: str = namespace.__dict__.pop("password")
no_auth: bool = namespace.__dict__.pop("no_auth")
robot_check: bool = namespace.__dict__.pop("robot_check")
users_directory: Path = namespace.__dict__.pop("users_directory")
# Determine authentication method
if no_auth and password is None:
authentication: AuthenticationMethod = NoAuthentication()
elif not no_auth and password is not None:
authentication = PasswordAndPublicKeyAuthentication(password)
elif not no_auth and password is None:
authentication = PublicKeyAuthentication()
else:
raise SystemExit(
"Both `--password` and `--no-auth` cannot be provided at the same time"
)
# Make sure that the ROM file exists before starting the server
rom_path: Path = namespace.romfile
if not rom_path.exists():
raise SystemExit(f"ROM file `{rom_path}` does not exist")
# Define a command parser for SSH clients
def command_parser(
command: str, namespace: argparse.Namespace, write: Writer
) -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser._print_message = lambda message, file=None: write(message) # type: ignore[method-assign]
add_tuning_arguments(parser)
console_cls.add_console_arguments(parser)
return parser.parse_args(command.split(), namespace)
# Run an executor with no limit on the number of threads
try:
with ThreadPoolExecutor(max_workers=32) as executor:
# Run the server in asyncio
async def async_main() -> None:
async with run_ssh_server(
bind,
port,
authentication,
robot_check,
console_cls,
namespace,
command_parser,
users_directory,
executor,
):
await asyncio.Future()
asyncio.run(async_main())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()