bash_completion: Enable extglob while parsing file
[ganeti-local] / tools / setup-ssh
1 #!/usr/bin/python
2 #
3
4 # Copyright (C) 2010, 2012 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 """Tool to setup the SSH configuration on a remote node.
22
23 This is needed before we can join the node into the cluster.
24
25 """
26
27 # pylint: disable=C0103
28 # C0103: Invalid name setup-ssh
29
30 import getpass
31 import logging
32 import os.path
33 import optparse
34 import sys
35
36 # workaround paramiko warnings
37 # FIXME: use 'with warnings.catch_warnings' once we drop Python 2.4
38 import warnings
39 warnings.simplefilter("ignore")
40 import paramiko
41 warnings.resetwarnings()
42
43 from ganeti import cli
44 from ganeti import constants
45 from ganeti import errors
46 from ganeti import netutils
47 from ganeti import ssconf
48 from ganeti import ssh
49 from ganeti import utils
50
51
52 class RemoteCommandError(errors.GenericError):
53   """Exception if remote command was not successful.
54
55   """
56
57
58 class JoinCheckError(errors.GenericError):
59   """Exception raised if join check fails.
60
61   """
62
63
64 class HostKeyVerificationError(errors.GenericError):
65   """Exception if host key do not match.
66
67   """
68
69
70 class AuthError(errors.GenericError):
71   """Exception for authentication errors to hosts.
72
73   """
74
75
76 def _CheckJoin(transport):
77   """Checks if a join is safe or dangerous.
78
79   Note: This function relies on the fact, that all
80   hosts have the same configuration at compile time of
81   Ganeti. So that the constants do not mismatch.
82
83   @param transport: The paramiko transport instance
84   @return: True if the join is safe; False otherwise
85
86   """
87   sftp = transport.open_sftp_client()
88   ss = ssconf.SimpleStore()
89   ss_cluster_name_path = ss.KeyToFilename(constants.SS_CLUSTER_NAME)
90
91   cluster_files = [
92     (constants.NODED_CERT_FILE, utils.ReadFile(constants.NODED_CERT_FILE)),
93     (ss_cluster_name_path, utils.ReadFile(ss_cluster_name_path)),
94     ]
95
96   for (filename, local_content) in cluster_files:
97     try:
98       remote_content = _ReadSftpFile(sftp, filename)
99     except IOError, err:
100       # Assume file does not exist. Paramiko's error reporting is lacking.
101       logging.debug("Failed to read %s: %s", filename, err)
102       continue
103
104     if remote_content != local_content:
105       logging.error("File %s doesn't match local version", filename)
106       return False
107
108   return True
109
110
111 def _RunRemoteCommand(transport, command):
112   """Invokes and wait for the command over SSH.
113
114   @param transport: The paramiko transport instance
115   @param command: The command to be executed
116
117   """
118   chan = transport.open_session()
119   chan.set_combine_stderr(True)
120   output_handler = chan.makefile("r")
121   chan.exec_command(command)
122
123   result = chan.recv_exit_status()
124   msg = output_handler.read()
125
126   out_msg = "'%s' exited with status code %s, output %r" % (command, result,
127                                                             msg)
128
129   # If result is -1 (no exit status provided) we assume it was not successful
130   if result:
131     raise RemoteCommandError(out_msg)
132
133   if msg:
134     logging.info(out_msg)
135
136
137 def _InvokeDaemonUtil(transport, command):
138   """Invokes daemon-util on the remote side.
139
140   @param transport: The paramiko transport instance
141   @param command: The daemon-util command to be run
142
143   """
144   _RunRemoteCommand(transport, "%s %s" % (constants.DAEMON_UTIL, command))
145
146
147 def _ReadSftpFile(sftp, filename):
148   """Reads a file over sftp.
149
150   @param sftp: An open paramiko SFTP client
151   @param filename: The filename of the file to read
152   @return: The content of the file
153
154   """
155   remote_file = sftp.open(filename, "r")
156   try:
157     return remote_file.read()
158   finally:
159     remote_file.close()
160
161
162 def _WriteSftpFile(sftp, name, perm, data):
163   """SFTPs data to a remote file.
164
165   @param sftp: A open paramiko SFTP client
166   @param name: The remote file name
167   @param perm: The remote file permission
168   @param data: The data to write
169
170   """
171   remote_file = sftp.open(name, "w")
172   try:
173     sftp.chmod(name, perm)
174     remote_file.write(data)
175   finally:
176     remote_file.close()
177
178
179 def SetupSSH(transport):
180   """Sets the SSH up on the other side.
181
182   @param transport: The paramiko transport instance
183
184   """
185   priv_key, pub_key, auth_keys = ssh.GetUserFiles(constants.GANETI_RUNAS)
186   keyfiles = [
187     (constants.SSH_HOST_DSA_PRIV, 0600),
188     (constants.SSH_HOST_DSA_PUB, 0644),
189     (constants.SSH_HOST_RSA_PRIV, 0600),
190     (constants.SSH_HOST_RSA_PUB, 0644),
191     (priv_key, 0600),
192     (pub_key, 0644),
193     ]
194
195   sftp = transport.open_sftp_client()
196
197   filemap = dict((name, (utils.ReadFile(name), perm))
198                  for (name, perm) in keyfiles)
199
200   auth_path = os.path.dirname(auth_keys)
201
202   try:
203     sftp.mkdir(auth_path, 0700)
204   except IOError, err:
205     # Sadly paramiko doesn't provide errno or similiar
206     # so we can just assume that the path already exists
207     logging.info("Assuming directory %s on remote node exists: %s",
208                  auth_path, err)
209
210   for name, (data, perm) in filemap.iteritems():
211     _WriteSftpFile(sftp, name, perm, data)
212
213   authorized_keys = sftp.open(auth_keys, "a+")
214   try:
215     # Due to the way SFTPFile and BufferedFile are implemented,
216     # opening in a+ mode and then issuing a read(), readline() or
217     # iterating over the file (which uses read() internally) will see
218     # an empty file, since the paramiko internal file position and the
219     # OS-level file-position are desynchronized; therefore, we issue
220     # an explicit seek to resynchronize these; writes should (note
221     # should) still go to the right place
222     authorized_keys.seek(0, 0)
223     # We don't have to close, as the close happened already in AddAuthorizedKey
224     utils.AddAuthorizedKey(authorized_keys, filemap[pub_key][0])
225   finally:
226     authorized_keys.close()
227
228   _InvokeDaemonUtil(transport, "reload-ssh-keys")
229
230
231 def ParseOptions():
232   """Parses options passed to program.
233
234   """
235   program = os.path.basename(sys.argv[0])
236
237   parser = optparse.OptionParser(usage=("%prog [--debug|--verbose] [--force]"
238                                         " <node> <node...>"), prog=program)
239   parser.add_option(cli.DEBUG_OPT)
240   parser.add_option(cli.VERBOSE_OPT)
241   parser.add_option(cli.NOSSH_KEYCHECK_OPT)
242   default_key = ssh.GetUserFiles(constants.GANETI_RUNAS)[0]
243   parser.add_option(optparse.Option("-f", dest="private_key",
244                                     default=default_key,
245                                     help="The private key to (try to) use for"
246                                     "authentication "))
247   parser.add_option(optparse.Option("--key-type", dest="key_type",
248                                     choices=("rsa", "dsa"), default="dsa",
249                                     help="The private key type (rsa or dsa)"))
250   parser.add_option(optparse.Option("-j", "--force-join", dest="force_join",
251                                     action="store_true", default=False,
252                                     help="Force the join of the host"))
253
254   (options, args) = parser.parse_args()
255
256   if not args:
257     parser.print_help()
258     sys.exit(constants.EXIT_FAILURE)
259
260   return (options, args)
261
262
263 def SetupLogging(options):
264   """Sets up the logging.
265
266   @param options: Parsed options
267
268   """
269   fmt = "%(asctime)s: %(threadName)s "
270   if options.debug or options.verbose:
271     fmt += "%(levelname)s "
272   fmt += "%(message)s"
273
274   formatter = logging.Formatter(fmt)
275
276   file_handler = logging.FileHandler(constants.LOG_SETUP_SSH)
277   stderr_handler = logging.StreamHandler()
278   stderr_handler.setFormatter(formatter)
279   file_handler.setFormatter(formatter)
280   file_handler.setLevel(logging.INFO)
281
282   if options.debug:
283     stderr_handler.setLevel(logging.DEBUG)
284   elif options.verbose:
285     stderr_handler.setLevel(logging.INFO)
286   else:
287     stderr_handler.setLevel(logging.WARNING)
288
289   root_logger = logging.getLogger("")
290   root_logger.setLevel(logging.NOTSET)
291   root_logger.addHandler(stderr_handler)
292   root_logger.addHandler(file_handler)
293
294   # This is the paramiko logger instance
295   paramiko_logger = logging.getLogger("paramiko")
296   paramiko_logger.addHandler(file_handler)
297   # We don't want to debug Paramiko, so filter anything below warning
298   paramiko_logger.setLevel(logging.WARNING)
299
300
301 def LoadPrivateKeys(options):
302   """Load the list of available private keys.
303
304   It loads the standard ssh key from disk and then tries to connect to
305   the ssh agent too.
306
307   @rtype: list
308   @return: a list of C{paramiko.PKey}
309
310   """
311   if options.key_type == "rsa":
312     pkclass = paramiko.RSAKey
313   elif options.key_type == "dsa":
314     pkclass = paramiko.DSSKey
315   else:
316     logging.critical("Unknown key type %s selected (choose either rsa or dsa)",
317                      options.key_type)
318     sys.exit(1)
319
320   try:
321     private_key = pkclass.from_private_key_file(options.private_key)
322   except (paramiko.SSHException, EnvironmentError), err:
323     logging.critical("Can't load private key %s: %s", options.private_key, err)
324     sys.exit(1)
325
326   try:
327     agent = paramiko.Agent()
328     agent_keys = agent.get_keys()
329   except paramiko.SSHException, err:
330     # this will only be seen when the agent is broken/uses invalid
331     # protocol; for non-existing agent, get_keys() will just return an
332     # empty tuple
333     logging.warning("Can't connect to the ssh agent: %s; skipping its use",
334                     err)
335     agent_keys = []
336
337   return [private_key] + list(agent_keys)
338
339
340 def _FormatFingerprint(fpr):
341   """Formats a paramiko.PKey.get_fingerprint() human readable.
342
343   @param fpr: The fingerprint to be formatted
344   @return: A human readable fingerprint
345
346   """
347   return ssh.FormatParamikoFingerprint(paramiko.util.hexify(fpr))
348
349
350 def LoginViaKeys(transport, username, keys):
351   """Try to login on the given transport via a list of keys.
352
353   @param transport: the transport to use
354   @param username: the username to login as
355   @type keys: list
356   @param keys: list of C{paramiko.PKey} to use for authentication
357   @rtype: boolean
358   @return: True or False depending on whether the login was
359       successfull or not
360
361   """
362   for private_key in keys:
363     try:
364       transport.auth_publickey(username, private_key)
365       fpr = _FormatFingerprint(private_key.get_fingerprint())
366       if isinstance(private_key, paramiko.AgentKey):
367         logging.debug("Authentication via the ssh-agent key %s", fpr)
368       else:
369         logging.debug("Authenticated via public key %s", fpr)
370       return True
371     except paramiko.SSHException:
372       continue
373   else:
374     # all keys exhausted
375     return False
376
377
378 def LoadKnownHosts():
379   """Load the known hosts.
380
381   @return: paramiko.util.load_host_keys dict
382
383   """
384   homedir = utils.GetHomeDir(constants.GANETI_RUNAS)
385   known_hosts = os.path.join(homedir, ".ssh", "known_hosts")
386
387   try:
388     return paramiko.util.load_host_keys(known_hosts)
389   except EnvironmentError:
390     # We didn't find the path, silently ignore and return an empty dict
391     return {}
392
393
394 def _VerifyServerKey(transport, host, host_keys):
395   """Verify the server keys.
396
397   @param transport: A paramiko.transport instance
398   @param host: Name of the host we verify
399   @param host_keys: Loaded host keys
400   @raises HostkeyVerificationError: When the host identify couldn't be verified
401
402   """
403
404   server_key = transport.get_remote_server_key()
405   keytype = server_key.get_name()
406
407   our_server_key = host_keys.get(host, {}).get(keytype, None)
408   if not our_server_key:
409     hexified_key = _FormatFingerprint(server_key.get_fingerprint())
410     msg = ("Unable to verify hostkey of host %s: %s. Do you want to accept"
411            " it?" % (host, hexified_key))
412
413     if cli.AskUser(msg):
414       our_server_key = server_key
415
416   if our_server_key != server_key:
417     raise HostKeyVerificationError("Unable to verify host identity")
418
419
420 def main():
421   """Main routine.
422
423   """
424   (options, args) = ParseOptions()
425
426   SetupLogging(options)
427
428   all_keys = LoadPrivateKeys(options)
429
430   passwd = None
431   username = constants.GANETI_RUNAS
432   ssh_port = netutils.GetDaemonPort("ssh")
433   host_keys = LoadKnownHosts()
434
435   # Below, we need to join() the transport objects, as otherwise the
436   # following happens:
437   # - the main thread finishes
438   # - the atexit functions run (in the main thread), and cause the
439   #   logging file to be closed
440   # - a tiny bit later, the transport thread is finally ending, and
441   #   wants to log one more message, which fails as the file is closed
442   #   now
443
444   success = True
445
446   for host in args:
447     logging.info("Configuring %s", host)
448
449     transport = paramiko.Transport((host, ssh_port))
450     try:
451       try:
452         transport.start_client()
453
454         if options.ssh_key_check:
455           _VerifyServerKey(transport, host, host_keys)
456
457         try:
458           if LoginViaKeys(transport, username, all_keys):
459             logging.info("Authenticated to %s via public key", host)
460           else:
461             if all_keys:
462               logging.warning("Authentication to %s via public key failed,"
463                               " trying password", host)
464             if passwd is None:
465               passwd = getpass.getpass(prompt="%s password:" % username)
466             transport.auth_password(username=username, password=passwd)
467             logging.info("Authenticated to %s via password", host)
468         except paramiko.SSHException, err:
469           raise AuthError("Auth error TODO" % err)
470
471         if not _CheckJoin(transport):
472           if not options.force_join:
473             raise JoinCheckError(("Host %s failed join check; Please verify"
474                                   " that the host was not previously joined"
475                                   " to another cluster and use --force-join"
476                                   " to continue") % host)
477
478           logging.warning("Host %s failed join check, forced to continue",
479                           host)
480
481         SetupSSH(transport)
482         logging.info("%s successfully configured", host)
483       finally:
484         transport.close()
485         # this is needed for compatibility with older Paramiko or Python
486         # versions
487         transport.join()
488     except AuthError, err:
489       logging.error("Authentication error: %s", err)
490       success = False
491       break
492     except HostKeyVerificationError, err:
493       logging.error("Host key verification error: %s", err)
494       success = False
495     except Exception, err:
496       logging.exception("During setup of %s: %s", host, err)
497       success = False
498
499   if success:
500     sys.exit(constants.EXIT_SUCCESS)
501
502   sys.exit(constants.EXIT_FAILURE)
503
504
505 if __name__ == "__main__":
506   main()