#!/usr/bin/env python # (c) 2017, Ansible, Inc. # # This file is part of Ansible # # Ansible is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Ansible is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . ######################################################## from __future__ import (absolute_import, division, print_function) __metaclass__ = type __requires__ = ['ansible'] try: import pkg_resources except Exception: pass import fcntl import os import shlex import signal import socket import sys import time import traceback import datetime import errno from ansible import constants as C from ansible.module_utils._text import to_bytes, to_native, to_text from ansible.module_utils.six import PY3 from ansible.module_utils.six.moves import cPickle from ansible.module_utils.connection import send_data, recv_data from ansible.playbook.play_context import PlayContext from ansible.plugins.loader import connection_loader from ansible.utils.path import unfrackpath, makedirs_safe from ansible.errors import AnsibleConnectionFailure from ansible.utils.display import Display def do_fork(): ''' Does the required double fork for a daemon process. Based on http://code.activestate.com/recipes/66012-fork-a-daemon-process-on-unix/ ''' try: pid = os.fork() if pid > 0: return pid # This is done as a 'good practice' for daemons, but we need to keep the cwd # leaving it here as a note that we KNOW its good practice but are not doing it on purpose. # os.chdir("/") os.setsid() os.umask(0) try: pid = os.fork() if pid > 0: sys.exit(0) if C.DEFAULT_LOG_PATH != '': out_file = open(C.DEFAULT_LOG_PATH, 'ab+') err_file = open(C.DEFAULT_LOG_PATH, 'ab+', 0) else: out_file = open('/dev/null', 'ab+') err_file = open('/dev/null', 'ab+', 0) os.dup2(out_file.fileno(), sys.stdout.fileno()) os.dup2(err_file.fileno(), sys.stderr.fileno()) os.close(sys.stdin.fileno()) return pid except OSError as e: sys.exit(1) except OSError as e: sys.exit(1) class Server(): def __init__(self, socket_path, play_context): self.socket_path = socket_path self.play_context = play_context display.display( 'creating new control socket for host %s:%s as user %s' % (play_context.remote_addr, play_context.port, play_context.remote_user), log_only=True ) display.display('control socket path is %s' % socket_path, log_only=True) display.display('current working directory is %s' % os.getcwd(), log_only=True) self._start_time = datetime.datetime.now() display.display("using connection plugin %s" % self.play_context.connection, log_only=True) self.connection = connection_loader.get(play_context.connection, play_context, sys.stdin) self.connection._connect() if not self.connection.connected: raise AnsibleConnectionFailure('unable to connect to remote host %s' % self._play_context.remote_addr) connection_time = datetime.datetime.now() - self._start_time display.display('connection established to %s in %s' % (play_context.remote_addr, connection_time), log_only=True) self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.socket.bind(self.socket_path) self.socket.listen(1) display.display('local socket is set to listening', log_only=True) def run(self): try: while True: signal.signal(signal.SIGALRM, self.connect_timeout) signal.signal(signal.SIGTERM, self.handler) signal.alarm(C.PERSISTENT_CONNECT_TIMEOUT) (s, addr) = self.socket.accept() display.display('incoming request accepted on persistent socket', log_only=True) signal.alarm(0) while True: data = recv_data(s) if not data: break signal.signal(signal.SIGALRM, self.command_timeout) signal.alarm(self.play_context.timeout) op = to_text(data.split(b':')[0]) display.display('socket operation is %s' % op, log_only=True) method = getattr(self, 'do_%s' % op, None) rc = 255 stdout = stderr = '' if not method: stderr = 'Invalid action specified' else: rc, stdout, stderr = method(data) signal.alarm(0) display.display('socket operation completed with rc %s' % rc, log_only=True) send_data(s, to_bytes(rc)) send_data(s, to_bytes(stdout)) send_data(s, to_bytes(stderr)) s.close() except Exception as e: # socket.accept() will raise EINTR if the socket.close() is called if e.errno != errno.EINTR: display.display(traceback.format_exc(), log_only=True) finally: # when done, close the connection properly and cleanup # the socket file so it can be recreated self.shutdown() end_time = datetime.datetime.now() delta = end_time - self._start_time display.display('shutdown local socket, connection was active for %s secs' % delta, log_only=True) def connect_timeout(self, signum, frame): display.display('persistent connection idle timeout triggered, timeout value is %s secs' % C.PERSISTENT_CONNECT_TIMEOUT, log_only=True) self.shutdown() def command_timeout(self, signum, frame): display.display('command timeout triggered, timeout value is %s secs' % self.play_context.timeout, log_only=True) self.shutdown() def handler(self, signum, frame): display.display('signal handler called with signal %s' % signum, log_only=True) self.shutdown() def shutdown(self): display.display('shutdown persistent connection requested', log_only=True) if not os.path.exists(self.socket_path): display.display('persistent connection is not active', log_only=True) return try: if self.socket: display.display('closing local listener', log_only=True) self.socket.close() if self.connection: display.display('closing the connection', log_only=True) self.connection.close() except: pass finally: if os.path.exists(self.socket_path): display.display('removing the local control socket', log_only=True) os.remove(self.socket_path) display.display('shutdown complete', log_only=True) def do_EXEC(self, data): cmd = data.split(b'EXEC: ')[1] display.display('Command executed: %s' % cmd, log_only=True) return self.connection.exec_command(cmd) def do_PUT(self, data): (op, src, dst) = shlex.split(to_native(data)) return self.connection.fetch_file(src, dst) def do_FETCH(self, data): (op, src, dst) = shlex.split(to_native(data)) return self.connection.put_file(src, dst) def do_CONTEXT(self, data): pc_data = data.split(b'CONTEXT: ', 1)[1] if PY3: pc_data = cPickle.loads(pc_data, encoding='bytes') else: pc_data = cPickle.loads(pc_data) pc = PlayContext() pc.deserialize(pc_data) try: self.connection.update_play_context(pc) except AttributeError: pass return (0, 'ok', '') def do_RUN(self, data): timeout = self.play_context.timeout while bool(timeout): if os.path.exists(self.socket_path): break time.sleep(1) timeout -= 1 socket_bytes = to_bytes(self.socket_path, errors='surrogate_or_strict') return 0, b'\n#SOCKET_PATH#: %s\n' % socket_bytes, '' def communicate(sock, data): send_data(sock, data) rc = int(recv_data(sock), 10) stdout = recv_data(sock) stderr = recv_data(sock) return (rc, stdout, stderr) def main(): # Need stdin as a byte stream if PY3: stdin = sys.stdin.buffer else: stdin = sys.stdin try: # read the play context data via stdin, which means depickling it # FIXME: as noted above, we will probably need to deserialize the # connection loader here as well at some point, otherwise this # won't find role- or playbook-based connection plugins cur_line = stdin.readline() init_data = b'' while cur_line.strip() != b'#END_INIT#': if cur_line == b'': raise Exception("EOF found before init data was complete") init_data += cur_line cur_line = stdin.readline() if PY3: pc_data = cPickle.loads(init_data, encoding='bytes') else: pc_data = cPickle.loads(init_data) pc = PlayContext() pc.deserialize(pc_data) except Exception as e: # FIXME: better error message/handling/logging sys.stderr.write(traceback.format_exc()) sys.exit("FAIL: %s" % e) ssh = connection_loader.get('ssh', class_only=True) cp = ssh._create_control_path(pc.remote_addr, pc.port, pc.remote_user, pc.connection) # create the persistent connection dir if need be and create the paths # which we will be using later tmp_path = unfrackpath(C.PERSISTENT_CONTROL_PATH_DIR) makedirs_safe(tmp_path) lock_path = unfrackpath("%s/.ansible_pc_lock" % tmp_path) socket_path = unfrackpath(cp % dict(directory=tmp_path)) # if the socket file doesn't exist, spin up the daemon process lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) fcntl.lockf(lock_fd, fcntl.LOCK_EX) if not os.path.exists(socket_path): pid = do_fork() if pid == 0: rc = 0 try: server = Server(socket_path, pc) except AnsibleConnectionFailure as exc: display.display('connecting to host %s returned an error' % pc.remote_addr, log_only=True) display.display(str(exc), log_only=True) rc = 1 except Exception as exc: display.display('failed to create control socket for host %s' % pc.remote_addr, log_only=True) display.display(traceback.format_exc(), log_only=True) rc = 1 fcntl.lockf(lock_fd, fcntl.LOCK_UN) os.close(lock_fd) if rc == 0: server.run() sys.exit(rc) else: display.display('re-using existing socket for %s@%s:%s' % (pc.remote_user, pc.remote_addr, pc.port), log_only=True) fcntl.lockf(lock_fd, fcntl.LOCK_UN) os.close(lock_fd) timeout = pc.timeout while bool(timeout): if os.path.exists(socket_path): display.vvvv('connected to local socket in %s' % (pc.timeout - timeout), pc.remote_addr) break time.sleep(1) timeout -= 1 else: raise AnsibleConnectionFailure('timeout waiting for local socket', pc.remote_addr) # now connect to the daemon process # FIXME: if the socket file existed but the daemonized process was killed, # the connection will timeout here. Need to make this more resilient. while True: data = stdin.readline() if data == b'': break if data.strip() == b'': continue sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) connect_retry_timeout = C.PERSISTENT_CONNECT_RETRY_TIMEOUT while bool(connect_retry_timeout): try: sock.connect(socket_path) break except socket.error: time.sleep(1) connect_retry_timeout -= 1 else: display.display('connect retry timeout expired, unable to connect to control socket', pc.remote_addr, pc.remote_user, log_only=True) display.display('persistent_connect_retry_timeout is %s secs' % (C.PERSISTENT_CONNECT_RETRY_TIMEOUT), pc.remote_addr, pc.remote_user, log_only=True) sys.stderr.write('failed to connect to control socket') sys.exit(255) # send the play_context back into the connection so the connection # can handle any privilege escalation activities pc_data = b'CONTEXT: %s' % init_data communicate(sock, pc_data) rc, stdout, stderr = communicate(sock, data.strip()) sys.stdout.write(to_native(stdout)) sys.stderr.write(to_native(stderr)) sock.close() break sys.exit(rc) if __name__ == '__main__': display = Display() main()