├── dev-requirements.txt ├── tests ├── integration │ ├── out │ │ └── .gitignore │ ├── id_rsa.pub │ ├── test_sshd_integration.py │ ├── id_rsa │ ├── login-logout-ubuntu-18.ipynb │ ├── parameterized-centos7.ipynb │ ├── parameterized-ubuntu-18.ipynb │ ├── login-logout-centos7.ipynb │ └── setup.py └── unit │ ├── test_magics.py │ ├── test_kernel.py │ └── test_ssh_wrapper_plumbum.py ├── sshkernel ├── version.py ├── __main__.py ├── images │ ├── logo-32x32.png │ └── logo-64x64.png ├── __init__.py ├── exception.py ├── ssh_wrapper.py ├── magics │ └── magics.py ├── kernel.py └── ssh_wrapper_plumbum.py ├── MANIFEST.in ├── misc ├── watch-test-ptw ├── cover-html ├── id_rsa.pub ├── sshd.sh └── id_rsa ├── .dockerignore ├── doc └── screenshot.png ├── examples ├── interrupt.png ├── parameterized-notebook.ipynb ├── parameterized-notebook-ja.ipynb ├── getting-started.ipynb ├── getting-started-ja.ipynb └── tutorial-ssh-agent.ipynb ├── .gitignore ├── requirements.txt ├── Makefile ├── Dockerfile ├── setup.py ├── LICENSE ├── .gitlab-ci.yml ├── tox.ini └── README.md /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | tox 2 | -------------------------------------------------------------------------------- /tests/integration/out/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | -------------------------------------------------------------------------------- /sshkernel/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.11.1" 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | include README.md 3 | -------------------------------------------------------------------------------- /misc/watch-test-ptw: -------------------------------------------------------------------------------- 1 | ptw sshkernel tests/unit -- --disable-pytest-warnings 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !MANIFEST.in 3 | !README.md 4 | !requirements.txt 5 | !sshkernel/ 6 | !setup.py 7 | -------------------------------------------------------------------------------- /doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NII-cloud-operation/sshkernel/master/doc/screenshot.png -------------------------------------------------------------------------------- /examples/interrupt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NII-cloud-operation/sshkernel/master/examples/interrupt.png -------------------------------------------------------------------------------- /misc/cover-html: -------------------------------------------------------------------------------- 1 | coverage run --source sshkernel -m unittest discover tests/unit 2 | coverage html --omit env 3 | -------------------------------------------------------------------------------- /sshkernel/__main__.py: -------------------------------------------------------------------------------- 1 | from .kernel import SSHKernel 2 | 3 | if __name__ == "__main__": 4 | SSHKernel.run_as_main() 5 | -------------------------------------------------------------------------------- /sshkernel/images/logo-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NII-cloud-operation/sshkernel/master/sshkernel/images/logo-32x32.png -------------------------------------------------------------------------------- /sshkernel/images/logo-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NII-cloud-operation/sshkernel/master/sshkernel/images/logo-64x64.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /htmlcov/ 3 | .mypy_cache/ 4 | .tox/ 5 | .vscode/ 6 | 7 | .coverage 8 | .ipynb_checkpoints/ 9 | *.egg-info 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ipykernel>=5.0.0 2 | ipython>=7.0.0 3 | jupyter_client>=5.2.0 4 | metakernel>=0.20.0 5 | nbconvert>=5.4.1 6 | paramiko>=2.4.2 7 | plumbum 8 | PyYAML 9 | -------------------------------------------------------------------------------- /sshkernel/__init__.py: -------------------------------------------------------------------------------- 1 | """A ssh kernel for Jupyter""" 2 | 3 | from .kernel import ExceptionWrapper 4 | from .kernel import SSHException 5 | from .version import __version__ 6 | -------------------------------------------------------------------------------- /sshkernel/exception.py: -------------------------------------------------------------------------------- 1 | class Error(Exception): 2 | """Base class for exceptions in this module.""" 3 | 4 | pass 5 | 6 | 7 | class SSHKernelNotConnectedException(Error): 8 | pass 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all checkdist clean cover lint sdist unit uploadtest upload 2 | 3 | all: ; 4 | 5 | checkdist: sdist 6 | twine check dist/* 7 | 8 | clean: 9 | rm -fr ./build ./dist 10 | 11 | lint: 12 | tox -e flake8 || true 13 | tox -e pylint || true 14 | 15 | upload: 16 | tox -e release 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jupyter/minimal-notebook:latest 2 | 3 | USER root 4 | 5 | RUN apt-get update \ 6 | && apt-get install -yq openssh-server 7 | 8 | USER jovyan 9 | 10 | ADD requirements.txt /tmp/ 11 | RUN pip install -r /tmp/requirements.txt 12 | 13 | ADD --chown=jovyan:users . /tmp/ssh 14 | 15 | RUN pip install -e /tmp/ssh 16 | 17 | RUN python -msshkernel install --sys-prefix 18 | -------------------------------------------------------------------------------- /misc/id_rsa.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCZJzUREekojn/XYYVFtoGP4/PFTkZOLQBMwUlpu48PJP0+MW808ohFkmWhn/ET2jWwoSZ5RS7kvOwFPsiPMc5qSU+4dZkepbj0PkmGd+visBsiPGgTZJkQjoMAYjIbyitRs2MgkmXI2N8xWMVt2f73ktSUIDaiw6iXyBGSSPPVFXBzBxD5Oqu3PHPRSVkyZJXT9T9QGqFJoYNHBdtLw0lB8w+Jt+osz7wyWe4zEd1gxNRNz9mhOqGYKMmHxkh+VjDNM6trXeURLIwkNRzXHqyS9lj2TEGOflkkNADPvCFLSiQOTypSyNwU2YtJ0Fo0OIEWIAWh4hQVKYhZ4tWyyZuh masaru@masaru-t460s 2 | -------------------------------------------------------------------------------- /tests/integration/id_rsa.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC6Q68lLRYvKzVhp8bUXko4vf+yOlAbT4scuCac0OaRaqV2DLtCV2q1I8w2aVBjnFlXLVl639/GfwmWVp0tSQP3fU0nyUROKXqFYk/tF28HWOf3N4Iez6oICZYQLxiDs9zeguNkJgjDESlMLYC8xV3jifWF8IvA66kRLrpADzhlrQr6q6UZr/408lfPcHQr7AGgdzsT0Hz+7j2EjSjp+OEFqSyIlOxboErThxa4ZYNK0Dekzb/7H5nc/50DzLyav7ZifQgMinzbYORsUnj7YgVPmlhVMsmLBXPRRBW7Ysz5q4tlM89H4bABxSG/PStN8wFQ5Ayz/0NpCk3AZHs1Ouvv test@sshkerneldev 2 | -------------------------------------------------------------------------------- /misc/sshd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -xe 2 | 3 | # 4 | # Launch sshd at admin@localhost:2222 5 | # 6 | 7 | docker kill sshd || true 8 | docker rm sshd || true 9 | ssh-keygen -R [localhost]:2222 10 | 11 | docker run \ 12 | -d \ 13 | -p 2222:22 \ 14 | --name sshd \ 15 | -e SSH_USERS=admin:1000:1000 \ 16 | -v $PWD/id_rsa.pub:/etc/authorized_keys/admin \ 17 | -v $PWD/id_rsa.pub:/root/.ssh/authorized_keys \ 18 | docker.io/panubo/sshd 19 | 20 | echo done 21 | -------------------------------------------------------------------------------- /sshkernel/ssh_wrapper.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class SSHWrapper(ABC): 5 | @abstractmethod 6 | def __init__(self, envdelta): 7 | """ 8 | Args: 9 | envdelta (dict) envdelta is injected into remote environment variables 10 | """ 11 | 12 | @abstractmethod 13 | def exec_command(self, cmd, print_function): 14 | """ 15 | Args: 16 | cmd (string) 17 | print_function (lambda) 18 | 19 | Returns: 20 | int: exit code 21 | """ 22 | 23 | @abstractmethod 24 | def connect(self, host): 25 | """ 26 | Connect to host 27 | 28 | Raises: 29 | SSHConnectionError 30 | """ 31 | 32 | @abstractmethod 33 | def close(self): 34 | """ 35 | Close connection to host 36 | """ 37 | 38 | @abstractmethod 39 | def interrupt(self): 40 | """ 41 | Send SIGINT to halt current execution 42 | """ 43 | 44 | @abstractmethod 45 | def isconnected(self): 46 | """ 47 | Connected to host or not 48 | 49 | Returns: 50 | bool 51 | """ 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | with open("README.md", "r") as fh: 5 | long_description = fh.read() 6 | 7 | 8 | version = {} 9 | with open("sshkernel/version.py") as f: 10 | exec(f.read(), version) 11 | # later on we use: version['__version__'] 12 | 13 | 14 | def _requirements(): 15 | return [name for name in open("requirements.txt").readlines()] 16 | 17 | 18 | setup( 19 | name="sshkernel", 20 | version=version["__version__"], 21 | author="UENO, Masaru", 22 | author_email="ueno.masaru@fujitsu.com", 23 | description="SSH Kernel", 24 | extras_require={"dev": ["pytest>=3", "pytest-watch"]}, 25 | include_package_data=True, 26 | zip_safe=False, 27 | platforms="any", 28 | install_requires=_requirements(), 29 | license='BSD 3-clause "New" or "Revised License"', 30 | long_description=long_description, 31 | long_description_content_type="text/markdown", 32 | url="https://github.com/nii-cloud-operation/sshkernel", 33 | packages=["sshkernel", "sshkernel.magics"], 34 | classifiers=[ 35 | "Framework :: IPython", 36 | "Framework :: Jupyter", 37 | "License :: OSI Approved :: BSD License", 38 | "Operating System :: OS Independent", 39 | "Programming Language :: Python :: 3", 40 | "Topic :: System :: Shells", 41 | ], 42 | ) 43 | -------------------------------------------------------------------------------- /tests/integration/test_sshd_integration.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | import unittest 4 | 5 | import papermill as pm 6 | 7 | from sshkernel.kernel import SSHKernel 8 | 9 | 10 | class SSHDIntegrationTests(unittest.TestCase): 11 | def test_proxycommand(self): 12 | """ 13 | # Expect the config below 14 | Host localhost 15 | User root 16 | Port 22 17 | IdentityFile {keyfile} 18 | ProxyCommand ssh -W %h:%p {bastion} 19 | """ 20 | kernel = SSHKernel() 21 | kernel.do_login("localhost") 22 | kernel.assert_connected() 23 | 24 | def list_notebooks(self): 25 | here = os.path.dirname(__file__) 26 | nbs = glob.glob("{}/*.ipynb".format(here)) 27 | 28 | return nbs 29 | 30 | def test_no_raise(self): 31 | 32 | nbs = self.list_notebooks() 33 | 34 | here = os.path.dirname(__file__) 35 | out_dir = "{}/out".format(here) 36 | if not os.path.exists(out_dir): 37 | os.mkdir(out_dir) 38 | 39 | for nb_input in nbs: 40 | basename = os.path.basename(nb_input) 41 | 42 | nb_output = "{}/{}".format(out_dir, basename) 43 | 44 | try: 45 | pm.execute_notebook(nb_input, nb_output) 46 | except Exception as e: 47 | with open(nb_output) as f: 48 | print(f.read()) 49 | 50 | raise e 51 | 52 | self.assertEqual(1, 1) 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, FUJITSU LABORATORIES LTD. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of National Institute of Informatics nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /tests/integration/id_rsa: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEogIBAAKCAQEAukOvJS0WLys1YafG1F5KOL3/sjpQG0+LHLgmnNDmkWqldgy7 3 | QldqtSPMNmlQY5xZVy1Zet/fxn8JlladLUkD931NJ8lETil6hWJP7RdvB1jn9zeC 4 | Hs+qCAmWEC8Yg7Pc3oLjZCYIwxEpTC2AvMVd44n1hfCLwOupES66QA84Za0K+qul 5 | Ga/+NPJXz3B0K+wBoHc7E9B8/u49hI0o6fjhBaksiJTsW6BK04cWuGWDStA3pM2/ 6 | +x+Z3P+dA8y8mr+2Yn0IDIp822DkbFJ4+2IFT5pYVTLJiwVz0UQVu2LM+auLZTPP 7 | R+GwAcUhvz0rTfMBUOQMs/9DaQpNwGR7NTrr7wIDAQABAoIBAB8/D3ibEaekBfZ1 8 | 4MLrdmQCa4yIf7u1Ik8VDVUtLiCi1VLyW8+LPplYgf92t0eeiNY5A1O7jpyL3x3b 9 | Nc7M+t9fo7vS5aR/DNCnZ2UMk1GWNoOgSjHFAG8MvKHGZMRjPeAN8Ptx7fJbRKRE 10 | 1d3U9iUflZJ8gdVBM0Fwp0nrw7g6f+CRZPTc1yBLlLCDmwAFQX6atuW1vqhdKxqv 11 | +nM/vvhsvVRabBqiczsWb5nCdD+Vygcj+g31Q/ViH1Tdpay2FaVLnMNMdt3nyz5R 12 | PUXpiCJKZW5l5bvbLyKhQmt9opXgrWU9A4o3AcqsC7anpkz4dRh6pnIsT3t1zYw/ 13 | xteyKCECgYEA3jS05bOqWBjsBvd2v5nnsOkxJUmWDaQWc8qiRh73kvmAgkMt11kT 14 | mHcjptNZqtRoyTwecLU+0cADRIuF0zzR5KarB130EOV9SH69+9DROGHzLu1DnuOk 15 | o8JgdMCj+//fNvbpM0DWskIX9NAO3arVcAIdRohzKVO/59njfwzSvEcCgYEA1pek 16 | j6hMEHNr9QDzbfpIdbJJK58ujQSZBdpCUcoRsVUkYuYryWaeEh8P91F2QoDhsFTk 17 | X2NToJsk8FB7khW0BDsdoeOabEcjzbWvCzol61feZrioPHMas1QJpxv+5WKZibW4 18 | uz95XS4NnWL4icrSojDuMrUlg6ol0QMdNG1+rxkCgYAiArE5g1FsYIwn2NKtKvJi 19 | ip7CFUxJVDc3i+lgkDbWoSfBSGUI1BKCwKGNVEYiNpa/TlteZu9xjVEyuaRHG8UI 20 | mVYOL5w+xdFyOiv8Eg8j8SIWqawy8qbthb/bvuyakqRotkwlUyRJboJaL0pHNXGr 21 | zjcK57Gwvqfd5eTDOoQ62wKBgFFKHIJ25GmO76Rd+dj9aJl9Mg6ePEobJcR7y4ek 22 | HlOoxyHXw1qFzvdaYPf1GfFTVSsw3VntDG7YloOaWVUbd5fqtOzwhTzjLgLtAiQk 23 | oNyn6o3LAZ/0knbALO/qwQIv+a2a8yGrh9PucCXgqfm4pVXfZzw6Nr3LpIuqvFNx 24 | 7v1xAoGABRLQczehqiHk0e6iPle9w/LYcwROOooXdoHEPYpwAx0cR4vRGVS7f5Rt 25 | TM/L1xIdWg2uyS9LQcsLpVRgUyX7iQcNWRUwg9mex6NWfDsHDrc66DQmMjm2T4er 26 | yj3hJ77ognY817ndJMJOKQsli+9iFA1YzjcvSdJUslU60P0sqlA= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /tests/integration/login-logout-ubuntu-18.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# success note" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": { 14 | "ExecuteTime": { 15 | "end_time": "2018-12-13T11:38:08.836695Z", 16 | "start_time": "2018-12-13T11:38:08.619631Z" 17 | } 18 | }, 19 | "outputs": [], 20 | "source": [ 21 | "%login ubuntu18" 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": null, 27 | "metadata": { 28 | "ExecuteTime": { 29 | "end_time": "2018-12-13T11:38:32.762303Z", 30 | "start_time": "2018-12-13T11:38:32.668820Z" 31 | } 32 | }, 33 | "outputs": [], 34 | "source": [ 35 | "id" 36 | ] 37 | }, 38 | { 39 | "cell_type": "code", 40 | "execution_count": null, 41 | "metadata": { 42 | "ExecuteTime": { 43 | "end_time": "2018-12-13T11:38:36.369487Z", 44 | "start_time": "2018-12-13T11:38:36.310821Z" 45 | } 46 | }, 47 | "outputs": [], 48 | "source": [ 49 | "%logout" 50 | ] 51 | } 52 | ], 53 | "metadata": { 54 | "kernelspec": { 55 | "display_name": "SSH", 56 | "language": "bash", 57 | "name": "ssh" 58 | }, 59 | "language_info": { 60 | "codemirror_mode": "shell", 61 | "file_extension": ".sh", 62 | "mimetype": "text/x-sh", 63 | "name": "ssh" 64 | }, 65 | "toc": { 66 | "base_numbering": 1, 67 | "nav_menu": {}, 68 | "number_sections": true, 69 | "sideBar": true, 70 | "skip_h1_title": false, 71 | "title_cell": "Table of Contents", 72 | "title_sidebar": "Contents", 73 | "toc_cell": false, 74 | "toc_position": {}, 75 | "toc_section_display": true, 76 | "toc_window_display": false 77 | } 78 | }, 79 | "nbformat": 4, 80 | "nbformat_minor": 2 81 | } 82 | -------------------------------------------------------------------------------- /misc/id_rsa: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn 3 | NhAAAAAwEAAQAAAQEAmSc1ERHpKI5/12GFRbaBj+PzxU5GTi0ATMFJabuPDyT9PjFvNPKI 4 | RZJloZ/xE9o1sKEmeUUu5LzsBT7IjzHOaklPuHWZHqW49D5Jhnfr4rAbIjxoE2SZEI6DAG 5 | IyG8orUbNjIJJlyNjfMVjFbdn+95LUlCA2osOol8gRkkjz1RVwcwcQ+Tqrtzxz0UlZMmSV 6 | 0/U/UBqhSaGDRwXbS8NJQfMPibfqLM+8MlnuMxHdYMTUTc/ZoTqhmCjJh8ZIflYwzTOra1 7 | 3lESyMJDUc1x6skvZY9kxBjn5ZJDQAz7whS0okDk8qUsjcFNmLSdBaNDiBFiAFoeIUFSmI 8 | WeLVssmboQAAA8gY+9IwGPvSMAAAAAdzc2gtcnNhAAABAQCZJzUREekojn/XYYVFtoGP4/ 9 | PFTkZOLQBMwUlpu48PJP0+MW808ohFkmWhn/ET2jWwoSZ5RS7kvOwFPsiPMc5qSU+4dZke 10 | pbj0PkmGd+visBsiPGgTZJkQjoMAYjIbyitRs2MgkmXI2N8xWMVt2f73ktSUIDaiw6iXyB 11 | GSSPPVFXBzBxD5Oqu3PHPRSVkyZJXT9T9QGqFJoYNHBdtLw0lB8w+Jt+osz7wyWe4zEd1g 12 | xNRNz9mhOqGYKMmHxkh+VjDNM6trXeURLIwkNRzXHqyS9lj2TEGOflkkNADPvCFLSiQOTy 13 | pSyNwU2YtJ0Fo0OIEWIAWh4hQVKYhZ4tWyyZuhAAAAAwEAAQAAAQA8AvD4T1xdV/kgHEZs 14 | mqcKCvhqR9GksF19tf9ePvA/ru7Mf/JjfOWI3WSVgGamsMp4+6xnuIQ3fQ++vms4MPwBCd 15 | kDHpFxQN7IVd/ZoQP9P6RiNelSMAkKQ81xSQj5eq6exPwCt1rK6LAMVgyGjvUpbY9+u2Ct 16 | Rp25W9wGxi9FPxYrAzdm1i5Ma8taEmYHdgNpxOJDiDSm5seV/BvjFty80uZp+z+xCiEx+o 17 | LRuqXRJLV2WRmYMzNg1XtTz6o0M+QlefkEjqqyo4zIoZoP9QUodLkcEoO94BI7h5vMD5tp 18 | V27cMKC0hZXx4ZtX1lKPLb+547tuqYcefVzYA5rRTscBAAAAgAlwLX8KYC9yuLRk10Pbfj 19 | MiKfhPI7sYMi8l4EsLWurGBFbW+VcsUdnr1d7efDfrNWahFTbSjd6UKMAg0sooS7ydjo5u 20 | jedeh07D37hAfCXCfqjMgqIgFQUfa3Y+oldyl7M4DX7k/+FMhkmt2dCHkUabwmBknQ/Uhy 21 | TDavxsBccGAAAAgQDKK82ICCcaO2zqMA9bpwBn58Z8MzwKfFvqNl2op/gk5mhT4xPvCP9j 22 | m0GY3eGesqFBmHRMJGkGcHCAxWbDUlpHa+y1j8kiOLPVEo91f1fkNwWtZc4uelXcgDnSxq 23 | GM4/XGlFHi1fWtEU8aCnN3Nk2V+v3/ImENjE+wm1UQrCRSrQAAAIEAwe5J8IR638Xg2IZL 24 | Dlp2u4/odiYs+3TLRnm7UcAoSuuTUFZlCn0yQfrTilMj41PqZ9oOrGr8jlrU344bRfh76H 25 | 01biL2EIKEhiPYqtYcYk2lXq0/EsF/ktroPhvPsvBQIudAx4QZa2P2xYeZtm+jodGRdsNm 26 | +Bl5ZA1X1HLZ/0UAAAATbWFzYXJ1QG1hc2FydS10NDYwcw== 27 | -----END OPENSSH PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /tests/integration/parameterized-centos7.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "%param TARGET_HOST centos7" 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": null, 15 | "metadata": { 16 | "ExecuteTime": { 17 | "end_time": "2018-12-13T11:38:08.836695Z", 18 | "start_time": "2018-12-13T11:38:08.619631Z" 19 | } 20 | }, 21 | "outputs": [], 22 | "source": [ 23 | "%login {TARGET_HOST}" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": null, 29 | "metadata": { 30 | "ExecuteTime": { 31 | "end_time": "2018-12-13T11:38:32.762303Z", 32 | "start_time": "2018-12-13T11:38:32.668820Z" 33 | } 34 | }, 35 | "outputs": [], 36 | "source": [ 37 | "id" 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": null, 43 | "metadata": { 44 | "ExecuteTime": { 45 | "end_time": "2018-12-13T11:38:36.369487Z", 46 | "start_time": "2018-12-13T11:38:36.310821Z" 47 | } 48 | }, 49 | "outputs": [], 50 | "source": [ 51 | "%logout" 52 | ] 53 | } 54 | ], 55 | "metadata": { 56 | "kernelspec": { 57 | "display_name": "SSH", 58 | "language": "bash", 59 | "name": "ssh" 60 | }, 61 | "language_info": { 62 | "codemirror_mode": "shell", 63 | "file_extension": ".sh", 64 | "mimetype": "text/x-sh", 65 | "name": "ssh" 66 | }, 67 | "toc": { 68 | "base_numbering": 1, 69 | "nav_menu": {}, 70 | "number_sections": true, 71 | "sideBar": true, 72 | "skip_h1_title": false, 73 | "title_cell": "Table of Contents", 74 | "title_sidebar": "Contents", 75 | "toc_cell": false, 76 | "toc_position": {}, 77 | "toc_section_display": true, 78 | "toc_window_display": false 79 | } 80 | }, 81 | "nbformat": 4, 82 | "nbformat_minor": 2 83 | } 84 | -------------------------------------------------------------------------------- /tests/integration/parameterized-ubuntu-18.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "%param TARGET_HOST ubuntu18" 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": null, 15 | "metadata": { 16 | "ExecuteTime": { 17 | "end_time": "2018-12-13T11:38:08.836695Z", 18 | "start_time": "2018-12-13T11:38:08.619631Z" 19 | } 20 | }, 21 | "outputs": [], 22 | "source": [ 23 | "%login {TARGET_HOST}" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": null, 29 | "metadata": { 30 | "ExecuteTime": { 31 | "end_time": "2018-12-13T11:38:32.762303Z", 32 | "start_time": "2018-12-13T11:38:32.668820Z" 33 | } 34 | }, 35 | "outputs": [], 36 | "source": [ 37 | "id" 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": null, 43 | "metadata": { 44 | "ExecuteTime": { 45 | "end_time": "2018-12-13T11:38:36.369487Z", 46 | "start_time": "2018-12-13T11:38:36.310821Z" 47 | } 48 | }, 49 | "outputs": [], 50 | "source": [ 51 | "%logout" 52 | ] 53 | } 54 | ], 55 | "metadata": { 56 | "kernelspec": { 57 | "display_name": "SSH", 58 | "language": "bash", 59 | "name": "ssh" 60 | }, 61 | "language_info": { 62 | "codemirror_mode": "shell", 63 | "file_extension": ".sh", 64 | "mimetype": "text/x-sh", 65 | "name": "ssh" 66 | }, 67 | "toc": { 68 | "base_numbering": 1, 69 | "nav_menu": {}, 70 | "number_sections": true, 71 | "sideBar": true, 72 | "skip_h1_title": false, 73 | "title_cell": "Table of Contents", 74 | "title_sidebar": "Contents", 75 | "toc_cell": false, 76 | "toc_position": {}, 77 | "toc_section_display": true, 78 | "toc_window_display": false 79 | } 80 | }, 81 | "nbformat": 4, 82 | "nbformat_minor": 2 83 | } 84 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: python:3.7 2 | 3 | stages: 4 | - test 5 | - medium_test 6 | - release 7 | 8 | # Change pip's cache directory to be inside the project directory since we can 9 | # only cache local items. 10 | variables: 11 | PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" 12 | 13 | # Pip's cache doesn't store the python packages 14 | # https://pip.pypa.io/en/stable/reference/pip_install/#caching 15 | # 16 | # If you want to also cache the installed packages, you have to install 17 | # them in a virtualenv and cache it as well. 18 | cache: 19 | paths: 20 | - .cache/pip 21 | 22 | before_script: 23 | - curl -O https://bootstrap.pypa.io/get-pip.py 24 | - python get-pip.py 25 | - pip install -r dev-requirements.txt 26 | - python -V 27 | - which python 28 | - which pip 29 | 30 | run_setup_instruction: 31 | stage: test 32 | script: 33 | - pip install -U . 34 | - python -m sshkernel install 35 | - pip freeze | grep sshkernel 36 | - jupyter kernelspec list | grep ssh 37 | 38 | python36: 39 | image: python:3.6 40 | stage: test 41 | script: tox -e py36 42 | 43 | python37: 44 | stage: test 45 | script: tox -e py37 46 | artifacts: 47 | paths: 48 | - htmlcov 49 | expire_in: 1 month 50 | 51 | check_format: 52 | stage: test 53 | script: tox -e black -- -v --check --diff sshkernel setup.py 54 | allow_failure: true 55 | 56 | linting: 57 | stage: test 58 | script: tox -e linters || true 59 | 60 | run_papermill: 61 | stage: medium_test 62 | script: 63 | - pip install . 64 | - pip install papermill 65 | - python ./tests/integration/setup.py 66 | - python -m unittest discover tests/integration 67 | services: 68 | - alias: ubuntu18 69 | name: rastasheep/ubuntu-sshd:18.04 70 | - alias: centos7 71 | name: indigodatacloud/centos-sshd:7 72 | artifacts: 73 | paths: 74 | - ./tests/integration/out 75 | expire_in: 1 month 76 | 77 | 78 | release_pypi_package: 79 | stage: release 80 | script: tox -e release 81 | artifacts: 82 | paths: 83 | - dist 84 | only: 85 | - tags 86 | -------------------------------------------------------------------------------- /tests/unit/test_magics.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | 4 | from sshkernel.kernel import SSHKernel 5 | from sshkernel.magics.magics import SSHKernelMagics 6 | from sshkernel.magics.magics import expand_parameters 7 | from sshkernel.magics.magics import validate_value_string 8 | 9 | from paramiko import AuthenticationException 10 | 11 | 12 | class MagicTest(unittest.TestCase): 13 | def setUp(self): 14 | kernel = Mock(spec=SSHKernel) 15 | instance = SSHKernelMagics(kernel=kernel) 16 | self.kernel = kernel 17 | self.instance = instance 18 | 19 | def test_login_should_call_do_login(self): 20 | host = "dummy" 21 | self.instance.line_login(host) 22 | 23 | self.kernel.do_login.assert_called_once_with(host) 24 | self.assertIsNone(self.instance.retval) 25 | 26 | def test_logout_should_call_logout(self): 27 | self.instance.line_logout() 28 | 29 | self.kernel.do_logout.assert_called_once_with() 30 | 31 | def test_login_with_exception_set_retval(self): 32 | self.kernel.do_login = Mock(side_effect=AuthenticationException) 33 | 34 | self.instance.line_login("dummy") 35 | self.assertIsNotNone(self.instance.retval) 36 | 37 | def test_expand_parameters(self): 38 | params = dict(A="1", B="3") 39 | s = "{A}2{B}" 40 | 41 | ret = expand_parameters(s, params) 42 | self.assertEqual(ret, "123") 43 | 44 | def test_expand_parameters_raise(self): 45 | with self.assertRaises(KeyError): 46 | expand_parameters("{NOTFOUND}", {}) 47 | 48 | def test_expand_parameters_with_unclosed_string(self): 49 | ret = expand_parameters("{YO", {}) 50 | self.assertEqual(ret, "{YO") 51 | 52 | def test_validate_value_string(self): 53 | func = validate_value_string 54 | 55 | ok_cases = [ 56 | "abc 123%@-", 57 | ] 58 | ng_cases = [ 59 | "ABC ###", 60 | 'ABC"', 61 | "ABC()", 62 | "ABC${}", 63 | ] 64 | 65 | for ok in ok_cases: 66 | self.assertIsNone(func(ok)) 67 | 68 | for ng in ng_cases: 69 | with self.assertRaises(ValueError): 70 | func(ng) 71 | -------------------------------------------------------------------------------- /tests/integration/login-logout-centos7.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# CentoOS7" 8 | ] 9 | }, 10 | { 11 | "cell_type": "code", 12 | "execution_count": null, 13 | "metadata": { 14 | "ExecuteTime": { 15 | "end_time": "2018-12-13T11:38:08.836695Z", 16 | "start_time": "2018-12-13T11:38:08.619631Z" 17 | } 18 | }, 19 | "outputs": [], 20 | "source": [ 21 | "%login centos7" 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": null, 27 | "metadata": { 28 | "ExecuteTime": { 29 | "end_time": "2018-12-13T11:38:32.762303Z", 30 | "start_time": "2018-12-13T11:38:32.668820Z" 31 | } 32 | }, 33 | "outputs": [], 34 | "source": [ 35 | "id" 36 | ] 37 | }, 38 | { 39 | "cell_type": "code", 40 | "execution_count": null, 41 | "metadata": { 42 | "ExecuteTime": { 43 | "end_time": "2018-12-13T11:38:36.369487Z", 44 | "start_time": "2018-12-13T11:38:36.310821Z" 45 | } 46 | }, 47 | "outputs": [], 48 | "source": [ 49 | "%logout" 50 | ] 51 | }, 52 | { 53 | "cell_type": "code", 54 | "execution_count": null, 55 | "metadata": {}, 56 | "outputs": [], 57 | "source": [] 58 | }, 59 | { 60 | "cell_type": "code", 61 | "execution_count": null, 62 | "metadata": { 63 | "ExecuteTime": { 64 | "end_time": "2018-12-13T11:39:59.696178Z", 65 | "start_time": "2018-12-13T11:39:59.684726Z" 66 | } 67 | }, 68 | "outputs": [], 69 | "source": [ 70 | "%%python\n", 71 | "import sys\n", 72 | "for c in ('D', 'o', 'n', 'e'):\n", 73 | " sys.stdout.write(c)" 74 | ] 75 | } 76 | ], 77 | "metadata": { 78 | "kernelspec": { 79 | "display_name": "SSH", 80 | "language": "bash", 81 | "name": "ssh" 82 | }, 83 | "language_info": { 84 | "codemirror_mode": "shell", 85 | "file_extension": ".sh", 86 | "mimetype": "text/x-sh", 87 | "name": "ssh" 88 | }, 89 | "toc": { 90 | "base_numbering": 1, 91 | "nav_menu": {}, 92 | "number_sections": true, 93 | "sideBar": true, 94 | "skip_h1_title": false, 95 | "title_cell": "Table of Contents", 96 | "title_sidebar": "Contents", 97 | "toc_cell": false, 98 | "toc_position": {}, 99 | "toc_section_display": true, 100 | "toc_window_display": false 101 | } 102 | }, 103 | "nbformat": 4, 104 | "nbformat_minor": 2 105 | } 106 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | # default targets without -e nor TOX_ENV 3 | envlist= 4 | py{36,37} 5 | black 6 | flake8 7 | pylint 8 | 9 | [testenv] 10 | # testenv is created using several basepython and run tests with coverage 11 | extras = dev 12 | deps= 13 | mock>=2.0.0 14 | coverage 15 | setuptools>=41 16 | commands= 17 | coverage erase 18 | coverage run --source sshkernel -m unittest discover tests/unit 19 | coverage report 20 | coverage html 21 | 22 | # Dogfood current master version 23 | [testenv:dogfood] 24 | basepython = python3.7 25 | whitelist_externals = /usr/bin/ssh-agent 26 | skip_install = true 27 | deps = 28 | jupyter 29 | setuptools>=41.0.0 30 | commands = 31 | python -mpip install -e . 32 | python -msshkernel install --sys-prefix 33 | 34 | python -mpip install git+https://github.com/NII-cloud-operation/Jupyter-multi_outputs 35 | {envbindir}/jupyter nbextension install --py lc_multi_outputs --sys-prefix 36 | {envbindir}/jupyter nbextension enable --py lc_multi_outputs --sys-prefix 37 | 38 | python -m pip install papermill 39 | 40 | ssh-agent {envbindir}/jupyter notebook --no-browser 41 | 42 | [testenv:black] 43 | # skip_install: Do not install the current package 44 | skip_install = true 45 | deps = 46 | black 47 | commands = 48 | black {posargs:sshkernel setup.py} 49 | 50 | [testenv:build] 51 | skip_install = true 52 | deps = 53 | wheel 54 | setuptools 55 | commands = 56 | python setup.py sdist bdist_wheel 57 | 58 | # build and upload 59 | [testenv:release] 60 | basepython = python3 61 | skip_install = true 62 | deps = 63 | {[testenv:build]deps} 64 | twine 65 | commands = 66 | {[testenv:build]commands} 67 | twine upload --skip-existing dist/* 68 | passenv = 69 | TWINE_USERNAME 70 | TWINE_PASSWORD 71 | 72 | [testenv:flake8] 73 | skip_install = true 74 | deps = 75 | flake8 76 | flake8-import-order 77 | pep8-naming 78 | flake8-colors 79 | commands = 80 | flake8 sshkernel setup.py 81 | 82 | # run all linters 83 | [testenv:linters] 84 | ignore_errors = true 85 | deps = 86 | {[testenv:flake8]deps} 87 | {[testenv:pylint]deps} 88 | {[testenv:mypy]deps} 89 | commands = 90 | {[testenv:flake8]commands} 91 | {[testenv:pylint]commands} 92 | {[testenv:mypy]commands} 93 | 94 | [testenv:pylint] 95 | skip_install = true 96 | deps = 97 | pylint 98 | commands = 99 | pylint -f colorized sshkernel 100 | 101 | [testenv:pytest-watch] 102 | skip_install = true 103 | deps = 104 | pytest-testmon 105 | pytest-watch 106 | commands = 107 | pip install -e . 108 | {envbindir}/ptw sshkernel tests/unit -- --disable-pytest-warnings --testmon 109 | 110 | [testenv:mypy] 111 | deps = 112 | mypy 113 | commands = 114 | mypy sshkernel 115 | 116 | # Flake8 configuration 117 | [flake8] 118 | # equal to black 119 | max-line-length=88 120 | # flake8-colors 121 | format = ${cyan}%(path)s${reset}:${yellow_bold}%(row)d${reset}:${green_bold}%(col)d${reset}: ${red_bold}%(code)s${reset} %(text)s 122 | -------------------------------------------------------------------------------- /tests/integration/setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | 5 | def _run(cmd): 6 | print("run: {}".format(cmd)) 7 | 8 | return subprocess.check_output( 9 | cmd, shell=True, encoding="utf-8", universal_newlines=True 10 | ) 11 | 12 | 13 | def make_ssh_config(host, keyfile): 14 | 15 | _run("chmod 600 {keyfile}".format(keyfile=keyfile)) 16 | _run("mkdir -p ~/.ssh; chmod 700 ~/.ssh") 17 | target = os.path.expanduser("~/.ssh/config") 18 | config = """ 19 | Host {host} 20 | Hostname {hostname} 21 | User root 22 | Port 22 23 | IdentityFile {keyfile} 24 | """.format( 25 | host=host, hostname=host, keyfile=keyfile 26 | ) 27 | 28 | with open(target, "a") as f: 29 | f.write(config) 30 | 31 | print("wrote {target}".format(target=target)) 32 | 33 | 34 | def setup_remotecommand(keyfile): 35 | """Setup proxycommand config. 36 | terminal -- bastion -- target 37 | * terminal is the main container (py37) 38 | * bastion is root@ubuntu 39 | * target is root@localhost 40 | 41 | bastion and target is the same container 42 | because GitLab CI doesn't support inter-service name resolution currently. 43 | 44 | * add ssh_config entry in terminal with proxycommand 45 | """ 46 | config = """ 47 | # target from bastion 48 | Host {target} 49 | User root 50 | Port 22 51 | IdentityFile {keyfile} 52 | ProxyCommand ssh -W %h:%p {bastion} 53 | """.format( 54 | target="localhost", bastion="ubuntu18", keyfile=keyfile, 55 | ) 56 | with open(os.path.expanduser("~/.ssh/config"), "a") as f: 57 | f.write(config) 58 | 59 | print("wrote proxycommand config") 60 | 61 | print(_run("ssh ubuntu18 \"ssh-keygen -f ~/.ssh/id_rsa -N ''\"")) 62 | print(_run('ssh ubuntu18 "ssh-keyscan -H localhost >> ~/.ssh/known_hosts"')) 63 | print(_run("ssh ubuntu18 -- 'apt-get update && apt-get install -y sshpass'")) 64 | print(_run("ssh ubuntu18 -- sshpass -p root ssh-copy-id localhost")) 65 | 66 | print(_run("ssh ubuntu18 'env |grep SSH'")) 67 | print(_run("ssh ubuntu18 'ssh localhost \"env |grep SSH\"'")) 68 | 69 | 70 | def update_known_hosts(host): 71 | cmd = "ssh-keyscan -H {host} >> ~/.ssh/known_hosts".format(host=host) 72 | buf = _run(cmd) 73 | print(buf) 74 | 75 | 76 | def install_sshpass(): 77 | cmd = "apt-get update && apt-get install -yq sshpass" 78 | print(_run(cmd)) 79 | 80 | 81 | def ssh_copy_id(host, key, password): 82 | cmd = "sshpass -p {password} ssh-copy-id -i {key} {host}".format( 83 | key=key, host=host, password=password 84 | ) 85 | print(_run(cmd)) 86 | 87 | 88 | def check_login(host): 89 | print(_run("cat ~/.ssh/config")) 90 | 91 | cmd = "ssh {host} echo ok".format(host=host) 92 | buf = _run(cmd) 93 | 94 | assert "ok" in buf 95 | 96 | 97 | def install_sshkernel(): 98 | cmd = "python -msshkernel install" 99 | print(_run(cmd)) 100 | 101 | 102 | def main(): 103 | hosts = [ 104 | ("ubuntu18", "root", "root"), 105 | ("centos7", "root", "indig0!"), 106 | ] 107 | 108 | for (host, _user, password) in hosts: 109 | base = os.path.dirname(__file__) 110 | keyfile = os.path.abspath("{base}/id_rsa".format(base=base)) 111 | 112 | if host.startswith("ubuntu"): 113 | install_sshpass() 114 | 115 | make_ssh_config(host, keyfile) 116 | update_known_hosts(host) 117 | ssh_copy_id(host, keyfile, password) 118 | 119 | check_login(host) 120 | 121 | setup_remotecommand(keyfile) 122 | install_sshkernel() 123 | 124 | 125 | main() 126 | -------------------------------------------------------------------------------- /sshkernel/magics/magics.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import traceback 4 | 5 | from metakernel import ExceptionWrapper 6 | from metakernel import Magic 7 | 8 | 9 | class SSHKernelMagics(Magic): 10 | def line_login(self, host): 11 | """ 12 | %login HOST 13 | 14 | SSH login to the remote host. 15 | Cells below this line will be executed remotely. 16 | 17 | Example: 18 | [~/.ssh/config] 19 | Host myserver 20 | Hostname 10.0.0.10 21 | Port 2222 22 | 23 | %login myserver 24 | """ 25 | 26 | self.retval = None 27 | try: 28 | self.kernel.Print("[ssh] Login to {}...".format(host)) 29 | 30 | expanded_host = expand_parameters(host, self.kernel.get_params()) 31 | self.kernel.do_login(expanded_host) 32 | except Exception as exc: 33 | self.kernel.Error("[ssh] Login to {} failed.".format(host)) 34 | self.kernel.Error(exc) 35 | 36 | tb = traceback.format_exc().splitlines() 37 | 38 | # (name, value, tb) 39 | self.retval = ExceptionWrapper( 40 | "SSHConnectionError", "Login to {} failed.".format(host), tb 41 | ) 42 | else: 43 | self.kernel.Print("[ssh] Successfully logged in.") 44 | 45 | def line_logout(self): 46 | """ 47 | %logout 48 | 49 | Logout and disconnect. 50 | 51 | Example: 52 | %logout 53 | """ 54 | 55 | self.retval = None 56 | self.kernel.do_logout() 57 | 58 | def line_param(self, variable, value): 59 | """ 60 | %param VARIABLE VALUE 61 | 62 | Define a hostname/env variable. 63 | This is useful for parameterized notebook execution using papermill. 64 | 65 | Examples: 66 | In [1]: 67 | %param HOST_A 10.10.10.10 68 | %param HOST_B 11.11.11.11 69 | 70 | In[2]: 71 | %login {HOST_A} 72 | 73 | In[3]: 74 | echo $HOST_B 75 | 76 | Out[3]: 77 | 11.11.11.11 78 | """ 79 | try: 80 | validate_value_string(value) 81 | self.kernel.set_param(variable, value) 82 | except Exception as exc: 83 | # To propagate exception to frontend through metakernel 84 | # store ExceptionWrapper instance into retval 85 | ex_type, _ex, _tb = sys.exc_info() 86 | tb_format = traceback.format_exc().splitlines() 87 | self.retval = ExceptionWrapper(ex_type.__name__, repr(exc.args), tb_format) 88 | 89 | def post_process(self, retval): 90 | try: 91 | return self.retval 92 | except AttributeError: 93 | return retval 94 | 95 | 96 | def expand_parameters(host, params): 97 | """Expand parameters in hostname. 98 | 99 | Examples: 100 | * "target{N}" => "target1" 101 | * "{host}.{domain} => "host01.example.com" 102 | 103 | """ 104 | pattern = r"\{(.*?)\}" 105 | 106 | def repl(match): 107 | param_name = match.group(1) 108 | return params[param_name] 109 | 110 | return re.sub(pattern, repl, host) 111 | 112 | 113 | blacklist = re.compile(r".*([^- %,\./:=_a-zA-Z\d@])") 114 | 115 | 116 | def validate_value_string(val_str): 117 | """Raise if given string contains invalid characters. 118 | 119 | Args: 120 | val_str (str) 121 | 122 | Raises: 123 | ValueError: If `val_str` matches `blacklist` 124 | """ 125 | m = re.match(blacklist, str(val_str)) 126 | if m: 127 | msg = "{val} contains invalid character {matched}. Valid characters are A-Z a-z 0-9 - % , . / : = _ @".format( 128 | val=repr(val_str), matched=repr(m.group(1)) 129 | ) 130 | raise ValueError(msg) 131 | 132 | 133 | def register_magics(kernel): 134 | kernel.register_magics(SSHKernelMagics) 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SSH Kernel 2 | 3 | SSH Kernel is a Jupyter kernel specialized in executing commands remotely 4 | with [paramiko](http://www.paramiko.org/) SSH client. 5 | 6 | ![](doc/screenshot.png) 7 | 8 | ## Major requirements 9 | 10 | * Python3.5+ 11 | * IPython 7.0+ 12 | 13 | ## Recommended system requirements 14 | 15 | Host OS (running notebook server): 16 | 17 | * Ubuntu 18.04+ 18 | * Windows 10 WSL (Ubuntu 18.04+) 19 | 20 | Target OS (running sshd): 21 | 22 | * Ubuntu16.04+ 23 | * CentOS6+ 24 | 25 | ## Installation 26 | 27 | Please adopt installation option depends on your Python environment. 28 | 29 | ``` 30 | pip install -U sshkernel 31 | python -m sshkernel install [--user|--sys-prefix] 32 | # `python -m sshkernel install --help` for more information. 33 | ``` 34 | 35 | Verify that sshkernel is installed correctly by listing available Jupyter kernel specs: 36 | 37 | ``` 38 | $ jupyter kernelspec list 39 | Available kernels: 40 | python3 /tmp/env/share/jupyter/kernels/python3 41 | ssh /tmp/env/share/jupyter/kernels/ssh # <-- 42 | 43 | (Path differs depends on environment) 44 | ``` 45 | 46 | To upgrade: 47 | 48 | ``` 49 | pip install --upgrade sshkernel 50 | ``` 51 | 52 | To uninstall: 53 | 54 | ``` 55 | jupyter kernelspec remove ssh 56 | pip uninstall sshkernel 57 | ``` 58 | 59 | ### Notes about python environment 60 | 61 | The latest version of this library is mainly developed with Python 3.7.3 installed with `pyenv`. 62 | 63 | CI is performed with Python3.6 and 3.7 on [Debian based container without conda](https://hub.docker.com/_/python), 64 | and manual test is performed with Ubuntu based container with conda, which derived from [official Jupyter Notebook stack](https://hub.docker.com/r/jupyter/minimal-notebook/). 65 | `Anaconda` on Windows 10 is also confirmed, but is less tested in our development/production. 66 | 67 | ## Getting Started 68 | 69 | Basic examples of using SSH Kernel: 70 | 71 | * [Getting Started](https://github.com/NII-cloud-operation/sshkernel/blob/master/examples/getting-started.ipynb) 72 | * [Getting Started (in Japanese)](https://github.com/NII-cloud-operation/sshkernel/blob/master/examples/getting-started-ja.ipynb) 73 | 74 | ## Configuration 75 | 76 | SSH Kernel obtains configuration data from `~/.ssh/config` file to connect servers. 77 | 78 | Possible keywords are as follows: 79 | 80 | * HostName 81 | * User 82 | * Port 83 | * IdentityFile 84 | * ForwardAgent 85 | 86 | ### Notes about private keys 87 | 88 | * As private key files in `~/.ssh/` are discoverable, you do not necessarily specify `IdentityFile` 89 | * If you use a ed25519 key, please generate with or convert into old PEM format 90 | * e.g. `ssh-keygen -m PEM -t ed25519 ...` 91 | * This is because `paramiko` library doesn't support latest format "RFC4716" 92 | 93 | ### Configuration examples 94 | 95 | Example1: 96 | 97 | ``` 98 | [~/.ssh/config] 99 | Host myserver 100 | HostName myserver.example.com 101 | User admin 102 | Port 2222 103 | IdentityFile ~/.ssh/id_rsa_myserver 104 | ForwardAgent yes 105 | 106 | Host * 107 | User ubuntu 108 | ``` 109 | 110 | Example2: 111 | 112 | ``` 113 | [~/.ssh/config] 114 | Host another-server 115 | HostName 192.0.2.1 116 | ``` 117 | 118 | Minimal example: 119 | 120 | ``` 121 | [~/.ssh/config] 122 | 123 | # If you specify a FQDN / IP address, same login user, and discoverable private key, 124 | # no configuration needed 125 | ``` 126 | 127 | See also a tutorial above in detail. 128 | 129 | ## Parameterized run 130 | 131 | See [examples/parameterized-notebook](https://github.com/NII-cloud-operation/sshkernel/blob/master/examples/parameterized-notebook.ipynb). 132 | 133 | ## Limitations 134 | 135 | * As Jupyter Notebook has limitation to handle `stdin`, 136 | you may need to change some server configuration and commands to avoid *interactive input*. 137 | * e.g. use publickey-authentication instead of password, enable NOPASSWD for sudo, use `expect` 138 | * Some shell variables are different from normal interactive shell 139 | * e.g. `$?`, `$$` 140 | 141 | ## LICENSE 142 | 143 | This software is released under the terms of the Modified BSD License. 144 | 145 | [Logo](https://commons.wikimedia.org/wiki/File:High-contrast-utilities-terminal.png) from Wikimedia Commons is licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0). 146 | -------------------------------------------------------------------------------- /examples/parameterized-notebook.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Parameterized notebook\n", 8 | "\n", 9 | "This page describes how to declare and use variables inside ssh kernel notebooks.


" 10 | ] 11 | }, 12 | { 13 | "cell_type": "markdown", 14 | "metadata": {}, 15 | "source": [ 16 | "## Declare variables with `%param` magic\n", 17 | "\n", 18 | "`%param ` magic declare a new variable.\n", 19 | "\n", 20 | "* Note:\n", 21 | " * Allowed characters in `variable`: A-Z a-z 0-9 _\n", 22 | " * Allowed characters in `value`: A-Z a-z 0-9 - % , . / : = _ @ SPACE" 23 | ] 24 | }, 25 | { 26 | "cell_type": "markdown", 27 | "metadata": {}, 28 | "source": [ 29 | "### Example" 30 | ] 31 | }, 32 | { 33 | "cell_type": "code", 34 | "execution_count": 1, 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "%param HOST_A 127.0.0.1\n", 39 | "%param HOST_B test.example.com" 40 | ] 41 | }, 42 | { 43 | "cell_type": "markdown", 44 | "metadata": {}, 45 | "source": [ 46 | "==> Declared two variables `HOST_A`, `HOST_B` . You can use these variables as follows.\n", 47 | "\n", 48 | "


" 49 | ] 50 | }, 51 | { 52 | "cell_type": "markdown", 53 | "metadata": {}, 54 | "source": [ 55 | "## Refer variables in `%login` magic\n", 56 | "\n", 57 | "Variables declared above are interpolated with `{name}` syntax in `%login` magic." 58 | ] 59 | }, 60 | { 61 | "cell_type": "markdown", 62 | "metadata": {}, 63 | "source": [ 64 | "### Example" 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": 2, 70 | "metadata": {}, 71 | "outputs": [ 72 | { 73 | "name": "stdout", 74 | "output_type": "stream", 75 | "text": [ 76 | "[ssh] Login to {HOST_A}...\n", 77 | "[ssh] host=127.0.0.1 hostname=127.0.0.1 other_conf={}\n", 78 | "[ssh] Successfully logged in.\n" 79 | ] 80 | } 81 | ], 82 | "source": [ 83 | "%login {HOST_A}" 84 | ] 85 | }, 86 | { 87 | "cell_type": "markdown", 88 | "metadata": {}, 89 | "source": [ 90 | "==> `{HOST_A}` is replaced to `127.0.0.1`" 91 | ] 92 | }, 93 | { 94 | "cell_type": "code", 95 | "execution_count": 3, 96 | "metadata": {}, 97 | "outputs": [ 98 | { 99 | "name": "stdout", 100 | "output_type": "stream", 101 | "text": [ 102 | "[ssh] host = 127.0.0.1, cwd = /home/masaru\n", 103 | "Hey, this is HOST_A\n", 104 | "\n" 105 | ] 106 | } 107 | ], 108 | "source": [ 109 | "echo Hey, this is HOST_A" 110 | ] 111 | }, 112 | { 113 | "cell_type": "code", 114 | "execution_count": 4, 115 | "metadata": {}, 116 | "outputs": [ 117 | { 118 | "name": "stdout", 119 | "output_type": "stream", 120 | "text": [ 121 | "[ssh] Successfully logged out.\n" 122 | ] 123 | } 124 | ], 125 | "source": [ 126 | "%logout" 127 | ] 128 | }, 129 | { 130 | "cell_type": "markdown", 131 | "metadata": {}, 132 | "source": [ 133 | "


" 134 | ] 135 | }, 136 | { 137 | "cell_type": "markdown", 138 | "metadata": {}, 139 | "source": [ 140 | "## Refer variable as remote environment variables\n", 141 | "\n", 142 | "Variables declared above are read by environment variables.\n", 143 | "\n", 144 | "* Note:\n", 145 | " * Declared variables are injected as default environment variables at login.\n", 146 | " * You can re-assign environment variables in remote context (i.e. execute `export HOST_B=xxx` or `HOST_B=xxx`)\n", 147 | " * However, the new value does not propagate to the \"local\" or other context.\n", 148 | " * That is, you cannot share environment variables between remote hosts." 149 | ] 150 | }, 151 | { 152 | "cell_type": "markdown", 153 | "metadata": {}, 154 | "source": [ 155 | "### Example" 156 | ] 157 | }, 158 | { 159 | "cell_type": "code", 160 | "execution_count": 5, 161 | "metadata": {}, 162 | "outputs": [ 163 | { 164 | "name": "stdout", 165 | "output_type": "stream", 166 | "text": [ 167 | "[ssh] Closing existing connection.\n", 168 | "[ssh] Login to {HOST_B}...\n", 169 | "[ssh] host=test.example.com hostname=localhost other_conf={'port': 22}\n", 170 | "[ssh] Successfully logged in.\n" 171 | ] 172 | } 173 | ], 174 | "source": [ 175 | "%login {HOST_B}" 176 | ] 177 | }, 178 | { 179 | "cell_type": "code", 180 | "execution_count": 6, 181 | "metadata": {}, 182 | "outputs": [ 183 | { 184 | "name": "stdout", 185 | "output_type": "stream", 186 | "text": [ 187 | "[ssh] host = test.example.com, cwd = /home/masaru\n", 188 | "\u001b[01;31m\u001b[KHOST_A\u001b[m\u001b[K=127.0.0.1\n", 189 | "\u001b[01;31m\u001b[KHOST_B\u001b[m\u001b[K=test.example.com\n", 190 | "\n" 191 | ] 192 | } 193 | ], 194 | "source": [ 195 | "printenv |sort | grep --color=always \"HOST_.\"" 196 | ] 197 | }, 198 | { 199 | "cell_type": "code", 200 | "execution_count": 7, 201 | "metadata": {}, 202 | "outputs": [ 203 | { 204 | "name": "stdout", 205 | "output_type": "stream", 206 | "text": [ 207 | "[ssh] host = test.example.com, cwd = /home/masaru\n", 208 | "HOST_A is 127.0.0.1\n", 209 | "HOST_B is test.example.com\n", 210 | "\n" 211 | ] 212 | } 213 | ], 214 | "source": [ 215 | "echo HOST_A is $HOST_A\n", 216 | "echo HOST_B is $HOST_B" 217 | ] 218 | }, 219 | { 220 | "cell_type": "code", 221 | "execution_count": 8, 222 | "metadata": {}, 223 | "outputs": [ 224 | { 225 | "name": "stdout", 226 | "output_type": "stream", 227 | "text": [ 228 | "[ssh] Successfully logged out.\n" 229 | ] 230 | } 231 | ], 232 | "source": [ 233 | "%logout" 234 | ] 235 | } 236 | ], 237 | "metadata": { 238 | "kernelspec": { 239 | "display_name": "SSH", 240 | "language": "bash", 241 | "name": "ssh" 242 | }, 243 | "language_info": { 244 | "codemirror_mode": "shell", 245 | "file_extension": ".sh", 246 | "mimetype": "text/x-sh", 247 | "name": "ssh" 248 | }, 249 | "toc": { 250 | "base_numbering": 1, 251 | "nav_menu": {}, 252 | "number_sections": true, 253 | "sideBar": true, 254 | "skip_h1_title": false, 255 | "title_cell": "Table of Contents", 256 | "title_sidebar": "Contents", 257 | "toc_cell": false, 258 | "toc_position": {}, 259 | "toc_section_display": true, 260 | "toc_window_display": false 261 | } 262 | }, 263 | "nbformat": 4, 264 | "nbformat_minor": 2 265 | } 266 | -------------------------------------------------------------------------------- /sshkernel/kernel.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import textwrap 4 | import traceback 5 | from logging import INFO 6 | 7 | from metakernel import ExceptionWrapper 8 | from metakernel import MetaKernel 9 | 10 | from paramiko.ssh_exception import SSHException 11 | 12 | from .exception import SSHKernelNotConnectedException 13 | from .ssh_wrapper_plumbum import SSHWrapperPlumbum 14 | from .version import __version__ 15 | 16 | version_pat = re.compile(r"version (\d+(\.\d+)+)") 17 | 18 | 19 | class SSHKernel(MetaKernel): 20 | """ 21 | SSH kernel run commands remotely. 22 | """ 23 | 24 | implementation = "sshkernel" 25 | implementation_version = __version__ 26 | language = "bash" 27 | language_version = __version__ 28 | banner = "SSH kernel version {}".format(__version__) 29 | kernel_json = { 30 | "argv": [sys.executable, "-m", "sshkernel", "-f", "{connection_file}"], 31 | "display_name": "SSH", 32 | "language": "bash", 33 | "codemirror_mode": "shell", 34 | "env": {"PS1": "$"}, 35 | "name": "ssh", 36 | } 37 | language_info = { 38 | "name": "ssh", 39 | "codemirror_mode": "shell", 40 | "mimetype": "text/x-sh", 41 | "file_extension": ".sh", 42 | } 43 | 44 | @property 45 | def sshwrapper(self): 46 | return self._sshwrapper 47 | 48 | @sshwrapper.setter 49 | def sshwrapper(self, value): 50 | self._sshwrapper = value 51 | 52 | def get_usage(self): 53 | return textwrap.dedent( 54 | """Usage: 55 | 56 | * Prepare `~/.ssh/config` 57 | * To login to the remote server, use magic command `%login ` into a new cell 58 | * e.g. `%login localhost` 59 | * After %login, input commands are executed remotely 60 | * To close session, use `%logout` magic command 61 | """ 62 | ) 63 | 64 | def __init__(self, sshwrapper_class=SSHWrapperPlumbum, **kwargs): 65 | super().__init__(**kwargs) 66 | 67 | self.__sshwrapper_class = sshwrapper_class 68 | self._sshwrapper = None 69 | self._parameters = dict() 70 | 71 | # Touch inherited attribute 72 | self.log.name = "SSHKernel" 73 | self.log.setLevel(INFO) 74 | 75 | def set_param(self, key, value): 76 | """ 77 | Set sshkernel parameter for hostname and remote envvars. 78 | """ 79 | 80 | self._parameters[key] = value 81 | 82 | def get_params(self): 83 | """ 84 | Get sshkernel parameters dict. 85 | """ 86 | 87 | return self._parameters 88 | 89 | def do_login(self, host: str): 90 | """Establish a ssh connection to the host.""" 91 | self.do_logout() 92 | 93 | wrapper = self.__sshwrapper_class(self.get_params()) 94 | wrapper.connect(host) 95 | self.sshwrapper = wrapper 96 | 97 | def do_logout(self): 98 | """Close the connection.""" 99 | if self.sshwrapper: 100 | self.Print("[ssh] Closing existing connection.") 101 | self.sshwrapper.close() # TODO: error handling 102 | self.Print("[ssh] Successfully logged out.") 103 | 104 | self.sshwrapper = None 105 | 106 | # Implement base class method 107 | def do_execute_direct(self, code, silent=False): 108 | try: 109 | self.assert_connected() 110 | except SSHKernelNotConnectedException: 111 | self.Error(traceback.format_exc()) 112 | return ExceptionWrapper("abort", "not connected", []) 113 | 114 | try: 115 | exitcode = self.sshwrapper.exec_command(code, self.Write) 116 | 117 | except KeyboardInterrupt: 118 | self.Error("* interrupt...") 119 | 120 | # TODO: Handle exception 121 | self.sshwrapper.interrupt() 122 | 123 | self.Error(traceback.format_exc()) 124 | 125 | return ExceptionWrapper("abort", str(1), [str(KeyboardInterrupt)]) 126 | 127 | except SSHException: 128 | # 129 | # TODO: Implement reconnect sequence 130 | return ExceptionWrapper("ssh_exception", str(1), []) 131 | 132 | if exitcode: 133 | ename = "abnormal exit code" 134 | evalue = str(exitcode) 135 | trace = [""] 136 | 137 | return ExceptionWrapper(ename, evalue, trace) 138 | 139 | return None 140 | 141 | # Implement ipykernel method 142 | def do_complete(self, code, cursor_pos): 143 | default = { 144 | "matches": [], 145 | "cursor_start": 0, 146 | "cursor_end": cursor_pos, 147 | "metadata": dict(), 148 | "status": "ok", 149 | } 150 | try: 151 | self.assert_connected() 152 | except SSHKernelNotConnectedException: 153 | # TODO: Error() in `do_complete` not shown in notebook 154 | self.log.error("not connected") 155 | return default 156 | 157 | code_current = code[:cursor_pos] 158 | if not code_current or code_current[-1] == " ": 159 | return default 160 | 161 | tokens = code_current.replace(";", " ").split() 162 | if not tokens: 163 | return default 164 | 165 | token = tokens[-1] 166 | 167 | if token[0] == "$": 168 | # complete variables 169 | 170 | # strip leading $ 171 | cmd = "compgen -A arrayvar -A export -A variable %s" % token[1:] 172 | completions = set() 173 | 174 | def callback(line): 175 | completions.add(line.rstrip()) 176 | 177 | self.sshwrapper.exec_command(cmd, callback) 178 | 179 | # append matches including leading $ 180 | matches = ["$" + c for c in completions] 181 | else: 182 | # complete functions and builtins 183 | cmd = "compgen -cdfa %s" % token 184 | matches = set() 185 | 186 | def callback(line): 187 | matches.add(line.rstrip()) 188 | 189 | self.sshwrapper.exec_command(cmd, callback) 190 | 191 | matches = [m for m in matches if m.startswith(token)] 192 | 193 | cursor_start = cursor_pos - len(token) 194 | cursor_end = cursor_pos 195 | 196 | return dict( 197 | matches=sorted(matches), 198 | cursor_start=cursor_start, 199 | cursor_end=cursor_end, 200 | metadata=dict(), 201 | status="ok", 202 | ) 203 | 204 | def restart_kernel(self): 205 | # TODO: log message 206 | # self.Print('[INFO] Restart sshkernel ...') 207 | 208 | self.do_logout() 209 | self._parameters = dict() 210 | 211 | def assert_connected(self): 212 | """ 213 | Assert client is connected. 214 | """ 215 | 216 | if self.sshwrapper is None: 217 | self.Error("[ssh] Not logged in.") 218 | raise SSHKernelNotConnectedException 219 | 220 | if not self.sshwrapper.isconnected(): 221 | self.Error("[ssh] Not connected.") 222 | raise SSHKernelNotConnectedException 223 | -------------------------------------------------------------------------------- /examples/parameterized-notebook-ja.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# ノートブックのパラメタライズ\n", 8 | "\n", 9 | "SSH Kernelのノートブックで変数を宣言・参照する方法を説明します。


" 10 | ] 11 | }, 12 | { 13 | "cell_type": "markdown", 14 | "metadata": {}, 15 | "source": [ 16 | "## 変数の宣言:`%param`\n", 17 | "\n", 18 | "`%param <変数名> <変数値>` 記法は、新しい変数を宣言します。\n", 19 | "\n", 20 | "* 注\n", 21 | " * 〈変数名〉に使うことのできる文字は次の通り: A-Z a-z 0-9 _\n", 22 | " * 〈変数値〉に使うことのできる文字は次の通り: A-Z a-z 0-9 - % , . / : = _ @ SPACE" 23 | ] 24 | }, 25 | { 26 | "cell_type": "markdown", 27 | "metadata": {}, 28 | "source": [ 29 | "### 例" 30 | ] 31 | }, 32 | { 33 | "cell_type": "code", 34 | "execution_count": 1, 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "%param HOST_A 127.0.0.1\n", 39 | "%param HOST_B test.example.com" 40 | ] 41 | }, 42 | { 43 | "cell_type": "markdown", 44 | "metadata": {}, 45 | "source": [ 46 | "==> `HOST_A`, `HOST_B`の変数が宣言され、次に示す方法で参照できます。\n", 47 | "\n", 48 | "


" 49 | ] 50 | }, 51 | { 52 | "cell_type": "markdown", 53 | "metadata": {}, 54 | "source": [ 55 | "## `%login`での変数展開\n", 56 | "\n", 57 | "上で宣言された変数は、`%login` 記法のなかで `{name}` と書くことで展開できます。" 58 | ] 59 | }, 60 | { 61 | "cell_type": "markdown", 62 | "metadata": {}, 63 | "source": [ 64 | "### 例" 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": 2, 70 | "metadata": {}, 71 | "outputs": [ 72 | { 73 | "name": "stdout", 74 | "output_type": "stream", 75 | "text": [ 76 | "[ssh] Login to {HOST_A}...\n", 77 | "[ssh] host=127.0.0.1 hostname=127.0.0.1 other_conf={}\n", 78 | "[ssh] Successfully logged in.\n" 79 | ] 80 | } 81 | ], 82 | "source": [ 83 | "%login {HOST_A}" 84 | ] 85 | }, 86 | { 87 | "cell_type": "markdown", 88 | "metadata": {}, 89 | "source": [ 90 | "==> `{HOST_A}` は `127.0.0.1` に置き換わります\n", 91 | "\n", 92 | "


" 93 | ] 94 | }, 95 | { 96 | "cell_type": "markdown", 97 | "metadata": {}, 98 | "source": [ 99 | "## リモート環境で環境変数として参照する\n", 100 | "\n", 101 | "上で宣言された変数は、リモート環境で環境変数として参照することができます。\n", 102 | "\n", 103 | "* 注\n", 104 | " * 宣言された変数は、環境変数のデフォルト値として、リモート環境へのログイン時に注入されます\n", 105 | " * ログイン後に環境変数を再代入した場合(つまりコマンド`export HOST_B=xxx`と実行した場合)には、その変数はそのリモート環境でのみ上書きされます" 106 | ] 107 | }, 108 | { 109 | "cell_type": "markdown", 110 | "metadata": {}, 111 | "source": [ 112 | "### 例" 113 | ] 114 | }, 115 | { 116 | "cell_type": "code", 117 | "execution_count": 3, 118 | "metadata": {}, 119 | "outputs": [ 120 | { 121 | "name": "stdout", 122 | "output_type": "stream", 123 | "text": [ 124 | "[ssh] Closing existing connection.\n", 125 | "[ssh] Login to {HOST_B}...\n", 126 | "[ssh] host=test.example.com hostname=localhost other_conf={'port': 22}\n", 127 | "[ssh] Successfully logged in.\n" 128 | ] 129 | } 130 | ], 131 | "source": [ 132 | "%login {HOST_B}" 133 | ] 134 | }, 135 | { 136 | "cell_type": "code", 137 | "execution_count": 4, 138 | "metadata": {}, 139 | "outputs": [ 140 | { 141 | "name": "stdout", 142 | "output_type": "stream", 143 | "text": [ 144 | "[ssh] host = test.example.com, cwd = /home/masaru\n", 145 | "HOST_A=127.0.0.1\n", 146 | "HOST_B=test.example.com\n", 147 | "\n" 148 | ] 149 | } 150 | ], 151 | "source": [ 152 | "env |sort | grep HOST_" 153 | ] 154 | }, 155 | { 156 | "cell_type": "code", 157 | "execution_count": 5, 158 | "metadata": {}, 159 | "outputs": [ 160 | { 161 | "name": "stdout", 162 | "output_type": "stream", 163 | "text": [ 164 | "[ssh] host = test.example.com, cwd = /home/masaru\n", 165 | "HOST_A is 127.0.0.1\n", 166 | "HOST_B is test.example.com\n", 167 | "\n" 168 | ] 169 | } 170 | ], 171 | "source": [ 172 | "echo HOST_A is $HOST_A\n", 173 | "echo HOST_B is $HOST_B" 174 | ] 175 | }, 176 | { 177 | "cell_type": "code", 178 | "execution_count": 6, 179 | "metadata": {}, 180 | "outputs": [ 181 | { 182 | "name": "stdout", 183 | "output_type": "stream", 184 | "text": [ 185 | "[ssh] Successfully logged out.\n" 186 | ] 187 | } 188 | ], 189 | "source": [ 190 | "%logout" 191 | ] 192 | }, 193 | { 194 | "cell_type": "markdown", 195 | "metadata": {}, 196 | "source": [ 197 | "## Papermillとの統合\n", 198 | "\n", 199 | "[Papermill](https://github.com/nteract/papermill)に次のパッチを当てて用いると、SSH Kernel形式のノートブックをコマンドラインからパラメタライズ実行できます。\n", 200 | "\n", 201 | "* `%param` を含むセルをノートブックに挿入するためのパッチ:\n", 202 | "\n", 203 | "```diff\n", 204 | "--- papermill/translators.py.orig 2019-05-10 18:44:49.592111720 +0900\n", 205 | "+++ papermill/translators.py 2019-04-25 17:32:40.204800528 +0900\n", 206 | "@@ -200,12 +200,31 @@\n", 207 | " return '# {}'.format(cmt_str).strip()\n", 208 | "\n", 209 | "\n", 210 | "+class SSHTranslator(Translator):\n", 211 | "+ @classmethod\n", 212 | "+ def translate_str(cls, val):\n", 213 | "+ \"\"\"Default behavior for translation\"\"\"\n", 214 | "+ return cls.translate_raw_str(val)\n", 215 | "+\n", 216 | "+ @classmethod\n", 217 | "+ def assign(cls, name, str_val):\n", 218 | "+ return '%param {} {}'.format(name, str_val)\n", 219 | "+\n", 220 | "+ @classmethod\n", 221 | "+ def codify(cls, parameters):\n", 222 | "+ content = ''\n", 223 | "+ for name, val in parameters.items():\n", 224 | "+ content += '{}\\n'.format(cls.assign(name, cls.translate(val)))\n", 225 | "+ return content\n", 226 | "+\n", 227 | "+\n", 228 | " # Instantiate a PapermillIO instance and register Handlers.\n", 229 | " papermill_translators = PapermillTranslators()\n", 230 | " papermill_translators.register(\"python\", PythonTranslator)\n", 231 | " papermill_translators.register(\"R\", RTranslator)\n", 232 | " papermill_translators.register(\"scala\", ScalaTranslator)\n", 233 | " papermill_translators.register(\"julia\", JuliaTranslator)\n", 234 | "+papermill_translators.register(\"ssh\", SSHTranslator)\n", 235 | "\n", 236 | "\n", 237 | " def translate_parameters(kernel_name, parameters):\n", 238 | "```" 239 | ] 240 | }, 241 | { 242 | "cell_type": "markdown", 243 | "metadata": {}, 244 | "source": [ 245 | "### 実行例\n", 246 | "\n", 247 | "Papermillに上記パッチを当て、次のコマンドを実行します。\n", 248 | "\n", 249 | "```shell\n", 250 | "papermill -p HOST_A 20.20.20.20 input.ipynb output.ipynb\n", 251 | "```" 252 | ] 253 | } 254 | ], 255 | "metadata": { 256 | "kernelspec": { 257 | "display_name": "SSH", 258 | "language": "bash", 259 | "name": "ssh" 260 | }, 261 | "language_info": { 262 | "codemirror_mode": "shell", 263 | "file_extension": ".sh", 264 | "mimetype": "text/x-sh", 265 | "name": "ssh" 266 | }, 267 | "toc": { 268 | "base_numbering": 1, 269 | "nav_menu": {}, 270 | "number_sections": true, 271 | "sideBar": true, 272 | "skip_h1_title": false, 273 | "title_cell": "Table of Contents", 274 | "title_sidebar": "Contents", 275 | "toc_cell": false, 276 | "toc_position": {}, 277 | "toc_section_display": true, 278 | "toc_window_display": false 279 | } 280 | }, 281 | "nbformat": 4, 282 | "nbformat_minor": 2 283 | } 284 | -------------------------------------------------------------------------------- /tests/unit/test_kernel.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from unittest.mock import Mock 3 | from unittest.mock import PropertyMock 4 | from unittest.mock import patch 5 | import unittest 6 | 7 | from ipykernel.kernelbase import Kernel 8 | from sshkernel import ExceptionWrapper 9 | from sshkernel import SSHException 10 | from sshkernel.exception import SSHKernelNotConnectedException 11 | from sshkernel.kernel import SSHKernel 12 | from sshkernel.ssh_wrapper import SSHWrapper 13 | 14 | 15 | class SSHWrapperDummy: 16 | def __init__(self, *args): 17 | pass 18 | 19 | def connect(self, host): 20 | pass 21 | 22 | 23 | class SSHKernelTest(unittest.TestCase): 24 | def setUp(self): 25 | self.instance = SSHKernel(sshwrapper_class=SSHWrapperDummy) 26 | # type(self.instance).sshwrapper = PropertyMock(return_value=Mock(spec=SSHWrapper)) 27 | self.instance.Error = Mock() 28 | self.instance.Print = Mock() 29 | self.instance.Write = Mock() 30 | 31 | def test_new(self): 32 | self.assertIsInstance(self.instance, Kernel) 33 | self.assertIsInstance(self.instance, SSHKernel) 34 | self.assertIsNone(self.instance.sshwrapper) 35 | 36 | def test_impl(self): 37 | self.assertEqual(self.instance.implementation, "sshkernel") 38 | 39 | def test_banner(self): 40 | self.assertIn("SSH", self.instance.banner) 41 | 42 | def test_do_login(self): 43 | self.instance.do_login("dummy") 44 | 45 | def test_do_execute_direct_should_return_exception_value(self): 46 | err = self.instance.do_execute_direct("ls") 47 | self.assertIsInstance(err, ExceptionWrapper) 48 | 49 | def test_do_execute_direct_calls_exec_command(self): 50 | cmd = "date" 51 | cmd_result = "Sat Oct 27 19:45:46 JST 2018\n" 52 | print_function = Mock() 53 | 54 | with patch("sshkernel.kernel.SSHKernel.sshwrapper", new_callable=PropertyMock): 55 | self.instance.sshwrapper.exec_command = Mock(return_value=0) 56 | 57 | self.assertEqual(0, self.instance.sshwrapper.exec_command()) 58 | 59 | err = self.instance.do_execute_direct(cmd, print_function) 60 | 61 | self.assertIsNone(err) 62 | self.instance.sshwrapper.exec_command.assert_called() 63 | 64 | def test_do_execute_direct_return_code_1(self): 65 | self.instance.assert_connected = Mock() 66 | self.instance.sshwrapper = PropertyMock(spec=SSHWrapper) 67 | self.instance.sshwrapper.exec_command = Mock(return_value=1) 68 | 69 | err = self.instance.do_execute_direct("sl") 70 | 71 | self.assertIsInstance(err, ExceptionWrapper) 72 | self.assertEqual(err.evalue, "1") 73 | 74 | def test_exec_with_exception_should_return_exception(self): 75 | self.instance.sshwrapper = PropertyMock(spec=SSHWrapper) 76 | self.instance.sshwrapper.exec_command = Mock(side_effect=SSHException("boom")) 77 | 78 | err = self.instance.do_execute_direct("sl") 79 | 80 | self.assertIsInstance(err, ExceptionWrapper) 81 | 82 | def test_exec_with_interrupt_should_return_exception(self): 83 | self.instance.sshwrapper = PropertyMock(spec=SSHWrapper) 84 | self.instance.sshwrapper.exec_command = Mock(side_effect=KeyboardInterrupt()) 85 | 86 | err = self.instance.do_execute_direct("sleep 10000") 87 | 88 | self.instance.Write.assert_not_called() 89 | 90 | self.assertIsInstance(err, ExceptionWrapper) 91 | self.assertEqual(err.ename, "abort") 92 | self.assertIsInstance(err.evalue, str) 93 | self.assertIsInstance(err.traceback, list) 94 | 95 | self.instance.Error.assert_called() 96 | 97 | @unittest.skip 98 | def test_exec_without_connected_should_return_exception(self): 99 | self.instance.sshwrapper.isconnected = Mock(return_value=False) 100 | err = self.instance.do_execute_direct("echo Before connect") 101 | 102 | self.instance.sshwrapper.isconnected.assert_called_once_with() 103 | self.assertIsInstance(err, ExceptionWrapper) 104 | 105 | @patch("sshkernel.kernel.SSHKernel.sshwrapper", new_callable=PropertyMock) 106 | def test_restart_kernel_should_call_close(self, prop): 107 | wrapper_double = Mock() 108 | prop.return_value = wrapper_double 109 | 110 | self.instance.restart_kernel() 111 | 112 | wrapper_double.close.assert_called_once() 113 | 114 | @unittest.skip("Moving to test_magic.py") 115 | def test_login_magic(self): 116 | # magic method call is received 117 | host = "dummy" 118 | (msg, err) = self.instance.Login(host) 119 | 120 | self.assertIsInstance(msg, str) 121 | self.instance.sshwrapper.connect.assert_called_once_with(host) 122 | 123 | def test_do_complete_should_return_empty_array_not_connected(self): 124 | connected_double = Mock(side_effect=SSHKernelNotConnectedException) 125 | 126 | self.instance.assert_connected = connected_double 127 | 128 | matches = self.instance.do_complete("code", len("code")) 129 | 130 | connected_double.assert_called_once() 131 | self.assertEqual(matches["matches"], []) 132 | 133 | def test_do_complete_should_return_empty_array_with_empty_string(self): 134 | for line in ["", "trailingspace ", "; ;;"]: 135 | connected_double = Mock(return_value=True) 136 | self.instance.assert_connected = connected_double 137 | 138 | matches = self.instance.do_complete("", 0) 139 | 140 | connected_double.assert_called_once() 141 | self.assertEqual(matches["matches"], []) 142 | 143 | def check_completion(self, result): 144 | self.assertIsInstance(result, dict) 145 | self.assertEqual(result["status"], "ok") 146 | 147 | self.assertIn("matches", result) 148 | matches = result["matches"] 149 | self.assertEqual(matches, sorted(matches)) 150 | self.assertEqual(matches, [e.rstrip() for e in matches]) 151 | 152 | @patch("sshkernel.kernel.SSHKernel.sshwrapper", new_callable=PropertyMock) 153 | def test_complete_bash_variables(self, mock): 154 | def exec_double(cmd, callback): 155 | result = dedent( 156 | """\ 157 | BASH_ARGC 158 | BASH_ARGV 159 | BASH_LINENO 160 | BASH_REMATCH 161 | """ 162 | ) 163 | for line in result.split(): 164 | callback(line) 165 | 166 | return 0 167 | 168 | # Replace with double without Mock() 169 | self.instance.sshwrapper.exec_command = exec_double 170 | 171 | res = self.instance.do_complete("$BASH", 5) 172 | 173 | self.check_completion(res) 174 | 175 | self.assertEqual( 176 | res["matches"], 177 | ["$BASH_ARGC", "$BASH_ARGV", "$BASH_LINENO", "$BASH_REMATCH"], 178 | ) 179 | 180 | @patch("sshkernel.kernel.SSHKernel.sshwrapper", new_callable=PropertyMock) 181 | def test_complete_bash_commands(self, mock): 182 | def exec_double(cmd, callback): 183 | result = dedent( 184 | """\ 185 | ls 186 | ls 187 | lspcmcia 188 | lslogins 189 | """ 190 | ) 191 | for line in result.split(): 192 | callback(line) 193 | return 0 194 | 195 | # Replace with double without Mock() 196 | self.instance.sshwrapper.exec_command = exec_double 197 | 198 | res = self.instance.do_complete("ls", 3) 199 | self.check_completion(res) 200 | 201 | self.assertEqual(res["matches"], ["ls", "lslogins", "lspcmcia"]) 202 | 203 | def test_sshwrapper_setter(self): 204 | self.assertIsNone(self.instance.sshwrapper) 205 | self.assertIsNone(self.instance._sshwrapper) 206 | 207 | self.instance.sshwrapper = 42 208 | 209 | self.assertEqual(self.instance._sshwrapper, 42) 210 | 211 | @unittest.skip("refactoring") 212 | @patch("sshkernel.kernel.SSHKernel.sshwrapper", new_callable=PropertyMock) 213 | def test_new_ssh_wrapper_call_close_if_old_instance_exist(self, prop): 214 | wrapper_double = Mock() 215 | prop.return_value = wrapper_double 216 | 217 | self.instance.do_login("yo") 218 | 219 | wrapper_double.close.assert_called_once() 220 | 221 | def test_params(self): 222 | self.assertEqual(dict(), self.instance.get_params()) 223 | self.instance.set_param("KEY1", "VALUE1") 224 | self.assertEqual({"KEY1": "VALUE1"}, self.instance.get_params()) 225 | 226 | -------------------------------------------------------------------------------- /sshkernel/ssh_wrapper_plumbum.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import re 4 | 5 | import paramiko 6 | 7 | from plumbum.machines.paramiko_machine import ParamikoMachine 8 | 9 | import yaml 10 | 11 | from .ssh_wrapper import SSHWrapper 12 | 13 | 14 | class SSHWrapperPlumbum(SSHWrapper): 15 | """ 16 | A plumbum remote machine wrapper 17 | SSHWrapperPlumbum wraps ssh client. 18 | 19 | Attributes: 20 | * ._remote : plumbum.machines.paramiko_machine.ParamikoMachine 21 | * ._remote._client: paramiko.SSHClient 22 | """ 23 | 24 | def __init__(self, envdelta_init=dict()): 25 | self.envdelta_init = envdelta_init 26 | self._remote = None 27 | self.__connected = False 28 | self._host = "" 29 | self.interrupt_function = lambda: None 30 | 31 | def exec_command(self, cmd, print_function): 32 | """ 33 | Returns: 34 | int: exit_code 35 | * Return the last command exit_code 36 | * Return 1 if failed to execute a command 37 | 38 | Raises: 39 | plumbum.commands.processes.ProcessExecutionError: If exit_code is 0 40 | """ 41 | 42 | print_function("[ssh] host = {}, cwd = {}\n".format(self._host, self.get_cwd())) 43 | 44 | marker = str(time.time())[::-1] 45 | full_command = append_footer(cmd, marker) 46 | 47 | proc = self._remote["bash"]["-c", full_command].popen() 48 | self._update_interrupt_function(proc) 49 | 50 | tuple_iterator = proc.iter_lines() 51 | env_info = process_output(tuple_iterator, marker, print_function) 52 | 53 | if env_info: 54 | return self.post_exec_command(env_info) 55 | else: 56 | return 1 57 | 58 | def connect(self, host): 59 | if self._remote: 60 | self.close() 61 | 62 | remote = self._build_remote(host) 63 | 64 | envdelta = {**self.envdelta_init, "PAGER": "cat"} 65 | remote.env.update(envdelta) 66 | 67 | self._remote = remote 68 | self.__connected = True 69 | self._host = host 70 | 71 | def _build_remote(self, host): 72 | (hostname, plumbum_kwargs, forward_agent) = load_ssh_config_for_plumbum( 73 | "~/.ssh/config", host 74 | ) 75 | 76 | print( 77 | "[ssh] host={host} hostname={hostname} other_conf={other_conf}".format( 78 | host=host, hostname=hostname, other_conf=plumbum_kwargs 79 | ) 80 | ) 81 | 82 | remote = ParamikoMachine(hostname, password=None, **plumbum_kwargs) 83 | 84 | if forward_agent == "yes": 85 | print("[ssh] forwarding local agent") 86 | enable_agent_forwarding(remote._client) 87 | 88 | return remote 89 | 90 | def close(self): 91 | self.__connected = False 92 | 93 | if self._remote: 94 | self._remote.close() 95 | 96 | def interrupt(self): 97 | self.interrupt_function() 98 | 99 | def isconnected(self): 100 | return self.__connected 101 | 102 | # private methods 103 | def _update_interrupt_function(self, proc): 104 | def to_interrupt(): 105 | proc.close() 106 | 107 | self.interrupt_function = to_interrupt 108 | 109 | def post_exec_command(self, env_out): 110 | """Receive yaml string, update instance state with its value 111 | 112 | Return: 113 | int: exit_code 114 | """ 115 | env_at_footer = yaml.safe_load(env_out) 116 | 117 | newdir = env_at_footer["pwd"] 118 | newenv = env_at_footer["env"] 119 | self.update_workdir(newdir) 120 | self.update_env(newenv) 121 | 122 | if "code" in env_at_footer: 123 | return env_at_footer["code"] 124 | else: 125 | print("[ssh] Error: Cannot parse exit_code. As a result, returing code=1") 126 | return 1 127 | 128 | def update_workdir(self, newdir): 129 | cwd = self.get_cwd() 130 | if newdir != cwd: 131 | self._remote.cwd.chdir(newdir) 132 | print("[ssh] new cwd: {}".format(newdir)) 133 | 134 | def get_cwd(self): 135 | return self._remote.cwd.getpath()._path 136 | 137 | def update_env(self, newenv): 138 | delimiter = "^@" 139 | reject_env_variables = ["SSH_CLIENT", "SSH_CONNECTION"] 140 | 141 | parsed_newenv = dict([kv.split("=", 1) for kv in newenv.split(delimiter) if kv]) 142 | parsed_newenv = { 143 | k: v for k, v in parsed_newenv.items() if k not in reject_env_variables 144 | } 145 | 146 | self._remote.env.update(parsed_newenv) 147 | 148 | 149 | def append_footer(cmd, marker): 150 | """ 151 | Append header/footer to `cmd`. 152 | 153 | Returns: 154 | str: new_command 155 | """ 156 | header = "" 157 | footer = """ 158 | EXIT_CODE=$? 159 | echo {marker}code: ${{EXIT_CODE}}{marker} 160 | echo {marker}pwd: $(pwd){marker} 161 | echo {marker}env: $(cat -v <(env -0)){marker} 162 | """.format( 163 | marker=marker 164 | ) 165 | 166 | full_command = "\n".join([header, cmd, footer]) 167 | 168 | return full_command 169 | 170 | 171 | def merge_stdout_stderr(iterator): 172 | """Merge two iterators returned by Popen.communicate() 173 | 174 | Args: 175 | iterator: yields (string, string), either one of two string is None 176 | 177 | Returns: 178 | iterator: yields string 179 | """ 180 | 181 | for (stdout, stderr) in iterator: 182 | if stdout: 183 | yield stdout 184 | else: 185 | yield stderr 186 | 187 | 188 | def process_output(tuple_iterator, marker, print_function): 189 | """Process iterator which is return of Popen.communicate() 190 | 191 | For normal lines, call callback print-fn. 192 | 193 | Args: 194 | tuple_iterator: yields tuple (string, string) 195 | print_function: callback fn 196 | 197 | Returns: 198 | string: footer string (YAML format) 199 | """ 200 | 201 | iterator = merge_stdout_stderr(tuple_iterator) 202 | 203 | env_out = "" 204 | for line in iterator: 205 | 206 | if line.endswith(marker + "\n"): 207 | 208 | if not line.startswith(marker): 209 | # The `line` contains 2 markers and trailing newline 210 | # e.g "123MARKcode: 0MARK\n".split(marker) => ("123", "code: 0", "\n") 211 | line1, line2, _ = line.split(marker) 212 | print_function(line1) 213 | line = line2 214 | 215 | env_out += line.replace(marker, "").rstrip() 216 | env_out += "\n" 217 | 218 | else: 219 | print_function(line) 220 | 221 | return env_out 222 | 223 | 224 | def enable_agent_forwarding(paramiko_sshclient): 225 | # SSH Agent Forwarding in Paramiko 226 | # http://docs.paramiko.org/en/stable/api/agent.html#paramiko.agent.AgentRequestHandler 227 | sess = paramiko_sshclient.get_transport().open_session() 228 | paramiko.agent.AgentRequestHandler(sess) 229 | 230 | 231 | def load_ssh_config_for_plumbum(filename, host): 232 | """Parse and postprocess ssh_config 233 | and rename some keys for plumbum.ParamikoMachine.__init__() 234 | """ 235 | 236 | conf = paramiko.config.SSHConfig() 237 | expanded_path = os.path.expanduser(filename) 238 | 239 | username_from_host = None 240 | m = re.search("([^@]+)@(.*)", host) 241 | if m: 242 | username_from_host = m.group(1) 243 | host = m.group(2) 244 | 245 | if os.path.exists(expanded_path): 246 | with open(expanded_path) as ssh_config: 247 | conf.parse(ssh_config) 248 | 249 | lookup = conf.lookup(host) 250 | 251 | plumbum_kwargs = dict( 252 | user=username_from_host, 253 | port=None, 254 | keyfile=None, 255 | load_system_ssh_config=False, 256 | # TODO: Drop WarningPolicy 257 | # This is need in current plumbum and wrapper implementation 258 | # in case proxycommand is set. 259 | missing_host_policy=paramiko.WarningPolicy(), 260 | ) 261 | 262 | plumbum_host = host 263 | if "hostname" in lookup: 264 | plumbum_host = lookup.get("hostname") 265 | 266 | if "port" in lookup: 267 | plumbum_kwargs["port"] = int(lookup["port"]) 268 | 269 | if not username_from_host: 270 | plumbum_kwargs["user"] = lookup.get("user") 271 | 272 | plumbum_kwargs["keyfile"] = lookup.get("identityfile") 273 | 274 | if "proxycommand" in lookup: 275 | plumbum_kwargs["load_system_ssh_config"] = True 276 | # load_system_ssh_config: read system SSH config for ProxyCommand configuration. 277 | # https://plumbum.readthedocs.io/en/latest/_modules/plumbum/machines/paramiko_machine.html 278 | 279 | if lookup.get("hostname") != host: 280 | msg = ( 281 | "can't handle both ProxyCommand and HostName at once, " 282 | "please drop either" 283 | ) 284 | raise ValueError(msg) 285 | plumbum_host = host 286 | # When load_system_ssh_config is True, plumbum_host must be Host 287 | # instead of HostName. 288 | # Otherwise parsing SSH config will fail in Plumbum. 289 | 290 | # Plumbum doesn't support agent-forwarding 291 | forward_agent = lookup.get("forwardagent") 292 | 293 | return (plumbum_host, plumbum_kwargs, forward_agent) 294 | -------------------------------------------------------------------------------- /examples/getting-started.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Getting Started" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "## Before you begin\n", 15 | "\n", 16 | "Confirm you can connect to the target server via ssh without host verification not password prompt.\n", 17 | "\n", 18 | "Example:\n", 19 | "\n", 20 | "```\n", 21 | "$ ssh whoami\n", 22 | "root # without any prompt\n", 23 | "```" 24 | ] 25 | }, 26 | { 27 | "cell_type": "markdown", 28 | "metadata": { 29 | "ExecuteTime": { 30 | "end_time": "2019-02-08T02:46:45.752155Z", 31 | "start_time": "2019-02-08T02:46:45.742587Z" 32 | } 33 | }, 34 | "source": [ 35 | "### OpenSSH configuration\n", 36 | "\n", 37 | "SSH Kernel read OpenSSH configuration from `~/.ssh/config` file to connect servers.\n", 38 | "\n", 39 | "Possible keywords are as follows:\n", 40 | "\n", 41 | "* HostName\n", 42 | "* User\n", 43 | "* Port\n", 44 | "* IdentityFile\n", 45 | " * If you use a ed25519 key, please generate with or convert into old PEM format \n", 46 | " * e.g. `ssh-keygen -m PEM -t ed25519 ...`" 47 | ] 48 | }, 49 | { 50 | "cell_type": "code", 51 | "execution_count": 1, 52 | "metadata": { 53 | "ExecuteTime": { 54 | "end_time": "2019-02-08T03:12:21.144013Z", 55 | "start_time": "2019-02-08T03:12:20.972637Z" 56 | } 57 | }, 58 | "outputs": [ 59 | { 60 | "name": "stdout", 61 | "output_type": "stream", 62 | "text": [ 63 | "Host test\n", 64 | " Hostname localhost\n", 65 | " User root\n", 66 | " Port 10022\n", 67 | " IdentityFile /tmp/id_rsa_test\n", 68 | "\n" 69 | ] 70 | } 71 | ], 72 | "source": [ 73 | "! head -n6 ~/.ssh/config" 74 | ] 75 | }, 76 | { 77 | "cell_type": "markdown", 78 | "metadata": {}, 79 | "source": [ 80 | "### Host key" 81 | ] 82 | }, 83 | { 84 | "cell_type": "markdown", 85 | "metadata": {}, 86 | "source": [ 87 | "SSH Kernel read a file of known SSH host keys, in the format used by OpenSSH." 88 | ] 89 | }, 90 | { 91 | "cell_type": "code", 92 | "execution_count": 2, 93 | "metadata": { 94 | "ExecuteTime": { 95 | "end_time": "2019-02-08T03:12:21.318479Z", 96 | "start_time": "2019-02-08T03:12:21.152347Z" 97 | } 98 | }, 99 | "outputs": [ 100 | { 101 | "name": "stdout", 102 | "output_type": "stream", 103 | "text": [ 104 | "\u001b[01;31m\u001b[Klocalhost\u001b[m\u001b[K,::1 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDvUcAPq8CMwZJt3f5In+zzoEo2MiT+t8xO8y61VscBhrOq1iX9okHmlrMzcHYrNQV6FYllQ7beRKN3pSgXow4wXK67MFMvMyMink8KcagyjpDNrvnM2eO0dWA+cd2PbGLZDn4L5xUDRs0jbhpuDaPADuyhHzQ9l3IqVxPZFCes2UdaBzouzqYNrWvAUiPkbDFE6q0eRLO6A+5/ecgOd01Ybh+SnK3aGqLUcC0KPdKGfvyWRZW3ga5FFz0MM5oP6OrCZ78QpeNDCMeHWGdG2kBD+HeXE8r0Ge1LS2L0tOMoV17cyQuc0rAwQJzQ9oQPx/k3sAULloIgDVLFLTuH2oLn\n" 105 | ] 106 | } 107 | ], 108 | "source": [ 109 | "! grep localhost ~/.ssh/known_hosts" 110 | ] 111 | }, 112 | { 113 | "cell_type": "markdown", 114 | "metadata": {}, 115 | "source": [ 116 | "## Execute remote commands in Notebook\n", 117 | "\n", 118 | "* Directives\n", 119 | " * `%login ` to connect and start SSH session\n", 120 | " * `%logout` to exit session\n", 121 | "* Commands between `%login` and `%logout` are executed remotely" 122 | ] 123 | }, 124 | { 125 | "cell_type": "code", 126 | "execution_count": 3, 127 | "metadata": { 128 | "ExecuteTime": { 129 | "end_time": "2019-02-08T03:12:21.553772Z", 130 | "start_time": "2019-02-08T03:12:21.323346Z" 131 | } 132 | }, 133 | "outputs": [ 134 | { 135 | "name": "stdout", 136 | "output_type": "stream", 137 | "text": [ 138 | "[ssh] Login to test...\n", 139 | "[ssh] host=test hostname=localhost other_conf={'keyfile': ['/tmp/id_rsa_test'], 'port': 10022, 'user': 'root'}\n", 140 | "[ssh] Successfully logged in.\n" 141 | ] 142 | } 143 | ], 144 | "source": [ 145 | "%login test" 146 | ] 147 | }, 148 | { 149 | "cell_type": "code", 150 | "execution_count": 4, 151 | "metadata": { 152 | "ExecuteTime": { 153 | "end_time": "2019-02-08T03:12:21.641678Z", 154 | "start_time": "2019-02-08T03:12:21.563441Z" 155 | } 156 | }, 157 | "outputs": [ 158 | { 159 | "name": "stdout", 160 | "output_type": "stream", 161 | "text": [ 162 | "[ssh] host = test, cwd = /root\n", 163 | "/root\n", 164 | "\n" 165 | ] 166 | } 167 | ], 168 | "source": [ 169 | "pwd" 170 | ] 171 | }, 172 | { 173 | "cell_type": "code", 174 | "execution_count": 5, 175 | "metadata": { 176 | "ExecuteTime": { 177 | "end_time": "2019-02-08T03:12:21.729051Z", 178 | "start_time": "2019-02-08T03:12:21.647212Z" 179 | } 180 | }, 181 | "outputs": [ 182 | { 183 | "name": "stdout", 184 | "output_type": "stream", 185 | "text": [ 186 | "[ssh] host = test, cwd = /root\n", 187 | "\n", 188 | "[ssh] new cwd: /\n" 189 | ] 190 | } 191 | ], 192 | "source": [ 193 | "cd /" 194 | ] 195 | }, 196 | { 197 | "cell_type": "code", 198 | "execution_count": 6, 199 | "metadata": { 200 | "ExecuteTime": { 201 | "end_time": "2019-02-08T03:12:21.811839Z", 202 | "start_time": "2019-02-08T03:12:21.735080Z" 203 | } 204 | }, 205 | "outputs": [ 206 | { 207 | "name": "stdout", 208 | "output_type": "stream", 209 | "text": [ 210 | "[ssh] host = test, cwd = /\n", 211 | "total 72K\n", 212 | "drwxr-xr-x 2 root root 4.0K Apr 24 2018 home\n", 213 | "drwxr-xr-x 2 root root 4.0K Apr 24 2018 boot\n", 214 | "drwxr-xr-x 2 root root 4.0K Apr 26 2018 srv\n", 215 | "drwxr-xr-x 2 root root 4.0K Apr 26 2018 opt\n", 216 | "drwxr-xr-x 2 root root 4.0K Apr 26 2018 mnt\n", 217 | "drwxr-xr-x 2 root root 4.0K Apr 26 2018 media\n", 218 | "drwxr-xr-x 2 root root 4.0K Apr 26 2018 lib64\n", 219 | "drwxr-xr-x 16 root root 4.0K May 20 2018 usr\n", 220 | "drwxr-xr-x 13 root root 4.0K May 20 2018 lib\n", 221 | "drwxr-xr-x 2 root root 4.0K May 20 2018 bin\n", 222 | "drwxr-xr-x 2 root root 4.0K May 20 2018 sbin\n", 223 | "drwxrwxrwt 2 root root 4.0K May 20 2018 tmp\n", 224 | "dr-xr-xr-x 13 root root 0 Nov 28 10:21 sys\n", 225 | "drwxr-xr-x 60 root root 4.0K Dec 6 00:58 etc\n", 226 | "-rwxr-xr-x 1 root root 0 Dec 6 00:58 .dockerenv\n", 227 | "dr-xr-xr-x 346 root root 0 Dec 6 00:58 proc\n", 228 | "drwxr-xr-x 5 root root 340 Dec 6 00:58 dev\n", 229 | "drwxr-xr-x 20 root root 4.0K Dec 6 00:59 var\n", 230 | "drwxr-xr-x 7 root root 4.0K Dec 6 00:59 run\n", 231 | "drwxr-xr-x 53 root root 4.0K Dec 6 01:00 ..\n", 232 | "drwxr-xr-x 53 root root 4.0K Dec 6 01:00 .\n", 233 | "drwx------ 4 root root 4.0K Jan 22 05:25 root\n", 234 | "\n" 235 | ] 236 | } 237 | ], 238 | "source": [ 239 | "ls -lahrt" 240 | ] 241 | }, 242 | { 243 | "cell_type": "code", 244 | "execution_count": 7, 245 | "metadata": { 246 | "ExecuteTime": { 247 | "end_time": "2019-02-08T03:12:21.887107Z", 248 | "start_time": "2019-02-08T03:12:21.818482Z" 249 | } 250 | }, 251 | "outputs": [ 252 | { 253 | "name": "stdout", 254 | "output_type": "stream", 255 | "text": [ 256 | "[ssh] host = test, cwd = /\n", 257 | "41b562ffa1b3\n", 258 | "\n" 259 | ] 260 | } 261 | ], 262 | "source": [ 263 | "hostname" 264 | ] 265 | }, 266 | { 267 | "cell_type": "code", 268 | "execution_count": 8, 269 | "metadata": { 270 | "ExecuteTime": { 271 | "end_time": "2019-02-08T03:12:21.967949Z", 272 | "start_time": "2019-02-08T03:12:21.890119Z" 273 | } 274 | }, 275 | "outputs": [ 276 | { 277 | "name": "stdout", 278 | "output_type": "stream", 279 | "text": [ 280 | "[ssh] host = test, cwd = /\n", 281 | "USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\n", 282 | "root 1 0.0 0.0 72296 6284 ? Ss 2018 0:00 /usr/sbin/sshd -D\n", 283 | "root 3047 0.0 0.0 74656 6620 ? Ss 03:12 0:00 sshd: root@notty\n", 284 | "root 3049 0.0 0.0 18376 3088 ? Ss 03:12 0:00 -bash\n", 285 | "root 3053 0.0 0.0 13060 2012 ? Ss 03:12 0:00 /usr/lib/openssh/sftp-server\n", 286 | "root 3080 0.0 0.0 9920 1188 ? Ss 03:12 0:00 /bin/bash -c ps aux EXIT_CODE=$? echo echo 1549595541.891784code: $EXIT_CODE echo 1549595541.891784pwd: $(pwd) echo 1549595541.891784env: $(cat -v <(env -0)) \n", 287 | "root 3081 0.0 0.0 34400 2876 ? R 03:12 0:00 ps aux\n", 288 | "\n" 289 | ] 290 | } 291 | ], 292 | "source": [ 293 | "ps aux" 294 | ] 295 | }, 296 | { 297 | "cell_type": "code", 298 | "execution_count": 9, 299 | "metadata": { 300 | "ExecuteTime": { 301 | "end_time": "2019-02-08T03:12:22.026252Z", 302 | "start_time": "2019-02-08T03:12:21.971038Z" 303 | } 304 | }, 305 | "outputs": [ 306 | { 307 | "name": "stdout", 308 | "output_type": "stream", 309 | "text": [ 310 | "[ssh] Successfully logged out.\n" 311 | ] 312 | } 313 | ], 314 | "source": [ 315 | "%logout" 316 | ] 317 | } 318 | ], 319 | "metadata": { 320 | "kernelspec": { 321 | "display_name": "SSH", 322 | "language": "bash", 323 | "name": "ssh" 324 | }, 325 | "language_info": { 326 | "codemirror_mode": "shell", 327 | "file_extension": ".sh", 328 | "mimetype": "text/x-sh", 329 | "name": "ssh" 330 | }, 331 | "toc": { 332 | "base_numbering": 1, 333 | "nav_menu": {}, 334 | "number_sections": true, 335 | "sideBar": true, 336 | "skip_h1_title": false, 337 | "title_cell": "Table of Contents", 338 | "title_sidebar": "Contents", 339 | "toc_cell": false, 340 | "toc_position": {}, 341 | "toc_section_display": true, 342 | "toc_window_display": false 343 | } 344 | }, 345 | "nbformat": 4, 346 | "nbformat_minor": 2 347 | } 348 | -------------------------------------------------------------------------------- /tests/unit/test_ssh_wrapper_plumbum.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import socket 4 | import tempfile 5 | import unittest 6 | from textwrap import dedent 7 | from unittest.mock import Mock 8 | from unittest.mock import patch 9 | 10 | from plumbum.machines.paramiko_machine import ParamikoMachine 11 | 12 | from sshkernel.ssh_wrapper_plumbum import SSHWrapperPlumbum 13 | from sshkernel.ssh_wrapper_plumbum import append_footer 14 | from sshkernel.ssh_wrapper_plumbum import load_ssh_config_for_plumbum 15 | from sshkernel.ssh_wrapper_plumbum import merge_stdout_stderr 16 | from sshkernel.ssh_wrapper_plumbum import process_output 17 | 18 | 19 | class SSHWrapperPlumbumTest(unittest.TestCase): 20 | def setUp(self): 21 | def new_test_instance(): 22 | subscriptable = Mock() 23 | subscriptable.popen = Mock(return_value=subscriptable) 24 | subscriptable.__getitem__ = Mock(return_value=subscriptable) 25 | subscriptable.iter_lines = Mock( 26 | return_value=([("output", None), (None, "err")]) 27 | ) 28 | 29 | remote_double = Mock(spec=ParamikoMachine) 30 | remote_double.__getitem__ = Mock(return_value=subscriptable) 31 | 32 | instance = SSHWrapperPlumbum() 33 | instance._remote = remote_double 34 | 35 | return instance 36 | 37 | self.instance = new_test_instance() 38 | 39 | def test_connect_should_raise_socket_error(self): 40 | # FIXME: fix setUp() to pass the line below 41 | # self.assertIsNone(self.instance._remote) 42 | self.assertFalse(self.instance.isconnected()) 43 | 44 | with self.assertRaises(socket.gaierror): 45 | self.instance.connect("dummy") 46 | 47 | def test_connect_updates_attributes(self): 48 | remote_double = Mock() 49 | self.instance._build_remote = Mock(return_value=remote_double) 50 | dummy = "dummy" 51 | # self.assertIsNone(self.instance._remote) 52 | self.assertFalse(self.instance.isconnected()) 53 | self.assertEqual(self.instance._host, "") 54 | 55 | self.instance.connect(dummy) 56 | 57 | self.assertTrue(self.instance.isconnected()) 58 | self.assertEqual(self.instance._host, dummy) 59 | remote_double.env.update.assert_called() 60 | 61 | @unittest.skip("fix setUp") 62 | def test_exec_command_returns_error_at_first(self): 63 | with self.assertRaises(socket.gaierror): 64 | self.instance.exec_command("yo", lambda line: None) 65 | 66 | @unittest.skip 67 | def test_exec_command_returns_stream(self): 68 | self.instance.connect("myserver") 69 | print_mock = Mock() 70 | 71 | ret = self.instance.exec_command("yo", print_mock) 72 | 73 | def test_exec_command_return_exit_code(self): 74 | print_mock = Mock() 75 | 76 | code = self.instance.exec_command("ls", print_mock) 77 | 78 | self.assertIsInstance(code, int) 79 | 80 | def test_close_should_delegate(self): 81 | mock = Mock() 82 | self.instance._remote.close = mock 83 | self.instance.close() 84 | 85 | mock.assert_called_once() 86 | 87 | def test_append_footer(self): 88 | cmd = """ 89 | ls 90 | ls 91 | yo 92 | """ 93 | marker = "THISISMARKER" 94 | 95 | full_command = append_footer(cmd, marker) 96 | 97 | self.assertIsInstance(full_command, str) 98 | self.assertEqual(full_command.count(marker), 6) 99 | 100 | @unittest.skip("Fail to patch plumbum") 101 | @patch( 102 | "sshkernel.ssh_wrapper_plumbum.SSHWrapperPlumbum.get_cwd", return_value="/tmp" 103 | ) 104 | @patch( 105 | "plumbum.machines.paramiko_machine.ParamikoMachine.cwd.getpath._path", 106 | return_value="/home", 107 | ) 108 | def test_update_workdir(self, mock1, mock2): 109 | mock = Mock() # return_value='/') 110 | self.instance.get_cwd = mock 111 | self.instance._remote = mock 112 | newdir = "/some/where" 113 | 114 | self.instance.update_workdir(newdir) 115 | 116 | mock.assert_called_once() 117 | 118 | @unittest.skip("Fail to patch the instance created in setUp()") 119 | def test_post_exec(self, env_mock): 120 | self.instance._remote.env = env_mock 121 | env_out = """code: 255 122 | env: A=1^@B=2^@TOKEN=AAAA9B== 123 | pwd: /some/where 124 | """ 125 | 126 | code = self.instance.post_exec_command(env_out) 127 | self.assertEqual(255, code) 128 | 129 | @patch("sshkernel.ssh_wrapper_plumbum.SSHWrapperPlumbum") 130 | def test__update_interrupt_function(self, proc): 131 | fn_before = self.instance.interrupt_function 132 | self.instance._update_interrupt_function(proc) 133 | fn_after = self.instance.interrupt_function 134 | 135 | self.assertTrue(callable(fn_before)) 136 | self.assertTrue(callable(fn_after)) 137 | self.assertNotEqual(fn_before, fn_after) 138 | 139 | @patch("sshkernel.ssh_wrapper_plumbum.SSHWrapperPlumbum") 140 | def test__update_interrupt_function_inject_proc_to_closure(self, proc): 141 | self.instance._update_interrupt_function(proc) 142 | fn_after = self.instance.interrupt_function 143 | 144 | proc.close.assert_not_called() 145 | fn_after() 146 | proc.close.assert_called_once() 147 | 148 | 149 | class UtilityTest(unittest.TestCase): 150 | def test_merge_stdout_stderr(self): 151 | lines = [ 152 | ("a", None), 153 | ("b\n", None), 154 | (None, "c"), 155 | ("d", None), 156 | ] 157 | 158 | def outs(): 159 | for line in lines: 160 | yield line 161 | 162 | merged = merge_stdout_stderr(outs()) 163 | lines = list(merged) 164 | 165 | self.assertEqual(len(lines), 4) 166 | for line in lines: 167 | self.assertIsNotNone(line) 168 | 169 | def test_process_output_with_newline(self): 170 | marker = "MARKER" 171 | 172 | def gen_iterator(): 173 | lines = io.StringIO( 174 | """line1 175 | line2 176 | {marker}code: 0{marker} 177 | {marker}pwd: /tmp{marker} 178 | """.format( 179 | marker=marker 180 | ) 181 | ) 182 | for line in lines: 183 | yield (line, None) 184 | 185 | iterator = gen_iterator() 186 | print_function = Mock() 187 | 188 | env_info = process_output(iterator, marker, print_function) 189 | env_info_expected = "code: 0\npwd: /tmp\n" 190 | 191 | self.assertEqual(env_info, env_info_expected) 192 | print_function.assert_any_call("line1\n") 193 | print_function.assert_any_call("line2\n") 194 | 195 | def test_process_output_without_newline(self): 196 | marker = "MARKER" 197 | 198 | def gen_iterator(): 199 | lines = io.StringIO( 200 | """line1 201 | line2 202 | line3{marker}code: 0{marker} 203 | {marker}pwd: /tmp{marker} 204 | """.format( 205 | marker=marker 206 | ) 207 | ) 208 | for line in lines: 209 | yield (line, None) 210 | 211 | iterator = gen_iterator() 212 | print_function = Mock() 213 | env_info = process_output(iterator, marker, print_function) 214 | 215 | print_function.assert_any_call("line1\n") 216 | print_function.assert_any_call("line2\n") 217 | print_function.assert_any_call("line3") # without newline 218 | self.assertEqual(env_info, "code: 0\npwd: /tmp\n") 219 | 220 | def test_load_ssh_config_for_plumbum(self): 221 | skip = "skip_dict_equality_test" 222 | cases = [ 223 | dict( 224 | input=dedent( 225 | """ 226 | Host test 227 | HostName 127.0.0.10 228 | User testuser 229 | IdentityFile ~/.ssh/id_rsa_test 230 | """ 231 | ), 232 | expect=( 233 | "127.0.0.10", 234 | dict( 235 | port=None, 236 | user="testuser", 237 | keyfile=[os.path.expanduser("~/.ssh/id_rsa_test")], 238 | load_system_ssh_config=False, 239 | ), 240 | None, 241 | ), 242 | ), 243 | dict( 244 | input=dedent( 245 | """ 246 | Host test 247 | IdentityFile ~/.ssh/id_rsa_test 248 | """ 249 | ), 250 | expect=("test", skip, None), 251 | ), 252 | dict( 253 | input=dedent( 254 | """ 255 | Host test 256 | User admin 257 | Port 2222 258 | HostName 1.2.3.4 259 | IdentityFile ~/.ssh/id_rsa_test 260 | 261 | Host * 262 | ForwardAgent yes 263 | """ 264 | ), 265 | expect=("1.2.3.4", skip, "yes"), 266 | ), 267 | dict( 268 | input=dedent( 269 | """ 270 | Host test 271 | User admin 272 | Port 2222 273 | IdentityFile ~/.ssh/id_rsa_test 274 | ProxyCommand ssh -W %h:%p target 275 | """ 276 | ), 277 | expect=( 278 | "test", 279 | dict( 280 | user="admin", 281 | port=2222, 282 | keyfile=[os.path.expanduser("~/.ssh/id_rsa_test")], 283 | load_system_ssh_config=True, 284 | ), 285 | None, 286 | ), 287 | ), 288 | ] 289 | 290 | case_raises = dedent( 291 | """ 292 | Host test 293 | User user 294 | Hostname bastion 295 | ProxyCommand ssh -W %h:%p target 296 | """ 297 | ) 298 | 299 | for case in cases: 300 | input, expect = case["input"], case["expect"] 301 | (hostname, lookup, forward) = expect 302 | with tempfile.NamedTemporaryFile("w") as f: 303 | f.write(input) 304 | f.seek(0) 305 | host = "test" 306 | got = load_ssh_config_for_plumbum(f.name, host) 307 | 308 | self.assertEqual(hostname, got[0]) 309 | if lookup is not skip: 310 | # can't assert missin_host_policy equality 311 | lookup["missing_host_policy"] = got[1].get("missing_host_policy") 312 | self.assertDictEqual(lookup, got[1]) 313 | 314 | self.assertEqual(forward, got[2]) 315 | 316 | with tempfile.NamedTemporaryFile("w") as f: 317 | f.write(case_raises) 318 | f.seek(0) 319 | host = "test" 320 | with self.assertRaises(ValueError): 321 | _ = load_ssh_config_for_plumbum(f.name, host) 322 | -------------------------------------------------------------------------------- /examples/getting-started-ja.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Getting Started(日本語)\n", 8 | "\n", 9 | "## このノートブックについて\n", 10 | "\n", 11 | "### 目的\n", 12 | "\n", 13 | "SSH Kernelの利用に最低限必要な設定と、使い方を述べます。\n", 14 | "\n", 15 | "### ノートブックの構成\n", 16 | "\n", 17 | "* 説明用のSSHデーモンをDockerコンテナとして作成し、\n", 18 | " SSH Kernelの利用に必要な接続先ユーザ名やポートなどの設定・検証手順を述べます\n", 19 | "* SSH Kernel独自の記法を述べ、リモートコマンド実行する手順を述べます\n", 20 | "\n", 21 | "### ノートブック作成環境\n", 22 | "\n", 23 | "* OS: Ubuntu 16.04\n", 24 | "* Docker: 18.06.1-ce\n", 25 | "* Python: 3.6.2\n", 26 | "* Jupyter notebook: 5.7.0\n", 27 | "* SSH Kernel: 0.6.1" 28 | ] 29 | }, 30 | { 31 | "cell_type": "markdown", 32 | "metadata": { 33 | "ExecuteTime": { 34 | "end_time": "2018-12-06T01:08:29.786270Z", 35 | "start_time": "2018-12-06T01:08:29.779027Z" 36 | } 37 | }, 38 | "source": [ 39 | "## 説明用SSH接続先デーモンの起動\n", 40 | "\n", 41 | "説明用のsshdコンテナを作成\n", 42 | "\n", 43 | "* host: localhost\n", 44 | "* port: 10022\n", 45 | "* login: root\n", 46 | "* password: root\n", 47 | "\n", 48 | "注:localhost以外のホストで実行する場合や、ポート番号を変更する場合は、次章の説明を読み替えてください" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": 11, 54 | "metadata": { 55 | "ExecuteTime": { 56 | "end_time": "2018-12-06T01:08:44.877006Z", 57 | "start_time": "2018-12-06T01:08:41.777447Z" 58 | } 59 | }, 60 | "outputs": [ 61 | { 62 | "name": "stdout", 63 | "output_type": "stream", 64 | "text": [ 65 | "18.04: Pulling from rastasheep/ubuntu-sshd\n", 66 | "Digest: sha256:1df808ccf95c13b8e62548ad434829bc28ee701a65624e5c0440fc24482e4a62\n", 67 | "Status: Image is up to date for rastasheep/ubuntu-sshd:18.04\n" 68 | ] 69 | } 70 | ], 71 | "source": [ 72 | "! docker pull rastasheep/ubuntu-sshd:18.04" 73 | ] 74 | }, 75 | { 76 | "cell_type": "code", 77 | "execution_count": 8, 78 | "metadata": { 79 | "ExecuteTime": { 80 | "end_time": "2018-12-06T00:58:42.266620Z", 81 | "start_time": "2018-12-06T00:58:41.550025Z" 82 | } 83 | }, 84 | "outputs": [ 85 | { 86 | "name": "stdout", 87 | "output_type": "stream", 88 | "text": [ 89 | "41b562ffa1b35b2aea62255d7e5645bc4bb0afd7a725c06cccf3c52dcafcb1e5\r\n" 90 | ] 91 | } 92 | ], 93 | "source": [ 94 | "!docker run -d --rm -p 10022:22 rastasheep/ubuntu-sshd:18.04" 95 | ] 96 | }, 97 | { 98 | "cell_type": "code", 99 | "execution_count": 11, 100 | "metadata": { 101 | "ExecuteTime": { 102 | "end_time": "2018-12-06T00:59:05.200178Z", 103 | "start_time": "2018-12-06T00:59:05.000565Z" 104 | } 105 | }, 106 | "outputs": [ 107 | { 108 | "name": "stdout", 109 | "output_type": "stream", 110 | "text": [ 111 | "41b562ffa1b3 rastasheep/ubuntu-sshd:18.04 \"/usr/sbin/sshd -D\" 24 seconds ago Up 22 seconds 0.0.0.0:10022->22/tcp lucid_roentgen\r\n" 112 | ] 113 | } 114 | ], 115 | "source": [ 116 | "!docker ps |grep ubuntu-sshd" 117 | ] 118 | }, 119 | { 120 | "cell_type": "markdown", 121 | "metadata": {}, 122 | "source": [ 123 | "## SSHの設定" 124 | ] 125 | }, 126 | { 127 | "cell_type": "markdown", 128 | "metadata": { 129 | "ExecuteTime": { 130 | "end_time": "2018-12-06T02:13:31.821040Z", 131 | "start_time": "2018-12-06T02:13:31.814176Z" 132 | } 133 | }, 134 | "source": [ 135 | "### 接続先ユーザ名・ポート・公開鍵などの設定と確認" 136 | ] 137 | }, 138 | { 139 | "cell_type": "markdown", 140 | "metadata": {}, 141 | "source": [ 142 | "#### 設定ファイルの作成\n", 143 | "\n", 144 | "ターミナルでの作業:\n", 145 | "\n", 146 | "* 次の内容の `~/.ssh/config` ファイルを作成します" 147 | ] 148 | }, 149 | { 150 | "cell_type": "code", 151 | "execution_count": 6, 152 | "metadata": { 153 | "ExecuteTime": { 154 | "end_time": "2018-12-06T01:14:29.892837Z", 155 | "start_time": "2018-12-06T01:14:29.724071Z" 156 | } 157 | }, 158 | "outputs": [ 159 | { 160 | "name": "stdout", 161 | "output_type": "stream", 162 | "text": [ 163 | "Host test\n", 164 | " Hostname localhost\n", 165 | " User root\n", 166 | " Port 10022\n", 167 | " IdentityFile /tmp/id_rsa_test\n" 168 | ] 169 | } 170 | ], 171 | "source": [ 172 | "!head -n5 ~/.ssh/config" 173 | ] 174 | }, 175 | { 176 | "cell_type": "markdown", 177 | "metadata": {}, 178 | "source": [ 179 | "#### 公開鍵ペアの作成" 180 | ] 181 | }, 182 | { 183 | "cell_type": "markdown", 184 | "metadata": { 185 | "ExecuteTime": { 186 | "end_time": "2018-12-06T00:59:15.307416Z", 187 | "start_time": "2018-12-06T00:59:15.302808Z" 188 | } 189 | }, 190 | "source": [ 191 | "ターミナルでの作業:\n", 192 | "\n", 193 | "* パスフレーズ無し秘密鍵作成\n", 194 | "\n", 195 | "```\n", 196 | "ssh-keygen -f /tmp/id_rsa_test -t rsa -N ''\n", 197 | "\n", 198 | "```\n", 199 | "\n", 200 | "* ホスト鍵記憶、パスワードログインの検証\n", 201 | "\n", 202 | "```\n", 203 | "% ssh test\n", 204 | "The authenticity of host '[localhost]:10022 ([::1]:10022)' can't be established.\n", 205 | "ECDSA key fingerprint is SHA256:Rath9QRSP1hKeFkIGwL1c1WUV+haHdJrxTyilRrRNnE.\n", 206 | "Are you sure you want to continue connecting (yes/no)? yes\n", 207 | "Warning: Permanently added '[localhost]:10022' (ECDSA) to the list of known hosts.\n", 208 | "root@localhost's password: \n", 209 | " 初期パスワード'root'と入力\n", 210 | "root@41b562ffa1b3:~#\n", 211 | "root@41b562ffa1b3:~# exit\n", 212 | "logout\n", 213 | "Connection to localhost closed.\n", 214 | "```\n", 215 | "\n", 216 | "* 公開鍵の登録\n", 217 | "\n", 218 | "```\n", 219 | "% ssh-copy-id -i /tmp/id_rsa_test test\n", 220 | "/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: \"/tmp/id_rsa_test.pub\"\n", 221 | "/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed\n", 222 | "/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys\n", 223 | "root@localhost's password:\n", 224 | "\n", 225 | "Number of key(s) added: 1\n", 226 | "\n", 227 | "Now try logging into the machine, with: \"ssh 'test'\"\n", 228 | "and check to make sure that only the key(s) you wanted were added.\n", 229 | "```" 230 | ] 231 | }, 232 | { 233 | "cell_type": "markdown", 234 | "metadata": {}, 235 | "source": [ 236 | "### 設定の検証" 237 | ] 238 | }, 239 | { 240 | "cell_type": "markdown", 241 | "metadata": {}, 242 | "source": [ 243 | "* 次のセルの結果が uid=0(root) gid=0(root) groups=0(root) であればOK" 244 | ] 245 | }, 246 | { 247 | "cell_type": "code", 248 | "execution_count": 7, 249 | "metadata": { 250 | "ExecuteTime": { 251 | "end_time": "2018-12-06T01:15:10.090387Z", 252 | "start_time": "2018-12-06T01:15:09.794085Z" 253 | } 254 | }, 255 | "outputs": [ 256 | { 257 | "name": "stdout", 258 | "output_type": "stream", 259 | "text": [ 260 | "uid=0(root) gid=0(root) groups=0(root)\n" 261 | ] 262 | } 263 | ], 264 | "source": [ 265 | "!ssh test id" 266 | ] 267 | }, 268 | { 269 | "cell_type": "markdown", 270 | "metadata": {}, 271 | "source": [ 272 | "## SSH Kernelの使い方" 273 | ] 274 | }, 275 | { 276 | "cell_type": "markdown", 277 | "metadata": { 278 | "ExecuteTime": { 279 | "end_time": "2018-12-06T02:05:00.511032Z", 280 | "start_time": "2018-12-06T02:05:00.505163Z" 281 | } 282 | }, 283 | "source": [ 284 | "### 概要\n", 285 | "\n", 286 | "SSH Kernel独自の記法 `%login {host}` 以降のセルを、リモートで実行します。\n", 287 | "\n", 288 | "リモート実行を終了するには `%logout`を実行します" 289 | ] 290 | }, 291 | { 292 | "cell_type": "code", 293 | "execution_count": 8, 294 | "metadata": { 295 | "ExecuteTime": { 296 | "end_time": "2018-12-06T01:15:12.158571Z", 297 | "start_time": "2018-12-06T01:15:11.956774Z" 298 | } 299 | }, 300 | "outputs": [ 301 | { 302 | "name": "stdout", 303 | "output_type": "stream", 304 | "text": [ 305 | "[ssh] Login to test...\n", 306 | "[DEBUG] host=test hostname=localhost other_conf={'user': 'root', 'port': 10022, 'keyfile': ['/tmp/id_rsa_test']}\n", 307 | "[ssh] Successfully logged in.\n" 308 | ] 309 | } 310 | ], 311 | "source": [ 312 | "%login test" 313 | ] 314 | }, 315 | { 316 | "cell_type": "code", 317 | "execution_count": 9, 318 | "metadata": { 319 | "ExecuteTime": { 320 | "end_time": "2018-12-06T01:15:17.854995Z", 321 | "start_time": "2018-12-06T01:15:17.756438Z" 322 | } 323 | }, 324 | "outputs": [ 325 | { 326 | "name": "stdout", 327 | "output_type": "stream", 328 | "text": [ 329 | "[INFO] host = localhost\n", 330 | "uid=0(root) gid=0(root) groups=0(root)\n" 331 | ] 332 | } 333 | ], 334 | "source": [ 335 | "id" 336 | ] 337 | }, 338 | { 339 | "cell_type": "code", 340 | "execution_count": 10, 341 | "metadata": { 342 | "ExecuteTime": { 343 | "end_time": "2018-12-06T01:48:50.646792Z", 344 | "start_time": "2018-12-06T01:48:50.589277Z" 345 | } 346 | }, 347 | "outputs": [ 348 | { 349 | "name": "stdout", 350 | "output_type": "stream", 351 | "text": [ 352 | "[ssh] Successfully logged out.\n" 353 | ] 354 | } 355 | ], 356 | "source": [ 357 | "%logout" 358 | ] 359 | }, 360 | { 361 | "cell_type": "markdown", 362 | "metadata": { 363 | "ExecuteTime": { 364 | "end_time": "2018-12-06T01:59:53.013743Z", 365 | "start_time": "2018-12-06T01:59:53.008178Z" 366 | } 367 | }, 368 | "source": [ 369 | "### 独自の記法\n", 370 | "\n", 371 | "#### %login記法\n", 372 | "\n", 373 | "`%login {host}` は、対象ホストとのSSHコネクションを生成する、SSH Kernel独自の記法です。\n", 374 | "先に作成した`~/.ssh/config`の設定が有効です。\n", 375 | "\n", 376 | "コネクション生成に成功すると成功メッセージが表示され、失敗するとエラーメッセージが表示されます。\n", 377 | "\n", 378 | "#### %logout記法\n", 379 | "\n", 380 | "`%logout` は、生成したコネクションを切断する、独自の記法です。" 381 | ] 382 | }, 383 | { 384 | "cell_type": "markdown", 385 | "metadata": {}, 386 | "source": [ 387 | "#### エラー例\n", 388 | "\n", 389 | "%login前、もしくは、%logout後、に !をつけずにセル実行するとエラーメッセージが表示されます" 390 | ] 391 | }, 392 | { 393 | "cell_type": "code", 394 | "execution_count": 12, 395 | "metadata": { 396 | "ExecuteTime": { 397 | "end_time": "2018-12-06T01:53:14.666256Z", 398 | "start_time": "2018-12-06T01:53:14.658352Z" 399 | } 400 | }, 401 | "outputs": [ 402 | { 403 | "name": "stderr", 404 | "output_type": "stream", 405 | "text": [ 406 | "\u001b[0;31m[ssh] Not connected\n", 407 | "\u001b[0m\u001b[0;31mTraceback (most recent call last):\n", 408 | " File \"/home/masaru/go/src/gitlab.com/m-ueno/ssh_kernel/ssh_kernel/kernel.py\", line 81, in do_execute_direct\n", 409 | " self.assert_connected()\n", 410 | " File \"/home/masaru/go/src/gitlab.com/m-ueno/ssh_kernel/ssh_kernel/kernel.py\", line 179, in assert_connected\n", 411 | " raise SSHKernelNotConnectedException\n", 412 | "ssh_kernel.exception.SSHKernelNotConnectedException\n", 413 | "\n", 414 | "\u001b[0m" 415 | ] 416 | }, 417 | { 418 | "ename": "abort", 419 | "evalue": "not connected", 420 | "output_type": "error", 421 | "traceback": [] 422 | } 423 | ], 424 | "source": [ 425 | "id" 426 | ] 427 | }, 428 | { 429 | "cell_type": "markdown", 430 | "metadata": {}, 431 | "source": [ 432 | "%login時に名前解決に失敗するとエラーメッセージが表示されます" 433 | ] 434 | }, 435 | { 436 | "cell_type": "code", 437 | "execution_count": 18, 438 | "metadata": { 439 | "ExecuteTime": { 440 | "end_time": "2018-12-06T02:20:48.375980Z", 441 | "start_time": "2018-12-06T02:20:48.231109Z" 442 | } 443 | }, 444 | "outputs": [ 445 | { 446 | "name": "stdout", 447 | "output_type": "stream", 448 | "text": [ 449 | "[ssh] Login to notfound...\n", 450 | "[DEBUG] host=notfound hostname=notfound other_conf={}\n" 451 | ] 452 | }, 453 | { 454 | "name": "stderr", 455 | "output_type": "stream", 456 | "text": [ 457 | "\u001b[0;31m[ssh] Login to notfound failed.\n", 458 | "\u001b[0m\u001b[0;31mError in calling magic 'login' on line:\n", 459 | " [Errno -5] No address associated with hostname\n", 460 | " args: ['notfound']\n", 461 | " kwargs: {}\n", 462 | "\u001b[0m\u001b[0;31mTraceback (most recent call last):\n", 463 | " File \"/home/masaru/go/src/gitlab.com/m-ueno/ssh_kernel/env/lib/python3.6/site-packages/metakernel/magic.py\", line 82, in call_magic\n", 464 | " func(*args, **kwargs)\n", 465 | " File \"/home/masaru/go/src/gitlab.com/m-ueno/ssh_kernel/ssh_kernel/magics.py\", line 28, in line_login\n", 466 | " raise e\n", 467 | " File \"/home/masaru/go/src/gitlab.com/m-ueno/ssh_kernel/ssh_kernel/magics.py\", line 25, in line_login\n", 468 | " self.kernel.sshwrapper.connect(host)\n", 469 | " File \"/home/masaru/go/src/gitlab.com/m-ueno/ssh_kernel/ssh_kernel/ssh_wrapper_plumbum.py\", line 88, in connect\n", 470 | " File \"/home/masaru/go/src/gitlab.com/m-ueno/ssh_kernel/env/lib/python3.6/site-packages/plumbum/machines/paramiko_machine.py\", line 261, in __init__\n", 471 | " self._client.connect(host, **kwargs)\n", 472 | " File \"/home/masaru/go/src/gitlab.com/m-ueno/ssh_kernel/env/lib/python3.6/site-packages/paramiko/client.py\", line 334, in connect\n", 473 | " to_try = list(self._families_and_addresses(hostname, port))\n", 474 | " File \"/home/masaru/go/src/gitlab.com/m-ueno/ssh_kernel/env/lib/python3.6/site-packages/paramiko/client.py\", line 204, in _families_and_addresses\n", 475 | " hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM\n", 476 | " File \"/home/masaru/.asdf/installs/python/3.6.2/lib/python3.6/socket.py\", line 743, in getaddrinfo\n", 477 | " for res in _socket.getaddrinfo(host, port, family, type, proto, flags):\n", 478 | "socket.gaierror: [Errno -5] No address associated with hostname\n", 479 | "\n", 480 | "\u001b[0m\u001b[0;31m%login HOST\n", 481 | "\n", 482 | "SSH login to the remote host.\n", 483 | "Cells below this line will be executed remotely.\n", 484 | "\n", 485 | "Example:\n", 486 | " [~/.ssh/config]\n", 487 | " Host myserver\n", 488 | " Hostname 10.0.0.10\n", 489 | " Port 2222\n", 490 | "\n", 491 | " %login myserver\n", 492 | "\u001b[0m" 493 | ] 494 | } 495 | ], 496 | "source": [ 497 | "%login notfound" 498 | ] 499 | }, 500 | { 501 | "cell_type": "markdown", 502 | "metadata": {}, 503 | "source": [ 504 | "#### %param記法\n", 505 | "\n", 506 | "`%param {変数名} {変数値}` はノートブック上で使えるパラメータを宣言します。\n", 507 | "\n", 508 | "詳細は [parameterized-notebook-ja](./parameterized-notebook-ja.ipynb) を参照して下さい。" 509 | ] 510 | }, 511 | { 512 | "cell_type": "markdown", 513 | "metadata": {}, 514 | "source": [ 515 | "### その他\n", 516 | "\n", 517 | "実装の仕組み上使えるものの未検証であったり主眼でない機能について触れます。" 518 | ] 519 | }, 520 | { 521 | "cell_type": "markdown", 522 | "metadata": { 523 | "ExecuteTime": { 524 | "end_time": "2018-12-06T02:06:05.528490Z", 525 | "start_time": "2018-12-06T02:06:05.522175Z" 526 | } 527 | }, 528 | "source": [ 529 | "#### ! 記法\n", 530 | "\n", 531 | "非推奨\n", 532 | "\n", 533 | "`!` で始まるセル内容は、ローカルシェルで実行されます。\n", 534 | "\n", 535 | "* 注\n", 536 | " * この記法は、コマンドが異常終了したとしても正常終了になってしまうため、利用を推奨しません。コマンドの異常終了を検知したい場合には`%login localhost`実行後にコマンドを入力してください\n", 537 | " * この記法は、IPythonやipykernelの記法に似たものですが、IPythonやipykernelのように行内に書くことはできません" 538 | ] 539 | }, 540 | { 541 | "cell_type": "code", 542 | "execution_count": 13, 543 | "metadata": { 544 | "ExecuteTime": { 545 | "end_time": "2018-12-06T01:53:18.487470Z", 546 | "start_time": "2018-12-06T01:53:18.317517Z" 547 | } 548 | }, 549 | "outputs": [ 550 | { 551 | "name": "stdout", 552 | "output_type": "stream", 553 | "text": [ 554 | "uid=1000(masaru) gid=1000(masaru) groups=1000(masaru),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),110(lxd),115(lpadmin),116(sambashare),121(libvirtd),999(docker)\n" 555 | ] 556 | } 557 | ], 558 | "source": [ 559 | "!id" 560 | ] 561 | }, 562 | { 563 | "cell_type": "markdown", 564 | "metadata": {}, 565 | "source": [ 566 | "#### %python記法" 567 | ] 568 | }, 569 | { 570 | "cell_type": "code", 571 | "execution_count": 27, 572 | "metadata": { 573 | "ExecuteTime": { 574 | "end_time": "2018-12-06T02:36:19.899777Z", 575 | "start_time": "2018-12-06T02:36:19.894556Z" 576 | } 577 | }, 578 | "outputs": [], 579 | "source": [ 580 | "%python x = 1+1" 581 | ] 582 | }, 583 | { 584 | "cell_type": "code", 585 | "execution_count": 28, 586 | "metadata": { 587 | "ExecuteTime": { 588 | "end_time": "2018-12-06T02:36:20.374683Z", 589 | "start_time": "2018-12-06T02:36:20.369185Z" 590 | } 591 | }, 592 | "outputs": [ 593 | { 594 | "data": { 595 | "text/plain": [ 596 | "2" 597 | ] 598 | }, 599 | "execution_count": 28, 600 | "metadata": {}, 601 | "output_type": "execute_result" 602 | } 603 | ], 604 | "source": [ 605 | "%python x" 606 | ] 607 | }, 608 | { 609 | "cell_type": "code", 610 | "execution_count": 32, 611 | "metadata": { 612 | "ExecuteTime": { 613 | "end_time": "2018-12-06T02:37:44.201456Z", 614 | "start_time": "2018-12-06T02:37:44.194121Z" 615 | } 616 | }, 617 | "outputs": [ 618 | { 619 | "name": "stdout", 620 | "output_type": "stream", 621 | "text": [ 622 | "x is 4\n" 623 | ] 624 | } 625 | ], 626 | "source": [ 627 | "%%python\n", 628 | "\n", 629 | "x *= 2\n", 630 | "print('x is ' + str(x))" 631 | ] 632 | }, 633 | { 634 | "cell_type": "markdown", 635 | "metadata": {}, 636 | "source": [ 637 | "#### %help\n", 638 | "\n", 639 | "実行すると、オンラインヘルプがポップアップ表示されます." 640 | ] 641 | }, 642 | { 643 | "cell_type": "code", 644 | "execution_count": 30, 645 | "metadata": { 646 | "ExecuteTime": { 647 | "end_time": "2018-12-06T02:36:45.829363Z", 648 | "start_time": "2018-12-06T02:36:45.824993Z" 649 | } 650 | }, 651 | "outputs": [], 652 | "source": [ 653 | "%help" 654 | ] 655 | }, 656 | { 657 | "cell_type": "code", 658 | "execution_count": 31, 659 | "metadata": { 660 | "ExecuteTime": { 661 | "end_time": "2018-12-06T02:36:52.346193Z", 662 | "start_time": "2018-12-06T02:36:52.339609Z" 663 | } 664 | }, 665 | "outputs": [], 666 | "source": [ 667 | "%help %login" 668 | ] 669 | }, 670 | { 671 | "cell_type": "code", 672 | "execution_count": 33, 673 | "metadata": { 674 | "ExecuteTime": { 675 | "end_time": "2018-12-06T02:38:03.388841Z", 676 | "start_time": "2018-12-06T02:38:03.384790Z" 677 | } 678 | }, 679 | "outputs": [], 680 | "source": [ 681 | "%help %%python" 682 | ] 683 | }, 684 | { 685 | "cell_type": "markdown", 686 | "metadata": {}, 687 | "source": [ 688 | "## 制限事項\n", 689 | "\n", 690 | "主に実装の都合により、次の制限があります。\n", 691 | "\n", 692 | "### 対話コマンド\n", 693 | "\n", 694 | "実行中にキー入力を待ち受ける対話コマンドは使えません。\n", 695 | "\n", 696 | "対策:\n", 697 | "\n", 698 | "* Jupyter Notebookの\"Terminal\"を使う\n", 699 | "* `sudo`コマンドは、パスワード入力なしで実行できるよう設定を変える\n", 700 | "* 「対話モード」の代わりに「バッチモード」で実行する\n", 701 | " * e.g. `top` => `top -b`\n", 702 | "* 間違って実行してしまったら、Jupyter Notebookの「interrupt the kernel」か「restart kernel」を実行し、コマンド実行を中断する\n", 703 | " * ![](./interrupt.png)\n", 704 | "\n", 705 | "### パスワードログイン\n", 706 | "\n", 707 | "上と同様の理由で、対象サーバへのパスワードでのログインはできません。\n", 708 | "\n", 709 | "対策:\n", 710 | "\n", 711 | "* SSH公開鍵認証の設定をする\n", 712 | "+ SSH秘密鍵をパスフレーズで保護し、Jupyter Notebookをssh-agentクライアントとして起動する\n", 713 | "\n", 714 | "### シェル変数\n", 715 | "\n", 716 | "セルをまたぐ場合、一部のシェル変数の値が、SSH端末と異なることがあります(例:`$?`, `$$`)。\n", 717 | "これは、コマンド実行の度に[SSHチャネル](http://docs.paramiko.org/en/2.4/api/channel.html)を生成しシェル変数がリセットされるためです。\n", 718 | "同一セル内であればリセットされません。\n", 719 | "\n", 720 | "### !記法\n", 721 | "\n", 722 | "* !記法 (`%shell` magic) は[Metakernel](https://github.com/Calysto/metakernel/)の実装を利用しており、IPython Kernelの実装・挙動と異なります。" 723 | ] 724 | } 725 | ], 726 | "metadata": { 727 | "kernelspec": { 728 | "display_name": "SSH", 729 | "language": "bash", 730 | "name": "ssh" 731 | }, 732 | "language_info": { 733 | "codemirror_mode": "shell", 734 | "file_extension": ".sh", 735 | "mimetype": "text/x-sh", 736 | "name": "ssh" 737 | }, 738 | "toc": { 739 | "base_numbering": 1, 740 | "nav_menu": {}, 741 | "number_sections": true, 742 | "sideBar": true, 743 | "skip_h1_title": false, 744 | "title_cell": "Table of Contents", 745 | "title_sidebar": "Contents", 746 | "toc_cell": false, 747 | "toc_position": {}, 748 | "toc_section_display": true, 749 | "toc_window_display": true 750 | } 751 | }, 752 | "nbformat": 4, 753 | "nbformat_minor": 2 754 | } 755 | -------------------------------------------------------------------------------- /examples/tutorial-ssh-agent.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Tutorial: SSH Agent\n", 8 | "\n", 9 | "Tutorial SSH Kernel with [ssh-agent](https://linux.die.net/man/1/ssh-agent)." 10 | ] 11 | }, 12 | { 13 | "cell_type": "markdown", 14 | "metadata": {}, 15 | "source": [ 16 | "## Start jupyter notebook with ssh-agent \n", 17 | "\n", 18 | "In console:\n", 19 | "\n", 20 | "```\n", 21 | "$ ssh-agent jupyter notebook\n", 22 | "```" 23 | ] 24 | }, 25 | { 26 | "attachments": { 27 | "image.png": { 28 | "image/png": "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" 29 | } 30 | }, 31 | "cell_type": "markdown", 32 | "metadata": { 33 | "ExecuteTime": { 34 | "end_time": "2019-02-08T03:19:16.833227Z", 35 | "start_time": "2019-02-08T03:19:16.823694Z" 36 | } 37 | }, 38 | "source": [ 39 | "## Generate key and connect the server\n", 40 | "\n", 41 | "In `Terminal` in Jupyter notebook, generate keys with passphrase and connect the server with it.\n", 42 | "\n", 43 | "![image.png](attachment:image.png)\n", 44 | "\n", 45 | "```\n", 46 | "$ ssh-keygen -f /tmp/id_ed25519_key -t ed25519 -m PEM\n", 47 | "Enter passphrase (empty for no passphrase):\n", 48 | "Enter same passphrase again:\n", 49 | "Your identification has been saved in /tmp/id_ed25519_key.\n", 50 | "Your public key has been saved in /tmp/id_ed25519_key.pub.\n", 51 | "The key fingerprint is:\n", 52 | "SHA256:FtwAprPsfW5Fu/SwQLbMS+TUw8gJLvyX2/8zTj8apsY masaru@garnet\n", 53 | "The key's randomart image is:\n", 54 | "+--[ED25519 256]--+\n", 55 | "| o.. |\n", 56 | "| o.. o |\n", 57 | "| .o. oo=. |\n", 58 | "| .oo. O.= |\n", 59 | "| oo OS+ o |\n", 60 | "| . ...X = |\n", 61 | "| . .o.B.= o. |\n", 62 | "| o+ +E+.+o |\n", 63 | "| .. .o.++oo|\n", 64 | "+----[SHA256]-----+\n", 65 | "\n", 66 | "$ edit ~/.ssh/config\n", 67 | "\n", 68 | "# (You must customize these definitions to your environment.)\n", 69 | "\n", 70 | "+ Host testagent\n", 71 | "+ Hostname localhost\n", 72 | "+ User root\n", 73 | "+ Port 11122\n", 74 | "+ IdentityFile /tmp/id_ed25519_key\n", 75 | "\n", 76 | "$ ssh testagent\n", 77 | "The authenticity of host '[localhost]:11122 ([::1]:11122)' can't be established.\n", 78 | "ECDSA key fingerprint is SHA256:Rath9QRSP1hKeFkIGwL1c1WUV+haHdJrxTyilRrRNnE.\n", 79 | "Are you sure you want to continue connecting (yes/no)? yes\n", 80 | "Warning: Permanently added '[localhost]:11122' (ECDSA) to the list of known hosts.\n", 81 | "root@localhost's password:\n", 82 | "root@b12253d3c87b:~# exit\n", 83 | "\n", 84 | "$ ssh-copy-id -i /tmp/id_ed25519_key testagent\n", 85 | "/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: \"/tmp/id_ed25519_key.pub\"/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are alre\n", 86 | "ady installed/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to insta\n", 87 | "ll the new keys\n", 88 | "root@localhost's password:\n", 89 | "\n", 90 | "Number of key(s) added: 1\n", 91 | "\n", 92 | "Now try logging into the machine, with: \"ssh 'testagent'\"\n", 93 | "and check to make sure that only the key(s) you wanted were added.\n", 94 | "\n", 95 | "\n", 96 | "$ ssh testagent id\n", 97 | "Enter passphrase for key '/tmp/id_ed25519_key':uid=0(root) gid=0(root) groups=0(root)\n", 98 | "```" 99 | ] 100 | }, 101 | { 102 | "cell_type": "markdown", 103 | "metadata": {}, 104 | "source": [ 105 | "## Add a private key identities to the authentication agent\n", 106 | "\n", 107 | "```\n", 108 | "$ ssh-add /tmp/id_ed25519_key\n", 109 | "Enter passphrase for /tmp/id_ed25519_key:\n", 110 | "Identity added: /tmp/id_ed25519_key (masaru@garnet)\n", 111 | "\n", 112 | "# Now you can connect without passphrase\n", 113 | "$ ssh testagent id\n", 114 | "uid=0(root) gid=0(root) groups=0(root)\n", 115 | "```" 116 | ] 117 | }, 118 | { 119 | "cell_type": "markdown", 120 | "metadata": {}, 121 | "source": [ 122 | "## Execute commands in notebook" 123 | ] 124 | }, 125 | { 126 | "cell_type": "code", 127 | "execution_count": 1, 128 | "metadata": { 129 | "ExecuteTime": { 130 | "end_time": "2019-02-08T06:58:44.262333Z", 131 | "start_time": "2019-02-08T06:58:44.037783Z" 132 | } 133 | }, 134 | "outputs": [ 135 | { 136 | "name": "stdout", 137 | "output_type": "stream", 138 | "text": [ 139 | "[ssh] Login to testagent...\n", 140 | "[ssh] host=testagent hostname=localhost other_conf={'keyfile': ['/tmp/id_ed25519_key'], 'user': 'root', 'port': 11122}\n", 141 | "[ssh] Successfully logged in.\n" 142 | ] 143 | } 144 | ], 145 | "source": [ 146 | "%login testagent" 147 | ] 148 | }, 149 | { 150 | "cell_type": "code", 151 | "execution_count": 2, 152 | "metadata": { 153 | "ExecuteTime": { 154 | "end_time": "2019-02-08T06:58:48.863516Z", 155 | "start_time": "2019-02-08T06:58:48.794532Z" 156 | } 157 | }, 158 | "outputs": [ 159 | { 160 | "name": "stdout", 161 | "output_type": "stream", 162 | "text": [ 163 | "[ssh] host = testagent, cwd = /root\n", 164 | "uid=0(root) gid=0(root) groups=0(root)\n", 165 | "\n" 166 | ] 167 | } 168 | ], 169 | "source": [ 170 | "id" 171 | ] 172 | }, 173 | { 174 | "cell_type": "code", 175 | "execution_count": 3, 176 | "metadata": { 177 | "ExecuteTime": { 178 | "end_time": "2019-02-08T06:58:53.415381Z", 179 | "start_time": "2019-02-08T06:58:53.357579Z" 180 | } 181 | }, 182 | "outputs": [ 183 | { 184 | "name": "stdout", 185 | "output_type": "stream", 186 | "text": [ 187 | "[ssh] Successfully logged out.\n" 188 | ] 189 | } 190 | ], 191 | "source": [ 192 | "%logout" 193 | ] 194 | } 195 | ], 196 | "metadata": { 197 | "kernelspec": { 198 | "display_name": "SSH", 199 | "language": "bash", 200 | "name": "ssh" 201 | }, 202 | "language_info": { 203 | "codemirror_mode": "shell", 204 | "file_extension": ".sh", 205 | "mimetype": "text/x-sh", 206 | "name": "ssh" 207 | }, 208 | "toc": { 209 | "base_numbering": 1, 210 | "nav_menu": {}, 211 | "number_sections": true, 212 | "sideBar": true, 213 | "skip_h1_title": false, 214 | "title_cell": "Table of Contents", 215 | "title_sidebar": "Contents", 216 | "toc_cell": false, 217 | "toc_position": {}, 218 | "toc_section_display": true, 219 | "toc_window_display": true 220 | } 221 | }, 222 | "nbformat": 4, 223 | "nbformat_minor": 2 224 | } 225 | --------------------------------------------------------------------------------