Skip to content

Commit 44ff05b

Browse files
jquastvxgmichel
andauthored
Add 'gambaterm-telnet' server (#24)
* Add telnet server with 60pps paced output Add gambaterm-telnet server using telnetlib3, with a simplified architecture compared to the previous NUL-padding approach. Frame output is paced at 60 packets per second via an asyncio forwarding task combined with TCP_NODELAY, producing smooth rendering without complex bandwidth estimation. - TelnetTerminal blessed subclass (mirrors SSHTerminal pattern) - Kitty keyboard protocol required for both SSH and telnet - Per-user save state directories (SHA-256 hashed usernames) - NAWS terminal resize support - Connection stats logging and idle timeout - --max-players and --robot-check support via telnetlib3 guard shells * Remove telnet_input module * Remove paced_forward_output since the run function already produces entire frames * Add ilde-timeout option to gambaterm-telnet and kick idle clients with a graceful shutdown --------- Co-authored-by: Vincent Michel <vxgmichel@gmail.com>
1 parent f7f4213 commit 44ff05b

9 files changed

Lines changed: 866 additions & 158 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,25 @@ $ ssh localhost -p 8022
125125
```
126126

127127

128+
Telnet server
129+
-------------
130+
131+
The emulator can also be served over telnet, requiring no authentication or SSH keys:
132+
133+
```shell
134+
$ gambaterm-telnet myrom.gbc
135+
$ gambaterm-telnet --bind 0.0.0.0 --port 8023 myrom.gbc # listen on all interfaces
136+
```
137+
138+
Connect with any telnet client:
139+
140+
```shell
141+
$ telnet localhost 8023
142+
```
143+
144+
Clients must use a terminal that supports the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) -- connections without it are rejected. Use `--max-players N` to limit concurrent connections. 24-bit color is always assumed. Audio is not available over telnet.
145+
146+
128147
Terminal support
129148
----------------
130149

@@ -247,6 +266,7 @@ Here is the list of the dependencies used in this project, all great open source
247266
- [xlib](https://github.com/python-xlib/python-xlib)/[pynput](https://github.com/moses-palmer/pynput) - Getting keyboard inputs
248267
- [pygame](https://github.com/pygame/pygame) - Getting game controller inputs
249268
- [asyncssh](https://github.com/ronf/asyncssh) - Running the SSH server
269+
- [telnetlib3](https://github.com/jquast/telnetlib3) - Running the telnet server
250270

251271

252272
Contact

gambaterm/remote_terminal.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""
2+
Provide a blessed Terminal subclass for remote (SSH/telnet) streams.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
import codecs
8+
import contextlib
9+
from typing import IO, Generator
10+
11+
from blessed import Terminal as BlessedTerminal
12+
from blessed.terminal import WINSZ
13+
14+
# Python's curses.setupterm() can only be called once per process — subsequent
15+
# calls with a different terminal type are silently ignored. Since the SSH
16+
# server handles multiple concurrent connections in threads, all RemoteTerminal
17+
# instances share whatever terminal type was initialized first by the local
18+
# Terminal(). We hardcode 'xterm-256color' as the kind since:
19+
# 1. It's universally compatible with modern terminals
20+
# 2. We use standard VT100/ANSI escape codes directly, not terminfo caps
21+
# 3. It avoids issues where the first client's TERM value differs from subsequent
22+
REMOTE_TERMINAL_TYPE = "xterm-256color"
23+
24+
25+
class RemoteTerminal(BlessedTerminal):
26+
"""A blessed Terminal subclass for remote streams (SSH, telnet).
27+
28+
Stubs raw/cbreak mode (the remote connection is already raw) and
29+
overrides size detection to use values provided by the server.
30+
"""
31+
32+
def __init__(
33+
self,
34+
stream: IO[str],
35+
keyboard_fd: int,
36+
rows: int,
37+
columns: int,
38+
) -> None:
39+
self._rows = rows
40+
self._columns = columns
41+
super().__init__(kind=REMOTE_TERMINAL_TYPE, stream=stream, force_styling=True)
42+
# Blessed only sets _keyboard_fd when stream is sys.__stdout__, so
43+
# for remote pipes we must set it and initialize the decoder manually
44+
self._keyboard_fd = keyboard_fd # type: ignore[assignment]
45+
self._keyboard_decoder = codecs.getincrementaldecoder("UTF-8")()
46+
47+
@property
48+
def is_a_tty(self) -> bool:
49+
return True
50+
51+
@contextlib.contextmanager
52+
def raw(self) -> Generator[None, None, None]:
53+
yield
54+
55+
@contextlib.contextmanager
56+
def cbreak(self) -> Generator[None, None, None]:
57+
yield
58+
59+
def _height_and_width(self) -> WINSZ:
60+
return WINSZ(
61+
ws_row=self._rows,
62+
ws_col=self._columns,
63+
ws_xpixel=0,
64+
ws_ypixel=0,
65+
)
66+
67+
def update_size(self, rows: int, columns: int) -> None:
68+
self._rows = rows
69+
self._columns = columns

gambaterm/ssh.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
from .main import add_base_arguments, add_optional_arguments, AppConfig
3131
from .console import Console, GameboyColor
3232

33-
from .ssh_app_session import SSHTerminal, process_to_terminal
33+
from .remote_terminal import RemoteTerminal
34+
from .ssh_app_session import process_to_terminal
3435

3536

3637
def is_x11_display_functional(
@@ -155,7 +156,7 @@ async def ssh_process_handler(process: SSHServerProcess[str]) -> int:
155156

156157

157158
def ssh_terminal_handler(
158-
terminal: SSHTerminal,
159+
terminal: RemoteTerminal,
159160
console_callback: Callable[[], Console],
160161
app_config: AppConfig,
161162
display: str | None,
@@ -206,13 +207,8 @@ def ssh_terminal_handler(
206207
else:
207208
assert False
208209

209-
# Default to 24-bit color since the vast majority of modern terminals
210-
# support it.
211-
color_mode = (
212-
app_config.color_mode
213-
if app_config.color_mode is not None
214-
else ColorMode.HAS_24_BIT_COLOR
215-
)
210+
# Kitty keyboard protocol implies 24-bit color support
211+
color_mode = app_config.color_mode or ColorMode.HAS_24_BIT_COLOR
216212

217213
print(
218214
f"[Terminal Info] {username}: {terminal_type}, {input_source}, {terminal.width}x{terminal.height}"

gambaterm/ssh_app_session.py

Lines changed: 8 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,21 @@
11
"""
2-
Provide an async context manager to create a blessed SSHTerminal
2+
Provide an async context manager to create a blessed RemoteTerminal
33
from an AsyncSSH process.
44
"""
55
from __future__ import annotations
66

77
import os
8-
import codecs
98
import asyncio
10-
import contextlib
119
import subprocess
1210
from concurrent.futures import ThreadPoolExecutor
1311
from contextlib import asynccontextmanager, contextmanager
14-
from typing import AsyncIterator, Generator, IO, Iterator, TypeVar, Callable
15-
16-
from blessed import Terminal as BlessedTerminal
17-
from blessed.terminal import WINSZ
12+
from typing import AsyncIterator, Iterator, TypeVar, Callable
1813

1914
from asyncssh import SSHServerProcess
2015

21-
T = TypeVar("T")
22-
23-
24-
# Python's curses.setupterm() can only be called once per process — subsequent
25-
# calls with a different terminal type are silently ignored. Since the SSH
26-
# server handles multiple concurrent connections in threads, all SSHTerminal
27-
# instances share whatever terminal type was initialized first by the local
28-
# Terminal(). We hardcode 'xterm-256color' as the kind since:
29-
# 1. It's universally compatible with modern terminals
30-
# 2. We use standard VT100/ANSI escape codes directly, not terminfo caps
31-
# 3. It avoids issues where the first SSH client's TERM value differs
32-
SSH_TERMINAL_TYPE = "xterm-256color"
33-
34-
35-
class SSHTerminal(BlessedTerminal):
36-
"""A blessed Terminal subclass for SSH streams.
37-
38-
Following the pattern from x84 (x84/terminal.py), this stubs raw/cbreak
39-
mode (SSH is already raw) and overrides size detection to use values
40-
provided by the SSH server.
41-
"""
16+
from .remote_terminal import RemoteTerminal
4217

43-
def __init__(
44-
self,
45-
stream: IO[str],
46-
keyboard_fd: int,
47-
rows: int,
48-
columns: int,
49-
) -> None:
50-
self._rows = rows
51-
self._columns = columns
52-
super().__init__(kind=SSH_TERMINAL_TYPE, stream=stream, force_styling=True)
53-
# Blessed only sets _keyboard_fd when stream is sys.__stdout__, so
54-
# for SSH pipes we must set it and initialize the decoder manually
55-
self._keyboard_fd = keyboard_fd # type: ignore[assignment]
56-
self._keyboard_decoder = codecs.getincrementaldecoder("UTF-8")()
57-
58-
@property
59-
def is_a_tty(self) -> bool:
60-
return True
61-
62-
@contextlib.contextmanager
63-
def raw(self) -> Generator[None, None, None]:
64-
yield
65-
66-
@contextlib.contextmanager
67-
def cbreak(self) -> Generator[None, None, None]:
68-
yield
69-
70-
def _height_and_width(self) -> WINSZ:
71-
return WINSZ(
72-
ws_row=self._rows,
73-
ws_col=self._columns,
74-
ws_xpixel=0,
75-
ws_ypixel=0,
76-
)
77-
78-
def update_size(self, rows: int, columns: int) -> None:
79-
self._rows = rows
80-
self._columns = columns
18+
T = TypeVar("T")
8119

8220

8321
@asynccontextmanager
@@ -118,7 +56,7 @@ async def _input_pipe_from_process(
11856

11957
@contextmanager
12058
def _bind_resize(
121-
process: SSHServerProcess[str], ssh_term: SSHTerminal
59+
process: SSHServerProcess[str], ssh_term: RemoteTerminal
12260
) -> Iterator[None]:
12361
original_method = process.terminal_size_changed
12462

@@ -138,9 +76,9 @@ def terminal_size_changed(
13876
async def process_to_terminal(
13977
process: SSHServerProcess[str],
14078
executor: ThreadPoolExecutor,
141-
target: Callable[[SSHTerminal], T],
79+
target: Callable[[RemoteTerminal], T],
14280
) -> T:
143-
"""Create a blessed SSHTerminal from an SSH process.
81+
"""Create a blessed RemoteTerminal from an SSH process.
14482
14583
Once the redirections are set up, I/O become synchronous,
14684
so we run the target function in a thread executor to avoid blocking the event loop
@@ -151,7 +89,7 @@ async def process_to_terminal(
15189

15290
def _target() -> T:
15391
with open(write_fd, "w", newline="\r\n") as stream:
154-
ssh_term = SSHTerminal(
92+
ssh_term = RemoteTerminal(
15593
stream=stream,
15694
keyboard_fd=keyboard_fd,
15795
rows=height,

0 commit comments

Comments
 (0)