4 # Copyright (C) 2010, 2012 Google Inc.
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.
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.
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
21 """Tool to setup the SSH configuration on a remote node.
23 This is needed before we can join the node into the cluster.
27 # pylint: disable=C0103
28 # C0103: Invalid name setup-ssh
36 # workaround paramiko warnings
37 # FIXME: use 'with warnings.catch_warnings' once we drop Python 2.4
39 warnings.simplefilter("ignore")
41 warnings.resetwarnings()
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
52 class RemoteCommandError(errors.GenericError):
53 """Exception if remote command was not successful.
58 class JoinCheckError(errors.GenericError):
59 """Exception raised if join check fails.
64 class HostKeyVerificationError(errors.GenericError):
65 """Exception if host key do not match.
70 class AuthError(errors.GenericError):
71 """Exception for authentication errors to hosts.
76 def _CheckJoin(transport):
77 """Checks if a join is safe or dangerous.
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.
83 @param transport: The paramiko transport instance
84 @return: True if the join is safe; False otherwise
87 sftp = transport.open_sftp_client()
88 ss = ssconf.SimpleStore()
89 ss_cluster_name_path = ss.KeyToFilename(constants.SS_CLUSTER_NAME)
92 (constants.NODED_CERT_FILE, utils.ReadFile(constants.NODED_CERT_FILE)),
93 (ss_cluster_name_path, utils.ReadFile(ss_cluster_name_path)),
96 for (filename, local_content) in cluster_files:
98 remote_content = _ReadSftpFile(sftp, filename)
100 # Assume file does not exist. Paramiko's error reporting is lacking.
101 logging.debug("Failed to read %s: %s", filename, err)
104 if remote_content != local_content:
105 logging.error("File %s doesn't match local version", filename)
111 def _RunRemoteCommand(transport, command):
112 """Invokes and wait for the command over SSH.
114 @param transport: The paramiko transport instance
115 @param command: The command to be executed
118 chan = transport.open_session()
119 chan.set_combine_stderr(True)
120 output_handler = chan.makefile("r")
121 chan.exec_command(command)
123 result = chan.recv_exit_status()
124 msg = output_handler.read()
126 out_msg = "'%s' exited with status code %s, output %r" % (command, result,
129 # If result is -1 (no exit status provided) we assume it was not successful
131 raise RemoteCommandError(out_msg)
134 logging.info(out_msg)
137 def _InvokeDaemonUtil(transport, command):
138 """Invokes daemon-util on the remote side.
140 @param transport: The paramiko transport instance
141 @param command: The daemon-util command to be run
144 _RunRemoteCommand(transport, "%s %s" % (constants.DAEMON_UTIL, command))
147 def _ReadSftpFile(sftp, filename):
148 """Reads a file over sftp.
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
155 remote_file = sftp.open(filename, "r")
157 return remote_file.read()
162 def _WriteSftpFile(sftp, name, perm, data):
163 """SFTPs data to a remote file.
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
171 remote_file = sftp.open(name, "w")
173 sftp.chmod(name, perm)
174 remote_file.write(data)
179 def SetupSSH(transport):
180 """Sets the SSH up on the other side.
182 @param transport: The paramiko transport instance
185 priv_key, pub_key, auth_keys = ssh.GetUserFiles(constants.GANETI_RUNAS)
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),
195 sftp = transport.open_sftp_client()
197 filemap = dict((name, (utils.ReadFile(name), perm))
198 for (name, perm) in keyfiles)
200 auth_path = os.path.dirname(auth_keys)
203 sftp.mkdir(auth_path, 0700)
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",
210 for name, (data, perm) in filemap.iteritems():
211 _WriteSftpFile(sftp, name, perm, data)
213 authorized_keys = sftp.open(auth_keys, "a+")
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])
226 authorized_keys.close()
228 _InvokeDaemonUtil(transport, "reload-ssh-keys")
232 """Parses options passed to program.
235 program = os.path.basename(sys.argv[0])
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",
245 help="The private key to (try to) use for"
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"))
254 (options, args) = parser.parse_args()
258 sys.exit(constants.EXIT_FAILURE)
260 return (options, args)
263 def SetupLogging(options):
264 """Sets up the logging.
266 @param options: Parsed options
269 fmt = "%(asctime)s: %(threadName)s "
270 if options.debug or options.verbose:
271 fmt += "%(levelname)s "
274 formatter = logging.Formatter(fmt)
276 file_handler = logging.FileHandler(constants.LOG_SETUP_SSH)
277 stderr_handler = logging.StreamHandler()
278 stderr_handler.setFormatter(formatter)
279 file_handler.setFormatter(formatter)
280 file_handler.setLevel(logging.INFO)
283 stderr_handler.setLevel(logging.DEBUG)
284 elif options.verbose:
285 stderr_handler.setLevel(logging.INFO)
287 stderr_handler.setLevel(logging.WARNING)
289 root_logger = logging.getLogger("")
290 root_logger.setLevel(logging.NOTSET)
291 root_logger.addHandler(stderr_handler)
292 root_logger.addHandler(file_handler)
294 # This is the paramiko logger instance
295 paramiko_logger = logging.getLogger("paramiko")
296 paramiko_logger.addHandler(file_handler)
297 # We don't want to debug Paramiko, so filter anything below warning
298 paramiko_logger.setLevel(logging.WARNING)
301 def LoadPrivateKeys(options):
302 """Load the list of available private keys.
304 It loads the standard ssh key from disk and then tries to connect to
308 @return: a list of C{paramiko.PKey}
311 if options.key_type == "rsa":
312 pkclass = paramiko.RSAKey
313 elif options.key_type == "dsa":
314 pkclass = paramiko.DSSKey
316 logging.critical("Unknown key type %s selected (choose either rsa or dsa)",
321 private_key = pkclass.from_private_key_file(options.private_key)
322 except (paramiko.SSHException, EnvironmentError), err:
323 logging.critical("Can't load private key %s: %s", options.private_key, err)
327 agent = paramiko.Agent()
328 agent_keys = agent.get_keys()
329 except paramiko.SSHException, err:
330 # this will only be seen when the agent is broken/uses invalid
331 # protocol; for non-existing agent, get_keys() will just return an
333 logging.warning("Can't connect to the ssh agent: %s; skipping its use",
337 return [private_key] + list(agent_keys)
340 def _FormatFingerprint(fpr):
341 """Formats a paramiko.PKey.get_fingerprint() human readable.
343 @param fpr: The fingerprint to be formatted
344 @return: A human readable fingerprint
347 return ssh.FormatParamikoFingerprint(paramiko.util.hexify(fpr))
350 def LoginViaKeys(transport, username, keys):
351 """Try to login on the given transport via a list of keys.
353 @param transport: the transport to use
354 @param username: the username to login as
356 @param keys: list of C{paramiko.PKey} to use for authentication
358 @return: True or False depending on whether the login was
362 for private_key in keys:
364 transport.auth_publickey(username, private_key)
365 fpr = _FormatFingerprint(private_key.get_fingerprint())
366 if isinstance(private_key, paramiko.AgentKey):
367 logging.debug("Authentication via the ssh-agent key %s", fpr)
369 logging.debug("Authenticated via public key %s", fpr)
371 except paramiko.SSHException:
378 def LoadKnownHosts():
379 """Load the known hosts.
381 @return: paramiko.util.load_host_keys dict
384 homedir = utils.GetHomeDir(constants.GANETI_RUNAS)
385 known_hosts = os.path.join(homedir, ".ssh", "known_hosts")
388 return paramiko.util.load_host_keys(known_hosts)
389 except EnvironmentError:
390 # We didn't find the path, silently ignore and return an empty dict
394 def _VerifyServerKey(transport, host, host_keys):
395 """Verify the server keys.
397 @param transport: A paramiko.transport instance
398 @param host: Name of the host we verify
399 @param host_keys: Loaded host keys
400 @raises HostkeyVerificationError: When the host identify couldn't be verified
404 server_key = transport.get_remote_server_key()
405 keytype = server_key.get_name()
407 our_server_key = host_keys.get(host, {}).get(keytype, None)
408 if not our_server_key:
409 hexified_key = _FormatFingerprint(server_key.get_fingerprint())
410 msg = ("Unable to verify hostkey of host %s: %s. Do you want to accept"
411 " it?" % (host, hexified_key))
414 our_server_key = server_key
416 if our_server_key != server_key:
417 raise HostKeyVerificationError("Unable to verify host identity")
424 (options, args) = ParseOptions()
426 SetupLogging(options)
428 all_keys = LoadPrivateKeys(options)
431 username = constants.GANETI_RUNAS
432 ssh_port = netutils.GetDaemonPort("ssh")
433 host_keys = LoadKnownHosts()
435 # Below, we need to join() the transport objects, as otherwise the
437 # - the main thread finishes
438 # - the atexit functions run (in the main thread), and cause the
439 # logging file to be closed
440 # - a tiny bit later, the transport thread is finally ending, and
441 # wants to log one more message, which fails as the file is closed
447 logging.info("Configuring %s", host)
449 transport = paramiko.Transport((host, ssh_port))
452 transport.start_client()
454 if options.ssh_key_check:
455 _VerifyServerKey(transport, host, host_keys)
458 if LoginViaKeys(transport, username, all_keys):
459 logging.info("Authenticated to %s via public key", host)
462 logging.warning("Authentication to %s via public key failed,"
463 " trying password", host)
465 passwd = getpass.getpass(prompt="%s password:" % username)
466 transport.auth_password(username=username, password=passwd)
467 logging.info("Authenticated to %s via password", host)
468 except paramiko.SSHException, err:
469 raise AuthError("Auth error TODO" % err)
471 if not _CheckJoin(transport):
472 if not options.force_join:
473 raise JoinCheckError(("Host %s failed join check; Please verify"
474 " that the host was not previously joined"
475 " to another cluster and use --force-join"
476 " to continue") % host)
478 logging.warning("Host %s failed join check, forced to continue",
482 logging.info("%s successfully configured", host)
485 # this is needed for compatibility with older Paramiko or Python
488 except AuthError, err:
489 logging.error("Authentication error: %s", err)
492 except HostKeyVerificationError, err:
493 logging.error("Host key verification error: %s", err)
495 except Exception, err:
496 logging.exception("During setup of %s: %s", host, err)
500 sys.exit(constants.EXIT_SUCCESS)
502 sys.exit(constants.EXIT_FAILURE)
505 if __name__ == "__main__":