2016-10-26 19:38:08 +02:00
|
|
|
#!/usr/bin/env python
|
|
|
|
|
2017-06-06 10:26:25 +02:00
|
|
|
# (c) 2017, Ansible, Inc. <support@ansible.com>
|
2016-10-26 19:38:08 +02:00
|
|
|
#
|
|
|
|
# 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 <http://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
########################################################
|
|
|
|
from __future__ import (absolute_import, division, print_function)
|
|
|
|
|
2017-01-13 01:20:25 +01:00
|
|
|
__metaclass__ = type
|
2016-10-26 19:38:08 +02:00
|
|
|
__requires__ = ['ansible']
|
2017-01-13 01:20:25 +01:00
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
try:
|
|
|
|
import pkg_resources
|
|
|
|
except Exception:
|
|
|
|
pass
|
|
|
|
|
|
|
|
import fcntl
|
|
|
|
import os
|
|
|
|
import shlex
|
|
|
|
import signal
|
|
|
|
import socket
|
|
|
|
import sys
|
|
|
|
import time
|
|
|
|
import traceback
|
2016-11-30 22:26:49 +01:00
|
|
|
import datetime
|
2017-06-06 10:26:25 +02:00
|
|
|
import errno
|
2016-10-26 19:38:08 +02:00
|
|
|
|
|
|
|
from ansible import constants as C
|
|
|
|
from ansible.module_utils._text import to_bytes, to_native
|
2017-05-12 18:13:51 +02:00
|
|
|
from ansible.module_utils.six import PY3
|
|
|
|
from ansible.module_utils.six.moves import cPickle
|
2017-06-06 10:26:25 +02:00
|
|
|
from ansible.module_utils.connection import send_data, recv_data
|
2016-10-26 19:38:08 +02:00
|
|
|
from ansible.playbook.play_context import PlayContext
|
|
|
|
from ansible.plugins import connection_loader
|
|
|
|
from ansible.utils.path import unfrackpath, makedirs_safe
|
2017-01-25 05:14:30 +01:00
|
|
|
from ansible.errors import AnsibleConnectionFailure
|
2017-03-21 04:08:02 +01:00
|
|
|
from ansible.utils.display import Display
|
2016-10-26 19:38:08 +02:00
|
|
|
|
2016-11-30 22:26:49 +01:00
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
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
|
|
|
|
|
2017-03-25 05:19:48 +01:00
|
|
|
#os.chdir("/")
|
2016-10-26 19:38:08 +02:00
|
|
|
os.setsid()
|
|
|
|
os.umask(0)
|
|
|
|
|
|
|
|
try:
|
|
|
|
pid = os.fork()
|
|
|
|
if pid > 0:
|
|
|
|
sys.exit(0)
|
|
|
|
|
2017-02-16 19:59:47 +01:00
|
|
|
if C.DEFAULT_LOG_PATH != '':
|
2017-05-12 18:13:51 +02:00
|
|
|
out_file = open(C.DEFAULT_LOG_PATH, 'ab+')
|
|
|
|
err_file = open(C.DEFAULT_LOG_PATH, 'ab+', 0)
|
2017-02-16 19:59:47 +01:00
|
|
|
else:
|
2017-05-12 18:13:51 +02:00
|
|
|
out_file = open('/dev/null', 'ab+')
|
|
|
|
err_file = open('/dev/null', 'ab+', 0)
|
2017-02-16 19:59:47 +01:00
|
|
|
|
|
|
|
os.dup2(out_file.fileno(), sys.stdout.fileno())
|
|
|
|
os.dup2(err_file.fileno(), sys.stderr.fileno())
|
2016-10-26 19:38:08 +02:00
|
|
|
os.close(sys.stdin.fileno())
|
|
|
|
|
|
|
|
return pid
|
|
|
|
except OSError as e:
|
|
|
|
sys.exit(1)
|
|
|
|
except OSError as e:
|
|
|
|
sys.exit(1)
|
|
|
|
|
2017-03-21 03:26:18 +01:00
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
class Server():
|
2016-11-30 22:26:49 +01:00
|
|
|
|
2017-06-06 10:26:25 +02:00
|
|
|
def __init__(self, socket_path, play_context):
|
|
|
|
self.socket_path = socket_path
|
2016-10-26 19:38:08 +02:00
|
|
|
self.play_context = play_context
|
2017-03-21 03:26:18 +01:00
|
|
|
|
2017-03-30 14:07:02 +02:00
|
|
|
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
|
|
|
|
)
|
|
|
|
|
2017-06-06 10:26:25 +02:00
|
|
|
display.display('control socket path is %s' % socket_path, log_only=True)
|
2017-03-25 05:19:48 +01:00
|
|
|
display.display('current working directory is %s' % os.getcwd(), log_only=True)
|
2016-10-26 19:38:08 +02:00
|
|
|
|
2016-11-30 22:26:49 +01:00
|
|
|
self._start_time = datetime.datetime.now()
|
|
|
|
|
2017-03-21 04:08:02 +01:00
|
|
|
display.display("using connection plugin %s" % self.play_context.connection, log_only=True)
|
2017-02-09 20:05:54 +01:00
|
|
|
|
2017-06-06 10:26:25 +02:00
|
|
|
self.connection = connection_loader.get(play_context.connection, play_context, sys.stdin)
|
|
|
|
self.connection._connect()
|
|
|
|
|
|
|
|
if not self.connection.connected:
|
2017-03-30 14:07:02 +02:00
|
|
|
raise AnsibleConnectionFailure('unable to connect to remote host %s' % self._play_context.remote_addr)
|
2016-11-30 22:26:49 +01:00
|
|
|
|
2017-03-14 15:31:02 +01:00
|
|
|
connection_time = datetime.datetime.now() - self._start_time
|
2017-03-30 14:07:02 +02:00
|
|
|
display.display('connection established to %s in %s' % (play_context.remote_addr, connection_time), log_only=True)
|
2017-03-14 15:31:02 +01:00
|
|
|
|
2017-01-25 05:14:30 +01:00
|
|
|
self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
2017-06-06 10:26:25 +02:00
|
|
|
self.socket.bind(self.socket_path)
|
2017-01-25 05:14:30 +01:00
|
|
|
self.socket.listen(1)
|
2017-06-06 10:26:25 +02:00
|
|
|
display.display('local socket is set to listening', log_only=True)
|
2016-10-26 19:38:08 +02:00
|
|
|
|
|
|
|
def run(self):
|
|
|
|
try:
|
|
|
|
while True:
|
2017-06-06 10:26:25 +02:00
|
|
|
signal.signal(signal.SIGALRM, self.connect_timeout)
|
|
|
|
signal.signal(signal.SIGTERM, self.handler)
|
2016-10-26 19:38:08 +02:00
|
|
|
signal.alarm(C.PERSISTENT_CONNECT_TIMEOUT)
|
2017-06-06 10:26:25 +02:00
|
|
|
|
|
|
|
(s, addr) = self.socket.accept()
|
|
|
|
display.display('incoming request accepted on persistent socket', log_only=True)
|
|
|
|
signal.alarm(0)
|
2016-10-26 19:38:08 +02:00
|
|
|
|
|
|
|
while True:
|
|
|
|
data = recv_data(s)
|
|
|
|
if not data:
|
|
|
|
break
|
|
|
|
|
2017-06-06 10:26:25 +02:00
|
|
|
signal.signal(signal.SIGALRM, self.command_timeout)
|
2017-05-13 14:58:14 +02:00
|
|
|
signal.alarm(self.play_context.timeout)
|
2016-11-30 22:26:49 +01:00
|
|
|
|
2017-06-06 10:26:25 +02:00
|
|
|
op = data.split(':')[0]
|
|
|
|
display.display('socket operation is %s' % op, log_only=True)
|
|
|
|
|
|
|
|
method = getattr(self, 'do_%s' % op, None)
|
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
rc = 255
|
2017-06-06 10:26:25 +02:00
|
|
|
stdout = stderr = ''
|
|
|
|
|
|
|
|
if not method:
|
|
|
|
stderr = 'Invalid action specified'
|
|
|
|
else:
|
|
|
|
rc, stdout, stderr = method(data)
|
2016-10-26 19:38:08 +02:00
|
|
|
|
2016-11-30 22:26:49 +01:00
|
|
|
signal.alarm(0)
|
|
|
|
|
2017-06-06 10:26:25 +02:00
|
|
|
display.display('socket operation completed with rc %s' % rc, log_only=True)
|
2017-02-21 15:21:41 +01:00
|
|
|
|
2017-05-12 18:13:51 +02:00
|
|
|
send_data(s, to_bytes(rc))
|
2016-10-26 19:38:08 +02:00
|
|
|
send_data(s, to_bytes(stdout))
|
|
|
|
send_data(s, to_bytes(stderr))
|
2017-06-06 10:26:25 +02:00
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
s.close()
|
2017-06-06 10:26:25 +02:00
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
except Exception as e:
|
2017-06-06 10:26:25 +02:00
|
|
|
# socket.accept() will raise EINTR if the socket.close() is called
|
|
|
|
if e.errno != errno.EINTR:
|
|
|
|
display.display(traceback.format_exc(), log_only=True)
|
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
finally:
|
|
|
|
# when done, close the connection properly and cleanup
|
|
|
|
# the socket file so it can be recreated
|
2017-06-06 10:26:25 +02:00
|
|
|
self.shutdown()
|
2016-11-30 22:26:49 +01:00
|
|
|
end_time = datetime.datetime.now()
|
|
|
|
delta = end_time - self._start_time
|
2017-06-06 10:26:25 +02:00
|
|
|
display.display('shutdown local socket, connection was active for %s secs' % delta, log_only=True)
|
|
|
|
|
|
|
|
def connect_timeout(self, signum, frame):
|
|
|
|
display.display('connect timeout triggered, timeout value is %s secs' % C.PERSISTENT_CONNECT_TIMEOUT, log_only=True)
|
|
|
|
self.shutdown()
|
|
|
|
|
|
|
|
def command_timeout(self, signum, frame):
|
|
|
|
display.display('commnad 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)
|
2016-11-30 22:26:49 +01:00
|
|
|
self.socket.close()
|
2017-06-06 10:26:25 +02:00
|
|
|
if self.connection:
|
|
|
|
display.display('closing the connection', log_only=True)
|
|
|
|
self.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]
|
|
|
|
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
|
|
|
|
return (0, self.socket_path, '')
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2016-10-26 19:38:08 +02:00
|
|
|
|
|
|
|
def main():
|
2017-05-12 18:13:51 +02:00
|
|
|
# Need stdin as a byte stream
|
|
|
|
if PY3:
|
|
|
|
stdin = sys.stdin.buffer
|
|
|
|
else:
|
|
|
|
stdin = sys.stdin
|
2017-01-13 01:20:25 +01:00
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
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
|
2017-05-12 18:13:51 +02:00
|
|
|
cur_line = stdin.readline()
|
|
|
|
init_data = b''
|
|
|
|
while cur_line.strip() != b'#END_INIT#':
|
2017-05-13 03:04:48 +02:00
|
|
|
if cur_line == b'':
|
2017-05-12 18:13:51 +02:00
|
|
|
raise Exception("EOF found before init data was complete")
|
2016-10-26 19:38:08 +02:00
|
|
|
init_data += cur_line
|
2017-05-12 18:13:51 +02:00
|
|
|
cur_line = stdin.readline()
|
2017-05-13 03:04:48 +02:00
|
|
|
if PY3:
|
|
|
|
pc_data = cPickle.loads(init_data, encoding='bytes')
|
|
|
|
else:
|
|
|
|
pc_data = cPickle.loads(init_data)
|
2016-10-26 19:38:08 +02:00
|
|
|
|
|
|
|
pc = PlayContext()
|
|
|
|
pc.deserialize(pc_data)
|
2017-06-06 10:26:25 +02:00
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
except Exception as e:
|
|
|
|
# FIXME: better error message/handling/logging
|
2017-02-08 15:30:04 +01:00
|
|
|
sys.stderr.write(traceback.format_exc())
|
|
|
|
sys.exit("FAIL: %s" % e)
|
2016-10-26 19:38:08 +02:00
|
|
|
|
2017-06-13 15:11:33 +02:00
|
|
|
ssh = connection_loader.get('ssh', class_only=True, log_only=True)
|
2017-06-06 10:26:25 +02:00
|
|
|
cp = ssh._create_control_path(pc.remote_addr, pc.connection, pc.remote_user)
|
2016-10-26 19:38:08 +02:00
|
|
|
|
|
|
|
# create the persistent connection dir if need be and create the paths
|
|
|
|
# which we will be using later
|
2017-06-06 10:26:25 +02:00
|
|
|
tmp_path = unfrackpath(C.PERSISTENT_CONTROL_PATH_DIR)
|
2016-10-26 19:38:08 +02:00
|
|
|
makedirs_safe(tmp_path)
|
2017-06-06 10:26:25 +02:00
|
|
|
lock_path = unfrackpath("%s/.ansible_pc_lock" % tmp_path)
|
|
|
|
socket_path = unfrackpath(cp % dict(directory=tmp_path))
|
2016-10-26 19:38:08 +02:00
|
|
|
|
|
|
|
# if the socket file doesn't exist, spin up the daemon process
|
2017-06-06 10:26:25 +02:00
|
|
|
lock_fd = os.open(lock_path, os.O_RDWR|os.O_CREAT, 0o600)
|
2016-10-26 19:38:08 +02:00
|
|
|
fcntl.lockf(lock_fd, fcntl.LOCK_EX)
|
2017-06-06 10:26:25 +02:00
|
|
|
|
|
|
|
if not os.path.exists(socket_path):
|
2016-10-26 19:38:08 +02:00
|
|
|
pid = do_fork()
|
|
|
|
if pid == 0:
|
2017-02-18 14:12:01 +01:00
|
|
|
rc = 0
|
2017-01-25 05:14:30 +01:00
|
|
|
try:
|
2017-06-06 10:26:25 +02:00
|
|
|
server = Server(socket_path, pc)
|
2017-02-18 14:12:01 +01:00
|
|
|
except AnsibleConnectionFailure as exc:
|
2017-03-30 14:07:02 +02:00
|
|
|
display.display('connecting to host %s returned an error' % pc.remote_addr, log_only=True)
|
|
|
|
display.display(str(exc), log_only=True)
|
2017-02-18 14:12:01 +01:00
|
|
|
rc = 1
|
2017-01-25 05:14:30 +01:00
|
|
|
except Exception as exc:
|
2017-03-30 14:07:02 +02:00
|
|
|
display.display('failed to create control socket for host %s' % pc.remote_addr, log_only=True)
|
|
|
|
display.display(traceback.format_exc(), log_only=True)
|
2017-02-18 14:12:01 +01:00
|
|
|
rc = 1
|
2016-11-30 22:26:49 +01:00
|
|
|
fcntl.lockf(lock_fd, fcntl.LOCK_UN)
|
|
|
|
os.close(lock_fd)
|
2017-02-18 14:12:01 +01:00
|
|
|
if rc == 0:
|
|
|
|
server.run()
|
|
|
|
sys.exit(rc)
|
2017-03-21 03:26:18 +01:00
|
|
|
else:
|
2017-03-30 14:07:02 +02:00
|
|
|
display.display('re-using existing socket for %s@%s:%s' % (pc.remote_user, pc.remote_addr, pc.port), log_only=True)
|
2017-06-06 10:26:25 +02:00
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
fcntl.lockf(lock_fd, fcntl.LOCK_UN)
|
|
|
|
os.close(lock_fd)
|
|
|
|
|
2017-06-06 10:26:25 +02:00
|
|
|
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)
|
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
# 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.
|
2017-06-06 10:26:25 +02:00
|
|
|
while True:
|
2017-05-12 18:13:51 +02:00
|
|
|
data = stdin.readline()
|
|
|
|
if data == b'':
|
2016-10-26 19:38:08 +02:00
|
|
|
break
|
2017-05-12 18:13:51 +02:00
|
|
|
if data.strip() == b'':
|
2016-10-26 19:38:08 +02:00
|
|
|
continue
|
2017-06-06 10:26:25 +02:00
|
|
|
|
|
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
|
|
|
|
|
|
attempts = C.PERSISTENT_CONNECT_RETRIES
|
|
|
|
while bool(attempts):
|
2016-10-26 19:38:08 +02:00
|
|
|
try:
|
2017-06-06 10:26:25 +02:00
|
|
|
sock.connect(socket_path)
|
2016-10-26 19:38:08 +02:00
|
|
|
break
|
|
|
|
except socket.error:
|
2016-11-30 22:26:49 +01:00
|
|
|
time.sleep(C.PERSISTENT_CONNECT_INTERVAL)
|
2017-06-06 10:26:25 +02:00
|
|
|
attempts -= 1
|
|
|
|
else:
|
|
|
|
display.display('number of connection attempts exceeded, unable to connect to control socket', pc.remote_addr, pc.remote_user, log_only=True)
|
|
|
|
display.display('persistent_connect_interval=%s, persistent_connect_retries=%s' % (C.PERSISTENT_CONNECT_INTERVAL, C.PERSISTENT_CONNECT_RETRIES), pc.remote_addr, pc.remote_user, log_only=True)
|
|
|
|
sys.stderr.write('failed to connect to control socket')
|
|
|
|
sys.exit(255)
|
2016-10-26 19:38:08 +02:00
|
|
|
|
2016-11-30 22:26:49 +01:00
|
|
|
# send the play_context back into the connection so the connection
|
2017-01-13 01:20:25 +01:00
|
|
|
# can handle any privilege escalation activities
|
2017-05-12 18:13:51 +02:00
|
|
|
pc_data = b'CONTEXT: %s' % init_data
|
2017-06-06 10:26:25 +02:00
|
|
|
communicate(sock, pc_data)
|
2016-11-30 22:26:49 +01:00
|
|
|
|
2017-06-06 10:26:25 +02:00
|
|
|
rc, stdout, stderr = communicate(sock, data.strip())
|
2016-11-30 22:26:49 +01:00
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
sys.stdout.write(to_native(stdout))
|
|
|
|
sys.stderr.write(to_native(stderr))
|
|
|
|
|
2017-06-06 10:26:25 +02:00
|
|
|
sock.close()
|
2016-10-26 19:38:08 +02:00
|
|
|
break
|
2016-11-30 22:26:49 +01:00
|
|
|
|
2016-10-26 19:38:08 +02:00
|
|
|
sys.exit(rc)
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
2017-03-21 04:08:02 +01:00
|
|
|
display = Display()
|
2016-10-26 19:38:08 +02:00
|
|
|
main()
|