@@ -98,6 +98,10 @@ def __init__(self):
9898 self .loop = asyncio .get_running_loop ()
9999 self .selected_server_id = None
100100
101+ # Tunnel management
102+ self .tunnel_process = None
103+ self .tunnel_address = None
104+
101105 # Do not initialize handler automatically on startup anymore
102106 # self.initialize_handler()
103107
@@ -845,6 +849,166 @@ def create_world(request: Request):
845849 # Basic stub. Minecraft creates world automatically if level-name changes to non-existent folder.
846850 pass
847851
852+ # --- Tunnel Management Endpoints (Pinggy) ---
853+
854+ @app .get ("/tunnel/status" )
855+ def get_tunnel_status ():
856+ if not state :
857+ return {"active" : False , "address" : None }
858+
859+ return {
860+ "active" : state .tunnel_process is not None and state .tunnel_process .poll () is None ,
861+ "address" : state .tunnel_address
862+ }
863+
864+ @app .post ("/tunnel/start" )
865+ def start_tunnel (request : Request , region : str = "eu" ):
866+ if not state :
867+ raise HTTPException (status_code = 500 , detail = "App state not initialized" )
868+
869+ # If tunnel already running, return existing address
870+ if state .tunnel_process and state .tunnel_process .poll () is None :
871+ return {"message" : "Tunnel already running" , "address" : state .tunnel_address }
872+
873+ # Get server port (default 25565)
874+ port = "25565"
875+ if state .server_handler :
876+ try :
877+ props_path = os .path .join (state .server_handler .server_path , "server.properties" )
878+ if os .path .exists (props_path ):
879+ with open (props_path , 'r' ) as f :
880+ for line in f :
881+ if line .startswith ("server-port=" ):
882+ port = line .split ("=" )[1 ].strip ()
883+ break
884+ except :
885+ pass
886+
887+ def _ensure_ssh_key ():
888+ """Ensures a dedicated SSH key exists for the app to authenticate with Pinggy."""
889+ try :
890+ ssh_dir = os .path .join (state .app_data_dir , "ssh" )
891+ if not os .path .exists (ssh_dir ):
892+ os .makedirs (ssh_dir )
893+
894+ key_path = os .path .join (ssh_dir , "id_rsa" )
895+ pub_path = f"{ key_path } .pub"
896+
897+ # If key doesn't exist, generate it
898+ if not os .path .exists (key_path ) or not os .path .exists (pub_path ):
899+ logging .info ("Generating new SSH key for Pinggy..." )
900+ subprocess .run (
901+ ["ssh-keygen" , "-t" , "rsa" , "-b" , "2048" , "-f" , key_path , "-N" , "" ],
902+ check = True ,
903+ stdout = subprocess .DEVNULL ,
904+ stderr = subprocess .DEVNULL ,
905+ creationflags = subprocess .CREATE_NO_WINDOW if sys .platform == "win32" else 0
906+ )
907+ return key_path
908+ except Exception as e :
909+ logging .error (f"Failed to generate SSH key: { e } " )
910+ return None
911+
912+ def run_tunnel ():
913+ import subprocess
914+ import re
915+ try :
916+ # Construct host based on region
917+ # regions: eu, us, ap, sa
918+ host = f"{ region } .free.pinggy.io"
919+
920+ logging .info (f"Starting Pinggy tunnel ({ region .upper ()} ) for port { port } ..." )
921+ state .broadcast_log_sync (f"🌐 Iniciando túnel público ({ region .upper ()} ) para puerto { port } ..." , "info" )
922+
923+ # Ensure we have a key
924+ key_path = _ensure_ssh_key ()
925+
926+ # Pinggy SSH command - optimized with identity
927+ cmd = [
928+ "ssh" ,
929+ "-p" , "443" ,
930+ "-o" , "StrictHostKeyChecking=no" ,
931+ "-o" , "ServerAliveInterval=30" ,
932+ "-o" , "BatchMode=yes" ,
933+ "-T" , # Disable pseudo-terminal
934+ ]
935+
936+ if key_path and os .path .exists (key_path ):
937+ cmd .extend (["-i" , key_path , "-o" , "IdentitiesOnly=yes" ])
938+
939+ cmd .extend ([
940+ "-R" , f"0:127.0.0.1:{ port } " ,
941+ f"tcp@{ host } "
942+ ])
943+
944+ # Using bufsize=1 for line buffering
945+ state .tunnel_process = subprocess .Popen (
946+ cmd ,
947+ stdout = subprocess .PIPE ,
948+ stderr = subprocess .STDOUT ,
949+ stdin = subprocess .PIPE ,
950+ text = True ,
951+ bufsize = 1 ,
952+ creationflags = subprocess .CREATE_NO_WINDOW if sys .platform == "win32" else 0
953+ )
954+
955+ # Read output to find the tunnel URL
956+ for line in state .tunnel_process .stdout :
957+ line = line .strip ()
958+ if not line : continue
959+
960+ logging .debug (f"Pinggy output: { line } " )
961+
962+ # Pinggy outputs something like: "tcp://xyz.a.pinggy.io:12345"
963+ # Match tcp:// format
964+ tcp_match = re .search (r'tcp://([a-zA-Z0-9\.\-]+:\d+)' , line )
965+ if tcp_match :
966+ state .tunnel_address = tcp_match .group (1 )
967+
968+ # Match raw address format (free.pinggy.io:12345)
969+ # Broader match: something.pinggy.io:digits
970+ if not state .tunnel_address :
971+ addr_match = re .search (r'([a-zA-Z0-9\.\-]+\.pinggy\.io:\d+)' , line )
972+ if addr_match :
973+ state .tunnel_address = addr_match .group (1 )
974+
975+ if state .tunnel_address :
976+ logging .info (f"Tunnel established: { state .tunnel_address } " )
977+ state .broadcast_log_sync (f"✅ ¡Servidor público activo! Dirección: { state .tunnel_address } " , "success" )
978+ state .broadcast_log_sync ({"type" : "tunnel_connected" , "address" : state .tunnel_address })
979+
980+ # If we exit the loop, tunnel has closed
981+ state .broadcast_log_sync ("🔴 Túnel cerrado" , "warning" )
982+ state .broadcast_log_sync ({"type" : "tunnel_disconnected" })
983+ state .tunnel_address = None
984+
985+ except Exception as e :
986+ logging .exception (f"Tunnel error: { e } " )
987+ state .broadcast_log_sync (f"❌ Error en túnel: { e } " , "error" )
988+ state .tunnel_address = None
989+
990+ threading .Thread (target = run_tunnel , daemon = True ).start ()
991+ return {"message" : "Tunnel starting..." , "status" : "connecting" }
992+
993+ @app .post ("/tunnel/stop" )
994+ def stop_tunnel ():
995+ if not state :
996+ raise HTTPException (status_code = 500 , detail = "App state not initialized" )
997+
998+ if state .tunnel_process :
999+ try :
1000+ state .tunnel_process .terminate ()
1001+ state .tunnel_process .wait (timeout = 5 )
1002+ except :
1003+ state .tunnel_process .kill ()
1004+
1005+ state .tunnel_process = None
1006+ state .tunnel_address = None
1007+ state .broadcast_log_sync ("🔴 Túnel detenido" , "info" )
1008+ state .broadcast_log_sync ({"type" : "tunnel_disconnected" })
1009+
1010+ return {"message" : "Tunnel stopped" }
1011+
8481012if __name__ == "__main__" :
8491013 import uvicorn
8501014 uvicorn .run (app , host = "127.0.0.1" , port = 8000 )
0 commit comments