autoconf - various bug fixes with DockerController
This commit is contained in:
parent
7180378d0c
commit
1a32e7c02c
|
@ -12,8 +12,10 @@ RUN chmod +x /tmp/docker.sh && \
|
|||
/tmp/docker.sh && \
|
||||
rm -f /tmp/docker.sh
|
||||
|
||||
# Fix CVE-2021-22901, CVE-2021-22898, CVE-2021-22897, CVE-2021-33560 and CVE-2021-36159
|
||||
RUN apk add "curl>=7.77.0-r0" "libgcrypt>=1.8.8-r0" "apk-tools>=2.12.6-r0"
|
||||
# Fix CVE-2021-22901, CVE-2021-22898, CVE-2021-22897 and CVE-2021-33560
|
||||
RUN apk add "curl>=7.77.0-r0" "libgcrypt>=1.8.8-r0"
|
||||
# Fix CVE-2021-36159
|
||||
#RUN apk add "apk-tools>=2.12.6-r0"
|
||||
|
||||
VOLUME /www /http-confs /server-confs /modsec-confs /modsec-crs-confs /cache /pre-server-confs /acme-challenge /plugins
|
||||
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
#!/usr/bin/python3
|
||||
|
||||
import utils
|
||||
import subprocess, shutil, os, traceback, requests, time
|
||||
|
||||
from Controller import ControllerType
|
||||
import Controller
|
||||
|
||||
from logger import log
|
||||
|
||||
class Config :
|
||||
|
||||
def __init__(type, api_uri) :
|
||||
def __init__(self, type, api_uri) :
|
||||
self.__type = type
|
||||
self.__api_uri = api_uri
|
||||
|
||||
def gen(env) :
|
||||
def gen(self, env) :
|
||||
try :
|
||||
# Write environment variables to a file
|
||||
with open("/tmp/variables.env", "w") as f :
|
||||
|
@ -27,32 +26,32 @@ class Config :
|
|||
stdout = proc.stdout.decode("ascii")
|
||||
stderr = proc.stderr.decode("ascii")
|
||||
if len(stdout) > 1 :
|
||||
log("CONFIG", "INFO", "generator output : " + stdout)
|
||||
log("config", "INFO", "generator output : " + stdout)
|
||||
if stderr != "" :
|
||||
log("CONFIG", "ERROR", "generator error : " + stderr)
|
||||
log("config", "ERROR", "generator error : " + stderr)
|
||||
|
||||
# We're done
|
||||
if proc.returncode == 0 :
|
||||
if self.__type == ControllerType.SWARM or self.__type == ControllerType.KUBERNETES :
|
||||
if self.__type == Controller.Type.SWARM or self.__type == Controller.Type.KUBERNETES :
|
||||
return self.__jobs()
|
||||
return True
|
||||
log("CONFIG", "ERROR", "error while generating config (return code = " + str(proc.returncode) + ")")
|
||||
log("config", "ERROR", "error while generating config (return code = " + str(proc.returncode) + ")")
|
||||
|
||||
except Exception as e :
|
||||
log("CONFIG", "ERROR", "exception while generating site config : " + traceback.format_exc())
|
||||
log("config", "ERROR", "exception while generating site config : " + traceback.format_exc())
|
||||
return False
|
||||
|
||||
def reload(self, instances) :
|
||||
ret = True
|
||||
if self.__type == ControllerType.DOCKER :
|
||||
if self.__type == Controller.Type.DOCKER :
|
||||
for instance in instances :
|
||||
try :
|
||||
instance.kill("SIGHUP")
|
||||
except :
|
||||
ret = False
|
||||
elif self.__type == ControllerType.SWARM :
|
||||
elif self.__type == Controller.Type.SWARM :
|
||||
ret = self.__api_call(instances, "/reload")
|
||||
elif self.__type == ControllerType.KUBERNETES :
|
||||
elif self.__type == Controller.Type.KUBERNETES :
|
||||
ret = self.__api_call(instances, "/reload")
|
||||
return ret
|
||||
|
||||
|
@ -61,9 +60,9 @@ class Config :
|
|||
|
||||
def wait(self, instances) :
|
||||
ret = True
|
||||
if self.__type == ControllerType.DOCKER :
|
||||
if self.__type == Controller.Type.DOCKER :
|
||||
ret = self.__wait_docker()
|
||||
elif self.__type == ControllerType.SWARM or self.__type == ControllerType.KUBERNETES :
|
||||
elif self.__type == Controller.Type.SWARM or self.__type == Controller.Type.KUBERNETES :
|
||||
ret = self.__wait_api()
|
||||
return ret
|
||||
|
||||
|
@ -96,20 +95,20 @@ class Config :
|
|||
started = True
|
||||
break
|
||||
i = i + 1
|
||||
log("CONFIG", "INFO" "waiting " + str(i) + " seconds before retrying to contact bunkerized-nginx instances")
|
||||
log("config", "INFO" "waiting " + str(i) + " seconds before retrying to contact bunkerized-nginx instances")
|
||||
if started :
|
||||
log("CONFIG", "INFO", "bunkerized-nginx instances started")
|
||||
log("config", "INFO", "bunkerized-nginx instances started")
|
||||
return True
|
||||
else :
|
||||
log("CONFIG", "ERROR", "bunkerized-nginx instances are not started")
|
||||
log("config", "ERROR", "bunkerized-nginx instances are not started")
|
||||
except Exception as e :
|
||||
log("CONFIG", "ERROR", "exception while waiting for bunkerized-nginx instances : " + traceback.format_exc())
|
||||
log("config", "ERROR", "exception while waiting for bunkerized-nginx instances : " + traceback.format_exc())
|
||||
return False
|
||||
|
||||
def __api_call(self, instances, path) :
|
||||
ret = True
|
||||
urls = []
|
||||
if self.__type == ControllerType.SWARM :
|
||||
if self.__type == Controller.Type.SWARM :
|
||||
for instance in instances :
|
||||
name = instance.name
|
||||
for task in instance.tasks() :
|
||||
|
@ -117,8 +116,8 @@ class Config :
|
|||
taskID = task["ID"]
|
||||
url = "http://" + name + "." + nodeID + "." + taskID + ":8080" + self.__api_uri + path
|
||||
urls.append(url)
|
||||
elif self.__type == ControllerType.KUBERNETES :
|
||||
log("CONFIG", "ERROR", "TODO get urls for k8s")
|
||||
elif self.__type == Controller.Type.KUBERNETES :
|
||||
log("config", "ERROR", "TODO get urls for k8s")
|
||||
|
||||
for url in urls :
|
||||
try :
|
||||
|
@ -126,8 +125,8 @@ class Config :
|
|||
except :
|
||||
pass
|
||||
if req and req.status_code == 200 and req.text == "ok" :
|
||||
log("CONFIG", "INFO", "successfully sent API order to " + url)
|
||||
log("config", "INFO", "successfully sent API order to " + url)
|
||||
else :
|
||||
log("CONFIG", "INFO", "failed API order to " + url)
|
||||
log("config", "INFO", "failed API order to " + url)
|
||||
ret = False
|
||||
return ret
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
|
||||
from Config import Config
|
||||
|
||||
class ControllerType(Enum) :
|
||||
class Type(Enum) :
|
||||
DOCKER = 1
|
||||
SWARM = 2
|
||||
KUBERNETES = 3
|
||||
|
@ -28,7 +30,7 @@ class Controller(ABC) :
|
|||
return self.__config.gen(env)
|
||||
|
||||
@abstractmethod
|
||||
def process_events(self) :
|
||||
def process_events(self, current_env) :
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import docker
|
||||
from Controller import Controller, ControllerType
|
||||
import utils
|
||||
import Controller
|
||||
|
||||
class DockerController(Controller) :
|
||||
from logger import log
|
||||
|
||||
class DockerController(Controller.Controller) :
|
||||
|
||||
def __init__(self) :
|
||||
super().__init__(ControllerType.DOCKER)
|
||||
super().__init__(Controller.Type.DOCKER)
|
||||
# TODO : honor env vars like DOCKER_HOST
|
||||
self.__client = docker.DockerClient(base_url='unix:///var/run/docker.sock')
|
||||
|
||||
|
@ -21,22 +22,38 @@ class DockerController(Controller) :
|
|||
for variable in instance.attrs["Config"]["Env"] :
|
||||
env[variable.split("=")[0]] = variable.replace(variable.split("=")[0] + "=", "", 1)
|
||||
first_servers = []
|
||||
if "SERVER_NAME" in env :
|
||||
if "SERVER_NAME" in env and env["SERVER_NAME"] != "" :
|
||||
first_servers = env["SERVER_NAME"].split(" ")
|
||||
for container in self.__get_containers() :
|
||||
first_server = container.labels["bunkerized-nginx.SERVER_NAME"].split(" ")[0]
|
||||
first_servers.append(first_server)
|
||||
for variable, value in instance.labels.items() :
|
||||
if variable.startswith("bunkerized-nginx.") :
|
||||
for variable, value in container.labels.items() :
|
||||
if variable.startswith("bunkerized-nginx.") and variable != "bunkerized-nginx.AUTOCONF" :
|
||||
env[first_server + "_" + variable.replace("bunkerized-nginx.", "", 1)] = value
|
||||
env["SERVER_NAME"] = " ".join(first_servers)
|
||||
if len(first_servers) == 0 :
|
||||
env["SERVER_NAME"] = ""
|
||||
else :
|
||||
env["SERVER_NAME"] = " ".join(first_servers)
|
||||
return self._fix_env(env)
|
||||
|
||||
def process_events(self, current_env) :
|
||||
old_env = current_env
|
||||
for event in client.events(decode=True, filter={"type": "container", "label": ["bunkerized-nginx.AUTOCONF", "bunkerized-nginx.SERVER_NAME"]}) :
|
||||
# TODO : check why filter isn't working as expected
|
||||
#for event in self.__client.events(decode=True, filters={"type": "container", "label": ["bunkerized-nginx.AUTOCONF", "bunkerized-nginx.SERVER_NAME"]}) :
|
||||
for event in self.__client.events(decode=True, filters={"type": "container"}) :
|
||||
new_env = self.get_env()
|
||||
if new_env != old_env :
|
||||
log("controller", "INFO", "generating new configuration")
|
||||
if self.gen_conf(new_env) :
|
||||
old_env.copy(new_env)
|
||||
log("CONTROLLER", "INFO", "successfully generated new configuration")
|
||||
old_env = new_env.copy()
|
||||
log("controller", "INFO", "successfully generated new configuration")
|
||||
if self.reload() :
|
||||
log("controller", "INFO", "successful reload")
|
||||
else :
|
||||
log("controller", "ERROR", "failed reload")
|
||||
else :
|
||||
log("controller", "ERROR", "can't generate new configuration")
|
||||
|
||||
def reload(self) :
|
||||
return self._reload(self.__get_instances())
|
||||
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
from kubernetes import client, config, watch
|
||||
from threading import Thread
|
||||
|
||||
from Controller import Controller, ControllerType
|
||||
import Controller
|
||||
|
||||
from logger import log
|
||||
|
||||
class IngressController :
|
||||
class IngressController(Controller.Controller) :
|
||||
|
||||
def __init__(self, api_uri) :
|
||||
super().__init__(ControllerType.KUBERNETES, api_uri=api_uri, lock=Lock())
|
||||
super().__init__(Controller.Type.KUBERNETES, api_uri=api_uri, lock=Lock())
|
||||
config.load_incluster_config()
|
||||
self.__api = client.CoreV1Api()
|
||||
self.__extensions_api = client.ExtensionsV1beta1Api()
|
||||
|
@ -79,7 +79,7 @@ class IngressController :
|
|||
new_env = self.get_env()
|
||||
if new_env != self.__old_env() :
|
||||
if self.gen_conf(new_env, lock=False) :
|
||||
self.__old_env.copy(new_env)
|
||||
self.__old_env = new_env.copy()
|
||||
log("CONTROLLER", "INFO", "successfully generated new configuration")
|
||||
self.lock.release()
|
||||
|
||||
|
@ -90,6 +90,9 @@ class IngressController :
|
|||
new_env = self.get_env()
|
||||
if new_env != self.__old_env() :
|
||||
if self.gen_conf(new_env, lock=False) :
|
||||
self.__old_env.copy(new_env)
|
||||
self.__old_env = new_env.copy()
|
||||
log("CONTROLLER", "INFO", "successfully generated new configuration")
|
||||
self.lock.release()
|
||||
|
||||
def reload(self) :
|
||||
return self._reload(self.__get_ingresses())
|
||||
|
|
|
@ -21,7 +21,7 @@ class ReloadServerHandler(socketserver.StreamRequestHandler):
|
|||
locked = False
|
||||
self.request.sendall(b"ok")
|
||||
elif data == b"reload" :
|
||||
ret = self.server.controller.reload() :
|
||||
ret = self.server.controller.reload()
|
||||
if ret :
|
||||
self.request.sendall(b"ok")
|
||||
else :
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import docker
|
||||
from Controller import Controller, ControllerType
|
||||
import utils
|
||||
|
||||
class SwarmController(Controller) :
|
||||
from logger import log
|
||||
|
||||
import Controller
|
||||
|
||||
class SwarmController(Controller.Controller) :
|
||||
|
||||
def __init__(self, api_uri) :
|
||||
super().__init__(ControllerType.SWARM, api_uri=api_uri, lock=Lock())
|
||||
super().__init__(Controller.Type.SWARM, api_uri=api_uri, lock=Lock())
|
||||
# TODO : honor env vars like DOCKER_HOST
|
||||
self.__client = docker.DockerClient(base_url='unix:///var/run/docker.sock')
|
||||
|
||||
|
@ -21,24 +23,39 @@ class SwarmController(Controller) :
|
|||
for variable in instance.attrs["Spec"]["TaskTemplate"]["ContainerSpec"]["Env"] :
|
||||
env[variable.split("=")[0]] = variable.replace(variable.split("=")[0] + "=", "", 1)
|
||||
first_servers = []
|
||||
if "SERVER_NAME" in env :
|
||||
if "SERVER_NAME" in env and env["SERVER_NAME"] != "" :
|
||||
first_servers = env["SERVER_NAME"].split(" ")
|
||||
for service in self.__get_services() :
|
||||
first_server = service.attrs["Spec"]["Labels"]["bunkerized-nginx.SERVER_NAME"].split(" ")[0]
|
||||
first_servers.append(first_server)
|
||||
for variable, value in service.attrs["Spec"]["Labels"].items() :
|
||||
if variable.startswith("bunkerized-nginx.") :
|
||||
if variable.startswith("bunkerized-nginx.") and variable != "bunkerized-nginx.AUTOCONF" :
|
||||
env[first_server + "_" + variable.replace("bunkerized-nginx.", "", 1)] = value
|
||||
env["SERVER_NAME"] = " ".join(first_servers)
|
||||
if len(first_servers) == 0 :
|
||||
env["SERVER_NAME"] = ""
|
||||
else :
|
||||
env["SERVER_NAME"] = " ".join(first_servers)
|
||||
return self._fix_env(env)
|
||||
|
||||
def process_events(self, current_env) :
|
||||
old_env = current_env
|
||||
for event in client.events(decode=True, filter={"type": "service", "label": ["bunkerized-nginx.AUTOCONF", "bunkerized-nginx.SERVER_NAME"]}) :
|
||||
# TODO : check why filter isn't working as expected
|
||||
#for event in self.__client.events(decode=True, filters={"type": "service", "label": ["bunkerized-nginx.AUTOCONF", "bunkerized-nginx.SERVER_NAME"]}) :
|
||||
for event in self.__client.events(decode=True, filters={"type": "service"}) :
|
||||
new_env = self.get_env()
|
||||
if new_env != old_env :
|
||||
self.lock.acquire()
|
||||
if self.gen_conf(new_env, lock=False) :
|
||||
old_env.copy(new_env)
|
||||
log("CONTROLLER", "INFO", "successfully generated new configuration")
|
||||
log("controller", "INFO", "generating new configuration")
|
||||
if self.gen_conf(new_env) :
|
||||
old_env = new_env.copy()
|
||||
log("controller", "INFO", "successfully generated new configuration")
|
||||
if self.reload() :
|
||||
log("controller", "INFO", "successful reload")
|
||||
else :
|
||||
log("controller", "ERROR", "failed reload")
|
||||
else :
|
||||
log("controller", "ERROR", "can't generate new configuration")
|
||||
self.lock.release()
|
||||
|
||||
def reload(self) :
|
||||
return self._reload(self.__get_instances())
|
||||
|
|
|
@ -6,7 +6,7 @@ import docker, os, stat, sys, select, threading
|
|||
|
||||
from DockerController import DockerController
|
||||
from SwarmController import SwarmController
|
||||
from KubernetesController import KubernetesController
|
||||
from IngressController import IngressController
|
||||
|
||||
from logger import log
|
||||
|
||||
|
@ -21,7 +21,7 @@ if swarm :
|
|||
controller = SwarmController(api_uri)
|
||||
elif kubernetes :
|
||||
log("autoconf", "INFO", "kubernetes mode detected")
|
||||
controller = KubernetesController(api_uri)
|
||||
controller = IngressController(api_uri)
|
||||
else :
|
||||
log("autoconf", "INFO", "docker mode detected")
|
||||
controller = DockerController()
|
||||
|
@ -32,8 +32,8 @@ if swarm or kubernetes :
|
|||
|
||||
# Apply the first config for existing services
|
||||
current_env = controller.get_env()
|
||||
if env != {} :
|
||||
if current_env != {} :
|
||||
controller.gen_conf(current_env)
|
||||
|
||||
# Process events
|
||||
controller.process_events()
|
||||
controller.process_events(current_env)
|
||||
|
|
|
@ -1,33 +1,35 @@
|
|||
#!/bin/bash
|
||||
|
||||
echo "[*] Starting bunkerized-nginx ..."
|
||||
. /opt/bunkerize-nginx/entrypoint/utils.sh
|
||||
|
||||
log "entrypoint" "INFO" "starting bunkerized-nginx ..."
|
||||
|
||||
# trap SIGTERM and SIGINT
|
||||
function trap_exit() {
|
||||
echo "[*] Catched stop operation"
|
||||
echo "[*] Stopping crond ..."
|
||||
log "stop" "INFO" "catched stop operation"
|
||||
log "stop" "INFO" "stopping crond ..."
|
||||
pkill -TERM crond
|
||||
echo "[*] Stopping nginx ..."
|
||||
log "stop" "INFO" "stopping nginx ..."
|
||||
/usr/sbin/nginx -s stop
|
||||
}
|
||||
trap "trap_exit" TERM INT QUIT
|
||||
|
||||
# trap SIGHUP
|
||||
function trap_reload() {
|
||||
echo "[*] Catched reload operation"
|
||||
log "reload" "INFO" "catched reload operation"
|
||||
if [ "$SWARM_MODE" != "yes" ] ; then
|
||||
/opt/bunkerized-nginx/entrypoint/jobs.sh
|
||||
fi
|
||||
if [ -f /tmp/nginx.pid ] ; then
|
||||
echo "[*] Reloading nginx ..."
|
||||
log "reload" "INFO" "reloading nginx ..."
|
||||
nginx -s reload
|
||||
if [ $? -eq 0 ] ; then
|
||||
echo "[*] Reload successfull"
|
||||
log "reload" "INFO" "reloading successful"
|
||||
else
|
||||
echo "[!] Reload failed"
|
||||
log "reload" "ERROR" "reloading failed"
|
||||
fi
|
||||
else
|
||||
echo "[!] Ignored reload operation because nginx is not running"
|
||||
log "reload" "INFO" "ignored reload operation because nginx is not running"
|
||||
fi
|
||||
}
|
||||
trap "trap_reload" HUP
|
||||
|
@ -35,7 +37,7 @@ trap "trap_reload" HUP
|
|||
# do the configuration magic if needed
|
||||
if [ ! -f "/etc/nginx/global.env" ] ; then
|
||||
|
||||
echo "[*] Configuring bunkerized-nginx ..."
|
||||
log "entrypoint" "INFO" "configuring bunkerized-nginx ..."
|
||||
|
||||
# check permissions
|
||||
if [ "$SWARM_MODE" != "yes" ] ; then
|
||||
|
@ -50,27 +52,34 @@ if [ ! -f "/etc/nginx/global.env" ] ; then
|
|||
# start temp nginx to solve Let's Encrypt challenges if needed
|
||||
/opt/bunkerized-nginx/entrypoint/nginx-temp.sh
|
||||
|
||||
# only do config if we are not in swarm mode
|
||||
if [ "$SWARM_MODE" != "yes" ] ; then
|
||||
# only do config if we are not in swarm/kubernetes mode
|
||||
if [ "$SWARM_MODE" != "yes" ] && [ "$KUBERNETES_MODE" != "yes" ] ; then
|
||||
# export the variables
|
||||
env | grep -E -v "^(HOSTNAME|PWD|PKG_RELEASE|NJS_VERSION|SHLVL|PATH|_|NGINX_VERSION|HOME)=" > "/tmp/variables.env"
|
||||
|
||||
# call the generator
|
||||
/opt/bunkerized-nginx/gen/main.py --settings /opt/bunkerized-nginx/settings.json --templates /opt/bunkerized-nginx/confs --output /etc/nginx --variables /tmp/variables.env
|
||||
gen_ret="$(/opt/bunkerized-nginx/gen/main.py --settings /opt/bunkerized-nginx/settings.json --templates /opt/bunkerized-nginx/confs --output /etc/nginx --variables /tmp/variables.env 2>&1)"
|
||||
if [ "$?" -ne 0 ] ; then
|
||||
log "entrypoint" "ERROR" "generator failed : $gen_ret"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$gen_ret" != "" ] ; then
|
||||
log "entrypoint" "INFO" "generator output : $gen_ret"
|
||||
fi
|
||||
|
||||
# call jobs
|
||||
/opt/bunkerized-nginx/entrypoint/jobs.sh
|
||||
fi
|
||||
else
|
||||
echo "[*] Skipping configuration process"
|
||||
log "entrypoint" "INFO" "skipping configuration process"
|
||||
fi
|
||||
|
||||
# start crond
|
||||
crond
|
||||
|
||||
# wait until config has been generated if we are in swarm mode
|
||||
if [ "$SWARM_MODE" = "yes" ] ; then
|
||||
echo "[*] Waiting until config has been generated ..."
|
||||
if [ "$SWARM_MODE" = "yes" ] || [ "$KUBERNETES_MODE" = "yes" ] ; then
|
||||
log "entrypoint" "INFO" "waiting until config has been generated ..."
|
||||
while [ ! -f "/etc/nginx/autoconf" ] ; do
|
||||
sleep 1
|
||||
done
|
||||
|
@ -82,7 +91,7 @@ if [ -f "/tmp/nginx-temp.pid" ] ; then
|
|||
fi
|
||||
|
||||
# run nginx
|
||||
echo "[*] Running nginx ..."
|
||||
log "entrypoint" "INFO" "running nginx ..."
|
||||
nginx -g 'daemon off;' &
|
||||
pid="$!"
|
||||
|
||||
|
@ -104,5 +113,5 @@ while [ -f "/tmp/nginx.pid" ] ; do
|
|||
done
|
||||
|
||||
# sigterm trapped
|
||||
echo "[*] bunkerized-nginx stopped"
|
||||
log "entrypoint" "INFO" "bunkerized-nginx stopped"
|
||||
exit 0
|
||||
|
|
|
@ -32,9 +32,12 @@ function has_value() {
|
|||
done
|
||||
}
|
||||
|
||||
# log to jobs.log
|
||||
function job_log() {
|
||||
# log to stdout
|
||||
function log() {
|
||||
when="$(date '+[%Y-%m-%d %H:%M:%S]')"
|
||||
what="$1"
|
||||
echo "$when $what" >> /var/log/nginx/jobs.log
|
||||
category="$1"
|
||||
severity="$2"
|
||||
message="$3"
|
||||
echo "$when $category - $severity - $message"
|
||||
}
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@ class Configurator :
|
|||
if check :
|
||||
self.__variables[var] = value
|
||||
else :
|
||||
print("Ignoring " + var + "=" + value + " (" + reason + ")")
|
||||
print("ignoring " + var + "=" + value + " (" + reason + ")", file=sys.stderr)
|
||||
|
||||
def get_config(self) :
|
||||
config = {}
|
||||
|
|
Loading…
Reference in New Issue