22
33import os
44import time
5+ import hmac
56import asyncio
67import argparse
78import traceback
89from pathlib import Path
9- from typing import IO , Callable , cast , ContextManager
10+ from dataclasses import dataclass
11+ from typing import IO , Callable , TypeAlias , cast , ContextManager
1012from enum import Enum , auto
1113from concurrent .futures import ThreadPoolExecutor , CancelledError
1214
@@ -251,61 +253,112 @@ def ssh_terminal_handler(
251253 pass
252254
253255
256+ @dataclass
257+ class PasswordAndPublicKeyAuthentication :
258+ password : str
259+
260+
261+ @dataclass
262+ class PublicKeyAuthentication :
263+ pass
264+
265+
266+ @dataclass
267+ class NoAuthentication :
268+ pass
269+
270+
271+ AuthenticationMethod : TypeAlias = (
272+ PasswordAndPublicKeyAuthentication | PublicKeyAuthentication | NoAuthentication
273+ )
274+
275+
254276class SSHServer (asyncssh .SSHServer ):
255277 def __init__ (
256278 self ,
257- password : str | None ,
279+ authentication : AuthenticationMethod ,
258280 console_cls : type [Console ],
259281 namespace : argparse .Namespace ,
260282 executor : ThreadPoolExecutor ,
261283 ):
262284 self ._gambaterm_console_cls = console_cls
263285 self ._gambaterm_namespace = namespace
264286 self ._gambaterm_executor = executor
265- self ._gambaterm_password = password
287+ self ._gambaterm_authentication = authentication
266288
267289 def connection_made (self , conn : asyncssh .SSHServerConnection ) -> None :
268290 conn .set_extra_info (console_cls = self ._gambaterm_console_cls )
269291 conn .set_extra_info (executor = self ._gambaterm_executor )
270292 conn .set_extra_info (namespace = self ._gambaterm_namespace )
271293
272294 def begin_auth (self , username : str ) -> bool :
273- return True
295+ return not isinstance ( self . _gambaterm_authentication , NoAuthentication )
274296
275297 def session_requested (self ) -> SSHServerProcess [str ]:
276298 return asyncssh .SSHServerProcess (
277299 safe_ssh_process_handler , sftp_factory = None , sftp_version = 3 , allow_scp = False
278300 )
279301
280302 def password_auth_supported (self ) -> bool :
281- return bool (self ._gambaterm_password )
303+ return isinstance (
304+ self ._gambaterm_authentication , (PasswordAndPublicKeyAuthentication ,)
305+ )
282306
283307 def validate_password (self , username : str , password : str ) -> bool :
284- assert self ._gambaterm_password is not None
285- return password == self ._gambaterm_password
308+ assert isinstance (
309+ self ._gambaterm_authentication , PasswordAndPublicKeyAuthentication
310+ )
311+ return hmac .compare_digest (password , self ._gambaterm_authentication .password )
286312
287313
288314async def run_server (
289315 bind : str ,
290316 port : int ,
291- password : str | None ,
317+ authentication : AuthenticationMethod ,
292318 console_cls : type [Console ],
293319 namespace : argparse .Namespace ,
294320 executor : ThreadPoolExecutor ,
295321) -> None :
296- ssh_key_dir = Path (os .environ .get ("GAMBATERM_SSH_KEY_DIR" , "~/.ssh" ))
297- user_private_key = (ssh_key_dir / "id_rsa" ).expanduser ()
298- user_public_key = (ssh_key_dir / "id_rsa.pub" ).expanduser ()
299- if not user_private_key .exists ():
322+ # Gambaterm configuration
323+ gambaterm_config_dir = Path (
324+ os .environ .get ("GAMBATERM_CONFIG_DIR" , "~/.config/gambaterm" )
325+ ).expanduser ()
326+ server_host_key = gambaterm_config_dir / "ssh_host_key"
327+ config_authorized_keys = gambaterm_config_dir / "authorized_keys"
328+
329+ # User SSH public keys (for authentication)
330+ user_ssh_dir = Path (os .environ .get ("GAMBATERM_USER_SSH_DIR" , "~/.ssh" )).expanduser ()
331+ user_authorized_keys = user_ssh_dir / "authorized_keys"
332+
333+ # Generate host key if it does not exist
334+ if not server_host_key .exists ():
335+ print (f"Generating SSH host key at { server_host_key } ..." )
336+ server_host_key .parent .mkdir (parents = True , exist_ok = True )
337+ key = asyncssh .generate_private_key ("ssh-ed25519" )
338+ server_host_key .write_bytes (key .export_private_key ())
339+ server_host_key .chmod (0o600 )
340+ server_host_keys = [str (server_host_key )]
341+
342+ # Collect authorized client keys for public key authentication
343+ authorized_client_keys = []
344+ if isinstance (
345+ authentication , (PublicKeyAuthentication , PasswordAndPublicKeyAuthentication )
346+ ):
347+ for key_type in ["rsa" , "ed25519" , "ecdsa" ]:
348+ user_public_key = user_ssh_dir / f"id_{ key_type } .pub"
349+ if user_public_key .exists ():
350+ authorized_client_keys .append (str (user_public_key ))
351+ if user_authorized_keys .exists ():
352+ authorized_client_keys .append (str (user_authorized_keys ))
353+ if config_authorized_keys .exists ():
354+ authorized_client_keys .append (str (config_authorized_keys ))
355+ if not authorized_client_keys and isinstance (
356+ authentication , PublicKeyAuthentication
357+ ):
300358 raise SystemExit (
301- f"The server requires a private RSA key to use as a host hey.\n "
302- f"You may generate one by running the following command:\n \n "
303- f" ssh-keygen -f { ssh_key_dir / 'id_rsa' } -P ''\n "
359+ f"Public key authentication is enabled, but no authorized keys were found.\n "
360+ f"Please add the public keys of allowed clients to { config_authorized_keys } ."
304361 )
305- server_host_keys = [str (user_private_key )]
306- authorized_client_keys = []
307- if user_public_key .exists ():
308- authorized_client_keys = [str (user_public_key )]
309362
310363 # Remove chacha20 from encryption_algs because it's a bit too expensive
311364 encryption_algs = [
@@ -318,7 +371,7 @@ async def run_server(
318371 ]
319372
320373 server = await asyncssh .create_server (
321- lambda : SSHServer (password , console_cls , namespace , executor ),
374+ lambda : SSHServer (authentication , console_cls , namespace , executor ),
322375 bind ,
323376 port ,
324377 server_host_keys = server_host_keys ,
@@ -328,8 +381,22 @@ async def run_server(
328381 line_editor = False ,
329382 reuse_address = True ,
330383 )
384+
385+ match authentication :
386+ case NoAuthentication ():
387+ print ("Authentication disabled (no password nor public key required)" )
388+ case PasswordAndPublicKeyAuthentication ():
389+ print ("Authentication methods:" )
390+ print ("- Global password" )
391+ for key_path in authorized_client_keys :
392+ print (f"- Public keys from: { key_path } " )
393+ case PublicKeyAuthentication ():
394+ print ("Authentication methods:" )
395+ for key_path in authorized_client_keys :
396+ print (f"- Public keys from: { key_path } " )
331397 bind , port = server .sockets [0 ].getsockname ()
332- print (f"Running ssh server on { bind } :{ port } ..." , flush = True )
398+ print (f"Running SSH server on { bind } :{ port } ..." , flush = True )
399+
333400 async with server :
334401 # Sleep forever
335402 await asyncio .Future ()
@@ -365,19 +432,37 @@ def main(
365432 default = None ,
366433 help = "Enable password authentification with the given global password" ,
367434 )
435+ parser .add_argument (
436+ "--no-auth" ,
437+ action = "store_true" ,
438+ help = "Disable authentication altogether (no password nor public key required)" ,
439+ )
368440
369441 # Parse arguments
370442 namespace = parser .parse_args (parser_args )
371443 bind : str = namespace .__dict__ .pop ("bind" )
372444 port : int = namespace .__dict__ .pop ("port" )
373445 password : str = namespace .__dict__ .pop ("password" )
446+ no_auth : bool = namespace .__dict__ .pop ("no_auth" )
447+
448+ # Determine authentication method
449+ if no_auth and password is None :
450+ authentication : AuthenticationMethod = NoAuthentication ()
451+ elif not no_auth and password is not None :
452+ authentication = PasswordAndPublicKeyAuthentication (password )
453+ elif not no_auth and password is None :
454+ authentication = PublicKeyAuthentication ()
455+ else :
456+ raise SystemExit (
457+ "Both `--password` and `--no-auth` cannot be provided at the same time"
458+ )
374459
375460 # Run an executor with no limit on the number of threads
376461 try :
377462 with ThreadPoolExecutor (max_workers = 32 ) as executor :
378463 # Run the server in asyncio
379464 asyncio .run (
380- run_server (bind , port , password , console_cls , namespace , executor )
465+ run_server (bind , port , authentication , console_cls , namespace , executor )
381466 )
382467 except KeyboardInterrupt :
383468 pass
0 commit comments