Statistics
| Branch: | Tag: | Revision:

root / tools / setup-ssh @ 052783ff

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
from ganeti import pathutils
51

    
52

    
53
class RemoteCommandError(errors.GenericError):
54
  """Exception if remote command was not successful.
55

    
56
  """
57

    
58

    
59
class JoinCheckError(errors.GenericError):
60
  """Exception raised if join check fails.
61

    
62
  """
63

    
64

    
65
class HostKeyVerificationError(errors.GenericError):
66
  """Exception if host key do not match.
67

    
68
  """
69

    
70

    
71
class AuthError(errors.GenericError):
72
  """Exception for authentication errors to hosts.
73

    
74
  """
75

    
76

    
77
def _CheckJoin(transport):
78
  """Checks if a join is safe or dangerous.
79

    
80
  Note: This function relies on the fact, that all
81
  hosts have the same configuration at compile time of
82
  Ganeti. So that the constants do not mismatch.
83

    
84
  @param transport: The paramiko transport instance
85
  @return: True if the join is safe; False otherwise
86

    
87
  """
88
  sftp = transport.open_sftp_client()
89
  ss = ssconf.SimpleStore()
90
  ss_cluster_name_path = ss.KeyToFilename(constants.SS_CLUSTER_NAME)
91

    
92
  cluster_files = [
93
    (pathutils.NODED_CERT_FILE, utils.ReadFile(pathutils.NODED_CERT_FILE)),
94
    (ss_cluster_name_path, utils.ReadFile(ss_cluster_name_path)),
95
    ]
96

    
97
  for (filename, local_content) in cluster_files:
98
    try:
99
      remote_content = _ReadSftpFile(sftp, filename)
100
    except IOError, err:
101
      # Assume file does not exist. Paramiko's error reporting is lacking.
102
      logging.debug("Failed to read %s: %s", filename, err)
103
      continue
104

    
105
    if remote_content != local_content:
106
      logging.error("File %s doesn't match local version", filename)
107
      return False
108

    
109
  return True
110

    
111

    
112
def _RunRemoteCommand(transport, command):
113
  """Invokes and wait for the command over SSH.
114

    
115
  @param transport: The paramiko transport instance
116
  @param command: The command to be executed
117

    
118
  """
119
  chan = transport.open_session()
120
  chan.set_combine_stderr(True)
121
  output_handler = chan.makefile("r")
122
  chan.exec_command(command)
123

    
124
  result = chan.recv_exit_status()
125
  msg = output_handler.read()
126

    
127
  out_msg = "'%s' exited with status code %s, output %r" % (command, result,
128
                                                            msg)
129

    
130
  # If result is -1 (no exit status provided) we assume it was not successful
131
  if result:
132
    raise RemoteCommandError(out_msg)
133

    
134
  if msg:
135
    logging.info(out_msg)
136

    
137

    
138
def _InvokeDaemonUtil(transport, command):
139
  """Invokes daemon-util on the remote side.
140

    
141
  @param transport: The paramiko transport instance
142
  @param command: The daemon-util command to be run
143

    
144
  """
145
  _RunRemoteCommand(transport, "%s %s" % (pathutils.DAEMON_UTIL, command))
146

    
147

    
148
def _ReadSftpFile(sftp, filename):
149
  """Reads a file over sftp.
150

    
151
  @param sftp: An open paramiko SFTP client
152
  @param filename: The filename of the file to read
153
  @return: The content of the file
154

    
155
  """
156
  remote_file = sftp.open(filename, "r")
157
  try:
158
    return remote_file.read()
159
  finally:
160
    remote_file.close()
161

    
162

    
163
def _WriteSftpFile(sftp, name, perm, data):
164
  """SFTPs data to a remote file.
165

    
166
  @param sftp: A open paramiko SFTP client
167
  @param name: The remote file name
168
  @param perm: The remote file permission
169
  @param data: The data to write
170

    
171
  """
172
  remote_file = sftp.open(name, "w")
173
  try:
174
    sftp.chmod(name, perm)
175
    remote_file.write(data)
176
  finally:
177
    remote_file.close()
178

    
179

    
180
def SetupSSH(transport):
181
  """Sets the SSH up on the other side.
182

    
183
  @param transport: The paramiko transport instance
184

    
185
  """
186
  priv_key, pub_key, auth_keys = ssh.GetUserFiles(constants.SSH_LOGIN_USER)
187
  keyfiles = [
188
    (pathutils.SSH_HOST_DSA_PRIV, 0600),
189
    (pathutils.SSH_HOST_DSA_PUB, 0644),
190
    (pathutils.SSH_HOST_RSA_PRIV, 0600),
191
    (pathutils.SSH_HOST_RSA_PUB, 0644),
192
    (priv_key, 0600),
193
    (pub_key, 0644),
194
    ]
195

    
196
  sftp = transport.open_sftp_client()
197

    
198
  filemap = dict((name, (utils.ReadFile(name), perm))
199
                 for (name, perm) in keyfiles)
200

    
201
  auth_path = os.path.dirname(auth_keys)
202

    
203
  try:
204
    sftp.mkdir(auth_path, 0700)
205
  except IOError, err:
206
    # Sadly paramiko doesn't provide errno or similiar
207
    # so we can just assume that the path already exists
208
    logging.info("Assuming directory %s on remote node exists: %s",
209
                 auth_path, err)
210

    
211
  for name, (data, perm) in filemap.iteritems():
212
    _WriteSftpFile(sftp, name, perm, data)
213

    
214
  authorized_keys = sftp.open(auth_keys, "a+")
215
  try:
216
    # Due to the way SFTPFile and BufferedFile are implemented,
217
    # opening in a+ mode and then issuing a read(), readline() or
218
    # iterating over the file (which uses read() internally) will see
219
    # an empty file, since the paramiko internal file position and the
220
    # OS-level file-position are desynchronized; therefore, we issue
221
    # an explicit seek to resynchronize these; writes should (note
222
    # should) still go to the right place
223
    authorized_keys.seek(0, 0)
224
    # We don't have to close, as the close happened already in AddAuthorizedKey
225
    utils.AddAuthorizedKey(authorized_keys, filemap[pub_key][0])
226
  finally:
227
    authorized_keys.close()
228

    
229
  _InvokeDaemonUtil(transport, "reload-ssh-keys")
230

    
231

    
232
def ParseOptions():
233
  """Parses options passed to program.
234

    
235
  """
236
  program = os.path.basename(sys.argv[0])
237
  (default_key, _, _) = ssh.GetUserFiles(constants.SSH_LOGIN_USER)
238

    
239
  parser = optparse.OptionParser(usage=("%prog [--debug|--verbose] [--force]"
240
                                        " <node> <node...>"), prog=program)
241
  parser.add_option(cli.DEBUG_OPT)
242
  parser.add_option(cli.VERBOSE_OPT)
243
  parser.add_option(cli.NOSSH_KEYCHECK_OPT)
244
  parser.add_option(optparse.Option("-f", dest="private_key",
245
                                    default=default_key,
246
                                    help="The private key to (try to) use for"
247
                                    "authentication "))
248
  parser.add_option(optparse.Option("--key-type", dest="key_type",
249
                                    choices=("rsa", "dsa"), default="dsa",
250
                                    help="The private key type (rsa or dsa)"))
251
  parser.add_option(optparse.Option("-j", "--force-join", dest="force_join",
252
                                    action="store_true", default=False,
253
                                    help="Force the join of the host"))
254

    
255
  (options, args) = parser.parse_args()
256

    
257
  if not args:
258
    parser.print_help()
259
    sys.exit(constants.EXIT_FAILURE)
260

    
261
  return (options, args)
262

    
263

    
264
def SetupLogging(options):
265
  """Sets up the logging.
266

    
267
  @param options: Parsed options
268

    
269
  """
270
  fmt = "%(asctime)s: %(threadName)s "
271
  if options.debug or options.verbose:
272
    fmt += "%(levelname)s "
273
  fmt += "%(message)s"
274

    
275
  formatter = logging.Formatter(fmt)
276

    
277
  file_handler = logging.FileHandler(pathutils.LOG_SETUP_SSH)
278
  stderr_handler = logging.StreamHandler()
279
  stderr_handler.setFormatter(formatter)
280
  file_handler.setFormatter(formatter)
281
  file_handler.setLevel(logging.INFO)
282

    
283
  if options.debug:
284
    stderr_handler.setLevel(logging.DEBUG)
285
  elif options.verbose:
286
    stderr_handler.setLevel(logging.INFO)
287
  else:
288
    stderr_handler.setLevel(logging.WARNING)
289

    
290
  root_logger = logging.getLogger("")
291
  root_logger.setLevel(logging.NOTSET)
292
  root_logger.addHandler(stderr_handler)
293
  root_logger.addHandler(file_handler)
294

    
295
  # This is the paramiko logger instance
296
  paramiko_logger = logging.getLogger("paramiko")
297
  paramiko_logger.addHandler(file_handler)
298
  # We don't want to debug Paramiko, so filter anything below warning
299
  paramiko_logger.setLevel(logging.WARNING)
300

    
301

    
302
def LoadPrivateKeys(options):
303
  """Load the list of available private keys.
304

    
305
  It loads the standard ssh key from disk and then tries to connect to
306
  the ssh agent too.
307

    
308
  @rtype: list
309
  @return: a list of C{paramiko.PKey}
310

    
311
  """
312
  if options.key_type == "rsa":
313
    pkclass = paramiko.RSAKey
314
  elif options.key_type == "dsa":
315
    pkclass = paramiko.DSSKey
316
  else:
317
    logging.critical("Unknown key type %s selected (choose either rsa or dsa)",
318
                     options.key_type)
319
    sys.exit(1)
320

    
321
  try:
322
    private_key = pkclass.from_private_key_file(options.private_key)
323
  except (paramiko.SSHException, EnvironmentError), err:
324
    logging.critical("Can't load private key %s: %s", options.private_key, err)
325
    sys.exit(1)
326

    
327
  try:
328
    agent = paramiko.Agent()
329
    agent_keys = agent.get_keys()
330
  except paramiko.SSHException, err:
331
    # this will only be seen when the agent is broken/uses invalid
332
    # protocol; for non-existing agent, get_keys() will just return an
333
    # empty tuple
334
    logging.warning("Can't connect to the ssh agent: %s; skipping its use",
335
                    err)
336
    agent_keys = []
337

    
338
  return [private_key] + list(agent_keys)
339

    
340

    
341
def _FormatFingerprint(fpr):
342
  """Formats a paramiko.PKey.get_fingerprint() human readable.
343

    
344
  @param fpr: The fingerprint to be formatted
345
  @return: A human readable fingerprint
346

    
347
  """
348
  return ssh.FormatParamikoFingerprint(paramiko.util.hexify(fpr))
349

    
350

    
351
def LoginViaKeys(transport, username, keys):
352
  """Try to login on the given transport via a list of keys.
353

    
354
  @param transport: the transport to use
355
  @param username: the username to login as
356
  @type keys: list
357
  @param keys: list of C{paramiko.PKey} to use for authentication
358
  @rtype: boolean
359
  @return: True or False depending on whether the login was
360
      successfull or not
361

    
362
  """
363
  for private_key in keys:
364
    try:
365
      transport.auth_publickey(username, private_key)
366
      fpr = _FormatFingerprint(private_key.get_fingerprint())
367
      if isinstance(private_key, paramiko.AgentKey):
368
        logging.debug("Authentication via the ssh-agent key %s", fpr)
369
      else:
370
        logging.debug("Authenticated via public key %s", fpr)
371
      return True
372
    except paramiko.SSHException:
373
      continue
374
  else:
375
    # all keys exhausted
376
    return False
377

    
378

    
379
def LoadKnownHosts():
380
  """Load the known hosts.
381

    
382
  @return: paramiko.util.load_host_keys dict
383

    
384
  """
385
  homedir = utils.GetHomeDir(constants.SSH_LOGIN_USER)
386
  known_hosts = os.path.join(homedir, ".ssh", "known_hosts")
387

    
388
  try:
389
    return paramiko.util.load_host_keys(known_hosts)
390
  except EnvironmentError:
391
    # We didn't find the path, silently ignore and return an empty dict
392
    return {}
393

    
394

    
395
def _VerifyServerKey(transport, host, host_keys):
396
  """Verify the server keys.
397

    
398
  @param transport: A paramiko.transport instance
399
  @param host: Name of the host we verify
400
  @param host_keys: Loaded host keys
401
  @raises HostkeyVerificationError: When the host identify couldn't be verified
402

    
403
  """
404

    
405
  server_key = transport.get_remote_server_key()
406
  keytype = server_key.get_name()
407

    
408
  our_server_key = host_keys.get(host, {}).get(keytype, None)
409
  if not our_server_key:
410
    hexified_key = _FormatFingerprint(server_key.get_fingerprint())
411
    msg = ("Unable to verify hostkey of host %s: %s. Do you want to accept"
412
           " it?" % (host, hexified_key))
413

    
414
    if cli.AskUser(msg):
415
      our_server_key = server_key
416

    
417
  if our_server_key != server_key:
418
    raise HostKeyVerificationError("Unable to verify host identity")
419

    
420

    
421
def main():
422
  """Main routine.
423

    
424
  """
425
  (options, args) = ParseOptions()
426

    
427
  SetupLogging(options)
428

    
429
  all_keys = LoadPrivateKeys(options)
430

    
431
  passwd = None
432
  username = constants.SSH_LOGIN_USER
433
  ssh_port = netutils.GetDaemonPort("ssh")
434
  host_keys = LoadKnownHosts()
435

    
436
  # Below, we need to join() the transport objects, as otherwise the
437
  # following happens:
438
  # - the main thread finishes
439
  # - the atexit functions run (in the main thread), and cause the
440
  #   logging file to be closed
441
  # - a tiny bit later, the transport thread is finally ending, and
442
  #   wants to log one more message, which fails as the file is closed
443
  #   now
444

    
445
  success = True
446

    
447
  for host in args:
448
    logging.info("Configuring %s", host)
449

    
450
    transport = paramiko.Transport((host, ssh_port))
451
    try:
452
      try:
453
        transport.start_client()
454

    
455
        if options.ssh_key_check:
456
          _VerifyServerKey(transport, host, host_keys)
457

    
458
        try:
459
          if LoginViaKeys(transport, username, all_keys):
460
            logging.info("Authenticated to %s via public key", host)
461
          else:
462
            if all_keys:
463
              logging.warning("Authentication to %s via public key failed,"
464
                              " trying password", host)
465
            if passwd is None:
466
              passwd = getpass.getpass(prompt="%s password:" % username)
467
            transport.auth_password(username=username, password=passwd)
468
            logging.info("Authenticated to %s via password", host)
469
        except paramiko.SSHException, err:
470
          raise AuthError("Auth error TODO" % err)
471

    
472
        if not _CheckJoin(transport):
473
          if not options.force_join:
474
            raise JoinCheckError(("Host %s failed join check; Please verify"
475
                                  " that the host was not previously joined"
476
                                  " to another cluster and use --force-join"
477
                                  " to continue") % host)
478

    
479
          logging.warning("Host %s failed join check, forced to continue",
480
                          host)
481

    
482
        SetupSSH(transport)
483
        logging.info("%s successfully configured", host)
484
      finally:
485
        transport.close()
486
        # this is needed for compatibility with older Paramiko or Python
487
        # versions
488
        transport.join()
489
    except AuthError, err:
490
      logging.error("Authentication error: %s", err)
491
      success = False
492
      break
493
    except HostKeyVerificationError, err:
494
      logging.error("Host key verification error: %s", err)
495
      success = False
496
    except Exception, err:
497
      logging.exception("During setup of %s: %s", host, err)
498
      success = False
499

    
500
  if success:
501
    sys.exit(constants.EXIT_SUCCESS)
502

    
503
  sys.exit(constants.EXIT_FAILURE)
504

    
505

    
506
if __name__ == "__main__":
507
  main()