Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ dist
/.venv
__pycache__/
**/.DS_STORE
/tests/docker/sudo/id_test
/tests/docker/sudo/id_test.pub
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Commands for provisioning hosts in the cloud (AWS or GCP) are also available.

- cf-remote requires python 3.6 or greater.
- SSH must be configured in such a way that cf-remote can login without a password.
- The account cf-remote logs in as must be root or be able to `sudo`. Passwordless sudo is not required, see [Switching user on the remote hosts](#switching-user-on-the-remote-hosts).
- An sftp server for transferring files on UNIX hosts. e.g. openssh-sftp-server for debian-based distributions.

## Installation
Expand Down Expand Up @@ -184,6 +185,39 @@ If you have more than one key in `~/.ssh` you may need to specify which key `cf-
$ export CF_REMOTE_SSH_KEY="~/.ssh/id_rsa.pub"
```

### Switching user on the remote hosts

Most of what `cf-remote` does needs root, so unless it logs in as root it runs commands through `sudo`.
If `sudo` asks for a password, use `--ask-pass` (`-K`) and `cf-remote` prompts for it once and uses it for all the hosts in the run:

```
$ cf-remote --ask-pass install --clients ubuntu@10.0.0.5
Password for switching user:
```

The password is written to the standard input of the `ssh` process, so it is never part of a command line and doesn't show up in the process list, in the shell history on the target host, or in the output of `--log-level DEBUG`.
It is only sent to hosts where switching user actually asks for a password.

Where there is nobody to answer a prompt, such as in a script or a CI job, put the password on the first line of a file and point `--password-file` at it:

```
$ cf-remote --password-file ~/.cf-remote-password install --clients ubuntu@10.0.0.5
```

`cf-remote` refuses to read the file if others can read it, the same way `ssh` refuses to use a private key with too generous permissions, so `chmod 600` it first.

Use `--switch-user-command` if `sudo` is not what you want to switch user with:

```
$ cf-remote --ask-pass --switch-user-command "doas /bin/sh -c" info -H bsd-host
```

The command to run is appended as a single quoted argument.
The default is `sudo bash -c`, or `sudo -S -p '' bash -c` with `--ask-pass`, since `sudo` only reads the password from standard input when it is given `-S`.

A password can only reach a command that reads it from standard input, which in practice means `sudo -S` and the tools that copy its interface, such as `dzdo -S`.
`doas` and `su` read from a terminal instead, so they work with `--switch-user-command` where they need no password, but cannot be given one by `cf-remote`.

### Working on the local host

`cf-remote` can work on the local host when the target host is `localhost`. In this case, it executes commands locally without connecting over SSH.
Expand Down
56 changes: 43 additions & 13 deletions cf_remote/aramid.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,38 @@ def _get_put_method_args(method, host, src, dst):
)


def _popen(args, stdin_input=None):
"""Start a process, giving it a pipe on standard input if we have input for it

The data itself is handed to 'Popen.communicate()' by
':meth:`_Task.communicate`' rather than written here: writing to
'proc.stdin' directly risks a deadlock, because the process can fill its
stdout or stderr pipe and stop reading while we are still blocked writing
to it. Standard input is left alone (inherited) when there is nothing to
send.

An empty string is not the same as `None`: it gives the command a pipe that
is closed with nothing in it, so anything waiting for input sees EOF at
once rather than blocking on the terminal 'cf-remote' was started from.
"""
return subprocess.Popen(
args,
stdin=(subprocess.PIPE if stdin_input is not None else None),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)


class _Task:
def __init__(self, host, proc, action=None, retries=0): # TODO: timeout=60
def __init__(
self, host, proc, action=None, retries=0, stdin_input=None
): # TODO: timeout=60
self.host = host
self.proc = proc
self.action = action
self.stdin_input = stdin_input
self._input_given = False
self._max_retries = retries
self._retries = retries
self.stdout = ""
Expand All @@ -130,8 +157,13 @@ def __init__(self, host, proc, action=None, retries=0): # TODO: timeout=60

def communicate(self, timeout=1, ignore_failed=False):
start = time.time()
# 'communicate()' keeps writing what the first call handed it, and
# raises if a later one hands it the same input again. Timing out is
# normal here, so only the first call gets it.
stdin_input = None if self._input_given else self.stdin_input
self._input_given = True
try:
out, err = self.proc.communicate(timeout=timeout)
out, err = self.proc.communicate(input=stdin_input, timeout=timeout)
except subprocess.TimeoutExpired:
log.debug("Connection timed out")
return False
Expand All @@ -143,12 +175,8 @@ def communicate(self, timeout=1, ignore_failed=False):
if self._retries > 0:
# wait for the rest of timeout (if any) and restart the process
time.sleep(max(timeout - (time.time() - start), 0))
self.proc = subprocess.Popen(
self.proc.args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
self.proc = _popen(self.proc.args, self.stdin_input)
self._input_given = False # a new process needs it again
self._retries -= 1
return False
else:
Expand Down Expand Up @@ -305,6 +333,7 @@ def execute(
ignore_failed=False,
echo=True,
echo_cmd=False,
stdin_input=None,
): # TODO: parallel=False
"""Execute command on remote hosts (in parallel)

Expand All @@ -321,6 +350,9 @@ def execute(
:param bool echo: whether to echo the output (STDOUT first followed by
STDERR) of the given commands
:param bool echo_cmd: whether to echo the commands run on the hosts
:param str stdin_input: data to write to the standard input of the commands,
for example a password for switching user. If `None`,
standard input is inherited from `cf-remote` itself.
:return: results of commands executed on the given hosts
:rtype: dict(:class:`Host` -> list(:class:`ExecutionResult`))

Expand All @@ -340,18 +372,16 @@ def execute(
port_args = []
if host.port != _DEFAULT_SSH_PORT:
port_args += ["-p", str(host.port)]
proc = subprocess.Popen(
proc = _popen(
["ssh"]
+ DEFAULT_SSH_ARGS
+ port_args
+ host.extra_ssh_args
+ [host.login]
+ [commands[i]],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
stdin_input=stdin_input,
)
task = _Task(host, proc, commands[i], retries=retries)
task = _Task(host, proc, commands[i], retries=retries, stdin_input=stdin_input)
host.tasks.append(task)
tasks.append(task)

Expand Down
31 changes: 31 additions & 0 deletions cf_remote/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import sys

from cf_remote import ssh
from cf_remote.utils import cache


Expand Down Expand Up @@ -278,6 +279,35 @@ def add_connect_args(sp: argparse.ArgumentParser) -> None:
)


def add_switch_user_args(ap: argparse.ArgumentParser) -> None:
password_source = ap.add_mutually_exclusive_group()
password_source.add_argument(
"--ask-pass",
"-K",
help="Prompt for the password used to switch user on the remote hosts."
+ " The password is asked for once and used for all hosts",
action="store_true",
)
password_source.add_argument(
"--password-file",
help="Read the password used to switch user from the first line of a"
+ " file, for use where there is nobody to answer --ask-pass."
+ " The file must not be readable by others",
type=str,
)
ap.add_argument(
"--switch-user-command",
help="Command used to run commands as another (privileged) user."
+ " The command to run is appended as a single quoted argument."
+ " Defaults to '%s', or '%s' when --ask-pass is used"
% (
ssh.DEFAULT_SWITCH_USER_COMMAND,
ssh.DEFAULT_SWITCH_USER_COMMAND_WITH_PASSWORD,
),
type=str,
)


@cache
def get_arg_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(
Expand All @@ -299,6 +329,7 @@ def get_arg_parser() -> argparse.ArgumentParser:
type=str,
const=True,
)
add_switch_user_args(ap)

command_help_hint = (
"Commands (use %s COMMAND --help to get more info)"
Expand Down
47 changes: 28 additions & 19 deletions cf_remote/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,24 +53,30 @@
from cf_remote import cloud_data


def info(hosts, users=None):
def info(hosts, users=None, switch_user=None):
assert hosts
log.debug("hosts='{}'".format(hosts))
errors = 0
for host in hosts:
data = get_info(host, users=users)
data = get_info(host, users=users, switch_user=switch_user)
if data:
print_info(data)
else:
errors += 1
return errors


def run(hosts, command, users=None, sudo=False, raw=False):
def run(hosts, command, users=None, sudo=False, raw=False, switch_user=None):
assert hosts
errors = 0
for host in hosts:
lines = run_command(host=host, command=command, users=users, sudo=sudo)
lines = run_command(
host=host,
command=command,
users=users,
sudo=sudo,
switch_user=switch_user,
)
if lines is None:
log.error("Command: '{}'\nFailed on host: '{}'".format(command, host))
errors += 1
Expand All @@ -96,15 +102,15 @@ def run(hosts, command, users=None, sudo=False, raw=False):
return errors


def sudo(hosts, command, users=None, raw=False):
return run(hosts, command, users, sudo=True, raw=raw)
def sudo(hosts, command, users=None, raw=False, switch_user=None):
return run(hosts, command, users, sudo=True, raw=raw, switch_user=switch_user)


def scp(hosts, files, users=None):
def scp(hosts, files, users=None, switch_user=None):
errors = 0
for host in hosts:
for file in files:
errors += transfer_file(host, file, users)
errors += transfer_file(host, file, users, switch_user=switch_user)
return errors


Expand Down Expand Up @@ -186,7 +192,8 @@ def install(
edition=None,
remote_download=False,
trust_keys=None,
insecure=False
insecure=False,
switch_user=None
):
assert hubs or clients
assert not (hubs and clients and package)
Expand Down Expand Up @@ -258,6 +265,7 @@ def install(
insecure=insecure,
demo_salt=salt,
demo_sha=sha,
switch_user=switch_user,
)
)

Expand Down Expand Up @@ -294,6 +302,7 @@ def install(
show_info=show_host_info,
remote_download=remote_download,
trust_keys=trust_keys,
switch_user=switch_user,
)
)

Expand Down Expand Up @@ -926,14 +935,14 @@ def show(ansible_inventory):
return 0


def uninstall(hosts, purge=False):
def uninstall(hosts, purge=False, switch_user=None):
errors = 0
for host in hosts:
errors += uninstall_host(host, purge=purge)
errors += uninstall_host(host, purge=purge, switch_user=switch_user)
return errors


def deploy_tarball(hubs, tarball):
def deploy_tarball(hubs, tarball, switch_user=None):
assert os.path.isfile(tarball)

if not tarball.endswith((".tgz", ".tar.gz")):
Expand All @@ -944,7 +953,7 @@ def deploy_tarball(hubs, tarball):

errors = 0
for hub in hubs:
errors += deploy_masterfiles(hub, tarball)
errors += deploy_masterfiles(hub, tarball, switch_user=switch_user)
return errors


Expand All @@ -965,7 +974,7 @@ def _get_hubs():
return hubs


def deploy(hubs, masterfiles):
def deploy(hubs, masterfiles, switch_user=None):
if not hubs:
hubs = _get_hubs()
if hubs:
Expand Down Expand Up @@ -1006,7 +1015,7 @@ def deploy(hubs, masterfiles):
masterfiles = masterfiles.rstrip("/")

if os.path.isfile(masterfiles):
return deploy_tarball(hubs, masterfiles)
return deploy_tarball(hubs, masterfiles, switch_user=switch_user)

if masterfiles.endswith((".tgz", ".tar.gz")):
if not os.path.exists(masterfiles):
Expand Down Expand Up @@ -1054,10 +1063,10 @@ def deploy(hubs, masterfiles):
above = directory[0 : -len("/masterfiles")]
os.system("rm -rf %s" % tarball)
os.system("tar -czf %s -C %s masterfiles" % (tarball, above))
return deploy_tarball(hubs, tarball)
return deploy_tarball(hubs, tarball, switch_user=switch_user)


def agent(hosts, bootstrap=None):
def agent(hosts, bootstrap=None, switch_user=None):
if bootstrap and len(bootstrap) > 1:
raise CFRExitError(
"Cannot boostrap {} to {}. Cannot bootstrap to more than one host.".format(
Expand All @@ -1066,7 +1075,7 @@ def agent(hosts, bootstrap=None):
)

for host in hosts:
data = get_info(host)
data = get_info(host, switch_user=switch_user)

if not data["agent"]:
raise CFRExitError("CFEngine not installed on {}".format(host))
Expand All @@ -1078,7 +1087,7 @@ def agent(hosts, bootstrap=None):

command = " ".join(args)

output = run_command(host, command, sudo=True)
output = run_command(host, command, sudo=True, switch_user=switch_user)
if output:
print(output)

Expand Down
Loading
Loading