From 94a68844451ed5e81b25f1f864c27ee4ba6e5f76 Mon Sep 17 00:00:00 2001 From: Robert Hensing Date: Fri, 6 Aug 2021 18:03:39 +0200 Subject: [PATCH 1/3] Always provide our known_hosts in addition to user known_hosts This way, we don't have to rely on user configuration to include known_hosts entries for the deployments. It makes `nixops import --include-keys` unnecessary, unless you use those entries outside of nixops. Since recently we can get our deployment state from remote storage backends, but we didn't have a way to get configure the known_hosts yet. This is now largely unnecessary. This functionality requires some cooperation from the plugins. For instance, here's what ec2 needs to do: (pun intended) + def get_ssh_host_keys(self): + return self.private_ipv4 + " " + self.public_host_key + "\n" + self.public_ipv4 + " " + self.public_host_key + "\n" --- nixops/backends/__init__.py | 80 ++++++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/nixops/backends/__init__.py b/nixops/backends/__init__.py index 0b24331e4..e7890268f 100644 --- a/nixops/backends/__init__.py +++ b/nixops/backends/__init__.py @@ -6,6 +6,7 @@ Mapping, Match, Any, + Dict, List, Optional, Union, @@ -453,15 +454,72 @@ def get_keys(self): return self.keys def get_ssh_name(self) -> str: + """ + In ssh terminology, this is the "Host", which part of the "destination" + but not necessarily the same as the "Hostname". + The ssh config file can set Hostname for specific Hosts, effectively + rewriting the destination into the final hostname or ip. + """ assert False - def get_ssh_flags(self, scp: bool = False) -> List[str]: - if scp: - return ["-P", str(self.ssh_port)] if self.ssh_port is not None else [] + def get_ssh_host_keys(self) -> Optional[str]: + """ + Return the public host key in known_hosts format or None if not known. + """ + return None + + def _get_ssh_ambient_options(self) -> Dict[str, str]: + proc = subprocess.Popen( + ["ssh", "-G", self.get_ssh_name()], stdout=subprocess.PIPE + ) + opts: Dict[str, str] = {} + if proc.stdout is None: # mostly for mypy; Popen won't do this to us. + return opts + while True: + line = proc.stdout.readline() + if not line: + break + + s = line.decode("utf-8").rstrip("\r\n").split(" ", 1) + if len(s) == 2: + opts[s[0].lower()] = s[1] + + return opts + + def get_known_hosts_file(self, *args, **kwargs) -> Optional[str]: + k = self.get_ssh_host_keys() + if k is not None: + return self.write_ssh_known_hosts(k) else: - return list(self.ssh_options) + ( - ["-p", str(self.ssh_port)] if self.ssh_port is not None else [] - ) + return None + + def get_ssh_flags(self, scp: bool = False) -> List[str]: + flags: List[str] = [] + + if self.ssh_port is not None: + flags = flags + ["-o", "Port=" + str(self.ssh_port)] + + # We add our own public host key (if known) to GlobalKnownHostsFile. + # This way we don't override keys in ~/.ssh/known_hosts that some users + # may rely on. We don't set UserKnownHostsFile, because that file is + # supposed to be editable, whereas ours is generated and shouldn't be + # edited. + if self.get_ssh_host_keys() is not None: + ambient_gkhfs = self._get_ssh_ambient_options().get("globalknownhostsfile") + known_hosts_file = self.get_known_hosts_file() + if ambient_gkhfs is None: + ambient_gkhfss = [] + else: + ambient_gkhfss = [ambient_gkhfs] + + if known_hosts_file is not None: + flags = flags + [ + "-o", + "GlobalKnownHostsFile=" + + " ".join(ambient_gkhfss + [known_hosts_file]), + ] + + return flags def get_ssh_password(self): return None @@ -505,6 +563,16 @@ def write_ssh_private_key(self, private_key: str) -> str: def get_ssh_private_key_file(self) -> Optional[str]: return None + def write_ssh_known_hosts(self, known_hosts: str) -> str: + """ + Write a temporary file for a known_hosts file containing this machine's + host public key. + """ + file = "{0}/known_host_nixops-{1}".format(self.depl.tempdir, self.name) + with os.fdopen(os.open(file, os.O_CREAT | os.O_WRONLY, 0o600), "w") as f: + f.write(known_hosts) + return file + def _logged_exec(self, command: List[str], **kwargs) -> Union[str, int]: return nixops.util.logged_exec(command, self.logger, **kwargs) From 6e0d3aa9704f209449ba414bececbae5cc007770 Mon Sep 17 00:00:00 2001 From: Robert Hensing Date: Thu, 26 Aug 2021 13:25:57 +0200 Subject: [PATCH 2/3] Make nix-copy-closure work when ssh GlobalKnownHostsFile entries are present --- nixops/backends/__init__.py | 38 +++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/nixops/backends/__init__.py b/nixops/backends/__init__.py index e7890268f..cb7bc92f9 100644 --- a/nixops/backends/__init__.py +++ b/nixops/backends/__init__.py @@ -504,20 +504,10 @@ def get_ssh_flags(self, scp: bool = False) -> List[str]: # may rely on. We don't set UserKnownHostsFile, because that file is # supposed to be editable, whereas ours is generated and shouldn't be # edited. - if self.get_ssh_host_keys() is not None: - ambient_gkhfs = self._get_ssh_ambient_options().get("globalknownhostsfile") - known_hosts_file = self.get_known_hosts_file() - if ambient_gkhfs is None: - ambient_gkhfss = [] - else: - ambient_gkhfss = [ambient_gkhfs] + known_hosts_file = self.get_known_hosts_file() - if known_hosts_file is not None: - flags = flags + [ - "-o", - "GlobalKnownHostsFile=" - + " ".join(ambient_gkhfss + [known_hosts_file]), - ] + if known_hosts_file is not None: + flags = flags + ["-o", "GlobalKnownHostsFile=" + known_hosts_file] return flags @@ -566,8 +556,28 @@ def get_ssh_private_key_file(self) -> Optional[str]: def write_ssh_known_hosts(self, known_hosts: str) -> str: """ Write a temporary file for a known_hosts file containing this machine's - host public key. + host public key and the global known hosts entries. """ + + # We copy the global known hosts files, because we can't pass multiple + # file names through NIX_SSHOPTS, because spaces are interpreted as + # option separators there. + ambientGlobalFilesStr = self._get_ssh_ambient_options().get( + "globalknownhostsfile" + ) + if ambientGlobalFilesStr is None: + ambientGlobalFiles = [] + else: + ambientGlobalFiles = ambientGlobalFilesStr.split() + + for globalFile in ambientGlobalFiles: + if os.path.exists(globalFile): + with open(globalFile) as f: + contents = f.read() + known_hosts = ( + known_hosts + f"\n\n# entries from {globalFile}\n" + contents + ) + file = "{0}/known_host_nixops-{1}".format(self.depl.tempdir, self.name) with os.fdopen(os.open(file, os.O_CREAT | os.O_WRONLY, 0o600), "w") as f: f.write(known_hosts) From 6c1e3487b699bd1a89a6e5b4880e54e59b0c0ec9 Mon Sep 17 00:00:00 2001 From: Robert Hensing Date: Mon, 30 Aug 2021 13:32:38 +0200 Subject: [PATCH 3/3] Improve process handling in _get_ssh_ambient_options Thanks Mic92! --- nixops/backends/__init__.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/nixops/backends/__init__.py b/nixops/backends/__init__.py index cb7bc92f9..723e5bf86 100644 --- a/nixops/backends/__init__.py +++ b/nixops/backends/__init__.py @@ -469,22 +469,17 @@ def get_ssh_host_keys(self) -> Optional[str]: return None def _get_ssh_ambient_options(self) -> Dict[str, str]: - proc = subprocess.Popen( - ["ssh", "-G", self.get_ssh_name()], stdout=subprocess.PIPE - ) - opts: Dict[str, str] = {} - if proc.stdout is None: # mostly for mypy; Popen won't do this to us. - return opts - while True: - line = proc.stdout.readline() - if not line: - break + with subprocess.Popen( + ["ssh", "-G", self.get_ssh_name()], stdout=subprocess.PIPE, text=True + ) as proc: + assert proc.stdout is not None + opts: Dict[str, str] = {} + for line in proc.stdout: + s = line.rstrip("\r\n").split(" ", 1) + if len(s) == 2: + opts[s[0].lower()] = s[1] - s = line.decode("utf-8").rstrip("\r\n").split(" ", 1) - if len(s) == 2: - opts[s[0].lower()] = s[1] - - return opts + return opts def get_known_hosts_file(self, *args, **kwargs) -> Optional[str]: k = self.get_ssh_host_keys()