Initial commit

Add nginx and certbot configurations
This commit is contained in:
2023-10-09 09:48:30 +03:00
commit 7af75ad55d
17 changed files with 468 additions and 0 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
EMAIL=your@email.com

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/nginx/domains.txt
/nginx/servers.json
*.env

33
README.md Normal file
View File

@@ -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).

35
certbot/Dockerfile Normal file
View File

@@ -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"]

14
certbot_manual.sh Executable file
View File

@@ -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

38
docker-compose.yml Normal file
View File

@@ -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

27
nginx/Dockerfile Normal file
View File

@@ -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"]

209
nginx/add_servers.py Normal file
View File

@@ -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 = "# <place_for_servers>"
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()

View File

@@ -0,0 +1,3 @@
your.domain.to_redirect
your.other_domain
#commented.domain

View File

@@ -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"]

27
nginx/nginx.conf Normal file
View File

@@ -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;
}
# <place_for_servers>
}

3
nginx/proxy_common.conf Normal file
View File

@@ -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;

6
nginx/server_common.conf Normal file
View File

@@ -0,0 +1,6 @@
listen 80;
keepalive_timeout 70;
location /.well-known/acme-challenge {
root /ssl/;
}

View File

@@ -0,0 +1,8 @@
{
"server.name": {
"redirect": "http://local.address",
"options": [
"other_server_level_nginx_options here;"
]
}
}

5
pyproject.toml Normal file
View File

@@ -0,0 +1,5 @@
[tool.pylint]
max-line-length = 120
[tool.black]
line-length = 120

30
run_once.d-c.yml Normal file
View File

@@ -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:

3
run_once.sh Executable file
View File

@@ -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