Statistics
| Branch: | Tag: | Revision:

root / tools / setup-ssh @ 3ec10f0d

History | View | Annotate | Download (14.5 kB)

1
#!/usr/bin/python
2
#
3

    
4
# Copyright (C) 2010 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
  return (options, args)
257

    
258

    
259
def SetupLogging(options):
260
  """Sets up the logging.
261

    
262
  @param options: Parsed options
263

    
264
  """
265
  fmt = "%(asctime)s: %(threadName)s "
266
  if options.debug or options.verbose:
267
    fmt += "%(levelname)s "
268
  fmt += "%(message)s"
269

    
270
  formatter = logging.Formatter(fmt)
271

    
272
  file_handler = logging.FileHandler(constants.LOG_SETUP_SSH)
273
  stderr_handler = logging.StreamHandler()
274
  stderr_handler.setFormatter(formatter)
275
  file_handler.setFormatter(formatter)
276
  file_handler.setLevel(logging.INFO)
277

    
278
  if options.debug:
279
    stderr_handler.setLevel(logging.DEBUG)
280
  elif options.verbose:
281
    stderr_handler.setLevel(logging.INFO)
282
  else:
283
    stderr_handler.setLevel(logging.WARNING)
284

    
285
  root_logger = logging.getLogger("")
286
  root_logger.setLevel(logging.NOTSET)
287
  root_logger.addHandler(stderr_handler)
288
  root_logger.addHandler(file_handler)
289

    
290
  # This is the paramiko logger instance
291
  paramiko_logger = logging.getLogger("paramiko")
292
  paramiko_logger.addHandler(file_handler)
293
  # We don't want to debug Paramiko, so filter anything below warning
294
  paramiko_logger.setLevel(logging.WARNING)
295

    
296

    
297
def LoadPrivateKeys(options):
298
  """Load the list of available private keys.
299

    
300
  It loads the standard ssh key from disk and then tries to connect to
301
  the ssh agent too.
302

    
303
  @rtype: list
304
  @return: a list of C{paramiko.PKey}
305

    
306
  """
307
  if options.key_type == "rsa":
308
    pkclass = paramiko.RSAKey
309
  elif options.key_type == "dsa":
310
    pkclass = paramiko.DSSKey
311
  else:
312
    logging.critical("Unknown key type %s selected (choose either rsa or dsa)",
313
                     options.key_type)
314
    sys.exit(1)
315

    
316
  try:
317
    private_key = pkclass.from_private_key_file(options.private_key)
318
  except (paramiko.SSHException, EnvironmentError), err:
319
    logging.critical("Can't load private key %s: %s", options.private_key, err)
320
    sys.exit(1)
321

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

    
333
  return [private_key] + list(agent_keys)
334

    
335

    
336
def _FormatFingerprint(fpr):
337
  """Formats a paramiko.PKey.get_fingerprint() human readable.
338

    
339
  @param fpr: The fingerprint to be formatted
340
  @return: A human readable fingerprint
341

    
342
  """
343
  return ssh.FormatParamikoFingerprint(paramiko.util.hexify(fpr))
344

    
345

    
346
def LoginViaKeys(transport, username, keys):
347
  """Try to login on the given transport via a list of keys.
348

    
349
  @param transport: the transport to use
350
  @param username: the username to login as
351
  @type keys: list
352
  @param keys: list of C{paramiko.PKey} to use for authentication
353
  @rtype: boolean
354
  @return: True or False depending on whether the login was
355
      successfull or not
356

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

    
373

    
374
def LoadKnownHosts():
375
  """Load the known hosts.
376

    
377
  @return: paramiko.util.load_host_keys dict
378

    
379
  """
380
  homedir = utils.GetHomeDir(constants.GANETI_RUNAS)
381
  known_hosts = os.path.join(homedir, ".ssh", "known_hosts")
382

    
383
  try:
384
    return paramiko.util.load_host_keys(known_hosts)
385
  except EnvironmentError:
386
    # We didn't find the path, silently ignore and return an empty dict
387
    return {}
388

    
389

    
390
def _VerifyServerKey(transport, host, host_keys):
391
  """Verify the server keys.
392

    
393
  @param transport: A paramiko.transport instance
394
  @param host: Name of the host we verify
395
  @param host_keys: Loaded host keys
396
  @raises HostkeyVerificationError: When the host identify couldn't be verified
397

    
398
  """
399

    
400
  server_key = transport.get_remote_server_key()
401
  keytype = server_key.get_name()
402

    
403
  our_server_key = host_keys.get(host, {}).get(keytype, None)
404
  if not our_server_key:
405
    hexified_key = _FormatFingerprint(server_key.get_fingerprint())
406
    msg = ("Unable to verify hostkey of host %s: %s. Do you want to accept"
407
           " it?" % (host, hexified_key))
408

    
409
    if cli.AskUser(msg):
410
      our_server_key = server_key
411

    
412
  if our_server_key != server_key:
413
    raise HostKeyVerificationError("Unable to verify host identity")
414

    
415

    
416
def main():
417
  """Main routine.
418

    
419
  """
420
  (options, args) = ParseOptions()
421

    
422
  SetupLogging(options)
423

    
424
  all_keys = LoadPrivateKeys(options)
425

    
426
  passwd = None
427
  username = constants.GANETI_RUNAS
428
  ssh_port = netutils.GetDaemonPort("ssh")
429
  host_keys = LoadKnownHosts()
430

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

    
440
  success = True
441

    
442
  for host in args:
443
    logging.info("Configuring %s", host)
444

    
445
    transport = paramiko.Transport((host, ssh_port))
446
    try:
447
      try:
448
        transport.start_client()
449

    
450
        if options.ssh_key_check:
451
          _VerifyServerKey(transport, host, host_keys)
452

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

    
467
        if not _CheckJoin(transport):
468
          if not options.force_join:
469
            raise JoinCheckError(("Host %s failed join check; Please verify"
470
                                  " that the host was not previously joined"
471
                                  " to another cluster and use --force-join"
472
                                  " to continue") % host)
473

    
474
          logging.warning("Host %s failed join check, forced to continue",
475
                          host)
476

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

    
495
  if success:
496
    sys.exit(constants.EXIT_SUCCESS)
497

    
498
  sys.exit(constants.EXIT_FAILURE)
499

    
500

    
501
if __name__ == "__main__":
502
  main()