diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 35dcd90..8465c7c 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -15,16 +15,16 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v4 - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: username: mesudip password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -51,7 +51,7 @@ jobs: - name: Build and push for tags if: startsWith(github.ref, 'refs/tags/') - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: file: Dockerfile context: . @@ -63,7 +63,7 @@ jobs: - name: Build and push for main branch if: github.ref == 'refs/heads/master' - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: file: Dockerfile context: . diff --git a/.github/workflows/publish-python-package.yml b/.github/workflows/publish-python-package.yml index 946f57a..818297a 100644 --- a/.github/workflows/publish-python-package.yml +++ b/.github/workflows/publish-python-package.yml @@ -15,10 +15,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 51b76a0..9d44a67 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -15,22 +15,25 @@ on: jobs: test: runs-on: ubuntu-latest + concurrency: + group: test-${{ github.event.pull_request.head.sha || github.sha }} + cancel-in-progress: true permissions: contents: read steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: python-version: '3.12' - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v4 - name: Install dependencies run: | @@ -41,16 +44,113 @@ jobs: - name: Run tests run: | - pytest --cov --cov-branch --junitxml=junit.xml -o junit_family=legacy + pytest --cov --cov-branch --cov-report=xml:coverage.xml --junitxml=junit.xml -o junit_family=legacy + + - name: Write test summary + if: ${{ always() }} + run: | + python - <<'PY' + import os + import sys + import xml.etree.ElementTree as ET + from pathlib import Path + + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + sys.exit(0) + + junit_path = Path("junit.xml") + + def md_escape(value): + return str(value).replace("|", "\\|").replace("\n", "
") + + lines = ["## Test summary", ""] + + if not junit_path.exists(): + lines.extend([ + "No `junit.xml` test report was generated.", + "", + ]) + else: + root = ET.parse(junit_path).getroot() + suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) + if not suites: + suites = [root] + + totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0, "time": 0.0} + for suite in suites: + totals["tests"] += int(suite.attrib.get("tests", 0)) + totals["failures"] += int(suite.attrib.get("failures", 0)) + totals["errors"] += int(suite.attrib.get("errors", 0)) + totals["skipped"] += int(suite.attrib.get("skipped", 0)) + totals["time"] += float(suite.attrib.get("time", 0.0)) + + passed = totals["tests"] - totals["failures"] - totals["errors"] - totals["skipped"] + lines.extend([ + "| Total | Passed | Failed | Errors | Skipped | Duration |", + "| ---: | ---: | ---: | ---: | ---: | ---: |", + f"| {totals['tests']} | {passed} | {totals['failures']} | {totals['errors']} | {totals['skipped']} | {totals['time']:.2f}s |", + "", + "### Metadata", + "", + "| Key | Value |", + "| --- | --- |", + f"| Python | {md_escape(sys.version.split()[0])} |", + f"| Runner OS | {md_escape(os.environ.get('RUNNER_OS', 'unknown'))} |", + f"| Event | {md_escape(os.environ.get('GITHUB_EVENT_NAME', 'unknown'))} |", + f"| Ref | {md_escape(os.environ.get('GITHUB_REF_NAME', 'unknown'))} |", + f"| SHA | `{md_escape(os.environ.get('GITHUB_SHA', 'unknown'))}` |", + f"| Actor | {md_escape(os.environ.get('GITHUB_ACTOR', 'unknown'))} |", + "", + ]) + + failed_cases = [] + for case in root.iter("testcase"): + issue = case.find("failure") + if issue is None: + issue = case.find("error") + if issue is not None: + failed_cases.append((case.attrib, issue)) + + if failed_cases: + lines.extend([ + "### Failed tests", + "", + "| Test | Type | Message |", + "| --- | --- | --- |", + ]) + for attrs, issue in failed_cases[:25]: + classname = attrs.get("classname", "") + name = attrs.get("name", "unknown") + test_name = f"{classname}.{name}" if classname else name + message = issue.attrib.get("message", "").strip() or (issue.text or "").strip().splitlines()[0:1] + if isinstance(message, list): + message = message[0] if message else "" + lines.append(f"| `{md_escape(test_name)}` | {md_escape(issue.tag)} | {md_escape(message[:300])} |") + if len(failed_cases) > 25: + lines.append(f"| ... | ... | {len(failed_cases) - 25} more failing tests omitted from summary |") + lines.append("") + + with open(summary_path, "a", encoding="utf-8") as summary: + summary.write("\n".join(lines)) + summary.write("\n") + PY - name: Upload coverage reports to Codecov + if: ${{ !cancelled() }} uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + disable_search: true fail_ci_if_error: false - name: Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + files: junit.xml + disable_search: true + report_type: test_results + fail_ci_if_error: false diff --git a/Dockerfile b/Dockerfile index 27e5736..14219b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# mesudip/python-nginx:alpine is merge of official python and nginx images. -FROM mesudip/python-nginx +# This provides nginx and python together in a container +FROM ghcr.io/mesudip/python-nginx:py3.13.13-nginx1.30.1-alpine3.23 RUN pip install --upgrade pip @@ -11,7 +11,7 @@ RUN apk --no-cache add openssl && \ gcc libc-dev openssl-dev linux-headers libffi-dev && \ pip install --no-cache-dir -r /requirements.txt && \ rm -f /requirements.txt && apk del .build-deps && \ - ln -s /app/getssl /bin/getssl && ln -s /app/verify /bin/verify && \ + ln -s /app/getssl /bin/getssl && ln -s /app/verify /bin/verify && ln -s /app/reload /bin/reload && \ mv /docker-entrypoint.sh /nginx-entrypoint.sh && \ ln -s /app/docker-entrypoint.sh /docker-entrypoint.sh RUN rm -rf /var/log/nginx/* && chown nginx:nginx /var/log/nginx && truncate -s 0 /etc/nginx/conf.d/default.conf @@ -19,7 +19,6 @@ COPY ./vhosts_template/nginx.conf /etc/nginx/nginx.conf ARG LETSENCRYPT_API="https://acme-v02.api.letsencrypt.org/directory" ENV LETSENCRYPT_API=${LETSENCRYPT_API} \ CHALLENGE_DIR=/etc/nginx/challenges/ \ - DHPARAM_SIZE=2048 \ CLIENT_MAX_BODY_SIZE=1m \ NGINX_WORKER_PROCESSES=auto \ NGINX_WORKER_CONNECTIONS=65535 \ @@ -27,4 +26,4 @@ ENV LETSENCRYPT_API=${LETSENCRYPT_API} \ DEFAULT_HOST=true \ VHOSTS_TEMPLATE_DIR=/app/vhosts_template WORKDIR /app -COPY . /app/ \ No newline at end of file +COPY . /app/ diff --git a/README.md b/README.md index bf4f53d..a2de884 100644 --- a/README.md +++ b/README.md @@ -85,12 +85,13 @@ Control the default behavior of `nginx-proxy`: | `NGINX_WORKER_CONNECTIONS` | `65535` | Max connections per worker. | | `CERT_RENEW_THRESHOLD_DAYS` | `30` | By default certificates are renewed when they have <=30 days remaining. | | `ENABLE_IPV6` | `false` | Enable IPv6 support on nginx. | -| `DOCKER_SWARM` | `ignore` | Treats every container like local by defeault. Set `enable` for Swarm support, `strict` for Swarm-only or`exclude` to not include swarm containers | +| `DOCKER_SWARM` | `ignore` | Controls Docker Swarm discovery. Supported values are `ignore`, `exclude`, `enable`, `prefer-local`, and `strict`; see [Docker Swarm Support](#docker-swarm-support-preview). | | `SWARM_DOCKER_HOST` | - | URL of the Swarm manager socket (e.g., `tcp://manager:2375`). | | `CERTAPI_URL` | - | External Certificate API URL. | | `CERTAPI_BATCH_DOMAINS` | `true` | When using `CERTAPI_URL`, request safe domain batching (`batch_domains=true`) to avoid recursive domain-order errors. | | `CHALLENGE_DIR` | `/etc/nginx/challenges/` | Base directory for acme challenge store, when requesting certificates with acme. `.well-known/acme-challenge` folder lives inside this.| | `CLOUDFLARE_API_KEY_KEY*` | - | Cloudflare api keys to issue DNS certificates.| +| `BACKEND_START_GRACE_SECONDS` | `10` | Delay registering containers without a Docker healthcheck so crashing backends dont' result reload| ## Virtual Hosts @@ -138,10 +139,39 @@ Format: `STATIC_VIRTUAL_HOST=domain.com->http://192.168.0.1:8080`. **Note** Be aware that if domain as target, nginx will crash if DNS resolution fails. ## Docker Swarm Support [Preview] -Enable swarm mode by setting `DOCKER_SWARM` to `enable` (local & swarm) or `strict` (swarm only). -If current node is not manager, set `SWARM_DOCKER_HOST=tcp://manager:2375`. -**Warning** : Automatic exposed port detection will not work when swarm support is enabled. You must explicitly set port on the `VIRTUAL_HOST` or set `VIRTUAL_PORT` on the container. +**Warning** : Automatic exposed port detection will not work when swarm support is enabled. You must explicitly set port on the `VIRTUAL_HOST`. + + +Docker Swarm discovery is controlled by the `DOCKER_SWARM` environment variable on the `nginx-proxy` container. + +| `DOCKER_SWARM` value | Local containers | Swarm services | Use case | +| :--- | :--- | :--- | :--- | +| `ignore` | Included | Not discovered | Default Docker-only behavior. Swarm task containers are treated like standalone containers if they are visible on the local Docker socket. | +| `exclude` | Included | Not discovered | Docker-only discovery while explicitly ignoring containers that belong to Swarm services. | +| `enable` | Included | Included | Mixed mode. Use this when `nginx-proxy` should route both standalone containers and Swarm services. | +| `prefer-local` | Included | Included | Mixed Swarm mode that prefers healthy local task containers and keeps the service VIP as a fallback. | +| `strict` | Excluded | Included | Swarm-only mode. Use this when `nginx-proxy` should route only Swarm services. | + +`ignore` is the default and does not require the Docker node to be in Swarm mode. In this mode, `nginx-proxy` only reads the normal Docker container API. If a Swarm task container is visible on the local Docker socket, it can be registered as if it were a regular container. + +`exclude` still uses only the local Docker container API, but skips containers that have Swarm service labels. This is useful when the same Docker host runs standalone containers and Swarm services, but this proxy instance should only manage standalone containers. + +`enable` reads both local containers and Swarm services. Standalone containers are discovered from the local Docker socket. Swarm services are discovered from the Swarm manager API, and task containers are skipped so each service is registered once. + +`prefer-local` reads both local containers and Swarm services, but local Swarm task containers are also discovered from the local Docker socket. When a route has local containers and the Swarm service VIP, nginx sends normal traffic to the local containers and marks the service VIP as a `backup` upstream server. If no local container is available, the service VIP is used normally. Existing container healthcheck and `BACKEND_START_GRACE_SECONDS` behavior still applies before local containers are registered. + +`strict` reads only Swarm services. Local standalone containers are ignored, and Swarm task containers are also ignored. This is the mode to use when this proxy instance is dedicated to Swarm routing. + +For `enable`, `prefer-local`, and `strict`, the Swarm API client must be connected to a manager node because Docker only allows managers to list services. If `nginx-proxy` is running on a worker node, set `SWARM_DOCKER_HOST` to a reachable manager Docker API endpoint: + +```bash +-e DOCKER_SWARM=enable \ +-e SWARM_DOCKER_HOST=tcp://manager:2375 +``` + +If `SWARM_DOCKER_HOST` is not set, the local Docker socket is used for both local containers and Swarm services. When `SWARM_DOCKER_HOST` is set, `nginx-proxy` uses the local Docker socket for standalone containers and the remote manager socket for Swarm services. If the local Docker socket cannot be reached but `SWARM_DOCKER_HOST` is set, `nginx-proxy` switches to `strict` mode and uses only the remote Swarm manager. + ## Advanced Features ### Redirection @@ -220,6 +250,8 @@ docker exec nginx-proxy verify www.example.com ## check if request routes back t docker exec nginx-proxy getssl www.example.com example.com www2.example.com # issue certificate +docker exec nginx-proxy reload # rescan Docker state and reload nginx config + ``` ## 🚀 Roadmap diff --git a/dev-requirements.txt b/dev-requirements.txt index 51713c3..6289d39 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,4 @@ -pytest==8.2.2 +pytest==9.0.3 pytest-cov websocket-client python-dotenv diff --git a/docker/entry-point.sh b/docker/entry-point.sh deleted file mode 100644 index 0c9feb4..0000000 --- a/docker/entry-point.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env ash -python /app/main.py \ No newline at end of file diff --git a/docker/nginx.conf b/docker/nginx.conf deleted file mode 100644 index 26be568..0000000 --- a/docker/nginx.conf +++ /dev/null @@ -1,76 +0,0 @@ - -user nginx; -worker_processes auto; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - - -events { - worker_connections 1024; -} - - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$host $remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - #tcp_nopush on; - - keepalive_timeout 65; - - gzip on; - - # If we receive X-Forwarded-Proto, pass it through; otherwise, pass along the - # scheme used to connect to this server - map $http_x_forwarded_proto $proxy_x_forwarded_proto { - default $http_x_forwarded_proto; - '' $scheme; - } - # If we receive X-Forwarded-Port, pass it through; otherwise, pass along the - # server port the client connected to - map $http_x_forwarded_port $proxy_x_forwarded_port { - default $http_x_forwarded_port; - '' $server_port; - } - # If we receive Upgrade, set Connection to "upgrade"; otherwise, delete any - # Connection header that may have been passed to this server - #map $http_upgrade $proxy_connection { - # default upgrade; - # '' close; - #} - # Default dhparam - ssl_dhparam /etc/nginx/dhparam/dhparam.pem; - # Set appropriate X-Forwarded-Ssl header - map $scheme $proxy_x_forwarded_ssl { - default off; - https on; - } - gzip_types text/plain text/css application/javascript application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; - - - # HTTP 1.1 support - proxy_http_version 1.1; - proxy_buffering off; - proxy_set_header Host $http_host; - #proxy_set_header Upgrade $http_upgrade; - #proxy_set_header Connection $proxy_connection; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - proxy_set_header X-Forwarded-Ssl $proxy_x_forwarded_ssl; - proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; - - proxy_set_header Proxy ""; - - - include /etc/nginx/conf.d/*.conf; - -} \ No newline at end of file diff --git a/docker/python-nginx-alpine.Dockerfile b/docker/python-nginx-alpine.Dockerfile deleted file mode 100644 index 5087969..0000000 --- a/docker/python-nginx-alpine.Dockerfile +++ /dev/null @@ -1,124 +0,0 @@ -FROM nginx:1.27.3-alpine3.20 - -COPY nginx.conf /etc/nginx/nginx.conf - -# copied from https://github.com/docker-library/python/blob/3d7b328b66525fe2e82af7063af10c176b6ee8cd/3.13/alpine3.21/Dockerfile -RUN set -eux; \ - apk add --no-cache \ - ca-certificates \ - tzdata \ - ; - -ENV GPG_KEY 7169605F62C751356D054A26A821E680E5FA6305 -ENV PYTHON_VERSION 3.13.1 -ENV PYTHON_SHA256 9cf9427bee9e2242e3877dd0f6b641c1853ca461f39d6503ce260a59c80bf0d9 - -RUN set -eux; \ - \ - apk add --no-cache --virtual .build-deps \ - gnupg \ - tar \ - xz \ - \ - bluez-dev \ - bzip2-dev \ - dpkg-dev dpkg \ - findutils \ - gcc \ - gdbm-dev \ - libc-dev \ - libffi-dev \ - libnsl-dev \ - libtirpc-dev \ - linux-headers \ - make \ - ncurses-dev \ - openssl-dev \ - pax-utils \ - readline-dev \ - sqlite-dev \ - tcl-dev \ - tk \ - tk-dev \ - util-linux-dev \ - xz-dev \ - zlib-dev \ - ; \ - \ - wget -O python.tar.xz "https://www.python.org/ftp/python/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz"; \ - echo "$PYTHON_SHA256 *python.tar.xz" | sha256sum -c -; \ - wget -O python.tar.xz.asc "https://www.python.org/ftp/python/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz.asc"; \ - GNUPGHOME="$(mktemp -d)"; export GNUPGHOME; \ - gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$GPG_KEY"; \ - gpg --batch --verify python.tar.xz.asc python.tar.xz; \ - gpgconf --kill all; \ - rm -rf "$GNUPGHOME" python.tar.xz.asc; \ - mkdir -p /usr/src/python; \ - tar --extract --directory /usr/src/python --strip-components=1 --file python.tar.xz; \ - rm python.tar.xz; \ - \ - cd /usr/src/python; \ - gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"; \ - ./configure \ - --build="$gnuArch" \ - --enable-loadable-sqlite-extensions \ - --enable-option-checking=fatal \ - --enable-shared \ - --with-lto \ - --with-ensurepip \ - ; \ - nproc="$(nproc)"; \ -# set thread stack size to 1MB so we don't segfault before we hit sys.getrecursionlimit() -# https://github.com/alpinelinux/aports/commit/2026e1259422d4e0cf92391ca2d3844356c649d0 - EXTRA_CFLAGS="-DTHREAD_STACK_SIZE=0x100000"; \ - LDFLAGS="${LDFLAGS:--Wl},--strip-all"; \ - make -j "$nproc" \ - "EXTRA_CFLAGS=${EXTRA_CFLAGS:-}" \ - "LDFLAGS=${LDFLAGS:-}" \ - ; \ -# https://github.com/docker-library/python/issues/784 -# prevent accidental usage of a system installed libpython of the same version - rm python; \ - make -j "$nproc" \ - "EXTRA_CFLAGS=${EXTRA_CFLAGS:-}" \ - "LDFLAGS=${LDFLAGS:--Wl},-rpath='\$\$ORIGIN/../lib'" \ - python \ - ; \ - make install; \ - \ - cd /; \ - rm -rf /usr/src/python; \ - \ - find /usr/local -depth \ - \( \ - \( -type d -a \( -name test -o -name tests -o -name idle_test \) \) \ - -o \( -type f -a \( -name '*.pyc' -o -name '*.pyo' -o -name 'libpython*.a' \) \) \ - \) -exec rm -rf '{}' + \ - ; \ - \ - find /usr/local -type f -executable -not \( -name '*tkinter*' \) -exec scanelf --needed --nobanner --format '%n#p' '{}' ';' \ - | tr ',' '\n' \ - | sort -u \ - | awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \ - | xargs -rt apk add --no-network --virtual .python-rundeps \ - ; \ - apk del --no-network .build-deps; \ - \ - export PYTHONDONTWRITEBYTECODE=1; \ - python3 --version; \ - pip3 --version - -# make some useful symlinks that are expected to exist ("/usr/local/bin/python" and friends) -RUN set -eux; \ - for src in idle3 pip3 pydoc3 python3 python3-config; do \ - dst="$(echo "$src" | tr -d 3)"; \ - [ -s "/usr/local/bin/$src" ]; \ - [ ! -e "/usr/local/bin/$dst" ]; \ - ln -svT "$src" "/usr/local/bin/$dst"; \ - done - - -EXPOSE 80 - -STOPSIGNAL SIGTERM - diff --git a/main.py b/main.py index a0cd6be..906b36f 100644 --- a/main.py +++ b/main.py @@ -17,9 +17,14 @@ def receiveSignal(signalNumber, frame): app.stop() app = None sys.exit(0) + if signalNumber == signal.SIGHUP: + print("\nReload Requested") + if app is not None: + app.reload() signal.signal(signal.SIGTERM, receiveSignal) +signal.signal(signal.SIGHUP, receiveSignal) def setup_debug_mode(): diff --git a/nginx/Nginx.py b/nginx/Nginx.py index ca5224b..b68238e 100644 --- a/nginx/Nginx.py +++ b/nginx/Nginx.py @@ -107,19 +107,19 @@ def _parse_error_line(self, error_msg): # Try to find the specific error line for the config file we are managing config_filename = os.path.basename(self.config_file_path) escaped_filename = re.escape(config_filename) - + # Search for: filename:line_number match = re.search(f"{escaped_filename}:(\\d+)", error_msg) if match: return int(match.group(1)) - + # Fallback: Search for any line number pattern usually at end of line in Nginx errors lines = error_msg.splitlines() for line in lines: if "emerg" in line or "error" in line: - match = re.search(r':(\d+)(?:\s|$)', line) - if match: - return int(match.group(1)) + match = re.search(r":(\d+)(?:\s|$)", line) + if match: + return int(match.group(1)) return None def _print_error_context(self, config_str, line_no): @@ -130,10 +130,10 @@ def _print_error_context(self, config_str, line_no): return False print(f"Error Location in Config (Line {line_no}):", file=sys.stderr) - - start_idx = max(0, line_no - 6) + + start_idx = max(0, line_no - 6) end_idx = min(total_lines, line_no + 5) - + for i in range(start_idx, end_idx): current_line = i + 1 marker = ">>" if current_line == line_no else " " diff --git a/nginx/Url.py b/nginx/Url.py index 95921b3..2da4166 100644 --- a/nginx/Url.py +++ b/nginx/Url.py @@ -36,16 +36,20 @@ def parse(entry_string: str, default_scheme=None, default_port=None, default_loc return Url(scheme, host if host else None, port, location) @staticmethod - def is_valid_hostname(hostname: str) -> bool: + def is_valid_hostname(hostname: str, allow_wildcard: bool = False, max_length: int = 253) -> bool: """ https://stackoverflow.com/a/33214423/2804342 :return: True if for valid hostname False otherwise """ + if not hostname: + return False if hostname[-1] == ".": # strip exactly one dot from the right, if present hostname = hostname[:-1] - if len(hostname) > 253: + if len(hostname) > max_length: return False + if allow_wildcard and hostname.startswith("*."): + hostname = hostname[2:] labels = hostname.split(".") diff --git a/nginx_proxy/BackendTarget.py b/nginx_proxy/BackendTarget.py index 7df82a4..ba2728d 100644 --- a/nginx_proxy/BackendTarget.py +++ b/nginx_proxy/BackendTarget.py @@ -17,6 +17,7 @@ def __init__( network_settings: dict = None, ports: dict = None, backend_type: str = "container", + backup: bool = False, ): self.name = name self.id = id @@ -30,6 +31,7 @@ def __init__( self.network_settings = network_settings if network_settings else {} self.ports = ports if ports else {} self.type = backend_type + self.backup = backup @staticmethod def from_container(container: DockerContainer): @@ -131,3 +133,10 @@ def __init__(self, network_names: List[str], backend_type="container"): class NoHostConfiguration(UnconfiguredBackend): def __init__(self, backend_type="container"): super().__init__(backend_type) + + +class InvalidHostConfiguration(UnconfiguredBackend): + def __init__(self, hostname: str, reason: str, backend_type="container"): + super().__init__(backend_type) + self.hostname = hostname + self.reason = reason diff --git a/nginx_proxy/DockerEventListener.py b/nginx_proxy/DockerEventListener.py index 65eb2ef..503afa9 100644 --- a/nginx_proxy/DockerEventListener.py +++ b/nginx_proxy/DockerEventListener.py @@ -1,9 +1,12 @@ import os +import queue import re import subprocess import sys import threading import traceback +from dataclasses import dataclass +from typing import Any import docker @@ -11,6 +14,68 @@ from nginx_proxy.WebServer import WebServer +SERVICE_EVENT_DELAY_SECONDS = 5 +SERVICE_EVENT_RETRY_DELAY_SECONDS = 20 +SERVICE_EVENT_MAX_ATTEMPTS = 2 + + +@dataclass(frozen=True) +class ServiceEvent: + action: str + event: dict[str, Any] + + +@dataclass(frozen=True) +class ContainerEvent: + action: str + event: dict[str, Any] + + +@dataclass(frozen=True) +class ContainerHealthEvent: + action: str + event: dict[str, Any] + + +@dataclass(frozen=True) +class NetworkEvent: + action: str + event: dict[str, Any] + + +@dataclass(frozen=True) +class ActivateBackend: + container_id: str + generation: int + + +@dataclass(frozen=True) +class ProcessServiceUpsert: + service_id: str + action: str + attempt: int + generation: int + + +@dataclass(frozen=True) +class RemoveBackend: + backend_id: str + + +@dataclass(frozen=True) +class RescanAndReload: + force: bool = False + bypass_start_grace: bool = True + + +@dataclass(frozen=True) +class Reload: + force: bool = False + + +_STOP = object() + + class DockerEventListener: def __init__( self, @@ -21,26 +86,126 @@ def __init__( self.web_server = web_server self.client = docker_client self.swarm_client = swarm_client if swarm_client is not None else docker_client - self.lock = threading.Lock() + self._command_queue: queue.Queue = queue.Queue() + self._dispatcher_thread: threading.Thread | None = None + self._dispatcher_stop = threading.Event() + self._dispatcher_thread_id: int | None = None + self._pending_backend_timers: dict[str, threading.Timer] = {} + self._pending_backend_generations: dict[str, int] = {} + self._pending_service_timers: dict[str, threading.Timer] = {} + self._pending_service_generations: dict[str, int] = {} + self._waiting_for_healthy: set[str] = set() + self._started_containers: set[str] = self._load_started_container_ids() + self.web_server.docker_event_listener = self - def run(self): - swarm_mode = self.web_server.config.get("docker_swarm", "ignore") - if self.client == self.swarm_client: - self._listen(self.client) + def start_dispatcher(self): + if self.is_dispatcher_running(): + return + self._dispatcher_stop.clear() + self.web_server.set_reload_dispatcher(self.enqueue, self.is_dispatcher_thread) + self._dispatcher_thread = threading.Thread(target=self._dispatch_loop, daemon=True) + self._dispatcher_thread.start() + + def stop_dispatcher(self): + self._dispatcher_stop.set() + self._cancel_all_timers() + self._command_queue.put(_STOP) + if self._dispatcher_thread is not None: + self._dispatcher_thread.join(timeout=5) + self._dispatcher_thread = None + self._dispatcher_thread_id = None + self.web_server.set_reload_dispatcher(None, None) + + def is_dispatcher_running(self) -> bool: + return self._dispatcher_thread is not None and self._dispatcher_thread.is_alive() + + def is_dispatcher_thread(self) -> bool: + return self._dispatcher_thread_id == threading.get_ident() + + def enqueue(self, command): + self._command_queue.put(command) + return True + + def drain_commands(self, limit: int = 100): + processed = 0 + while processed < limit: + try: + command = self._command_queue.get_nowait() + except queue.Empty: + return processed + if command is _STOP: + self._command_queue.task_done() + continue + self._dispatch(command) + self._command_queue.task_done() + processed += 1 + return processed + + def _dispatch_loop(self): + self._dispatcher_thread_id = threading.get_ident() + while True: + command = self._command_queue.get() + try: + if command is _STOP: + return + self._dispatch(command) + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + print("Unexpected dispatcher error :" + e.__class__.__name__ + " -> " + str(e), file=sys.stderr) + traceback.print_exc(limit=10) + finally: + self._command_queue.task_done() + + def _dispatch(self, command): + if isinstance(command, ServiceEvent): + self._process_service_event(command.action, command.event) + elif isinstance(command, ContainerEvent): + self._process_container_event(command.action, command.event) + elif isinstance(command, ContainerHealthEvent): + self._process_container_health_event(command.action, command.event) + elif isinstance(command, NetworkEvent): + self._process_network_event(command.action, command.event) + elif isinstance(command, ActivateBackend): + self._process_backend_activation(command.container_id, command.generation) + elif isinstance(command, ProcessServiceUpsert): + self._process_scheduled_service_upsert( + command.service_id, command.action, command.attempt, command.generation + ) + elif isinstance(command, RemoveBackend): + self.web_server.remove_backend(command.backend_id) + elif isinstance(command, RescanAndReload): + self.web_server.rescan_all_container(bypass_start_grace=command.bypass_start_grace) + self.web_server._do_reload(command.force) + elif isinstance(command, Reload): + self.web_server._do_reload(command.force) + elif callable(command): + command() else: - threads = [] - if swarm_mode != "strict" and self.client is not None: - t1 = threading.Thread(target=self._listen, args=(self.client,), daemon=True) - t1.start() - threads.append(t1) + raise ValueError(f"Unknown DockerEventListener command: {command!r}") + + def run(self): + self.start_dispatcher() + try: + swarm_mode = self.web_server.config.get("docker_swarm", "ignore") + if self.client == self.swarm_client: + self._listen(self.client) + else: + threads = [] + if swarm_mode != "strict" and self.client is not None: + t1 = threading.Thread(target=self._listen, args=(self.client,), daemon=True) + t1.start() + threads.append(t1) - if swarm_mode in ("enable", "strict") and self.swarm_client is not None: - t2 = threading.Thread(target=self._listen, args=(self.swarm_client,), daemon=True) - t2.start() - threads.append(t2) + if swarm_mode in ("enable", "prefer-local", "strict") and self.swarm_client is not None: + t2 = threading.Thread(target=self._listen, args=(self.swarm_client,), daemon=True) + t2.start() + threads.append(t2) - for t in threads: - t.join() + for t in threads: + t.join() + finally: + self.stop_dispatcher() def _listen(self, client): client_url = getattr(getattr(client, "api", None), "base_url", "unknown") @@ -50,7 +215,7 @@ def _listen(self, client): types = [] events = ["health_status"] # common events - if client == self.swarm_client and swarm_mode in ("enable", "strict"): + if client == self.swarm_client and swarm_mode in ("enable", "prefer-local", "strict"): types.append("service") events.extend(["create", "update", "remove"]) @@ -74,17 +239,15 @@ def _listen(self, client): eventType = event.get("Type") eventAction = event.get("Action") - with self.lock: - if eventType == "service": - self._process_service_event(eventAction, event) - elif eventType == "network": - self._process_network_event(eventAction, event) - elif eventType == "container": - if eventAction == "health_status": - # self._process_container_health_event(event) - continue - else: - self._process_container_event(eventAction, event) + if eventType == "service": + self.enqueue(ServiceEvent(eventAction, event)) + elif eventType == "network": + self.enqueue(NetworkEvent(eventAction, event)) + elif eventType == "container": + if eventAction and eventAction.startswith("health_status"): + self.enqueue(ContainerHealthEvent(eventAction, event)) + else: + self.enqueue(ContainerEvent(eventAction, event)) except (KeyboardInterrupt, SystemExit): raise @@ -96,41 +259,237 @@ def _listen(self, client): def _process_service_event(self, action, event): service_id = event.get("Actor", {}).get("ID") or event.get("id") if action in ("create", "update"): - try: - service = self.swarm_client.services.get(service_id) - backend = BackendTarget.from_service(service) - self.web_server.update_backend(backend) - except docker.errors.NotFound: - print(f"WARN: Service {service_id} not found ...", file=sys.stderr) - except (KeyboardInterrupt, SystemExit): - raise - except Exception as e: - print(f"Error processing service event {action} for {service_id}: {e}", file=sys.stderr) + self._schedule_service_processing(service_id, action, SERVICE_EVENT_DELAY_SECONDS, attempt=1) elif action == "remove": + self._cancel_pending_service_processing(service_id) self.web_server.remove_backend(service_id) + def _schedule_service_processing(self, service_id: str, action: str, delay_seconds: float, attempt: int): + self._cancel_pending_service_processing(service_id) + generation = self._pending_service_generations.get(service_id, 0) + 1 + self._pending_service_generations[service_id] = generation + + def process(): + self.enqueue(ProcessServiceUpsert(service_id, action, attempt, generation)) + + timer = threading.Timer(delay_seconds, process) + timer.daemon = True + self._pending_service_timers[service_id] = timer + timer.start() + + def _cancel_pending_service_processing(self, service_id: str): + timer = self._pending_service_timers.pop(service_id, None) + if timer is not None: + timer.cancel() + self._pending_service_generations.pop(service_id, None) + + def _cancel_all_timers(self): + for timer in self._pending_backend_timers.values(): + timer.cancel() + for timer in self._pending_service_timers.values(): + timer.cancel() + self._pending_backend_timers.clear() + self._pending_backend_generations.clear() + self._pending_service_timers.clear() + self._pending_service_generations.clear() + + def _process_scheduled_service_upsert(self, service_id: str, action: str, attempt: int, generation: int): + if self._pending_service_generations.get(service_id) != generation: + return + self._pending_service_timers.pop(service_id, None) + self._pending_service_generations.pop(service_id, None) + self._process_service_upsert(service_id, action, attempt) + + def _process_service_upsert(self, service_id: str, action: str, attempt: int): + try: + service = self.swarm_client.services.get(service_id) + backend = BackendTarget.from_service(service) + if not self._service_backend_has_reachable_vip(backend) and self._retry_service_event( + service_id, action, attempt, "has no reachable VIP" + ): + return + self.web_server.update_backend(backend) + except docker.errors.NotFound: + if not self._retry_service_event(service_id, action, attempt, "not found"): + print(f"WARN: Service {service_id} not found ...", file=sys.stderr) + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + print(f"Error processing service event {action} for {service_id}: {e}", file=sys.stderr) + + def _retry_service_event(self, service_id: str, action: str, attempt: int, reason: str) -> bool: + if attempt >= SERVICE_EVENT_MAX_ATTEMPTS: + return False + print( + f"WARN: Service {service_id} {reason}; retrying in {SERVICE_EVENT_RETRY_DELAY_SECONDS}s", + file=sys.stderr, + ) + self._schedule_service_processing( + service_id, + action, + SERVICE_EVENT_RETRY_DELAY_SECONDS, + attempt=attempt + 1, + ) + return True + + def _service_backend_has_reachable_vip(self, backend: BackendTarget) -> bool: + known_networks = set(self.web_server.networks.keys()) + for detail in backend.network_settings.values(): + if detail.get("NetworkID") in known_networks and detail.get("IPAddress"): + return True + return False + def _process_container_event(self, action, event): container_id = event.get("Actor", {}).get("ID") or event.get("id") attributes = event.get("Actor", {}).get("Attributes", {}) - + swarm_mode = self.web_server.config.get("docker_swarm", "ignore") - if swarm_mode != "ignore" and "com.docker.swarm.service.id" in attributes: + if swarm_mode not in ("ignore", "prefer-local") and "com.docker.swarm.service.id" in attributes: # print(f"Skipping event {action} for service task container {container_id}") return if action == "start": - # print("container started", event["id"]) - try: - container = self.client.containers.get(container_id) - backend = BackendTarget.from_container(container) - self.web_server.update_backend(backend) - except (KeyboardInterrupt, SystemExit): - raise - except Exception as e: - print(f"Error processing container event {action} for {container_id}: {e}", file=sys.stderr) + self._started_containers.add(container_id) + self._handle_container_start(container_id, attributes) elif action == "stop" or action == "die" or action == "destroy": + self._started_containers.discard(container_id) + pending_startup = self._clear_pending_startup_state(container_id) + if pending_startup: + self._log_container_event("Container crashed ", container_id, attributes=attributes) self.web_server.remove_backend(container_id) + def _process_container_health_event(self, action, event): + container_id = event.get("Actor", {}).get("ID") or event.get("id") + attributes = event.get("Actor", {}).get("Attributes", {}) + + swarm_mode = self.web_server.config.get("docker_swarm", "ignore") + if swarm_mode not in ("ignore", "prefer-local") and "com.docker.swarm.service.id" in attributes: + return + + health_status = (action or "").strip().lower().removeprefix("health_status:").strip() + if health_status == "healthy": + self._clear_pending_startup_state(container_id) + self._activate_backend_if_running(container_id) + elif health_status == "unhealthy": + self.web_server.remove_backend(container_id) + + def _handle_container_start(self, container_id: str, attributes=None): + try: + container = self.client.containers.get(container_id) + if self._container_has_healthcheck(container): + if self._container_health_status(container) == "healthy": + self._activate_backend_if_running(container_id, container=container) + else: + self._waiting_for_healthy.add(container_id) + self._log_container_event( + "Container waiting ", container_id, container=container, detail="for healthy" + ) + return + + grace_seconds = float(self.web_server.config.get("backend_start_grace_seconds", 0) or 0) + if grace_seconds > 0: + self._schedule_backend_activation(container_id, grace_seconds) + return + + self._activate_backend_if_running(container_id, container=container) + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + print(f"Error processing container event start for {container_id}: {e}", file=sys.stderr) + + def _schedule_backend_activation(self, container_id: str, grace_seconds: float): + self._clear_pending_startup_state(container_id) + generation = self._pending_backend_generations.get(container_id, 0) + 1 + self._pending_backend_generations[container_id] = generation + + def activate(): + self.enqueue(ActivateBackend(container_id, generation)) + + timer = threading.Timer(grace_seconds, activate) + timer.daemon = True + self._pending_backend_timers[container_id] = timer + timer.start() + + def _cancel_pending_backend_activation(self, container_id: str): + timer = self._pending_backend_timers.pop(container_id, None) + if timer is not None: + timer.cancel() + self._pending_backend_generations.pop(container_id, None) + + def _clear_pending_startup_state(self, container_id: str) -> bool: + had_pending_timer = container_id in self._pending_backend_timers + was_waiting_for_healthy = container_id in self._waiting_for_healthy + self._cancel_pending_backend_activation(container_id) + self._waiting_for_healthy.discard(container_id) + return had_pending_timer or was_waiting_for_healthy + + def _activate_backend_if_running(self, container_id: str, container=None): + try: + container = container or self.client.containers.get(container_id) + if not self._container_is_running(container): + return + backend = BackendTarget.from_container(container) + self.web_server.update_backend(backend) + except docker.errors.NotFound: + return + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + print(f"Error activating backend for {container_id}: {e}", file=sys.stderr) + + def _process_backend_activation(self, container_id: str, generation: int): + if self._pending_backend_generations.get(container_id) != generation: + return + self._pending_backend_timers.pop(container_id, None) + self._pending_backend_generations.pop(container_id, None) + self._activate_backend_if_running(container_id) + + @staticmethod + def _container_has_healthcheck(container) -> bool: + healthcheck = container.attrs.get("Config", {}).get("Healthcheck") + return bool(healthcheck and healthcheck.get("Test") not in (None, [], ["NONE"], ["NONE", ""])) + + @staticmethod + def _container_health_status(container) -> str | None: + return container.attrs.get("State", {}).get("Health", {}).get("Status") + + @staticmethod + def _container_is_running(container) -> bool: + state_status = container.attrs.get("State", {}).get("Status") + return state_status == "running" or getattr(container, "status", None) == "running" + + def _log_container_event( + self, label: str, container_id: str, container=None, attributes=None, detail: str | None = None + ): + container_name = self._container_name(container=container, container_id=container_id, attributes=attributes) + parts = [label, "Id:" + container_id[:12]] + if container_name: + parts.append(" " + container_name) + if detail: + parts.append(detail) + print(*parts, sep="\t") + + def _container_name(self, container=None, container_id: str | None = None, attributes=None) -> str | None: + if attributes and attributes.get("name"): + return attributes["name"] + if container is not None: + name = getattr(container, "name", None) or container.attrs.get("Name") + if isinstance(name, str): + return name.lstrip("/") + if container_id is None: + return None + try: + resolved = self.client.containers.get(container_id) + except docker.errors.NotFound: + return None + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + self._log_unexpected_error(f"Could not resolve container name for {container_id}", e) + return None + name = getattr(resolved, "name", None) or resolved.attrs.get("Name") + return name.lstrip("/") if isinstance(name, str) else None + def _process_network_event(self, action, event): if action == "create": # print("network created") @@ -144,12 +503,61 @@ def _process_network_event(self, action, event): scope=event["scope"], ) elif action == "connect": - # print("network connect") - self.web_server.connect( - network=event["Actor"]["ID"], - container=event["Actor"]["Attributes"]["container"], - scope=event["scope"], - ) + container_id = event["Actor"]["Attributes"]["container"] + if self._should_forward_network_connect(container_id): + self.web_server.connect( + network=event["Actor"]["ID"], + container=container_id, + scope=event["scope"], + ) elif action == "destroy": # print("network destryed") pass + + def _should_forward_network_connect(self, container_id: str) -> bool: + try: + container = self.client.containers.get(container_id) + except docker.errors.NotFound: + return False + except ValueError: + print(f"WARN: Ignoring network connect for invalid container id {container_id!r}", file=sys.stderr) + return False + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + self._log_unexpected_error(f"Could not inspect container {container_id} for network connect", e) + return False + + if not self._container_is_running(container): + return False + if self._container_has_healthcheck(container) and self._container_health_status(container) != "healthy": + return False + if container_id not in self._started_containers: + return False + if self._is_pending_startup(container_id): + return False + + swarm_mode = self.web_server.config.get("docker_swarm", "ignore") + labels = container.attrs.get("Config", {}).get("Labels", {}) + if swarm_mode not in ("ignore", "prefer-local") and "com.docker.swarm.service.id" in labels: + return False + return True + + def _is_pending_startup(self, container_id: str) -> bool: + return container_id in self._pending_backend_timers or container_id in self._waiting_for_healthy + + def _load_started_container_ids(self) -> set[str]: + try: + return { + container.id for container in self.client.containers.list() if self._container_is_running(container) + } + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + self._log_unexpected_error("Could not load running containers during Docker event listener startup", e) + return set() + + @staticmethod + def _log_unexpected_error(message: str, error: Exception): + print(f"WARN: {message}: {error.__class__.__name__} -> {error}", file=sys.stderr) + traceback.print_exc(limit=10) diff --git a/nginx_proxy/Host.py b/nginx_proxy/Host.py index a68ad20..58dd04b 100644 --- a/nginx_proxy/Host.py +++ b/nginx_proxy/Host.py @@ -28,6 +28,7 @@ def __init__(self, hostname: str, port: int, scheme=None): self.scheme: set = scheme self.secured: bool = "https" in scheme or "wss" in scheme self.full_redirect: Union[Url, None] = None + self.is_redirect: bool = False self.extras: Dict[str, Any] = {} def set_external_parameters(self, host, port) -> None: diff --git a/nginx_proxy/NginxProxyApp.py b/nginx_proxy/NginxProxyApp.py index 6a7d24a..2ba7134 100644 --- a/nginx_proxy/NginxProxyApp.py +++ b/nginx_proxy/NginxProxyApp.py @@ -42,6 +42,7 @@ class NginxProxyAppConfig(TypedDict): enable_ipv6: bool docker_swarm: str swarm_docker_host: str | None + backend_start_grace_seconds: float def _strip_end(s: str, char="/") -> str: @@ -71,13 +72,8 @@ def _loadconfig(self) -> NginxProxyAppConfig: port = parsed.port if port is None: port = 443 if parsed.scheme == "https" else 80 - - certapi = { - "url": certapi_url, - "host": parsed.hostname, - "scheme": parsed.scheme, - "port": port - } + + certapi = {"url": certapi_url, "host": parsed.hostname, "scheme": parsed.scheme, "port": port} wellknown_path = os.getenv("WELLKNOWN_PATH", "/.well-known/acme-challenge/").strip() # Ensure wellknown_path starts with / and ends with / @@ -107,6 +103,7 @@ def _loadconfig(self) -> NginxProxyAppConfig: enable_ipv6=os.getenv("ENABLE_IPV6", "false").strip().lower() == "true", docker_swarm=os.getenv("DOCKER_SWARM", "ignore").strip().lower(), swarm_docker_host=os.getenv("SWARM_DOCKER_HOST", "").strip() or None, + backend_start_grace_seconds=float(os.getenv("BACKEND_START_GRACE_SECONDS", "10").strip()), ) def _setup_nginx_conf(self): @@ -161,7 +158,7 @@ def _init_docker_client(self) -> None: # Validate Swarm mode if enabled swarm_mode = self.config["docker_swarm"] - if swarm_mode in ("enable", "strict"): + if swarm_mode in ("enable", "prefer-local", "strict"): try: info = self.swarm_client.info() swarm_info = info.get("Swarm", {}) @@ -184,14 +181,29 @@ def _init_docker_client(self) -> None: def start(self): self.server = WebServer(self.docker_client, self.config, swarm_client=self.swarm_client) - self.docker_event_listener = DockerEventListener(self.server, self.docker_client, swarm_client=self.swarm_client) + self.docker_event_listener = DockerEventListener( + self.server, self.docker_client, swarm_client=self.swarm_client + ) def stop(self): print("Stopping NginxProxyApp...") self.cleanup() + def reload(self): + if self.server is None: + print("Reload requested before NginxProxyApp started", file=sys.stderr) + return False + print("Reload requested. Rescanning Docker state...") + if self.docker_event_listener is not None and self.docker_event_listener.is_dispatcher_running(): + from nginx_proxy.DockerEventListener import RescanAndReload + + return self.docker_event_listener.enqueue(RescanAndReload(force=True, bypass_start_grace=True)) + return self.server.rescan_and_reload(force=True, bypass_start_grace=True) + def cleanup(self): - # No explicit stop for DockerEventListener needed as it's not a thread + if self.docker_event_listener is not None and self.docker_event_listener.is_dispatcher_running(): + self.docker_event_listener.stop_dispatcher() + self.docker_event_listener = None if self.server is not None: self.server.cleanup() self.server = None diff --git a/nginx_proxy/Throttler.py b/nginx_proxy/Throttler.py index 2a77e5d..eb34d61 100644 --- a/nginx_proxy/Throttler.py +++ b/nginx_proxy/Throttler.py @@ -9,11 +9,15 @@ def __init__(self, interval: float): self._lock = threading.Lock() self._timer: Optional[threading.Timer] = None self._last_run_time = 0.0 + self._pending_task: Optional[Callable] = None - def _trigger(self, task: Callable): + def _trigger(self): with self._lock: + task = self._pending_task + self._pending_task = None self._timer = None self._last_run_time = time.time() + if task is not None: task() def throttle(self, task: Callable, immediate: bool = False): @@ -41,18 +45,22 @@ def throttle(self, task: Callable, immediate: bool = False): if self._timer: self._timer.cancel() self._timer = None + self._pending_task = None self._last_run_time = current_time - return task() else: + self._pending_task = task # Too soon, schedule if not already scheduled if not self._timer: wait_time = (self._last_run_time + self.interval) - current_time - self._timer = threading.Timer(wait_time, self._trigger, args=[task]) + self._timer = threading.Timer(wait_time, self._trigger) self._timer.start() return False + return task() + def shutdown(self): with self._lock: if self._timer: self._timer.cancel() self._timer = None + self._pending_task = None diff --git a/nginx_proxy/WebServer.py b/nginx_proxy/WebServer.py index f428839..54e0cfd 100644 --- a/nginx_proxy/WebServer.py +++ b/nginx_proxy/WebServer.py @@ -1,9 +1,10 @@ import copy import os import sys -import threading -from typing import List, TYPE_CHECKING +from datetime import datetime, timezone +from typing import Callable, List, TYPE_CHECKING +import docker import requests from docker import DockerClient from jinja2 import Template @@ -46,7 +47,8 @@ def __init__( self.config["conf_dir"] + "/conf.d/nginx-proxy.conf", self.config["challenge_dir"] ) self.config_data = ProxyConfigData() - self._lock = threading.Lock() + self._reload_dispatcher: Callable | None = None + self._is_reload_dispatcher_thread: Callable[[], bool] | None = None self.services = set() self.networks = {} vhosts_template_path = os.path.join(self.config["vhosts_template_dir"], "default.conf.jinja2") @@ -67,7 +69,7 @@ def __init__( ) self.basic_auth_processor = post_processors.BasicAuthProcessor(self.config["conf_dir"] + "/basic_auth") self.redirect_processor = post_processors.RedirectProcessor() - self.sticky_session_processor = post_processors.StickySessionProcessor() + self.upstream_processor = post_processors.UpstreamProcessor() # Render default config for Nginx setup default_nginx_config = self.template.render(config=self.config) @@ -77,9 +79,13 @@ def __init__( print("Reachable Networks :", self.networks) self.setup_error_config() - self.rescan_and_reload(force=True) + self.rescan_and_reload(force=True, bypass_start_grace=True) self.ssl_processor.start() + def set_reload_dispatcher(self, dispatcher: Callable | None, is_dispatcher_thread: Callable[[], bool] | None): + self._reload_dispatcher = dispatcher + self._is_reload_dispatcher_thread = is_dispatcher_thread + def setup_error_config(self): # Render error.conf.jinja2 and save it rendered_error_conf_path = os.path.join(self.config["conf_dir"], "error.conf") @@ -94,13 +100,14 @@ def _ensure_https_redirects(self, hosts: List[Host]) -> List[Host]: http_hosts = {(host.hostname, int(host.port)): host for host in hosts if int(host.port) == 80} for host in hosts: - if not host.secured or int(host.port) == 80: + if host.is_redirect or not host.secured or int(host.port) == 80: continue redirect_target = Url({"https"}, host.hostname, int(host.port), "/") http_host = http_hosts.get((host.hostname, 80)) if http_host is None: redirect_host = Host(host.hostname, 80) redirect_host.full_redirect = redirect_target + redirect_host.update_extras_content("redirect_status_code", "308") # Added after redirect post-processing, so mark it explicitly for template rendering. redirect_host.is_redirect = True redirect_hosts.append(redirect_host) @@ -112,7 +119,7 @@ def _ensure_https_redirects(self, hosts: List[Host]) -> List[Host]: return hosts + redirect_hosts - def _do_reload(self, forced=False, has_addition=True) -> bool: + def _do_reload(self, forced=False) -> bool: """ Creates a new configuration based on current state and signals nginx to reload. This is called whenever there's change in container or network state. @@ -134,7 +141,9 @@ def _do_reload(self, forced=False, has_addition=True) -> bool: location.container = list(location.backends)[0] hosts.append(host) - upstreams = self.sticky_session_processor.process(hosts) + upstreams = self.upstream_processor.process( + hosts, prefer_local=self.config.get("docker_swarm") == "prefer-local" + ) self.basic_auth_processor.process_basic_auth(hosts) self.ssl_processor.process_ssl_certificates(hosts) hosts = self._ensure_https_redirects(hosts) @@ -207,25 +216,43 @@ def register_backend(self, backend: BackendTarget): # removes container from the maintained list. # this is called when a caontainer dies or leaves a known network def remove_backend(self, container_id: str): - deleted, deleted_domain = self.config_data.remove_backend(container_id) + deleted, deleted_domain = self._remove_backend_without_reload(container_id) if deleted: + service_id = deleted.labels.get("com.docker.swarm.service.id") + has_service_id = isinstance(service_id, str) and bool(service_id) + is_service = deleted.type == "service" or has_service_id + label = "Service removed " if is_service else "Container removed " + display_id = service_id[:12] if has_service_id else container_id[:12] print( - "Container removed ", - "Id:" + container_id[:12], + label, + "Id:" + display_id, " " + deleted.name, sep="\t", ) - self.reload(has_addition=False) + self.reload() - def reload(self, immediate=False, force=False, has_addition=True) -> bool: + def _remove_backend_without_reload(self, container_id: str): + return self.config_data.remove_backend(container_id) + + def reload(self, immediate=False, force=False) -> bool: """ Schedules or performs a reload of the Nginx configuration. Returns True if a reload was initiated or scheduled. """ - return self.throttler.throttle(lambda: self._do_reload(force, has_addition), immediate=immediate or force) - def disconnect(self, network, container, scope): + return self.throttler.throttle(lambda: self._do_reload(force), immediate=immediate or force) + def enqueue_reload(self, force=False) -> bool: + if self._reload_dispatcher is None: + return self.reload(immediate=force, force=force) + if self._is_reload_dispatcher_thread is not None and self._is_reload_dispatcher_thread(): + return self.reload(immediate=force, force=force) + + from nginx_proxy.DockerEventListener import Reload + + return self._reload_dispatcher(Reload(force)) + + def disconnect(self, network, container, scope): if self.id is not None and container == self.id: if network in self.networks: print("Nginx Proxy removed from network ", self.networks[network]) @@ -242,7 +269,7 @@ def disconnect(self, network, container, scope): if self.config_data.has_backend(container): try: backend = BackendTarget.from_container(self.client.containers.get(container)) - if not self.update_backend(backend): + if not self.update_backend(backend, replace_existing=True): self.remove_backend( container ) # remove_backend not implemented yet, using remove_container (it takes ID) @@ -264,91 +291,148 @@ def connect(self, network, container, scope): return try: container_obj = self.client.containers.get(container) - if swarm_mode != "ignore" and "com.docker.swarm.service.id" in container_obj.attrs["Config"].get("Labels", {}): + if container_obj.status != "running": + return + if ( + self._container_has_healthcheck(container_obj) + and self._container_health_status(container_obj) != "healthy" + ): + return + if swarm_mode not in ( + "ignore", + "prefer-local", + ) and "com.docker.swarm.service.id" in container_obj.attrs["Config"].get("Labels", {}): # print(f"Skipping network connect for service task container {container}") return backend = BackendTarget.from_container(container_obj) - self.update_backend(backend) + self.update_backend(backend, replace_existing=True) + except docker.errors.NotFound: + return except (KeyboardInterrupt, SystemExit): raise except Exception as e: print(f"Error processing connect for container {container}: {e}", file=sys.stderr) - def update_backend(self, backend: BackendTarget): + def update_backend(self, backend: BackendTarget, replace_existing: bool = False): """ Rescan the backend to detect changes. And update nginx configuration if necessary. :param backend: BackendTarget object :return: true if state change affected the nginx configuration else false """ try: - if not self.config_data.has_backend(backend.id): - if self.register_backend(backend): - self.reload() - return True + existing_backend = self.config_data.has_backend(backend.id) + if existing_backend and backend.type != "service" and not replace_existing: + return False + + removed = None + if existing_backend: + removed, _ = self._remove_backend_without_reload(backend.id) + + registered = self.register_backend(backend) + if registered or removed: + self.reload() + return True except requests.exceptions.HTTPError as e: pass return False - def rescan_all_container(self): + def rescan_all_container(self, bypass_start_grace=False): """ - Rescan all the containers and services to detect changes. + Rescan all the containers and services to detect changes. Previously this only did containers, but now it's a full rescan for consistency. """ swarm_mode = self.config.get("docker_swarm", "ignore") - with self._lock: - # Clear previous state to ensure we don't leak dead containers/services - self.config_data.clear() - - # 1. Register local containers (unless in strict swarm mode) - if swarm_mode != "strict" and self.client is not None: - try: - containers = self.client.containers.list() - for container in containers: - if swarm_mode != "ignore" and "com.docker.swarm.service.id" in container.attrs["Config"].get("Labels", {}): - continue - backend = BackendTarget.from_container(container) - self.register_backend(backend) - except (KeyboardInterrupt, SystemExit): - raise - except Exception as e: - print(f"Error scanning containers: {e}", file=sys.stderr) + # Clear previous state to ensure we don't leak dead containers/services + self.config_data.clear() - # 2. Register swarm services (if enable or strict) - if swarm_mode in ("enable", "strict"): - try: - info = self.swarm_client.info() - swarm_info = info.get("Swarm", {}) - node_state = swarm_info.get("LocalNodeState", "inactive") - # ControlAvailable is usually present if it's a manager - is_manager = swarm_info.get("ControlAvailable", False) - - if node_state == "active" and is_manager: - services = self.swarm_client.services.list() - for service in services: - backend = BackendTarget.from_service(service) - self.register_backend(backend) - elif node_state == "active": - # If node is active but not manager, we can't list services on this client. - # However, if we have a remote swarm_client, it might be a manager. - # But self.swarm_client.info() would have returned is_manager=True if it were. - pass - except (KeyboardInterrupt, SystemExit): - raise - except Exception as e: - print(f"Error scanning services: {e}", file=sys.stderr) + # 1. Register local containers (unless in strict swarm mode) + if swarm_mode != "strict" and self.client is not None: + try: + containers = self.client.containers.list() + for container in containers: + if swarm_mode not in ( + "ignore", + "prefer-local", + ) and "com.docker.swarm.service.id" in container.attrs["Config"].get("Labels", {}): + continue + if not self._should_register_container_now(container, bypass_start_grace=bypass_start_grace): + continue + backend = BackendTarget.from_container(container) + self.register_backend(backend) + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + print(f"Error scanning containers: {e}", file=sys.stderr) + + # 2. Register swarm services (if enable, prefer-local, or strict) + if swarm_mode in ("enable", "prefer-local", "strict"): + try: + info = self.swarm_client.info() + swarm_info = info.get("Swarm", {}) + node_state = swarm_info.get("LocalNodeState", "inactive") + # ControlAvailable is usually present if it's a manager + is_manager = swarm_info.get("ControlAvailable", False) + + if node_state == "active" and is_manager: + services = self.swarm_client.services.list() + for service in services: + backend = BackendTarget.from_service(service) + self.register_backend(backend) + elif node_state == "active": + # If node is active but not manager, we can't list services on this client. + # However, if we have a remote swarm_client, it might be a manager. + # But self.swarm_client.info() would have returned is_manager=True if it were. + pass + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + print(f"Error scanning services: {e}", file=sys.stderr) def rescan_services(self): """ Included for compatibility with DockerEventListener. Calls rescan_all_container to perform a unified full rescan. """ - self.rescan_all_container() + self.rescan_all_container(bypass_start_grace=True) - def rescan_and_reload(self, force=False): - self.rescan_all_container() - return self.reload(force) + def rescan_and_reload(self, force=False, bypass_start_grace=True): + self.rescan_all_container(bypass_start_grace=bypass_start_grace) + return self.reload(immediate=force, force=force) def cleanup(self): self.throttler.shutdown() self.ssl_processor.shutdown() self.nginx.stop() + + def _should_register_container_now(self, container, bypass_start_grace=False) -> bool: + if not self._container_is_running(container): + return False + if self._container_has_healthcheck(container): + return self._container_health_status(container) == "healthy" + if bypass_start_grace: + return True + grace_seconds = float(self.config.get("backend_start_grace_seconds", 0) or 0) + if grace_seconds <= 0: + return True + started_at = container.attrs.get("State", {}).get("StartedAt") + if not started_at: + return True + try: + started_time = datetime.fromisoformat(started_at.replace("Z", "+00:00")) + except ValueError: + return True + return (datetime.now(timezone.utc) - started_time).total_seconds() >= grace_seconds + + @staticmethod + def _container_has_healthcheck(container) -> bool: + healthcheck = container.attrs.get("Config", {}).get("Healthcheck") + return bool(healthcheck and healthcheck.get("Test") not in (None, [], ["NONE"], ["NONE", ""])) + + @staticmethod + def _container_health_status(container) -> str | None: + return container.attrs.get("State", {}).get("Health", {}).get("Status") + + @staticmethod + def _container_is_running(container) -> bool: + state_status = container.attrs.get("State", {}).get("Status") + return state_status == "running" or getattr(container, "status", None) == "running" diff --git a/nginx_proxy/post_processors/__init__.py b/nginx_proxy/post_processors/__init__.py index 3bccf4b..3b565d0 100644 --- a/nginx_proxy/post_processors/__init__.py +++ b/nginx_proxy/post_processors/__init__.py @@ -1,4 +1,4 @@ from .basic_auth_processor import BasicAuthProcessor from .redirect_processor import RedirectProcessor from .ssl_certificate_processor import SslCertificateProcessor -from .sticky_session_processor import StickySessionProcessor +from .upstream_processor import UpstreamProcessor diff --git a/nginx_proxy/post_processors/redirect_processor.py b/nginx_proxy/post_processors/redirect_processor.py index c7f30bc..b6e36b9 100644 --- a/nginx_proxy/post_processors/redirect_processor.py +++ b/nginx_proxy/post_processors/redirect_processor.py @@ -10,7 +10,7 @@ def process_redirection(self, config: ProxyConfigData): for host in config.host_list(): if host.isredirect(): redirected_hosts[host.hostname] = host.full_redirect - target = config.getHost(host.full_redirect.hostname) + target = config.getHost(host.full_redirect.hostname, host.full_redirect.port) if target is not None: if target.hostname == host.hostname: host.full_redirect = None diff --git a/nginx_proxy/post_processors/ssl_certificate_processor.py b/nginx_proxy/post_processors/ssl_certificate_processor.py index 082adc1..c4aa24d 100644 --- a/nginx_proxy/post_processors/ssl_certificate_processor.py +++ b/nginx_proxy/post_processors/ssl_certificate_processor.py @@ -32,7 +32,7 @@ def __init__( self.challenge_store = backend_info.challenge_store self.renewal_manager = RenewalManager( self.backend, - renewal_callback=self.sync_watch_domains, + renewal_callback=self.ssl_renewal_callback, renew_threshold_days=max(1, int(self.update_threshold_secs // (24 * 3600))), batch_domains=self.certapi_batch_domains, ) @@ -43,12 +43,11 @@ def __init__( def start(self): self.renewal_manager.start() - def sync_watch_domains(self): + def ssl_renewal_callback(self): + print("[SSL] Renewal callback triggered") if self.server is None: return - domains = sorted({host.hostname for host in self.server.config_data.host_list() if host.secured}) - self.renewal_manager.update_watch_domains(domains) - self.server.reload(force=True) + self.server.enqueue_reload(force=True) def is_certificate_fresh(self, domain: str, threshold_seconds: float | None = None) -> bool: result = self.key_store.find_key_and_cert_by_domain(domain) diff --git a/nginx_proxy/post_processors/sticky_session_processor.py b/nginx_proxy/post_processors/sticky_session_processor.py deleted file mode 100644 index 4275ca6..0000000 --- a/nginx_proxy/post_processors/sticky_session_processor.py +++ /dev/null @@ -1,46 +0,0 @@ -import hashlib -from typing import List, Dict, Any -from nginx_proxy.Host import Host - - -class StickySessionProcessor: - def process(self, hosts: List[Host]) -> List[Dict[str, Any]]: - global_upstreams = {} - - for host in hosts: - for i, location in enumerate(host.locations.values()): - if len(location.backends) > 1: - backend_key = tuple( - sorted([(str(b.address), str(b.port)) for b in location.backends]) - ) - if backend_key in global_upstreams: - location.upstream = global_upstreams[backend_key]["id"] - else: - upstream_id = ( - host.hostname.strip() - + "_" - + hashlib.sha1(str(backend_key).encode("utf-8")).hexdigest()[:12] - ) - sticky_value = None - for b in location.backends: - if "NGINX_STICKY_SESSION" in b.env: - val = b.env["NGINX_STICKY_SESSION"] - env_value=val.lower().strip() - if env_value == "true": - sticky_value = "ip_hash" - elif env_value == "false": - sticky_value = None - else: - sticky_value = val - break - - global_upstreams[backend_key] = { - "id": upstream_id, - "containers": location.backends, - "sticky": sticky_value, - } - location.upstream = upstream_id - else: - location.upstream = False - - return list(global_upstreams.values()) diff --git a/nginx_proxy/post_processors/upstream_processor.py b/nginx_proxy/post_processors/upstream_processor.py new file mode 100644 index 0000000..be63a5a --- /dev/null +++ b/nginx_proxy/post_processors/upstream_processor.py @@ -0,0 +1,86 @@ +import hashlib +from typing import List, Dict, Any + +from nginx_proxy.Host import Host + + +class UpstreamProcessor: + def process(self, hosts: List[Host], prefer_local: bool = False) -> List[Dict[str, Any]]: + global_upstreams = {} + + for host in hosts: + for i, location in enumerate(host.locations.values()): + if len(location.backends) > 1: + local_service_ids = self._local_service_ids(location.backends) + for backend in location.backends: + backend.backup = prefer_local and backend.type == "service" and backend.id in local_service_ids + if prefer_local and local_service_ids: + self._align_service_backup_ports(location.backends) + + backend_key = tuple( + sorted([(str(b.address), str(b.port), bool(b.backup)) for b in location.backends]) + ) + if backend_key in global_upstreams: + location.upstream = global_upstreams[backend_key]["id"] + else: + upstream_id = ( + host.hostname.strip() + + "_" + + hashlib.sha1(str(backend_key).encode("utf-8")).hexdigest()[:12] + ) + sticky_value = None + if not any(b.backup for b in location.backends): + sticky_value = self._sticky_value(location.backends) + + global_upstreams[backend_key] = { + "id": upstream_id, + "containers": location.backends, + "sticky": sticky_value, + } + location.upstream = upstream_id + else: + for backend in location.backends: + backend.backup = False + location.upstream = False + + return list(global_upstreams.values()) + + @staticmethod + def _sticky_value(backends): + for backend in backends: + if "NGINX_STICKY_SESSION" not in backend.env: + continue + value = backend.env["NGINX_STICKY_SESSION"] + env_value = value.lower().strip() + if env_value == "true": + return "ip_hash" + if env_value == "false": + return None + return value + return None + + @staticmethod + def _local_service_ids(backends): + return { + b.labels.get("com.docker.swarm.service.id") + for b in backends + if b.type != "service" and b.labels.get("com.docker.swarm.service.id") + } + + @staticmethod + def _align_service_backup_ports(backends): + for backend in backends: + if backend.type != "service" or not backend.backup or int(backend.port or 80) != 80: + continue + local_ports = { + int(b.port) + for b in backends + if b.type != "service" + and b.port is not None + and b.labels.get("com.docker.swarm.service.id") == backend.id + } + if len(local_ports) != 1: + continue + local_port = next(iter(local_ports)) + if local_port != 80: + backend.port = local_port diff --git a/nginx_proxy/pre_processors/redirect_processor.py b/nginx_proxy/pre_processors/redirect_processor.py index bc03b50..bb198c6 100644 --- a/nginx_proxy/pre_processors/redirect_processor.py +++ b/nginx_proxy/pre_processors/redirect_processor.py @@ -8,6 +8,14 @@ from nginx_proxy.Host import Host +def _is_certificate_redirect_target(target: Url): + return "https" in target.scheme or "wss" in target.scheme or int(target.port or 80) == 443 + + +def _hostname_exceeds_certificate_limit(hostname: str) -> bool: + return bool(hostname) and len(hostname.rstrip(".")) > 64 + + def process_redirection(backend: BackendTarget, environments: map, vhost_map: Dict[str, Dict[int, Host]]): redirect_env = [e[1] for e in environments.items() if e[0].startswith("PROXY_FULL_REDIRECT")] hosts = [] @@ -23,16 +31,34 @@ def process_redirection(backend: BackendTarget, environments: map, vhost_map: Di if len(split) == 2: _sources, target = split sources = [Url.parse(source) for source in _sources.split(",")] - target = Url.parse(target, default_port=80) - if single_host: - if target.hostname is None: - target = single_host + target = Url.parse(target) + if target.hostname is None and single_host: + target.hostname = hosts[0].hostname elif target.hostname is None: print("Unknown target to redirect with PROXY_FULL_REDIRECT" + redirect_entry) continue + target.port = int(target.port) if target.port is not None else None + if target.port is None and target.hostname in vhost_map: + target_host = vhost_map[target.hostname].get(443) or vhost_map[target.hostname].get(80) + if target_host is not None: + target.port = target_host.port + target.scheme = {"https"} if target_host.secured else {"http"} + if target.port is None: + target.port = 443 if "https" in target.scheme or "wss" in target.scheme else 80 + if not target.scheme: + target.scheme = {"https"} if target.port == 443 else {"http"} + if not Url.is_valid_hostname(target.hostname, allow_wildcard=True): + print("Invalid PROXY_FULL_REDIRECT target hostname: " + target.hostname) + continue + if _is_certificate_redirect_target(target) and _hostname_exceeds_certificate_limit(target.hostname): + print("Invalid PROXY_FULL_REDIRECT target certificate hostname: " + target.hostname) + continue for source in sources: if source.hostname is not None: - port = 80 if source.port is None else source.port + if not Url.is_valid_hostname(source.hostname, allow_wildcard=True): + print("Invalid PROXY_FULL_REDIRECT source hostname: " + source.hostname) + continue + port = 80 if source.port is None else int(source.port) if source.hostname not in vhost_map: host = Host(source.hostname, port) host.full_redirect = target diff --git a/nginx_proxy/pre_processors/virtual_host_processor.py b/nginx_proxy/pre_processors/virtual_host_processor.py index 5373936..9e20755 100644 --- a/nginx_proxy/pre_processors/virtual_host_processor.py +++ b/nginx_proxy/pre_processors/virtual_host_processor.py @@ -1,7 +1,8 @@ import re +from nginx import Url from nginx_proxy import Host, ProxyConfigData -from nginx_proxy.BackendTarget import BackendTarget, NoHostConfiguration, UnreachableNetwork +from nginx_proxy.BackendTarget import BackendTarget, InvalidHostConfiguration, NoHostConfiguration, UnreachableNetwork from nginx_proxy.utils import split_url @@ -20,6 +21,30 @@ def _default_external_port(schemes): return 443 if has_secure_scheme and not has_insecure_scheme else 80 +def _requires_certificate(host: Host) -> bool: + return "https" in host.scheme or "wss" in host.scheme or int(host.port or 80) == 443 + + +def _hostname_exceeds_certificate_limit(hostname: str) -> bool: + return bool(hostname) and len(hostname.rstrip(".")) > 64 + + +def _validate_external_host(host: Host): + if not Url.is_valid_hostname(host.hostname, allow_wildcard=True): + raise InvalidHostConfiguration(host.hostname, "invalid hostname") + if _requires_certificate(host) and _hostname_exceeds_certificate_limit(host.hostname): + raise InvalidHostConfiguration(host.hostname, "certificate hostnames must be 64 characters or fewer") + + +def _backend_log_identity(backend: BackendTarget) -> str: + service_id = backend.labels.get("com.docker.swarm.service.id") + if isinstance(service_id, str) and service_id: + return "Service Id: " + service_id[:12] + if backend.type == "service": + return "Service Id: " + backend.id[:12] + return f"{backend.type:>9}".title() + " Id: " + backend.id[:12] + + def _parse_extra_directive(raw_directive: str): directive = raw_directive.strip() if not directive: @@ -76,7 +101,7 @@ def process_virtual_hosts(backend: BackendTarget, known_networks: set) -> ProxyC hosts.add_host(host) print( "Valid configuration ", - f"{backend.type:>9}".title() + " Id: " + backend.id[:12], + _backend_log_identity(backend), backend.name, sep="\t", ) @@ -84,18 +109,26 @@ def process_virtual_hosts(backend: BackendTarget, known_networks: set) -> ProxyC except NoHostConfiguration: print( "No VIRTUAL_HOST ", - f"{backend.type:>9}".title() + " Id: " + backend.id[:12], + _backend_log_identity(backend), backend.name, sep="\t", ) except UnreachableNetwork as e: print( "Unreachable Network ", - f"{backend.type:>9}".title() + " Id: " + backend.id[:12], + _backend_log_identity(backend), backend.name, "networks: " + ", ".join(list(e.network_names)), sep="\t", ) + except InvalidHostConfiguration as e: + print( + "Invalid VIRTUAL_HOST ", + _backend_log_identity(backend), + backend.name, + f"{e.hostname}: {e.reason}", + sep="\t", + ) return hosts @@ -151,7 +184,12 @@ def host_generator(backend: BackendTarget, known_networks: set = {}): # We need a clean object to return that represents the target target_base = BackendTarget( - backend.id, name=backend.name, env=backend.env, labels=backend.labels, backend_type=backend.type + backend.id, + name=backend.name, + env=backend.env, + labels=backend.labels, + backend_type=backend.type, + backup=backend.backup, ) found_ip = None @@ -171,6 +209,7 @@ def host_generator(backend: BackendTarget, known_networks: set = {}): for host_config in static_hosts: host, location, container_data, extras = _parse_host_entry(host_config) + _validate_external_host(host) if container_data.address is None: print( @@ -182,6 +221,10 @@ def host_generator(backend: BackendTarget, known_networks: set = {}): location = location + "/" container_data.id = backend.id container_data.name = backend.name + container_data.env = backend.env + container_data.labels = backend.labels + container_data.type = backend.type + container_data.backup = backend.backup host.secured = "https" in host.scheme or "wss" in host.scheme or host.port == 443 if host.port is None: host.port = 443 if host.secured else 80 @@ -199,12 +242,17 @@ def host_generator(backend: BackendTarget, known_networks: set = {}): for host_config in virtual_hosts: host, location, container_data, extras = _parse_host_entry(host_config) + _validate_external_host(host) # Protect double / in urls. if location and not location.endswith("/") and container_data.path and container_data.path.endswith("/"): location = location + "/" container_data.address = found_ip container_data.id = backend.id container_data.name = backend.name + container_data.env = backend.env + container_data.labels = backend.labels + container_data.type = backend.type + container_data.backup = backend.backup if container_data.port is None: if override_port: diff --git a/reload b/reload new file mode 100755 index 0000000..b3cc1d4 --- /dev/null +++ b/reload @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -eu + +pid="$(pgrep -f 'python3 -uB main.py' | head -n 1 || true)" +if [ -z "$pid" ]; then + pid="$(pgrep -f 'main.py' | head -n 1 || true)" +fi + +if [ -z "$pid" ]; then + echo "nginx-proxy process not found" >&2 + exit 1 +fi + +kill -HUP "$pid" +echo "Reload signal sent to nginx-proxy process $pid" diff --git a/requirements.txt b/requirements.txt index 76c6ea6..f090841 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,5 @@ docker==7.1.0 Jinja2==3.1.6 pydevd==3.1.0 bcrypt==4.3.0 # 5.0.0 requires rust so ignoring -certapi>=1.1.9 +certapi>=1.1.11 requests==2.33.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..49d89e5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +def pytest_collection_modifyitems(items): + original_order = {item: index for index, item in enumerate(items)} + swarm_mode_order = { + "enable": 0, + "exclude": 1, + "ignore": 2, + "prefer-local": 3, + "strict": 4, + } + + def swarm_mode_sort_key(item): + callspec = getattr(item, "callspec", None) + swarm_mode = callspec.params.get("swarm_mode") if callspec is not None else None + if swarm_mode is None: + return (0, 0, original_order[item]) + return (1, swarm_mode_order.get(swarm_mode, len(swarm_mode_order)), original_order[item]) + + items.sort(key=swarm_mode_sort_key) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index cacd410..82c9588 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1,2 +1,7 @@ from .docker_utils import start_backend, stop_backend -from .integration_helpers import expect_server_down_integration, expect_server_up_integration, get_nginx_config_from_container ,expect_server_not_present_integration +from .integration_helpers import ( + expect_server_down_integration, + expect_server_up_integration, + get_nginx_config_from_container, + expect_server_not_present_integration, +) diff --git a/tests/helpers/docker_test_client.py b/tests/helpers/docker_test_client.py index 4b952c7..e2354c1 100644 --- a/tests/helpers/docker_test_client.py +++ b/tests/helpers/docker_test_client.py @@ -57,7 +57,7 @@ def match(event): return False elif key == "event": action = event.get("Action") or event.get("status") - if action not in values: + if action not in values and not any(str(action).startswith(f"{value}:") for value in values): return False # Additional filter types can be added if needed return True @@ -106,7 +106,13 @@ def create(self, image, command=None, **kwargs): "Image": image, }, "NetworkSettings": {"Ports": {}, "Networks": {}}, # Add Ports here + "State": {"Status": "created"}, } + healthcheck = kwargs.get("healthcheck") + if healthcheck: + test = healthcheck.get("Test") or healthcheck.get("test") + cont.attrs["Config"]["Healthcheck"] = {"Test": test} + cont.attrs["State"]["Health"] = {"Status": "starting"} if command: cont.attrs["Config"]["Cmd"] = command if isinstance(command, list) else command.split() self.client._containers[cid] = cont @@ -211,6 +217,7 @@ def start(self, **kwargs): with self.client._lock: if self.status in ("created", "stopped"): self.status = "running" + self.attrs.setdefault("State", {})["Status"] = "running" # Assign dynamic IPs if not set for net_name, settings in self.attrs["NetworkSettings"]["Networks"].items(): if not settings["IPAddress"]: @@ -239,6 +246,7 @@ def stop(self, **kwargs): with self.client._lock: if self.status == "running": self.status = "stopped" + self.attrs.setdefault("State", {})["Status"] = "exited" # Emit stop event (and die for similarity to real behavior) self.client._emit_event( { @@ -302,6 +310,7 @@ def remove(self, **kwargs): } ) self.status = "stopped" + self.attrs.setdefault("State", {})["Status"] = "exited" self.client._emit_event( { "status": "stop", @@ -335,6 +344,23 @@ def remove(self, **kwargs): } ) + def set_health_status(self, status): + with self.client._lock: + self.attrs.setdefault("State", {}).setdefault("Health", {})["Status"] = status + self.client._emit_event( + { + "status": f"health_status: {status}", + "id": self.id, + "from": self.image, + "Type": "container", + "Action": f"health_status: {status}", + "Actor": {"ID": self.id, "Attributes": {"name": self.name, "image": self.image}}, + "scope": "local", + "time": int(time.time()), + "timeNano": time.time_ns(), + } + ) + def reload(self, **kwargs): pass diff --git a/tests/helpers/docker_utils.py b/tests/helpers/docker_utils.py index 1b611e7..d7ee0db 100644 --- a/tests/helpers/docker_utils.py +++ b/tests/helpers/docker_utils.py @@ -14,6 +14,7 @@ def start_backend( backend_type: str = "container", sleep=True, pytest_request=None, + healthcheck=None, ) -> docker.models.containers.Container | docker.models.services.Service: image_name = "mesudip/test-backend:test" @@ -30,36 +31,49 @@ def start_backend( if isinstance(virtual_host_env, dict): env_list = [f"{k}={v}" for k, v in virtual_host_env.items()] - def slug63(name: str) -> str: - s = re.sub(r'[^A-Za-z0-9]+', '-', name).strip('-') - s = s[-63:].lstrip('-') + s = re.sub(r"[^A-Za-z0-9]+", "-", name).strip("-") + s = s[-63:].lstrip("-") return s - svc_name=f"test-service-{uuid.uuid4().hex}" if pytest_request is None else slug63(f"{pytest_request.node.name}-{uuid.uuid4().hex[:8]}") - + + svc_name = ( + f"test-service-{uuid.uuid4().hex}" + if pytest_request is None + else slug63(f"{pytest_request.node.name}-{uuid.uuid4().hex[:8]}") + ) + if backend_type == "service": - backend= docker_client.services.create( + service_kwargs = {} + if healthcheck is not None: + service_kwargs["healthcheck"] = healthcheck + backend = docker_client.services.create( image=image_name, env=env_list, networks=[test_network.name], name=svc_name, labels={"com.nginx-proxy.test.container": "tetruet"}, # optional common label + **service_kwargs, ) if sleep: time.sleep(5) else: - backend=docker_client.containers.run( + container_kwargs = {} + if healthcheck is not None: + container_kwargs["healthcheck"] = healthcheck + backend = docker_client.containers.run( image_name, detach=True, environment=virtual_host_env, # run accepts dict or list network=test_network.name, name=f"test-backend-{uuid.uuid4().hex}", restart_policy={"Name": "no"}, + **container_kwargs, ) if sleep: time.sleep(1) return backend + def stop_backend( backend: docker.models.containers.Container | docker.models.services.Service, ): @@ -71,10 +85,12 @@ def stop_backend( backend.remove() # additionally try to find container and force remove it try: - containers = backend.client.containers.list(all=True, filters={"label": f"com.docker.swarm.service.name={backend.name}"}) + containers = backend.client.containers.list( + all=True, filters={"label": f"com.docker.swarm.service.name={backend.name}"} + ) for container in containers: container.remove(force=True) except (KeyboardInterrupt, SystemExit): raise except Exception: - pass \ No newline at end of file + pass diff --git a/tests/helpers/integration_helpers.py b/tests/helpers/integration_helpers.py index b5766bd..437721e 100644 --- a/tests/helpers/integration_helpers.py +++ b/tests/helpers/integration_helpers.py @@ -7,6 +7,7 @@ from nginx.NginxConf import HttpBlock, NginxConfig, ServerBlock from tests.helpers.docker_utils import start_backend, stop_backend + def get_nginx_config_from_container(nginx_proxy_container: docker.models.containers.Container) -> str: """ Executes a command inside the nginx_proxy_container to get the current Nginx configuration. diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index ab21c0d..ed27f80 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -14,6 +14,40 @@ # Load environment variables from .env file load_dotenv() +SWARM_MODES = ["ignore", "exclude", "enable", "prefer-local", "strict"] +SWARM_MODE_IDS = { + "ignore": "swarm_ignore", + "exclude": "swarm_exclude", + "enable": "swarm_enable", + "prefer-local": "swarm_prefer_local", + "strict": "swarm_strict", +} + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "swarm_mode(*modes): limit tests using the swarm_mode fixture to the given Docker Swarm modes", + ) + + +def pytest_generate_tests(metafunc): + if "swarm_mode" not in metafunc.fixturenames: + return + + marker = metafunc.definition.get_closest_marker("swarm_mode") + modes = list(marker.args) if marker else SWARM_MODES + unknown_modes = [mode for mode in modes if mode not in SWARM_MODE_IDS] + if unknown_modes: + raise ValueError(f"Unknown swarm_mode marker values: {unknown_modes}") + + metafunc.parametrize( + "swarm_mode", + modes, + ids=[SWARM_MODE_IDS[mode] for mode in modes], + scope="session", + ) + @pytest.fixture(scope="session") def docker_host_ip(): @@ -53,7 +87,7 @@ def docker_client(): @pytest.fixture(scope="session") -def test_network(docker_client: docker.DockerClient,swarm_mode): +def test_network(docker_client: docker.DockerClient, swarm_mode): network_name = "nginx-proxy-test-" + swarm_mode server_details = docker_client.info() is_swarm = server_details.get("Swarm", {}).get("LocalNodeState") == "active" @@ -83,7 +117,7 @@ def test_network(docker_client: docker.DockerClient,swarm_mode): print(f"Error removing network {network_name}: {e}") -@pytest.fixture(scope="session", params=["ignore", "exclude", "enable", "strict"], ids=["swarm_ignore", "swarm_exclude", "swarm_enable", "swarm_strict"]) +@pytest.fixture(scope="session") def swarm_mode(request): return request.param @@ -91,7 +125,7 @@ def swarm_mode(request): @pytest.fixture(scope="session") def nginx_proxy_container(docker_client: docker.DockerClient, test_network, docker_host_ip, swarm_mode): image_name = "mesudip/nginx-proxy:test" - container_name = "nginx-proxy-test-container-swarm_"+swarm_mode + container_name = "nginx-proxy-test-container-swarm_" + swarm_mode # Ensure previous container is stopped and removed try: @@ -132,10 +166,10 @@ def nginx_proxy_container(docker_client: docker.DockerClient, test_network, dock name=container_name, environment={ "LETSENCRYPT_API": "https://acme-staging-v02.api.letsencrypt.org/directory", - "DHPARAM_SIZE": "256", "VHOSTS_TEMPLATE_DIR": "/app/vhosts_template", "CHALLENGE_DIR": "/etc/nginx/acme-challenges", "DOCKER_SWARM": swarm_mode, + "BACKEND_START_GRACE_SECONDS": "2", }, restart_policy={"Name": "no"}, ) @@ -256,6 +290,7 @@ def websocket_connect(self, url: str, **kwargs): return ws + @pytest.fixture def nginx_request(nginx_proxy_container, docker_host_ip): _, port_80, port_443 = nginx_proxy_container diff --git a/tests/integration/test_nginx_proxy.py b/tests/integration/test_nginx_proxy.py index ab4bb30..1f77ede 100644 --- a/tests/integration/test_nginx_proxy.py +++ b/tests/integration/test_nginx_proxy.py @@ -5,7 +5,14 @@ import requests import websocket import time +import re +import hashlib +from datetime import datetime, timezone +from unittest.mock import patch +from nginx.NginxConf import HttpBlock +from nginx_proxy.WebServer import WebServer +from tests.helpers.docker_test_client import DockerTestClient from tests.helpers.integration_helpers import expect_server_up_integration from ..helpers import start_backend, stop_backend # Import helpers @@ -14,7 +21,7 @@ def is_reachable(swarm_mode, backend_type): """ Determines if a backend should be reachable based on swarm mode and backend type. """ - if swarm_mode in ("enable", "ignore"): + if swarm_mode in ("enable", "ignore", "prefer-local"): return True if swarm_mode == "strict" and backend_type == "service": return True @@ -33,6 +40,67 @@ def get_request_url(virtual_host, request_path, scheme="http"): return f"{scheme}://{hostname}{request_path}" +def _hostname_mode_token(swarm_mode): + return {"prefer-local": "pl"}.get(swarm_mode, swarm_mode) + + +def _hostname_slug(value): + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + digest = hashlib.sha1(value.encode("utf-8")).hexdigest()[:10] + return f"{slug[:36].strip('-')}-{digest}" + + +def _has_proxy_server(config_str, server_name): + config = HttpBlock.parse(config_str) + for server in config.servers: + if server_name in server.server_names: + return any(location.proxy_pass is not None for location in server.locations) + return False + + +def test_rescan_during_start_grace_registers_running_backend(tmp_path): + docker_client = DockerTestClient() + docker_client.networks.create("frontend") + hostname = "rescan-grace.example.com" + grace_seconds = 0.3 + config = { + "dummy_nginx": True, + "conf_dir": str(tmp_path / "nginx"), + "challenge_dir": str(tmp_path / "challenges") + "/", + "vhosts_template_dir": "vhosts_template", + "ssl_dir": str(tmp_path / "ssl"), + "cert_renew_threshold_days": 30, + "docker_swarm": "ignore", + "backend_start_grace_seconds": grace_seconds, + "client_max_body_size": "1m", + "default_server": True, + "certapi": None, + "wellknown_path": "/.well-known/acme-challenge/", + "enable_ipv6": False, + } + for path in ("nginx", "challenges", "ssl"): + (tmp_path / path).mkdir() + + with patch("certapi.manager.acme_cert_manager.AcmeCertManager.setup", return_value=None): + webserver = WebServer(docker_client, config, nginx_update_throtle_sec=0.05) + + try: + container = docker_client.containers.run( + "nginx:alpine", + name="rescan_grace_backend", + environment={"VIRTUAL_HOST": hostname}, + network="frontend", + ) + container.attrs["State"]["StartedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + webserver.rescan_and_reload(force=True) + + assert _has_proxy_server(webserver.nginx.current_config, hostname) + finally: + docker_client.close() + webserver.cleanup() + + @pytest.mark.parametrize( "virtual_host_path, request_path, container_received_path", [ @@ -40,8 +108,8 @@ def get_request_url(virtual_host, request_path, scheme="http"): ("/", "", "/"), ("/ -> /", "", "/"), (" -> /", "", "/"), - ("/api ", "/api", "/api"), - ("/api/ ", "/api", "/api/"), # This is weird case. + ("/api ", "/api", "/api"), + ("/api/ ", "/api", "/api/"), # This is weird case. ("/api/ -> /", "/api", "/"), ("/api/ -> /internal", "/api/test", "/internaltest"), ("/api/ -> /internal/", "/api/test", "/internal/test"), @@ -52,28 +120,39 @@ def get_request_url(virtual_host, request_path, scheme="http"): ) @pytest.mark.parametrize("backend_type", ["container", "service"]) def test_http_routing_discovery( - nginx_request, docker_client, test_network, virtual_host_path, request_path, container_received_path, swarm_mode, backend_type,request + nginx_request, + docker_client, + test_network, + virtual_host_path, + request_path, + container_received_path, + swarm_mode, + backend_type, + request, ): """ Test HTTP routing discovery for various swarm modes and backend types. """ - hostname = f"{backend_type}.{swarm_mode}.routing.example.com" + case_slug = _hostname_slug(request.node.name) + hostname = f"{backend_type}.{_hostname_mode_token(swarm_mode)}.{case_slug}.routing.example.com" should_be_reachable = is_reachable(swarm_mode, backend_type) - env = {"VIRTUAL_HOST": hostname+virtual_host_path ,"VIRTUAL_PORT": "8080"} + env = {"VIRTUAL_HOST": hostname + virtual_host_path, "VIRTUAL_PORT": "8080"} backend = None try: - backend = start_backend(docker_client, test_network, env, backend_type=backend_type,pytest_request=request,sleep=False) + backend = start_backend( + docker_client, test_network, env, backend_type=backend_type, pytest_request=request, sleep=False + ) - url = "http://" + hostname+request_path + url = "http://" + hostname + request_path print(f"\nTesting {swarm_mode} with {backend_type}: URL='{url}', expected={should_be_reachable}") # Retrying for async discovery response = None - ex=None + ex = None for x in range(15): try: - ex=None + ex = None response = nginx_request.get(url, timeout=2) if should_be_reachable and response.status_code == 200: break @@ -82,11 +161,11 @@ def test_http_routing_discovery( except SystemExit or KeyboardInterrupt: raise except Exception as e: - ex=e - print(x,e) + ex = e + print(x, e) time.sleep(1) - + assert ex is None assert response is not None @@ -104,7 +183,9 @@ def test_http_routing_discovery( @pytest.mark.parametrize("backend_type", ["container", "service"]) -def test_http_to_https_redirect_preserves_query_string(nginx_request, docker_client, test_network, swarm_mode, backend_type, request): +def test_http_to_https_redirect_preserves_query_string( + nginx_request, docker_client, test_network, swarm_mode, backend_type, request +): """ Test that HTTP->HTTPS redirect keeps the original request URI including query string. """ @@ -116,7 +197,9 @@ def test_http_to_https_redirect_preserves_query_string(nginx_request, docker_cli backend = None try: - backend = start_backend(docker_client, test_network, env, backend_type=backend_type, pytest_request=request, sleep=False) + backend = start_backend( + docker_client, test_network, env, backend_type=backend_type, pytest_request=request, sleep=False + ) request_uri = "/v2/_catalog?n=50&last=abc" url = f"http://{hostname}{request_uri}" @@ -144,6 +227,68 @@ def test_http_to_https_redirect_preserves_query_string(nginx_request, docker_cli stop_backend(backend) +@pytest.mark.parametrize("backend_type", ["container", "service"]) +def test_proxy_full_redirect_to_https_target_response( + nginx_request, + docker_client, + test_network, + swarm_mode, + backend_type, + request, +): + """ + Test that PROXY_FULL_REDIRECT returns a 301 to the existing HTTPS target + while preserving the original request URI. + """ + if not is_reachable(swarm_mode, backend_type): + pytest.skip("Backend discovery not available for this swarm mode/backend type combination.") + + suffix = f"{backend_type[:3]}.{_hostname_mode_token(swarm_mode)}.{datetime.now(timezone.utc).strftime('%H%M%S%f')}" + target_host = f"{suffix}.frt.example.com" + source_host = f"{suffix}.frs.example.com" + env = { + "VIRTUAL_HOST": f"https://{target_host} -> :8080", + "VIRTUAL_PORT": "8080", + "PROXY_FULL_REDIRECT": f"{source_host} -> {target_host}", + } + backend = None + + try: + backend = start_backend( + docker_client, + test_network, + env, + backend_type=backend_type, + pytest_request=request, + sleep=False, + ) + + request_uri = "/v2/_catalog?n=50&last=abc" + url = f"http://{source_host}{request_uri}" + + response = None + ex = None + for _ in range(20): + try: + ex = None + response = nginx_request.get(url, timeout=2, allow_redirects=False) + if response.status_code == 301: + break + except (KeyboardInterrupt, SystemExit): + raise + except Exception as e: + ex = e + time.sleep(1) + + assert ex is None + assert response is not None + assert response.status_code == 301 + assert response.headers.get("Location") == f"https://{target_host}{request_uri}" + finally: + if backend: + stop_backend(backend) + + @pytest.mark.parametrize("backend_type", ["container", "service"]) @pytest.mark.parametrize( "virtual_host_base, request_path", @@ -153,7 +298,9 @@ def test_http_to_https_redirect_preserves_query_string(nginx_request, docker_cli ("ws.example.com/ws_path", "/ws_path"), ], ) -def test_websocket_routing(nginx_request, docker_client, test_network, virtual_host_base, request_path, swarm_mode, backend_type): +def test_websocket_routing( + nginx_request, docker_client, test_network, virtual_host_base, request_path, swarm_mode, backend_type +): """ Test WebSocket routing for various VIRTUAL_HOST configurations. """ @@ -170,7 +317,9 @@ def test_websocket_routing(nginx_request, docker_client, test_network, virtual_h # We need to construct the full URL that the NginxRequest class will parse for the host. full_url_for_host_parsing = get_request_url(virtual_host, request_path, scheme="ws") - print(f"\nTesting WebSocket {swarm_mode} with {backend_type}: VIRTUAL_HOST='{virtual_host}', WS_URL='{full_url_for_host_parsing}', expected={should_be_reachable}") + print( + f"\nTesting WebSocket {swarm_mode} with {backend_type}: VIRTUAL_HOST='{virtual_host}', WS_URL='{full_url_for_host_parsing}', expected={should_be_reachable}" + ) # Retrying for async discovery connected = False @@ -182,7 +331,7 @@ def test_websocket_routing(nginx_request, docker_client, test_network, virtual_h except Exception: if not should_be_reachable: # Connection failed as expected (e.g. 503/404) - break + break time.sleep(1) if should_be_reachable: diff --git a/tests/integration/test_prefer_local_swarm.py b/tests/integration/test_prefer_local_swarm.py new file mode 100644 index 0000000..62463f6 --- /dev/null +++ b/tests/integration/test_prefer_local_swarm.py @@ -0,0 +1,49 @@ +import time +import uuid + +import pytest + +from nginx.NginxConf import HttpBlock +from tests.helpers.docker_utils import start_backend, stop_backend +from tests.helpers.integration_helpers import get_nginx_config_from_container + + +@pytest.mark.swarm_mode("prefer-local") +def test_prefer_local_uses_local_swarm_task_primary_and_service_vip_backup( + nginx_proxy_container, + docker_client, + test_network, +): + virtual_host = f"prefer-local-{uuid.uuid4().hex[:6]}.example.com" + env = {"VIRTUAL_HOST": f"{virtual_host} -> :8080"} + backend = start_backend(docker_client, test_network, env, backend_type="service", sleep=False) + + try: + upstream = None + config_str = "" + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + config_str = get_nginx_config_from_container(nginx_proxy_container[0]) + config = HttpBlock.parse(config_str) + upstream = next((u for u in config.upstreams if virtual_host in u.parameters), None) + if upstream: + server_directives = upstream.get_directives("server") + server_values = [" ".join(directive.values) for directive in server_directives] + has_primary = any("backup" not in values for values in server_values) + has_backup = any("backup" in values for values in server_values) + if len(server_directives) == 2 and has_primary and has_backup: + break + time.sleep(1) + + assert upstream is not None, f"Upstream block for {virtual_host} not found. Config:\n{config_str}" + server_directives = upstream.get_directives("server") + server_values = [" ".join(directive.values) for directive in server_directives] + assert len(server_directives) == 2, f"Expected local task plus service VIP. Config:\n{config_str}" + assert any("backup" not in values for values in server_values), f"Expected local primary. Config:\n{config_str}" + assert any( + "backup" in values for values in server_values + ), f"Expected service VIP backup. Config:\n{config_str}" + assert "# container:" in config_str + assert "# service:" in config_str + finally: + stop_backend(backend) diff --git a/tests/integration/test_webserver_events.py b/tests/integration/test_webserver_events.py index d8a464c..b28c89f 100644 --- a/tests/integration/test_webserver_events.py +++ b/tests/integration/test_webserver_events.py @@ -6,18 +6,44 @@ import requests from typing import List +from docker.types import Healthcheck from nginx.NginxConf import HttpBlock, NginxConfig, ServerBlock from tests.helpers.docker_utils import start_backend, stop_backend -from tests.helpers import get_nginx_config_from_container,expect_server_down_integration, expect_server_not_present_integration, expect_server_up_integration +from tests.helpers import ( + get_nginx_config_from_container, + expect_server_down_integration, + expect_server_not_present_integration, + expect_server_up_integration, +) -@pytest.fixture(scope="session", params=["enable"], ids=["swarm_enable"]) -def swarm_mode(request): - return request.param + +pytestmark = pytest.mark.swarm_mode("enable") # Regex to match the dynamically assigned IP:PORT for proxy_pass # Example: http://172.18.0.2:80 pattern = re.compile(r"^http://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:80") +START_GRACE_SECONDS = 2 + + +def _http_healthcheck() -> Healthcheck: + return Healthcheck( + test="node -e \"require('http').get('http://127.0.0.1:8080', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\"", + interval=1_000_000_000, + timeout=1_000_000_000, + retries=2, + start_period=0, + ) + + +def _marker_healthcheck() -> Healthcheck: + return Healthcheck( + test="test -f /tmp/nginx-proxy-health-ready && node -e \"require('http').get('http://127.0.0.1:8080', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\"", + interval=1_000_000_000, + timeout=1_000_000_000, + retries=1, + start_period=0, + ) def test_webserver_initialization_integration(nginx_proxy_container: docker.models.containers.Container): @@ -83,6 +109,86 @@ def test_webserver_add_container_integration( stop_backend(backend) +def test_container_start_grace_delays_config_update( + nginx_proxy_container, + docker_client, + test_network, +): + virtual_host = f"container.grace-{uuid.uuid4().hex[:6]}.example.com" + before_config = get_nginx_config_from_container(nginx_proxy_container[0]) + backend = None + try: + backend = start_backend( + docker_client, + test_network, + {"VIRTUAL_HOST": virtual_host}, + backend_type="container", + sleep=False, + ) + + time.sleep(START_GRACE_SECONDS / 2) + within_grace_config = get_nginx_config_from_container(nginx_proxy_container[0]) + assert within_grace_config == before_config + + expect_server_up_integration(nginx_proxy_container[0], virtual_host, timeout=20) + after_grace_config = get_nginx_config_from_container(nginx_proxy_container[0]) + assert after_grace_config != before_config + finally: + if backend: + stop_backend(backend) + + +def test_healthchecked_container_waits_until_healthy_integration( + nginx_proxy_container, + docker_client, + test_network, +): + virtual_host = f"container.health-{uuid.uuid4().hex[:6]}.example.com" + backend = None + try: + backend = start_backend( + docker_client, + test_network, + {"VIRTUAL_HOST": virtual_host, "VIRTUAL_PORT": "8080"}, + backend_type="container", + sleep=False, + healthcheck=_marker_healthcheck(), + ) + + time.sleep(START_GRACE_SECONDS + 1) + expect_server_not_present_integration(nginx_proxy_container[0], virtual_host, timeout=1) + + backend.exec_run("touch /tmp/nginx-proxy-health-ready") + + expect_server_up_integration(nginx_proxy_container[0], virtual_host, timeout=20) + finally: + if backend: + stop_backend(backend) + + +def test_healthchecked_service_is_discovered_integration( + nginx_proxy_container, + docker_client, + test_network, +): + virtual_host = f"service.health-{uuid.uuid4().hex[:6]}.example.com" + backend = None + try: + backend = start_backend( + docker_client, + test_network, + {"VIRTUAL_HOST": virtual_host, "VIRTUAL_PORT": "8080"}, + backend_type="service", + sleep=False, + healthcheck=_http_healthcheck(), + ) + + expect_server_up_integration(nginx_proxy_container[0], virtual_host, timeout=20) + finally: + if backend: + stop_backend(backend) + + @pytest.mark.parametrize("backend_type", ["container", "service"]) def test_webserver_remove_container_integration( nginx_proxy_container, @@ -126,7 +232,7 @@ def test_webserver_add_network_integration( other_network = docker_client.networks.create(other_network_name, driver="bridge") backend = None try: - backend = start_backend(docker_client, other_network, env, backend_type=backend_type,sleep=False) + backend = start_backend(docker_client, other_network, env, backend_type=backend_type, sleep=False) expect_server_not_present_integration(nginx_proxy_container[0], virtual_host, timeout=15) @@ -158,7 +264,7 @@ def test_webserver_remove_network_integration( virtual_host = backend_type + "." + "removenet.example.com" env = {"VIRTUAL_HOST": virtual_host} - backend = start_backend(docker_client, test_network, env, backend_type=backend_type,sleep=False) + backend = start_backend(docker_client, test_network, env, backend_type=backend_type, sleep=False) expect_server_up_integration(nginx_proxy_container[0], virtual_host, timeout=15) # Disconnect the container from the test_network @@ -185,7 +291,7 @@ def test_webserver_recreate_same_name_container_with_different_host_integration( # Create with old env backend_old = start_backend( - docker_client, test_network, {"VIRTUAL_HOST": old_virtual_host}, backend_type=backend_type,sleep=False + docker_client, test_network, {"VIRTUAL_HOST": old_virtual_host}, backend_type=backend_type, sleep=False ) expect_server_up_integration(nginx_proxy_container[0], old_virtual_host, timeout=15) @@ -195,8 +301,7 @@ def test_webserver_recreate_same_name_container_with_different_host_integration( expect_server_down_integration(nginx_proxy_container[0], old_virtual_host, timeout=15) backend_new = start_backend( - docker_client, test_network, {"VIRTUAL_HOST": new_virtual_host}, backend_type=backend_type - ,sleep=False + docker_client, test_network, {"VIRTUAL_HOST": new_virtual_host}, backend_type=backend_type, sleep=False ) try: expect_server_up_integration(nginx_proxy_container[0], new_virtual_host, timeout=15) @@ -218,7 +323,7 @@ def test_webserver_add_container_with_ssl_integration( and HTTPS server blocks with self-signed certificates. """ virtual_host = backend_type + "." + "ssl-test.example.com" - env = {"VIRTUAL_HOST": f"https://{virtual_host}","VIRTUAL_PORT":"8080"} + env = {"VIRTUAL_HOST": f"https://{virtual_host}", "VIRTUAL_PORT": "8080"} backend = start_backend(docker_client, test_network, env, backend_type=backend_type) try: @@ -250,6 +355,55 @@ def test_webserver_add_container_with_ssl_integration( stop_backend(backend) +@pytest.mark.parametrize("backend_type", ["container", "service"]) +def test_proxy_full_redirect_to_https_target_integration( + nginx_proxy_container: docker.models.containers.Container, + docker_client: docker.DockerClient, + test_network: docker.models.networks.Network, + backend_type: str, +): + """ + Test that PROXY_FULL_REDIRECT resolves a bare target to the existing HTTPS vhost + and renders a documented 301 redirect without appending :80. + """ + suffix = uuid.uuid4().hex[:6] + target_host = f"{backend_type}.full-redirect-target-{suffix}.example.com" + source_host = f"{backend_type}.full-redirect-source-{suffix}.example.com" + env = { + "VIRTUAL_HOST": f"https://{target_host} -> :8080", + "VIRTUAL_PORT": "8080", + "PROXY_FULL_REDIRECT": f"{source_host} -> {target_host}", + } + + backend = start_backend(docker_client, test_network, env, backend_type=backend_type, sleep=False) + try: + redirect_server = None + for _ in range(25): + config_str = get_nginx_config_from_container(nginx_proxy_container[0]) + config = HttpBlock.parse(config_str) + redirect_server = next((s for s in config.servers if source_host in s.server_names), None) + if redirect_server is not None: + redirect_loc = next((loc for loc in redirect_server.locations if loc.path == "/"), None) + if redirect_loc and redirect_loc.return_code == f"301 https://{target_host}$request_uri": + break + time.sleep(1) + + config_str = get_nginx_config_from_container(nginx_proxy_container[0]) + config = HttpBlock.parse(config_str) + target_servers = [s for s in config.servers if target_host in s.server_names] + source_servers = [s for s in config.servers if source_host in s.server_names] + + assert any("443" in s.listen for s in target_servers), f"HTTPS target server not found. Config:\n{config_str}" + assert len(source_servers) == 1, f"Expected one redirect source server. Config:\n{config_str}" + + redirect_loc = next((loc for loc in source_servers[0].locations if loc.path == "/"), None) + assert redirect_loc is not None, f"Redirect location not found. Config:\n{config_str}" + assert redirect_loc.return_code == f"301 https://{target_host}$request_uri" + finally: + if backend: + stop_backend(backend) + + @pytest.mark.parametrize("backend_type", ["container", "service"]) def test_webserver_add_two_containers_with_same_virtual_host_integration( nginx_proxy_container, @@ -279,7 +433,9 @@ def test_webserver_add_two_containers_with_same_virtual_host_integration( config_str = get_nginx_config_from_container(nginx_proxy_container[0]) assert upstream is not None, f"Upstream block for {virtual_host} not found after timeout. Config:\n{config_str}" - assert len(upstream.get_directives("server")) == 2, f"Expected 2 servers in upstream, found {len(upstream.get_directives('server'))}. Config:\n{config_str}" + assert ( + len(upstream.get_directives("server")) == 2 + ), f"Expected 2 servers in upstream, found {len(upstream.get_directives('server'))}. Config:\n{config_str}" finally: stop_backend(backend1) stop_backend(backend2) diff --git a/tests/unit/test_backend_target.py b/tests/unit/test_backend_target.py index a6b380f..7ea8f48 100644 --- a/tests/unit/test_backend_target.py +++ b/tests/unit/test_backend_target.py @@ -19,6 +19,7 @@ def test_backend_target_init_defaults(self): assert bt.labels == {} assert bt.network_settings == {} assert bt.ports == {} + assert bt.backup is False def test_backend_target_from_container(self): container = MagicMock() @@ -52,21 +53,15 @@ def test_from_service(self): "Spec": { "Name": "my-web-service", "Labels": {"com.example.foo": "bar"}, - "TaskTemplate": { - "ContainerSpec": { - "Env": ["VIRTUAL_HOST=web.service.local", "DB_HOST=db.local"] - } - } + "TaskTemplate": {"ContainerSpec": {"Env": ["VIRTUAL_HOST=web.service.local", "DB_HOST=db.local"]}}, }, "Endpoint": { - "Ports": [ - {"Protocol": "tcp", "TargetPort": 80, "PublishedPort": 8080} - ], + "Ports": [{"Protocol": "tcp", "TargetPort": 80, "PublishedPort": 8080}], "VirtualIPs": [ {"NetworkID": "net1", "Addr": "10.0.0.5/24"}, - {"NetworkID": "net2", "Addr": "192.168.1.5/24"} - ] - } + {"NetworkID": "net2", "Addr": "192.168.1.5/24"}, + ], + }, } bt = BackendTarget.from_service(service) @@ -128,6 +123,21 @@ def test_process_virtual_hosts_integration(self): assert len(hosts) == 1 assert hosts[0].hostname == "int.test" + def test_process_virtual_hosts_preserves_service_backend_type(self): + bt = BackendTarget( + id="service-id", + name="service-test", + env={"VIRTUAL_HOST": "svc.test -> :8080"}, + network_settings={"int-net": {"NetworkID": "int-net-id", "IPAddress": "10.0.0.10"}}, + backend_type="service", + ) + known_networks = {"int-net-id"} + + config_data = process_virtual_hosts(bt, known_networks) + + hosts = list(config_data.host_list()) + backend = hosts[0].locations["/"].backends[0] + assert backend.type == "service" def test_duplicate_injected_directives_are_deduplicated(self): known_networks = {"shared-net-id"} @@ -163,7 +173,6 @@ def test_duplicate_injected_directives_are_deduplicated(self): injections = host.locations["/"].extras.get("injected", []) assert injections.count("client_max_body_size 200M") == 1 - def test_process_virtual_hosts_no_virtual_host(self): bt = BackendTarget( id="no-host-id", @@ -219,6 +228,78 @@ def test_process_virtual_hosts_uses_next_network_when_first_ip_blank(self): backend = location.backends[0] assert backend.address == "10.0.0.12" + def test_https_virtual_host_rejects_certificate_hostname_longer_than_64_chars(self): + long_hostname = f"{'a' * 55}.example.com" + assert len(long_hostname) > 64 + bt = BackendTarget( + id="long-https-id", + name="long-https-test", + env={"VIRTUAL_HOST": f"https://{long_hostname}"}, + network_settings={"my-net": {"NetworkID": "my-net-id", "IPAddress": "10.0.0.12"}}, + ) + + config_data = process_virtual_hosts(bt, {"my-net-id"}) + + assert len(list(config_data.host_list())) == 0 + + def test_http_virtual_host_allows_dns_valid_hostname_longer_than_64_chars(self): + long_hostname = f"{'a' * 55}.example.com" + assert len(long_hostname) > 64 + bt = BackendTarget( + id="long-http-id", + name="long-http-test", + env={"VIRTUAL_HOST": long_hostname}, + network_settings={"my-net": {"NetworkID": "my-net-id", "IPAddress": "10.0.0.12"}}, + ) + + config_data = process_virtual_hosts(bt, {"my-net-id"}) + + hosts = list(config_data.host_list()) + assert len(hosts) == 1 + assert hosts[0].hostname == long_hostname + + def test_virtual_host_rejects_invalid_hostname(self): + bt = BackendTarget( + id="invalid-host-id", + name="invalid-host-test", + env={"VIRTUAL_HOST": "bad_host.example.com"}, + network_settings={"my-net": {"NetworkID": "my-net-id", "IPAddress": "10.0.0.12"}}, + ) + + config_data = process_virtual_hosts(bt, {"my-net-id"}) + + assert len(list(config_data.host_list())) == 0 + + def test_https_virtual_host_allows_certificate_hostname_at_64_chars(self): + hostname = f"{'a' * 52}.example.com" + assert len(hostname) == 64 + bt = BackendTarget( + id="max-https-id", + name="max-https-test", + env={"VIRTUAL_HOST": f"https://{hostname}"}, + network_settings={"my-net": {"NetworkID": "my-net-id", "IPAddress": "10.0.0.12"}}, + ) + + config_data = process_virtual_hosts(bt, {"my-net-id"}) + + hosts = list(config_data.host_list()) + assert len(hosts) == 1 + assert hosts[0].hostname == hostname + + def test_virtual_host_allows_wildcard_hostname(self): + bt = BackendTarget( + id="wildcard-host-id", + name="wildcard-host-test", + env={"VIRTUAL_HOST": "https://*.example.com"}, + network_settings={"my-net": {"NetworkID": "my-net-id", "IPAddress": "10.0.0.12"}}, + ) + + config_data = process_virtual_hosts(bt, {"my-net-id"}) + + hosts = list(config_data.host_list()) + assert len(hosts) == 1 + assert hosts[0].hostname == "*.example.com" + def test_parse_host_entry_simple(self): h, loc, c, extras = _parse_host_entry("example.com") assert h.hostname == "example.com" diff --git a/tests/unit/test_docker_event_listener.py b/tests/unit/test_docker_event_listener.py index 1b6c33f..2314eb0 100644 --- a/tests/unit/test_docker_event_listener.py +++ b/tests/unit/test_docker_event_listener.py @@ -3,7 +3,9 @@ import threading import time -from nginx_proxy.DockerEventListener import DockerEventListener +import docker + +from nginx_proxy.DockerEventListener import ContainerEvent, DockerEventListener from nginx_proxy.WebServer import WebServer @@ -26,14 +28,22 @@ def swarm_client(): def test_run_with_separate_clients(web_server, docker_client, swarm_client): listener = DockerEventListener(web_server, docker_client, swarm_client) - with patch('threading.Thread') as mock_thread: + with ( + patch.object(listener, "start_dispatcher"), + patch.object(listener, "stop_dispatcher"), + patch("threading.Thread") as mock_thread, + ): listener.run() assert mock_thread.call_count == 2 def test_run_with_same_client(web_server, docker_client): listener = DockerEventListener(web_server, docker_client, docker_client) - with patch.object(listener, '_listen') as mock_listen: + with ( + patch.object(listener, "start_dispatcher"), + patch.object(listener, "stop_dispatcher"), + patch.object(listener, "_listen") as mock_listen, + ): listener.run() mock_listen.assert_called_once_with(docker_client) @@ -42,35 +52,105 @@ def test_listen_for_container_events(web_server, docker_client): web_server.config = {"docker_swarm": "ignore"} listener = DockerEventListener(web_server, docker_client, docker_client) - docker_client.events.return_value = iter([ - {"Type": "container", "Action": "start", "id": "container1"}, - {"Type": "container", "Action": "stop", "id": "container2"}, - ]) + docker_client.events.return_value = iter( + [ + {"Type": "container", "Action": "start", "id": "container1"}, + {"Type": "container", "Action": "stop", "id": "container2"}, + ] + ) - with patch.object(listener, '_process_container_event') as mock_process: + with patch.object(listener, "enqueue") as mock_enqueue: t = threading.Thread(target=listener._listen, args=(docker_client,)) t.daemon = True t.start() time.sleep(0.1) - assert mock_process.call_count == 2 + assert mock_enqueue.call_count == 2 + assert all(isinstance(call.args[0], ContainerEvent) for call in mock_enqueue.call_args_list) docker_client.events.return_value = iter([]) t.join(timeout=0.1) -def test_process_service_event_update(web_server:WebServer, docker_client, swarm_client): +def test_process_service_event_update(web_server: WebServer, docker_client, swarm_client): listener = DockerEventListener(web_server, docker_client, swarm_client) service_id = "service1" event = {"Action": "update", "Actor": {"ID": service_id}} + + with patch.object(listener, "_schedule_service_processing") as mock_schedule: + listener._process_service_event("update", event) + + mock_schedule.assert_called_once_with(service_id, "update", 5, attempt=1) + swarm_client.services.get.assert_not_called() + web_server.update_backend.assert_not_called() + + +def test_process_service_upsert_updates_backend_when_vip_is_reachable( + web_server: WebServer, docker_client, swarm_client +): + web_server.networks = {"net1": "frontend", "frontend": "net1"} + listener = DockerEventListener(web_server, docker_client, swarm_client) + service_id = "service1" mock_service = MagicMock() + mock_service.id = service_id + mock_service.attrs = { + "Spec": { + "Name": "service-name", + "Labels": {}, + "TaskTemplate": {"ContainerSpec": {"Env": ["VIRTUAL_HOST=service.example.com"]}}, + }, + "Endpoint": { + "Ports": [{"Protocol": "tcp", "TargetPort": 80}], + "VirtualIPs": [{"NetworkID": "net1", "Addr": "10.0.0.5/24"}], + }, + } swarm_client.services.get.return_value = mock_service - listener._process_service_event("update", event) + listener._process_service_upsert(service_id, "update", attempt=1) swarm_client.services.get.assert_called_once_with(service_id) web_server.update_backend.assert_called_once() -def test_process_service_event_remove(web_server:WebServer, docker_client, swarm_client): +def test_process_service_upsert_retries_when_service_is_not_found(web_server: WebServer, docker_client, swarm_client): + listener = DockerEventListener(web_server, docker_client, swarm_client) + service_id = "service1" + swarm_client.services.get.side_effect = docker.errors.NotFound("missing") + + with patch.object(listener, "_schedule_service_processing") as mock_schedule: + listener._process_service_upsert(service_id, "create", attempt=1) + + mock_schedule.assert_called_once_with(service_id, "create", 20, attempt=2) + web_server.update_backend.assert_not_called() + + +def test_process_service_upsert_retries_when_service_vip_is_not_reachable( + web_server: WebServer, docker_client, swarm_client +): + web_server.networks = {"net1": "frontend", "frontend": "net1"} + listener = DockerEventListener(web_server, docker_client, swarm_client) + service_id = "service1" + mock_service = MagicMock() + mock_service.id = service_id + mock_service.attrs = { + "Spec": { + "Name": "service-name", + "Labels": {}, + "TaskTemplate": {"ContainerSpec": {"Env": ["VIRTUAL_HOST=service.example.com"]}}, + }, + "Endpoint": { + "Ports": [{"Protocol": "tcp", "TargetPort": 80}], + "VirtualIPs": [], + }, + } + swarm_client.services.get.return_value = mock_service + + with patch.object(listener, "_schedule_service_processing") as mock_schedule: + listener._process_service_upsert(service_id, "create", attempt=1) + + mock_schedule.assert_called_once_with(service_id, "create", 20, attempt=2) + web_server.update_backend.assert_not_called() + + +def test_process_service_event_remove(web_server: WebServer, docker_client, swarm_client): listener = DockerEventListener(web_server, docker_client, swarm_client) service_id = "service1" event = {"Action": "remove", "Actor": {"ID": service_id}} @@ -78,3 +158,302 @@ def test_process_service_event_remove(web_server:WebServer, docker_client, swarm listener._process_service_event("remove", event) web_server.remove_backend.assert_called_once_with(service_id) + + +def test_swarm_task_container_event_is_ignored_in_enable(web_server, docker_client): + web_server.config = {"docker_swarm": "enable", "backend_start_grace_seconds": 0} + listener = DockerEventListener(web_server, docker_client, docker_client) + + listener._process_container_event( + "start", + { + "Actor": { + "ID": "container1", + "Attributes": {"com.docker.swarm.service.id": "service1"}, + } + }, + ) + + web_server.update_backend.assert_not_called() + docker_client.containers.get.assert_not_called() + + +def test_swarm_task_container_event_is_processed_in_prefer_local(web_server, docker_client): + web_server.config = {"docker_swarm": "prefer-local", "backend_start_grace_seconds": 0} + listener = DockerEventListener(web_server, docker_client, docker_client) + container = MagicMock() + container.id = "container1" + container.attrs = { + "Config": { + "Env": [], + "Labels": {"com.docker.swarm.service.id": "service1"}, + }, + "State": {"Status": "running"}, + "Name": "/swarm-task", + "NetworkSettings": {"Networks": {}, "Ports": {}}, + } + docker_client.containers.get.return_value = container + + listener._process_container_event( + "start", + { + "Actor": { + "ID": "container1", + "Attributes": {"com.docker.swarm.service.id": "service1"}, + } + }, + ) + + web_server.update_backend.assert_called_once() + + +def test_swarm_task_container_healthcheck_waits_in_prefer_local(web_server, docker_client): + web_server.config = {"docker_swarm": "prefer-local", "backend_start_grace_seconds": 0} + listener = DockerEventListener(web_server, docker_client, docker_client) + container = MagicMock() + container.name = "swarm-health-task" + container.attrs = { + "Config": { + "Healthcheck": {"Test": ["CMD", "curl", "-f", "http://localhost/health"]}, + "Labels": {"com.docker.swarm.service.id": "service1"}, + }, + "State": {"Status": "running", "Health": {"Status": "starting"}}, + "Name": "/swarm-health-task", + "NetworkSettings": {"Networks": {}, "Ports": {}}, + } + docker_client.containers.get.return_value = container + + listener._process_container_event( + "start", + { + "Actor": { + "ID": "container1", + "Attributes": {"com.docker.swarm.service.id": "service1"}, + } + }, + ) + + web_server.update_backend.assert_not_called() + assert "container1" in listener._waiting_for_healthy + + +def test_process_container_start_with_healthcheck_waits_for_healthy(web_server, docker_client): + web_server.config = {"docker_swarm": "ignore", "backend_start_grace_seconds": 0} + listener = DockerEventListener(web_server, docker_client, docker_client) + container = MagicMock() + container.name = "health-container" + container.attrs = { + "Config": {"Healthcheck": {"Test": ["CMD", "curl", "-f", "http://localhost/health"]}}, + "State": {"Status": "running", "Health": {"Status": "starting"}}, + } + docker_client.containers.get.return_value = container + + with patch("builtins.print") as mock_print: + listener._process_container_event("start", {"Actor": {"ID": "container1", "Attributes": {}}}) + + web_server.update_backend.assert_not_called() + mock_print.assert_called_once_with( + "Container waiting ", + "Id:container1", + " health-container", + "for healthy", + sep="\t", + ) + + +def test_process_container_healthy_event_adds_backend(web_server, docker_client): + web_server.config = {"docker_swarm": "ignore", "backend_start_grace_seconds": 0} + listener = DockerEventListener(web_server, docker_client, docker_client) + container = MagicMock() + container.id = "container1" + container.attrs = { + "Config": {"Env": [], "Labels": {}, "Healthcheck": {"Test": ["CMD", "curl", "-f", "http://localhost/health"]}}, + "State": {"Status": "running", "Health": {"Status": "healthy"}}, + "Name": "/healthy-container", + "NetworkSettings": {"Networks": {}, "Ports": {}}, + } + docker_client.containers.get.return_value = container + + listener._process_container_health_event( + "health_status: healthy", + {"Actor": {"ID": "container1", "Attributes": {}}}, + ) + + web_server.update_backend.assert_called_once() + + +def test_process_container_unhealthy_event_removes_backend(web_server, docker_client): + web_server.config = {"docker_swarm": "ignore", "backend_start_grace_seconds": 0} + listener = DockerEventListener(web_server, docker_client, docker_client) + + listener._process_container_health_event( + "health_status: unhealthy", + {"Actor": {"ID": "container1", "Attributes": {}}}, + ) + + web_server.remove_backend.assert_called_once_with("container1") + web_server.update_backend.assert_not_called() + + +def test_process_container_start_with_grace_period_defers_activation(web_server, docker_client): + web_server.config = {"docker_swarm": "ignore", "backend_start_grace_seconds": 0.2} + listener = DockerEventListener(web_server, docker_client, docker_client) + container = MagicMock() + container.id = "container1" + container.attrs = { + "Config": {"Env": [], "Labels": {}}, + "State": {"Status": "running"}, + "Name": "/grace-container", + "NetworkSettings": {"Networks": {}, "Ports": {}}, + } + docker_client.containers.get.return_value = container + + with patch("builtins.print") as mock_print: + listener._process_container_event("start", {"Actor": {"ID": "container1", "Attributes": {}}}) + web_server.update_backend.assert_not_called() + mock_print.assert_not_called() + + time.sleep(0.3) + web_server.update_backend.assert_not_called() + listener.drain_commands() + + web_server.update_backend.assert_called_once() + + +def test_service_delay_timer_enqueues_upsert_without_calling_webserver(web_server, docker_client, swarm_client): + web_server.networks = {"net1": "frontend", "frontend": "net1"} + listener = DockerEventListener(web_server, docker_client, swarm_client) + service_id = "service1" + mock_service = MagicMock() + mock_service.id = service_id + mock_service.attrs = { + "Spec": { + "Name": "service-name", + "Labels": {}, + "TaskTemplate": {"ContainerSpec": {"Env": ["VIRTUAL_HOST=service.example.com"]}}, + }, + "Endpoint": { + "Ports": [{"Protocol": "tcp", "TargetPort": 80}], + "VirtualIPs": [{"NetworkID": "net1", "Addr": "10.0.0.5/24"}], + }, + } + swarm_client.services.get.return_value = mock_service + + listener._schedule_service_processing(service_id, "update", 0.01, attempt=1) + time.sleep(0.05) + + swarm_client.services.get.assert_not_called() + web_server.update_backend.assert_not_called() + + listener.drain_commands() + + swarm_client.services.get.assert_called_once_with(service_id) + web_server.update_backend.assert_called_once() + + +def test_process_container_die_cancels_pending_activation(web_server, docker_client): + web_server.config = {"docker_swarm": "ignore", "backend_start_grace_seconds": 1} + listener = DockerEventListener(web_server, docker_client, docker_client) + container = MagicMock() + container.id = "container1" + container.name = "dying-container" + container.attrs = { + "Config": {"Env": [], "Labels": {}}, + "State": {"Status": "running"}, + "Name": "/dying-container", + "NetworkSettings": {"Networks": {}, "Ports": {}}, + } + docker_client.containers.get.return_value = container + + listener._process_container_event("start", {"Actor": {"ID": "container1", "Attributes": {}}}) + with patch("builtins.print") as mock_print: + listener._process_container_event( + "die", {"Actor": {"ID": "container1", "Attributes": {"name": "dying-container"}}} + ) + + time.sleep(0.1) + + web_server.update_backend.assert_not_called() + web_server.remove_backend.assert_called_once_with("container1") + mock_print.assert_called_once_with( + "Container crashed ", + "Id:container1", + " dying-container", + sep="\t", + ) + + +def test_process_healthchecked_container_die_logs_crash_while_waiting(web_server, docker_client): + web_server.config = {"docker_swarm": "ignore", "backend_start_grace_seconds": 1} + listener = DockerEventListener(web_server, docker_client, docker_client) + container = MagicMock() + container.id = "container1" + container.name = "health-dying-container" + container.attrs = { + "Config": {"Healthcheck": {"Test": ["CMD", "curl", "-f", "http://localhost/health"]}}, + "State": {"Status": "running", "Health": {"Status": "starting"}}, + "Name": "/health-dying-container", + "NetworkSettings": {"Networks": {}, "Ports": {}}, + } + docker_client.containers.get.return_value = container + + listener._process_container_event("start", {"Actor": {"ID": "container1", "Attributes": {}}}) + with patch("builtins.print") as mock_print: + listener._process_container_event( + "die", + {"Actor": {"ID": "container1", "Attributes": {"name": "health-dying-container"}}}, + ) + + web_server.update_backend.assert_not_called() + web_server.remove_backend.assert_called_once_with("container1") + mock_print.assert_called_once_with( + "Container crashed ", + "Id:container1", + " health-dying-container", + sep="\t", + ) + + +def test_network_connect_is_ignored_for_container_still_starting(web_server, docker_client): + web_server.config = {"docker_swarm": "ignore", "backend_start_grace_seconds": 1} + listener = DockerEventListener(web_server, docker_client, docker_client) + container = MagicMock() + container.id = "container1" + container.status = "created" + container.attrs = { + "Config": {"Env": [], "Labels": {}}, + "State": {"Status": "created"}, + "Name": "/starting-container", + "NetworkSettings": {"Networks": {"frontend": {}}, "Ports": {}}, + } + docker_client.containers.get.return_value = container + + listener._process_network_event( + "connect", + {"Actor": {"ID": "network1", "Attributes": {"container": "container1"}}, "scope": "local"}, + ) + + web_server.connect.assert_not_called() + + +def test_network_connect_is_ignored_for_unhealthy_container(web_server, docker_client): + web_server.config = {"docker_swarm": "ignore", "backend_start_grace_seconds": 0} + listener = DockerEventListener(web_server, docker_client, docker_client) + listener._started_containers.add("container1") + container = MagicMock() + container.id = "container1" + container.status = "running" + container.attrs = { + "Config": {"Healthcheck": {"Test": ["CMD", "curl", "-f", "http://localhost/health"]}, "Labels": {}}, + "State": {"Status": "running", "Health": {"Status": "unhealthy"}}, + "Name": "/unhealthy-container", + "NetworkSettings": {"Networks": {"frontend": {}}, "Ports": {}}, + } + docker_client.containers.get.return_value = container + + listener._process_network_event( + "connect", + {"Actor": {"ID": "network1", "Attributes": {"container": "container1"}}, "scope": "local"}, + ) + + web_server.connect.assert_not_called() diff --git a/tests/unit/test_error_resilience.py b/tests/unit/test_error_resilience.py index 6af3b50..a6b91a5 100644 --- a/tests/unit/test_error_resilience.py +++ b/tests/unit/test_error_resilience.py @@ -291,9 +291,10 @@ def test_failed_initial_request_delegates_fallback_to_renewal_manager(webserver_ processor = webserver.ssl_processor hosts = [Host(hostname="test-fallback-delegated.example.com", port=443, scheme={"https"})] - with patch.object(processor.cert_manager, "obtain") as mock_obtain, patch.object( - processor.renewal_manager, "update_watch_domains" - ) as mock_update_watch_domains: + with ( + patch.object(processor.cert_manager, "obtain") as mock_obtain, + patch.object(processor.renewal_manager, "update_watch_domains") as mock_update_watch_domains, + ): processor.process_ssl_certificates(hosts) mock_obtain.assert_not_called() @@ -351,9 +352,10 @@ def find_cert(domain): return ("*.example.com", Mock(), [fresh_cert]) return None - with patch.object(webserver.ssl_processor.key_store, "find_key_and_cert_by_domain", side_effect=find_cert), patch.object( - webserver.ssl_processor.renewal_manager, "update_watch_domains" - ) as mock_update_watch_domains: + with ( + patch.object(webserver.ssl_processor.key_store, "find_key_and_cert_by_domain", side_effect=find_cert), + patch.object(webserver.ssl_processor.renewal_manager, "update_watch_domains") as mock_update_watch_domains, + ): webserver.ssl_processor.process_ssl_certificates(hosts) assert hosts[0].ssl_file == "*.example.com" @@ -374,9 +376,10 @@ def find_cert(domain): return ("*.example.com", Mock(), [expiring_cert]) return None - with patch.object(webserver.ssl_processor.key_store, "find_key_and_cert_by_domain", side_effect=find_cert), patch.object( - webserver.ssl_processor.renewal_manager, "update_watch_domains" - ) as mock_update_watch_domains: + with ( + patch.object(webserver.ssl_processor.key_store, "find_key_and_cert_by_domain", side_effect=find_cert), + patch.object(webserver.ssl_processor.renewal_manager, "update_watch_domains") as mock_update_watch_domains, + ): webserver.ssl_processor.process_ssl_certificates(hosts) assert hosts[0].ssl_file == "*.example.com" @@ -397,7 +400,9 @@ def find_cert(domain): with ( patch.object(processor.key_store, "find_key_and_cert_by_domain", side_effect=find_cert), - patch.object(processor.cert_manager, "obtain", side_effect=CertApiException("ACME renewal failed", step="Test")) as mock_obtain, + patch.object( + processor.cert_manager, "obtain", side_effect=CertApiException("ACME renewal failed", step="Test") + ) as mock_obtain, ): hosts = [Host(hostname=hostname, port=443, scheme={"https"})] webserver.ssl_processor.process_ssl_certificates(hosts) diff --git a/tests/unit/test_nginx_config.py b/tests/unit/test_nginx_config.py index ecf211b..43320da 100644 --- a/tests/unit/test_nginx_config.py +++ b/tests/unit/test_nginx_config.py @@ -5,7 +5,7 @@ @pytest.fixture def loaded_config(): - CONFIG = """ + CONFIG = r""" user www www; worker_processes 2; @@ -239,7 +239,7 @@ def test_location_blocks(loaded_config): loc3 = server.locations[3] assert loc3.path == "/download/" assert loc3.valid_referers == ["none", "blocked", "server_names", "*.example.com"] - assert loc3.rewrite == "^/(download/.*)/mp3/(.*)\..*$ /$1/mp3/$2.mp3 break" + assert loc3.rewrite == r"^/(download/.*)/mp3/(.*)\..*$ /$1/mp3/$2.mp3 break" assert loc3.root == "/spool/www" assert loc3.access_log == "/var/log/nginx-download.access_log download" @@ -251,7 +251,7 @@ def test_location_blocks(loaded_config): # Location ~* \.(jpg|jpeg|gif)$ loc4 = server.locations[4] - assert loc4.path == "~* \.(jpg|jpeg|gif)$" + assert loc4.path == r"~* \.(jpg|jpeg|gif)$" assert loc4.root == "/spool/www" assert loc4.access_log == "off" assert loc4.expires == "30d" diff --git a/tests/unit/test_redirect_processor.py b/tests/unit/test_redirect_processor.py new file mode 100644 index 0000000..660c573 --- /dev/null +++ b/tests/unit/test_redirect_processor.py @@ -0,0 +1,31 @@ +from nginx_proxy.BackendTarget import BackendTarget +from nginx_proxy.Host import Host +from nginx_proxy.pre_processors.redirect_processor import process_redirection + + +def test_proxy_full_redirect_rejects_invalid_target_hostname(): + vhost_map = {"target.example.com": {80: Host("target.example.com", 80)}} + backend = BackendTarget(id="redirect-id", name="redirect-test") + + process_redirection( + backend, + {"PROXY_FULL_REDIRECT": "source.example.com -> bad_target.example.com"}, + vhost_map, + ) + + assert "source.example.com" not in vhost_map + assert "bad_target.example.com" not in vhost_map + + +def test_proxy_full_redirect_skips_invalid_source_hostname(): + vhost_map = {"target.example.com": {80: Host("target.example.com", 80)}} + backend = BackendTarget(id="redirect-id", name="redirect-test") + + process_redirection( + backend, + {"PROXY_FULL_REDIRECT": "bad_source.example.com,valid-source.example.com -> target.example.com"}, + vhost_map, + ) + + assert "bad_source.example.com" not in vhost_map + assert "valid-source.example.com" in vhost_map diff --git a/tests/unit/test_ssl_certapi_batch_domains.py b/tests/unit/test_ssl_certapi_batch_domains.py index 566e1b9..146c611 100644 --- a/tests/unit/test_ssl_certapi_batch_domains.py +++ b/tests/unit/test_ssl_certapi_batch_domains.py @@ -22,6 +22,7 @@ def _make_server(): } }, reload=Mock(), + enqueue_reload=Mock(), ) @@ -78,7 +79,7 @@ def test_certapi_batch_domains_passed_to_renewal_manager(monkeypatch): assert processor.certapi_batch_domains is False backend.obtain.assert_not_called() assert processor._test_renewal_cls_call_args.kwargs["batch_domains"] is False - assert processor._test_renewal_cls_call_args.kwargs["renewal_callback"] == processor.sync_watch_domains + assert processor._test_renewal_cls_call_args.kwargs["renewal_callback"] == processor.ssl_renewal_callback def test_processor_does_not_obtain_directly_and_triggers_renewal_once(monkeypatch): @@ -103,19 +104,25 @@ def test_ssl_starts_and_stops_certapi_renewal_manager(monkeypatch): renewal.stop.assert_called_once_with() -def test_sync_watch_domains_publishes_secured_hosts_to_renewal_manager(monkeypatch): - secured = Host("secure.example.com", 443, {"https"}) - wildcard = Host("*.example.com", 443, {"https"}) - plain = Host("plain.example.com", 80, {"http"}) +def test_ssl_renewal_callback_enqueues_server_reload(monkeypatch): server = _make_server() - server.config_data = SimpleNamespace(host_list=lambda: [secured, plain, wildcard]) processor, _backend, renewal = _build_processor(monkeypatch, None) processor.server = server - processor.sync_watch_domains() + processor.ssl_renewal_callback() - renewal.update_watch_domains.assert_called_once_with(["*.example.com", "secure.example.com"]) - server.reload.assert_called_once_with(force=True) + renewal.update_watch_domains.assert_not_called() + server.enqueue_reload.assert_called_once_with(force=True) + server.reload.assert_not_called() + + +def test_ssl_renewal_callback_ignores_missing_server(monkeypatch): + processor, _backend, renewal = _build_processor(monkeypatch, None) + processor.server = None + + processor.ssl_renewal_callback() + + renewal.update_watch_domains.assert_not_called() def test_getssl_force_passes_self_verify_false_to_remote_backend(monkeypatch, tmp_path): @@ -147,9 +154,7 @@ def test_getssl_force_passes_self_verify_false_to_remote_backend(monkeypatch, tm ): runpy.run_path(str(REPO_ROOT / "getssl"), run_name="__main__") - backend.obtain.assert_called_once_with( - ["api.example.com"], key_type="ecdsa", batch_domains=True, self_verify=False - ) + backend.obtain.assert_called_once_with(["api.example.com"], key_type="ecdsa", batch_domains=True, self_verify=False) def test_getssl_passes_self_verify_true_to_local_backend_by_default(monkeypatch, tmp_path): @@ -181,6 +186,4 @@ def test_getssl_passes_self_verify_true_to_local_backend_by_default(monkeypatch, ): runpy.run_path(str(REPO_ROOT / "getssl"), run_name="__main__") - backend.obtain.assert_called_once_with( - ["api.example.com"], key_type="ecdsa", batch_domains=True, self_verify=True - ) + backend.obtain.assert_called_once_with(["api.example.com"], key_type="ecdsa", batch_domains=True, self_verify=True) diff --git a/tests/unit/test_throttler.py b/tests/unit/test_throttler.py new file mode 100644 index 0000000..8f79c4e --- /dev/null +++ b/tests/unit/test_throttler.py @@ -0,0 +1,19 @@ +import time + +from nginx_proxy.Throttler import Throttler + + +def test_immediate_run_cancels_pending_task(): + throttler = Throttler(0.2) + calls: list[str] = [] + + try: + throttler.throttle(lambda: calls.append("initial")) + throttler.throttle(lambda: calls.append("pending")) + throttler.throttle(lambda: calls.append("forced"), immediate=True) + + time.sleep(0.25) + + assert calls == ["initial", "forced"] + finally: + throttler.shutdown() diff --git a/tests/unit/test_unit_nginx_proxy_app.py b/tests/unit/test_unit_nginx_proxy_app.py index 497f418..dc948f6 100644 --- a/tests/unit/test_unit_nginx_proxy_app.py +++ b/tests/unit/test_unit_nginx_proxy_app.py @@ -2,38 +2,42 @@ import pytest from unittest.mock import patch, MagicMock +from nginx_proxy.DockerEventListener import RescanAndReload from nginx_proxy.NginxProxyApp import NginxProxyApp -@patch('docker.from_env') -@patch('docker.DockerClient') +@patch("docker.from_env") +@patch("docker.DockerClient") def test_load_config_from_env(mock_docker_client, mock_from_env): - with patch.dict(os.environ, { - "CERT_RENEW_THRESHOLD_DAYS": "60", - "DUMMY_NGINX": "true", - "SSL_DIR": "/custom/ssl", - "NGINX_CONF_DIR": "/custom/nginx", - "CLIENT_MAX_BODY_SIZE": "10m", - "DEFAULT_HOST": "false", - "ENABLE_IPV6": "true", - "DOCKER_SWARM": "strict", - "SWARM_DOCKER_HOST": "tcp://swarm:2375" - }): - with patch('sys.exit'): + with patch.dict( + os.environ, + { + "CERT_RENEW_THRESHOLD_DAYS": "60", + "DUMMY_NGINX": "true", + "SSL_DIR": "/custom/ssl", + "NGINX_CONF_DIR": "/custom/nginx", + "CLIENT_MAX_BODY_SIZE": "10m", + "DEFAULT_HOST": "false", + "ENABLE_IPV6": "true", + "DOCKER_SWARM": "strict", + "SWARM_DOCKER_HOST": "tcp://swarm:2375", + }, + ): + with patch("sys.exit"): app = NginxProxyApp() config = app.config - assert config['cert_renew_threshold_days'] == 60 - assert config['dummy_nginx'] is True - assert config['ssl_dir'] == '/custom/ssl' - assert config['conf_dir'] == '/custom/nginx' - assert config['client_max_body_size'] == '10m' - assert config['default_server'] is False - assert config['enable_ipv6'] is True - assert config['docker_swarm'] == 'strict' - assert config['swarm_docker_host'] == 'tcp://swarm:2375' - - -@patch('docker.from_env') + assert config["cert_renew_threshold_days"] == 60 + assert config["dummy_nginx"] is True + assert config["ssl_dir"] == "/custom/ssl" + assert config["conf_dir"] == "/custom/nginx" + assert config["client_max_body_size"] == "10m" + assert config["default_server"] is False + assert config["enable_ipv6"] is True + assert config["docker_swarm"] == "strict" + assert config["swarm_docker_host"] == "tcp://swarm:2375" + + +@patch("docker.from_env") def test_init_docker_client_default(mock_from_env): with patch.dict(os.environ, {"SWARM_DOCKER_HOST": ""}): app = NginxProxyApp() @@ -42,29 +46,70 @@ def test_init_docker_client_default(mock_from_env): assert app.docker_client == app.swarm_client -@patch('docker.DockerClient') -@patch('docker.from_env') +@patch("docker.DockerClient") +@patch("docker.from_env") def test_init_docker_client_with_swarm_host(mock_from_env, mock_docker_client): with patch.dict(os.environ, {"SWARM_DOCKER_HOST": "tcp://swarm:2375"}): - with patch('sys.exit'): + with patch("sys.exit"): NginxProxyApp() assert mock_docker_client.called assert mock_from_env.called -@patch('nginx_proxy.NginxProxyApp.render_nginx_conf') -@patch('os.path.exists', return_value=True) +@patch("nginx_proxy.NginxProxyApp.render_nginx_conf") +@patch("os.path.exists", return_value=False) +@patch("docker.from_env") +def test_prefer_local_validates_swarm_like_enable(mock_from_env, mock_exists, mock_render): + docker_client = MagicMock() + docker_client.info.return_value = {"Swarm": {"LocalNodeState": "active", "ControlAvailable": True}} + mock_from_env.return_value = docker_client + + with patch.dict(os.environ, {"DOCKER_SWARM": "prefer-local", "SWARM_DOCKER_HOST": ""}): + app = NginxProxyApp() + + assert app.config["docker_swarm"] == "prefer-local" + docker_client.info.assert_called_once() + + +@patch("nginx_proxy.NginxProxyApp.render_nginx_conf") +@patch("os.path.exists", return_value=True) def test_setup_nginx_conf_renders_template(mock_exists, mock_render): - with patch('docker.from_env'), patch('docker.DockerClient'): + with patch("docker.from_env"), patch("docker.DockerClient"): app = NginxProxyApp() app._setup_nginx_conf() assert mock_render.called -@patch('nginx_proxy.NginxProxyApp.render_nginx_conf') -@patch('os.path.exists', return_value=False) +@patch("nginx_proxy.NginxProxyApp.render_nginx_conf") +@patch("os.path.exists", return_value=False) def test_setup_nginx_conf_skips_if_no_template(mock_exists, mock_render): - with patch('docker.from_env'), patch('docker.DockerClient'): + with patch("docker.from_env"), patch("docker.DockerClient"): app = NginxProxyApp() app._setup_nginx_conf() assert not mock_render.called + + +def test_reload_rescans_and_forces_reload(): + with patch("docker.from_env"), patch("docker.DockerClient"): + app = NginxProxyApp() + + app.server = MagicMock() + + app.reload() + + app.server.rescan_and_reload.assert_called_once_with(force=True, bypass_start_grace=True) + + +def test_reload_enqueues_rescan_when_dispatcher_is_running(): + with patch("docker.from_env"), patch("docker.DockerClient"): + app = NginxProxyApp() + + app.server = MagicMock() + app.docker_event_listener = MagicMock() + app.docker_event_listener.is_dispatcher_running.return_value = True + + app.reload() + + app.server.rescan_and_reload.assert_not_called() + command = app.docker_event_listener.enqueue.call_args.args[0] + assert command == RescanAndReload(force=True, bypass_start_grace=True) diff --git a/tests/unit/test_upstream_processor.py b/tests/unit/test_upstream_processor.py new file mode 100644 index 0000000..5060a80 --- /dev/null +++ b/tests/unit/test_upstream_processor.py @@ -0,0 +1,172 @@ +from jinja2 import Template + +from nginx_proxy.BackendTarget import BackendTarget +from nginx_proxy.Host import Host +from nginx_proxy.post_processors.upstream_processor import UpstreamProcessor + + +def _backend(id, address, backend_type, port=80, labels=None): + return BackendTarget( + id=id, + address=address, + port=port, + path="", + name=id, + env={}, + labels=labels or {}, + backend_type=backend_type, + ) + + +def test_service_vip_is_backup_when_local_backend_exists(): + host = Host("example.com", 80) + local_backend = _backend( + "container1", "172.18.0.2", "container", labels={"com.docker.swarm.service.id": "service1"} + ) + service_backend = _backend("service1", "10.0.0.5", "service") + host.add_container("/", local_backend) + host.add_container("/", service_backend) + + upstreams = UpstreamProcessor().process([host], prefer_local=True) + + assert len(upstreams) == 1 + assert local_backend.backup is False + assert service_backend.backup is True + + +def test_service_vip_is_not_backup_for_unmatched_local_swarm_task(): + host = Host("example.com", 80) + local_backend = _backend( + "container1", "172.18.0.2", "container", labels={"com.docker.swarm.service.id": "service1"} + ) + service_backend_with_local_task = _backend("service1", "10.0.0.5", "service") + service_backend_without_local_task = _backend("service2", "10.0.0.6", "service") + host.add_container("/", local_backend) + host.add_container("/", service_backend_with_local_task) + host.add_container("/", service_backend_without_local_task) + + upstreams = UpstreamProcessor().process([host], prefer_local=True) + + assert len(upstreams) == 1 + assert service_backend_with_local_task.backup is True + assert service_backend_without_local_task.backup is False + + +def test_service_vip_backup_uses_local_port_when_service_only_defaulted_to_80(): + host = Host("example.com", 80) + local_backend = _backend( + "container1", + "172.18.0.2", + "container", + port=8080, + labels={"com.docker.swarm.service.id": "service1"}, + ) + service_backend = _backend("service1", "10.0.0.5", "service", port=80) + host.add_container("/", local_backend) + host.add_container("/", service_backend) + + upstreams = UpstreamProcessor().process([host], prefer_local=True) + + assert len(upstreams) == 1 + assert local_backend.port == 8080 + assert service_backend.port == 8080 + assert service_backend.backup is True + + +def test_service_vip_keeps_inferred_port_after_local_backend_is_removed(): + host = Host("example.com", 80) + local_backend = _backend( + "container1", + "172.18.0.2", + "container", + port=8080, + labels={"com.docker.swarm.service.id": "service1"}, + ) + service_backend = _backend("service1", "10.0.0.5", "service", port=80) + host.add_container("/", local_backend) + host.add_container("/", service_backend) + + UpstreamProcessor().process([host], prefer_local=True) + host.locations["/"].remove(local_backend) + UpstreamProcessor().process([host], prefer_local=True) + + assert host.locations["/"].upstream is False + assert service_backend.port == 8080 + assert service_backend.backup is False + + +def test_service_vip_backup_keeps_port_when_local_ports_disagree(): + host = Host("example.com", 80) + service_backend = _backend("service1", "10.0.0.5", "service", port=80) + host.add_container( + "/", + _backend( + "container1", + "172.18.0.2", + "container", + port=8080, + labels={"com.docker.swarm.service.id": "service1"}, + ), + ) + host.add_container( + "/", + _backend( + "container2", + "172.18.0.3", + "container", + port=9090, + labels={"com.docker.swarm.service.id": "service1"}, + ), + ) + host.add_container("/", service_backend) + + UpstreamProcessor().process([host], prefer_local=True) + + assert service_backend.port == 80 + assert service_backend.backup is True + + +def test_service_vip_is_not_backup_outside_prefer_local(): + host = Host("example.com", 80) + local_backend = _backend("container1", "172.18.0.2", "container") + service_backend = _backend("service1", "10.0.0.5", "service") + host.add_container("/", local_backend) + host.add_container("/", service_backend) + + UpstreamProcessor().process([host]) + + assert local_backend.backup is False + assert service_backend.backup is False + + +def test_service_vip_only_backend_is_direct_in_prefer_local(): + host = Host("example.com", 80) + service_backend = _backend("service1", "10.0.0.5", "service") + host.add_container("/", service_backend) + + upstreams = UpstreamProcessor().process([host], prefer_local=True) + + assert upstreams == [] + assert host.locations["/"].upstream is False + assert service_backend.backup is False + + +def test_backup_service_vip_is_rendered_in_upstream(): + host = Host("example.com", 80) + host.add_container( + "/", _backend("container1", "172.18.0.2", "container", labels={"com.docker.swarm.service.id": "service1"}) + ) + host.add_container("/", _backend("service1", "10.0.0.5", "service")) + upstreams = UpstreamProcessor().process([host], prefer_local=True) + + with open("vhosts_template/default.conf.jinja2") as template_file: + rendered = Template(template_file.read()).render( + virtual_servers=[], + upstreams=upstreams, + config={"client_max_body_size": "1m", "default_server": False}, + ) + + assert "server 172.18.0.2:80;" in rendered + assert "server 10.0.0.5:80 backup;" in rendered + assert "# container: container1" in rendered + assert "# service: service1" in rendered diff --git a/tests/unit/test_web_server.py b/tests/unit/test_web_server.py index 8b0d79d..7f96787 100644 --- a/tests/unit/test_web_server.py +++ b/tests/unit/test_web_server.py @@ -1,6 +1,10 @@ import pytest from unittest.mock import MagicMock, patch, mock_open +from datetime import datetime, timedelta, timezone +from nginx_proxy.BackendTarget import BackendTarget +from nginx_proxy.DockerEventListener import Reload +from nginx_proxy.ProxyConfigData import ProxyConfigData from nginx_proxy.WebServer import WebServer @@ -13,15 +17,17 @@ def mock_config(tmpdir): "vhosts_template_dir": "vhosts_template", "ssl_dir": str(tmpdir.mkdir("ssl")), "cert_renew_threshold_days": 30, - "docker_swarm": "ignore" + "docker_swarm": "ignore", } @pytest.fixture def web_server(mock_config): - with patch('builtins.open', mock_open(read_data="template_content")), \ - patch('nginx_proxy.WebServer.DummyNginx'), \ - patch('nginx_proxy.post_processors.SslCertificateProcessor'): + with ( + patch("builtins.open", mock_open(read_data="template_content")), + patch("nginx_proxy.WebServer.DummyNginx"), + patch("nginx_proxy.post_processors.SslCertificateProcessor"), + ): server = WebServer(MagicMock(), mock_config, swarm_client=MagicMock()) return server @@ -31,10 +37,24 @@ def test_init(web_server): assert web_server.swarm_client is not None +def test_init_bypasses_startup_grace_on_initial_rescan(mock_config): + with ( + patch("builtins.open", mock_open(read_data="template_content")), + patch("nginx_proxy.WebServer.DummyNginx"), + patch("nginx_proxy.post_processors.SslCertificateProcessor"), + patch.object(WebServer, "rescan_and_reload") as mock_rescan, + ): + WebServer(MagicMock(), mock_config, swarm_client=MagicMock()) + + mock_rescan.assert_called_once_with(force=True, bypass_start_grace=True) + + def test_learn_yourself_in_container(web_server): - with patch('os.path.exists', return_value=True), \ - patch('builtins.open', mock_open(read_data=".../docker/abcde...")), \ - patch('os.getenv', return_value="my-container-id"): + with ( + patch("os.path.exists", return_value=True), + patch("builtins.open", mock_open(read_data=".../docker/abcde...")), + patch("os.getenv", return_value="my-container-id"), + ): container_mock = MagicMock() container_mock.attrs = {"NetworkSettings": {"Networks": {"net1": {"NetworkID": "net1-id"}}}} container_mock.id = "my-container-id" @@ -58,12 +78,7 @@ def test_register_backend(web_server): backend.type = "container" backend.env = {"VIRTUAL_HOST": "example.com"} # Mock backend to simulate network attachment - backend.network_settings = { - "net1": { - "NetworkID": "net1-id", - "IPAddress": "172.18.0.2" - } - } + backend.network_settings = {"net1": {"NetworkID": "net1-id", "IPAddress": "172.18.0.2"}} # Mock web_server.networks to include the network the backend is on web_server.networks = {"net1": "net1-id", "net1-id": "net1"} @@ -73,9 +88,9 @@ def test_register_backend(web_server): # register_backend doesn't trigger reload/throttle on its own ret = web_server.register_backend(backend) - + # Check if add_host was called (register_backend calls it for valid hosts) - # Since we are not mocking pre_processors, we assume MagicMock backend is enough for process_virtual_hosts to return something + # Since we are not mocking pre_processors, we assume MagicMock backend is enough for process_virtual_hosts to return something # if VIRTUAL_HOST is present. # Check if it returned True implies it found hosts assert ret is True @@ -87,14 +102,279 @@ def test_remove_backend(web_server): config_data.remove_backend.return_value = (MagicMock(name="deleted_backend"), "deleted.domain") web_server.config_data = config_data - with patch.object(web_server.throttler, 'throttle') as mock_run: + with patch.object(web_server.throttler, "throttle") as mock_run: web_server.remove_backend("container1") mock_run.assert_called_once() config_data.remove_backend.assert_called_with("container1") +def _backend_target(backend_id, hostname, address, port=80, backend_type="container"): + return BackendTarget( + id=backend_id, + name=backend_id, + env={"VIRTUAL_HOST": hostname}, + network_settings={"frontend": {"NetworkID": "frontend-id", "IPAddress": address}}, + ports={f"{port}/tcp": None}, + backend_type=backend_type, + ) + + +def test_update_backend_ignores_existing_container_backend(web_server): + web_server.networks = {"frontend": "frontend-id", "frontend-id": "frontend"} + web_server.config_data = ProxyConfigData() + existing = _backend_target("container1", "old.example.com", "172.18.0.2") + updated = _backend_target("container1", "new.example.com", "172.18.0.3") + web_server.register_backend(existing) + + with patch.object(web_server.throttler, "throttle") as mock_throttle: + changed = web_server.update_backend(updated) + + assert changed is False + assert web_server.config_data.getHost("old.example.com") is not None + assert web_server.config_data.getHost("new.example.com") is None + mock_throttle.assert_not_called() + + +def test_update_backend_replaces_existing_container_backend_when_requested(web_server): + web_server.networks = {"frontend": "frontend-id", "frontend-id": "frontend"} + web_server.config_data = ProxyConfigData() + existing = _backend_target("container1", "old.example.com", "172.18.0.2") + updated = _backend_target("container1", "new.example.com", "172.18.0.3") + web_server.register_backend(existing) + + with patch.object(web_server.throttler, "throttle") as mock_throttle: + changed = web_server.update_backend(updated, replace_existing=True) + + assert changed is True + assert web_server.config_data.getHost("old.example.com").isempty() + assert web_server.config_data.getHost("new.example.com") is not None + mock_throttle.assert_called_once() + + +def test_update_backend_replaces_existing_service_backend(web_server): + web_server.networks = {"frontend": "frontend-id", "frontend-id": "frontend"} + web_server.config_data = ProxyConfigData() + existing = _backend_target("service1", "old.example.com", "10.0.0.2", backend_type="service") + updated = _backend_target("service1", "new.example.com", "10.0.0.3", port=8080, backend_type="service") + updated.env["VIRTUAL_PORT"] = "8080" + web_server.register_backend(existing) + + with patch.object(web_server.throttler, "throttle") as mock_throttle: + changed = web_server.update_backend(updated) + + assert changed is True + assert web_server.config_data.getHost("old.example.com").isempty() + new_host = web_server.config_data.getHost("new.example.com") + assert new_host is not None + backend = new_host.locations["/"].backends[0] + assert backend.address == "10.0.0.3" + assert backend.port == "8080" + mock_throttle.assert_called_once() + + +def test_update_backend_removes_existing_service_when_updated_config_is_invalid(web_server): + web_server.networks = {"frontend": "frontend-id", "frontend-id": "frontend"} + web_server.config_data = ProxyConfigData() + existing = _backend_target("service1", "old.example.com", "10.0.0.2", backend_type="service") + invalid = BackendTarget( + id="service1", + name="service1", + env={}, + network_settings={"frontend": {"NetworkID": "frontend-id", "IPAddress": "10.0.0.3"}}, + backend_type="service", + ) + web_server.register_backend(existing) + + with patch.object(web_server.throttler, "throttle") as mock_throttle: + changed = web_server.update_backend(invalid) + + assert changed is True + assert web_server.config_data.getHost("old.example.com").isempty() + assert not web_server.config_data.has_backend("service1") + mock_throttle.assert_called_once() + + def test_rescan_and_reload(web_server): - with patch.object(web_server, '_do_reload') as mock_reload, \ - patch.object(web_server, 'rescan_all_container'): + with patch.object(web_server, "_do_reload") as mock_reload, patch.object(web_server, "rescan_all_container"): web_server.rescan_and_reload(force=True) mock_reload.assert_called_once() + + +def test_reload_force_runs_immediately(web_server): + with patch.object(web_server.throttler, "throttle") as mock_throttle: + web_server.reload(force=True) + mock_throttle.assert_called_once() + assert mock_throttle.call_args.kwargs["immediate"] is True + + +def test_enqueue_reload_uses_dispatcher_when_running(web_server): + dispatcher = MagicMock(return_value=True) + web_server.set_reload_dispatcher(dispatcher, lambda: False) + + assert web_server.enqueue_reload(force=True) is True + + command = dispatcher.call_args.args[0] + assert isinstance(command, Reload) + assert command.force is True + + +def test_should_register_container_now_skips_unhealthy_healthcheck(web_server): + container = MagicMock() + container.status = "running" + container.attrs = { + "Config": {"Healthcheck": {"Test": ["CMD", "curl", "-f", "http://localhost/health"]}}, + "State": {"Status": "running", "Health": {"Status": "starting"}}, + } + + assert web_server._should_register_container_now(container) is False + + +def test_should_register_container_now_honors_startup_grace(web_server): + web_server.config["backend_start_grace_seconds"] = 10 + container = MagicMock() + container.status = "running" + container.attrs = { + "Config": {}, + "State": { + "Status": "running", + "StartedAt": (datetime.now(timezone.utc) - timedelta(seconds=3)).isoformat().replace("+00:00", "Z"), + }, + } + + assert web_server._should_register_container_now(container) is False + + +def test_should_register_container_now_can_bypass_startup_grace(web_server): + web_server.config["backend_start_grace_seconds"] = 10 + container = MagicMock() + container.status = "running" + container.attrs = { + "Config": {}, + "State": { + "Status": "running", + "StartedAt": (datetime.now(timezone.utc) - timedelta(seconds=3)).isoformat().replace("+00:00", "Z"), + }, + } + + assert web_server._should_register_container_now(container, bypass_start_grace=True) is True + + +def test_connect_skips_unhealthy_healthchecked_container(web_server): + web_server.networks = {"network1": "frontend", "frontend": "network1"} + container = MagicMock() + container.status = "running" + container.attrs = { + "Config": {"Healthcheck": {"Test": ["CMD", "curl", "-f", "http://localhost/health"]}, "Labels": {}}, + "State": {"Status": "running", "Health": {"Status": "unhealthy"}}, + } + web_server.client.containers.get.return_value = container + + with patch.object(web_server, "update_backend") as mock_update_backend: + web_server.connect("network1", "container1", "local") + + mock_update_backend.assert_not_called() + + +def _running_container_with_labels(labels): + container = MagicMock() + container.status = "running" + container.attrs = { + "Config": {"Env": ["VIRTUAL_HOST=example.com"], "Labels": labels}, + "State": {"Status": "running"}, + "Name": "/backend", + "NetworkSettings": { + "Networks": {"frontend": {"NetworkID": "network1", "IPAddress": "172.18.0.2"}}, + "Ports": {"80/tcp": None}, + }, + } + return container + + +def _service(service_id="service1"): + service = MagicMock() + service.id = service_id + service.attrs = { + "Spec": { + "Name": service_id, + "Labels": {}, + "TaskTemplate": {"ContainerSpec": {"Env": ["VIRTUAL_HOST=service.example.com"]}}, + }, + "Endpoint": { + "Ports": [{"Protocol": "tcp", "TargetPort": 80}], + "VirtualIPs": [{"NetworkID": "network1", "Addr": "10.0.0.5/24"}], + }, + } + return service + + +def _starting_healthcheck_container_with_labels(labels): + container = _running_container_with_labels(labels) + container.attrs["Config"]["Healthcheck"] = {"Test": ["CMD", "curl", "-f", "http://localhost/health"]} + container.attrs["State"]["Health"] = {"Status": "starting"} + return container + + +@pytest.mark.parametrize( + "swarm_mode, expected_backend_ids", + [ + ("ignore", ["standalone", "task"]), + ("exclude", ["standalone"]), + ("enable", ["standalone", "service1"]), + ("prefer-local", ["standalone", "task", "service1"]), + ("strict", ["service1"]), + ], +) +def test_rescan_all_container_matches_swarm_mode_matrix(web_server, swarm_mode, expected_backend_ids): + web_server.config["docker_swarm"] = swarm_mode + standalone = _running_container_with_labels({}) + standalone.id = "standalone" + task = _running_container_with_labels({"com.docker.swarm.service.id": "service1"}) + task.id = "task" + web_server.client.containers.list.return_value = [standalone, task] + web_server.swarm_client.info.return_value = {"Swarm": {"LocalNodeState": "active", "ControlAvailable": True}} + web_server.swarm_client.services.list.return_value = [_service()] + + with patch.object(web_server, "register_backend") as mock_register: + web_server.rescan_all_container(bypass_start_grace=True) + + backend_ids = [call.args[0].id for call in mock_register.call_args_list] + assert backend_ids == expected_backend_ids + + +def test_rescan_skips_swarm_task_container_in_enable(web_server): + web_server.config["docker_swarm"] = "enable" + web_server.client.containers.list.return_value = [ + _running_container_with_labels({"com.docker.swarm.service.id": "service1"}) + ] + web_server.swarm_client.info.return_value = {"Swarm": {"LocalNodeState": "inactive"}} + + with patch.object(web_server, "register_backend") as mock_register: + web_server.rescan_all_container(bypass_start_grace=True) + + mock_register.assert_not_called() + + +def test_rescan_includes_swarm_task_container_in_prefer_local(web_server): + web_server.config["docker_swarm"] = "prefer-local" + web_server.client.containers.list.return_value = [ + _running_container_with_labels({"com.docker.swarm.service.id": "service1"}) + ] + web_server.swarm_client.info.return_value = {"Swarm": {"LocalNodeState": "inactive"}} + + with patch.object(web_server, "register_backend") as mock_register: + web_server.rescan_all_container(bypass_start_grace=True) + + mock_register.assert_called_once() + + +def test_rescan_prefer_local_keeps_health_gate_for_swarm_task(web_server): + web_server.config["docker_swarm"] = "prefer-local" + web_server.client.containers.list.return_value = [ + _starting_healthcheck_container_with_labels({"com.docker.swarm.service.id": "service1"}) + ] + web_server.swarm_client.info.return_value = {"Swarm": {"LocalNodeState": "inactive"}} + + with patch.object(web_server, "register_backend") as mock_register: + web_server.rescan_all_container(bypass_start_grace=True) + + mock_register.assert_not_called() diff --git a/tests/unit/test_webserver_events.py b/tests/unit/test_webserver_events.py index 5fcb8c3..cd93ebd 100644 --- a/tests/unit/test_webserver_events.py +++ b/tests/unit/test_webserver_events.py @@ -160,7 +160,7 @@ def test_webserver_initialization(webserver: WebServer, nginx: DummyNginx): def test_webserver_add_container(docker_client: DockerTestClient, nginx: DummyNginx): container_name = "test_container" - hostname = "add_container.example.com" + hostname = "add-container.example.com" env = {"VIRTUAL_HOST": hostname} container = docker_client.containers.run("nginx:alpine", name=container_name, environment=env, network="frontend") time.sleep(0.2) # Small delay for async event processing @@ -176,9 +176,102 @@ def test_webserver_add_container(docker_client: DockerTestClient, nginx: DummyNg assert f"http://{container_ip}:80" in location.proxy_pass +def test_healthchecked_container_waits_for_healthy_event(docker_client: DockerTestClient, nginx: DummyNginx): + hostname = "health-waits.example.com" + container = docker_client.containers.run( + "nginx:alpine", + name="health_waits_container", + environment={"VIRTUAL_HOST": hostname}, + network="frontend", + healthcheck={"Test": ["CMD", "curl", "-f", "http://localhost/health"]}, + ) + time.sleep(0.2) + + expect_server_not_present(nginx, hostname) + + container.set_health_status("healthy") + time.sleep(0.2) + + expect_server_up(nginx, hostname) + + +def test_healthchecked_container_unhealthy_event_removes_backend(docker_client: DockerTestClient, nginx: DummyNginx): + hostname = "health-unhealthy.example.com" + container = docker_client.containers.run( + "nginx:alpine", + name="health_unhealthy_container", + environment={"VIRTUAL_HOST": hostname}, + network="frontend", + healthcheck={"Test": ["CMD", "curl", "-f", "http://localhost/health"]}, + ) + container.set_health_status("healthy") + time.sleep(0.2) + expect_server_up(nginx, hostname) + + container.set_health_status("unhealthy") + time.sleep(0.2) + + expect_server_down(nginx, hostname) + + +def test_unhealthy_container_network_reconnect_does_not_readd_backend( + docker_client: DockerTestClient, nginx: DummyNginx +): + hostname = "health-reconnect.example.com" + container = docker_client.containers.run( + "nginx:alpine", + name="health_reconnect_container", + environment={"VIRTUAL_HOST": hostname}, + network="frontend", + healthcheck={"Test": ["CMD", "curl", "-f", "http://localhost/health"]}, + ) + container.set_health_status("healthy") + time.sleep(0.2) + expect_server_up(nginx, hostname) + + container.set_health_status("unhealthy") + time.sleep(0.2) + expect_server_down(nginx, hostname) + + frontend_network = docker_client.networks.get("frontend") + frontend_network.disconnect(container.id) + time.sleep(0.2) + frontend_network.connect(container.id) + time.sleep(0.2) + + expect_server_down(nginx, hostname) + + +def test_container_startup_is_processed_once(docker_client: DockerTestClient): + with patch("certapi.manager.acme_cert_manager.AcmeCertManager.setup") as mock_acme_setup: + mock_acme_setup.return_value = None + config = get_test_config() + docker_client.networks.create("frontend") + webserver = WebServer(docker_client, config, nginx_update_throtle_sec=0.1) + webserver.update_backend = MagicMock(wraps=webserver.update_backend) + + listener = DockerEventListener(webserver, docker_client) + listener_thread = threading.Thread(target=listener.run, daemon=True) + listener_thread.start() + + try: + docker_client.containers.run( + "nginx:alpine", + name="count_start_once", + environment={"VIRTUAL_HOST": "count-start-once.example.com"}, + network="frontend", + ) + time.sleep(0.3) + assert webserver.update_backend.call_count == 1 + finally: + docker_client.close() + listener_thread.join(timeout=2) + webserver.cleanup() + + def test_webserver_remove_container(docker_client: DockerTestClient, nginx: DummyNginx): container_name = "test_container" - hostname = "remove_container.example.com" + hostname = "remove-container.example.com" env = {"VIRTUAL_HOST": hostname} # Add container @@ -190,13 +283,13 @@ def test_webserver_remove_container(docker_client: DockerTestClient, nginx: Dumm # Remove container container.remove(force=True) time.sleep(1) - + expect_server_down(nginx, hostname) def test_webserver_add_network(docker_client: DockerTestClient, nginx: DummyNginx): container_name = "test_container" - hostname = "add_network.example.com" + hostname = "add-network.example.com" env = { "VIRTUAL_HOST": hostname, } @@ -220,9 +313,32 @@ def test_webserver_add_network(docker_client: DockerTestClient, nginx: DummyNgin expect_server_up(nginx, hostname) +def test_webserver_add_first_reachable_network_after_start(docker_client: DockerTestClient, nginx: DummyNginx): + container_name = "first_reachable_network_container" + hostname = "single-network-attach.example.com" + env = { + "VIRTUAL_HOST": hostname, + } + + container = docker_client.containers.run("nginx:alpine", name=container_name, environment=env) + time.sleep(0.2) + + expect_server_not_present(nginx, hostname) + + bridge_network = docker_client.networks.get("bridge") + bridge_network.disconnect(container.id) + time.sleep(0.2) + + frontend_network = docker_client.networks.get("frontend") + frontend_network.connect(container.id) + time.sleep(0.2) + + expect_server_up(nginx, hostname) + + def test_webserver_remove_network(docker_client: DockerTestClient, nginx: DummyNginx): container_name = "test_container" - hostname = "remove_network.example.com" + hostname = "remove-network.example.com" env = { "VIRTUAL_HOST": hostname, } @@ -243,6 +359,34 @@ def test_webserver_remove_network(docker_client: DockerTestClient, nginx: DummyN expect_server_down(nginx, hostname) +def test_webserver_disconnect_keeps_backend_when_another_proxy_network_remains( + docker_client: DockerTestClient, webserver: WebServer, nginx: DummyNginx +): + container_name = "multi_reachable_network_container" + hostname = "multi-reachable-network.example.com" + env = { + "VIRTUAL_HOST": hostname, + } + + alt_network = docker_client.networks.create("frontend_alt") + webserver.networks[alt_network.id] = alt_network.name + webserver.networks[alt_network.name] = alt_network.id + + container = docker_client.containers.run("nginx:alpine", name=container_name, environment=env, network="frontend") + time.sleep(0.2) + + expect_server_up(nginx, hostname) + + alt_network.connect(container.id) + time.sleep(0.2) + + frontend_network = docker_client.networks.get("frontend") + frontend_network.disconnect(container.id) + time.sleep(0.2) + + expect_server_up(nginx, hostname) + + def test_webserver_recreate_same_name_container_with_different_host(docker_client: DockerTestClient, nginx: DummyNginx): container_name = "test_container" old_hostname = "old.recreate.example.com" @@ -296,6 +440,43 @@ def test_webserver_add_container_with_ssl(docker_client: DockerTestClient, nginx assert http_redirect_location.return_code == "308 https://ssl.example.com$request_uri" +def test_proxy_full_redirect_uses_existing_https_target(docker_client: DockerTestClient, nginx: DummyNginx): + target_hostname = "redirect-target.example.com" + source_hostname = "redirect-source.example.com" + alternate_source = "redirect-source-www.example.com" + env = { + "VIRTUAL_HOST": f"https://{target_hostname}", + "PROXY_FULL_REDIRECT": f"{source_hostname},{alternate_source} -> {target_hostname}", + } + + docker_client.containers.run("nginx:alpine", name="full_redirect_https", environment=env, network="frontend") + + target_servers = expect_servers(nginx, target_hostname, 2) + assert next((s for s in target_servers if "443" in s.listen), None) is not None + + for hostname in (source_hostname, alternate_source): + redirect_server = expect_server(nginx, hostname) + redirect_location = next((l for l in redirect_server.locations if l.path == "/"), None) + assert redirect_location is not None + assert redirect_location.return_code == f"301 https://{target_hostname}$request_uri" + + +def test_proxy_full_redirect_preserves_http_target_scheme(docker_client: DockerTestClient, nginx: DummyNginx): + target_hostname = "redirect-http-target.example.com" + source_hostname = "redirect-http-source.example.com" + env = { + "VIRTUAL_HOST": target_hostname, + "PROXY_FULL_REDIRECT": f"{source_hostname} -> {target_hostname}", + } + + docker_client.containers.run("nginx:alpine", name="full_redirect_http", environment=env, network="frontend") + + redirect_server = expect_servers(nginx, source_hostname, 1)[0] + redirect_location = next((l for l in redirect_server.locations if l.path == "/"), None) + assert redirect_location is not None + assert redirect_location.return_code == f"301 http://{target_hostname}$request_uri" + + def test_webserver_ssl_does_not_override_explicit_http_location(docker_client: DockerTestClient, nginx: DummyNginx): container_name = "ssl_http_container" hostname = "ssl-http.example.com" @@ -354,7 +535,7 @@ def test_webserver_ssl_respects_explicit_http_root(docker_client: DockerTestClie def test_webserver_add_two_containers_with_same_virtual_host(docker_client: DockerTestClient, nginx: DummyNginx): - hostname = "two_containers.example.com" + hostname = "two-containers.example.com" env = {"VIRTUAL_HOST": hostname} c1 = docker_client.containers.run("nginx:alpine", name="test_container_1", environment=env, network="frontend") c2 = docker_client.containers.run("nginx:alpine", name="test_container_2", environment=env, network="frontend") diff --git a/vhosts_template/default.conf.jinja2 b/vhosts_template/default.conf.jinja2 index bc47022..8f5f3da 100644 --- a/vhosts_template/default.conf.jinja2 +++ b/vhosts_template/default.conf.jinja2 @@ -22,7 +22,7 @@ client_max_body_size {{ config.client_max_body_size }}; {% for upstream in upstreams %} upstream {{ upstream.id }} { {% if upstream.sticky %} {{ upstream.sticky }};{% endif %} {% for container in upstream.containers %} - server {{ container.address }}:{{ container.port }}; # container: {{container.id[:12]}}{% endfor %} + server {{ container.address }}:{{ container.port }}{% if container.backup %} backup{% endif %}; # {{ container.type }}: {{container.id[:12]}}{% endfor %} } {% endfor %} @@ -48,8 +48,8 @@ server{ {{ injection }};{% endfor %} {% if location.extras.security %} auth_basic "Basic Auth Enabled"; auth_basic_user_file {{ location.extras.security_file }};{% endif %} {% if location.upstream %} - proxy_pass {{ location.container.scheme }}://{{ location.upstream }}{{location.container.path}}; # container: {{ location.container.id[:12] }}{% else %} - proxy_pass {{location.container.scheme }}://{{ location.container.address }}:{{ location.container.port }}{{ location.container.path }}; # container: {{ location.container.id[:12] }}{% endif %}{% if location.name != '/' %} + proxy_pass {{ location.container.scheme }}://{{ location.upstream }}{{location.container.path}}; # {{ location.container.type }}: {{ location.container.id[:12] }}{% else %} + proxy_pass {{location.container.scheme }}://{{ location.container.address }}:{{ location.container.port }}{{ location.container.path }}; # {{ location.container.type }}: {{ location.container.id[:12] }}{% endif %}{% if location.name != '/' %} proxy_redirect $scheme://$http_host{{ location.container.path if location.container.path else '/' }} $scheme://$http_host{{location.name}};{% endif %} {% if location.websocket and location.http %} proxy_set_header Host $http_host; proxy_set_header Connection $connection_upgrade; @@ -84,8 +84,8 @@ server { alias {{ config.challenge_dir }}; try_files $uri =404;{% endif %} } - location / { {% if server.is_redirect %} - return 308 https://{{ server.full_redirect.hostname }}{% if server.full_redirect.port and server.full_redirect.port != 443 %}:{{ server.full_redirect.port }}{% endif %}$request_uri;{% else %} + location / { {% if server.is_redirect %}{% set redirect_scheme = "https" if "https" in server.full_redirect.scheme or "wss" in server.full_redirect.scheme else "http" %} + return {{ server.extras.redirect_status_code if server.extras.redirect_status_code else "301" }} {{ redirect_scheme }}://{{ server.full_redirect.hostname }}{% if server.full_redirect.port and ((redirect_scheme == "https" and server.full_redirect.port != 443) or (redirect_scheme == "http" and server.full_redirect.port != 80)) %}:{{ server.full_redirect.port }}{% endif %}$request_uri;{% else %} return 308 https://$host$request_uri;{% endif %} } }{% endif %}{% endfor %}