#!/usr/bin/python2
# -*- coding: utf-8 -*-

# 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 parent
from testlib import *

import re
import subprocess

WAIT_KRB_SCRIPT = """
set -ex
# HACK: This needs to work, but may take a minute
for x in $(seq 1 60); do
    if getent passwd admin@cockpit.lan; then
        break
    fi
    if systemctl --quiet is-failed sssd.service; then
        systemctl status --lines=100 sssd.service >&2
        exit 1
    fi
    sleep $x
done

# This directory should be owned by the domain user
getent passwd admin@cockpit.lan
chown -R admin@cockpit.lan /home/admin

# HACK: This needs to work but may take a minute
for x in $(seq 1 60); do
    if ssh -oStrictHostKeyChecking=no -oBatchMode=yes -l admin@cockpit.lan x0.cockpit.lan true; then
        break
    fi
    sleep $x
done
"""


@skipImage("No realmd available", "continuous-atomic", "fedora-atomic", "rhel-atomic")
@skipImage("No freeipa available", "debian-stable", "debian-testing")
class TestRealms(MachineCase):
    provision = {
        "0": { "address": "10.111.113.1/20", "dns": "10.111.112.100" },
        "ipa": { "image": "ipa", "memory_mb": 2048 }
    }

    def addSudoRule(self):
        '''Allow "admins" IPA group members to run sudo'''

        # HACK: https://pagure.io/freeipa/issue/7538
        # This is an "unbreak my setup" step and ought to happen by default.
        # Do that on the IPA server instead of the client:
        #  - This avoids interfering too much with the client and test
        #  - It is a more realistic scenario anyway (admins configuring everything
        #    before clients join)
        #  - It avoids long propagation delays
        #  - It also works for Ubuntu clients that don't have the `ipa` CLI tool

        ipa_machine = self.machines['ipa']
        # need to wait for IPA to start up
        ipa_machine.execute("while ! echo foobarfoo | kinit -f admin; do sleep 5; done", timeout=300)
        ipa_machine.execute('export LC_ALL=C.UTF-8 && while ! ipa user-find >/dev/null; do sleep 5; done', timeout=300)
        ipa_machine.execute('export LC_ALL=C.UTF-8 && ipa sudorule-add --hostcat=all --cmdcat=all All && ipa sudorule-add-user --groups=admins All')

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

        self.addSudoRule()

        m.execute("hostnamectl set-hostname x0.cockpit.lan")
        self.login_and_go("/system")

        # Wait for DNS to work as expected.
        # https://bugzilla.redhat.com/show_bug.cgi?id=1071356#c11
        #
        wait(lambda: m.execute("nslookup -type=SRV _ldap._tcp.cockpit.lan"))

        def wait_number_domains(n):
            if n == 0:
                b.wait_text("#system-info-domain a", "Join Domain")
            else:
                b.wait_text_not("#system-info-domain a", "Join Domain")
            b.wait_not_attr("#system-info-domain a", "disabled", "disabled")

        wait_number_domains(0)

        # Join cockpit.lan
        b.click("#system-info-domain a")
        b.wait_popup("realms-op")
        with b.wait_timeout(180):
            b.set_val(".realms-op-address", "cockpit.lan")
            b.wait_attr(".realms-op-admin", "placeholder", 'e.g. "admin"')
            b.set_val(".realms-op-admin", "admin")
            b.set_val(".realms-op-admin-password", "foobarfoo")
            b.wait_not_visible(".realms-op-leave-only-row")
            b.click(".realms-op-apply")
            b.wait_popdown("realms-op")

            # Check that this has worked
            wait_number_domains(1)

        # should not have any leftover tickets from the joining
        m.execute("! klist")
        m.execute("! su -c klist admin")
        b.logout()

        if m.image in ["fedora-28", "fedora-testing"]:
            # HACK: fix authselect/IPA regression (https://bugzilla.redhat.com/show_bug.cgi?id=1582111)
            self.machine.execute("authselect enable-feature with-sudo")

        # the following requires the "ipa" tool and fixed "admin" permission check (PR #9127)
        if m.image not in ["rhel-7-5", "ubuntu-1604"]:
            # validate Kerberos setup for ws
            m.execute("echo foobarfoo | kinit -f admin@COCKPIT.LAN")
            m.execute(script=WAIT_KRB_SCRIPT, timeout=300)

            # should have added SPN to ws keytab
            output = m.execute(['klist', '-k', '/etc/cockpit/krb5.keytab'])
            self.assertIn('HTTP/x0.cockpit.lan@COCKPIT.LAN', output)

            # kerberos login should work
            output = m.execute(['curl', '-s', '--negotiate', '--delegation', 'always', '-u', ':', "-D", "-",
                                'http://x0.cockpit.lan:9090/cockpit/login'])
            self.assertIn("HTTP/1.1 200 OK", output)
            self.assertIn('"csrf-token"', output)

            # Restart cockpit with SSL enabled, this should have gotten an SSL cert from FreeIPA
            m.stop_cockpit()
            m.start_cockpit(tls=True)
            # OpenSSL and curl should use the system PKI which should trust the IPA server CA
            out = m.execute("openssl s_client -verify 5 -verify_return_error -connect localhost:9090")
            self.assertIn("subject=/O=COCKPIT.LAN/CN=x0.cockpit.lan", out)
            self.assertIn("issuer=/O=COCKPIT.LAN/CN=Certificate Authority", out)
            self.assertIn("Content-Type: text/html", m.execute("curl --head https://x0.cockpit.lan:9090"))
            # don't leave the secret key copy behind
            m.execute("! test -e /run/cockpit/ipa.key")
            # remotectl agrees
            self.assertIn("/etc/cockpit/ws-certs.d/10-ipa.cert", m.execute("remotectl certificate"))
            # cert is being tracked
            self.assertIn("MONITORING", m.execute("ipa-getcert list"))
            # Restart without SSL (IPA certificate is not on the testing host)
            m.stop_cockpit()
            m.start_cockpit()

            # wait until IPA user works
            m.execute('while ! su - -c "echo foobarfoo | sudo -S true" admin@cockpit.lan; do sleep 5; done',
                      timeout=300)

            # log in as IPA admin and check that we can do privileged operations
            orig_password = b.password
            b.password = 'foobarfoo'

            # Joining a domain with realmd enables `use_fully_qualified_names` by default; this is inconsistent with
            # ipa-client-install (https://bugzilla.redhat.com/show_bug.cgi?id=1575538)
            self.login_and_go('/system/services#/systemd-tmpfiles-clean.timer', user='admin@cockpit.lan')
            b.wait_present('#service-unit')
            b.wait_in_text('#service-unit', "waiting")
            b.wait_present('#service-unit-action button[data-action="StopUnit"]')
            b.click('#service-unit-action button[data-action="StopUnit"]')
            b.wait_in_text('#service-unit', "dead")
            b.logout()
            b.password = orig_password

            self.allow_authorize_journal_messages()

        elif m.image != "rhel-7-5":  # on 7.5 with older ws curl succeeds
            # curl negotiation should fail without a proper ws keytab; this provides a hint
            # when FreeIPA becomes new enough on the images above to start working
            self.assertRaisesRegexp(subprocess.CalledProcessError, "returned non-zero exit status 6", m.execute,
                    ['curl', '-s', '--negotiate', '--delegation', 'always', '-u:', '-D-',
                     'http://x0.cockpit.lan:9090/cockpit/login'])

        # Leave the domain
        b.login_and_go("/system")
        b.wait_present("#system-info-domain a")
        b.wait_in_text("#system-info-domain a", "cockpit.lan")
        b.click("#system-info-domain a")
        b.wait_popup("realms-op")
        b.wait_visible(".realms-op-leave-only-row")
        b.wait_in_text(".realms-op-leave-only-row", "cockpit.lan")
        b.click(".realms-op-apply")
        b.wait_popdown("realms-op")
        wait_number_domains(0)

        # should have cleaned up ws keytab
        m.execute("! klist -k /etc/cockpit/krb5.keytab | grep COCKPIT.LAN")
        # should have cleaned up certificates
        m.execute("! test -e /etc/cockpit/ws-certs.d/10-ipa.cert")
        # should have stopped cert tracking
        self.assertNotIn("status:", m.execute("ipa-getcert list"))

        # Send a wrong password
        b.click("#system-info-domain a")
        b.wait_popup("realms-op")
        b.set_val(".realms-op-address", "cockpit.lan")
        b.wait_attr(".realms-op-admin", "placeholder", 'e.g. "admin"')
        b.set_val(".realms-op-admin", "admin")
        b.set_val(".realms-op-admin-password", "foo")
        b.click(".realms-op-apply")
        b.wait_text_not(".realms-op-message", "")
        b.wait_not_visible(".realms-op-leave-only-row")
        b.click(".realms-op-cancel")
        b.wait_popdown("realms-op")

        # Try to join a non-existing domain
        b.click("#system-info-domain a")
        b.wait_popup("realms-op")
        b.set_val(".realms-op-address", "NOPE")
        b.wait_js_cond("ph_find('.realms-op-address-error').title != ''")
        b.click(".realms-op-cancel")
        b.wait_popdown("realms-op")

        # Cancel a join
        b.click("#system-info-domain a")
        b.wait_popup("realms-op")
        b.set_val(".realms-op-address", "cockpit.lan")
        b.wait_attr(".realms-op-admin", "placeholder", 'e.g. "admin"')
        b.set_val(".realms-op-admin", "admin")
        b.set_val(".realms-op-admin-password", "foobarfoo")
        b.click(".realms-op-apply")
        b.wait_visible(".realms-op-spinner")
        b.click(".realms-op-cancel")
        b.wait_popdown("realms-op")

        self.allow_restart_journal_messages()

    @skipImage("Requires fixed admin permission check (PR #9127)", "rhel-7-5")
    def testIpaUnqualifiedUsers(self):
        m = self.machine
        b = self.browser

        self.addSudoRule()

        m.execute("hostnamectl set-hostname x0.cockpit.lan")

        # Wait for DNS to work as expected.
        # https://bugzilla.redhat.com/show_bug.cgi?id=1071356#c11
        wait(lambda: m.execute("nslookup -type=SRV _ldap._tcp.cockpit.lan"))

        # delete the local admin user, going to use the IPA one instead
        m.execute("userdel --remove admin" )

        # Tell realmd to not enable domain-qualified logins
        # (https://bugzilla.redhat.com/show_bug.cgi?id=1575538)
        m.execute("printf '[cockpit.lan]\\nfully-qualified-names = no\\n'  >> /etc/realmd.conf")
        m.execute("echo foobarfoo | realm join -vU admin cockpit.lan")

        if m.image in ["fedora-28", "fedora-testing"]:
            # HACK: fix authselect/IPA regression (https://bugzilla.redhat.com/show_bug.cgi?id=1582111)
            self.machine.execute("authselect enable-feature with-sudo")

        # wait until IPA user works
        m.execute('while ! su - -c "echo foobarfoo | sudo -S true" admin; do sleep 5; done',
                  timeout=300)

        # login should now work with the IPA admin user
        b.password = 'foobarfoo'
        self.login_and_go("/system")
        b.wait_present("#system-info-domain a")
        b.wait_in_text("#system-info-domain a", "cockpit.lan")

        # should be able to run admin operations
        b.go('/system/services#/systemd-tmpfiles-clean.timer')
        b.enter_page('/system/services')
        b.wait_present('#service-unit')
        b.wait_in_text('#service-unit', "waiting")
        b.wait_present('#service-unit-action button[data-action="StopUnit"]')
        b.click('#service-unit-action button[data-action="StopUnit"]')
        b.wait_in_text('#service-unit', "dead")

        b.go('/system')
        b.enter_page('/system')
        # it takes a while for the permission check to finish, it is always enabled at first
        b.wait_present('#shutdown-group button.btn-danger[data-stable=yes]')
        # shutdown button should be enabled and working
        b.click("#shutdown-group button.btn-danger")
        b.wait_popup("shutdown-dialog")
        b.wait_in_text("#shutdown-dialog .btn-danger", 'Restart')
        b.click("#shutdown-dialog .dropdown button")
        b.click("a:contains('No Delay')")
        b.click("#shutdown-dialog .btn-danger")
        b.switch_to_top()
        b.wait_visible(".curtains-ct")
        b.wait_in_text(".curtains-ct h1", "Disconnected")
        m.wait_reboot()

        self.allow_authorize_journal_messages()

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

        # Disable sssd support in realmd

        realmd_distro_conf = "/usr/lib/realmd/realmd-distro.conf"
        if m.image.startswith("rhel-7") or m.image.startswith("centos-7"):
            realmd_distro_conf = "/usr/lib64/realmd/realmd-distro.conf"

        m.execute("echo -e '[providers]\nsssd = no\n' >>%s" % realmd_distro_conf)

        m.execute("hostnamectl set-hostname x0.cockpit.lan")
        self.login_and_go("/system")

        # Wait for DNS to work as expected.
        # https://bugzilla.redhat.com/show_bug.cgi?id=1071356#c11
        #
        wait(lambda: m.execute("nslookup -type=SRV _ldap._tcp.cockpit.lan"))

        # Join cockpit.lan
        b.click("#system-info-domain a")
        b.wait_popup("realms-op")
        b.set_val(".realms-op-address", "cockpit.lan")
        b.wait_js_cond("ph_find('.realms-op-address-error').title != ''")
        b.set_val(".realms-op-admin", "admin")
        b.set_val(".realms-op-admin-password", "foobarfoo")
        b.click(".realms-op-apply")
        b.wait_text(".realms-op-message", "Joining this domain is not supported")

JOIN_SCRIPT = """
set -ex
# Wait until zones from LDAP get loaded
for x in $(seq 1 20); do
    if nslookup -type=SRV _ldap._tcp.cockpit.lan; then
        break
    else
        sleep $x
    fi
done

if ! echo '%(password)s' | realm join -vU admin cockpit.lan; then
    if systemctl --quiet is-failed sssd.service; then
        systemctl status --lines=100 sssd.service >&2
    fi
    journalctl -u realmd.service
    exit 1
fi

# On certain OS's it takes time for sssd to come up properly
#   [8347] 1528294262.886088: Sending initial UDP request to dgram 172.27.0.15:88
#   kinit: Cannot contact any KDC for realm 'COCKPIT.LAN' while getting initial credentials
for x in $(seq 1 20); do
    if echo '%(password)s' | KRB5_TRACE=/dev/stderr kinit -f admin@COCKPIT.LAN; then
        break
    else
        sleep $x
    fi
done

# create SPN and keytab for ws
if type ipa >/dev/null 2>&1; then
    LC_ALL=C.UTF-8 ipa service-add --ok-as-delegate=true --force HTTP/x0.cockpit.lan@COCKPIT.LAN
else
    curl --insecure -s --negotiate -u : https://f0.cockpit.lan/ipa/json --header 'Referer: https://f0.cockpit.lan/ipa' --header "Content-Type: application/json" --header "Accept: application/json" --data '{"params": [["HTTP/x0.cockpit.lan@COCKPIT.LAN"], {"raw": false, "all": false, "version": "2.101", "force": true, "no_members": false, "ipakrbokasdelegate": true}], "method": "service_add", "id": 0}'
fi
ipa-getkeytab -p HTTP/x0.cockpit.lan -k %(keytab)s
"""

# This is here because our test framework can't run ipa VM's twice
@skipImage("No realmd available", "continuous-atomic", "fedora-atomic", "rhel-atomic")
@skipImage("No freeipa available", "debian-stable", "debian-testing")
class TestKerberos(MachineCase):
    provision = {
        "0": { "address": "10.111.113.1/20", "dns": "10.111.112.100" },
        "ipa": { "image": "ipa", "memory_mb": 2048 }
    }

    def configure_kerberos(self, keytab):
        # Setup a place for kerberos caches
        args = { "addr": "10.111.112.100", "password": "foobarfoo", "keytab": keytab }
        self.machine.execute("hostnamectl set-hostname x0.cockpit.lan")
        if "ubuntu" in self.machine.image:
            # no nss-myhostname there
            self.machine.execute("echo '10.111.113.1 x0.cockpit.lan' >> /etc/hosts")
        self.machine.execute(script=JOIN_SCRIPT % args, timeout=1800)
        self.machine.execute(script=WAIT_KRB_SCRIPT, timeout=300)

    def testNegotiate(self):
        self.allow_authorize_journal_messages()
        self.allow_hostkey_messages()
        b = self.browser

        # HACK: There is no operating system where the domain admins are admins by default
        # This is something that needs to be worked on at an OS level. We use admin level
        # features below, such as adding a machine to the dashboard
        self.machine.execute("echo 'admin@cockpit.lan        ALL=(ALL)       NOPASSWD: ALL' >> /etc/sudoers")

        # Make sure negotiate auth is not offered first
        self.machine.start_cockpit()

        output = self.machine.execute(['/usr/bin/curl -v -s',
                                       '--resolve', 'x0.cockpit.lan:9090:10.111.113.1',
                                       'http://x0.cockpit.lan:9090/cockpit/login', '2>&1'])
        self.assertIn("HTTP/1.1 401", output)
        self.assertNotIn("WWW-Authenticate: Negotiate", output)

        keytab = "/etc/cockpit/krb5.keytab"
        # This only supports the default keytab location
        if self.machine.image == "rhel-7-5":
            keytab = "/etc/krb5.keytab"

        self.configure_kerberos(keytab)
        self.machine.restart_cockpit()

        output = self.machine.execute(['/usr/bin/curl', '-s', '--negotiate', '--delegation', 'always', '-u', ':', "-D", "-",
                                       '--resolve', 'x0.cockpit.lan:9090:10.111.113.1',
                                       'http://x0.cockpit.lan:9090/cockpit/login'])
        self.assertIn("HTTP/1.1 200 OK", output)
        self.assertIn('"csrf-token"', output)

        cookie = re.search("Set-Cookie: cockpit=([^ ;]+)", output).group(1)
        b.open("/system/terminal", cookie={ "name": "cockpit", "value": cookie, "domain": self.machine.web_address, "path": "/" })
        b.wait_present('#content')
        b.wait_visible('#content')
        b.enter_page("/system/terminal")
        b.wait_present(".terminal")
        b.focus(".terminal")

        def line_sel(i):
            return '.terminal > div:nth-child(%d)' % i

        def wait_prompt(b, line):
            b.wait_js_func("(function (sel) { return ph_text(sel).trim() != ''})", line_sel(line))

        wait_prompt(b, 1)

        # run kinit and see if got forwarded the kerberos ticket into the session
        b.key_press("klist\r")
        wait_prompt(b, 2)
        text = b.text(line_sel(2)).strip()
        self.assertIn("Ticket cache", text)

        # Now connect to another machine
        self.assertNotIn("admin@cockpit.lan", self.machine.execute("ps -xa | grep sshd"))
        b.switch_to_top()
        b.go("/@x0.cockpit.lan/system/terminal")
        b.wait_visible("#machine-troubleshoot")
        b.click("#machine-troubleshoot")
        b.wait_popup('troubleshoot-dialog')
        b.wait_present("#add-machine-address")

        b.set_val("#add-machine-address", "bad-user@x0.cockpit.lan")
        b.wait_present("#troubleshoot-dialog .btn-primary:not([disabled])")
        b.wait_text('#troubleshoot-dialog .btn-primary', "Add")
        b.click('#troubleshoot-dialog .btn-primary')
        b.wait_in_text('#troubleshoot-dialog', "Fingerprint")
        b.click('#troubleshoot-dialog .btn-primary')
        b.wait_in_text('#troubleshoot-dialog h4', "Log in to")
        b.wait_present("#troubleshoot-dialog .btn-primary:not([disabled])")
        b.wait_present("#login-type button")
        b.click("#login-type button")
        b.click("#login-type li[value=stored] a");
        b.wait_in_text("#login-type button span", "Using available credentials");
        b.wait_not_visible("#login-diff-password")
        b.wait_visible("#login-available")
        b.wait_not_in_text("#login-available", "Login Password")
        b.wait_in_text("#login-available", "Kerberos Ticket")
        b.wait_not_present("#login-available .empty")
        b.set_val("#login-custom-user", "")
        b.wait_present("#troubleshoot-dialog .btn-primary:not([disabled])")
        b.click('#troubleshoot-dialog .btn-primary')

        b.wait_popdown('troubleshoot-dialog')
        b.enter_page("/system/terminal", host="x0.cockpit.lan")
        b.wait_present(".terminal")
        b.wait_visible(".terminal")

        # Make sure we connected via SSH
        self.assertIn("admin@cockpit.lan", self.machine.execute("ps -xa | grep sshd"))
        b.kill()

        if self.machine.image not in ["rhel-7-5"]:
            # Remove cockpit keytab
            self.machine.execute("mv /etc/cockpit/krb5.keytab /etc/cockpit/bk.keytab")
            output = self.machine.execute(['/usr/bin/curl', '-s', '--negotiate', '--delegation', 'always', '-u', ':', "-D", "-",
                                           '--resolve', 'x0.cockpit.lan:9090:10.111.113.1',
                                           'http://x0.cockpit.lan:9090/cockpit/login'])
            self.assertIn("HTTP/1.1 401", output)

            # Pull http into default keytab
            self.machine.execute('printf "rkt /etc/cockpit/bk.keytab\nwkt /etc/krb5.keytab\nq" | ktutil')
            output = self.machine.execute(['/usr/bin/curl', '-s', '--negotiate', '--delegation', 'always', '-u', ':', "-D", "-",
                                           '--resolve', 'x0.cockpit.lan:9090:10.111.113.1',
                                           'http://x0.cockpit.lan:9090/cockpit/login'])
            self.assertIn("HTTP/1.1 200 OK", output)
            self.assertIn('"csrf-token"', output)
        self.allow_restart_journal_messages()

if __name__ == '__main__':
    test_main()
