ssh.GetUserFiles: RSA support, unit tests
[ganeti-local] / lib / ssh.py
1 #
2 #
3
4 # Copyright (C) 2006, 2007, 2010, 2011 Google Inc.
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 # General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19 # 02110-1301, USA.
20
21
22 """Module encapsulating ssh functionality.
23
24 """
25
26
27 import os
28 import logging
29 import re
30
31 from ganeti import utils
32 from ganeti import errors
33 from ganeti import constants
34 from ganeti import netutils
35 from ganeti import pathutils
36 from ganeti import vcluster
37
38
39 def FormatParamikoFingerprint(fingerprint):
40   """Format paramiko PKey fingerprint.
41
42   @type fingerprint: str
43   @param fingerprint: PKey fingerprint
44   @return: The string hex representation of the fingerprint
45
46   """
47   assert len(fingerprint) % 2 == 0
48   return ":".join(re.findall(r"..", fingerprint.lower()))
49
50
51 def GetUserFiles(user, mkdir=False, kind=constants.SSHK_DSA,
52                  _homedir_fn=utils.GetHomeDir):
53   """Return the paths of a user's SSH files.
54
55   @type user: string
56   @param user: Username
57   @type mkdir: bool
58   @param mkdir: Whether to create ".ssh" directory if it doesn't exist
59   @type kind: string
60   @param kind: One of L{constants.SSHK_ALL}
61   @rtype: tuple; (string, string, string)
62   @return: Tuple containing three file system paths; the private SSH key file,
63     the public SSH key file and the user's C{authorized_keys} file
64   @raise errors.OpExecError: When home directory of the user can not be
65     determined
66   @raise errors.OpExecError: Regardless of the C{mkdir} parameters, this
67     exception is raised if C{~$user/.ssh} is not a directory
68
69   """
70   user_dir = _homedir_fn(user)
71   if not user_dir:
72     raise errors.OpExecError("Cannot resolve home of user '%s'" % user)
73
74   if kind == constants.SSHK_DSA:
75     suffix = "dsa"
76   elif kind == constants.SSHK_RSA:
77     suffix = "rsa"
78   else:
79     raise errors.ProgrammerError("Unknown SSH key kind '%s'" % kind)
80
81   ssh_dir = utils.PathJoin(user_dir, ".ssh")
82   if mkdir:
83     utils.EnsureDirs([(ssh_dir, constants.SECURE_DIR_MODE)])
84   elif not os.path.isdir(ssh_dir):
85     raise errors.OpExecError("Path %s is not a directory" % ssh_dir)
86
87   return [utils.PathJoin(ssh_dir, base)
88           for base in ["id_%s" % suffix, "id_%s.pub" % suffix,
89                        "authorized_keys"]]
90
91
92 class SshRunner:
93   """Wrapper for SSH commands.
94
95   """
96   def __init__(self, cluster_name, ipv6=False):
97     """Initializes this class.
98
99     @type cluster_name: str
100     @param cluster_name: name of the cluster
101     @type ipv6: bool
102     @param ipv6: If true, force ssh to use IPv6 addresses only
103
104     """
105     self.cluster_name = cluster_name
106     self.ipv6 = ipv6
107
108   def _BuildSshOptions(self, batch, ask_key, use_cluster_key,
109                        strict_host_check, private_key=None, quiet=True):
110     """Builds a list with needed SSH options.
111
112     @param batch: same as ssh's batch option
113     @param ask_key: allows ssh to ask for key confirmation; this
114         parameter conflicts with the batch one
115     @param use_cluster_key: if True, use the cluster name as the
116         HostKeyAlias name
117     @param strict_host_check: this makes the host key checking strict
118     @param private_key: use this private key instead of the default
119     @param quiet: whether to enable -q to ssh
120
121     @rtype: list
122     @return: the list of options ready to use in L{utils.process.RunCmd}
123
124     """
125     options = [
126       "-oEscapeChar=none",
127       "-oHashKnownHosts=no",
128       "-oGlobalKnownHostsFile=%s" % pathutils.SSH_KNOWN_HOSTS_FILE,
129       "-oUserKnownHostsFile=/dev/null",
130       "-oCheckHostIp=no",
131       ]
132
133     if use_cluster_key:
134       options.append("-oHostKeyAlias=%s" % self.cluster_name)
135
136     if quiet:
137       options.append("-q")
138
139     if private_key:
140       options.append("-i%s" % private_key)
141
142     # TODO: Too many boolean options, maybe convert them to more descriptive
143     # constants.
144
145     # Note: ask_key conflicts with batch mode
146     if batch:
147       if ask_key:
148         raise errors.ProgrammerError("SSH call requested conflicting options")
149
150       options.append("-oBatchMode=yes")
151
152       if strict_host_check:
153         options.append("-oStrictHostKeyChecking=yes")
154       else:
155         options.append("-oStrictHostKeyChecking=no")
156
157     else:
158       # non-batch mode
159
160       if ask_key:
161         options.append("-oStrictHostKeyChecking=ask")
162       elif strict_host_check:
163         options.append("-oStrictHostKeyChecking=yes")
164       else:
165         options.append("-oStrictHostKeyChecking=no")
166
167     if self.ipv6:
168       options.append("-6")
169
170     return options
171
172   def BuildCmd(self, hostname, user, command, batch=True, ask_key=False,
173                tty=False, use_cluster_key=True, strict_host_check=True,
174                private_key=None, quiet=True):
175     """Build an ssh command to execute a command on a remote node.
176
177     @param hostname: the target host, string
178     @param user: user to auth as
179     @param command: the command
180     @param batch: if true, ssh will run in batch mode with no prompting
181     @param ask_key: if true, ssh will run with
182         StrictHostKeyChecking=ask, so that we can connect to an
183         unknown host (not valid in batch mode)
184     @param use_cluster_key: whether to expect and use the
185         cluster-global SSH key
186     @param strict_host_check: whether to check the host's SSH key at all
187     @param private_key: use this private key instead of the default
188     @param quiet: whether to enable -q to ssh
189
190     @return: the ssh call to run 'command' on the remote host.
191
192     """
193     argv = [constants.SSH]
194     argv.extend(self._BuildSshOptions(batch, ask_key, use_cluster_key,
195                                       strict_host_check, private_key,
196                                       quiet=quiet))
197     if tty:
198       argv.extend(["-t", "-t"])
199
200     argv.append("%s@%s" % (user, hostname))
201
202     # Insert variables for virtual nodes
203     argv.extend("export %s=%s;" %
204                 (utils.ShellQuote(name), utils.ShellQuote(value))
205                 for (name, value) in
206                   vcluster.EnvironmentForHost(hostname).items())
207
208     argv.append(command)
209
210     return argv
211
212   def Run(self, *args, **kwargs):
213     """Runs a command on a remote node.
214
215     This method has the same return value as `utils.RunCmd()`, which it
216     uses to launch ssh.
217
218     Args: see SshRunner.BuildCmd.
219
220     @rtype: L{utils.process.RunResult}
221     @return: the result as from L{utils.process.RunCmd()}
222
223     """
224     return utils.RunCmd(self.BuildCmd(*args, **kwargs))
225
226   def CopyFileToNode(self, node, filename):
227     """Copy a file to another node with scp.
228
229     @param node: node in the cluster
230     @param filename: absolute pathname of a local file
231
232     @rtype: boolean
233     @return: the success of the operation
234
235     """
236     if not os.path.isabs(filename):
237       logging.error("File %s must be an absolute path", filename)
238       return False
239
240     if not os.path.isfile(filename):
241       logging.error("File %s does not exist", filename)
242       return False
243
244     command = [constants.SCP, "-p"]
245     command.extend(self._BuildSshOptions(True, False, True, True))
246     command.append(filename)
247     if netutils.IP6Address.IsValid(node):
248       node = netutils.FormatAddress((node, None))
249
250     command.append("%s:%s" % (node, vcluster.ExchangeNodeRoot(node, filename)))
251
252     result = utils.RunCmd(command)
253
254     if result.failed:
255       logging.error("Copy to node %s failed (%s) error '%s',"
256                     " command was '%s'",
257                     node, result.fail_reason, result.output, result.cmd)
258
259     return not result.failed
260
261   def VerifyNodeHostname(self, node):
262     """Verify hostname consistency via SSH.
263
264     This functions connects via ssh to a node and compares the hostname
265     reported by the node to the name with have (the one that we
266     connected to).
267
268     This is used to detect problems in ssh known_hosts files
269     (conflicting known hosts) and inconsistencies between dns/hosts
270     entries and local machine names
271
272     @param node: nodename of a host to check; can be short or
273         full qualified hostname
274
275     @return: (success, detail), where:
276         - success: True/False
277         - detail: string with details
278
279     """
280     cmd = ("if test -z \"$GANETI_HOSTNAME\"; then"
281            "  hostname --fqdn;"
282            "else"
283            "  echo \"$GANETI_HOSTNAME\";"
284            "fi")
285     retval = self.Run(node, constants.SSH_LOGIN_USER, cmd, quiet=False)
286
287     if retval.failed:
288       msg = "ssh problem"
289       output = retval.output
290       if output:
291         msg += ": %s" % output
292       else:
293         msg += ": %s (no output)" % retval.fail_reason
294       logging.error("Command %s failed: %s", retval.cmd, msg)
295       return False, msg
296
297     remotehostname = retval.stdout.strip()
298
299     if not remotehostname or remotehostname != node:
300       if node.startswith(remotehostname + "."):
301         msg = "hostname not FQDN"
302       else:
303         msg = "hostname mismatch"
304       return False, ("%s: expected %s but got %s" %
305                      (msg, node, remotehostname))
306
307     return True, "host matches"
308
309
310 def WriteKnownHostsFile(cfg, file_name):
311   """Writes the cluster-wide equally known_hosts file.
312
313   """
314   utils.WriteFile(file_name, mode=0600,
315                   data="%s ssh-rsa %s\n" % (cfg.GetClusterName(),
316                                             cfg.GetHostKey()))