Statistics
| Branch: | Tag: | Revision:

root / tools / setup-ssh @ 697a3d61

History | View | Annotate | Download (11.8 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-msg=C0103
28
# C0103: Invalid name setup-ssh
29

    
30
import getpass
31
import logging
32
import paramiko
33
import os.path
34
import optparse
35
import sys
36

    
37
from ganeti import cli
38
from ganeti import constants
39
from ganeti import errors
40
from ganeti import netutils
41
from ganeti import ssh
42
from ganeti import utils
43

    
44

    
45
class RemoteCommandError(errors.GenericError):
46
  """Exception if remote command was not successful.
47

    
48
  """
49

    
50

    
51
def _RunRemoteCommand(transport, command):
52
  """Invokes and wait for the command over SSH.
53

    
54
  @param transport: The paramiko transport instance
55
  @param command: The command to be executed
56

    
57
  """
58
  chan = transport.open_session()
59
  chan.set_combine_stderr(True)
60
  output_handler = chan.makefile("r")
61
  chan.exec_command(command)
62

    
63
  result = chan.recv_exit_status()
64
  msg = output_handler.read()
65

    
66
  out_msg = "'%s' exited with status code %s, output %r" % (command, result,
67
                                                            msg)
68

    
69
  # If result is -1 (no exit status provided) we assume it was not successful
70
  if result:
71
    raise RemoteCommandError(out_msg)
72

    
73
  if msg:
74
    logging.info(out_msg)
75

    
76

    
77
def _InvokeDaemonUtil(transport, command):
78
  """Invokes daemon-util on the remote side.
79

    
80
  @param transport: The paramiko transport instance
81
  @param command: The daemon-util command to be run
82

    
83
  """
84
  _RunRemoteCommand(transport, "%s %s" % (constants.DAEMON_UTIL, command))
85

    
86

    
87
def _WriteSftpFile(sftp, name, perm, data):
88
  """SFTPs data to a remote file.
89

    
90
  @param sftp: A open paramiko SFTP client
91
  @param name: The remote file name
92
  @param perm: The remote file permission
93
  @param data: The data to write
94

    
95
  """
96
  remote_file = sftp.open(name, "w")
97
  try:
98
    sftp.chmod(name, perm)
99
    remote_file.write(data)
100
  finally:
101
    remote_file.close()
102

    
103

    
104
def SetupSSH(transport):
105
  """Sets the SSH up on the other side.
106

    
107
  @param transport: The paramiko transport instance
108

    
109
  """
110
  priv_key, pub_key, auth_keys = ssh.GetUserFiles(constants.GANETI_RUNAS)
111
  keyfiles = [
112
    (constants.SSH_HOST_DSA_PRIV, 0600),
113
    (constants.SSH_HOST_DSA_PUB, 0644),
114
    (constants.SSH_HOST_RSA_PRIV, 0600),
115
    (constants.SSH_HOST_RSA_PUB, 0644),
116
    (priv_key, 0600),
117
    (pub_key, 0644),
118
    ]
119

    
120
  sftp = transport.open_sftp_client()
121

    
122
  filemap = dict((name, (utils.ReadFile(name), perm))
123
                 for (name, perm) in keyfiles)
124

    
125
  auth_path = os.path.dirname(auth_keys)
126

    
127
  try:
128
    sftp.mkdir(auth_path, 0700)
129
  except IOError:
130
    # Sadly paramiko doesn't provide errno or similiar
131
    # so we can just assume that the path already exists
132
    logging.info("Path %s seems already to exist on remote node. Ignoring.",
133
                 auth_path)
134

    
135
  for name, (data, perm) in filemap.iteritems():
136
    _WriteSftpFile(sftp, name, perm, data)
137

    
138
  authorized_keys = sftp.open(auth_keys, "a+")
139
  try:
140
    # Due to the way SFTPFile and BufferedFile are implemented,
141
    # opening in a+ mode and then issuing a read(), readline() or
142
    # iterating over the file (which uses read() internally) will see
143
    # an empty file, since the paramiko internal file position and the
144
    # OS-level file-position are desynchronized; therefore, we issue
145
    # an explicit seek to resynchronize these; writes should (note
146
    # should) still go to the right place
147
    authorized_keys.seek(0, 0)
148
    # We don't have to close, as the close happened already in AddAuthorizedKey
149
    utils.AddAuthorizedKey(authorized_keys, filemap[pub_key][0])
150
  finally:
151
    authorized_keys.close()
152

    
153
  _InvokeDaemonUtil(transport, "reload-ssh-keys")
154

    
155

    
156
def SetupNodeDaemon(transport):
157
  """Sets the node daemon up on the other side.
158

    
159
  @param transport: The paramiko transport instance
160

    
161
  """
162
  noded_cert = utils.ReadFile(constants.NODED_CERT_FILE)
163

    
164
  sftp = transport.open_sftp_client()
165
  _WriteSftpFile(sftp, constants.NODED_CERT_FILE, 0400, noded_cert)
166

    
167
  _InvokeDaemonUtil(transport, "start %s" % constants.NODED)
168

    
169

    
170
def ParseOptions():
171
  """Parses options passed to program.
172

    
173
  """
174
  program = os.path.basename(sys.argv[0])
175

    
176
  parser = optparse.OptionParser(usage=("%prog [--debug|--verbose] <node>"
177
                                        " <node...>"), prog=program)
178
  parser.add_option(cli.DEBUG_OPT)
179
  parser.add_option(cli.VERBOSE_OPT)
180
  parser.add_option(cli.NOSSH_KEYCHECK_OPT)
181
  default_key = ssh.GetUserFiles(constants.GANETI_RUNAS)[0]
182
  parser.add_option(optparse.Option("-f", dest="private_key",
183
                                    default=default_key,
184
                                    help="The private key to (try to) use for"
185
                                    "authentication "))
186
  parser.add_option(optparse.Option("--key-type", dest="key_type",
187
                                    choices=("rsa", "dsa"), default="dsa",
188
                                    help="The private key type (rsa or dsa)"))
189

    
190
  (options, args) = parser.parse_args()
191

    
192
  return (options, args)
193

    
194

    
195
def SetupLogging(options):
196
  """Sets up the logging.
197

    
198
  @param options: Parsed options
199

    
200
  """
201
  fmt = "%(asctime)s: %(threadName)s "
202
  if options.debug or options.verbose:
203
    fmt += "%(levelname)s "
204
  fmt += "%(message)s"
205

    
206
  formatter = logging.Formatter(fmt)
207

    
208
  file_handler = logging.FileHandler(constants.LOG_SETUP_SSH)
209
  stderr_handler = logging.StreamHandler()
210
  stderr_handler.setFormatter(formatter)
211
  file_handler.setFormatter(formatter)
212
  file_handler.setLevel(logging.INFO)
213

    
214
  if options.debug:
215
    stderr_handler.setLevel(logging.DEBUG)
216
  elif options.verbose:
217
    stderr_handler.setLevel(logging.INFO)
218
  else:
219
    stderr_handler.setLevel(logging.WARNING)
220

    
221
  root_logger = logging.getLogger("")
222
  root_logger.setLevel(logging.NOTSET)
223
  root_logger.addHandler(stderr_handler)
224
  root_logger.addHandler(file_handler)
225

    
226
  # This is the paramiko logger instance
227
  paramiko_logger = logging.getLogger("paramiko")
228
  paramiko_logger.addHandler(file_handler)
229
  # We don't want to debug Paramiko, so filter anything below warning
230
  paramiko_logger.setLevel(logging.WARNING)
231

    
232

    
233
def LoadPrivateKeys(options):
234
  """Load the list of available private keys.
235

    
236
  It loads the standard ssh key from disk and then tries to connect to
237
  the ssh agent too.
238

    
239
  @rtype: list
240
  @return: a list of C{paramiko.PKey}
241

    
242
  """
243
  if options.key_type == "rsa":
244
    pkclass = paramiko.RSAKey
245
  elif options.key_type == "dsa":
246
    pkclass = paramiko.DSSKey
247
  else:
248
    logging.critical("Unknown key type %s selected (choose either rsa or dsa)",
249
                     options.key_type)
250
    sys.exit(1)
251

    
252
  try:
253
    private_key = pkclass.from_private_key_file(options.private_key)
254
  except (paramiko.SSHException, EnvironmentError), err:
255
    logging.critical("Can't load private key %s: %s", options.private_key, err)
256
    sys.exit(1)
257

    
258
  try:
259
    agent = paramiko.Agent()
260
    agent_keys = agent.get_keys()
261
  except paramiko.SSHException, err:
262
    # this will only be seen when the agent is broken/uses invalid
263
    # protocol; for non-existing agent, get_keys() will just return an
264
    # empty tuple
265
    logging.warning("Can't connect to the ssh agent: %s; skipping its use",
266
                    err)
267
    agent_keys = []
268

    
269
  return [private_key] + list(agent_keys)
270

    
271

    
272
def LoginViaKeys(transport, username, keys):
273
  """Try to login on the given transport via a list of keys.
274

    
275
  @param transport: the transport to use
276
  @param username: the username to login as
277
  @type keys: list
278
  @param keys: list of C{paramiko.PKey} to use for authentication
279
  @rtype: boolean
280
  @return: True or False depending on whether the login was
281
      successfull or not
282

    
283
  """
284
  for private_key in keys:
285
    try:
286
      transport.auth_publickey(username, private_key)
287
      fpr = ":".join("%02x" % ord(i) for i in private_key.get_fingerprint())
288
      if isinstance(private_key, paramiko.AgentKey):
289
        logging.debug("Authentication via the ssh-agent key %s", fpr)
290
      else:
291
        logging.debug("Authenticated via public key %s", fpr)
292
      return True
293
    except paramiko.SSHException:
294
      continue
295
  else:
296
    # all keys exhausted
297
    return False
298

    
299

    
300
def LoadKnownHosts():
301
  """Load the known hosts.
302

    
303
  @return: paramiko.util.load_host_keys dict
304

    
305
  """
306
  homedir = utils.GetHomeDir(constants.GANETI_RUNAS)
307
  known_hosts = os.path.join(homedir, ".ssh", "known_hosts")
308

    
309
  try:
310
    return paramiko.util.load_host_keys(known_hosts)
311
  except EnvironmentError:
312
    # We didn't found the path, silently ignore and return an empty dict
313
    return {}
314

    
315

    
316
def main():
317
  """Main routine.
318

    
319
  """
320
  (options, args) = ParseOptions()
321

    
322
  SetupLogging(options)
323

    
324
  all_keys = LoadPrivateKeys(options)
325

    
326
  passwd = None
327
  username = constants.GANETI_RUNAS
328
  ssh_port = netutils.GetDaemonPort("ssh")
329
  host_keys = LoadKnownHosts()
330

    
331
  # Below, we need to join() the transport objects, as otherwise the
332
  # following happens:
333
  # - the main thread finishes
334
  # - the atexit functions run (in the main thread), and cause the
335
  #   logging file to be closed
336
  # - a tiny bit later, the transport thread is finally ending, and
337
  #   wants to log one more message, which fails as the file is closed
338
  #   now
339

    
340
  for host in args:
341
    transport = paramiko.Transport((host, ssh_port))
342
    transport.start_client()
343
    server_key = transport.get_remote_server_key()
344
    keytype = server_key.get_name()
345

    
346
    our_server_key = host_keys.get(host, {}).get(keytype, None)
347
    if options.ssh_key_check:
348
      if not our_server_key:
349
        hexified_key = ssh.FormatParamikoFingerprint(
350
            server_key.get_fingerprint())
351
        msg = ("Unable to verify hostkey of host %s: %s. Do you want to accept"
352
               " it?" % (host, hexified_key))
353

    
354
        if cli.AskUser(msg):
355
          our_server_key = server_key
356

    
357
      if our_server_key != server_key:
358
        logging.error("Unable to verify identity of host. Aborting")
359
        transport.close()
360
        transport.join()
361
        # TODO: Run over all hosts, fetch the keys and let them verify from the
362
        #       user beforehand then proceed with actual work later on
363
        raise paramiko.SSHException("Unable to verify identity of host")
364

    
365
    try:
366
      if LoginViaKeys(transport, username, all_keys):
367
        logging.info("Authenticated to %s via public key", host)
368
      else:
369
        logging.warning("Authentication to %s via public key failed, trying"
370
                        " password", host)
371
        if passwd is None:
372
          passwd = getpass.getpass(prompt="%s password:" % username)
373
        transport.auth_password(username=username, password=passwd)
374
        logging.info("Authenticated to %s via password", host)
375
    except paramiko.SSHException, err:
376
      logging.error("Connection or authentication failed to host %s: %s",
377
                    host, err)
378
      transport.close()
379
      # this is needed for compatibility with older Paramiko or Python
380
      # versions
381
      transport.join()
382
      continue
383
    try:
384
      try:
385
        SetupSSH(transport)
386
        SetupNodeDaemon(transport)
387
      except errors.GenericError, err:
388
        logging.error("While doing setup on host %s an error occured: %s",
389
                      host, err)
390
    finally:
391
      transport.close()
392
      # this is needed for compatibility with older Paramiko or Python
393
      # versions
394
      transport.join()
395

    
396

    
397
if __name__ == "__main__":
398
  main()