Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Remote CLI on Chassis to simplify connections between supervisor and linecard #2302

Draft
wants to merge 22 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added rcli/__init__.py
Empty file.
8 changes: 8 additions & 0 deletions rcli/get_all_bgp_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from click.testing import CliRunner
from rcli import rexec

runner = CliRunner()

result = runner.invoke(rexec.cli, ["all", "-c", "show ip bgp summary", "-p","password.txt"])

print(result.output.strip("\n"))
76 changes: 76 additions & 0 deletions rcli/interactive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# From https://github.com/paramiko/paramiko/blob/main/demos/interactive.py
#
#######################################################################
#
# Copyright (C) 2003-2007 Robey Pointer <robeypointer@gmail.com>
#
# This file is part of paramiko.
#
# Paramiko is free software; you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation; either version 2.1 of the License, or (at your option)
# any later version.
#
# Paramiko is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Paramiko; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

import select
import socket
import sys
import termios
import tty

from paramiko.py3compat import u
from paramiko import Channel


def interactive_shell(channel: Channel):
"""
Continuously wait for commands and execute them

The function is a loop that waits for input from either the channel or the terminal. If input is
received from the channel, it is printed to the terminal. If input is received from the terminal, it
is sent to the channel.

:param channel: The channel object we use to communicate with the linecard
:type channel: paramiko.Channel
"""
# Save the current tty so we can return to it later
oldtty = termios.tcgetattr(sys.stdin)
try:
tty.setraw(sys.stdin.fileno())
tty.setcbreak(sys.stdin.fileno())
channel.settimeout(0.0)

while True:
# Continuously wait for commands and execute them
r, w, e = select.select([channel, sys.stdin], [], [])
if channel in r:
try:
# Get output from channel
x = u(channel.recv(1024))
if len(x) == 0:
# logout message will be displayed
break
# Write channel output to terminal
sys.stdout.write(x)
sys.stdout.flush()
except socket.timeout:
pass
if sys.stdin in r:
# If we are able to send input, get the input from stdin
x = sys.stdin.read(1)
if len(x) == 0:
break
# Send the input to the channel
channel.send(x)

finally:
# Now that the channel has been exited, return to the previously-saved old tty
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, oldtty)
148 changes: 148 additions & 0 deletions rcli/linecard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import click
import os
import paramiko

from getpass import getpass
from .utils import get_linecard_ip, get_password
from . import interactive

EMPTY_OUTPUTS = ['', '\x1b[?2004l\r']

class Linecard:

def __init__(self, linecard_name, username, password=None, use_ssh_keys=False):
"""
Initialize Linecard object and store credentials, connection, and channel

:param linecard_name: The name of the linecard you want to connect to
:param username: The username to use to connect to the linecard
:param password: The linecard password. If password not provided, it
will prompt the user for it
:param use_ssh_keys: Whether or not to use SSH keys to authenticate.
"""
self.ip = get_linecard_ip(linecard_name)

if not self.ip:
click.echo("Linecard '{}' not found.\n".format(linecard_name))
self.connection = None
return None

self.linecard_name = linecard_name
self.username = username

if use_ssh_keys and os.environ.get("SSH_AUTH_SOCK"):
# The user wants to use SSH keys and the ssh agent is running
self.connection = paramiko.SSHClient()
# if ip address not in known_hosts, ignore known_hosts error
self.connection.load_system_host_keys()
self.connection.set_missing_host_key_policy(paramiko.AutoAddPolicy())

ssh_agent = paramiko.Agent()
available_keys = ssh_agent.get_keys()
if available_keys:
# Try to connect using all keys
connected = False
for key in available_keys:
try:
self.connection.connect(self.ip, username=username, pkey=key)
# If we connected successfully without error, break out of loop
connected = True
break
except paramiko.ssh_exception.AuthenticationException:
# key didn't work
continue
if not connected:
# None of the available keys worked, copy new keys over
password = password if password is not None else get_password(username)
self.ssh_copy_id(password)
else:
# host does not trust this client, perform ssh-copy-id
password = password if password is not None else get_password(username)
self.ssh_copy_id(password)

else:
password = password if password is not None else getpass(
"Password for username '{}': ".format(username),
# Pass in click stdout stream - this is similar to using click.echo
stream=click.get_text_stream('stdout')
)
self.connection = paramiko.SSHClient()
# if ip address not in known_hosts, ignore known_hosts error
self.connection.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
self.connection.connect(self.ip, username=self.username, password=password)
except paramiko.ssh_exception.NoValidConnectionsError as e:
self.connection = None
click.echo(e)

def ssh_copy_id(self, password:str) -> None:
"""
This function generates a new ssh key, copies it to the remote server,
and adds it to the ssh-agent for 15 minutes

:param password: The password for the user
:type password: str
"""
default_key_path = os.path.expanduser(os.path.join("~/",".ssh","id_rsa"))
# If ssh keys don't exist, create them
if not os.path.exists(default_key_path):
os.system(
'ssh-keygen -f {} -N "" > /dev/null'.format(default_key_path)
)

# Get contents of public keys
pub_key = open(default_key_path + ".pub", "rt")
pub_key_contents = pub_key.read()
pub_key.close()

# Connect to linecard using password
self.connection.connect(self.ip, username=self.username, password=password)

# Create ssh directory (if it doesn't exist) and add supervisor public
# key to authorized_keys
self.connection.exec_command('mkdir ~/.ssh -p \n')
self.connection.exec_command(
'echo \'{}\' >> ~/.ssh/authorized_keys \n'
.format(pub_key_contents)
)

# Add key to supervisor SSH Agent with 15 minute timeout
os.system('ssh-add -t 15m {}'.format(default_key_path))

# Now that keys are stored in SSH Agent, remove keys from disk
os.remove(default_key_path)
os.remove('{}.pub'.format(default_key_path))

def start_shell(self) -> None:
"""
Opens a session, gets a pseudo-terminal, invokes a shell, and then
attaches the host shell to the remote shell.
"""
# Create shell session
self.channel = self.connection.get_transport().open_session()
self.channel.get_pty()
self.channel.invoke_shell()
# Use Paramiko Interactive script to connect to the shell
interactive.interactive_shell(self.channel)
# After user exits interactive shell, close the connection
self.connection.close()


def execute_cmd(self, command) -> str:
"""
Takes a command as an argument, executes it on the remote shell, and returns the output

:param command: The command to execute on the remote shell
:return: The output of the command.
"""
# Execute the command and gather errors and output
_, stdout, stderr = self.connection.exec_command(command + "\n")
output = stdout.read().decode('utf-8')

if stderr:
# Error was present, add message to output
output += stderr.read().decode('utf-8')

# Close connection and return output
self.connection.close()
return output
18 changes: 18 additions & 0 deletions rcli/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import click


#
# 'rcli' group (root group)
#

# This is our entrypoint - the main "show" command
@click.command()
# @click.pass_context
def cli():
"""
SONiC command line - 'rcli' command.

Usage: rexec LINECARDS -c \"COMMAND\"
or rshell LINECARD
"""
print(cli.__doc__)
54 changes: 54 additions & 0 deletions rcli/rexec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import os
import click
import paramiko

from .linecard import Linecard
from .utils import get_all_linecards, get_password, get_password_from_file

@click.command()
@click.argument('linecard_names', nargs=-1, type=str, required=True, autocompletion=get_all_linecards)
@click.option('-c', '--command', type=str, required=True)
@click.option('-k','--use-ssh-keys/--no-keys', default=False)
@click.option('-p','--password-filename', type=str)
def cli(linecard_names, command, use_ssh_keys=False, password_filename=None):
"""
Executes a command on one or many linecards

:param linecard_names: A list of linecard names to execute the command on,
use `all` to execute on all linecards.
:param command: The command to execute on the linecard(s)
:param use_ssh_keys: If True, will attempt to use ssh keys to login to the
linecard. If False, will prompt for password, defaults to False (optional)
:param password_filename: A file containing the password for the linecard. If
not provided inline, user will be prompted for password. File should be
relative to current path.
"""
username = os.getlogin()

if list(linecard_names) == ["all"]:
# Get all linecard names using autocompletion helper
linecard_names = get_all_linecards(None, None, "")

if use_ssh_keys:
# If we want to use ssh keys, check if the user provided a password
password = None if not password_filename else get_password_from_file(password_filename)
elif password_filename:
# Don't use ssh keys and read password from file
password = get_password_from_file(password_filename)
else:
# Password filename was not provided, read password from user input
password = get_password(username)

# Iterate through each linecard, execute command, and gather output
for linecard_name in linecard_names:
try:
lc = Linecard(linecard_name, username, password, use_ssh_keys)
if lc.connection:
# If connection was created, connection exists. Otherwise, user will see an error message.
click.echo("======== {} output: ========".format(lc.linecard_name))
click.echo(lc.execute_cmd(command))
except paramiko.ssh_exception.AuthenticationException:
click.echo("Login failed on '{}' with username '{}'".format(linecard_name, username))

if __name__=="__main__":
cli(prog_name='rexec')
44 changes: 44 additions & 0 deletions rcli/rshell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import os
import click
import paramiko

from .linecard import Linecard
from .utils import get_all_linecards, get_password, get_password_from_file

@click.command()
@click.argument('linecard_name', type=str, autocompletion=get_all_linecards)
@click.option('-k','--use-ssh-keys/--no-keys', default=False)
@click.option('-p','--password-filename', type=str)
def cli(linecard_name, use_ssh_keys=False,password_filename=None):
"""
Open interactive shell for one linecard

:param linecard_name: The name of the linecard to connect to
:param use_ssh_keys: If True, will attempt to use ssh keys to login to the
linecard. If False, will prompt for password, defaults to False (optional)
:param password_filename: The password for the linecard, if not provided inline,
user will be prompted for password
"""
username = os.getlogin()

if use_ssh_keys:
# If we want to use ssh keys, check if the user provided a password
password = None if not password_filename else get_password_from_file(password_filename)
elif password_filename:
# Don't use ssh keys and read password from file
password = get_password_from_file(password_filename)
else:
# Password filename was not provided, read password from user input
password = get_password(username)

try:
lc = Linecard(linecard_name, username, password, use_ssh_keys)
if lc.connection:
# If connection was created, connection exists. Otherwise, user will see an error message.
lc.start_shell()
except paramiko.ssh_exception.AuthenticationException:
click.echo("Login failed on '{}' with username '{}'".format(linecard_name, username))


if __name__=="__main__":
cli(prog_name='rshell')
Loading