├── 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 | 
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 | " * \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 | "\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 |
--------------------------------------------------------------------------------