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 class HostKeyVerificationError(errors.GenericError):
59 """Exception if host key do not match.
64 class AuthError(errors.GenericError):
65 """Exception for authentication errors to hosts.
70 def _CheckJoin(transport):
71 """Checks if a join is safe or dangerous.
73 Note: This function relies on the fact, that all
74 hosts have the same configuration at compile time of
75 Ganeti. So that the constants do not mismatch.
77 @param transport: The paramiko transport instance
78 @return: True if the join is safe; False otherwise
81 sftp = transport.open_sftp_client()
82 ss = ssconf.SimpleStore()
83 ss_cluster_name_path = ss.KeyToFilename(constants.SS_CLUSTER_NAME)
86 (constants.NODED_CERT_FILE, utils.ReadFile(constants.NODED_CERT_FILE)),
87 (ss_cluster_name_path, utils.ReadFile(ss_cluster_name_path)),
90 for (filename, local_content) in cluster_files:
92 remote_content = _ReadSftpFile(sftp, filename)
94 # Assume file does not exist. Paramiko's error reporting is lacking.
95 logging.debug("Failed to read %s: %s", filename, err)
98 if remote_content != local_content:
99 logging.error("File %s doesn't match local version", filename)
105 def _RunRemoteCommand(transport, command):
106 """Invokes and wait for the command over SSH.
108 @param transport: The paramiko transport instance
109 @param command: The command to be executed
112 chan = transport.open_session()
113 chan.set_combine_stderr(True)
114 output_handler = chan.makefile("r")
115 chan.exec_command(command)
117 result = chan.recv_exit_status()
118 msg = output_handler.read()
120 out_msg = "'%s' exited with status code %s, output %r" % (command, result,
123 # If result is -1 (no exit status provided) we assume it was not successful
125 raise RemoteCommandError(out_msg)
128 logging.info(out_msg)
131 def _InvokeDaemonUtil(transport, command):
132 """Invokes daemon-util on the remote side.
134 @param transport: The paramiko transport instance
135 @param command: The daemon-util command to be run
138 _RunRemoteCommand(transport, "%s %s" % (constants.DAEMON_UTIL, command))
141 def _ReadSftpFile(sftp, filename):
142 """Reads a file over sftp.
144 @param sftp: An open paramiko SFTP client
145 @param filename: The filename of the file to read
146 @return: The content of the file
149 remote_file = sftp.open(filename, "r")
151 return remote_file.read()
156 def _WriteSftpFile(sftp, name, perm, data):
157 """SFTPs data to a remote file.
159 @param sftp: A open paramiko SFTP client
160 @param name: The remote file name
161 @param perm: The remote file permission
162 @param data: The data to write
165 remote_file = sftp.open(name, "w")
167 sftp.chmod(name, perm)
168 remote_file.write(data)
173 def SetupSSH(transport):
174 """Sets the SSH up on the other side.
176 @param transport: The paramiko transport instance
179 priv_key, pub_key, auth_keys = ssh.GetUserFiles(constants.GANETI_RUNAS)
181 (constants.SSH_HOST_DSA_PRIV, 0600),
182 (constants.SSH_HOST_DSA_PUB, 0644),
183 (constants.SSH_HOST_RSA_PRIV, 0600),
184 (constants.SSH_HOST_RSA_PUB, 0644),
189 sftp = transport.open_sftp_client()
191 filemap = dict((name, (utils.ReadFile(name), perm))
192 for (name, perm) in keyfiles)
194 auth_path = os.path.dirname(auth_keys)
197 sftp.mkdir(auth_path, 0700)
199 # Sadly paramiko doesn't provide errno or similiar
200 # so we can just assume that the path already exists
201 logging.info("Assuming directory %s on remote node exists: %s",
204 for name, (data, perm) in filemap.iteritems():
205 _WriteSftpFile(sftp, name, perm, data)
207 authorized_keys = sftp.open(auth_keys, "a+")
209 # Due to the way SFTPFile and BufferedFile are implemented,
210 # opening in a+ mode and then issuing a read(), readline() or
211 # iterating over the file (which uses read() internally) will see
212 # an empty file, since the paramiko internal file position and the
213 # OS-level file-position are desynchronized; therefore, we issue
214 # an explicit seek to resynchronize these; writes should (note
215 # should) still go to the right place
216 authorized_keys.seek(0, 0)
217 # We don't have to close, as the close happened already in AddAuthorizedKey
218 utils.AddAuthorizedKey(authorized_keys, filemap[pub_key][0])
220 authorized_keys.close()
222 _InvokeDaemonUtil(transport, "reload-ssh-keys")
226 """Parses options passed to program.
229 program = os.path.basename(sys.argv[0])
231 parser = optparse.OptionParser(usage=("%prog [--debug|--verbose] [--force]"
232 " <node> <node...>"), prog=program)
233 parser.add_option(cli.DEBUG_OPT)
234 parser.add_option(cli.VERBOSE_OPT)
235 parser.add_option(cli.NOSSH_KEYCHECK_OPT)
236 default_key = ssh.GetUserFiles(constants.GANETI_RUNAS)[0]
237 parser.add_option(optparse.Option("-f", dest="private_key",
239 help="The private key to (try to) use for"
241 parser.add_option(optparse.Option("--key-type", dest="key_type",
242 choices=("rsa", "dsa"), default="dsa",
243 help="The private key type (rsa or dsa)"))
244 parser.add_option(optparse.Option("-j", "--force-join", dest="force_join",
245 action="store_true", default=False,
246 help="Force the join of the host"))
248 (options, args) = parser.parse_args()
250 return (options, args)
253 def SetupLogging(options):
254 """Sets up the logging.
256 @param options: Parsed options
259 fmt = "%(asctime)s: %(threadName)s "
260 if options.debug or options.verbose:
261 fmt += "%(levelname)s "
264 formatter = logging.Formatter(fmt)
266 file_handler = logging.FileHandler(constants.LOG_SETUP_SSH)
267 stderr_handler = logging.StreamHandler()
268 stderr_handler.setFormatter(formatter)
269 file_handler.setFormatter(formatter)
270 file_handler.setLevel(logging.INFO)
273 stderr_handler.setLevel(logging.DEBUG)
274 elif options.verbose:
275 stderr_handler.setLevel(logging.INFO)
277 stderr_handler.setLevel(logging.WARNING)
279 root_logger = logging.getLogger("")
280 root_logger.setLevel(logging.NOTSET)
281 root_logger.addHandler(stderr_handler)
282 root_logger.addHandler(file_handler)
284 # This is the paramiko logger instance
285 paramiko_logger = logging.getLogger("paramiko")
286 paramiko_logger.addHandler(file_handler)
287 # We don't want to debug Paramiko, so filter anything below warning
288 paramiko_logger.setLevel(logging.WARNING)
291 def LoadPrivateKeys(options):
292 """Load the list of available private keys.
294 It loads the standard ssh key from disk and then tries to connect to
298 @return: a list of C{paramiko.PKey}
301 if options.key_type == "rsa":
302 pkclass = paramiko.RSAKey
303 elif options.key_type == "dsa":
304 pkclass = paramiko.DSSKey
306 logging.critical("Unknown key type %s selected (choose either rsa or dsa)",
311 private_key = pkclass.from_private_key_file(options.private_key)
312 except (paramiko.SSHException, EnvironmentError), err:
313 logging.critical("Can't load private key %s: %s", options.private_key, err)
317 agent = paramiko.Agent()
318 agent_keys = agent.get_keys()
319 except paramiko.SSHException, err:
320 # this will only be seen when the agent is broken/uses invalid
321 # protocol; for non-existing agent, get_keys() will just return an
323 logging.warning("Can't connect to the ssh agent: %s; skipping its use",
327 return [private_key] + list(agent_keys)
330 def _FormatFingerprint(fpr):
331 """Formats a paramiko.PKey.get_fingerprint() human readable.
333 @param fpr: The fingerprint to be formatted
334 @return: A human readable fingerprint
337 return ssh.FormatParamikoFingerprint(paramiko.util.hexify(fpr))
340 def LoginViaKeys(transport, username, keys):
341 """Try to login on the given transport via a list of keys.
343 @param transport: the transport to use
344 @param username: the username to login as
346 @param keys: list of C{paramiko.PKey} to use for authentication
348 @return: True or False depending on whether the login was
352 for private_key in keys:
354 transport.auth_publickey(username, private_key)
355 fpr = _FormatFingerprint(private_key.get_fingerprint())
356 if isinstance(private_key, paramiko.AgentKey):
357 logging.debug("Authentication via the ssh-agent key %s", fpr)
359 logging.debug("Authenticated via public key %s", fpr)
361 except paramiko.SSHException:
368 def LoadKnownHosts():
369 """Load the known hosts.
371 @return: paramiko.util.load_host_keys dict
374 homedir = utils.GetHomeDir(constants.GANETI_RUNAS)
375 known_hosts = os.path.join(homedir, ".ssh", "known_hosts")
378 return paramiko.util.load_host_keys(known_hosts)
379 except EnvironmentError:
380 # We didn't find the path, silently ignore and return an empty dict
384 def _VerifyServerKey(transport, host, host_keys):
385 """Verify the server keys.
387 @param transport: A paramiko.transport instance
388 @param host: Name of the host we verify
389 @param host_keys: Loaded host keys
390 @raises HostkeyVerificationError: When the host identify couldn't be verified
394 server_key = transport.get_remote_server_key()
395 keytype = server_key.get_name()
397 our_server_key = host_keys.get(host, {}).get(keytype, None)
398 if not our_server_key:
399 hexified_key = _FormatFingerprint(server_key.get_fingerprint())
400 msg = ("Unable to verify hostkey of host %s: %s. Do you want to accept"
401 " it?" % (host, hexified_key))
404 our_server_key = server_key
406 if our_server_key != server_key:
407 raise HostKeyVerificationError("Unable to verify host identity")
414 (options, args) = ParseOptions()
416 SetupLogging(options)
418 all_keys = LoadPrivateKeys(options)
421 username = constants.GANETI_RUNAS
422 ssh_port = netutils.GetDaemonPort("ssh")
423 host_keys = LoadKnownHosts()
425 # Below, we need to join() the transport objects, as otherwise the
427 # - the main thread finishes
428 # - the atexit functions run (in the main thread), and cause the
429 # logging file to be closed
430 # - a tiny bit later, the transport thread is finally ending, and
431 # wants to log one more message, which fails as the file is closed
437 logging.info("Configuring %s", host)
439 transport = paramiko.Transport((host, ssh_port))
442 transport.start_client()
444 if options.ssh_key_check:
445 _VerifyServerKey(transport, host, host_keys)
448 if LoginViaKeys(transport, username, all_keys):
449 logging.info("Authenticated to %s via public key", host)
452 logging.warning("Authentication to %s via public key failed,"
453 " trying password", host)
455 passwd = getpass.getpass(prompt="%s password:" % username)
456 transport.auth_password(username=username, password=passwd)
457 logging.info("Authenticated to %s via password", host)
458 except paramiko.SSHException, err:
459 raise AuthError("Auth error TODO" % err)
461 if not _CheckJoin(transport):
462 if not options.force_join:
463 raise JoinCheckError(("Host %s failed join check; Please verify"
464 " that the host was not previously joined"
465 " to another cluster and use --force-join"
466 " to continue") % host)
468 logging.warning("Host %s failed join check, forced to continue",
472 logging.info("%s successfully configured", host)
475 # this is needed for compatibility with older Paramiko or Python
478 except AuthError, err:
479 logging.error("Authentication error: %s", err)
482 except HostKeyVerificationError, err:
483 logging.error("Host key verification error: %s", err)
485 except Exception, err:
486 logging.exception("During setup of %s: %s", host, err)
490 sys.exit(constants.EXIT_SUCCESS)
492 sys.exit(constants.EXIT_FAILURE)
495 if __name__ == "__main__":