├── CONTRIBUTING.md
├── .dockerignore
├── .github
├── ISSUE_TEMPLATE
│ ├── 03_documentation.md
│ ├── 04_deployment-problem.md
│ ├── 05_anything-else.md
│ ├── 02_feature-request.md
│ └── 01_bug-report.md
├── ISSUE_TEMPLATE.md
├── PULL_REQUEST_TEMPLATE.md
└── SUPPORT.md
├── docker-res
├── scripts
│ └── clean-layer.sh
├── start_ssh.py
└── ssh
│ ├── authorize.sh
│ ├── sshd_config
│ └── update_authorized_keys.py
├── CODE_OF_CONDUCT.md
├── Dockerfile
├── README.md
└── LICENSE
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | TBD
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | *
2 | !docker-res/
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/03_documentation.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F4DA Documentation"
3 | about: Is there a mistake in the docs, is anything unclear or do you have a suggestion?
4 | title: ''
5 | labels: enhancement, docs
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe your request:**
11 |
12 |
13 |
14 | **Which page or section is this issue related to?**
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | **Describe the issue:**
8 |
9 |
10 |
11 | **Technical details:**
12 |
13 | - Image version :
14 | - Docker version :
15 | - Host Machine OS (Windows/Linux/Mac):
16 | - Command used to start the ssh-proxy :
--------------------------------------------------------------------------------
/docker-res/scripts/clean-layer.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # This scripts should be called at the end of each RUN command
4 | # in the Dockerfiles.
5 | #
6 | # Each RUN command creates a new layer that is stored separately.
7 | # At the end of each command, we should ensure we clean up downloaded
8 | # archives and source files used to produce binary to reduce the size
9 | # of the layer.
10 | set -e
11 | set -x
12 |
13 | # Delete old downloaded archive files
14 | apt-get autoremove -y
15 | # Delete downloaded archive files
16 | apt-get clean
17 | # Delete source files used for building binaries
18 | rm -rf /usr/local/src/*
19 | # Delete cache and temp folders
20 | rm -rf /tmp/* /var/tmp/* /root/.cache/* /var/cache/apt/*
21 | # Remove apt lists
22 | rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/*
23 |
24 | # Clean npm
25 | if [ -x "$(command -v npm)" ]; then
26 | npm cache clean --force
27 | rm -rf /root/.npm/* /root/.node-gyp/*
28 | fi
29 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
4 |
5 | **What kind of change does this PR introduce?**
6 |
7 |
8 | - [ ] Bugfix
9 | - [ ] New Feature
10 | - [ ] Feature Improvment
11 | - [ ] Refactoring
12 | - [ ] Documentation
13 | - [ ] Other, please describe:
14 |
15 | **Description:**
16 |
17 |
18 | **Checklist:**
19 |
21 |
22 | - [ ] I have read the [CONTRIBUTING](https://github.com/ml-tooling/ssh-proxy/blob/develop/CONTRIBUTING.md) document.
23 | - [ ] My changes don't require a change to the documentation, or if they do, I've added all required information.
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/04_deployment-problem.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F433 Deployment Problem"
3 | about: Do you have problems with deployment, and none of the suggestions in the docs
4 | and other issues helped?
5 | title: ''
6 | labels: ''
7 | assignees: ''
8 |
9 | ---
10 |
11 |
16 |
17 | **Describe the issue:**
18 |
19 |
20 |
21 | **Technical details:**
22 |
23 | - Image version :
24 | - Docker version :
25 | - Host Machine OS (Windows/Linux/Mac):
26 | - Command used to start the ssh-proxy :
27 | -
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/05_anything-else.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F4AC Anything else?"
3 | about: For general usage questions, please consider posting on Stack Overflow or
4 | Gitter instead.
5 | title: ''
6 | labels: ''
7 | assignees: ''
8 |
9 | ---
10 |
11 |
16 |
17 | **Describe the issue:**
18 |
19 |
20 |
21 | **Technical details:**
22 |
23 | - Image version :
24 | - Docker version :
25 | - Host Machine OS (Windows/Linux/Mac):
26 | - Command used to start the ssh-proxy :
27 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/02_feature-request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F381 Feature request"
3 | about: Do you have an idea for an improvement or a new feature?
4 | title: ''
5 | labels: feature-request
6 | assignees: ''
7 |
8 | ---
9 |
10 |
15 |
16 | **Feature description:**
17 |
18 |
23 |
24 | **Problem and motivation:**
25 |
26 |
29 |
30 | **Is this something you're interested in working on?**
31 |
32 |
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/01_bug-report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F6A8 Bug report"
3 | about: Did you come across a bug or unexpected behaviour differing from the docs?
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 |
17 |
18 | **Describe the bug:**
19 |
20 |
21 |
22 | **Expected behaviour:**
23 |
24 |
25 |
26 | **Steps to reproduce the issue:**
27 |
28 |
29 |
30 |
36 |
37 | **Technical details:**
38 |
39 | - Image version :
40 | - Docker version :
41 | - Host Machine OS (Windows/Linux/Mac):
42 | - Command used to start the ssh-proxy :
43 |
44 | **Possible Fix:**
45 |
46 |
47 |
48 | **Additional context:**
49 |
50 |
51 |
--------------------------------------------------------------------------------
/docker-res/start_ssh.py:
--------------------------------------------------------------------------------
1 | """
2 | Container init script
3 | """
4 | #!/usr/bin/python
5 |
6 | from subprocess import call, Popen
7 | import os
8 | import time
9 |
10 | # Start the SSH server that will serve as a jump host to the Workspaces to allow a user to ssh into a target
11 | ENV_NAME_PERMIT_TARGET_HOST = "SSH_PERMIT_TARGET_HOST"
12 | ENV_SSH_PERMIT_TARGET_HOST = os.getenv(ENV_NAME_PERMIT_TARGET_HOST, "")
13 |
14 | ENV_NAME_MANUAL_AUTH_FILE = "MANUAL_AUTH_FILE"
15 | ENV_MANUAL_AUTH_FILE = os.getenv(ENV_NAME_MANUAL_AUTH_FILE, "false")
16 |
17 | ENV_NAME_SSH_TARGET_LABELS = "SSH_TARGET_LABELS"
18 | ENV_SSH_TARGET_LABELS = os.getenv(ENV_NAME_SSH_TARGET_LABELS, "")
19 |
20 | if ENV_SSH_PERMIT_TARGET_HOST == "":
21 | print("The environment variable {} must be set.".format(ENV_NAME_PERMIT_TARGET_HOST))
22 | exit(1)
23 |
24 | ENV_NAME_PERMIT_TARGET_PORT = "SSH_PERMIT_TARGET_PORT"
25 | ENV_SSH_PERMIT_TARGET_PORT = os.getenv(ENV_NAME_PERMIT_TARGET_PORT, "22")
26 | SSHD_CONFIG = "/etc/ssh/sshd_config"
27 |
28 | # replace the PermitOpen directive placeholders in the sshd_config
29 | call("sed -i 's/{" + ENV_NAME_PERMIT_TARGET_HOST + "}/" + ENV_SSH_PERMIT_TARGET_HOST + "/g' " + SSHD_CONFIG, shell=True)
30 | call("sed -i 's/{" + ENV_NAME_PERMIT_TARGET_PORT + "}/" + ENV_SSH_PERMIT_TARGET_PORT + "/g' " + SSHD_CONFIG, shell=True)
31 |
32 | # export environment variables to a file which sshd can read to preserve their values in the ssh session
33 | call("echo 'export {}={}' >> {}".format(ENV_NAME_PERMIT_TARGET_HOST, ENV_SSH_PERMIT_TARGET_HOST, os.getenv("SSHD_ENVIRONMENT_VARIABLES")), shell=True)
34 | call("echo 'export {}={}' >> {}".format(ENV_NAME_MANUAL_AUTH_FILE, ENV_MANUAL_AUTH_FILE, os.getenv("SSHD_ENVIRONMENT_VARIABLES")), shell=True)
35 | call("echo 'export {}={}' >> {}".format(ENV_NAME_SSH_TARGET_LABELS, ENV_SSH_TARGET_LABELS, os.getenv("SSHD_ENVIRONMENT_VARIABLES")), shell=True)
36 |
37 | call("/usr/local/sbin/sshd -D -f " + SSHD_CONFIG, shell=True)
38 |
--------------------------------------------------------------------------------
/docker-res/ssh/authorize.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Command to be called by the OpenSSH Server AuthorizedKeysCommand to check whether a valid Runtime container exists
3 |
4 | public_key=$1
5 | echo "$(date) $public_key" >> /etc/ssh/access.log
6 |
7 | # If MANUAL_AUTH_FILE is true, another mechanism is supposed to fill the /etc/ssh/authorized_keys_cache file (e.g. a mounted file)
8 | if [ {MANUAL_AUTH_FILE} = true ]; then
9 | cat /etc/ssh/authorized_keys_cache;
10 | exit
11 | fi
12 |
13 | # Read environment variables from file to access general environment variables
14 | # This script is run in an SSH session and, thus, the environment variables do not exist otherwise
15 | source $SSHD_ENVIRONMENT_VARIABLES
16 |
17 | CACHE_TIME=$((60 * 15))
18 | #[ ! -f /etc/ssh/authorized_keys_cache ] && python /etc/ssh/update_authorized_keys.py
19 | current_date=$(date +%s)
20 | cache_last_modified=$(date -r /etc/ssh/authorized_keys_cache +%s)
21 | time_difference=$(($current_date - $cache_last_modified))
22 |
23 | # check if the key is in the authorized_keys_cache file.
24 | # If not, perform a synchronous update (the user has to wait) and return the content then.
25 | # Use still a shorter chache validation time to prevent brute-force try (when a user offers a key that does not match
26 | # any runtime key then the test will always fail and a costly key update is triggered)
27 | # Check for " $public_key " with spaces before and after to prevent matching parts of a key
28 | SHORTER_CACHE_TIME=$((60 * 2))
29 | if [ ! -f etc/ssh/authorized_keys_cache ]; then
30 | python /etc/ssh/update_authorized_keys.py full
31 | elif ! grep -q " $public_key " /etc/ssh/authorized_keys_cache; then
32 | if [ $time_difference -ge $SHORTER_CACHE_TIME ]; then
33 | python /etc/ssh/update_authorized_keys.py
34 | fi
35 | elif [ $time_difference -ge $CACHE_TIME ]; then
36 | # Run the python script to update the cache in the background so the command can directly return
37 | nohup python /etc/ssh/update_authorized_keys.py full > /dev/null 2>&1 &
38 | fi
39 |
40 | # Return the collected keys to the OpenSSH server
41 | cat /etc/ssh/authorized_keys_cache
42 |
--------------------------------------------------------------------------------
/.github/SUPPORT.md:
--------------------------------------------------------------------------------
1 | ## Support
2 |
3 | The SSH Proxy project is maintained by [@raethlein](https://twitter.com/raethlein) and [@LukasMasuch](https://twitter.com/LukasMasuch). Please understand that we won't be able
4 | to provide individual support via email. We also believe that help is much more
5 | valuable if it's shared publicly so that more people can benefit from it.
6 |
7 | | Type | Channel |
8 | | ------------------------ | ------------------------------------------------------ |
9 | | 🚨 **Bug Reports** | |
10 | | 🎁 **Feature Requests** |
|
11 | | 👩💻 **Usage Questions** |
|
12 | | 🗯 **General Discussion** |
|
13 |
14 | ## Contribution
15 |
16 | - Pull requests are encouraged and always welcome. Read [`CONTRIBUTING.md`](https://github.com/ml-tooling/ssh-proxy/tree/master/CONTRIBUTING.md) and check out [help-wanted](https://github.com/ml-tooling/ssh-proxy/issues?utf8=%E2%9C%93&q=is%3Aopen+is%3Aissue+label%3A"help+wanted"+sort%3Areactions-%2B1-desc+) issues.
17 | - Submit github issues for any [feature enhancements](https://github.com/ml-tooling/ml-workspace/issues/new?assignees=&labels=feature-request&template=02_feature-request.md&title=), [bugs](https://github.com/ml-tooling/ssh-proxy/issues/new?assignees=&labels=bug&template=01_bug-report.md&title=), or [documentation](https://github.com/ml-tooling/ssh-proxy/issues/new?assignees=&labels=enhancement%2C+docs&template=03_documentation.md&title=) problems.
18 | - By participating in this project you agree to abide by its [Code of Conduct](https://github.com/ml-tooling/ssh-proxy/tree/master/CODE_OF_CONDUCT.md).
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at mltooling.team@gmail.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ubuntu:18.04
2 |
3 | # Basics
4 | ENV _RESOURCES_PATH="/resources"
5 |
6 | # Layer cleanup script
7 | COPY docker-res/scripts/clean-layer.sh /usr/bin/clean-layer.sh
8 |
9 | RUN \
10 | mkdir $_RESOURCES_PATH && \
11 | chmod ug+rwx $_RESOURCES_PATH && \
12 | chmod a+rwx /usr/bin/clean-layer.sh
13 |
14 | RUN \
15 | apt-get update && \
16 | apt-get install -y \
17 | wget \
18 | python3 \
19 | python3-pip && \
20 | ln -s /usr/bin/pip3 /usr/bin/pip && \
21 | ln -s /usr/bin/python3 /usr/bin/python && \
22 | # Cleanup
23 | clean-layer.sh
24 |
25 | # SSH Server
26 | ## Install & Prepare SSH
27 | RUN \
28 | mkdir /var/run/sshd && \
29 | mkdir /root/.ssh && \
30 | mkdir /var/lib/sshd && \
31 | chmod -R 700 /var/lib/sshd/ && \
32 | chown -R root:sys /var/lib/sshd/ && \
33 | useradd -r -U -d /var/lib/sshd/ -c "sshd privsep" -s /bin/false sshd && \
34 | apt-get update && \
35 | apt-get install -y build-essential libssl-dev zlib1g-dev && \
36 | cd $_RESOURCES_PATH && \
37 | wget "https://cdn.openbsd.org/pub/OpenBSD/OpenSSH/portable/openssh-8.0p1.tar.gz" && \
38 | tar xfz openssh-8.0p1.tar.gz && \
39 | cd $_RESOURCES_PATH/openssh-8.0p1/ && \
40 | # modify the code where the 'PermitOpen' host is checked so that it supports regexes
41 | sed -i "s@strcmp(allowed_open->host_to_connect, requestedhost) != 0@strcmp(allowed_open->host_to_connect, requestedhost) != 0 \&\& match_hostname(requestedhost, allowed_open->host_to_connect) == 0@g" ./channels.c && \
42 | ./configure && \
43 | make && \
44 | make install && \
45 | # filelock is needed for our custom AuthorizedKeysCommand script in the OpenSSH server
46 | apt-get install -y python3-setuptools && \
47 | pip install filelock && \
48 | # Python kubernetes client is needed for caching the authorized keys in Kubernetes mode
49 | pip install kubernetes && \
50 | pip install docker && \
51 | # Cleanup
52 | clean-layer.sh
53 |
54 | ## Create user with restricted permissions for ssh
55 | # https://gist.github.com/smoser/3e9430c51e23e0c0d16c359a2ca668ae
56 | # https://www.tecmint.com/restrict-ssh-user-to-directory-using-chrooted-jail/
57 | # http://www.ab-weblog.com/en/creating-a-restricted-ssh-user-for-ssh-tunneling-only/
58 | RUN useradd -d /home/limited-user -m -s /bin/true --gid nogroup --skel /dev/null --create-home limited-user && \
59 | #chmod 755 /home/limited-user && \
60 | #chmod g+rwx /home/limited-user && \
61 | echo 'PATH=""' >> /home/limited-user/.profile && \
62 | echo 'limited-user:limited' |chpasswd && \
63 | chmod 555 /home/limited-user/ && \
64 | cd /home/limited-user/ && \
65 | # .bash_logout .bashrc
66 | chmod 444 .profile && \
67 | chown root:root /home/limited-user/
68 |
69 | COPY docker-res/start_ssh.py $_RESOURCES_PATH/start_ssh.py
70 | COPY docker-res/ssh/* /etc/ssh/
71 |
72 | # Set default configuration
73 | ENV SSH_PERMIT_TARGET_HOST="*" \
74 | SSH_PERMIT_TARGET_PORT="*" \
75 | SSH_TARGET_KEY_PATH="~/.ssh/id_ed25519.pub" \
76 | MANUAL_AUTH_FILE="false" \
77 | SSHD_ENVIRONMENT_VARIABLES="${_RESOURCES_PATH}/sshd_environment" \
78 | SSH_TARGET_PUBLICKEY_API_PORT=8080 \
79 | ENV_NAME_SSH_TARGET_LABELS=""
80 |
81 | RUN \
82 | chmod -R ug+rwx $_RESOURCES_PATH && \
83 | touch $SSHD_ENVIRONMENT_VARIABLES && \
84 | chmod a+r $SSHD_ENVIRONMENT_VARIABLES && \
85 | # Replace the environment variable in the script directly here, since the script is executed from sshd shell and cannot
86 | # access the environment variable directly
87 | sed -i 's@$SSHD_ENVIRONMENT_VARIABLES@'"$SSHD_ENVIRONMENT_VARIABLES"'@g' /etc/ssh/authorize.sh
88 |
89 | ENTRYPOINT python $_RESOURCES_PATH/start_ssh.py
90 |
--------------------------------------------------------------------------------
/docker-res/ssh/sshd_config:
--------------------------------------------------------------------------------
1 | # See the sshd_config(5) manpage for details: https://www.freebsd.org/cgi/man.cgi?sshd_config(5)
2 |
3 | # What ports, IPs and protocols we listen for
4 | Port 22
5 | # Use these options to restrict which interfaces/protocols sshd will bind to
6 | #ListenAddress ::
7 | #ListenAddress 0.0.0.0
8 | Protocol 2
9 | # HostKeys for protocol version 2
10 | # HostKey /etc/ssh/ssh_host_rsa_key
11 | # HostKey /etc/ssh/ssh_host_dsa_key
12 | # HostKey /etc/ssh/ssh_host_ecdsa_key
13 | # HostKey /etc/ssh/ssh_host_ed25519_key
14 | # Privilege Separation is turned on for security
15 | # UsePrivilegeSeparation yes
16 |
17 | # Lifetime and size of ephemeral version 1 server key
18 | # deprecated KeyRegenerationInterval 3600
19 | # deprecated ServerKeyBits 1024
20 |
21 | # Logging
22 | SyslogFacility AUTH
23 | LogLevel INFO
24 |
25 | # Authentication:
26 | LoginGraceTime 120
27 | StrictModes yes
28 |
29 | # deprecated RSAAuthentication yes
30 | PubkeyAuthentication yes
31 | #AuthorizedKeysFile %h/.ssh/authorized_keys
32 |
33 | # Don't read the user's ~/.rhosts and ~/.shosts files
34 | IgnoreRhosts yes
35 | # For this to work you will also need host keys in /etc/ssh_known_hosts
36 | # deprecated RhostsRSAAuthentication no
37 | # similar for protocol version 2
38 | HostbasedAuthentication no
39 | # Uncomment if you don't trust ~/.ssh/known_hosts for RhostsRSAAuthentication
40 | #IgnoreUserKnownHosts yes
41 |
42 | # To enable empty passwords, change to yes (NOT RECOMMENDED)
43 | PermitEmptyPasswords no
44 |
45 | # Change to yes to enable challenge-response passwords (beware issues with
46 | # some PAM modules and threads)
47 | ChallengeResponseAuthentication no
48 |
49 | # X11Forwarding yes
50 | X11DisplayOffset 10
51 | PrintMotd no
52 | PrintLastLog yes
53 | TCPKeepAlive yes
54 | #UseLogin no
55 |
56 | #MaxStartups 10:30:60
57 | #Banner /etc/issue.net
58 |
59 | # Allow client to pass locale environment variables
60 | AcceptEnv LANG LC_*
61 |
62 | # Do not activate sftp?
63 | # Subsystem sftp /usr/lib/openssh/sftp-server
64 |
65 | PermitUserEnvironment yes
66 | ClientAliveInterval 60
67 | ClientAliveCountMax 10
68 |
69 | # https://askubuntu.com/questions/48129/how-to-create-a-restricted-ssh-user-for-port-forwarding
70 | # https://unix.stackexchange.com/questions/208960/how-to-restrict-a-user-to-one-folder-and-not-allow-them-to-move-out-his-folder
71 |
72 | UseDNS yes
73 |
74 | # Only allow limited-user
75 | PermitRootLogin no
76 | DenyUsers root
77 | AllowUsers limited-user
78 |
79 | AllowTcpForwarding no
80 | PermitListen none
81 | PermitOpen none
82 |
83 | # Change to no to disable tunnelled clear text passwords
84 | PasswordAuthentication no
85 |
86 | Match User limited-user
87 | GatewayPorts no
88 | AllowTcpForwarding yes
89 | # placeholder are replaced by run.py
90 | PermitOpen {SSH_PERMIT_TARGET_HOST}:{SSH_PERMIT_TARGET_PORT}
91 | PermitListen none
92 | PermitTTY no
93 | PermitTunnel no
94 | X11Forwarding no
95 | AllowAgentForwarding no
96 | AllowStreamLocalForwarding no
97 | AuthorizedKeysCommand /bin/bash /etc/ssh/authorize.sh %k
98 | AuthorizedKeysCommandUser root
99 | ForceCommand echo 'This account can only be used for tunneling.'
100 |
101 | # /bin/false Vs /bin/true
102 |
103 | Match Host localhost
104 | PermitOpen none
105 | X11Forwarding no
106 | AllowAgentForwarding no
107 | AllowTcpForwarding no
108 | ForceCommand echo 'Dont try to connect to local!.'
109 |
110 | Match address 127.0.0.1
111 | PermitOpen none
112 | X11Forwarding no
113 | AllowAgentForwarding no
114 | AllowTcpForwarding no
115 | ForceCommand echo 'Dont try to connect to 127.0.0.1!.'
116 |
117 | Match address ::1
118 | PermitOpen none
119 | X11Forwarding no
120 | AllowAgentForwarding no
121 | AllowTcpForwarding no
122 | ForceCommand echo 'Dont try to connect to ::1!.'
123 |
124 | Match LocalAddress 127.0.0.1,::1
125 | PermitOpen none
126 | X11Forwarding no
127 | AllowAgentForwarding no
128 | AllowTcpForwarding no
129 | ForceCommand echo 'Dont try to connect to localhost (via localaddress)!.'
130 |
--------------------------------------------------------------------------------
/docker-res/ssh/update_authorized_keys.py:
--------------------------------------------------------------------------------
1 | """Used for sshd's AuthorizedKeysCommand to fetch a list of authorized keys.
2 | The script will create a file in ssh's authorized_keys format at `/etc/ssh/authorized_keys_cache` containing
3 | authorized_keys from all ssh target containers / pods (by exec'ing into them and fetching the public keys).
4 | The script will cache the containers / pods it already got the keys from to reduce runtime and will only exec
5 | into those it did not fetch the keys from in a previous run. This behavior can be changed with the first argument arg1.
6 |
7 | Args:
8 | arg1 (str): If value of arg1 is 'full', then the cache files are not considered
9 |
10 | """
11 |
12 | import docker
13 | from kubernetes import client, config, stream
14 | import os
15 | import sys
16 | from filelock import FileLock, Timeout
17 | from subprocess import getoutput
18 | import re
19 | import requests
20 |
21 | SSH_PERMIT_TARGET_HOST = os.getenv("SSH_PERMIT_TARGET_HOST", "*")
22 | SSH_TARGET_KEY_PATH = os.getenv("SSH_TARGET_KEY_PATH", "~/.ssh/id_ed25519.pub")
23 | SSH_TARGET_PUBLICKEY_API_PORT = os.getenv("SSH_TARGET_PUBLICKEY_API_PORT", 8080)
24 | ENV_SSH_TARGET_LABELS = os.getenv("SSH_TARGET_LABELS", "")
25 |
26 | authorized_keys_cache_file = "/etc/ssh/authorized_keys_cache"
27 | authorized_keys_cache_file_lock = "cache_files.lock"
28 | query_cache_file = "/etc/ssh/query_cache"
29 |
30 | container_client = None
31 | CONTAINER_CLIENT_KUBERNETES = "kubernetes"
32 | CONTAINER_CLIENT_DOCKER = "docker"
33 |
34 | PRINT_KEY_COMMAND = '/bin/bash -c "cat {SSH_TARGET_KEY_PATH}"'.format(SSH_TARGET_KEY_PATH=SSH_TARGET_KEY_PATH)
35 |
36 | SSH_PERMIT_TARGET_HOST_REGEX = SSH_PERMIT_TARGET_HOST.replace("*", ".*")
37 | SSH_PERMIT_TARGET_HOST_REGEX = re.compile(SSH_PERMIT_TARGET_HOST_REGEX)
38 |
39 |
40 | # First try to find Kubernetes client. If Kubernetes client or the config is not there, use the Docker client
41 | try:
42 | try:
43 | # incluster config is the config given by a service account and it's role permissions
44 | config.load_incluster_config()
45 | except config.ConfigException:
46 | config.load_kube_config()
47 | kubernetes_client = client.CoreV1Api()
48 | container_client = CONTAINER_CLIENT_KUBERNETES
49 |
50 | # at this path the namespace the container is in is stored in Kubernetes deployment (see https://stackoverflow.com/questions/31557932/how-to-get-the-namespace-from-inside-a-pod-in-openshift)
51 | NAMESPACE = getoutput(
52 | "cat /var/run/secrets/kubernetes.io/serviceaccount/namespace")
53 | except (FileNotFoundError, TypeError):
54 | try:
55 | docker_client = docker.from_env()
56 | docker_client.ping()
57 | container_client = CONTAINER_CLIENT_DOCKER
58 | except FileNotFoundError:
59 | pass
60 |
61 | if container_client is None:
62 | print("Could neither initialize Kubernetes nor Docker client. Stopping execution.")
63 | exit(1)
64 |
65 |
66 | def get_authorized_keys_kubernetes(query_cache: list = []) -> (list, list):
67 | """Execs into all Kubernetes pods where the name complies to `SSH_PERMIT_TARGET_HOST` and returns it's public key.
68 |
69 | Note:
70 | This method can be quite slow. For big setups / clusters, think about rewriting it to fetch public keys from a REST API or so.
71 |
72 | Args:
73 | query_cache (list[str]): contains Pod names which are skipped
74 |
75 | Returns:
76 | list[str]: newly fetched public keys
77 | list[str]: name of all pods (previously cached ones and newly exec'd ones)
78 |
79 | """
80 |
81 | pod_list = kubernetes_client.list_namespaced_pod(
82 | NAMESPACE, field_selector="status.phase=Running", label_selector=ENV_SSH_TARGET_LABELS)
83 | authorized_keys = []
84 | new_query_cache = []
85 | for pod in pod_list.items:
86 | name = pod.metadata.name
87 | pod_ip = pod.status.pod_ip
88 |
89 | if SSH_PERMIT_TARGET_HOST_REGEX.match(name) is None:
90 | continue
91 | elif name in query_cache:
92 | new_query_cache.append(name)
93 | continue
94 |
95 | key = None
96 | # Try to get the public key via an API call first
97 | publickey_url = "http://{}:{}/publickey".format(pod_ip, str(SSH_TARGET_PUBLICKEY_API_PORT))
98 | timeout_seconds = 10
99 | try:
100 | request = requests.request("GET", publickey_url, timeout=timeout_seconds)
101 | if request.status_code == 200:
102 | key = request.text
103 | except requests.exceptions.ConnectTimeout:
104 | print("Connection to {ip} timed out after {timeout} seconds. Will try to exec into the pod to retrieve the key.".format(ip=pod_ip, timeout=str(timeout_seconds)))
105 |
106 | # If the API call did not work, try to exec into the pod.
107 | # Make sure that the executing process has permission to exec into the target pod (e.g. when Kubernetes roles are used)
108 | if key is None:
109 | try:
110 | exec_result = stream.stream(kubernetes_client.connect_get_namespaced_pod_exec, name,
111 | NAMESPACE, command=PRINT_KEY_COMMAND, stderr=True, stdin=False, stdout=True, tty=False)
112 | key = exec_result
113 | except:
114 | # This can happen when the pod is in a false state such as Terminating, as status.phase is 'Running' but pod cannot be reached anymore
115 | print("Could not reach pod {}".format(name))
116 |
117 | if key is not None:
118 | authorized_keys.append(key)
119 | new_query_cache.append(name)
120 |
121 | return authorized_keys, new_query_cache
122 |
123 |
124 | def get_authorized_keys_docker(query_cache: list = []) -> (list, list):
125 | """Execs into all Docker containers where the name starts with `SSH_PERMIT_TARGET_HOST` and returns it's public key.
126 |
127 | Note:
128 | This method can be quite slow. For big setups / clusters, think about rewriting it to fetch public keys from a REST API or so.
129 |
130 | Args:
131 | query_cache (list[str]): contains container ids which are skipped
132 |
133 | Returns:
134 | list[str] newly fetched public keys
135 | list[str]: ids of all containers (previously cached ones and newly exec'd ones)
136 |
137 | """
138 |
139 | filters = {"status": "running"}
140 | if ENV_SSH_TARGET_LABELS != "":
141 | SSH_TARGET_LABELS = ENV_SSH_TARGET_LABELS.split(",")
142 | filters.update({"label": SSH_TARGET_LABELS})
143 |
144 | containers = docker_client.containers.list(filters=filters)
145 | authorized_keys = []
146 | new_query_cache = []
147 | for container in containers:
148 |
149 | if SSH_PERMIT_TARGET_HOST_REGEX.match(container.name) is None:
150 | continue
151 | elif container.id in query_cache:
152 | new_query_cache.append(container.id)
153 | continue
154 |
155 | key = None
156 | # Try to get the public key via an API call first
157 | publickey_url = "http://{}:{}/publickey".format(container.name, str(SSH_TARGET_PUBLICKEY_API_PORT))
158 | timeout_seconds = 10
159 | try:
160 | request = requests.request("GET", publickey_url, timeout=timeout_seconds)
161 | if request.status_code == 200:
162 | key = request.text
163 | except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout):
164 | print("Connection to {ip} timed out after {timeout} seconds. Will try to exec into the pod to retrieve the key.".format(ip=container.id, timeout=str(timeout_seconds)))
165 |
166 | if key is None:
167 | exec_result = container.exec_run(PRINT_KEY_COMMAND)
168 | key = exec_result[1].decode("utf-8")
169 |
170 | if key is not None:
171 | authorized_keys.append(key)
172 | new_query_cache.append(container.id)
173 |
174 | return authorized_keys, new_query_cache
175 |
176 |
177 | def update_cache_file():
178 | # make sure only a single script execution can update authorized_keys file
179 | lock = FileLock(authorized_keys_cache_file_lock, timeout=0)
180 | try:
181 | with lock:
182 | write_mode = 'a'
183 | # Delete query_cache file in case it is a 'full' run
184 | if len(sys.argv) == 2 and sys.argv[1] == "full":
185 | if os.path.isfile(query_cache_file):
186 | os.remove(query_cache_file)
187 | write_mode = 'w'
188 |
189 | query_cache = []
190 | if os.path.isfile(query_cache_file):
191 | with open(query_cache_file, 'r') as cache_file:
192 | for line in cache_file.readlines():
193 | # the strip will remove the newline character at the end of each line
194 | query_cache.append(line.strip())
195 |
196 | if container_client == CONTAINER_CLIENT_DOCKER:
197 | authorized_keys, new_query_cache = get_authorized_keys_docker(
198 | query_cache=query_cache)
199 | elif container_client == CONTAINER_CLIENT_KUBERNETES:
200 | authorized_keys, new_query_cache = get_authorized_keys_kubernetes(
201 | query_cache=query_cache)
202 |
203 | with open(authorized_keys_cache_file, write_mode) as cache_file:
204 | for authorized_key in authorized_keys:
205 | if authorized_key.startswith("ssh") == False:
206 | continue
207 |
208 | cache_file.write("{}\n".format(authorized_key))
209 |
210 | with open(query_cache_file, 'w') as cache_file:
211 | for entry in new_query_cache:
212 | cache_file.write("{}\n".format(entry))
213 |
214 | except Timeout:
215 | # The cache is currently updated by someone else
216 | print("The cache is currently updated by someone else")
217 | pass
218 |
219 |
220 | if __name__ == "__main__":
221 | update_cache_file()
222 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
6 | Dockerized SSH bastion to proxy SSH connections to arbitrary containers. 7 |
8 | 9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
19 | Getting Started • 20 | Highlights • 21 | Support • 22 | Report a Bug • 23 | Contribution 24 |
25 | 26 | This SSH proxy can be deployed as a standalone docker container that allows to proxy any user SSH connection to arbitrary unexposed containers. This enables users to securely access any container via SSH within a cluster only via a single exposed port and provides full SSH compatibility (e.g. port tunneling, scp, sftp, rsync, sshfs, X11). This proxy has a few security features built-in to make sure that users can only access target containers that they are allowed to. 27 | 28 | ## Highlights 29 | 30 | - 🛡 SSH access to behind-firewall clusters via a single port. 31 | - 🔐 Restrict target containers based on port and DNS pattern. 32 | - 🛠 Full SSH compatibility (port tunneling, scp, sftp, rsync, sshfs). 33 | - 📄 Basic access logging based on user logins. 34 | - 🐳 Easy to deploy via Docker and Kubernetes. 35 | - 🏗 Use it as a base image in your own Docker image to bring the ssh functionality into it (checkout the [ml-hub Dockerfile](https://github.com/ml-tooling/ml-hub/blob/1ab1c6b1b4b4b8a6fd2f321ccfb9c8f6f0e0c6eb/Dockerfile#L1) as an example) 36 | 37 | ## Getting Started 38 | 39 | ### Prerequisites 40 | 41 | The target containers must run an SSH server and provide a valid public key. The ssh-proxy container will try to get a key from a target container via a `/publickey` endpoint (e.g. `http://| Variable | 85 |Description | 86 |Default | 87 |
|---|---|---|
| SSH_PERMIT_TARGET_HOST | 90 |Defines which other containers can be ssh targets. The container names must start with the prefix. The ssh connection to the target can only be made for targets where the name matches the given target host. The '*' character can be used as wildcards, e.g. 'workspace-*' would allow connecting to target containers/services which names start with 'workspace-'. 91 | | 92 |* | 93 |
| SSH_PERMIT_TARGET_PORT | 96 |Defines on which port the other containers can be reached via ssh. The ssh connection to the target can only be made via this port then. The default value '*' permits any port. | 97 |* | 98 |
| SSH_TARGET_LABELS | 101 |Specify which containers are targeted. Filters containers / pods via these labels. Must be in the form of "label1=value1,label2=value2,label3=value3". Default is empty string which disables filtering. | 102 |"" | 103 |
| SSH_TARGET_PUBLICKEY_API_PORT | 106 |Port where the target container exposes the /publickey endpoint (if used). | 107 |8080 | 108 |
| SSH_TARGET_KEY_PATH | 111 |The path inside the target containers where the manager looks for a valid public key. 112 | Consider that `~` will be resolved to the target container's home. Only used when the target container does not return a public key via the /publickey endpoint. | 113 |~/.ssh/id_ed25519.pub | 114 |
| MANUAL_AUTH_FILE | 117 |Disables the bastion's public key fetching method and you have to maintain the /etc/ssh/authorized_keys_cache file yourself (e.g. by mounting a respective file there). Only used when the target container does not return a public key via the /publickey endpoint. | 118 |false | 119 |