1330 lines
59 KiB
Python
Executable File
1330 lines
59 KiB
Python
Executable File
#!/usr/bin/python3 -cimport os, sys; os.execv(os.path.dirname(sys.argv[1]) + "/../common/pywrap", sys.argv)
|
|
|
|
# This file is part of Cockpit.
|
|
#
|
|
# Copyright (C) 2013 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 base64
|
|
import os
|
|
import subprocess
|
|
import time
|
|
|
|
from testlib import (
|
|
TEST_DIR,
|
|
MachineCase,
|
|
nondestructive,
|
|
skipBrowser,
|
|
skipDistroPackage,
|
|
skipImage,
|
|
skipOstree,
|
|
test_main,
|
|
wait,
|
|
)
|
|
|
|
|
|
@skipDistroPackage()
|
|
class TestConnection(MachineCase):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.ws_executable = f"{self.libexecdir}/cockpit-ws"
|
|
|
|
def ostree_setup_ws(self):
|
|
'''Overlay cockpit-ws package on OSTree image
|
|
|
|
Disable the cockpit/ws container. This is for tests that don't work with the container,
|
|
and to make sure that overlaying cockpit-ws works as well.
|
|
'''
|
|
m = self.machine
|
|
if not m.ostree_image:
|
|
return
|
|
|
|
# uninstall cockpit/ws container startup script
|
|
m.execute("rm /etc/systemd/system/cockpit.service")
|
|
# overlay cockpit-ws rpm
|
|
m.execute("rpm-ostree install --cache-only /var/tmp/cockpit-ws-*.rpm", timeout=180)
|
|
m.reboot()
|
|
|
|
def assertNoAdminProcessLeaks(self):
|
|
'''Check that machine did not leak any bridges or ssh-agent admin processes'''
|
|
m = self.machine
|
|
try:
|
|
# there may still be user-wide ones like dbus-broker
|
|
m.execute("while pgrep -au admin '(cockpit|ssh-agent)'; do sleep 0.1; done", timeout=10)
|
|
except RuntimeError:
|
|
# show the leaked processes in the assertion
|
|
self.fail(m.execute("pgrep -au admin '(cockpit|ssh-agent)'"))
|
|
|
|
@skipBrowser("Firefox cannot work with cookies", "firefox")
|
|
def testBasic(self):
|
|
m = self.machine
|
|
|
|
# always test with the default ws install (container on OSTree, package everywhere else)
|
|
self.check_basic_with_start_stop(m.start_cockpit, m.stop_cockpit)
|
|
|
|
# on OSTree, also check with overlaid cockpit-ws rpm
|
|
if m.ostree_image:
|
|
def ws_start():
|
|
m.execute(r"""
|
|
mkdir -p /etc/systemd/system/cockpit.service.d/
|
|
printf "[Service]\nExecStart=\n%s --no-tls" `grep ExecStart= /lib/systemd/system/cockpit.service` \
|
|
> /etc/systemd/system/cockpit.service.d/notls.conf
|
|
systemctl daemon-reload
|
|
systemctl start cockpit.socket""")
|
|
|
|
def ws_stop():
|
|
m.execute("systemctl stop cockpit cockpit.socket")
|
|
|
|
self.ostree_setup_ws()
|
|
# HACK: Getting SELinux errors with just rpm-ostree install; there's a plethora of failures, so just allow them all
|
|
m.execute("setenforce 0")
|
|
self.allow_journal_messages('audit.*avc: denied .*')
|
|
self.check_basic_with_start_stop(ws_start, ws_stop)
|
|
|
|
def check_basic_with_start_stop(self, start_cockpit, stop_cockpit):
|
|
m = self.machine
|
|
b = self.browser
|
|
start_cockpit()
|
|
|
|
# take cockpit-ws down on the login page
|
|
b.open("/system")
|
|
b.wait_visible("#login")
|
|
b.set_val("#login-user-input", "admin")
|
|
b.set_val("#login-password-input", "foobar")
|
|
stop_cockpit()
|
|
b.click('#login-button')
|
|
b.wait_text_not('#login-fatal-message', "")
|
|
start_cockpit()
|
|
b.reload()
|
|
b.wait_visible("#login")
|
|
b.set_val("#login-user-input", "admin")
|
|
b.set_val("#login-password-input", "foobar")
|
|
b.click('#login-button')
|
|
b.enter_page("/system")
|
|
|
|
# cookie should not be marked as secure, it's not https
|
|
cookie = b.cookie("cockpit")
|
|
self.assertTrue(cookie["httpOnly"])
|
|
self.assertEqual(cookie["sameSite"], "Strict")
|
|
self.assertFalse(cookie["secure"])
|
|
|
|
# take cockpit-ws down on the server page
|
|
stop_cockpit()
|
|
b.switch_to_top()
|
|
b.wait_in_text(".curtains-ct h1", "Disconnected")
|
|
|
|
start_cockpit()
|
|
b.click("#machine-reconnect")
|
|
b.wait_visible("#login")
|
|
b.set_val("#login-user-input", "admin")
|
|
b.set_val("#login-password-input", "foobar")
|
|
|
|
# sever the connection on the login page
|
|
m.execute("iptables -w -I INPUT -p tcp --dport 9090 -j REJECT --reject-with tcp-reset")
|
|
b.click('#login-button')
|
|
b.wait_text_not('#login-fatal-message', "")
|
|
m.execute("iptables -w -D INPUT -p tcp --dport 9090 -j REJECT --reject-with tcp-reset")
|
|
b.reload()
|
|
b.wait_visible("#login")
|
|
b.set_val("#login-user-input", "admin")
|
|
b.set_val("#login-password-input", "foobar")
|
|
b.click('#login-button')
|
|
b.enter_page("/system")
|
|
|
|
# sever the connection on the server page
|
|
m.execute("iptables -w -I INPUT -p tcp --dport 9090 -j REJECT")
|
|
b.switch_to_top()
|
|
with b.wait_timeout(60):
|
|
b.wait_visible(".curtains-ct")
|
|
|
|
b.wait_in_text(".curtains-ct h1", "Disconnected")
|
|
b.wait_in_text('.curtains-ct .pf-v5-c-empty-state__body', "Connection has timed out.")
|
|
m.execute("iptables -w -D INPUT -p tcp --dport 9090 -j REJECT")
|
|
b.click("#machine-reconnect")
|
|
b.enter_page("/system")
|
|
b.logout()
|
|
|
|
# deleted cookie after logout should not be marked as secure, it's not https
|
|
cookie = b.cookie("cockpit")
|
|
self.assertEqual(cookie["value"], "deleted")
|
|
self.assertTrue(cookie["httpOnly"])
|
|
self.assertFalse(cookie["secure"])
|
|
|
|
self.assertNoAdminProcessLeaks()
|
|
|
|
if not m.ostree_image: # cannot write to /usr on OSTree, and cockpit-session is in a container
|
|
# damage cockpit-session permissions, expect generic error message
|
|
m.execute(f"chmod g-x {self.libexecdir}/cockpit-session")
|
|
b.open("/system")
|
|
b.wait_in_text('#login-fatal-message', "Internal error in login process")
|
|
m.execute(f"chmod g+x {self.libexecdir}/cockpit-session")
|
|
|
|
self.allow_journal_messages(".*cockpit-session: bridge program failed.*")
|
|
|
|
# pretend cockpit-bridge is not installed, expect specific error message
|
|
m.execute("while B=$(command -v cockpit-bridge); do mv $B ${B}.disabled; done")
|
|
b.open("/system")
|
|
b.wait_visible("#login")
|
|
b.set_val("#login-user-input", "admin")
|
|
b.set_val("#login-password-input", "foobar")
|
|
b.click('#login-button')
|
|
b.wait_visible('#login-fatal-message')
|
|
b.wait_text('#login-fatal-message', "The cockpit package is not installed")
|
|
m.execute("while B=$(command -v cockpit-bridge.disabled); do mv $B ${B%.disabled}; done")
|
|
|
|
# Lets crash a systemd-controlled process and see if we get a proper backtrace in the logs
|
|
# This helps with debugging failures in the tests elsewhere
|
|
m.write("/run/systemd/system/systemd-hostnamed.service.d/core.conf",
|
|
"[Service]\nLimitCORE=infinity\n")
|
|
m.execute("""systemctl daemon-reload
|
|
systemctl restart systemd-hostnamed
|
|
pkill -e -SEGV systemd-hostnam""")
|
|
wait(lambda: m.execute("journalctl -b | grep 'Process.*systemd-hostnam.*of user.*dumped core.'"))
|
|
|
|
# Make sure the core dumps exist in the directory, so we can download them
|
|
cores = m.execute("find /var/lib/systemd/coredump -type f")
|
|
self.assertNotEqual(cores, "")
|
|
|
|
self.allow_core_dumps = True
|
|
self.allow_journal_messages(".*org.freedesktop.hostname1.*DBus.Error.NoReply.*")
|
|
|
|
@skipOstree("OSTree doesn't use systemd units")
|
|
@nondestructive
|
|
def testUnitLifecycle(self):
|
|
m = self.machine
|
|
|
|
def expect_active(unit, is_active):
|
|
status = m.execute(f"systemctl is-active {unit} || true").strip()
|
|
self.assertIn(status, ["active", "inactive"])
|
|
if is_active:
|
|
self.assertEqual(status, "active", f"{unit} is not active")
|
|
else:
|
|
self.assertEqual(status, "inactive", f"{unit} is active")
|
|
|
|
def expect_actives(ws_socket, instance_sockets, http_instances, https_instances=0):
|
|
expect_active("cockpit.socket", ws_socket)
|
|
# http instances
|
|
for instance in ["http"]:
|
|
expect_active(f"cockpit-wsinstance-{instance}.socket", instance_sockets)
|
|
expect_active(f"cockpit-wsinstance-{instance}.service", instance in http_instances)
|
|
# number of https instances
|
|
expect_active("cockpit-wsinstance-https-factory.socket", instance_sockets)
|
|
for _type in ["service", "socket"]:
|
|
out = m.execute(f"systemctl --no-legend -t {_type} list-units cockpit-wsinstance-https@*")
|
|
count = len(out.strip().splitlines())
|
|
self.assertEqual(count, https_instances, out)
|
|
|
|
# at the beginning, no cockpit related units are running
|
|
m.stop_cockpit()
|
|
expect_actives(False, False, [])
|
|
|
|
# http only mode
|
|
|
|
m.start_cockpit(tls=False)
|
|
expect_actives(True, False, [])
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent --head http://127.0.0.1:9090"))
|
|
|
|
expect_actives(True, True, ["http"])
|
|
self.assertRaises(subprocess.CalledProcessError, m.execute,
|
|
"curl --silent https://127.0.0.1:9090")
|
|
# c-tls knows it can't do https, and not activate that instance
|
|
expect_actives(True, True, ["http"])
|
|
|
|
m.restart_cockpit()
|
|
expect_actives(True, True, ["http"])
|
|
|
|
m.stop_cockpit()
|
|
expect_actives(False, False, [])
|
|
|
|
# cleans up also when cockpit-tls crashes or idle-exits, not just by explicit stop request
|
|
m.start_cockpit(tls=False)
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent --head http://127.0.0.1:9090"))
|
|
m.execute("pkill -e cockpit-tls")
|
|
expect_actives(True, False, [])
|
|
|
|
# and recovers from that
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent --head http://127.0.0.1:9090"))
|
|
expect_actives(True, True, ["http"])
|
|
|
|
# https mode
|
|
|
|
m.start_cockpit(tls=True)
|
|
expect_actives(True, False, [], 0)
|
|
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent --head http://127.0.0.1:9090"))
|
|
|
|
expect_actives(True, True, ["http"], 0)
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent -k --head https://127.0.0.1:9090"))
|
|
expect_actives(True, True, ["http"], 1)
|
|
|
|
m.restart_cockpit()
|
|
expect_actives(True, True, ["http"], 1)
|
|
|
|
m.stop_cockpit()
|
|
expect_actives(False, False, [], 0)
|
|
|
|
m.start_cockpit(tls=True)
|
|
expect_actives(True, False, [], 0)
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent --head http://127.0.0.1:9090"))
|
|
|
|
expect_actives(True, True, ["http"], 0)
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent -k --head https://127.0.0.1:9090"))
|
|
expect_actives(True, True, ["http"], 1)
|
|
|
|
# cleans up also when cockpit-tls crashes or idle-exits, not just by explicit stop request
|
|
m.execute("pkill -e cockpit-tls")
|
|
expect_actives(True, False, [], 0)
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent --head http://127.0.0.1:9090"))
|
|
expect_actives(True, True, ["http"], 0)
|
|
# next https request after crash doesn't leak an instance
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent -k --head https://127.0.0.1:9090"))
|
|
expect_actives(True, True, ["http"], 1)
|
|
|
|
# instance service+socket going away does not confuse cockpit-tls' bookkeeping
|
|
m.execute("systemctl stop cockpit-wsinstance-https@*.service cockpit-wsinstance-https@*.socket")
|
|
expect_actives(True, True, ["http"], 0)
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent --show-error -k --head https://127.0.0.1:9090"))
|
|
expect_actives(True, True, ["http"], 1)
|
|
|
|
# sockets are inaccessible to users, only to cockpit-tls
|
|
for s in ["http.sock", "https-factory.sock"]:
|
|
out = m.execute(f"su -c '! nc -U /run/cockpit/wsinstance/{s} 2>&1 || exit 1' admin")
|
|
self.assertIn("Permission denied", out)
|
|
|
|
@skipOstree("OSTree doesn't use systemd units")
|
|
@nondestructive
|
|
def testHttpsInstanceDoS(self):
|
|
m = self.machine
|
|
# prevent generating core dump artifacts
|
|
orig = m.execute("cat /proc/sys/kernel/core_pattern").strip()
|
|
m.execute("echo core > /proc/sys/kernel/core_pattern")
|
|
self.addCleanup(m.execute, f"echo '{orig}' > /proc/sys/kernel/core_pattern")
|
|
m.start_cockpit(tls=True)
|
|
|
|
# some netcat versions need an explicit shutdown option, others default to shutting down and don't have -N
|
|
n_opt = "-N" if "-N" in m.execute("nc -h 2>&1") else ""
|
|
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent -k --head https://127.0.0.1:9090"))
|
|
|
|
# number of https instances is bounded (DoS prevention)
|
|
# with MaxTasks=200 und 2 threads per ws instance we should have a
|
|
# rough limit of 100 instances, so at some point curl should start failing
|
|
m.execute("su -s /bin/sh -c 'RC=1; for i in `seq 120`; do "
|
|
" echo -n $i | nc %s -U /run/cockpit/wsinstance/https-factory.sock;"
|
|
" curl --silent --head --max-time 5 --unix /run/cockpit/wsinstance/https@$i.sock http://dummy > /dev/null || RC=0; "
|
|
"done; exit $RC' cockpit-ws" % n_opt)
|
|
|
|
for type_ in ["socket", "service"]:
|
|
active = int(m.execute("systemctl --no-legend list-units -t %s --state=active "
|
|
"'cockpit-wsinstance-https@*' | wc -l" % type_).strip())
|
|
self.assertGreater(active, 45)
|
|
self.assertLess(active, 110)
|
|
failed = int(m.execute("systemctl --no-legend list-units --state=failed 'cockpit-wsinstance-https@*' | wc -l").strip())
|
|
self.assertGreater(failed, 0)
|
|
self.assertLess(failed, 75) # services and sockets
|
|
|
|
self.allow_journal_messages(".*cockpit-ws.*dumped core.*")
|
|
self.allow_journal_messages(".*Error creating thread: Resource temporarily unavailable.*")
|
|
|
|
# initial instance still works
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent --show-error -k --head https://127.0.0.1:9090"))
|
|
|
|
# can launch new instances after freeing up some old ones
|
|
m.execute("systemctl stop cockpit-wsinstance-https@30 cockpit-wsinstance-https@31 cockpit-wsinstance-https@32")
|
|
m.execute(f"echo -n new | nc {n_opt} -U /run/cockpit/wsinstance/https-factory.sock")
|
|
out = m.execute("curl --silent --show-error --head --unix /run/cockpit/wsinstance/https@new.sock http://dummy")
|
|
self.assertIn("HTTP/1.1 200 OK", out)
|
|
|
|
@skipBrowser("Firefox needs proper cert and CA", "firefox")
|
|
@nondestructive
|
|
def testTls(self):
|
|
m = self.machine
|
|
b = self.browser
|
|
|
|
# Start Cockpit with TLS, force cert regeneration
|
|
m.execute("rm -f /etc/cockpit/ws-certs.d/*")
|
|
m.start_cockpit(tls=True)
|
|
|
|
# A normal TLS connection works
|
|
output = m.execute('openssl s_client -connect 172.27.0.15:9090 2>&1')
|
|
m.message(output)
|
|
self.assertIn("DONE", output)
|
|
|
|
# has proper keyUsage and SAN (both with sscg and with self-signed)
|
|
output = m.execute("openssl s_client -showcerts -connect 172.27.0.15:9090 |"
|
|
"openssl x509 -noout -ext keyUsage,extendedKeyUsage,subjectAltName")
|
|
# keyUsage
|
|
self.assertIn("Digital Signature", output)
|
|
self.assertIn("Key Encipherment", output)
|
|
# extendedKeyUsage
|
|
self.assertIn("TLS Web Server Authentication", output)
|
|
# SAN
|
|
self.assertIn("IP Address:127.0.0.1", output)
|
|
self.assertIn("DNS:localhost", output)
|
|
|
|
# SSLv3 should not work
|
|
output = m.execute('openssl s_client -connect 172.27.0.15:9090 -ssl3 2>&1 || true')
|
|
self.assertNotIn("DONE", output)
|
|
|
|
# Some operating systems fail SSL3 on the server side
|
|
self.assertRegex(output, "Secure Renegotiation IS NOT supported|"
|
|
"ssl handshake failure|"
|
|
"[uU]nknown option.* -ssl3|"
|
|
"null ssl method passed|"
|
|
"wrong version number")
|
|
|
|
# RC4 should not work
|
|
output = m.execute('! openssl s_client -connect 172.27.0.15:9090 -tls1_2 -cipher RC4 2>&1')
|
|
self.assertNotIn("DONE", output)
|
|
self.assertRegex(
|
|
output, r"no cipher match|no ciphers available|ssl handshake failure|Cipher is \(NONE\)")
|
|
|
|
# get along with read-only config directory, as long as certificate exists
|
|
# this does not work on coreos as the user/group IDs are not mapped correctly
|
|
if not m.ostree_image:
|
|
m.stop_cockpit()
|
|
try:
|
|
m.execute("mount -o bind -r /etc/cockpit /etc/cockpit")
|
|
m.start_cockpit(tls=True)
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl -k --head https://127.0.0.1:9090"))
|
|
finally:
|
|
m.execute("umount /etc/cockpit")
|
|
|
|
# Install a certificate chain
|
|
m.upload(["verify/files/cert-chain.cert", "verify/files/cert-chain.key"], "/etc/cockpit/ws-certs.d")
|
|
|
|
def check_cert_chain():
|
|
# This should also reset the file context
|
|
m.restart_cockpit()
|
|
output = m.execute('openssl s_client -connect 172.27.0.15:9090 2>&1')
|
|
self.assertIn("DONE", output)
|
|
self.assertRegex(output, "s:/?CN *= *localhost")
|
|
self.assertRegex(output, "1 s:/?OU *= *Intermediate")
|
|
|
|
check_cert_chain()
|
|
|
|
# *.crt file also works
|
|
m.execute("mv /etc/cockpit/ws-certs.d/cert-chain.cert /etc/cockpit/ws-certs.d/cert-chain.crt")
|
|
check_cert_chain()
|
|
|
|
# backwards compat: merged cert+key file also still works with cockpit-tls (but not any more with cockpit-ws/container)
|
|
if not m.ostree_image:
|
|
m.execute("""cat /etc/cockpit/ws-certs.d/cert-chain.key >> /etc/cockpit/ws-certs.d/cert-chain.crt
|
|
chmod 640 /etc/cockpit/ws-certs.d/cert-chain.crt
|
|
chown root:cockpit-ws /etc/cockpit/ws-certs.d/cert-chain.crt
|
|
rm /etc/cockpit/ws-certs.d/cert-chain.key""")
|
|
check_cert_chain()
|
|
|
|
# certmonger generated certificate; asciibetically later than the above
|
|
# not all images have certmonger
|
|
if m.image not in ["debian-stable", "debian-testing", "fedora-coreos", "rhel4edge", "arch"]:
|
|
hostname = m.execute("hostname --fqdn").strip()
|
|
m.execute(f"getcert request -f /etc/cockpit/ws-certs.d/monger.cert -k /etc/cockpit/ws-certs.d/monger.key -D {hostname} --ca=local --wait")
|
|
self.addCleanup(m.execute, "getcert stop-tracking -f /etc/cockpit/ws-certs.d/monger.cert")
|
|
# cert generation succeeded, and it is being tracked
|
|
self.assertIn("MONITORING", m.execute("getcert list"))
|
|
self.assertIn("/etc/cockpit/ws-certs.d/monger.cert",
|
|
m.execute(f"{self.libexecdir}/cockpit-certificate-ensure --check"))
|
|
m.restart_cockpit()
|
|
output = m.execute('openssl s_client -connect 172.27.0.15:9090 2>&1')
|
|
self.assertIn("DONE", output)
|
|
self.assertRegex(output, f"s:/?CN *= {hostname}")
|
|
self.assertRegex(output, "i:/?CN *= Local Signing Authority.*")
|
|
|
|
# login handler: correct password
|
|
m.execute("curl -k -c cockpit.jar -s --head --header 'Authorization: Basic {}' https://127.0.0.1:9090/cockpit/login".format(
|
|
base64.b64encode(b"admin:foobar").decode(), ))
|
|
headers = m.execute("curl -k --head -b cockpit.jar -s https://127.0.0.1:9090/")
|
|
self.assertIn(
|
|
"default-src 'self' https://127.0.0.1:9090; connect-src 'self' https://127.0.0.1:9090 wss://127.0.0.1:9090", headers)
|
|
self.assertIn("Access-Control-Allow-Origin: https://127.0.0.1:9090", headers)
|
|
# CORP and Frame-Options are also set for dynamic paths
|
|
self.assertIn("Cross-Origin-Resource-Policy: same-origin", headers)
|
|
self.assertIn("X-Frame-Options: sameorigin", headers)
|
|
|
|
self.allow_journal_messages(
|
|
".*Peer failed to perform TLS handshake",
|
|
".*Peer sent fatal TLS alert:.*",
|
|
".*invalid base64 data in Basic header",
|
|
"Received unexpected TLS connection and no certificate was configured",
|
|
".*Error performing TLS handshake: No supported cipher suites have been found.",
|
|
".*Error performing TLS handshake: Could not negotiate a supported cipher suite.")
|
|
|
|
# check the Debian smoke test
|
|
m.upload(["../tools/debian/tests/smoke"], "/tmp")
|
|
m.execute("/tmp/smoke")
|
|
|
|
b.ignore_ssl_certificate_errors(True)
|
|
self.login_and_go("/system", tls=True)
|
|
cookie = b.cookie("cockpit")
|
|
# cookie should be marked as secure
|
|
self.assertTrue(cookie["httpOnly"])
|
|
self.assertTrue(cookie["secure"])
|
|
self.assertEqual(cookie["sameSite"], "Strict")
|
|
# same after logout
|
|
b.logout()
|
|
cookie = b.cookie("cockpit")
|
|
self.assertEqual(cookie["value"], "deleted")
|
|
self.assertTrue(cookie["httpOnly"])
|
|
self.assertTrue(cookie["secure"])
|
|
self.assertEqual(cookie["sameSite"], "Strict")
|
|
|
|
# http on localhost should not redirect to https
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --head http://127.0.0.1:9090"))
|
|
# http on other IP should redirect to https
|
|
output = m.execute("curl --head http://172.27.0.15:9090")
|
|
self.assertIn("HTTP/1.1 301 Moved Permanently", output)
|
|
self.assertIn("Location: https://172.27.0.15:9090/", output)
|
|
# enable AllowUnencrypted, this disables redirect
|
|
m.write("/etc/cockpit/cockpit.conf", "[WebService]\nAllowUnencrypted=true")
|
|
m.restart_cockpit()
|
|
# now it should not redirect
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --head http://127.0.0.1:9090"))
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --head http://172.27.0.15:9090"))
|
|
|
|
@nondestructive
|
|
def testConfigOrigins(self):
|
|
m = self.machine
|
|
m.write("/etc/cockpit/cockpit.conf", "[WebService]\nOrigins = http://other-origin:9090 http://localhost:9090")
|
|
m.start_cockpit()
|
|
headers = {
|
|
'Connection': 'Upgrade',
|
|
'Upgrade': 'websocket',
|
|
'Host': 'localhost:9090',
|
|
'Origin': 'http://other-origin:9090',
|
|
'Sec-Websocket-Key': '3sc2c9IzwRUc3BlSIYwtSA==',
|
|
'Sec-Websocket-Version': 13
|
|
}
|
|
output = m.curl('-f', '-N', 'http://localhost:9090/cockpit/socket', headers=headers)
|
|
self.assertIn('"no-session"', output)
|
|
|
|
# The socket should also answer at /socket
|
|
output = m.curl('-f', '-N', 'http://localhost:9090/socket', headers=headers)
|
|
self.assertIn('"no-session"', output)
|
|
|
|
self.allow_journal_messages('peer did not close io when expected')
|
|
|
|
@skipOstree("OSTree doesn't have cockpit-ws")
|
|
@nondestructive
|
|
def test100YearsCert(self):
|
|
m = self.machine
|
|
|
|
selfsign = '/etc/cockpit/ws-certs.d/0-self-signed.cert'
|
|
helper = f'{self.libexecdir}/cockpit-certificate-helper'
|
|
ensure = f'{self.libexecdir}/cockpit-certificate-ensure'
|
|
|
|
# Ensure things are as we expect them to be
|
|
m.execute('rm -f /etc/cockpit/ws-certs.d/*')
|
|
m.execute(f'grep DAYS=395 {helper}')
|
|
|
|
# Generate a 100 years expiry certificate
|
|
self.sed_file('s/DAYS=395/DAYS=36500/', helper)
|
|
m.execute(f'grep DAYS=36500 {helper}') # double-check
|
|
m.execute(ensure)
|
|
|
|
# Verify the expiry date to be in the far-future. This is a bit
|
|
# annoying due to the date format OpenSSL uses (which Python can't
|
|
# trivially parse) and the question of locales
|
|
expires = m.execute(f'date -d "$(openssl x509 -enddate -noout < {selfsign} | cut -f2 -d=)" +%s')
|
|
self.assertGreater(int(expires), time.time() + 99 * 365 * 24 * 60 * 60)
|
|
|
|
# Put things back: avoid problematic multiple invocations of .sed_file()
|
|
m.execute(f'sed -i s/DAYS=36500/DAYS=395/ {helper}')
|
|
m.execute(f'grep DAYS=395 {helper}') # double-check
|
|
|
|
# Run ensure again and make sure we get a new certificate
|
|
m.execute('touch /tmp/timestamp')
|
|
m.execute(ensure)
|
|
m.execute(f'test {selfsign} -nt /tmp/timestamp')
|
|
m.execute('rm /tmp/timestamp')
|
|
|
|
# Check that the expiry is less than 420 days
|
|
# See https://github.com/sgallagher/sscg/pull/28 for why 420
|
|
expires = m.execute(f'date -d "$(openssl x509 -enddate -noout < {selfsign} | cut -f2 -d=)" +%s')
|
|
self.assertLess(int(expires), time.time() + 420 * 24 * 60 * 60)
|
|
|
|
# Run ensure again and make sure we *don't* get a new certificate
|
|
m.execute('touch /tmp/timestamp')
|
|
m.execute(ensure)
|
|
m.execute(f'test ! {selfsign} -nt /tmp/timestamp')
|
|
m.execute('rm /tmp/timestamp')
|
|
|
|
@skipOstree("OSTree doesn't use systemd units")
|
|
@nondestructive
|
|
def testSocket(self):
|
|
m = self.machine
|
|
|
|
# non-admin user
|
|
m.execute("useradd user")
|
|
|
|
# enable no-password login for 'admin' and 'user'
|
|
m.execute("passwd -d admin")
|
|
m.execute("passwd -d user")
|
|
|
|
self.sed_file('$ a\\\nPermitEmptyPasswords yes', '/etc/ssh/sshd_config',
|
|
'systemctl restart sshd.service')
|
|
|
|
def assertInOrNot(string, result, expected):
|
|
if expected:
|
|
self.assertIn(string, result)
|
|
else:
|
|
self.assertNotIn(string, result)
|
|
|
|
def checkMotdForUser(string, user, expected):
|
|
result = m.execute(f"ssh -o StrictHostKeyChecking=no -n {user}@localhost")
|
|
assertInOrNot(string, result, expected)
|
|
|
|
def checkMotdContent(string, expected=True):
|
|
# Needs https://github.com/linux-pam/linux-pam/pull/292 (or PAM 1.5.0)
|
|
old_pam = (m.image in ['centos-8-stream', 'debian-stable', 'ubuntu-2204', 'rhel-8-7', 'rhel-8-8', 'rhel-8-9'])
|
|
|
|
# check issue (should be exactly the same as motd)
|
|
assertInOrNot(string, m.execute("cat /etc/issue.d/cockpit.issue"), expected)
|
|
|
|
# check motd as 'root' (via cat) and 'admin' and 'user' (via ssh)
|
|
assertInOrNot(string, m.execute("cat /etc/motd.d/cockpit"), expected)
|
|
checkMotdForUser(string, expected=expected, user='admin')
|
|
checkMotdForUser(string, expected=old_pam and expected or False, user='user')
|
|
|
|
m.stop_cockpit()
|
|
checkMotdContent('systemctl')
|
|
checkMotdContent(':9090/', expected=False)
|
|
m.start_cockpit()
|
|
|
|
checkMotdContent(':9090/')
|
|
checkMotdContent('systemctl', expected=False)
|
|
m.execute("systemctl stop cockpit.socket")
|
|
|
|
# Change port according to documentation: https://cockpit-project.org/guide/latest/listen.html
|
|
m.execute('! selinuxenabled || semanage port -m -t websm_port_t -p tcp 443')
|
|
self.write_file("/etc/systemd/system/cockpit.socket.d/listen.conf",
|
|
"[Socket]\nListenStream=\nListenStream=/run/cockpit/sock\nListenStream=443",
|
|
post_restore_action="systemctl stop cockpit.socket; systemctl daemon-reload")
|
|
|
|
checkMotdContent('systemctl')
|
|
checkMotdContent(':9090/', expected=False)
|
|
checkMotdContent(':443/', expected=False)
|
|
m.start_cockpit(tls=True)
|
|
|
|
checkMotdContent('systemctl', expected=False)
|
|
checkMotdContent(':9090/', expected=False)
|
|
checkMotdContent(':443/')
|
|
|
|
output = m.execute('curl -k https://localhost 2>&1 || true')
|
|
self.assertIn('Loading...', output)
|
|
output = m.execute('curl -k --unix /run/cockpit/sock https://dummy 2>&1 || true')
|
|
self.assertIn('Loading...', output)
|
|
|
|
output = m.execute('curl -k https://localhost:9090 2>/dev/null || echo $?')
|
|
self.assertIn('7', output.strip())
|
|
|
|
self.allow_journal_messages(".*Peer failed to perform TLS handshake")
|
|
|
|
@skipOstree("Can't remove/upgrade packages on OSTree")
|
|
def testWsPackage(self):
|
|
m = self.machine
|
|
|
|
# On RHEL-8 SSH allows password root login by default, so Cockpit does too.
|
|
ROOT_LOGIN_ENABLED = ['rhel-8', 'centos-8']
|
|
root_login_disallowed = not any(m.image.startswith(img) for img in ROOT_LOGIN_ENABLED)
|
|
|
|
if m.image.startswith("debian") or m.image.startswith("ubuntu"):
|
|
# clean up debug symbols, they get in the way of upgrading
|
|
m.execute("dpkg --purge cockpit-ws-dbgsym")
|
|
elif m.image.startswith("rhel"):
|
|
# subscription-manager-cockpit depends on cockpit-ws, and cockpit.rpm metapackage depends on sub-man on RHEL
|
|
m.execute("rpm --erase cockpit subscription-manager-cockpit")
|
|
|
|
def install():
|
|
if m.image.startswith("debian") or m.image.startswith("ubuntu"):
|
|
m.execute("dpkg --install /var/tmp/build/cockpit-ws_*.deb")
|
|
elif m.image == "arch":
|
|
m.execute("pacman -U --noconfirm /var/tmp/build/cockpit-*.pkg.tar.zst")
|
|
else:
|
|
m.execute("if rpm -q cockpit-ws; then rpm --verify cockpit-ws; fi")
|
|
m.execute("rpm --upgrade --force /var/tmp/build/cockpit-ws-*.rpm")
|
|
m.execute("rpm --verify cockpit-ws")
|
|
|
|
def remove():
|
|
if m.image.startswith("debian") or m.image.startswith("ubuntu"):
|
|
m.execute("dpkg --purge cockpit cockpit-ws")
|
|
elif m.image == "arch":
|
|
m.execute("pacman -Rdd --noconfirm cockpit")
|
|
elif m.image.startswith("rhel"):
|
|
m.execute("rpm --erase cockpit-ws")
|
|
else:
|
|
m.execute("rpm --erase cockpit cockpit-ws")
|
|
|
|
# clean install sets up dynamic motd/issue symlink and pam disallowed users.
|
|
self.assertIn('Activate the web console', m.execute("cat /etc/motd.d/cockpit"))
|
|
self.assertIn('Activate the web console', m.execute("cat /etc/issue.d/cockpit.issue"))
|
|
if root_login_disallowed:
|
|
self.assertIn('root', m.execute("cat /etc/cockpit/disallowed-users"))
|
|
# remove disallowed-users to simulate an upgrade where we did not have this file yet.
|
|
m.execute("rm /etc/cockpit/disallowed-users")
|
|
|
|
# package upgrade keeps them
|
|
install()
|
|
self.assertIn('Activate the web console', m.execute("cat /etc/motd.d/cockpit"))
|
|
self.assertIn('Activate the web console', m.execute("cat /etc/issue.d/cockpit.issue"))
|
|
# disallowed-users should not exists now as this is an upgrade
|
|
m.execute("test ! -e /etc/cockpit/disallowed-users")
|
|
m.execute("echo 'root' > /etc/cockpit/disallowed-users")
|
|
|
|
# HACK: On Arch Linux the symlink is overwritten, bug?
|
|
if m.image != "arch":
|
|
# manual change/removal is respected on upgrade
|
|
m.execute("ln -sf /dev/null /etc/motd.d/cockpit; rm /etc/issue.d/cockpit.issue")
|
|
install()
|
|
self.assertEqual(m.execute("readlink /etc/motd.d/cockpit").strip(), "/dev/null")
|
|
m.execute("test ! -e /etc/issue.d/cockpit.issue")
|
|
|
|
# removing the package cleans up the links
|
|
remove()
|
|
m.execute("test ! -e /etc/motd.d/cockpit")
|
|
m.execute("test ! -e /etc/issue.d/cockpit.issue")
|
|
m.execute("test ! -e /etc/cockpit/disallowed-users")
|
|
|
|
# fresh install (most of our test images have cockpit-ws preinstalled, so the first test above does not cover that)
|
|
install()
|
|
# verify that we installed relative links in the new package
|
|
self.assertEqual(m.execute("readlink /etc/motd.d/cockpit").strip(), "../../run/cockpit/motd")
|
|
self.assertEqual(m.execute("readlink /etc/issue.d/cockpit.issue").strip(), "../../run/cockpit/motd")
|
|
if root_login_disallowed:
|
|
self.assertIn('root', m.execute("cat /etc/cockpit/disallowed-users"))
|
|
|
|
@skipOstree("OSTree doesn't have cockpit-ws")
|
|
@nondestructive
|
|
def testCommandline(self):
|
|
m = self.machine
|
|
|
|
# Large requests are processed correctly with plain HTTP through cockpit-tls
|
|
m.start_cockpit(tls=True)
|
|
large_headers = {'Authorization': f'Negotiate {1:07000}'}
|
|
self.assertIn('id="login"', m.curl('http://localhost:9090/', headers=large_headers))
|
|
|
|
# Large requests are processed correctly with TLS through cockpit-tls
|
|
self.assertIn('id="login"', m.curl('-k', 'https://localhost:9090/', headers=large_headers))
|
|
m.stop_cockpit()
|
|
|
|
m.execute("rm -f /etc/cockpit/ws-certs.d/* /etc/cockpit/cockpit.conf")
|
|
m.write("/etc/cockpit/cockpit.conf", "[WebService]\nLoginTitle = A Custom Title\n")
|
|
|
|
m.execute(f"{self.libexecdir}/cockpit-certificate-ensure")
|
|
self.assertTrue(m.execute("ls /etc/cockpit/ws-certs.d/*"))
|
|
|
|
pid = m.spawn(f"{self.ws_executable} --port 9000 --address 127.0.0.1", "cockpit-ws.log")
|
|
self.addCleanup(m.execute, f"kill {pid}")
|
|
|
|
# The port may not be available immediately, so wait for it
|
|
wait(lambda: 'A Custom Title' in m.curl('-k', 'https://localhost:9000/'))
|
|
|
|
output = m.execute('curl -s -S -k https://172.27.0.15:9000/ 2>&1 || echo $?')
|
|
self.assertIn('7', output.strip())
|
|
|
|
# Large requests are processed correctly with plain HTTP
|
|
self.assertIn('A Custom Title', m.curl('http://localhost:9000/', headers=large_headers))
|
|
|
|
# Large requests are processed correctly with TLS
|
|
self.assertIn('A Custom Title', m.curl('-k', 'https://localhost:9000/', headers=large_headers))
|
|
|
|
@nondestructive
|
|
def testHeadRequest(self):
|
|
m = self.machine
|
|
m.start_cockpit()
|
|
|
|
# static handler
|
|
headers = m.execute("curl -s --head http://172.27.0.15:9090/cockpit/static/login.html")
|
|
self.assertIn("HTTP/1.1 200 OK\r\n", headers)
|
|
self.assertIn("Content-Type: text/html\r\n", headers)
|
|
self.assertIn("Cross-Origin-Resource-Policy: same-origin\r\n", headers)
|
|
self.assertIn("X-Frame-Options: sameorigin\r\n", headers)
|
|
# login.html is not always accessible as a file (e.g. on CoreOS), so just assert a reasonable content length
|
|
self.assertIn("Content-Length: ", headers)
|
|
length = int(headers.split('Content-Length: ', 1)[1].split()[0])
|
|
self.assertGreater(length, 5000)
|
|
self.assertLess(length, 100000)
|
|
|
|
# login handler: wrong password
|
|
headers = m.execute("curl -s --head --header 'Authorization: Basic {}' http://172.27.0.15:9090/cockpit/login".format(
|
|
base64.b64encode(b"admin:hahawrong").decode()))
|
|
self.assertRegex(headers, r"HTTP/1.1 (401 Authentication failed|403 Permission denied)\r\n")
|
|
self.assertNotIn("Set-Cookie:", headers)
|
|
|
|
# login handler: correct password
|
|
headers = m.execute("curl -s --head --header 'Authorization: Basic {}' http://172.27.0.15:9090/cockpit/login".format(
|
|
base64.b64encode(b"admin:foobar").decode()))
|
|
self.assertIn("HTTP/1.1 200 OK\r\n", headers)
|
|
self.assertIn("Set-Cookie: cockpit", headers)
|
|
|
|
# socket handler; this should refuse HEAD (as it makes little sense on sockets), so 404
|
|
headers = m.execute("curl -s --head http://172.27.0.15:9090/cockpit/socket")
|
|
self.assertIn("HTTP/1.1 404 Not Found\r\n", headers)
|
|
|
|
# external channel handler; unauthenticated, thus 404
|
|
headers = m.execute("curl -s --head http://172.27.0.15:9090/cockpit+123/channel/foo")
|
|
self.assertIn("HTTP/1.1 404 Not Found\r\n", headers)
|
|
|
|
@skipOstree("ssh root login not allowed")
|
|
@nondestructive
|
|
def testFlowControl(self):
|
|
m = self.machine
|
|
b = self.browser
|
|
|
|
self.login_and_go("/playground/speed", user="root", enable_root_login=True)
|
|
|
|
# Check the speed playground page
|
|
b.switch_to_top()
|
|
b.go("/playground/speed")
|
|
b.enter_page("/playground/speed")
|
|
|
|
b.wait_text_not("#pid", "")
|
|
pid = b.text("#pid")
|
|
|
|
b.set_val("#read-path", "/dev/vda")
|
|
b.click("#read-sideband")
|
|
|
|
b.wait_text_not("#speed", "")
|
|
time.sleep(20)
|
|
output = m.execute(f"cat /proc/{pid}/statm")
|
|
rss = int(output.split(" ")[0])
|
|
|
|
# This fails when flow control is not present
|
|
self.assertLess(rss, 250000)
|
|
|
|
@skipOstree("OSTree doesn't have cockpit-ws")
|
|
@nondestructive
|
|
def testLocalSession(self):
|
|
m = self.machine
|
|
|
|
# start ws with --local-session, let it spawn bridge; ensure that this works without /etc/cockpit/
|
|
m.spawn("su - -c 'G_MESSAGES_DEBUG=all XDG_CONFIG_DIRS=/usr/local %s -p 9999 -a 127.0.0.90 "
|
|
"--local-session=cockpit-bridge' admin" % self.ws_executable,
|
|
"cockpit-ws-local")
|
|
m.wait_for_cockpit_running('127.0.0.90', 9999)
|
|
# System frame should work directly, no login page
|
|
out = m.execute("curl --compressed http://127.0.0.90:9999/cockpit/@localhost/system/index.html")
|
|
self.assertIn('id="overview"', out)
|
|
|
|
# shut it down, wait until it is gone
|
|
m.execute("pkill cockpit-ws; while pgrep -a cockpit-ws; do sleep 1; done")
|
|
|
|
# start ws with --local-session and existing running bridge
|
|
self.write_file("/tmp/local.sh", f'''#!/bin/bash -eu
|
|
coproc env G_MESSAGES_DEBUG=all cockpit-bridge
|
|
export G_MESSAGES_DEBUG=all
|
|
export XDG_CONFIG_DIRS=/usr/local
|
|
{self.ws_executable} -p 9999 -a 127.0.0.90 --local-session=- <&${{COPROC[0]}} >&${{COPROC[1]}}
|
|
''')
|
|
m.execute("chmod a+x /tmp/local.sh")
|
|
m.spawn("su - -c /tmp/local.sh admin", "local.sh")
|
|
m.wait_for_cockpit_running('127.0.0.90', 9999)
|
|
|
|
# System frame should work directly, no login page
|
|
out = m.execute("curl --compressed http://127.0.0.90:9999/cockpit/@localhost/system/index.html")
|
|
self.assertIn('id="overview"', out)
|
|
|
|
# shut it down, wait until it is gone
|
|
m.execute("pkill cockpit-ws; while pgrep -a cockpit-ws; do sleep 1; done")
|
|
|
|
self.allow_journal_messages("couldn't register polkit authentication agent.*")
|
|
|
|
@skipOstree("OSTree doesn't have cockpit-ws")
|
|
@skipImage("Kernel does not allow user namespaces", "debian-*")
|
|
@nondestructive
|
|
def testCockpitDesktop(self):
|
|
m = self.machine
|
|
m.stop_cockpit()
|
|
|
|
cases = [(['/cockpit/@localhost/system/index.html', 'system', 'system/index', 'system/'],
|
|
['id="overview"']
|
|
),
|
|
(['/cockpit/@localhost/network/firewall.html', 'network/firewall'],
|
|
['div id="firewall"', 'script src="firewall.js"']
|
|
),
|
|
(['/cockpit/@localhost/playground/react-patterns.html', 'playground/react-patterns'],
|
|
['script src="react-patterns.js"']
|
|
),
|
|
# no ssh host
|
|
(['/cockpit/@localhost/manifests.json'],
|
|
['"system"', '"Overview"']
|
|
),
|
|
# remote ssh host
|
|
(['/cockpit/@localhost/manifests.json test1@localhost'],
|
|
['"system"', '"Overview"', '"HACK"']
|
|
)
|
|
]
|
|
|
|
# prepare fake ssh target; to verify that we really use that, fake dashboard manifest
|
|
m.execute("""set -e; useradd test1
|
|
[ -f ~admin/.ssh/id_rsa ] || su -c "ssh-keygen -t rsa -N '' -f ~/.ssh/id_rsa" admin
|
|
mkdir -p ~test1/.ssh ~test1/.local/share/cockpit/dashboard
|
|
echo '{ "version": "42", "dashboard": { "index": { "label": "HACK" } } }' > ~test1/.local/share/cockpit/dashboard/manifest.json
|
|
cp ~admin/.ssh/id_rsa.pub ~test1/.ssh/authorized_keys
|
|
ssh-keyscan localhost >> ~admin/.ssh/known_hosts
|
|
chown admin:admin ~admin/.ssh/known_hosts
|
|
chown -R test1:test1 ~test1
|
|
su -c "ssh test1@localhost cockpit-bridge --packages" admin | grep -q test1.*dashboard # validate setup
|
|
""")
|
|
|
|
is_pybridge = self.is_pybridge()
|
|
|
|
for (pages, asserts) in cases:
|
|
for page in pages:
|
|
m.execute(f'''su - -c 'BROWSER="curl --silent --compressed -o /tmp/out.html" {self.libexecdir}/cockpit-desktop {page}' admin''',
|
|
timeout=10)
|
|
|
|
out = m.execute("cat /tmp/out.html")
|
|
for a in asserts:
|
|
self.assertIn(a, out)
|
|
|
|
if is_pybridge:
|
|
# FIXME: the C bridge leaks ssh-agent
|
|
self.assertNoAdminProcessLeaks()
|
|
|
|
# cockpit-desktop can start a privileged bridge through polkit
|
|
# we don't have an agent, so just allow the privilege without interactive authentication
|
|
if m.image == "arch":
|
|
self.write_file("/etc/polkit-1/rules.d/test.rules", r"""
|
|
polkit.addRule(function(action, subject) {
|
|
if (action.id == "org.cockpit-project.cockpit.root-bridge" && subject.user == "admin")
|
|
return polkit.Result.YES;
|
|
}); """)
|
|
else:
|
|
self.write_file("/etc/polkit-1/localauthority/50-local.d/test.pkla", r"""
|
|
[Testing without an agent]
|
|
Identity=unix-user:admin
|
|
Action=org.cockpit-project.cockpit.root-bridge
|
|
ResultAny=yes
|
|
ResultInactive=yes
|
|
ResultActive=yes""")
|
|
|
|
self.write_file("/tmp/browser.sh", """#!/bin/sh -e
|
|
curl --silent --compressed -o /tmp/out.html "$@"
|
|
# wait until privileged bridge starts
|
|
until pgrep -f '^(/usr/[^ ]+/[^ /]*python[^ /]* )?/usr/bin/cockpit-bridge'; do sleep 1; done
|
|
""")
|
|
m.execute("chmod 755 /tmp/browser.sh")
|
|
m.execute(f"su - -c 'BROWSER=/tmp/browser.sh {self.libexecdir}/cockpit-desktop system' admin", timeout=10)
|
|
self.assertIn('id="overview"', m.execute("cat /tmp/out.html"))
|
|
|
|
if is_pybridge:
|
|
# FIXME: the C bridge leaks ssh-agent
|
|
self.assertNoAdminProcessLeaks()
|
|
|
|
self.allow_journal_messages("couldn't register polkit authentication agent.*")
|
|
self.allow_journal_messages("Refusing to render service to dead parents.")
|
|
self.allow_journal_messages(".*No authentication agent found.*")
|
|
self.allow_journal_messages(".*Peer failed to perform TLS handshake.*")
|
|
self.allow_journal_messages(r".*cannot reauthorize identity\(s\): unix-user:.*")
|
|
self.allow_journal_messages("admin: Executing command .*COMMAND=.*cockpit-bridge --privileged.*")
|
|
|
|
@skipBrowser("Firefox needs proper cert and CA", "firefox")
|
|
def testReverseProxy(self):
|
|
m = self.machine
|
|
b = self.browser
|
|
|
|
self.ostree_setup_ws()
|
|
|
|
# set up a poor man's reverse TLS proxy with socat
|
|
m.upload(["../src/bridge/mock-server.crt", "../src/bridge/mock-server.key"], "/tmp")
|
|
m.spawn("socat OPENSSL-LISTEN:9090,reuseaddr,fork,cert=/tmp/mock-server.crt,"
|
|
"key=/tmp/mock-server.key,verify=0 TCP:localhost:9099",
|
|
"socat-tls.log")
|
|
|
|
# and another proxy for plain http
|
|
m.spawn("socat TCP-LISTEN:9091,reuseaddr,fork TCP:localhost:9099", "socat.log")
|
|
|
|
# ws with plain --no-tls should fail after login with mismatching Origin (expected http, got https)
|
|
m.spawn(f"su -s /bin/sh -c '{self.ws_executable} --no-tls -p 9099' cockpit-wsinstance",
|
|
"ws-notls.log")
|
|
m.wait_for_cockpit_running(tls=True)
|
|
|
|
b.ignore_ssl_certificate_errors(True)
|
|
b.open(f"https://{b.address}:{b.port}/system")
|
|
b.wait_visible("#login")
|
|
b.set_val("#login-user-input", "admin")
|
|
b.set_val("#login-password-input", "foobar")
|
|
b.click('#login-button')
|
|
|
|
def check_wss_log():
|
|
for log in self.browser.get_js_log():
|
|
if 'Error during WebSocket handshake: Unexpected response code: 403' in log:
|
|
return True
|
|
return False
|
|
wait(check_wss_log)
|
|
|
|
wait(lambda: m.execute("grep 'received request from bad Origin' /var/log/ws-notls.log"))
|
|
|
|
# sanity check: unencrypted http through SSL proxy does not work
|
|
m.execute("! curl http://localhost:9090")
|
|
|
|
# does not redirect to https (through plain http proxy)
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent --head http://127.0.0.1:9091"))
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent --head http://172.27.0.15:9091"))
|
|
|
|
m.execute("pkill -e cockpit-ws; while pgrep -a cockpit-ws; do sleep 1; done")
|
|
# this page failure is reeally noisy
|
|
self.allow_journal_messages(".*No authentication agent found.*")
|
|
self.allow_journal_messages("couldn't register polkit authentication agent.*")
|
|
self.allow_journal_messages("received request from bad Origin.*")
|
|
self.allow_journal_messages(".*invalid handshake.*")
|
|
self.allow_browser_errors(".*received unsupported version in init message.*")
|
|
self.allow_browser_errors(".*received message before init.*")
|
|
self.allow_browser_errors("Error reading machine id")
|
|
|
|
# ws with --for-tls-proxy accepts only https origins, thus should work
|
|
m.spawn(f"su -s /bin/sh -c '{self.ws_executable} --for-tls-proxy -p 9099 -a 127.0.0.1' cockpit-wsinstance",
|
|
"ws-fortlsproxy.log")
|
|
m.wait_for_cockpit_running(tls=True)
|
|
b.open(f"https://{b.address}:{b.port}/system")
|
|
b.wait_visible("#login")
|
|
b.set_val("#login-user-input", "admin")
|
|
b.set_val("#login-password-input", "foobar")
|
|
b.click('#login-button')
|
|
b.wait_visible('#content')
|
|
b.enter_page("/system")
|
|
# cookie should be marked as secure, as for the browser it's https
|
|
cookie = b.cookie("cockpit")
|
|
self.assertTrue(cookie["httpOnly"])
|
|
self.assertTrue(cookie["secure"])
|
|
b.logout()
|
|
# deleted cookie after logout should be marked as secure
|
|
cookie = b.cookie("cockpit")
|
|
self.assertEqual(cookie["value"], "deleted")
|
|
self.assertTrue(cookie["httpOnly"])
|
|
self.assertTrue(cookie["secure"])
|
|
|
|
# should have https:// URLs in Content-Security-Policy
|
|
out = m.execute("curl --insecure --head https://localhost:9090/")
|
|
self.assertIn("Content-Security-Policy: connect-src 'self' https://localhost:9090 wss://localhost:9090;", out)
|
|
|
|
# sanity check: does not redirect to https (through plain http proxy) -- this isn't a supported mode, though!
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent --head http://127.0.0.1:9091"))
|
|
self.assertIn("HTTP/1.1 200 OK", m.execute("curl --silent --head http://172.27.0.15:9091"))
|
|
|
|
@nondestructive
|
|
def testCaCert(self):
|
|
m = self.machine
|
|
|
|
# force cert regeneration
|
|
m.execute("systemctl stop cockpit; rm -f /etc/cockpit/ws-certs.d/*")
|
|
m.start_cockpit()
|
|
if not m.ostree_image:
|
|
# Really start Cockpit to make sure it has generated all its certificates.
|
|
m.execute("systemctl start cockpit")
|
|
|
|
# Start without a CA certificate.
|
|
self.addCleanup(m.execute, "rm -f /etc/cockpit/ws-certs.d/0-self-signed-ca.pem")
|
|
m.execute("rm -f /etc/cockpit/ws-certs.d/0-self-signed-ca.pem")
|
|
m.execute("! curl -sfS http://localhost:9090/ca.cer")
|
|
|
|
# Now make one up and check that is is served.
|
|
m.write("/etc/cockpit/ws-certs.d/0-self-signed-ca.pem", "FAKE CERT FOR TESTING\n")
|
|
self.assertEqual(m.execute("curl -sfS http://localhost:9090/ca.cer"), "FAKE CERT FOR TESTING\n")
|
|
|
|
@nondestructive
|
|
def test_branding(self):
|
|
m = self.machine
|
|
m.start_cockpit()
|
|
|
|
# for all of our CI images, the part before the dash is the name of the
|
|
# subdirectory in /usr/share/cockpit/branding.
|
|
brand = m.image.split('-')[0]
|
|
branddir = f'/usr/share/cockpit/branding/{brand}'
|
|
|
|
# We don't have access to the stuff on the filesystem if it's living in
|
|
# the container.
|
|
if not m.ostree_image:
|
|
# make sure that there are no broken links in "our" directory
|
|
self.assertEqual(m.execute(f'find {branddir} -xtype l'), '')
|
|
|
|
# branding.css undergoes variable substitution based on the content of
|
|
# /usr/lib/os-release. Perform the substitution for ourselves for
|
|
# validation. envsubst comes from gettext.
|
|
os_release_vars = m.execute("cat /usr/lib/os-release").replace('\n', ' ')
|
|
m.execute(f'{os_release_vars} envsubst < {branddir}/branding.css > /tmp/branding.ref')
|
|
self.addCleanup(m.execute, "rm /tmp/branding.ref")
|
|
|
|
# fetch some files and make sure they match what we expect
|
|
def curl_and_compare(name, content_type=None, reference=None):
|
|
url = f'http://localhost:9090/cockpit/static/{name}'
|
|
reference = reference or f'{branddir}/{name}'
|
|
|
|
# Check that the expected content type is served
|
|
if content_type is not None:
|
|
self.assertIn(f'Content-Type: {content_type}', m.execute(f'curl --head {url}'))
|
|
|
|
# Check that we can fetch the file
|
|
m.execute(f'curl --fail -o /tmp/{name} {url}')
|
|
self.addCleanup(m.execute, f"rm /tmp/{name}")
|
|
|
|
# compare that it matches what we expected
|
|
if not m.ostree_image:
|
|
m.execute(f'cmp /tmp/{name} {reference}')
|
|
|
|
# some brands miss the images, but the OSes we CI have them all
|
|
curl_and_compare('branding.css', 'text/css', reference='/tmp/branding.ref')
|
|
curl_and_compare('logo.png', 'image/png')
|
|
curl_and_compare('favicon.ico')
|
|
|
|
# do a pixel test to make sure everything looks like we expect
|
|
b = self.browser
|
|
b.open("/system")
|
|
b.wait_visible("#login")
|
|
b.assert_pixels("body", "login-screen")
|
|
|
|
@skipOstree("no cockpit-ws package")
|
|
@nondestructive
|
|
def testAuthUnixPath(self):
|
|
'''test UnixPath for auth method in cockpit.conf'''
|
|
m = self.machine
|
|
|
|
m.execute(['systemctl', 'start', 'cockpit-session.socket'])
|
|
self.addCleanup(m.execute, 'systemctl stop cockpit-session.socket')
|
|
m.write('/etc/cockpit/cockpit.conf', '''
|
|
[Negotiate]
|
|
Action=none
|
|
|
|
[Basic]
|
|
UnixPath=/run/cockpit/session
|
|
''')
|
|
|
|
# make sure this isn't being run via spawning
|
|
m.execute(f'chmod 700 {self.libexecdir}/cockpit-session')
|
|
self.addCleanup(m.execute, f'chmod 4750 {self.libexecdir}/cockpit-session')
|
|
|
|
m.start_cockpit()
|
|
self.login_and_go("/system")
|
|
|
|
|
|
@skipDistroPackage()
|
|
class TestReverseProxy(MachineCase):
|
|
|
|
provision = {
|
|
"0": {"forward": {"443": 8443}}
|
|
}
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
m = self.machine
|
|
|
|
m.execute("if firewall-cmd --state >/dev/null 2>&1; then firewall-cmd --add-service https; fi")
|
|
|
|
m.upload(["../src/tls/ca/alice.pem", "../src/tls/ca/alice.key"], "/etc/pki")
|
|
|
|
m.write("/etc/cockpit/cockpit.conf", """[WebService]
|
|
Origins = https://%(origin)s wss://%(origin)s
|
|
ForwardedForHeader = X-Forwarded-For
|
|
ProtocolHeader = X-Forwarded-Proto
|
|
""" % {"origin": m.forward["443"]}, append=True)
|
|
|
|
m.execute("setsebool -P httpd_can_network_connect on")
|
|
self.allow_journal_messages("audit.*bool=httpd_can_network_connect.*val=1.*")
|
|
|
|
def callProxyCurl(self, path, *args):
|
|
# should use nginx' certificate, not cockpit's; use --resolve so that SNI matches the certificate's CN
|
|
(https_host, https_port) = self.machine.forward["443"].split(':')
|
|
return subprocess.check_output(
|
|
["curl", "--verbose",
|
|
"--resolve", f"alice:{https_port}:{https_host}",
|
|
"--cacert", os.path.join(TEST_DIR, "../src/tls/ca/ca.pem"),
|
|
*args,
|
|
f"https://alice:{https_port}{path}"],
|
|
stderr=subprocess.STDOUT)
|
|
|
|
def checkCockpitOnProxy(self, urlroot="", login=True):
|
|
b = self.new_browser()
|
|
|
|
out = self.callProxyCurl(f"{urlroot}/cockpit/static/login.html", "--head")
|
|
self.assertIn(b"HTTP/1.1 200 OK", out)
|
|
self.assertIn(b"subject: CN=alice; DC=COCKPIT", out)
|
|
|
|
# works with browser (but we can't set our CA)
|
|
b.ignore_ssl_certificate_errors(True)
|
|
(https_host, https_port) = self.machine.forward["443"].split(':')
|
|
b.open(f"https://{https_host}:{https_port}{urlroot}/system")
|
|
if login:
|
|
b.wait_visible("#login")
|
|
b.set_val("#login-user-input", "admin")
|
|
b.set_val("#login-password-input", "foobar")
|
|
b.click('#login-button')
|
|
b.wait_visible('#content')
|
|
# Verify that the urlRoot is applied to links in the navbar.
|
|
b.wait_visible(f'#host-apps a[href="{urlroot}/system"]')
|
|
|
|
if login:
|
|
# check that we show up in the system logs from the browser's IP, not the proxy's
|
|
self.assertIn('(172.27.0.2)', self.machine.execute('who'))
|
|
b.logout()
|
|
b.cdp.invoke("Browser.close")
|
|
|
|
@skipImage("nginx not installed", "centos-8-stream", "rhel-*", "debian-*", "ubuntu-*", "arch")
|
|
@skipOstree("nginx not installed")
|
|
@skipBrowser("Firefox needs proper cert and CA", "firefox")
|
|
def testNginxTLS(self):
|
|
'''test proxying to Cockpit with TLS
|
|
|
|
As described on https://github.com/cockpit-project/cockpit/wiki/Proxying-Cockpit-over-NGINX
|
|
This use use case is important for proxying a remote machine.
|
|
'''
|
|
m = self.machine
|
|
|
|
m.write("/etc/nginx/conf.d/cockpit.conf", """
|
|
server {
|
|
listen 443 ssl;
|
|
server_name %(origin)s;
|
|
root /srv/www;
|
|
|
|
ssl_certificate "/etc/pki/alice.pem";
|
|
ssl_certificate_key "/etc/pki/alice.key";
|
|
|
|
location / {
|
|
# Required to proxy the connection to Cockpit
|
|
proxy_pass https://127.0.0.1:9090;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
|
|
# Required for web sockets to function
|
|
proxy_http_version 1.1;
|
|
proxy_buffering off;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
|
|
# Pass ETag header from Cockpit to clients.
|
|
# See: https://github.com/cockpit-project/cockpit/issues/5239
|
|
gzip off;
|
|
}
|
|
}
|
|
""" % {"origin": m.forward["443"]})
|
|
|
|
m.execute("systemctl start nginx")
|
|
m.start_cockpit(tls=True)
|
|
self.checkCockpitOnProxy()
|
|
|
|
# now test with UrlRoot
|
|
m.write("/etc/cockpit/cockpit.conf", "UrlRoot = cockpit-root\n", append=True)
|
|
m.execute("systemctl stop cockpit.service")
|
|
self.sed_file("s_location /_location /cockpit-root_", "/etc/nginx/conf.d/cockpit.conf",
|
|
"systemctl restart nginx")
|
|
self.checkCockpitOnProxy(urlroot="/cockpit-root")
|
|
|
|
# get a non-cockpit file from the server
|
|
m.execute("mkdir -p /srv/www/embed-cockpit")
|
|
m.upload(["verify/files/embed-cockpit/index.html",
|
|
"verify/files/embed-cockpit/embed.js",
|
|
"verify/files/embed-cockpit/embed.css"],
|
|
"/srv/www/embed-cockpit/")
|
|
m.execute("if selinuxenabled 2>&1; then chcon -R -t httpd_sys_content_t /srv/www; fi")
|
|
|
|
out = self.callProxyCurl("/embed-cockpit/embed.css")
|
|
self.assertIn(b"HTTP/1.1 200 OK", out)
|
|
self.assertIn(b"#embed-links", out)
|
|
|
|
# embedding
|
|
b = self.browser
|
|
b.ignore_ssl_certificate_errors(True)
|
|
(https_host, https_port) = self.machine.forward["443"].split(':')
|
|
b.open(f"https://{https_host}:{https_port}/embed-cockpit/index.html")
|
|
b.set_val("#embed-address", f"https://{https_host}:{https_port}/cockpit-root")
|
|
b.click("#embed-full")
|
|
b.wait_visible("iframe[name='embed-full'][loaded]")
|
|
b.switch_to_frame("embed-full")
|
|
|
|
b.wait_visible("#login")
|
|
b.set_val("#login-user-input", "admin")
|
|
b.set_val("#login-password-input", "foobar")
|
|
b.click('#login-button')
|
|
b.wait_visible('.pf-v5-c-card.system-health')
|
|
|
|
@skipImage("nginx not installed", "centos-8-stream", "rhel-*", "debian-*", "ubuntu-*", "arch")
|
|
@skipOstree("nginx not installed")
|
|
@skipBrowser("Firefox needs proper cert and CA", "firefox")
|
|
def testNginxNoTLS(self):
|
|
'''test proxying to Cockpit with plain HTTP
|
|
|
|
This can be done when nginx and cockpit run on the same machine.
|
|
'''
|
|
m = self.machine
|
|
|
|
m.write("/etc/nginx/conf.d/cockpit.conf", """
|
|
server {
|
|
listen 443 ssl;
|
|
server_name %(origin)s;
|
|
|
|
ssl_certificate "/etc/pki/alice.pem";
|
|
ssl_certificate_key "/etc/pki/alice.key";
|
|
|
|
location / {
|
|
# Required to proxy the connection to Cockpit
|
|
proxy_pass http://127.0.0.1:9090;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
|
|
# Required for web sockets to function
|
|
proxy_http_version 1.1;
|
|
proxy_buffering off;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
|
|
# Pass ETag header from Cockpit to clients.
|
|
# See: https://github.com/cockpit-project/cockpit/issues/5239
|
|
gzip off;
|
|
}
|
|
}
|
|
""" % {"origin": m.forward["443"]})
|
|
|
|
m.execute("systemctl start nginx")
|
|
|
|
def run_ws(extra_opts=""):
|
|
m.spawn(f"su -s /bin/sh -c '{self.libexecdir}/cockpit-ws --address=127.0.0.1 --for-tls-proxy {extra_opts}' cockpit-wsinstance", "ws.log")
|
|
m.wait_for_cockpit_running()
|
|
|
|
def kill_ws():
|
|
m.execute("pkill cockpit-ws; while pgrep -a cockpit-ws; do sleep 1; done")
|
|
|
|
# start cockpit-ws in proxy mode, skip all the ws-certs.d/ steps
|
|
run_ws()
|
|
self.checkCockpitOnProxy()
|
|
kill_ws()
|
|
|
|
# works also without the login page (krb, oauth, --local-session, etc.)
|
|
run_ws("--local-session=cockpit-bridge")
|
|
self.checkCockpitOnProxy(login=False)
|
|
kill_ws()
|
|
|
|
# UrlRoot + login page
|
|
m.write("/etc/cockpit/cockpit.conf", "UrlRoot = myroot\n", append=True)
|
|
self.sed_file("s_location /_location /myroot_", "/etc/nginx/conf.d/cockpit.conf",
|
|
"systemctl restart nginx")
|
|
run_ws()
|
|
self.checkCockpitOnProxy(urlroot="/myroot")
|
|
kill_ws()
|
|
|
|
# UrlRoot without login page
|
|
run_ws("--local-session=cockpit-bridge")
|
|
self.checkCockpitOnProxy(urlroot="/myroot", login=False)
|
|
kill_ws()
|
|
|
|
self.allow_restart_journal_messages()
|
|
self.allow_journal_messages("couldn't register polkit authentication agent.*")
|
|
self.allow_journal_messages("couldn't change to runtime dir.*Permission denied")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
test_main()
|