4 # Copyright (C) 2010 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-msg=C0103
28 # C0103: Invalid name setup-ssh
37 from ganeti import cli
38 from ganeti import constants
39 from ganeti import errors
40 from ganeti import netutils
41 from ganeti import ssconf
42 from ganeti import ssh
43 from ganeti import utils
46 class RemoteCommandError(errors.GenericError):
47 """Exception if remote command was not successful.
52 class JoinCheckError(errors.GenericError):
53 """Exception raised if join check fails.
58 def _CheckJoin(transport):
59 """Checks if a join is safe or dangerous.
61 Note: This function relies on the fact, that all
62 hosts have the same configuration at compile time of
63 Ganeti. So that the constants do not mismatch.
65 @param transport: The paramiko transport instance
66 @return: True if the join is safe; False otherwise
69 sftp = transport.open_sftp_client()
70 ss = ssconf.SimpleStore()
71 ss_cluster_name_path = ss.KeyToFilename(constants.SS_CLUSTER_NAME)
74 ss_cluster_name_path: utils.ReadFile(ss_cluster_name_path),
75 constants.NODED_CERT_FILE: utils.ReadFile(constants.NODED_CERT_FILE),
79 remote_noded_file = _ReadSftpFile(sftp, constants.NODED_CERT_FILE)
81 # We can just assume that the file doesn't exist as such error reporting
82 # is lacking from paramiko
84 # We don't have the noded certificate. As without the cert, the
85 # noded is not running, we are on the safe bet to say that this
86 # node doesn't belong to a cluster
90 remote_cluster_name = _ReadSftpFile(sftp, ss_cluster_name_path)
92 # This can indicate that a previous join was not successful
93 # So if the noded cert was found and matches we are fine
94 return cluster_files[constants.NODED_CERT_FILE] == remote_noded_file
96 return (cluster_files[constants.NODED_CERT_FILE] == remote_noded_file and
97 cluster_files[ss_cluster_name_path] == remote_cluster_name)
100 def _RunRemoteCommand(transport, command):
101 """Invokes and wait for the command over SSH.
103 @param transport: The paramiko transport instance
104 @param command: The command to be executed
107 chan = transport.open_session()
108 chan.set_combine_stderr(True)
109 output_handler = chan.makefile("r")
110 chan.exec_command(command)
112 result = chan.recv_exit_status()
113 msg = output_handler.read()
115 out_msg = "'%s' exited with status code %s, output %r" % (command, result,
118 # If result is -1 (no exit status provided) we assume it was not successful
120 raise RemoteCommandError(out_msg)
123 logging.info(out_msg)
126 def _InvokeDaemonUtil(transport, command):
127 """Invokes daemon-util on the remote side.
129 @param transport: The paramiko transport instance
130 @param command: The daemon-util command to be run
133 _RunRemoteCommand(transport, "%s %s" % (constants.DAEMON_UTIL, command))
136 def _ReadSftpFile(sftp, filename):
137 """Reads a file over sftp.
139 @param sftp: An open paramiko SFTP client
140 @param filename: The filename of the file to read
141 @return: The content of the file
144 remote_file = sftp.open(filename, "r")
146 return remote_file.read()
151 def _WriteSftpFile(sftp, name, perm, data):
152 """SFTPs data to a remote file.
154 @param sftp: A open paramiko SFTP client
155 @param name: The remote file name
156 @param perm: The remote file permission
157 @param data: The data to write
160 remote_file = sftp.open(name, "w")
162 sftp.chmod(name, perm)
163 remote_file.write(data)
168 def SetupSSH(transport):
169 """Sets the SSH up on the other side.
171 @param transport: The paramiko transport instance
174 priv_key, pub_key, auth_keys = ssh.GetUserFiles(constants.GANETI_RUNAS)
176 (constants.SSH_HOST_DSA_PRIV, 0600),
177 (constants.SSH_HOST_DSA_PUB, 0644),
178 (constants.SSH_HOST_RSA_PRIV, 0600),
179 (constants.SSH_HOST_RSA_PUB, 0644),
184 sftp = transport.open_sftp_client()
186 filemap = dict((name, (utils.ReadFile(name), perm))
187 for (name, perm) in keyfiles)
189 auth_path = os.path.dirname(auth_keys)
192 sftp.mkdir(auth_path, 0700)
194 # Sadly paramiko doesn't provide errno or similiar
195 # so we can just assume that the path already exists
196 logging.info("Path %s seems already to exist on remote node. Ignoring.",
199 for name, (data, perm) in filemap.iteritems():
200 _WriteSftpFile(sftp, name, perm, data)
202 authorized_keys = sftp.open(auth_keys, "a+")
204 # Due to the way SFTPFile and BufferedFile are implemented,
205 # opening in a+ mode and then issuing a read(), readline() or
206 # iterating over the file (which uses read() internally) will see
207 # an empty file, since the paramiko internal file position and the
208 # OS-level file-position are desynchronized; therefore, we issue
209 # an explicit seek to resynchronize these; writes should (note
210 # should) still go to the right place
211 authorized_keys.seek(0, 0)
212 # We don't have to close, as the close happened already in AddAuthorizedKey
213 utils.AddAuthorizedKey(authorized_keys, filemap[pub_key][0])
215 authorized_keys.close()
217 _InvokeDaemonUtil(transport, "reload-ssh-keys")
221 """Parses options passed to program.
224 program = os.path.basename(sys.argv[0])
226 parser = optparse.OptionParser(usage=("%prog [--debug|--verbose] [--force]"
227 " <node> <node...>"), prog=program)
228 parser.add_option(cli.DEBUG_OPT)
229 parser.add_option(cli.VERBOSE_OPT)
230 parser.add_option(cli.NOSSH_KEYCHECK_OPT)
231 default_key = ssh.GetUserFiles(constants.GANETI_RUNAS)[0]
232 parser.add_option(optparse.Option("-f", dest="private_key",
234 help="The private key to (try to) use for"
236 parser.add_option(optparse.Option("--key-type", dest="key_type",
237 choices=("rsa", "dsa"), default="dsa",
238 help="The private key type (rsa or dsa)"))
239 parser.add_option(optparse.Option("-j", "--force-join", dest="force_join",
240 action="store_true", default=False,
241 help="Force the join of the host"))
243 (options, args) = parser.parse_args()
245 return (options, args)
248 def SetupLogging(options):
249 """Sets up the logging.
251 @param options: Parsed options
254 fmt = "%(asctime)s: %(threadName)s "
255 if options.debug or options.verbose:
256 fmt += "%(levelname)s "
259 formatter = logging.Formatter(fmt)
261 file_handler = logging.FileHandler(constants.LOG_SETUP_SSH)
262 stderr_handler = logging.StreamHandler()
263 stderr_handler.setFormatter(formatter)
264 file_handler.setFormatter(formatter)
265 file_handler.setLevel(logging.INFO)
268 stderr_handler.setLevel(logging.DEBUG)
269 elif options.verbose:
270 stderr_handler.setLevel(logging.INFO)
272 stderr_handler.setLevel(logging.WARNING)
274 root_logger = logging.getLogger("")
275 root_logger.setLevel(logging.NOTSET)
276 root_logger.addHandler(stderr_handler)
277 root_logger.addHandler(file_handler)
279 # This is the paramiko logger instance
280 paramiko_logger = logging.getLogger("paramiko")
281 paramiko_logger.addHandler(file_handler)
282 # We don't want to debug Paramiko, so filter anything below warning
283 paramiko_logger.setLevel(logging.WARNING)
286 def LoadPrivateKeys(options):
287 """Load the list of available private keys.
289 It loads the standard ssh key from disk and then tries to connect to
293 @return: a list of C{paramiko.PKey}
296 if options.key_type == "rsa":
297 pkclass = paramiko.RSAKey
298 elif options.key_type == "dsa":
299 pkclass = paramiko.DSSKey
301 logging.critical("Unknown key type %s selected (choose either rsa or dsa)",
306 private_key = pkclass.from_private_key_file(options.private_key)
307 except (paramiko.SSHException, EnvironmentError), err:
308 logging.critical("Can't load private key %s: %s", options.private_key, err)
312 agent = paramiko.Agent()
313 agent_keys = agent.get_keys()
314 except paramiko.SSHException, err:
315 # this will only be seen when the agent is broken/uses invalid
316 # protocol; for non-existing agent, get_keys() will just return an
318 logging.warning("Can't connect to the ssh agent: %s; skipping its use",
322 return [private_key] + list(agent_keys)
325 def LoginViaKeys(transport, username, keys):
326 """Try to login on the given transport via a list of keys.
328 @param transport: the transport to use
329 @param username: the username to login as
331 @param keys: list of C{paramiko.PKey} to use for authentication
333 @return: True or False depending on whether the login was
337 for private_key in keys:
339 transport.auth_publickey(username, private_key)
340 fpr = ":".join("%02x" % ord(i) for i in private_key.get_fingerprint())
341 if isinstance(private_key, paramiko.AgentKey):
342 logging.debug("Authentication via the ssh-agent key %s", fpr)
344 logging.debug("Authenticated via public key %s", fpr)
346 except paramiko.SSHException:
353 def LoadKnownHosts():
354 """Load the known hosts.
356 @return: paramiko.util.load_host_keys dict
359 homedir = utils.GetHomeDir(constants.GANETI_RUNAS)
360 known_hosts = os.path.join(homedir, ".ssh", "known_hosts")
363 return paramiko.util.load_host_keys(known_hosts)
364 except EnvironmentError:
365 # We didn't found the path, silently ignore and return an empty dict
373 (options, args) = ParseOptions()
375 SetupLogging(options)
379 all_keys = LoadPrivateKeys(options)
382 username = constants.GANETI_RUNAS
383 ssh_port = netutils.GetDaemonPort("ssh")
384 host_keys = LoadKnownHosts()
386 # Below, we need to join() the transport objects, as otherwise the
388 # - the main thread finishes
389 # - the atexit functions run (in the main thread), and cause the
390 # logging file to be closed
391 # - a tiny bit later, the transport thread is finally ending, and
392 # wants to log one more message, which fails as the file is closed
396 transport = paramiko.Transport((host, ssh_port))
397 transport.start_client()
398 server_key = transport.get_remote_server_key()
399 keytype = server_key.get_name()
401 our_server_key = host_keys.get(host, {}).get(keytype, None)
402 if options.ssh_key_check:
403 if not our_server_key:
404 hexified_key = ssh.FormatParamikoFingerprint(
405 paramiko.util.hexify(server_key.get_fingerprint()))
406 msg = ("Unable to verify hostkey of host %s: %s. Do you want to accept"
407 " it?" % (host, hexified_key))
410 our_server_key = server_key
412 if our_server_key != server_key:
413 logging.error("Unable to verify identity of host. Aborting")
416 # TODO: Run over all hosts, fetch the keys and let them verify from the
417 # user beforehand then proceed with actual work later on
418 raise paramiko.SSHException("Unable to verify identity of host")
421 if LoginViaKeys(transport, username, all_keys):
422 logging.info("Authenticated to %s via public key", host)
424 logging.warning("Authentication to %s via public key failed, trying"
427 passwd = getpass.getpass(prompt="%s password:" % username)
428 transport.auth_password(username=username, password=passwd)
429 logging.info("Authenticated to %s via password", host)
430 except paramiko.SSHException, err:
431 logging.error("Connection or authentication failed to host %s: %s",
434 # this is needed for compatibility with older Paramiko or Python
440 if not _CheckJoin(transport):
441 if options.force_join:
442 logging.warning("Host %s failed join check, forced to continue",
445 raise JoinCheckError(("Host %s failed join check; Please verify"
446 " that the host was not previously joined"
447 " to another cluster and use --force-join"
448 " to continue") % host)
450 except errors.GenericError, err:
451 logging.error("While doing setup on host %s an error occurred: %s",
456 # this is needed for compatibility with older Paramiko or Python
464 if __name__ == "__main__":