Statistics
| Branch: | Tag: | Revision:

root / tools / setup-ssh @ f1bebf4c

History | View | Annotate | Download (14.6 kB)

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()