commit 7af75ad55db16d97dda0d3df9eac841d28581a84 Author: Aleksei Sokol Date: Mon Oct 9 09:48:30 2023 +0300 Initial commit Add nginx and certbot configurations diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..212b5b9 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +EMAIL=your@email.com \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afe4e47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/nginx/domains.txt +/nginx/servers.json +*.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..517a03b --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# ssl_nginx hosting + +## Preparation + +Add your domains for _certbot_ to [domains.txt file at nginx directory](nginx/domains.txt) and servers configuration + json file in format +```json +{ + "domain": { + "redirect": "redirection_path", + "options": [ + "some_nginx_option_on_server_level here;" + ] + } +} +``` +to [servers.json file at nginx directory](nginx/servers.json). + +User email used for certbot can be set as environment variable at build process or in .env file. + +## Usage + +For the first time you should run (run_once.d-c.yml)[run_once.d-c.yml] docker-compose file to get certificates. + +You can use (run_once.sh)[run_once.sh] script for this. + +After it, `docker compose up` should do the trick. Certificates update attempt will be performed automatically at 02:15 + on each seventh day of month. (set in Dockerfile of _certbot_ and _nginx_). + +## certbot_manual.sh + +This sceipt is available to perform manual certificates obtaining. One can use it to get a + wildcard certificate for example (not available for automatic generation without an appropriate plugin). \ No newline at end of file diff --git a/certbot/Dockerfile b/certbot/Dockerfile new file mode 100644 index 0000000..114f9bc --- /dev/null +++ b/certbot/Dockerfile @@ -0,0 +1,35 @@ +# Uses /ssl volume to store certificates and read domains.txt on run_once +# Uses /etc/letsencrypt volume to store letsencrypt data +FROM alpine + +ARG EMAIL + +RUN apk add certbot bash + +RUN echo "#!/bin/sh" > /usr/bin/update_certificates && \ + echo "certbot renew --quiet" >> /usr/bin/update_certificates && \ + echo "cp -rL /etc/letsencrypt/live/* /ssl/" >> /usr/bin/update_certificates && \ + \ + mkdir -p /etc/letsencrypt /ssl/.well-known && \ + \ + echo "webroot-path = /ssl/" > /etc/letsencrypt/cli.ini && \ + \ + echo '15 2 */7 * * /usr/bin/update_certificates' > /etc/crontabs/root && \ + \ + echo "echo 'running with cron'" > /run_with_cron && \ + echo "cp /etc/letsencrypt.bak/cli.ini /etc/letsencrypt/cli.ini" >> /run_with_cron && \ + echo "crond -f" >> /run_with_cron && \ + \ + echo "echo 'running once'" > /run_once && \ + echo "mv /etc/letsencrypt.bak/cli.ini /etc/letsencrypt/cli.ini" >> /run_once && \ + echo "if [ ! -f /ssl/domains.txt ]; then echo 'No domains.txt file found in /ssl, exiting' && exit 1; fi" >> /run_once && \ + echo 'for domain in $(cat /ssl/domains.txt); do case $domain in "#"*) :; ;; *) certbot certonly -n --authenticator webroot -d $domain; ;; esac; done' >> /run_once && \ + echo "cp -rL /etc/letsencrypt/live/* /ssl/" >> /run_once && \ + chmod +x /usr/bin/update_certificates + +RUN certbot register --email $EMAIL --non-interactive --agree-tos + +RUN cp -r /etc/letsencrypt /etc/letsencrypt.bak + +ENTRYPOINT ["/bin/sh"] +CMD ["/run_with_cron"] diff --git a/certbot_manual.sh b/certbot_manual.sh new file mode 100755 index 0000000..42419d6 --- /dev/null +++ b/certbot_manual.sh @@ -0,0 +1,14 @@ +#!/bin/sh +SSL_VOLUME_NAME=ssl_nginx_ssl +LETSENCRYPT_VOLUME_NAME=ssl_nginx_letsencrypt + +if [ "$EMAIL" = '' ]; then + echo "Set EMAIL environment variable to run this!" + exit 1 +fi + +docker build --tag certbot_manual_test --build-arg EMAIL="$EMAIL" certbot +echo "ececute 'cat /run_once' to get commands list" +echo "for wildcard domains use manual mode with dns challange: 'certbot certonly -d '*.domain' --manual --preferred-challenges dns" +docker run -it --rm -v $SSL_VOLUME_NAME:/ssl -v $LETSENCRYPT_VOLUME_NAME:/etc/letsencrypt --entrypoint /bin/bash certbot_manual_test +docker rmi certbot_manual_test \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dcbcccc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +version: '3.4' + +project: ssl_nginx + +services: + certbot: + container_name: 'global-certbot' + build: + context: ./certbot + args: + EMAIL: ${EMAIL} + volumes: + - ssl:/ssl + - letsencrypt:/etc/letsencrypt + depends_on: + - nginx + + nginx: + container_name: 'global-nginx' + build: ./nginx + volumes: + - ssl:/ssl + - letsencrypt:/etc/letsencrypt + ports: + - 80:80 + - 443:443 + networks: + - hosting_net + +volumes: + ssl: + name: ssl_nginx_ssl + letsencrypt: + name: ssl_nginx_letsencrypt + +networks: + hosting_net: + external: true \ No newline at end of file diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..ff6dadf --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.10-alpine as builder + +COPY add_servers.py /add_servers.py +COPY servers.json /servers.json +COPY domains.txt /domains.txt +COPY nginx.conf /nginx.conf + +RUN python /add_servers.py --nginx /nginx.conf --domains_list_txt /domains.txt --servers_config_json server.json --certificates_path /ssl + + +FROM nginx:alpine + +COPY proxy_common.conf /etc/nginx/proxy_common.conf +COPY server_common.conf /etc/nginx/server_common.conf + +COPY --from=builder /nginx.conf /etc/nginx/nginx.conf +COPY domains.txt /domains.txt + +RUN echo "16 2 */7 * * nginx -s reload" > /etc/crontabs/certbot && \ + \ + echo "cp /domains.txt /ssl/domains.txt" > /entrypoint && \ + echo "crond" >> /entrypoint && \ + echo "nginx -g 'daemon off;'" >> /entrypoint && \ + echo "nginx" >> /entrypoint + +ENTRYPOINT ["/bin/sh"] +CMD ["/entrypoint"] diff --git a/nginx/add_servers.py b/nginx/add_servers.py new file mode 100644 index 0000000..5098d7c --- /dev/null +++ b/nginx/add_servers.py @@ -0,0 +1,209 @@ +"""Add domain servers to nginx.conf executable script.""" +import argparse +from dataclasses import dataclass, field +import json +import sys + +REDIRECT_TEMPLATE = "\n".join( + ( + "\tlocation / {{", + "\t\tproxy_pass {redirection_host}/;", + "\t\tinclude proxy_common.conf;", + "\t}}", + ) +) + +SERVER_HTTPS_TEMPLATE = "\n".join( + ( + "server {{", + "\tinclude server_common.conf;", + "\tlisten 443 ssl;", + "\t", + "\tserver_name {server_name};", + "\tssl_certificate {certificates_path}/{certificate}/fullchain.pem;", + "\tssl_certificate_key {certificates_path}/{certificate}/privkey.pem;", + "\t", + "{options}", + "{redirection}", + "}}", + ) +) + +SERVER_HTTP_TEMPLATE = "\n".join( + ( + "server {{", + "\tinclude server_common.conf;", + "\t", + "\tserver_name {server_name};", + "{options}", + "{redirection}", + "}}", + ) +) + + +@dataclass +class Server: + """Server entry for nginx.conf file.""" + + server_name: str + redirect: str | None = None + certificate: str | None = None + options: list[str] = field(default_factory=list) + + certificates_path: str = "/etc/letsencrypt/live" + + def __post_init__(self) -> None: + if self.options is None: + self.options = [] + + def format(self, indent: str = " ", base_indent: int = 1) -> str: + """Format server to place inside nginx.conf""" + res = (indent * base_indent) + ( + SERVER_HTTPS_TEMPLATE if self.certificate is not None else SERVER_HTTP_TEMPLATE + ).replace("\n", "\n" + indent * base_indent).replace("\t", indent).format( + server_name=self.server_name, + certificate=self.certificate, + redirection=( + REDIRECT_TEMPLATE.replace("\n", "\n" + indent * base_indent) + .replace("\t", indent) + .format(redirection_host=self.redirect) + if self.redirect is not None + else "" + ), + options=( + ("\n" + indent * (base_indent + 1)) + + ("\n" + indent * (base_indent + 1)).join( + ("\n" + indent * (base_indent + 1)).join(option.split("\n")) for option in self.options + ) + ), + certificates_path=self.certificates_path, + ) + res = "\n".join(line for line in res.split("\n") if line.strip() != "") + return res + + +@dataclass +class CLIParams: + """add_servers CLI parameters""" + + nginx: str + domains_list_txt: str + servers_config_json: str + certificates_path: str + http_only: bool + dry_run: bool + + +def main() -> None: + """Parse arguments and add domains to nginx.conf""" + parser = argparse.ArgumentParser("add-servers", description="Add domain servers to a given nginx.conf file") + parser.add_argument("--nginx", "-f", required=True, help="Path to nginx.conf file to edit inplace") + parser.add_argument( + "--domains_list_txt", + "-d", + required=True, + help="Path to domains list with ssl certificates ", + ) + parser.add_argument( + "--servers_config_json", + "-s", + required=False, + default=None, + help='Path to domains json {"domain": {"redirect": "redirection_path", "options": []}};', + ) + parser.add_argument( + "--certificates_path", + "-c", + required=False, + default="/etc/letsencrypt/live", + help="Path to a directory containing certificates", + ) + parser.add_argument( + "--http-only", + action="store_true", + help="Remove certificates usage from servers section", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print servers section and quit withot making changes", + ) + + args: CLIParams = parser.parse_args() + + replacement_substring = "# " + + with open(args.nginx, "r", encoding="utf-8") as file: + nginx_config = file.read() + if nginx_config.count(replacement_substring) != 1: + print( + f"Error. File must contain exactly one '{replacement_substring}' substring to replace with servers. Exiting" + ) + sys.exit(1) + + if args.domains_list_txt is not None: + with open(args.domains_list_txt, "r", encoding="utf-8") as file: + domains_certs_list = [ + domain.strip() for domain in file.readlines() if domain.strip() != "" and not domain.startswith("#") + ] + else: + domains_certs_list = [] + + nginx_servers: list[Server] = [] + if args.servers_config_json is not None: + with open(args.servers_config_json, "r", encoding="utf-8") as file: + for server_name, params in json.load(file).items(): + server_name: str + params: dict[str, str] + nginx_servers.append( + Server( + (server_name if "*" not in server_name else f"{server_name} {server_name.replace('*.', '')}"), + params.get("redirect"), + ( + None + if args.http_only + else server_name.replace("*.", "") + if server_name in domains_certs_list + else server_name[server_name.find(".") + 1 :] + if "." in server_name + and f"*.{server_name[server_name.find('.') + 1:]}" in domains_certs_list + else None + ), + params.get("options"), + args.certificates_path, + ) + ) + for domain in domains_certs_list: + if not any( + ( + domain == server.server_name + or ( + domain == f"*.{server.server_name[server.server_name.find('.') + 1:]}" + if "." in server.server_name + else False + ) + ) + for server in nginx_servers + ): + nginx_servers.append( + Server(domain, None, domain if not args.http_only else None, certificates_path=args.certificates_path) + ) + + nginx_servers_part = "\n\n".join(server.format() for server in nginx_servers) + + print(f"Using following servers part for nginx.conf:\n\n{nginx_servers_part}") + + if not args.dry_run: + config_backup_filename = f"{args.nginx}.bak" + print(f"Backing up old nginx config {args.nginx} as {config_backup_filename}") + + with open(config_backup_filename, "w", encoding="utf-8") as file: + file.write(nginx_config) + + with open(args.nginx, "w", encoding="utf-8") as file: + file.write(nginx_config.replace(replacement_substring, nginx_servers_part.lstrip())) + + +if __name__ == "__main__": + main() diff --git a/nginx/domains.txt.example b/nginx/domains.txt.example new file mode 100644 index 0000000..e39f4e1 --- /dev/null +++ b/nginx/domains.txt.example @@ -0,0 +1,3 @@ +your.domain.to_redirect +your.other_domain +#commented.domain \ No newline at end of file diff --git a/nginx/get-certificates.Dockerfile b/nginx/get-certificates.Dockerfile new file mode 100644 index 0000000..c1d269e --- /dev/null +++ b/nginx/get-certificates.Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.10-alpine as builder + +COPY add_servers.py /add_servers.py +COPY domains.txt /domains.txt +COPY nginx.conf /nginx.conf + +RUN python /add_servers.py --nginx /nginx.conf --domains_list_txt /domains.txt --http-only + +FROM nginx:alpine + +COPY proxy_common.conf /etc/nginx/proxy_common.conf +COPY server_common.conf /etc/nginx/server_common.conf + +COPY --from=builder /nginx.conf /etc/nginx/nginx.conf +COPY domains.txt /domains.txt + +RUN echo "(sleep 30 && killall nginx) &" > /entrypoint && \ + echo "cp /domains.txt /ssl/domains.txt" >> /entrypoint && \ + echo "nginx -g 'daemon off;'" >> /entrypoint && \ + echo "nginx" >> /entrypoint + +ENTRYPOINT ["/bin/sh"] +CMD ["/entrypoint"] diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..3eeee77 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,27 @@ +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + server_tokens off; + gzip on; + proxy_connect_timeout 300; + proxy_send_timeout 600; + proxy_read_timeout 600; + send_timeout 600; + client_max_body_size 1100M; + + server { + return 404; + } + + # +} diff --git a/nginx/proxy_common.conf b/nginx/proxy_common.conf new file mode 100644 index 0000000..41f4005 --- /dev/null +++ b/nginx/proxy_common.conf @@ -0,0 +1,3 @@ + proxy_set_header Host $host:$server_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; diff --git a/nginx/server_common.conf b/nginx/server_common.conf new file mode 100644 index 0000000..036cf8a --- /dev/null +++ b/nginx/server_common.conf @@ -0,0 +1,6 @@ + listen 80; + keepalive_timeout 70; + + location /.well-known/acme-challenge { + root /ssl/; + } diff --git a/nginx/servers.json.example b/nginx/servers.json.example new file mode 100644 index 0000000..dd5fa79 --- /dev/null +++ b/nginx/servers.json.example @@ -0,0 +1,8 @@ +{ + "server.name": { + "redirect": "http://local.address", + "options": [ + "other_server_level_nginx_options here;" + ] + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..140c9fc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.pylint] +max-line-length = 120 + +[tool.black] +line-length = 120 \ No newline at end of file diff --git a/run_once.d-c.yml b/run_once.d-c.yml new file mode 100644 index 0000000..802014d --- /dev/null +++ b/run_once.d-c.yml @@ -0,0 +1,30 @@ +version: '3.4' + +services: + certbot: + container_name: "certbot_get_certificates" + build: + context: ./certbot + args: + EMAIL: ${EMAIL} + volumes: + - ssl:/ssl + - letsencrypt:/etc/letsencrypt + command: ["/run_once"] + depends_on: + - nginx + + nginx: + container_name: "nginx-get-certificates" + build: + context: ./nginx + dockerfile: get-certificates.Dockerfile + volumes: + - ssl:/ssl + - letsencrypt:/etc/letsencrypt + ports: + - 80:80 + +volumes: + ssl: + letsencrypt: diff --git a/run_once.sh b/run_once.sh new file mode 100755 index 0000000..105367a --- /dev/null +++ b/run_once.sh @@ -0,0 +1,3 @@ +#!/bin/sh +docker compose -f run_once.d-c.yml up --build +docker compose -f run_once.d-c.yml rm nginx certbot --force