Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Various login/beiboot preparations/cleanups #20967

Merged
merged 8 commits into from
Sep 2, 2024
48 changes: 31 additions & 17 deletions pkg/static/login.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import "./login.scss";

function debug(...args) {
if (window.debugging === 'all' || window.debugging?.includes('login'))
console.debug('login:', ...args);
Comment on lines +4 to +5
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 2 added lines are not executed by any test.

}

(function(console) {
let localStorage;

Expand Down Expand Up @@ -552,8 +557,7 @@ import "./login.scss";
}

function host_failure(msg) {
const host = id("server-field").value;
if (!host) {
if (!login_machine) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

login_failure(msg);
} else {
clear_errors();
Expand Down Expand Up @@ -590,17 +594,20 @@ import "./login.scss";
return hosts;
}

// value of #server-field at the time of clicking "Login"
let login_machine = null;

function call_login() {
login_failure(null);
login_machine = id("server-field").value;
const user = trim(id("login-user-input").value);
if (user === "" && !environment.is_cockpit_client) {
login_failure(_("User name cannot be empty"));
} else if (need_host() && id("server-field").value === "") {
} else if (need_host() && login_machine === "") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

login_failure(_("Please specify the host to connect to"));
} else {
const machine = id("server-field").value;
if (machine) {
application = "cockpit+=" + machine;
if (login_machine) {
application = "cockpit+=" + login_machine;
login_path = org_login_path.replace("/" + org_application + "/", "/" + application + "/");
id("brand").style.display = "none";
id("badge").style.visibility = "hidden";
Expand All @@ -611,12 +618,12 @@ import "./login.scss";
brand("brand", "Cockpit");
}

id("server-name").textContent = machine || environment.hostname;
id("server-name").textContent = login_machine || environment.hostname;
id("login-button").removeEventListener("click", call_login);

const password = id("login-password-input").value;

const superuser_key = "superuser:" + user + (machine ? ":" + machine : "");
const superuser_key = "superuser:" + user + (login_machine ? ":" + login_machine : "");
const superuser = localStorage.getItem(superuser_key) || "none";
localStorage.setItem("superuser-key", superuser_key);
localStorage.setItem(superuser_key, superuser);
Expand All @@ -629,7 +636,7 @@ import "./login.scss";
"X-Superuser": superuser,
};
// allow unknown remote hosts with interactive logins with "Connect to:"
if (machine)
if (login_machine)
headers["X-SSH-Connect-Unknown-Hosts"] = "yes";

send_login_request("GET", headers, false);
Expand Down Expand Up @@ -770,25 +777,30 @@ import "./login.scss";
function do_hostkey_verification(data) {
const key_db = get_known_hosts_db();
const key = data["host-key"];
const key_key = key.split(" ")[0];
const key_host = key.split(" ")[0];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<3. Srsly. This confused the heck out of me too.

const key_type = key.split(" ")[1];

if (key_db[key_key] == key) {
if (key_db[key_host] == key) {
debug("do_hostkey_verification: received key matches known_hosts database, auto-accepting fingerprint", data.default);
Comment on lines +783 to +784
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 2 added lines are not executed by any test.

converse(data.id, data.default);
return;
}

if (key_db[key_key]) {
id("hostkey-title").textContent = format(_("$0 key changed"), id("server-field").value);
if (key_db[key_host]) {
debug("do_hostkey_verification: received key fingerprint", data.default, "for host", key_host,
"does not match key in known_hosts database:", key_db[key_host], "; treating as changed");
id("hostkey-title").textContent = format(_("$0 key changed"), login_machine);
show("#hostkey-warning-group");
id("hostkey-message-1").textContent = "";
} else {
debug("do_hostkey_verification: received key fingerprint", data.default, "for host", key_host,
"not in known_hosts database; treating as new host");
id("hostkey-title").textContent = _("New host");
hide("#hostkey-warning-group");
id("hostkey-message-1").textContent = format(_("You are connecting to $0 for the first time."), id("server-field").value);
id("hostkey-message-1").textContent = format(_("You are connecting to $0 for the first time."), login_machine);
}

id("hostkey-verify-help-1").textContent = format(_("To verify a fingerprint, run the following on $0 while physically sitting at the machine or through a trusted network:"), id("server-field").value);
id("hostkey-verify-help-1").textContent = format(_("To verify a fingerprint, run the following on $0 while physically sitting at the machine or through a trusted network:"), login_machine);
id("hostkey-verify-help-cmds").textContent = format("ssh-keyscan$0 localhost | ssh-keygen -lf -",
key_type ? " -t " + key_type : "");

Expand All @@ -806,7 +818,7 @@ import "./login.scss";
function call_converse() {
id("login-button").removeEventListener("click", call_converse);
login_failure(null, "hostkey");
key_db[key_key] = key;
key_db[key_host] = key;
set_known_hosts_db(key_db);
converse(data.id, data.default);
}
Expand All @@ -816,7 +828,7 @@ import "./login.scss";
show_form("hostkey");
show("#get-out-link");

if (key_db[key_key]) {
if (key_db[key_host]) {
id("login-button").classList.add("pf-m-danger");
id("login-button").classList.remove("pf-m-primary");
}
Expand Down Expand Up @@ -905,6 +917,7 @@ import "./login.scss";
}

function send_login_request(method, headers, is_conversation) {
debug("send_login_request():", method, "headers:", JSON.stringify(headers));
id("login-button").setAttribute('disabled', "true");
id("login-button").setAttribute('spinning', "true");
const xhr = new XMLHttpRequest();
Expand All @@ -921,6 +934,7 @@ import "./login.scss";
const resp = JSON.parse(xhr.responseText);
run(resp);
} else if (xhr.status == 401) {
debug("send_login_request():", method, "got 401, status:", xhr.statusText, "; response:", xhr.responseText);
const challenge = xhr.getResponseHeader("WWW-Authenticate");
if (challenge && challenge.toLowerCase().indexOf("x-conversation") === 0) {
const prompt_data = get_prompt_from_challenge(challenge, xhr.responseText);
Expand Down
8 changes: 7 additions & 1 deletion selinux/cockpit.te
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,13 @@ optional_policy(`
')

optional_policy(`
ssh_read_user_home_files(cockpit_ws_t)
ssh_read_user_home_files(cockpit_ws_t)
')

# cockpit-ws can read ssh config drop-ins like 20-systemd-ssh-proxy.conf
optional_policy(`
gen_require(`type systemd_conf_t;')
read_files_pattern(cockpit_ws_t, systemd_conf_t, systemd_conf_t)
')

#########################################################
Expand Down
118 changes: 79 additions & 39 deletions src/cockpit/beiboot.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from cockpit.bridge import setup_logging
from cockpit.channel import ChannelRoutingRule
from cockpit.channels import PackagesChannel
from cockpit.jsonutil import JsonObject
from cockpit.jsonutil import JsonObject, get_str
from cockpit.packages import Packages, PackagesLoader, patch_libexecdir
from cockpit.peer import Peer
from cockpit.protocol import CockpitProblem
Expand Down Expand Up @@ -172,6 +172,38 @@ async def do_custom_command(self, command: str, args: tuple, fds: list[int], std
self.router.routing_rules.insert(0, ChannelRoutingRule(self.router, [PackagesChannel]))


def python_interpreter(comment: str) -> tuple[Sequence[str], Sequence[str]]:
return ('python3', '-ic', f'# {comment}'), ()


def via_ssh(cmd: Sequence[str], dest: str, ssh_askpass: Path, *ssh_opts: str) -> tuple[Sequence[str], Sequence[str]]:
host, _, port = dest.rpartition(':')
# catch cases like `host:123` but not cases like `[2001:abcd::1]
if port.isdigit():
destination = ['-p', port, host]
else:
destination = [dest]

return (
'ssh', *ssh_opts, *destination, shlex.join(cmd)
), (
f'SSH_ASKPASS={ssh_askpass!s}',
# DISPLAY=x helps trigger a heuristic in old ssh versions to force them
# to use askpass. Newer ones look at SSH_ASKPASS_REQUIRE.
'DISPLAY=x',
'SSH_ASKPASS_REQUIRE=force',
)


def flatpak_spawn(cmd: Sequence[str], env: Sequence[str]) -> tuple[Sequence[str], Sequence[str]]:
return (
'flatpak-spawn', '--host',
*(f'--env={kv}' for kv in env),
*cmd
), (
)


class SshPeer(Peer):
always: bool

Expand All @@ -181,49 +213,55 @@ def __init__(self, router: Router, destination: str, args: argparse.Namespace):
super().__init__(router)

async def do_connect_transport(self) -> None:
beiboot_helper = BridgeBeibootHelper(self)

agent = ferny.InteractionAgent([AuthorizeResponder(self.router), beiboot_helper])
# Choose your own adventure...
if os.path.exists('/.flatpak-info'):
await self.connect_from_flatpak()
else:
await self.connect_from_bastion_host()

async def connect_from_flatpak(self) -> None:
# We want to run a python interpreter somewhere...
cmd: Sequence[str] = ('python3', '-ic', '# cockpit-bridge')
env: Sequence[str] = ()

in_flatpak = os.path.exists('/.flatpak-info')
cmd, env = python_interpreter('cockpit-bridge')

# Remote host? Wrap command with SSH
if self.destination != 'localhost':
if in_flatpak:
# we run ssh and thus the helper on the host, always use the xdg-cache helper
ssh_askpass = ensure_ferny_askpass()
else:
# outside of the flatpak we expect cockpit-ws and thus an installed helper
askpass = patch_libexecdir('${libexecdir}/cockpit-askpass')
assert isinstance(askpass, str)
ssh_askpass = Path(askpass)
if not ssh_askpass.exists():
logger.error("Could not find cockpit-askpass helper at %r", askpass)

env = (
f'SSH_ASKPASS={ssh_askpass!s}',
'DISPLAY=x',
'SSH_ASKPASS_REQUIRE=force',
)
host, _, port = self.destination.rpartition(':')
# catch cases like `host:123` but not cases like `[2001:abcd::1]
if port.isdigit():
host_args = ['-p', port, host]
else:
host_args = [self.destination]

cmd = ('ssh', *host_args, shlex.join(cmd))

# Running in flatpak? Wrap command with flatpak-spawn --host
if in_flatpak:
cmd = ('flatpak-spawn', '--host',
*(f'--env={kv}' for kv in env),
*cmd)
env = ()
# we run ssh and thus the helper on the host, always use the xdg-cache helper
cmd, env = via_ssh(cmd, self.destination, ensure_ferny_askpass())

cmd, env = flatpak_spawn(cmd, env)

await self.boot(cmd, env)

async def connect_from_bastion_host(self) -> None:
basic_password = None
username_opts = []

# do we have user/password (Basic auth) from the login page?
auth = await self.router.request_authorization_object("*")
response = get_str(auth, 'response')

if response.startswith('Basic '):
user, basic_password = base64.b64decode(response.split(' ', 1)[1]).decode().split(':', 1)
if user: # this can be empty, i.e. auth is just ":"
logger.debug("got username %s and password from Basic auth", user)
username_opts = ['-l', user]

# We want to run a python interpreter somewhere...
cmd, env = python_interpreter('cockpit-bridge')

# outside of the flatpak we expect cockpit-ws and thus an installed helper
askpass = patch_libexecdir('${libexecdir}/cockpit-askpass')
assert isinstance(askpass, str)
ssh_askpass = Path(askpass)
if not ssh_askpass.exists():
logger.error("Could not find cockpit-askpass helper at %r", askpass)

cmd, env = via_ssh(cmd, self.destination, ssh_askpass, *username_opts)
await self.boot(cmd, env, basic_password)

async def boot(self, cmd: Sequence[str], env: Sequence[str], basic_password: 'str | None' = None) -> None:
beiboot_helper = BridgeBeibootHelper(self)
agent = ferny.InteractionAgent([AuthorizeResponder(self.router), beiboot_helper])

logger.debug("Launching command: cmd=%s env=%s", cmd, env)
transport = await self.spawn(cmd, env, stderr=agent, start_new_session=True)
Expand Down Expand Up @@ -312,6 +350,8 @@ async def run(args) -> None:
logger.debug("ferny.InteractionError: %s, interpreted as: %r", exc, error)
if isinstance(error, ferny.SshAuthenticationError):
problem = 'authentication-failed'
elif isinstance(error, ferny.SshChangedHostKeyError):
problem = 'invalid-hostkey'
elif isinstance(error, ferny.SshHostKeyError):
problem = 'unknown-hostkey'
elif isinstance(error, OSError):
Expand Down
19 changes: 12 additions & 7 deletions src/cockpit/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import json
import logging
import traceback
import uuid

from .jsonutil import JsonError, JsonObject, JsonValue, create_object, get_int, get_str, get_str_or_none, typechecked

Expand Down Expand Up @@ -204,7 +203,8 @@ def eof_received(self) -> bool:
# Helpful functionality for "server"-side protocol implementations
class CockpitProtocolServer(CockpitProtocol):
init_host: 'str | None' = None
authorizations: 'dict[str, asyncio.Future[str]] | None' = None
authorizations: 'dict[str, asyncio.Future[JsonObject]] | None' = None
next_auth_id = 0

def do_send_init(self) -> None:
raise NotImplementedError
Expand Down Expand Up @@ -232,12 +232,13 @@ def do_ready(self) -> None:
self.do_send_init()

# authorize request/response API
async def request_authorization(
async def request_authorization_object(
self, challenge: str, timeout: 'int | None' = None, **kwargs: JsonValue
) -> str:
) -> JsonObject:
if self.authorizations is None:
self.authorizations = {}
cookie = str(uuid.uuid4())
cookie = str(self.next_auth_id)
self.next_auth_id += 1
future = asyncio.get_running_loop().create_future()
try:
self.authorizations[cookie] = future
Expand All @@ -246,12 +247,16 @@ async def request_authorization(
finally:
self.authorizations.pop(cookie)

async def request_authorization(
self, challenge: str, timeout: 'int | None' = None, **kwargs: JsonValue
) -> str:
return get_str(await self.request_authorization_object(challenge, timeout, **kwargs), 'response')

def do_authorize(self, message: JsonObject) -> None:
cookie = get_str(message, 'cookie')
response = get_str(message, 'response')

if self.authorizations is None or cookie not in self.authorizations:
logger.warning('no matching authorize request')
return

self.authorizations[cookie].set_result(response)
self.authorizations[cookie].set_result(message)