Revision 8a670753

b/Makefile.am
681 681
	tools/lvmstrap \
682 682
	tools/move-instance \
683 683
	tools/ovfconverter \
684
	tools/sanitize-config \
685
	tools/setup-ssh
684
	tools/sanitize-config
686 685

  
687 686
dist_tools_SCRIPTS = \
688 687
	$(python_scripts) \
b/lib/pathutils.py
43 43
IMPORT_EXPORT_DAEMON = _autoconf.PKGLIBDIR + "/import-export"
44 44
KVM_CONSOLE_WRAPPER = _autoconf.PKGLIBDIR + "/tools/kvm-console-wrapper"
45 45
KVM_IFUP = _autoconf.PKGLIBDIR + "/kvm-ifup"
46
SETUP_SSH = _autoconf.TOOLSDIR + "/setup-ssh"
47 46
PREPARE_NODE_JOIN = _autoconf.PKGLIBDIR + "/prepare-node-join"
48 47
XM_CONSOLE_WRAPPER = _autoconf.PKGLIBDIR + "/tools/xm-console-wrapper"
49 48
ETC_HOSTS = vcluster.ETC_HOSTS
......
139 138
LOG_WATCHER = GetLogFilename("watcher")
140 139
LOG_COMMANDS = GetLogFilename("commands")
141 140
LOG_BURNIN = GetLogFilename("burnin")
142
LOG_SETUP_SSH = GetLogFilename("setup-ssh")
/dev/null
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()

Also available in: Unified diff