Statistics
| Branch: | Tag: | Revision:

root / tools / setup-ssh @ 634a9a35

History | View | Annotate | Download (10.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-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
  default_key = ssh.GetUserFiles(constants.GANETI_RUNAS)[0]
181
  parser.add_option(optparse.Option("-f", dest="private_key",
182
                                    default=default_key,
183
                                    help="The private key to (try to) use for"
184
                                    "authentication "))
185
  parser.add_option(optparse.Option("--key-type", dest="key_type",
186
                                    choices=("rsa", "dsa"), default="dsa",
187
                                    help="The private key type (rsa or dsa)"))
188

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

    
191
  return (options, args)
192

    
193

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

    
197
  @param options: Parsed options
198

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

    
205
  formatter = logging.Formatter(fmt)
206

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

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

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

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

    
231

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

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

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

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

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

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

    
268
  return [private_key] + list(agent_keys)
269

    
270

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

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

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

    
298

    
299
def main():
300
  """Main routine.
301

    
302
  """
303
  (options, args) = ParseOptions()
304

    
305
  SetupLogging(options)
306

    
307
  all_keys = LoadPrivateKeys(options)
308

    
309
  passwd = None
310
  username = constants.GANETI_RUNAS
311
  ssh_port = netutils.GetDaemonPort("ssh")
312

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

    
322
  for host in args:
323
    transport = paramiko.Transport((host, ssh_port))
324
    transport.start_client()
325
    try:
326
      if LoginViaKeys(transport, username, all_keys):
327
        logging.info("Authenticated to %s via public key", host)
328
      else:
329
        logging.warning("Authentication to %s via public key failed, trying"
330
                        " password", host)
331
        if passwd is None:
332
          passwd = getpass.getpass(prompt="%s password:" % username)
333
        transport.auth_password(username=username, password=passwd)
334
        logging.info("Authenticated to %s via password", host)
335
    except paramiko.SSHException, err:
336
      logging.error("Connection or authentication failed to host %s: %s",
337
                    host, err)
338
      transport.close()
339
      # this is needed for compatibility with older Paramiko or Python
340
      # versions
341
      transport.join()
342
      continue
343
    try:
344
      try:
345
        SetupSSH(transport)
346
        SetupNodeDaemon(transport)
347
      except errors.GenericError, err:
348
        logging.error("While doing setup on host %s an error occured: %s",
349
                      host, err)
350
    finally:
351
      transport.close()
352
      # this is needed for compatibility with older Paramiko or Python
353
      # versions
354
      transport.join()
355

    
356

    
357
if __name__ == "__main__":
358
  main()