2022-03-19 20:41:35 -03:00
#!/usr/bin/env python3
2022-03-17 19:05:23 -03:00
#Imports
import os
import re
import pexpect
from Crypto . PublicKey import RSA
from Crypto . Cipher import PKCS1_OAEP
import ast
2022-05-25 17:25:02 -03:00
from time import sleep , time
2022-03-17 19:05:23 -03:00
import datetime
import sys
2022-03-30 19:51:54 -03:00
import threading
2022-05-19 16:11:41 -03:00
from pathlib import Path
2022-05-11 14:25:43 -03:00
from copy import deepcopy
2024-04-22 18:17:11 -03:00
from . hooks import ClassHook , MethodHook
2022-05-25 17:25:02 -03:00
import io
2026-04-27 15:12:07 -03:00
import asyncio
import fcntl
2026-04-17 18:42:08 -03:00
from . import printer
2026-04-27 15:12:07 -03:00
from . tunnels import LocalStream
2026-05-08 21:53:57 -03:00
from contextlib import contextmanager
2026-04-17 18:42:08 -03:00
2026-05-08 21:53:57 -03:00
@contextmanager
def copilot_terminal_mode ( ) :
import sys , tty , termios
fd = sys . stdin . fileno ( )
try :
old_settings = termios . tcgetattr ( fd )
# Primero pasamos a raw mode absoluto para matar ISIG, ICANON, ECHO, etc.
tty . setraw ( fd )
# Luego rehabilitamos OPOST para que rich.Live se dibuje correctamente
new_settings = termios . tcgetattr ( fd )
new_settings [ 1 ] = new_settings [ 1 ] | termios . OPOST
termios . tcsetattr ( fd , termios . TCSANOW , new_settings )
yield
finally :
try :
termios . tcsetattr ( fd , termios . TCSANOW , old_settings )
except Exception :
pass
2022-03-17 19:05:23 -03:00
2022-03-19 20:41:35 -03:00
#functions and classes
2024-04-22 18:17:11 -03:00
@ClassHook
2022-03-17 19:05:23 -03:00
class node :
2022-04-02 23:25:53 -03:00
''' This class generates a node object. Containts all the information and methods to connect and interact with a device using ssh or telnet.
2022-04-03 10:26:08 -03:00
### Attributes:
- output (str): Output of the commands you ran with run or test
method.
2022-04-02 23:25:53 -03:00
2022-04-03 10:26:08 -03:00
- result(bool): True if expected value is found after running
the commands using test method.
2022-04-23 17:11:38 -03:00
2025-08-04 11:34:22 -03:00
- status (int): 0 if the method run or test run successfully.
2022-04-23 17:11:38 -03:00
1 if connection failed.
2 if expect timeouts without prompt or EOF.
2022-04-02 23:25:53 -03:00
'''
2023-12-01 18:30:29 -03:00
def __init__ ( self , unique , host , options = ' ' , logs = ' ' , password = ' ' , port = ' ' , protocol = ' ' , user = ' ' , config = ' ' , tags = ' ' , jumphost = ' ' ) :
2022-04-02 23:25:53 -03:00
'''
2022-04-03 10:26:08 -03:00
### Parameters:
- unique (str): Unique name to assign to the node.
- host (str): IP address or hostname of the node.
### Optional Parameters:
- options (str): Additional options to pass the ssh/telnet for
connection.
- logs (str): Path/file for storing the logs. You can use
$ {unique} ,$ {host} , $ {port} , $ {user} , $ {protocol}
as variables.
- password (str): Encrypted or plaintext password.
- port (str): Port to connect to node, default 22 for ssh and 23
for telnet.
2024-06-17 15:58:28 -03:00
- protocol (str): Select ssh, telnet, kubectl or docker. Default is ssh.
2022-04-03 10:26:08 -03:00
- user (str): Username to of the node.
- config (obj): Pass the object created with class configfile with
key for decryption and extra configuration if you
are using connection manager.
2023-05-05 13:41:32 -03:00
- tags (dict) : Tags useful for automation and personal porpuse
like " os " , " prompt " and " screenleght_command "
2023-12-01 18:30:29 -03:00
- jumphost (str): Reference another node to be used as a jumphost
2022-04-02 23:25:53 -03:00
'''
2026-05-07 15:17:00 -03:00
self . config = config
2022-03-18 15:32:48 -03:00
if config == ' ' :
2022-03-17 19:05:23 -03:00
self . idletime = 0
2022-03-18 15:32:48 -03:00
self . key = None
else :
self . idletime = config . config [ " idletime " ]
self . key = config . key
2022-03-17 19:05:23 -03:00
self . unique = unique
2023-12-01 18:30:29 -03:00
attr = { " host " : host , " logs " : logs , " options " : options , " port " : port , " protocol " : protocol , " user " : user , " tags " : tags , " jumphost " : jumphost }
2022-03-17 19:05:23 -03:00
for key in attr :
2023-05-05 13:41:32 -03:00
profile = re . search ( " ^@(.*) " , str ( attr [ key ] ) )
2022-03-18 15:32:48 -03:00
if profile and config != ' ' :
2023-05-05 13:41:32 -03:00
try :
setattr ( self , key , config . profiles [ profile . group ( 1 ) ] [ key ] )
2026-04-03 17:11:45 -03:00
except KeyError :
2023-05-05 13:41:32 -03:00
setattr ( self , key , " " )
2022-03-17 19:05:23 -03:00
elif attr [ key ] == ' ' and key == " protocol " :
try :
setattr ( self , key , config . profiles [ " default " ] [ key ] )
2026-04-03 17:11:45 -03:00
except ( KeyError , AttributeError ) :
2022-03-17 19:05:23 -03:00
setattr ( self , key , " ssh " )
else :
setattr ( self , key , attr [ key ] )
if isinstance ( password , list ) :
self . password = [ ]
for i , s in enumerate ( password ) :
profile = re . search ( " ^@(.*) " , password [ i ] )
2022-03-18 15:32:48 -03:00
if profile and config != ' ' :
2022-03-17 19:05:23 -03:00
self . password . append ( config . profiles [ profile . group ( 1 ) ] [ " password " ] )
2026-04-17 18:42:08 -03:00
else :
self . password . append ( password [ i ] )
2022-03-17 19:05:23 -03:00
else :
2022-03-18 15:32:48 -03:00
self . password = [ password ]
2023-12-01 18:30:29 -03:00
if self . jumphost != " " and config != ' ' :
self . jumphost = config . getitem ( self . jumphost )
for key in self . jumphost :
profile = re . search ( " ^@(.*) " , str ( self . jumphost [ key ] ) )
if profile :
try :
self . jumphost [ key ] = config . profiles [ profile . group ( 1 ) ] [ key ]
2026-04-03 17:11:45 -03:00
except KeyError :
2023-12-01 18:30:29 -03:00
self . jumphost [ key ] = " "
elif self . jumphost [ key ] == ' ' and key == " protocol " :
try :
self . jumphost [ key ] = config . profiles [ " default " ] [ key ]
2026-04-03 17:11:45 -03:00
except KeyError :
2023-12-01 18:30:29 -03:00
self . jumphost [ key ] = " ssh "
if isinstance ( self . jumphost [ " password " ] , list ) :
jumphost_password = [ ]
for i , s in enumerate ( self . jumphost [ " password " ] ) :
profile = re . search ( " ^@(.*) " , self . jumphost [ " password " ] [ i ] )
if profile :
jumphost_password . append ( config . profiles [ profile . group ( 1 ) ] [ " password " ] )
2026-04-17 18:42:08 -03:00
else :
jumphost_password . append ( self . jumphost [ " password " ] [ i ] )
2023-12-01 18:30:29 -03:00
self . jumphost [ " password " ] = jumphost_password
else :
self . jumphost [ " password " ] = [ self . jumphost [ " password " ] ]
if self . jumphost [ " password " ] != [ " " ] :
self . password = self . jumphost [ " password " ] + self . password
if self . jumphost [ " protocol " ] == " ssh " :
jumphost_cmd = self . jumphost [ " protocol " ] + " -W % h: % p "
2023-12-04 11:11:58 -03:00
if self . jumphost [ " port " ] != ' ' :
2023-12-01 18:30:29 -03:00
jumphost_cmd = jumphost_cmd + " -p " + self . jumphost [ " port " ]
2023-12-04 11:11:58 -03:00
if self . jumphost [ " options " ] != ' ' :
2023-12-01 18:30:29 -03:00
jumphost_cmd = jumphost_cmd + " " + self . jumphost [ " options " ]
2023-12-04 11:11:58 -03:00
if self . jumphost [ " user " ] == ' ' :
2023-12-01 18:30:29 -03:00
jumphost_cmd = jumphost_cmd + " {} " . format ( self . jumphost [ " host " ] )
else :
jumphost_cmd = jumphost_cmd + " {} " . format ( " @ " . join ( [ self . jumphost [ " user " ] , self . jumphost [ " host " ] ] ) )
self . jumphost = f " -o ProxyCommand= \" { jumphost_cmd } \" "
2026-04-30 19:25:17 -03:00
elif self . jumphost [ " protocol " ] == " ssm " :
ssm_target = self . jumphost [ " host " ]
ssm_cmd = f " aws ssm start-session --target { ssm_target } --document-name AWS-StartSSHSession --parameters ' portNumber=22 ' "
if isinstance ( self . jumphost . get ( " tags " ) , dict ) :
if " profile " in self . jumphost [ " tags " ] :
ssm_cmd + = f " --profile { self . jumphost [ ' tags ' ] [ ' profile ' ] } "
if " region " in self . jumphost [ " tags " ] :
ssm_cmd + = f " --region { self . jumphost [ ' tags ' ] [ ' region ' ] } "
if self . jumphost [ " options " ] != ' ' :
ssm_cmd + = f " { self . jumphost [ ' options ' ] } "
bastion_user_part = f " { self . jumphost [ ' user ' ] } @ { ssm_target } " if self . jumphost [ ' user ' ] else ssm_target
ssh_opts = " "
if isinstance ( self . jumphost . get ( " tags " ) , dict ) and " ssh_options " in self . jumphost [ " tags " ] :
ssh_opts = f " { self . jumphost [ ' tags ' ] [ ' ssh_options ' ] } "
inner_ssh = f " ssh { ssh_opts } -o ProxyCommand= ' { ssm_cmd } ' -W %h:%p { bastion_user_part } "
self . jumphost = f " -o ProxyCommand= \" { inner_ssh } \" "
elif self . jumphost [ " protocol " ] in [ " kubectl " , " docker " ] :
nc_cmd = " nc "
if isinstance ( self . jumphost . get ( " tags " ) , dict ) and " nc_command " in self . jumphost [ " tags " ] :
nc_cmd = self . jumphost [ " tags " ] [ " nc_command " ]
if self . jumphost [ " protocol " ] == " kubectl " :
proxy_cmd = f " kubectl exec "
if self . jumphost [ " options " ] != ' ' :
proxy_cmd + = f " { self . jumphost [ ' options ' ] } "
proxy_cmd + = f " { self . jumphost [ ' host ' ] } -i -- { nc_cmd } %h %p "
else :
proxy_cmd = f " docker "
if self . jumphost [ " options " ] != ' ' :
proxy_cmd + = f " { self . jumphost [ ' options ' ] } "
proxy_cmd + = f " exec -i { self . jumphost [ ' host ' ] } { nc_cmd } %h %p "
self . jumphost = f " -o ProxyCommand= \" { proxy_cmd } \" "
2023-12-01 18:30:29 -03:00
else :
self . jumphost = " "
2026-04-30 14:18:41 -03:00
self . output = " "
self . status = 1
self . result = { }
2026-05-15 16:25:18 -03:00
self . cmd_byte_positions = [ ( 0 , None ) ]
2022-03-17 19:05:23 -03:00
2024-04-22 18:17:11 -03:00
@MethodHook
2022-06-10 13:24:26 -03:00
def _passtx ( self , passwords , * , keyfile = None ) :
2022-04-02 23:25:53 -03:00
# decrypts passwords, used by other methdos.
2022-03-17 19:05:23 -03:00
dpass = [ ]
2022-03-18 15:32:48 -03:00
if keyfile is None :
keyfile = self . key
2022-03-25 12:25:59 -03:00
if keyfile is not None :
2022-06-10 13:24:26 -03:00
with open ( keyfile ) as f :
key = RSA . import_key ( f . read ( ) )
2022-03-18 15:32:48 -03:00
decryptor = PKCS1_OAEP . new ( key )
2022-03-17 19:05:23 -03:00
for passwd in passwords :
2022-03-29 18:57:27 -03:00
if not re . match ( ' ^b[ \" \' ].+[ \" \' ]$ ' , passwd ) :
2022-03-18 15:32:48 -03:00
dpass . append ( passwd )
else :
try :
2022-03-29 18:57:27 -03:00
decrypted = decryptor . decrypt ( ast . literal_eval ( passwd ) ) . decode ( " utf-8 " )
2022-03-18 15:32:48 -03:00
dpass . append ( decrypted )
2026-04-03 17:11:45 -03:00
except Exception :
2026-04-17 18:42:08 -03:00
printer . error ( " Decryption failed: Missing or corrupted key. " )
printer . info ( " Verify your RSA key and configuration settings. " )
sys . exit ( 1 )
2022-03-17 19:05:23 -03:00
return dpass
2024-04-22 18:17:11 -03:00
@MethodHook
2022-03-17 19:05:23 -03:00
def _logfile ( self , logfile = None ) :
2022-04-02 23:25:53 -03:00
# translate logs variables and generate logs path.
2022-03-17 19:05:23 -03:00
if logfile == None :
logfile = self . logs
logfile = logfile . replace ( " $ {unique} " , self . unique )
logfile = logfile . replace ( " $ {host} " , self . host )
logfile = logfile . replace ( " $ {port} " , self . port )
logfile = logfile . replace ( " $ {user} " , self . user )
logfile = logfile . replace ( " $ {protocol} " , self . protocol )
now = datetime . datetime . now ( )
dateconf = re . search ( r ' \ $ \ { date \' (.*) \' } ' , logfile )
if dateconf :
logfile = re . sub ( r ' \ $ \ { date (.*)} ' , now . strftime ( dateconf . group ( 1 ) ) , logfile )
return logfile
2024-04-22 18:17:11 -03:00
@MethodHook
2022-03-18 16:16:31 -03:00
def _logclean ( self , logfile , var = False ) :
2026-05-12 12:20:50 -03:00
""" Remove special ascii characters and process terminal cursor movements to clean logs. """
2026-05-12 13:19:06 -03:00
from . utils import log_cleaner
2026-05-12 12:20:50 -03:00
2022-03-18 16:16:31 -03:00
if var == False :
2026-05-12 12:20:50 -03:00
try :
with open ( logfile , " r " ) as f :
t = f . read ( )
except :
return
2022-03-18 16:16:31 -03:00
else :
t = logfile
2026-04-27 15:12:07 -03:00
2026-05-12 12:20:50 -03:00
result = log_cleaner ( t )
2026-04-27 15:12:07 -03:00
2022-03-18 16:16:31 -03:00
if var == False :
2026-05-12 12:20:50 -03:00
try :
with open ( logfile , " w " ) as f :
f . write ( result )
except :
pass
2022-03-18 16:16:31 -03:00
return
else :
2026-05-12 12:20:50 -03:00
return result
2022-03-17 19:05:23 -03:00
2024-04-22 18:17:11 -03:00
@MethodHook
2023-10-05 15:21:17 -03:00
def _savelog ( self ) :
''' Save the log buffer to the file at regular intervals if there are changes. '''
t = threading . current_thread ( )
prev_size = 0 # Store the previous size of the buffer
while getattr ( t , " do_run " , True ) : # Check if thread is signaled to stop
current_size = self . mylog . tell ( ) # Current size of the buffer
# Only save if the buffer size has changed
if current_size != prev_size :
with open ( self . logfile , " w " ) as f : # Use "w" to overwrite the file
f . write ( self . _logclean ( self . mylog . getvalue ( ) . decode ( ) , True ) )
prev_size = current_size # Update the previous size
sleep ( 5 )
2024-04-22 18:17:11 -03:00
@MethodHook
2022-05-25 17:25:02 -03:00
def _filter ( self , a ) :
#Set time for last input when using interact
self . lastinput = time ( )
return a
2024-04-22 18:17:11 -03:00
@MethodHook
2022-05-25 17:25:02 -03:00
def _keepalive ( self ) :
#Send keepalive ctrl+e when idletime passed without new inputs on interact
self . lastinput = time ( )
2022-06-10 13:24:26 -03:00
t = threading . current_thread ( )
2022-05-25 17:25:02 -03:00
while True :
if time ( ) - self . lastinput > = self . idletime :
self . child . sendcontrol ( " e " )
self . lastinput = time ( )
sleep ( 1 )
2026-04-27 15:12:07 -03:00
def _setup_interact_environment ( self , debug = False , logger = None , async_mode = False ) :
size = re . search ( ' columns=([0-9]+).*lines=([0-9]+) ' , str ( os . get_terminal_size ( ) ) )
self . child . setwinsize ( int ( size . group ( 2 ) ) , int ( size . group ( 1 ) ) )
if logger :
port_str = f " : { self . port } " if self . port and self . protocol not in [ " ssm " , " kubectl " , " docker " ] else " "
logger ( " success " , f " Connected to { self . unique } at { self . host } { port_str } via: { self . protocol } " )
2026-04-17 18:42:08 -03:00
2026-05-11 12:30:43 -03:00
# Always initialize self.mylog to capture terminal context for the AI Copilot
if not hasattr ( self , ' mylog ' ) :
self . mylog = io . BytesIO ( )
if not async_mode :
self . child . logfile_read = self . mylog
# Only start disk-logging tasks if logfile is configured
2026-04-27 15:12:07 -03:00
if ' logfile ' in dir ( self ) :
if not async_mode :
2026-05-11 12:30:43 -03:00
# Start the _savelog thread (sync mode)
2023-10-05 15:21:17 -03:00
log_thread = threading . Thread ( target = self . _savelog )
log_thread . daemon = True
log_thread . start ( )
2026-04-27 15:12:07 -03:00
if ' missingtext ' in dir ( self ) :
print ( self . child . after . decode ( ) , end = ' ' )
if self . idletime > 0 and not async_mode :
x = threading . Thread ( target = self . _keepalive )
x . daemon = True
x . start ( )
if debug :
if ' mylog ' in dir ( self ) :
2026-05-05 18:24:31 -03:00
if not async_mode :
print ( self . mylog . getvalue ( ) . decode ( ) )
2022-05-25 17:25:02 -03:00
2026-04-27 15:12:07 -03:00
def _teardown_interact_environment ( self ) :
if ' logfile ' in dir ( self ) and hasattr ( self , ' mylog ' ) :
with open ( self . logfile , " w " ) as f :
f . write ( self . _logclean ( self . mylog . getvalue ( ) . decode ( ) , True ) )
2026-05-07 15:17:00 -03:00
async def _async_interact_loop ( self , local_stream , resize_callback , copilot_handler = None ) :
2026-04-27 15:12:07 -03:00
local_stream . setup ( resize_callback = resize_callback )
try :
child_fd = self . child . child_fd
# 1. Flush ghost buffer (Clean UX)
ghost_buffer = b ' '
if getattr ( self , ' missingtext ' , False ) :
# If we are missing the password, we MUST show the password prompt
ghost_buffer = ( self . child . after or b ' ' ) + ( self . child . buffer or b ' ' )
else :
# We auto-logged in. Hide the messy password negotiation and just keep any pending live stream.
ghost_buffer = self . child . buffer or b ' '
# Fix user's pet peeve: Strip leading newlines to avoid the empty lines
# the router echoes after receiving the password or blank line.
if not getattr ( self , ' missingtext ' , False ) :
ghost_buffer = ghost_buffer . lstrip ( b ' \r \n ' )
if ghost_buffer :
# Add a single clean newline so it doesn't merge with the Connected message
await local_stream . write ( b ' \r \n ' + ghost_buffer )
if hasattr ( self , ' mylog ' ) :
self . mylog . write ( b ' \n ' + ghost_buffer )
self . child . buffer = b ' '
self . child . before = b ' '
# 2. Set child fd non-blocking
flags = fcntl . fcntl ( child_fd , fcntl . F_GETFL )
fcntl . fcntl ( child_fd , fcntl . F_SETFL , flags | os . O_NONBLOCK )
loop = asyncio . get_running_loop ( )
child_reader_queue = asyncio . Queue ( )
2026-05-15 16:25:18 -03:00
# Reset and track command byte positions for copilot context navigation
2026-05-08 21:53:57 -03:00
# Each entry is (byte_position, command_text_or_None)
2026-05-15 16:25:18 -03:00
self . cmd_byte_positions = [ ( self . mylog . tell ( ) if hasattr ( self , ' mylog ' ) else 0 , None ) ]
2026-05-07 17:30:43 -03:00
2026-04-27 15:12:07 -03:00
def _child_read_ready ( ) :
try :
2026-05-08 18:45:42 -03:00
# Increase buffer to 64KB for better high-speed handling
data = os . read ( child_fd , 65536 )
2026-04-27 15:12:07 -03:00
if data :
child_reader_queue . put_nowait ( data )
else :
child_reader_queue . put_nowait ( b ' ' )
except BlockingIOError :
pass
except OSError :
child_reader_queue . put_nowait ( b ' ' )
loop . add_reader ( child_fd , _child_read_ready )
self . lastinput = time ( )
async def ingress_task ( ) :
while True :
data = await local_stream . read ( )
if not data :
break
2026-05-07 15:17:00 -03:00
# Copilot interception
if copilot_handler and b ' \x00 ' in data :
2026-05-12 12:20:50 -03:00
# Build node info from available metadata and ensure values are strings (not bytes)
def to_str ( val ) :
if isinstance ( val , bytes ) :
return val . decode ( errors = ' replace ' )
return str ( val ) if val is not None else " unknown "
node_info = {
" name " : to_str ( getattr ( self , ' unique ' , ' unknown ' ) ) ,
" host " : to_str ( getattr ( self , ' host ' , ' unknown ' ) )
}
2026-05-07 15:17:00 -03:00
if isinstance ( getattr ( self , ' tags ' , None ) , dict ) :
2026-05-12 12:20:50 -03:00
node_info [ " os " ] = to_str ( self . tags . get ( " os " , " unknown " ) )
node_info [ " prompt " ] = to_str ( self . tags . get ( " prompt " , r ' >$|#$| \ $$|>.$|#.$| \ $.$ ' ) )
2026-05-07 15:17:00 -03:00
# Invoke copilot (async callback handles UI)
2026-05-15 16:25:18 -03:00
await copilot_handler ( self . mylog . getvalue ( ) , node_info , local_stream , child_fd , self . cmd_byte_positions )
2026-05-07 15:17:00 -03:00
continue
# Remove any stray \x00 bytes and forward normally
clean_data = data . replace ( b ' \x00 ' , b ' ' )
if clean_data :
2026-05-07 17:30:43 -03:00
# Track command boundaries when user hits Enter
if hasattr ( self , ' mylog ' ) and ( b ' \r ' in clean_data or b ' \n ' in clean_data ) :
2026-05-15 16:25:18 -03:00
self . cmd_byte_positions . append ( ( self . mylog . tell ( ) , None ) )
try : os . write ( child_fd , clean_data )
2026-05-07 15:17:00 -03:00
except OSError :
break
self . lastinput = time ( )
2026-04-27 15:12:07 -03:00
async def egress_task ( ) :
# Continue stripping newlines from the live stream until we hit real text
skip_newlines = not getattr ( self , ' missingtext ' , False ) and not ghost_buffer
while True :
data = await child_reader_queue . get ( )
if not data :
break
2026-05-08 18:45:42 -03:00
# Batching Optimization: Drain the queue to batch writes during high-volume bursts
# Helps the terminal parse ANSI faster and reduces syscalls.
chunks = [ data ]
while not child_reader_queue . empty ( ) :
try :
extra = child_reader_queue . get_nowait ( )
if not extra :
chunks . append ( b ' ' ) # Re-put EOF later or handle it
break
chunks . append ( extra )
except asyncio . QueueEmpty :
break
has_eof = chunks [ - 1 ] == b ' '
if has_eof :
chunks . pop ( )
if chunks :
combined_data = b ' ' . join ( chunks )
if skip_newlines :
stripped = combined_data . lstrip ( b ' \r \n ' )
if stripped :
skip_newlines = False
combined_data = stripped
else :
if has_eof : break
continue
await local_stream . write ( combined_data )
if hasattr ( self , ' mylog ' ) :
self . mylog . write ( combined_data )
if has_eof :
break
2026-04-27 15:12:07 -03:00
async def keepalive_task ( ) :
while True :
await asyncio . sleep ( 1 )
if time ( ) - self . lastinput > = self . idletime :
try :
self . child . sendcontrol ( " e " )
self . lastinput = time ( )
except Exception :
pass
async def savelog_task ( ) :
prev_size = 0
while True :
await asyncio . sleep ( 5 )
current_size = self . mylog . tell ( )
if current_size != prev_size :
try :
2026-05-08 18:45:42 -03:00
# Move heavy log cleaning to a thread to avoid freezing the interaction loop
raw_log = self . mylog . getvalue ( ) . decode ( errors = ' replace ' )
cleaned_log = await asyncio . to_thread ( self . _logclean , raw_log , True )
2026-04-27 15:12:07 -03:00
with open ( self . logfile , " w " ) as f :
2026-05-08 18:45:42 -03:00
f . write ( cleaned_log )
2026-04-27 15:12:07 -03:00
prev_size = current_size
except Exception :
pass
try :
2026-05-08 18:45:42 -03:00
# We wait for either the user (ingress) or the child (egress) to finish
2026-04-27 15:12:07 -03:00
tasks = [
asyncio . create_task ( ingress_task ( ) ) ,
2026-04-30 19:25:17 -03:00
asyncio . create_task ( egress_task ( ) )
2026-04-27 15:12:07 -03:00
]
2026-04-30 19:25:17 -03:00
if self . idletime > 0 :
tasks . append ( asyncio . create_task ( keepalive_task ( ) ) )
if hasattr ( self , ' logfile ' ) and hasattr ( self , ' mylog ' ) :
tasks . append ( asyncio . create_task ( savelog_task ( ) ) )
2026-05-08 18:45:42 -03:00
done , pending = await asyncio . wait (
[ tasks [ 0 ] , tasks [ 1 ] ] ,
return_when = asyncio . FIRST_COMPLETED
)
# If ingress finished first (user quit), give egress a small window to catch up
# on the remaining output in the queue.
if tasks [ 0 ] in done and tasks [ 1 ] not in done :
try :
await asyncio . wait_for ( tasks [ 1 ] , timeout = 0.2 )
except ( asyncio . TimeoutError , asyncio . CancelledError ) :
pass
for t in tasks :
if t not in done :
t . cancel ( )
# Final log sync on thread to avoid losing last lines
if hasattr ( self , ' logfile ' ) and hasattr ( self , ' mylog ' ) :
try :
raw_log = self . mylog . getvalue ( ) . decode ( errors = ' replace ' )
cleaned_log = await asyncio . to_thread ( self . _logclean , raw_log , True )
with open ( self . logfile , " w " ) as f :
f . write ( cleaned_log )
except Exception :
pass
2026-04-27 15:12:07 -03:00
finally :
loop . remove_reader ( child_fd )
try :
flags = fcntl . fcntl ( child_fd , fcntl . F_GETFL )
fcntl . fcntl ( child_fd , fcntl . F_SETFL , flags & ~ os . O_NONBLOCK )
except Exception :
pass
finally :
local_stream . teardown ( )
2026-05-15 16:25:18 -03:00
@MethodHook
async def inject_commands ( self , commands , child_fd , on_inject = None ) :
"""
Inject a list of commands into the node ' s PTY.
Handles screen_length_command, history tracking and delays.
"""
if not commands :
return
# 0. Clear line
os . write ( child_fd , b ' \x15 ' )
await asyncio . sleep ( 0.1 )
# 1. Prepare list (prepend screen_length if exists)
slc = self . tags . get ( " screen_length_command " ) if hasattr ( self , ' tags ' ) and isinstance ( self . tags , dict ) else None
to_send = list ( commands )
if slc and slc not in to_send : # avoid duplicates if already there
to_send . insert ( 0 , slc )
# 2. Inject one by one
for cmd in to_send :
# Register in node's official history (SKIP if it's the administrative screen length command)
if cmd != slc and hasattr ( self , ' cmd_byte_positions ' ) and self . cmd_byte_positions is not None :
log_pos = self . mylog . tell ( ) if hasattr ( self , ' mylog ' ) else 0
self . cmd_byte_positions . append ( ( log_pos , cmd ) )
# Write physically to PTY
os . write ( child_fd , ( cmd + " \n " ) . encode ( ) )
# Notify (e.g., for gRPC or logs) - SKIP for administrative SLC
if on_inject and cmd != slc :
if asyncio . iscoroutinefunction ( on_inject ) :
await on_inject ( cmd )
else :
on_inject ( cmd )
# Delay to avoid overwhelming the router
await asyncio . sleep ( 0.8 )
2026-04-27 15:12:07 -03:00
@MethodHook
def interact ( self , debug = False , logger = None ) :
'''
Asynchronous interactive session using Smart Tunnel architecture.
Allows multiplexing I/O and handling SIGWINCH events locally without blocking.
'''
connect = self . _connect ( debug = debug , logger = logger )
if connect == True :
try :
self . _setup_interact_environment ( debug = debug , logger = logger , async_mode = True )
local_stream = LocalStream ( )
def resize_callback ( rows , cols ) :
try :
self . child . setwinsize ( rows , cols )
except Exception :
pass
2026-05-07 15:17:00 -03:00
# Build local copilot handler
copilot_handler = self . _build_local_copilot_handler ( )
asyncio . run ( self . _async_interact_loop ( local_stream , resize_callback , copilot_handler = copilot_handler ) )
2026-04-27 15:12:07 -03:00
finally :
self . _teardown_interact_environment ( )
2022-03-25 12:25:59 -03:00
else :
2026-04-17 18:42:08 -03:00
if logger :
logger ( " error " , str ( connect ) )
else :
printer . error ( f " Connection failed: { str ( connect ) } " )
sys . exit ( 1 )
2026-05-07 15:17:00 -03:00
def _build_local_copilot_handler ( self ) :
""" Build copilot handler for local CLI sessions using rich for rendering. """
config = getattr ( self , ' config ' , None ) if hasattr ( self , ' config ' ) else None
2026-05-12 12:20:50 -03:00
return self . _copilot_handler ( config )
def _copilot_handler ( self , config ) :
""" Unified copilot handler for local session. """
from . cli . terminal_ui import CopilotInterface
from . services . ai_service import AIService
import asyncio
import os
2026-05-07 17:30:43 -03:00
async def handler ( buffer , node_info , stream , child_fd , cmd_byte_positions = None ) :
2026-05-07 15:17:00 -03:00
try :
2026-05-13 14:16:14 -03:00
interface = CopilotInterface (
config ,
history = getattr ( stream , ' copilot_history ' , None ) ,
session_state = getattr ( stream , ' copilot_state ' , None )
)
2026-05-12 12:20:50 -03:00
# Save history back to stream for persistence in current session
stream . copilot_history = interface . history
2026-05-13 14:16:14 -03:00
stream . copilot_state = interface . session_state
2026-05-12 12:20:50 -03:00
ai_service = AIService ( config )
2026-05-13 14:16:14 -03:00
async def on_ai_call ( active_buffer , question , chunk_callback , merged_node_info ) :
2026-05-12 12:20:50 -03:00
return await ai_service . aask_copilot (
2026-05-13 14:16:14 -03:00
active_buffer ,
question ,
node_info = merged_node_info ,
2026-05-12 12:20:50 -03:00
chunk_callback = chunk_callback
)
# Get raw bytes from BytesIO
raw_bytes = self . mylog . getvalue ( )
2026-05-07 15:17:00 -03:00
2026-05-12 12:20:50 -03:00
# Detener el lector de la terminal para que prompt_toolkit (en run_session)
# tenga control exclusivo del stdin sin interferencias de LocalStream.
if hasattr ( stream , ' stop_reading ' ) :
stream . stop_reading ( )
elif hasattr ( stream , ' _loop ' ) and hasattr ( stream , ' stdin_fd ' ) :
# Fallback si no tiene el método (en LocalStream)
stream . _loop . remove_reader ( stream . stdin_fd )
2026-05-08 21:53:57 -03:00
try :
2026-05-12 12:20:50 -03:00
with copilot_terminal_mode ( ) :
2026-05-13 14:16:14 -03:00
while True :
action , commands , custom_cmd = await interface . run_session (
raw_bytes = raw_bytes ,
2026-05-15 16:25:18 -03:00
cmd_byte_positions = self . cmd_byte_positions ,
2026-05-13 14:16:14 -03:00
node_info = node_info ,
on_ai_call = on_ai_call
)
if action == " continue " :
continue
break
2026-05-12 12:20:50 -03:00
finally :
2026-05-18 14:07:24 -03:00
print ( " \033 [2m Returning to session... \033 [0m " , flush = True )
2026-05-12 12:20:50 -03:00
# Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet
if hasattr ( stream , ' start_reading ' ) :
stream . start_reading ( )
elif hasattr ( stream , ' _loop ' ) and hasattr ( stream , ' stdin_fd ' ) :
stream . _loop . add_reader ( stream . stdin_fd , stream . _read_ready )
if action in ( " send_all " , " custom " ) :
cmds_to_send = commands if action == " send_all " else custom_cmd
2026-05-15 16:25:18 -03:00
await self . inject_commands ( cmds_to_send , child_fd )
2026-05-07 15:17:00 -03:00
else :
os . write ( child_fd , b ' \x15 \r ' )
except Exception as e :
import traceback
print ( f " \n [ERROR in Copilot Handler] { e } " , flush = True )
traceback . print_exc ( )
2026-05-12 12:20:50 -03:00
os . write ( child_fd , b ' \x15 \r ' )
2026-05-07 15:17:00 -03:00
2026-05-12 12:20:50 -03:00
return handler
2022-03-18 15:32:48 -03:00
2024-04-22 18:17:11 -03:00
@MethodHook
2026-04-17 18:42:08 -03:00
def run ( self , commands , vars = None , * , folder = ' ' , prompt = r ' >$|#$| \ $$|>.$|#.$| \ $.$ ' , stdout = False , timeout = 10 , logger = None ) :
2022-04-02 23:25:53 -03:00
'''
Run a command or list of commands on the node and return the output.
2026-04-17 18:42:08 -03:00
2022-04-03 10:26:08 -03:00
### Parameters:
- commands (str/list): Commands to run on the node. Should be
2022-04-23 17:11:38 -03:00
str or a list of str. You can use variables
as {varname} and defining them in optional
parameter vars.
### Optional Parameters:
- vars (dict): Dictionary containing the definition of variables
used in commands parameter.
Keys: Variable names.
Values: strings.
2022-04-02 23:25:53 -03:00
2022-04-03 10:26:08 -03:00
### Optional Named Parameters:
2022-04-02 23:25:53 -03:00
2022-04-03 10:26:08 -03:00
- folder (str): Path where output log should be stored, leave
empty to disable logging.
2022-04-02 23:25:53 -03:00
2022-04-03 10:26:08 -03:00
- prompt (str): Prompt to be expected after a command is finished
running. Usually linux uses " > " or EOF while
routers use " > " or " # " . The default value should
work for most nodes. Change it if your connection
need some special symbol.
2022-04-02 23:25:53 -03:00
2022-04-03 10:26:08 -03:00
- stdout (bool):Set True to send the command output to stdout.
default False.
2022-04-02 23:25:53 -03:00
2022-04-23 17:11:38 -03:00
- timeout (int):Time in seconds for expect to wait for prompt/EOF.
2023-03-21 18:23:29 -03:00
default 10.
2022-04-23 17:11:38 -03:00
2022-04-03 10:26:08 -03:00
### Returns:
str: Output of the commands you ran on the node.
2022-04-02 23:25:53 -03:00
'''
2026-04-17 18:42:08 -03:00
connect = self . _connect ( timeout = timeout , logger = logger )
2022-05-19 16:11:41 -03:00
now = datetime . datetime . now ( ) . strftime ( ' % Y- % m- %d _ % H % M % S ' )
2022-03-18 15:32:48 -03:00
if connect == True :
2026-04-17 18:42:08 -03:00
if logger :
2026-04-24 19:23:00 -03:00
port_str = f " : { self . port } " if self . port and self . protocol not in [ " ssm " , " kubectl " , " docker " ] else " "
logger ( " success " , f " Connected to { self . unique } at { self . host } { port_str } via: { self . protocol } " )
2026-04-17 18:42:08 -03:00
2024-06-17 15:58:28 -03:00
# Attempt to set the terminal size
try :
self . child . setwinsize ( 65535 , 65535 )
except Exception :
try :
self . child . setwinsize ( 10000 , 10000 )
except Exception :
pass
2023-05-05 13:41:32 -03:00
if " prompt " in self . tags :
prompt = self . tags [ " prompt " ]
2022-04-23 17:11:38 -03:00
expects = [ prompt , pexpect . EOF , pexpect . TIMEOUT ]
2022-03-18 15:32:48 -03:00
output = ' '
2022-05-19 16:11:41 -03:00
status = ' '
2022-04-23 17:11:38 -03:00
if not isinstance ( commands , list ) :
commands = [ commands ]
2023-05-05 13:41:32 -03:00
if " screen_length_command " in self . tags :
commands . insert ( 0 , self . tags [ " screen_length_command " ] )
2022-05-25 17:25:02 -03:00
self . mylog = io . BytesIO ( )
self . child . logfile_read = self . mylog
2022-04-23 17:11:38 -03:00
for c in commands :
if vars is not None :
2026-04-30 14:18:41 -03:00
try :
c = c . format ( * * vars )
except KeyError as e :
self . output = f " Error: Variable { e } not defined in task or inventory "
self . status = 1
return self . output
2022-04-23 17:11:38 -03:00
result = self . child . expect ( expects , timeout = timeout )
self . child . sendline ( c )
if result == 2 :
2022-05-19 16:11:41 -03:00
break
2022-05-25 17:25:02 -03:00
if not result == 2 :
2022-05-19 16:11:41 -03:00
result = self . child . expect ( expects , timeout = timeout )
2022-03-28 10:20:00 -03:00
self . child . close ( )
2022-05-25 17:25:02 -03:00
output = self . _logclean ( self . mylog . getvalue ( ) . decode ( ) , True )
2026-04-17 18:42:08 -03:00
if logger :
logger ( " output " , output )
2022-03-25 12:25:59 -03:00
if folder != ' ' :
2022-05-19 16:11:41 -03:00
with open ( folder + " / " + self . unique + " _ " + now + " .txt " , " w " ) as f :
2022-03-18 15:32:48 -03:00
f . write ( output )
f . close ( )
2022-03-18 16:16:31 -03:00
self . output = output
2022-05-25 17:25:02 -03:00
if result == 2 :
2022-05-19 16:11:41 -03:00
self . status = 2
else :
self . status = 0
2022-03-18 16:16:31 -03:00
return output
2022-03-25 17:55:43 -03:00
else :
2022-03-30 17:36:27 -03:00
self . output = connect
2022-04-23 17:11:38 -03:00
self . status = 1
2026-04-17 18:42:08 -03:00
if logger :
logger ( " error " , f " Connection failed: { connect } " )
2022-05-19 16:11:41 -03:00
if folder != ' ' :
with open ( folder + " / " + self . unique + " _ " + now + " .txt " , " w " ) as f :
f . write ( connect )
2026-04-17 18:42:08 -03:00
2022-05-19 16:11:41 -03:00
f . close ( )
2022-03-25 17:55:43 -03:00
return connect
2022-03-18 15:32:48 -03:00
2024-04-22 18:17:11 -03:00
@MethodHook
2026-04-30 14:18:41 -03:00
def test ( self , commands , expected , vars = None , * , folder = ' ' , prompt = r ' >$|#$| \ $$|>.$|#.$| \ $.$ ' , timeout = 10 , logger = None ) :
2022-04-02 23:25:53 -03:00
'''
Run a command or list of commands on the node, then check if expected value appears on the output after the last command.
2026-04-17 18:42:08 -03:00
2022-04-03 10:26:08 -03:00
### Parameters:
- commands (str/list): Commands to run on the node. Should be
2022-04-23 17:11:38 -03:00
str or a list of str. You can use variables
as {varname} and defining them in optional
parameter vars.
2022-04-02 23:25:53 -03:00
2022-04-03 10:26:08 -03:00
- expected (str) : Expected text to appear after running
2022-04-23 17:11:38 -03:00
all the commands on the node.You can use
variables as {varname} and defining them
in optional parameter vars.
### Optional Parameters:
- vars (dict): Dictionary containing the definition of variables
used in commands and expected parameters.
Keys: Variable names.
Values: strings.
2022-04-02 23:25:53 -03:00
2022-04-03 10:26:08 -03:00
### Optional Named Parameters:
2022-04-02 23:25:53 -03:00
2026-04-30 14:18:41 -03:00
- folder (str): Path where output log should be stored, leave
empty to not store logs.
2022-04-03 10:26:08 -03:00
- prompt (str): Prompt to be expected after a command is finished
running. Usually linux uses " > " or EOF while
routers use " > " or " # " . The default value should
work for most nodes. Change it if your connection
need some special symbol.
2022-04-02 23:25:53 -03:00
2022-04-23 17:11:38 -03:00
- timeout (int):Time in seconds for expect to wait for prompt/EOF.
2023-03-21 18:23:29 -03:00
default 10.
2022-04-23 17:11:38 -03:00
2022-04-03 09:38:00 -03:00
### Returns:
2022-04-03 10:26:08 -03:00
bool: true if expected value is found after running the commands
false if prompt is found before.
2022-04-02 23:25:53 -03:00
'''
2026-04-30 14:18:41 -03:00
now = datetime . datetime . now ( ) . strftime ( " % Y- % m- %d _ % H- % M- % S " )
2026-04-17 18:42:08 -03:00
connect = self . _connect ( timeout = timeout , logger = logger )
2022-03-28 10:20:00 -03:00
if connect == True :
2026-04-17 18:42:08 -03:00
if logger :
2026-04-24 19:23:00 -03:00
port_str = f " : { self . port } " if self . port and self . protocol not in [ " ssm " , " kubectl " , " docker " ] else " "
logger ( " success " , f " Connected to { self . unique } at { self . host } { port_str } via: { self . protocol } " )
2026-04-17 18:42:08 -03:00
2024-06-17 15:58:28 -03:00
# Attempt to set the terminal size
try :
self . child . setwinsize ( 65535 , 65535 )
except Exception :
try :
self . child . setwinsize ( 10000 , 10000 )
except Exception :
pass
2023-05-05 13:41:32 -03:00
if " prompt " in self . tags :
prompt = self . tags [ " prompt " ]
2022-04-23 17:11:38 -03:00
expects = [ prompt , pexpect . EOF , pexpect . TIMEOUT ]
2022-03-28 10:20:00 -03:00
output = ' '
2022-04-23 17:11:38 -03:00
if not isinstance ( commands , list ) :
commands = [ commands ]
2023-07-11 19:33:21 -03:00
if not isinstance ( expected , list ) :
expected = [ expected ]
2023-05-05 13:41:32 -03:00
if " screen_length_command " in self . tags :
commands . insert ( 0 , self . tags [ " screen_length_command " ] )
2022-05-25 17:25:02 -03:00
self . mylog = io . BytesIO ( )
self . child . logfile_read = self . mylog
2022-04-23 17:11:38 -03:00
for c in commands :
if vars is not None :
2026-04-30 14:18:41 -03:00
try :
c = c . format ( * * vars )
except KeyError as e :
self . output = f " Error: Variable { e } not defined in task or inventory "
self . status = 1
return self . output
2022-04-23 17:11:38 -03:00
result = self . child . expect ( expects , timeout = timeout )
self . child . sendline ( c )
if result == 2 :
2022-05-25 17:25:02 -03:00
break
2023-05-05 13:41:32 -03:00
if not result == 2 :
2022-05-25 17:25:02 -03:00
result = self . child . expect ( expects , timeout = timeout )
2022-04-23 17:11:38 -03:00
self . child . close ( )
2022-05-25 17:25:02 -03:00
output = self . _logclean ( self . mylog . getvalue ( ) . decode ( ) , True )
2026-04-30 14:18:41 -03:00
if logger :
logger ( " output " , output )
if folder != ' ' :
with open ( folder + " / " + self . unique + " _ " + now + " .txt " , " w " ) as f :
f . write ( output )
f . close ( )
2022-05-25 17:25:02 -03:00
self . output = output
2023-05-05 13:41:32 -03:00
if result in [ 0 , 1 ] :
2023-07-11 19:33:21 -03:00
# lastcommand = commands[-1]
# if vars is not None:
# lastcommand = lastcommand.format(**vars)
# last_command_index = output.rfind(lastcommand)
# cleaned_output = output[last_command_index + len(lastcommand):].strip()
self . result = { }
for e in expected :
if vars is not None :
e = e . format ( * * vars )
updatedprompt = re . sub ( r ' (?<! \\ ) \ $ ' , ' ' , prompt )
newpattern = f " .*( { updatedprompt } ).* { e } .* "
cleaned_output = output
cleaned_output = re . sub ( newpattern , ' ' , cleaned_output )
if e in cleaned_output :
self . result [ e ] = True
else :
self . result [ e ] = False
2022-04-23 17:11:38 -03:00
self . status = 0
2023-07-11 19:33:21 -03:00
return self . result
2023-05-05 13:41:32 -03:00
if result == 2 :
2022-04-23 17:11:38 -03:00
self . result = None
self . status = 2
return output
2022-03-28 10:20:00 -03:00
else :
2022-03-30 19:51:54 -03:00
self . result = None
2022-03-30 17:36:27 -03:00
self . output = connect
2022-04-23 17:11:38 -03:00
self . status = 1
2022-03-28 10:20:00 -03:00
return connect
2024-04-22 18:17:11 -03:00
@MethodHook
2024-06-17 15:58:28 -03:00
def _generate_ssh_sftp_cmd ( self ) :
cmd = self . protocol
if self . port :
if self . protocol == " ssh " :
cmd + = " -p " + self . port
elif self . protocol == " sftp " :
cmd + = " -P " + self . port
if self . options :
2026-05-05 18:24:31 -03:00
opts = self . options
if self . protocol == " sftp " :
# Strip SSH-only flags that sftp doesn't support
opts = re . sub ( r ' (?<! \ S)-[XxtTAaNf] \ b ' , ' ' , opts ) . strip ( )
if opts :
cmd + = " " + opts
2024-06-17 15:58:28 -03:00
if self . jumphost :
cmd + = " " + self . jumphost
user_host = f " { self . user } @ { self . host } " if self . user else self . host
cmd + = f " { user_host } "
return cmd
@MethodHook
def _generate_telnet_cmd ( self ) :
cmd = f " telnet { self . host } "
if self . port :
cmd + = f " { self . port } "
if self . options :
cmd + = f " { self . options } "
return cmd
@MethodHook
def _generate_kube_cmd ( self ) :
cmd = f " kubectl exec { self . options } { self . host } -it -- "
kube_command = self . tags . get ( " kube_command " , " /bin/bash " ) if isinstance ( self . tags , dict ) else " /bin/bash "
cmd + = f " { kube_command } "
return cmd
@MethodHook
def _generate_docker_cmd ( self ) :
cmd = f " docker { self . options } exec -it { self . host } "
docker_command = self . tags . get ( " docker_command " , " /bin/bash " ) if isinstance ( self . tags , dict ) else " /bin/bash "
cmd + = f " { docker_command } "
return cmd
2026-04-24 19:23:00 -03:00
@MethodHook
def _generate_ssm_cmd ( self ) :
region = self . tags . get ( " region " , " " ) if isinstance ( self . tags , dict ) else " "
profile = self . tags . get ( " profile " , " " ) if isinstance ( self . tags , dict ) else " "
cmd = f " aws ssm start-session --target { self . host } "
if region :
cmd + = f " --region { region } "
if profile :
cmd + = f " --profile { profile } "
if self . options :
cmd + = f " { self . options } "
return cmd
2026-05-12 12:20:50 -03:00
@MethodHook
def _generate_ssm_cmd ( self ) :
region = self . tags . get ( " region " , " " ) if isinstance ( self . tags , dict ) else " "
profile = self . tags . get ( " profile " , " " ) if isinstance ( self . tags , dict ) else " "
cmd = f " aws ssm start-session --target { self . host } "
if region :
cmd + = f " --region { region } "
if profile :
cmd + = f " --profile { profile } "
if self . options :
cmd + = f " { self . options } "
return cmd
2024-06-17 15:58:28 -03:00
@MethodHook
def _get_cmd ( self ) :
2023-11-03 11:59:00 -03:00
if self . protocol in [ " ssh " , " sftp " ] :
2024-06-17 15:58:28 -03:00
return self . _generate_ssh_sftp_cmd ( )
2022-03-17 19:05:23 -03:00
elif self . protocol == " telnet " :
2024-06-17 15:58:28 -03:00
return self . _generate_telnet_cmd ( )
elif self . protocol == " kubectl " :
return self . _generate_kube_cmd ( )
elif self . protocol == " docker " :
return self . _generate_docker_cmd ( )
2026-04-24 19:23:00 -03:00
elif self . protocol == " ssm " :
return self . _generate_ssm_cmd ( )
2022-03-17 19:05:23 -03:00
else :
2026-04-17 18:42:08 -03:00
printer . error ( f " Invalid protocol: { self . protocol } " )
sys . exit ( 1 )
2024-06-17 15:58:28 -03:00
@MethodHook
2026-04-17 18:42:08 -03:00
def _connect ( self , debug = False , timeout = 10 , max_attempts = 3 , logger = None ) :
2024-06-17 15:58:28 -03:00
cmd = self . _get_cmd ( )
2026-04-17 18:42:08 -03:00
passwords = self . _passtx ( self . password ) if self . password and any ( self . password ) else [ ]
2024-06-17 15:58:28 -03:00
if self . logs != ' ' :
self . logfile = self . _logfile ( )
default_prompt = r ' >$|#$| \ $$|>.$|#.$| \ $.$ '
prompt = self . tags . get ( " prompt " , default_prompt ) if isinstance ( self . tags , dict ) else default_prompt
password_prompt = ' [p|P]assword:|[u|U]sername: ' if self . protocol != ' telnet ' else ' [p|P]assword: '
expects = {
" ssh " : [ ' yes/no ' , ' refused ' , ' supported ' , ' Invalid|[u|U]sage: ssh ' , ' ssh-keygen.* \" ' , ' timeout|timed.out ' , ' unavailable ' , ' closed ' , password_prompt , prompt , ' suspend ' , pexpect . EOF , pexpect . TIMEOUT , " No route to host " , " resolve hostname " , " no matching " , " [b|B]ad (owner|permissions) " ] ,
" sftp " : [ ' yes/no ' , ' refused ' , ' supported ' , ' Invalid|[u|U]sage: sftp ' , ' ssh-keygen.* \" ' , ' timeout|timed.out ' , ' unavailable ' , ' closed ' , password_prompt , prompt , ' suspend ' , pexpect . EOF , pexpect . TIMEOUT , " No route to host " , " resolve hostname " , " no matching " , " [b|B]ad (owner|permissions) " ] ,
" telnet " : [ ' [u|U]sername: ' , ' refused ' , ' supported ' , ' invalid|unrecognized option ' , ' ssh-keygen.* \" ' , ' timeout|timed.out ' , ' unavailable ' , ' closed ' , password_prompt , prompt , ' suspend ' , pexpect . EOF , pexpect . TIMEOUT , " No route to host " , " resolve hostname " , " no matching " , " [b|B]ad (owner|permissions) " ] ,
" kubectl " : [ ' [u|U]sername: ' , ' [r|R]efused ' , ' [E|e]rror ' , ' DEPRECATED ' , pexpect . TIMEOUT , password_prompt , prompt , pexpect . EOF , " expired|invalid " ] ,
2026-04-24 19:23:00 -03:00
" docker " : [ ' [u|U]sername: ' , ' Cannot ' , ' [E|e]rror ' , ' failed ' , ' not a docker command ' , ' unknown ' , ' unable to resolve ' , pexpect . TIMEOUT , password_prompt , prompt , pexpect . EOF ] ,
2026-04-30 19:25:17 -03:00
" ssm " : [ ' [u|U]sername: ' , ' Cannot ' , ' [E|e]rror ' , ' failed ' , ' SessionManagerPlugin ' , ' [u|U]nknown ' , ' unable to resolve ' , pexpect . TIMEOUT , password_prompt , prompt , pexpect . EOF ]
2024-06-17 15:58:28 -03:00
}
error_indices = {
" ssh " : [ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 12 , 13 , 14 , 15 , 16 ] ,
" sftp " : [ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 12 , 13 , 14 , 15 , 16 ] ,
" telnet " : [ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 12 , 13 , 14 , 15 , 16 ] ,
" kubectl " : [ 1 , 2 , 3 , 4 , 8 ] , # Define error indices for kube
2026-04-24 19:23:00 -03:00
" docker " : [ 1 , 2 , 3 , 4 , 5 , 6 , 7 ] , # Define error indices for docker
" ssm " : [ 1 , 2 , 3 , 4 , 5 , 6 , 7 ]
2024-06-17 15:58:28 -03:00
}
eof_indices = {
" ssh " : [ 8 , 9 , 10 , 11 ] ,
" sftp " : [ 8 , 9 , 10 , 11 ] ,
" telnet " : [ 8 , 9 , 10 , 11 ] ,
" kubectl " : [ 5 , 6 , 7 ] , # Define eof indices for kube
2026-04-24 19:23:00 -03:00
" docker " : [ 8 , 9 , 10 ] , # Define eof indices for docker
" ssm " : [ 8 , 9 , 10 ]
2024-06-17 15:58:28 -03:00
}
initial_indices = {
" ssh " : [ 0 ] ,
" sftp " : [ 0 ] ,
" telnet " : [ 0 ] ,
" kubectl " : [ 0 ] , # Define special indices for kube
2026-04-24 19:23:00 -03:00
" docker " : [ 0 ] , # Define special indices for docker
" ssm " : [ 0 ]
2024-06-17 15:58:28 -03:00
}
2023-01-05 16:39:22 -03:00
attempts = 1
while attempts < = max_attempts :
child = pexpect . spawn ( cmd )
2025-05-09 17:44:29 -03:00
if isinstance ( self . tags , dict ) and self . tags . get ( " console " ) :
child . sendline ( )
2023-01-05 16:39:22 -03:00
if debug :
2026-04-17 18:42:08 -03:00
if logger :
logger ( " debug " , f " Command: \n { cmd } " )
2023-01-05 16:39:22 -03:00
self . mylog = io . BytesIO ( )
2026-05-05 18:24:31 -03:00
self . mylog . write ( f " [i] [DEBUG] Command: \r \n { cmd } \r \n " . encode ( ) )
2023-01-05 16:39:22 -03:00
child . logfile_read = self . mylog
2024-06-17 15:58:28 -03:00
2026-04-17 18:42:08 -03:00
2023-01-05 16:39:22 -03:00
endloop = False
2024-06-17 15:58:28 -03:00
for i in range ( len ( passwords ) if passwords else 1 ) :
2023-01-05 16:39:22 -03:00
while True :
2024-06-17 15:58:28 -03:00
results = child . expect ( expects [ self . protocol ] , timeout = timeout )
results_value = expects [ self . protocol ] [ results ]
if results in initial_indices [ self . protocol ] :
2023-11-03 11:59:00 -03:00
if self . protocol in [ " ssh " , " sftp " ] :
2023-01-05 16:39:22 -03:00
child . sendline ( ' yes ' )
2026-04-24 19:23:00 -03:00
elif self . protocol in [ " telnet " , " kubectl " , " docker " , " ssm " ] :
2024-06-17 15:58:28 -03:00
if self . user :
2023-01-05 16:39:22 -03:00
child . sendline ( self . user )
else :
self . missingtext = True
break
2024-06-17 15:58:28 -03:00
elif results in error_indices [ self . protocol ] :
2023-01-05 16:39:22 -03:00
child . terminate ( )
2024-06-17 15:58:28 -03:00
if results_value == pexpect . TIMEOUT and attempts != max_attempts :
2023-01-05 16:39:22 -03:00
attempts + = 1
endloop = True
break
else :
2024-07-05 17:49:53 -03:00
after = " Connection timeout " if results_value == pexpect . TIMEOUT else child . after . decode ( )
2024-06-17 15:58:28 -03:00
return f " Connection failed code: { results } \n { child . before . decode ( ) . lstrip ( ) } { after } { child . readline ( ) . decode ( ) } " . rstrip ( )
elif results in eof_indices [ self . protocol ] :
if results_value == password_prompt :
if passwords :
child . sendline ( passwords [ i ] )
2023-10-26 17:33:44 -03:00
else :
2024-06-17 15:58:28 -03:00
self . missingtext = True
break
elif results_value == " suspend " :
child . sendline ( " \r " )
sleep ( 2 )
2022-03-17 19:05:23 -03:00
else :
2024-06-17 15:58:28 -03:00
endloop = True
child . sendline ( )
break
2023-01-05 16:39:22 -03:00
if endloop :
2022-04-01 17:53:51 -03:00
break
2024-06-17 15:58:28 -03:00
if results_value == pexpect . TIMEOUT :
2023-01-05 16:39:22 -03:00
continue
else :
2022-03-17 19:05:23 -03:00
break
2024-06-17 15:58:28 -03:00
2025-05-09 17:44:29 -03:00
if isinstance ( self . tags , dict ) and self . tags . get ( " post_connect_commands " ) :
cmds = self . tags . get ( " post_connect_commands " )
commands = [ cmds ] if isinstance ( cmds , str ) else cmds
for command in commands :
child . sendline ( command )
sleep ( 1 )
2022-03-17 19:05:23 -03:00
child . readline ( 0 )
2022-03-18 15:32:48 -03:00
self . child = child
2025-08-04 11:34:22 -03:00
from pexpect import fdpexpect
self . raw_child = fdpexpect . fdspawn ( self . child . child_fd )
2022-03-18 15:32:48 -03:00
return True
2024-04-22 18:17:11 -03:00
@ClassHook
2022-03-30 19:51:54 -03:00
class nodes :
2022-04-02 23:25:53 -03:00
''' This class generates a nodes object. Contains a list of node class objects and methods to run multiple tasks on nodes simultaneously.
### Attributes:
- nodelist (list): List of node class objects passed to the init
function.
- output (dict): Dictionary formed by nodes unique as keys,
output of the commands you ran on the node as
value. Created after running methods run or test.
- result (dict): Dictionary formed by nodes unique as keys, value
is True if expected value is found after running
the commands, False if prompt is found before.
Created after running method test.
2022-04-23 17:11:38 -03:00
- status (dict): Dictionary formed by nodes unique as keys, value:
2025-08-04 11:34:22 -03:00
0 if method run or test ended successfully.
2022-04-23 17:11:38 -03:00
1 if connection failed.
2 if expect timeouts without prompt or EOF.
2022-04-02 23:25:53 -03:00
- <unique> (obj): For each item in nodelist, there is an attribute
generated with the node unique.
'''
2022-03-30 19:51:54 -03:00
def __init__ ( self , nodes : dict , config = ' ' ) :
2022-04-02 23:25:53 -03:00
'''
### Parameters:
- nodes (dict): Dictionary formed by node information:
Keys: Unique name for each node.
Mandatory Subkeys: host(str).
Optional Subkeys: options(str), logs(str), password(str),
port(str), protocol(str), user(str).
For reference on subkeys check node class.
2022-04-03 10:26:08 -03:00
### Optional Parameters:
2022-04-02 23:25:53 -03:00
- config (obj): Pass the object created with class configfile with key
for decryption and extra configuration if you are using
connection manager.
'''
2022-03-30 19:51:54 -03:00
self . nodelist = [ ]
self . config = config
for n in nodes :
2022-03-31 13:42:25 -03:00
this = node ( n , * * nodes [ n ] , config = config )
self . nodelist . append ( this )
setattr ( self , n , this )
2022-03-30 19:51:54 -03:00
2024-04-22 18:17:11 -03:00
@MethodHook
2022-04-02 23:25:53 -03:00
def _splitlist ( self , lst , n ) :
#split a list in lists of n members.
2022-03-30 19:51:54 -03:00
for i in range ( 0 , len ( lst ) , n ) :
yield lst [ i : i + n ]
2024-04-22 18:17:11 -03:00
@MethodHook
2026-04-17 18:42:08 -03:00
def run ( self , commands , vars = None , * , folder = None , prompt = None , stdout = None , parallel = 10 , timeout = None , on_complete = None , logger = None ) :
2022-04-02 23:25:53 -03:00
'''
Run a command or list of commands on all the nodes in nodelist.
2026-04-17 18:42:08 -03:00
2022-04-03 10:26:08 -03:00
### Parameters:
2022-04-23 17:11:38 -03:00
- commands (str/list): Commands to run on the nodes. Should be str or
list of str. You can use variables as {varname}
and defining them in optional parameter vars.
### Optional Parameters:
- vars (dict): Dictionary containing the definition of variables for
each node, used in commands parameter.
Keys should be formed by nodes unique names. Use
special key name __global__ for global variables.
Subkeys: Variable names.
Values: strings.
2022-04-03 10:26:08 -03:00
### Optional Named Parameters:
2022-04-23 17:11:38 -03:00
- folder (str): Path where output log should be stored, leave empty
to disable logging.
2022-04-03 10:26:08 -03:00
2022-04-23 17:11:38 -03:00
- prompt (str): Prompt to be expected after a command is finished
running. Usually linux uses " > " or EOF while routers
use " > " or " # " . The default value should work for
most nodes. Change it if your connection need some
special symbol.
2022-04-02 23:25:53 -03:00
2022-04-23 17:11:38 -03:00
- stdout (bool): Set True to send the command output to stdout.
Default False.
2022-04-02 23:25:53 -03:00
2022-04-23 17:11:38 -03:00
- parallel (int): Number of nodes to run the commands simultaneously.
Default is 10, if there are more nodes that this
value, nodes are groups in groups with max this
number of members.
- timeout (int): Time in seconds for expect to wait for prompt/EOF.
2023-03-21 18:23:29 -03:00
default 10.
2022-04-03 10:26:08 -03:00
2026-04-03 15:11:37 -03:00
- on_complete (callable): Optional callback called when each node
finishes. Receives (unique, output, status).
Called from the node ' s thread so it must
be thread-safe.
2022-04-03 10:26:08 -03:00
###Returns:
dict: Dictionary formed by nodes unique as keys, Output of the
commands you ran on the node as value.
2022-04-02 23:25:53 -03:00
'''
2022-03-30 19:51:54 -03:00
args = { }
2022-04-23 17:11:38 -03:00
nodesargs = { }
2022-03-30 19:51:54 -03:00
args [ " commands " ] = commands
if folder != None :
args [ " folder " ] = folder
2022-05-19 16:11:41 -03:00
Path ( folder ) . mkdir ( parents = True , exist_ok = True )
2022-03-30 19:51:54 -03:00
if prompt != None :
args [ " prompt " ] = prompt
2026-04-03 15:11:37 -03:00
if stdout != None and on_complete is None :
2022-03-30 19:51:54 -03:00
args [ " stdout " ] = stdout
2022-04-23 17:11:38 -03:00
if timeout != None :
args [ " timeout " ] = timeout
2022-03-30 19:51:54 -03:00
output = { }
2022-04-23 17:11:38 -03:00
status = { }
2022-03-30 19:51:54 -03:00
tasks = [ ]
2026-04-03 15:11:37 -03:00
def _run_node ( node_obj , node_args , callback ) :
""" Wrapper that runs a node and fires the callback on completion. """
node_obj . run ( * * node_args )
if callback :
callback ( node_obj . unique , node_obj . output , node_obj . status )
2022-03-30 19:51:54 -03:00
for n in self . nodelist :
2022-05-11 14:25:43 -03:00
nodesargs [ n . unique ] = deepcopy ( args )
2022-04-23 17:11:38 -03:00
if vars != None :
nodesargs [ n . unique ] [ " vars " ] = { }
if " __global__ " in vars . keys ( ) :
nodesargs [ n . unique ] [ " vars " ] . update ( vars [ " __global__ " ] )
2026-04-30 14:18:41 -03:00
for var_key , var_val in vars . items ( ) :
if var_key == " __global__ " :
continue
try :
if re . search ( var_key , n . unique , re . IGNORECASE ) :
nodesargs [ n . unique ] [ " vars " ] . update ( var_val )
except re . error :
if var_key == n . unique :
nodesargs [ n . unique ] [ " vars " ] . update ( var_val )
2026-04-17 18:42:08 -03:00
# Pass the logger to the node
nodesargs [ n . unique ] [ " logger " ] = logger
2026-04-03 15:11:37 -03:00
if on_complete :
tasks . append ( threading . Thread ( target = _run_node , args = ( n , nodesargs [ n . unique ] , on_complete ) ) )
else :
tasks . append ( threading . Thread ( target = n . run , kwargs = nodesargs [ n . unique ] ) )
2026-04-17 18:42:08 -03:00
2022-04-02 23:25:53 -03:00
taskslist = list ( self . _splitlist ( tasks , parallel ) )
2026-04-17 18:42:08 -03:00
2022-03-30 19:51:54 -03:00
for t in taskslist :
for i in t :
i . start ( )
for i in t :
i . join ( )
for i in self . nodelist :
2022-04-02 23:25:53 -03:00
output [ i . unique ] = i . output
2022-04-23 17:11:38 -03:00
status [ i . unique ] = i . status
2022-03-30 19:51:54 -03:00
self . output = output
2022-04-23 17:11:38 -03:00
self . status = status
2022-03-30 19:51:54 -03:00
return output
2024-04-22 18:17:11 -03:00
@MethodHook
2026-04-30 14:18:41 -03:00
def test ( self , commands , expected , vars = None , * , folder = None , prompt = None , parallel = 10 , timeout = None , on_complete = None , logger = None ) :
2022-04-02 23:25:53 -03:00
'''
Run a command or list of commands on all the nodes in nodelist, then check if expected value appears on the output after the last command.
2026-04-17 18:42:08 -03:00
2022-04-03 10:26:08 -03:00
### Parameters:
2022-04-23 17:11:38 -03:00
- commands (str/list): Commands to run on the node. Should be str or
list of str.
- expected (str) : Expected text to appear after running all the
commands on the node.
### Optional Parameters:
2022-04-03 10:26:08 -03:00
2022-04-23 17:11:38 -03:00
- vars (dict): Dictionary containing the definition of variables for
each node, used in commands and expected parameters.
Keys should be formed by nodes unique names. Use
special key name __global__ for global variables.
Subkeys: Variable names.
Values: strings.
2022-04-03 10:26:08 -03:00
### Optional Named Parameters:
2022-04-23 17:11:38 -03:00
- prompt (str): Prompt to be expected after a command is finished
running. Usually linux uses " > " or EOF while
routers use " > " or " # " . The default value should
work for most nodes. Change it if your connection
need some special symbol.
- parallel (int): Number of nodes to run the commands simultaneously.
Default is 10, if there are more nodes that this
value, nodes are groups in groups with max this
number of members.
- timeout (int): Time in seconds for expect to wait for prompt/EOF.
2023-03-21 18:23:29 -03:00
default 10.
2022-04-02 23:25:53 -03:00
2026-04-17 18:42:08 -03:00
- on_complete (callable): Optional callback called when each node
finishes. Receives (unique, output, status).
Called from the node ' s thread so it must
be thread-safe.
2022-04-03 10:26:08 -03:00
### Returns:
2022-04-02 23:25:53 -03:00
2022-04-03 10:26:08 -03:00
dict: Dictionary formed by nodes unique as keys, value is True if
expected value is found after running the commands, False
if prompt is found before.
2022-04-02 23:25:53 -03:00
'''
2022-03-30 19:51:54 -03:00
args = { }
2022-04-23 17:11:38 -03:00
nodesargs = { }
2022-03-30 19:51:54 -03:00
args [ " commands " ] = commands
args [ " expected " ] = expected
2026-04-30 14:18:41 -03:00
if folder != None :
args [ " folder " ] = folder
Path ( folder ) . mkdir ( parents = True , exist_ok = True )
2022-03-30 19:51:54 -03:00
if prompt != None :
args [ " prompt " ] = prompt
2022-04-23 17:11:38 -03:00
if timeout != None :
args [ " timeout " ] = timeout
2022-03-30 19:51:54 -03:00
output = { }
result = { }
2022-04-23 17:11:38 -03:00
status = { }
2022-03-30 19:51:54 -03:00
tasks = [ ]
2026-04-17 18:42:08 -03:00
def _test_node ( node_obj , node_args , callback ) :
""" Wrapper that runs a node test and fires the callback on completion. """
node_obj . test ( * * node_args )
if callback :
callback ( node_obj . unique , node_obj . output , node_obj . status , node_obj . result )
2022-03-30 19:51:54 -03:00
for n in self . nodelist :
2022-05-11 14:25:43 -03:00
nodesargs [ n . unique ] = deepcopy ( args )
2022-04-23 17:11:38 -03:00
if vars != None :
nodesargs [ n . unique ] [ " vars " ] = { }
if " __global__ " in vars . keys ( ) :
nodesargs [ n . unique ] [ " vars " ] . update ( vars [ " __global__ " ] )
2026-04-30 14:18:41 -03:00
for var_key , var_val in vars . items ( ) :
if var_key == " __global__ " :
continue
try :
if re . search ( var_key , n . unique , re . IGNORECASE ) :
nodesargs [ n . unique ] [ " vars " ] . update ( var_val )
except re . error :
if var_key == n . unique :
nodesargs [ n . unique ] [ " vars " ] . update ( var_val )
2026-04-17 18:42:08 -03:00
nodesargs [ n . unique ] [ " logger " ] = logger
if on_complete :
tasks . append ( threading . Thread ( target = _test_node , args = ( n , nodesargs [ n . unique ] , on_complete ) ) )
else :
tasks . append ( threading . Thread ( target = n . test , kwargs = nodesargs [ n . unique ] ) )
2022-04-02 23:25:53 -03:00
taskslist = list ( self . _splitlist ( tasks , parallel ) )
2022-03-30 19:51:54 -03:00
for t in taskslist :
for i in t :
i . start ( )
for i in t :
i . join ( )
for i in self . nodelist :
2022-04-02 23:25:53 -03:00
result [ i . unique ] = i . result
output [ i . unique ] = i . output
2022-04-23 17:11:38 -03:00
status [ i . unique ] = i . status
2022-03-30 19:51:54 -03:00
self . output = output
self . result = result
2022-04-23 17:11:38 -03:00
self . status = status
2022-03-30 19:51:54 -03:00
return result
2022-03-17 19:05:23 -03:00
# script