Skip to content

Commit

Permalink
containers: Add SSH key login support to ws
Browse files Browse the repository at this point in the history
Copy the custom auth script from containers/bastion. Without
`$COCKPIT_SSH_KEY_PATH` this transparently falls back to regular
cockpit-ssh, thus does not change behaviour.
  • Loading branch information
martinpitt committed Jun 19, 2022
1 parent 2c69a44 commit 2ee0b66
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 1 deletion.
10 changes: 10 additions & 0 deletions containers/ws/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ You can still log into the ws container's host if you specify its *public*
address. [podman](https://podman.io/) provides the special name
`host.containers.internal` for that.

### Configuration

By default, the container uses an internal
[cockpit.conf](https://cockpit-project.org/guide/latest/cockpit.conf.5.html)
which sets `RequireHost = true` and a neutral login title. You can customize
Expand All @@ -75,13 +77,21 @@ this by passing your own configuration as a volume:
Similarly you can also provide a custom `/etc/os-release` to change the
branding.

### SSH authentication

The login page asks the user to confirm unknown SSH host key fingerprints. You
can mount your known host keys into the container at
`/etc/ssh/ssh_known_hosts`, or set the environment variable
`COCKPIT_SSH_KNOWN_HOSTS_FILE` to specify a different location:

-v /path/to/known_hosts:/etc/ssh/ssh_known_hosts:ro,Z

You can also mount an encrypted private key inside the container and set the environment variable `COCKPIT_SSH_KEY_PATH` to point to it:

-e COCKPIT_SSH_KEY_PATH=/id_rsa -v ~/.ssh/id_rsa:/id_rsa:ro,Z

Then cockpit will use the provided password to decrypt the key and establish an SSH connection to the given host using that private key. **Warning:** This currently only supports RSA keys in the old PEM file format (`ssh-keygen -t rsa -m PEM`), not the current OpenSSH specific format.

## More Info

* [Cockpit Project](https://cockpit-project.org)
Expand Down
182 changes: 182 additions & 0 deletions containers/ws/cockpit-auth-ssh-key
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/python3
# This file is part of Cockpit.
#
# Copyright (C) 2018 Red Hat, Inc.
#
# Cockpit is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2.1 of the License, or
# (at your option) any later version.
#
# Cockpit is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.

import os
import sys
import json
import time
import base64
import subprocess

COCKPIT_SSH_COMMAND = "/usr/libexec/cockpit-ssh"

# This command is meant to be used as an authentication command launched
# by cockpit-ws. It asks for authentication from cockpit-ws and expects
# to receive a basic auth header in response. If COCKPIT_SSH_KEY_PATH is set
# we will try to decrypt the key with the given password. If successful
# we send the decrypted key to cockpit-ws for use with cockpit-ssh.
# Once finished we exec cockpit-ssh to actually establish the ssh connection.
# All communication with cockpit-ws happens on stdin and stdout using the
# cockpit protocol
# (https://github.com/cockpit-project/cockpit/blob/main/doc/protocol.md)


def usage():
sys.stderr.write("usage {} [user@]host[:port]\n".format(sys.argv[0]))
sys.exit(os.EX_USAGE)


def send_auth_command(challenge, response):
cmd = {
"command": "authorize",
}

if challenge:
cmd["cookie"] = "session{}{}".format(os.getpid(), time.time())
cmd["challenge"] = challenge
if response:
cmd["response"] = response

text = json.dumps(cmd).encode('utf-8')
os.write(1, "{}\n\n".format(len(text) + 1).encode('utf-8'))
os.write(1, text)


def send_problem_init(problem, message, auth_methods):
cmd = {
"command": "init",
"problem": problem
}

if message:
cmd["message"] = message

if auth_methods:
cmd["auth-method-results"] = auth_methods

text = json.dumps(cmd).encode('utf-8')
os.write(1, "{}\n\n".format(len(text) + 1).encode('utf-8'))
os.write(1, text)


def read_size(fd):
sep = b'\n'
size = 0
seen = 0

while True:
t = os.read(fd, 1)

if not t:
return 0

if t == sep:
break

size = (size * 10) + int(t)
seen = seen + 1

if seen > 7:
raise ValueError("Invalid frame: size too long")

return size


def read_frame(fd):
size = read_size(fd)

data = ""
while size > 0:
d = os.read(fd, size)
size = size - len(d)
data = data + d.decode('utf8')

return data


def read_auth_reply():
data = read_frame(1)
cmd = json.loads(data)
response = cmd.get("response")
if cmd.get("command") != "authorize" or \
not cmd.get("cookie") or not response:
raise ValueError("Did not receive a valid authorize command")

return response


def decode_basic_header(response):
starts = "Basic "

assert response
assert response.startswith(starts), response

val = base64.b64decode(response[len(starts):].encode('utf-8')).decode("utf-8")
user, password = val.split(':', 1)
return user, password


def send_decrypted_key(fname, password):
r, w = os.pipe()
os.set_inheritable(r, True)
os.set_inheritable(w, True)
p = subprocess.Popen(["openssl", "rsa", "-in", fname, "-passin", "fd:{}".format(r)],
preexec_fn=lambda: os.close(w),
pass_fds=(r,),
stdout=subprocess.PIPE, stderr=subprocess.PIPE)

os.write(w, password.encode('utf-8'))
os.close(w)
os.close(r)

data, err = p.communicate()
if p.returncode == 0:
send_auth_command(None, "private-key {}".format(data.decode('utf-8')))
return True
else:
sys.stderr.write("Couldn't open private key: {}".format(err))
return False


def main(args):
if len(args) != 2:
usage()

host = args[1]
key_name = os.environ.get("COCKPIT_SSH_KEY_PATH")
if key_name:
send_auth_command("*", None)
try:
resp = read_auth_reply()
user, password = decode_basic_header(resp)
except (ValueError, TypeError, AssertionError) as e:
send_problem_init("internal-error", str(e), {})
raise

if send_decrypted_key(key_name, password):
host = "{}@{}".format(user, host)
else:
send_problem_init("authentication-failed", "Couldn't open private key",
{"password": "denied"})
return

os.execlpe(COCKPIT_SSH_COMMAND, COCKPIT_SSH_COMMAND, host, os.environ)


if __name__ == '__main__':
main(sys.argv)
6 changes: 6 additions & 0 deletions containers/ws/default-bastion.conf
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ RequireHost = true

[Negotiate]
action = none

[Basic]
Command = /container/cockpit-auth-ssh-key

[Ssh-Login]
Command = /container/cockpit-auth-ssh-key
2 changes: 1 addition & 1 deletion test/fedora-coreos.install
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ podman run --name build-cockpit -i \
-v /var/tmp/:/run/build:Z \
quay.io/cockpit/ws sh -exc '
rpm --freshen --verbose /run/build/cockpit-ws-*.rpm /run/build/cockpit-bridge-*.rpm
cp /run/build/containers/ws/label-* /run/build/containers/ws/default-bastion.conf /container/
cp /run/build/containers/ws/label-* /run/build/containers/ws/default-bastion.conf /run/build/containers/ws/cockpit-auth-ssh-key /container/
'
podman commit --change CMD=/container/label-run build-cockpit localhost/cockpit/ws
podman rm -f build-cockpit
Expand Down
49 changes: 49 additions & 0 deletions test/verify/check-ws-bastion
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
# You should have received a copy of the GNU Lesser General Public License
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.

import os
import unittest

import parent
Expand Down Expand Up @@ -167,6 +168,54 @@ class TestWsBastionContainer(MachineCase):
b.wait_visible('#content')
b.wait_text('#current-username', 'admin')

def testKeyLogin(self):
m = self.machine
b = self.browser

KEY_PASSWORD = "sshfoobar"
# FIXME: cockpit-auth-ssh-key only understands PEM format RSA keys
m.execute(f"ssh-keygen -q -f /root/id_bastion -t rsa -m PEM -N {KEY_PASSWORD}")
m.execute(f"ssh-keyscan localhost | sed 's/^localhost/{HOST}/' > /root/known_hosts")
self.addCleanup(m.execute, "rm /root/known_hosts /root/id_bastion /root/id_bastion.pub")

m.execute("podman run -d --name cockpit-bastion -p 9090:9090 "
"-v /root/known_hosts:/etc/ssh/ssh_known_hosts:ro,Z "
"-v /root/id_bastion:/id_bastion:ro,Z "
"-e COCKPIT_SSH_KEY_PATH=/id_bastion "
"-e G_MESSAGES_DEBUG=cockpit-ssh "
"localhost/cockpit/ws")
m.execute("until curl --fail --head -k https://localhost:9090/; do sleep 1; done")

b.ignore_ssl_certificate_errors(True)
b.open("/", tls=True)
b.set_val("#login-user-input", "admin")
b.set_val("#server-field", HOST)

# the account password does not work
b.set_val("#login-password-input", "foobar")
b.click("#login-button")
b.wait_text_not("#login-error-message", "")

# SSH key password, but key is not authorized
b.set_val("#login-password-input", KEY_PASSWORD)
b.click("#login-button")
b.wait_text_not("#login-error-message", "")

# authorize key
p = "/home/admin/.ssh/authorized_keys.d/bastion"
self.restore_dir(os.path.dirname(p))
m.execute(f"cp /root/id_bastion.pub {p} && chown admin:admin {p} && chmod 600 {p}")

# fails with wrong key password
b.set_val("#login-password-input", "notthispassword")
b.click("#login-button")
b.wait_text_not("#login-error-message", "")

# works with correct key password
b.set_val("#login-password-input", KEY_PASSWORD)
b.click("#login-button")
b.wait_visible('#content')


if __name__ == '__main__':
test_main()

0 comments on commit 2ee0b66

Please sign in to comment.