Statistics
| Branch: | Tag: | Revision:

root / tools / setup-ssh @ 2c9cf6bb

History | View | Annotate | Download (11.4 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 ParseOptions():
157
  """Parses options passed to program.
158

    
159
  """
160
  program = os.path.basename(sys.argv[0])
161

    
162
  parser = optparse.OptionParser(usage=("%prog [--debug|--verbose] <node>"
163
                                        " <node...>"), prog=program)
164
  parser.add_option(cli.DEBUG_OPT)
165
  parser.add_option(cli.VERBOSE_OPT)
166
  parser.add_option(cli.NOSSH_KEYCHECK_OPT)
167
  default_key = ssh.GetUserFiles(constants.GANETI_RUNAS)[0]
168
  parser.add_option(optparse.Option("-f", dest="private_key",
169
                                    default=default_key,
170
                                    help="The private key to (try to) use for"
171
                                    "authentication "))
172
  parser.add_option(optparse.Option("--key-type", dest="key_type",
173
                                    choices=("rsa", "dsa"), default="dsa",
174
                                    help="The private key type (rsa or dsa)"))
175

    
176
  (options, args) = parser.parse_args()
177

    
178
  return (options, args)
179

    
180

    
181
def SetupLogging(options):
182
  """Sets up the logging.
183

    
184
  @param options: Parsed options
185

    
186
  """
187
  fmt = "%(asctime)s: %(threadName)s "
188
  if options.debug or options.verbose:
189
    fmt += "%(levelname)s "
190
  fmt += "%(message)s"
191

    
192
  formatter = logging.Formatter(fmt)
193

    
194
  file_handler = logging.FileHandler(constants.LOG_SETUP_SSH)
195
  stderr_handler = logging.StreamHandler()
196
  stderr_handler.setFormatter(formatter)
197
  file_handler.setFormatter(formatter)
198
  file_handler.setLevel(logging.INFO)
199

    
200
  if options.debug:
201
    stderr_handler.setLevel(logging.DEBUG)
202
  elif options.verbose:
203
    stderr_handler.setLevel(logging.INFO)
204
  else:
205
    stderr_handler.setLevel(logging.WARNING)
206

    
207
  root_logger = logging.getLogger("")
208
  root_logger.setLevel(logging.NOTSET)
209
  root_logger.addHandler(stderr_handler)
210
  root_logger.addHandler(file_handler)
211

    
212
  # This is the paramiko logger instance
213
  paramiko_logger = logging.getLogger("paramiko")
214
  paramiko_logger.addHandler(file_handler)
215
  # We don't want to debug Paramiko, so filter anything below warning
216
  paramiko_logger.setLevel(logging.WARNING)
217

    
218

    
219
def LoadPrivateKeys(options):
220
  """Load the list of available private keys
221

    
222
  It loads the standard ssh key from disk and then tries to connect to
223
  the ssh agent too.
224

    
225
  @rtype: list
226
  @return: a list of C{paramiko.PKey}
227

    
228
  """
229
  if options.key_type == "rsa":
230
    pkclass = paramiko.RSAKey
231
  elif options.key_type == "dsa":
232
    pkclass = paramiko.DSSKey
233
  else:
234
    logging.critical("Unknown key type %s selected (choose either rsa or dsa)",
235
                     options.key_type)
236
    sys.exit(1)
237

    
238
  try:
239
    private_key = pkclass.from_private_key_file(options.private_key)
240
  except (paramiko.SSHException, EnvironmentError), err:
241
    logging.critical("Can't load private key %s: %s", options.private_key, err)
242
    sys.exit(1)
243

    
244
  try:
245
    agent = paramiko.Agent()
246
    agent_keys = agent.get_keys()
247
  except paramiko.SSHException, err:
248
    # this will only be seen when the agent is broken/uses invalid
249
    # protocol; for non-existing agent, get_keys() will just return an
250
    # empty tuple
251
    logging.warning("Can't connect to the ssh agent: %s; skipping its use",
252
                    err)
253
    agent_keys = []
254

    
255
  return [private_key] + list(agent_keys)
256

    
257

    
258
def LoginViaKeys(transport, username, keys):
259
  """Try to login on the given transport via a list of keys.
260

    
261
  @param transport: the transport to use
262
  @param username: the username to login as
263
  @type keys: list
264
  @param keys: list of C{paramiko.PKey} to use for authentication
265
  @rtype: boolean
266
  @return: True or False depending on whether the login was
267
      successfull or not
268

    
269
  """
270
  for private_key in keys:
271
    try:
272
      transport.auth_publickey(username, private_key)
273
      fpr = ":".join("%02x" % ord(i) for i in private_key.get_fingerprint())
274
      if isinstance(private_key, paramiko.AgentKey):
275
        logging.debug("Authentication via the ssh-agent key %s", fpr)
276
      else:
277
        logging.debug("Authenticated via public key %s", fpr)
278
      return True
279
    except paramiko.SSHException:
280
      continue
281
  else:
282
    # all keys exhausted
283
    return False
284

    
285

    
286
def LoadKnownHosts():
287
  """Loads the known hosts
288

    
289
    @return L{paramiko.util.load_host_keys} dict
290

    
291
  """
292
  homedir = utils.GetHomeDir(constants.GANETI_RUNAS)
293
  known_hosts = os.path.join(homedir, ".ssh", "known_hosts")
294

    
295
  try:
296
    return paramiko.util.load_host_keys(known_hosts)
297
  except EnvironmentError:
298
    # We didn't found the path, silently ignore and return an empty dict
299
    return {}
300

    
301

    
302
def main():
303
  """Main routine.
304

    
305
  """
306
  (options, args) = ParseOptions()
307

    
308
  SetupLogging(options)
309

    
310
  all_keys = LoadPrivateKeys(options)
311

    
312
  passwd = None
313
  username = constants.GANETI_RUNAS
314
  ssh_port = netutils.GetDaemonPort("ssh")
315
  host_keys = LoadKnownHosts()
316

    
317
  # Below, we need to join() the transport objects, as otherwise the
318
  # following happens:
319
  # - the main thread finishes
320
  # - the atexit functions run (in the main thread), and cause the
321
  #   logging file to be closed
322
  # - a tiny bit later, the transport thread is finally ending, and
323
  #   wants to log one more message, which fails as the file is closed
324
  #   now
325

    
326
  for host in args:
327
    transport = paramiko.Transport((host, ssh_port))
328
    transport.start_client()
329
    server_key = transport.get_remote_server_key()
330
    keytype = server_key.get_name()
331

    
332
    our_server_key = host_keys.get(host, {}).get(keytype, None)
333
    if options.ssh_key_check:
334
      if not our_server_key:
335
        hexified_key = ssh.FormatParamikoFingerprint(
336
            server_key.get_fingerprint())
337
        msg = ("Unable to verify hostkey of host %s: %s. Do you want to accept"
338
               " it?" % (host, hexified_key))
339

    
340
        if cli.AskUser(msg):
341
          our_server_key = server_key
342

    
343
      if our_server_key != server_key:
344
        logging.error("Unable to verify identity of host. Aborting")
345
        transport.close()
346
        transport.join()
347
        # TODO: Run over all hosts, fetch the keys and let them verify from the
348
        #       user beforehand then proceed with actual work later on
349
        raise paramiko.SSHException("Unable to verify identity of host")
350

    
351
    try:
352
      if LoginViaKeys(transport, username, all_keys):
353
        logging.info("Authenticated to %s via public key", host)
354
      else:
355
        logging.warning("Authentication to %s via public key failed, trying"
356
                        " password", host)
357
        if passwd is None:
358
          passwd = getpass.getpass(prompt="%s password:" % username)
359
        transport.auth_password(username=username, password=passwd)
360
        logging.info("Authenticated to %s via password", host)
361
    except paramiko.SSHException, err:
362
      logging.error("Connection or authentication failed to host %s: %s",
363
                    host, err)
364
      transport.close()
365
      # this is needed for compatibility with older Paramiko or Python
366
      # versions
367
      transport.join()
368
      continue
369
    try:
370
      try:
371
        SetupSSH(transport)
372
      except errors.GenericError, err:
373
        logging.error("While doing setup on host %s an error occured: %s",
374
                      host, err)
375
    finally:
376
      transport.close()
377
      # this is needed for compatibility with older Paramiko or Python
378
      # versions
379
      transport.join()
380

    
381

    
382
if __name__ == "__main__":
383
  main()