#!/usr/bin/env python3 from docker import Client from docker.errors import APIError from string import Template import json import re import signal import os import logging target_path = "/tmp/nginx/" auth_path = target_path + "auth/" config_path = target_path + "conf/" pid_file = "/var/run/nginx.pid" non_location_template = """# proxy for container '$containername' server { listen $listen; server_name $names; $auth_config location / { client_max_body_size $body_size; client_body_timeout 300s; if ($$http_x_forwarded_for = "") { add_header X-Forwarded-For $$remote_addr; } proxy_set_header Host $$host; proxy_pass http://$ip:$port; } } """ location_template = """# proxy for container '$containername' server { listen $listen; server_name $names; $auth_config location / { return 301 $$scheme://$name/$location; } location /$location { client_max_body_size $body_size; client_body_timeout 300s; if ($$http_x_forwarded_for = "") { add_header X-Forwarded-For $$remote_addr; } proxy_set_header Host $$host; proxy_pass http://$ip:$port; } } """ def analyse_env_vars(inspect_data): """Extracts the environment variables from the given result of an 'inspect container' call.""" env_data = {} counter = 0 if 'Env' not in inspect_data['Config'] or inspect_data['Config']['Env'] is None: return env_data for env_var in inspect_data['Config']['Env']: t = env_var.split("=") key = t[0] value = t[1] if key == 'PROXY_DATA': if key in env_data: key = "{key}{postfix}".format(key=key, postfix=counter) env_data[key] = value return env_data def analyse_proxy_data(data): """Extracts the data for the proxy configuration (envrionment variable 'PROXY_DATA' and converts it to a dictionary.""" proxy_data = {} for proxy_var in data.split(','): t = proxy_var.split(":",1) proxy_data[t[0]] = t[1] return proxy_data def check_proxy_data_format(var_content): """ Validates the content of the variable. :param var_content: content of the proxy data variable :return: True if the content is of valid format, False otherwise """ return re.match(r"^(\w+:[^:,]+,)+\w+:[^:,]+$", var_content) is not None def extract_ip(inspect_data): """extracts the container's ip from the given inspect data""" return inspect_data['NetworkSettings']['IPAddress'] def extract_name(inspect_data): """extracts the container's name from the given inspect data""" return inspect_data['Name'] def get_if_available(dictionary, key, defValue): if key in dictionary: return dictionary[key] else: return defValue def handle_container(id): """This function take a container's id and collects all data required to create a proper proxy configuration. The configuration is then written to the directory of temporary nginx files""" inspect_data = client.inspect_container(id) env_vars = analyse_env_vars(inspect_data) for env_key in env_vars: env_data = env_vars[env_key] if not check_proxy_data_format(env_data): logging.info('cannot handle container with id "%s" named "%s": %s', id, extract_name(inspect_data), env_data) return proxy_data = analyse_proxy_data(env_data) container_listen_ip = get_if_available(proxy_data, 'ip', '0.0.0.0') if container_listen_ip != '0.0.0.0' and container_listen_ip not in listen_ips: logging.info('container "%s"(%s) does not listen on %s.', extract_name(inspect_data), container_listen_ip, str(listen_ips)) return logging.info('container "%s"(%s) is allowed to listen on %s.', extract_name(inspect_data), container_listen_ip, str(listen_ips)) auth_data = get_if_available(proxy_data, 'auth_data', None) logging.info('auth data: %s', auth_data) auth_config = '' if auth_data: realm, htpasswd = auth_data.split(';', 1) htpasswd = htpasswd.replace(';', ':') auth_file_name = auth_path + 'proxy_{id}_{code}'.format(id=id, code=env_key) with open(auth_file_name, 'w') as file: file.write(htpasswd) # write auth data to file # add to config auth_config = """ auth_basic "{realm}"; auth_basic_user_file {file}; """.format(realm=realm,file=auth_file_name) substitutes = { 'containername': '{c} {d}'.format(c=extract_name(inspect_data), d=env_data), 'ip': extract_ip(inspect_data), 'location': get_if_available(proxy_data, 'location', ''), 'names': get_if_available(proxy_data, 'server_names', '').replace(';', ' '), 'port': get_if_available(proxy_data, 'port', 80), 'body_size': get_if_available(proxy_data, 'body_size', '1m'), 'listen': '*:80', 'auth_config': auth_config } logging.info('writing to %sproxy_%s', config_path, id) with open(config_path + '/proxy_{id}_{code}'.format(id=id,code=env_key), 'w') as file: if substitutes['location'] == '': del substitutes['location'] logging.info(Template(non_location_template).safe_substitute(substitutes)) file.write(Template(non_location_template).safe_substitute(substitutes)) else: # make sure we have a name for the redirect substitutes['name'] = substitutes['names'].split(' ')[0] logging.info(Template(location_template).safe_substitute(substitutes)) file.write(Template(location_template).safe_substitute(substitutes)) def reload_nginx_configuration(): logging.info('HUPing nginx') os.kill(pid, signal.SIGHUP) def get_pid(): """This function reads the process id from the given file.""" with open(pid_file, 'r') as file: return int(file.read()) def get_docker_id(): """This function extracts the container's id from /proc/self/cgroup.""" id = '' with open('/proc/self/cgroup', 'r') as file: lines = file.read().split('\n') for line in lines: index = line.find('docker-') if index != -1: id = line[index+7:-6] break index = line.find('docker/') if index != -1: id = line[index+7:] break if id == '': logging.error('could not determine container\'s id!') return id def get_listen_ips(): inspect_data = client.inspect_container(get_docker_id()) mappings = inspect_data['NetworkSettings']['Ports']['80/tcp'] ips = [] if mappings is None or len(mappings) == 0: ips.append('0.0.0.0') else: logging.info('count %s', len(mappings)) for data in mappings: ips.append(data['HostIp']) return ips def setup_logging(): logging.basicConfig(format='%(asctime)s [%(levelname)s]: %(message)s', level=logging.INFO) if __name__ == '__main__': setup_logging() # prepare required stuff pid = get_pid() logging.info('nginx pid: %s', str(pid)) if not os.path.exists(config_path): logging.info('creating target path: %s', target_path) os.mkdir(target_path) logging.info('creating target path: %s', auth_path) os.mkdir(auth_path) logging.info('creating target path: %s', config_path) os.mkdir(config_path) client = Client(base_url='unix://var/run/docker.sock', version='1.15') listen_ips = get_listen_ips() logging.info('Listening on %s.', listen_ips) if len(listen_ips) != 1: logging.error('auto_proxy should only listen on 1 IP at a time, everything else can have security implications.') exit() # handle all running containers existing at startup of this container container_ids = client.containers(quiet=True) for container_id in container_ids: handle_container(container_id['Id']) reload_nginx_configuration() # hook to the events for line in client.events(): line_str = line.decode("utf-8") event = json.loads(line_str) if not 'id' in event: continue container_id = event['id'] try: inspect_data = client.inspect_container(container_id) ip = extract_ip(inspect_data) except APIError: ip = '' if ip == '': logging.info('removing data for container %s', container_id) # since a container can expose multiple ports per PROXY_DATA it # will generate multiple files. All names contain the container id. # This will be used to delete all relevant files for a container for filename in os.listdir(config_path): if container_id in filename: logging.info('removing file %s%s', config_path, container_id) os.remove(config_path + filename) else: handle_container(container_id) reload_nginx_configuration()