diff --git a/.github/DISCUSSION_TEMPLATE/issue-triage.yml b/.github/DISCUSSION_TEMPLATE/issue-triage.yml new file mode 100644 index 0000000000..36d8981096 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/issue-triage.yml @@ -0,0 +1,131 @@ +title: "[Triage] " +labels: + - triage +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report an issue or suggest a feature! + + **Before submitting, please:** + - Search [existing discussions](https://github.com/benoitc/gunicorn/discussions) and [issues](https://github.com/benoitc/gunicorn/issues) for duplicates + - Check the [FAQ](https://gunicorn.org/faq/) and [documentation](https://gunicorn.org/) + + - type: dropdown + id: type + attributes: + label: Type + description: What type of issue is this? + options: + - Bug Report + - Feature Request + - Performance Issue + - Documentation Issue + validations: + required: true + + - type: textarea + id: description + attributes: + label: Description + description: A clear description of the issue or feature request + placeholder: | + For bugs: What happened? What did you expect? + For features: What problem does this solve? + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce (for bugs) + description: Minimal steps to reproduce the behavior + placeholder: | + 1. Create a simple app with... + 2. Run gunicorn with... + 3. Send request... + 4. See error... + validations: + required: false + + - type: textarea + id: config + attributes: + label: Configuration + description: Your gunicorn configuration (command line or config file) + render: bash + placeholder: | + gunicorn --workers 4 --bind 0.0.0.0:8000 myapp:app + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Logs / Error Output + description: Relevant logs or error messages (use --log-level debug for more detail) + render: text + validations: + required: false + + - type: input + id: gunicorn-version + attributes: + label: Gunicorn Version + description: Output of `gunicorn --version` + placeholder: gunicorn 24.1.0 + validations: + required: true + + - type: input + id: python-version + attributes: + label: Python Version + description: Output of `python --version` + placeholder: Python 3.12.0 + validations: + required: true + + - type: dropdown + id: worker-class + attributes: + label: Worker Class + description: Which worker type are you using? + options: + - sync (default) + - gthread + - gevent + - eventlet + - tornado + - asgi (beta) + - custom + - N/A (feature request) + validations: + required: true + + - type: input + id: os + attributes: + label: Operating System + description: Your OS and version + placeholder: Ubuntu 22.04, macOS 14.0, etc. + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context (proxy setup, Docker, proposed solution, etc.) + validations: + required: false + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I have searched existing discussions and issues for duplicates + required: true + - label: I have checked the documentation and FAQ + required: true diff --git a/.github/DISCUSSION_TEMPLATE/question.yml b/.github/DISCUSSION_TEMPLATE/question.yml new file mode 100644 index 0000000000..8f6abd347c --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/question.yml @@ -0,0 +1,50 @@ +title: "[Question] " +body: + - type: markdown + attributes: + value: | + Have a question about Gunicorn? + + Before asking, please check: + - [Documentation](https://gunicorn.org/) + - [FAQ](https://gunicorn.org/faq/) + - [Settings Reference](https://gunicorn.org/reference/settings/) + - [Existing discussions](https://github.com/benoitc/gunicorn/discussions) + + - type: textarea + id: question + attributes: + label: Question + description: What would you like to know? + validations: + required: true + + - type: textarea + id: context + attributes: + label: Context + description: Any relevant context (your setup, what you've tried, etc.) + placeholder: | + I'm running gunicorn with... + I've tried... + validations: + required: false + + - type: textarea + id: config + attributes: + label: Configuration (if relevant) + description: Your gunicorn configuration + render: bash + validations: + required: false + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I have checked the documentation and FAQ + required: true + - label: I have searched existing discussions + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..496851e084 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Bug Report / Feature Request + url: https://github.com/benoitc/gunicorn/discussions/new?category=issue-triage + about: Report a bug or request a feature (triaged before becoming an issue) + - name: Question + url: https://github.com/benoitc/gunicorn/discussions/new?category=q-a + about: Ask a question about configuration, deployment, or usage diff --git a/.github/ISSUE_TEMPLATE/preapproved.md b/.github/ISSUE_TEMPLATE/preapproved.md new file mode 100644 index 0000000000..ec428a7eb0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/preapproved.md @@ -0,0 +1,16 @@ +--- +name: Pre-Discussed and Approved Topics +about: Only for topics already discussed and approved in GitHub Discussions +title: '' +labels: '' +assignees: '' +--- + +**Only for topics already discussed and approved in the GitHub Discussions section.** + +DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION. + +Link to approved discussion: + +--- + diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000000..8054947df8 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,57 @@ +name: Docker Publish +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + name: Build and Push Docker Image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 11f27c8333..8e1e6a2ddd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,17 +36,18 @@ jobs: - name: Install Dependencies (non-toxic) if: ${{ ! matrix.toxenv }} run: | - python -m pip install sphinx - - name: "Update docs" + python -m pip install --upgrade pip + python -m pip install -e . + - name: "Check generated docs" if: ${{ ! matrix.toxenv }} run: | - # this will update docs/source/settings.rst - but will not create html output - (cd docs && sphinx-build -b "dummy" -d _build/doctrees source "_build/dummy") - git update-index --assume-unchanged docs/source/settings.rst + # Regenerate settings.md and check for uncommitted changes + python scripts/build_settings_doc.py if unclean=$(git status --untracked-files=no --porcelain) && [ -z "$unclean" ]; then echo "no uncommitted changes in working tree (as it should be)" else - echo "did you forget to run `make -C docs html`?" + echo "did you forget to run 'python scripts/build_settings_doc.py'?" echo "$unclean" + git diff exit 2 fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ef79de3d5..a73295957a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,24 +21,22 @@ to do everything for everybody. This means that we might decide against incorporating a new feature. However, there might be a way to implement that feature *on top of* Gunicorn. -### Discuss your design on the mailing list +### Start with a Discussion -We recommend discussing your plans [on the mailing -list](http://gunicorn.org/#community) before starting to code - -especially for more ambitious contributions. This gives other -contributors a chance to point you in the right direction, give feedback -on your design, and maybe point out if someone else is working on the -same thing. +We use [GitHub Discussions](https://github.com/benoitc/gunicorn/discussions) +as the starting point for all bug reports, feature requests, and questions. +This allows for proper triage before creating formal issues. -### Create issues... +- **Bug reports**: Start in [Q&A](https://github.com/benoitc/gunicorn/discussions/categories/q-a) +- **Feature requests**: Start in [Ideas](https://github.com/benoitc/gunicorn/discussions/categories/ideas) +- **Questions**: Start in [Q&A](https://github.com/benoitc/gunicorn/discussions/categories/q-a) -Any significant improvement should be documented as [a github -issue](https://github.com/benoitc/gunicorn/issues) before anybody starts -working on it. +After discussion and triage, maintainers will create issues for confirmed +bugs and approved features. -### ...but check for existing issues first! +### Check for existing discussions first! -Please take a moment to check that an issue doesn't already exist +Please take a moment to check that a discussion or issue doesn't already exist documenting your bug report or improvement proposal. If it does, it never hurts to add a quick "+1" or "I have this problem too". This will help prioritize the most common problems and requests. diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 0000000000..32f0329d43 --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1,8 @@ +.git +.github +__pycache__ +*.pyc +.pytest_cache +.tox +docs +tests diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000000..9b628a7ced --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.12-slim + +LABEL org.opencontainers.image.source=https://github.com/benoitc/gunicorn +LABEL org.opencontainers.image.description="Gunicorn Python WSGI HTTP Server" +LABEL org.opencontainers.image.licenses=MIT + +# Create non-root user +RUN useradd --create-home --shell /bin/bash gunicorn + +WORKDIR /app + +# Install gunicorn from source +COPY pyproject.toml README.rst LICENSE ./ +COPY gunicorn/ ./gunicorn/ +RUN pip install --no-cache-dir . + +# Copy entrypoint script +COPY docker/docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Configuration via environment: +# GUNICORN_BIND - full bind address (default: 0.0.0.0:8000) +# GUNICORN_HOST - bind host (default: 0.0.0.0) +# GUNICORN_PORT - bind port (default: 8000) +# GUNICORN_WORKERS - number of workers (default: 2 * CPU + 1) +# GUNICORN_ARGS - additional arguments (e.g., "--timeout 120") + +USER gunicorn + +EXPOSE 8000 + +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["--help"] diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 0000000000..04fd606a03 --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +# Allow running other commands (e.g., bash for debugging) +if [ "${1:0:1}" = '-' ] || [ -z "${1##*:*}" ]; then + # First arg is a flag or contains ':' (app:callable), run gunicorn + + # Build bind address from GUNICORN_HOST and GUNICORN_PORT, or use GUNICORN_BIND + PORT="${GUNICORN_PORT:-8000}" + BIND="${GUNICORN_BIND:-${GUNICORN_HOST:-0.0.0.0}:${PORT}}" + + # Add bind if not specified in args or GUNICORN_ARGS + if [[ ! " $* $GUNICORN_ARGS " =~ " --bind " ]] && [[ ! " $* $GUNICORN_ARGS " =~ " -b " ]] && [[ ! "$* $GUNICORN_ARGS" =~ --bind= ]] && [[ ! "$* $GUNICORN_ARGS" =~ -b= ]]; then + set -- --bind "$BIND" "$@" + fi + + # Add workers if not specified - default to (2 * CPU_COUNT) + 1 + if [[ ! " $* $GUNICORN_ARGS " =~ " --workers " ]] && [[ ! " $* $GUNICORN_ARGS " =~ " -w " ]] && [[ ! "$* $GUNICORN_ARGS" =~ --workers= ]] && [[ ! "$* $GUNICORN_ARGS" =~ -w= ]]; then + WORKERS="${GUNICORN_WORKERS:-$(( 2 * $(nproc) + 1 ))}" + set -- --workers "$WORKERS" "$@" + fi + + # Append GUNICORN_ARGS if set + if [ -n "$GUNICORN_ARGS" ]; then + exec gunicorn $GUNICORN_ARGS "$@" + fi + + exec gunicorn "$@" +fi + +# Otherwise, run the command as-is (e.g., bash, sh, python) +exec "$@" diff --git a/docs/content/2012-news.md b/docs/content/2012-news.md index 7d3380467e..31f11e31e9 100644 --- a/docs/content/2012-news.md +++ b/docs/content/2012-news.md @@ -29,7 +29,7 @@ ## 0.15.0 / 2012-10-18 -- new documentation site on http://docs.gunicorn.org +- new documentation site on https://gunicorn.org - new website on http://gunicorn.org - add `haproxy PROXY protocol `_ support - add ForwardedAllowIPS option: allows to filter Front-end's IPs diff --git a/docs/content/2014-news.md b/docs/content/2014-news.md index ed1937c2b2..f9149cfb72 100644 --- a/docs/content/2014-news.md +++ b/docs/content/2014-news.md @@ -80,7 +80,7 @@ ### Documentation - update faq: put a note on how `watch logs in the console - `_ + `_ since many people asked for it. @@ -88,7 +88,7 @@ Gunicorn 19.0 is a major release with new features and fixes. This version improve a lot the usage of Gunicorn with python 3 by adding `two -new workers `_ +new workers `_ to it: `gthread` a fully threaded async worker using futures and `gaiohttp` a worker using asyncio. diff --git a/docs/content/2026-news.md b/docs/content/2026-news.md index 55f6a18112..7f7b329a01 100644 --- a/docs/content/2026-news.md +++ b/docs/content/2026-news.md @@ -1,6 +1,66 @@ # Changelog - 2026 +## 24.1.0 - 2026-01-23 + +### New Features + +- **PROXY Protocol v2 Support**: Extended PROXY protocol implementation to support + the binary v2 format in addition to the existing text-based v1 format + ([PR #3451](https://github.com/benoitc/gunicorn/pull/3451)) + - New `--proxy-protocol` modes: `off`, `v1`, `v2`, `auto` + - `auto` mode (default when enabled) detects v1 or v2 automatically + - v2 binary format is more efficient and supports additional metadata + - Works with HAProxy, AWS NLB/ALB, and other PROXY protocol v2 sources + +- **CIDR Network Support**: `--forwarded-allow-ips` and `--proxy-allow-from` now + accept CIDR notation (e.g., `192.168.0.0/16`) for specifying trusted networks + ([PR #3449](https://github.com/benoitc/gunicorn/pull/3449)) + +- **Socket Backlog Metric**: New `gunicorn.socket.backlog` gauge metric reports + the current socket backlog size on Linux systems + ([PR #3450](https://github.com/benoitc/gunicorn/pull/3450)) + +- **InotifyReloader Enhancement**: The inotify-based reloader now watches newly + imported modules, not just those loaded at startup + ([PR #3447](https://github.com/benoitc/gunicorn/pull/3447)) + +### Bug Fixes + +- Fix signal handling regression where SIGCLD alias caused "Unhandled signal: cld" + errors on Linux when workers fail during boot + ([#3453](https://github.com/benoitc/gunicorn/discussions/3453)) + +- Fix socket blocking mode on keepalive connections preventing SSL handshake + failures with async workers + ([PR #3452](https://github.com/benoitc/gunicorn/pull/3452)) + +- Use smaller buffer size in `finish_body()` for faster timeout detection on + slow or abandoned connections + ([PR #3453](https://github.com/benoitc/gunicorn/pull/3453)) + +- Handle `SSLWantReadError` in `finish_body()` to prevent worker hangs during + SSL renegotiation + ([PR #3448](https://github.com/benoitc/gunicorn/pull/3448)) + +- Log SIGTERM as info level instead of warning to reduce noise in orchestrated + environments + ([PR #3446](https://github.com/benoitc/gunicorn/pull/3446)) + +- Print exception details to stderr when worker fails to boot + ([PR #3443](https://github.com/benoitc/gunicorn/pull/3443)) + +- Fix `unreader.unread()` to prepend data to buffer instead of appending + ([PR #3442](https://github.com/benoitc/gunicorn/pull/3442)) + +- Prevent `RecursionError` when pickling Config objects + ([PR #3441](https://github.com/benoitc/gunicorn/pull/3441)) + +- Use proper exception chaining with `raise from` in glogging.py + ([PR #3440](https://github.com/benoitc/gunicorn/pull/3440)) + +--- + ## 24.0.0 - 2026-01-23 ### New Features @@ -23,14 +83,24 @@ with Material theme for improved navigation and mobile experience ([PR #3426](https://github.com/benoitc/gunicorn/pull/3426)) -### Changes +### Security -- Minimum Python version is now 3.12 -- Documentation now hosted at https://gunicorn.org +- **eventlet**: Require eventlet >= 0.40.3 to address CVE-2021-21419 (websocket + memory exhaustion) and CVE-2025-58068 (HTTP request smuggling) + ([PR #3445](https://github.com/benoitc/gunicorn/pull/3445)) + +- **gevent**: Require gevent >= 24.10.1 to address CVE-2023-41419 (HTTP request + smuggling) and CVE-2024-3219 (socket.socketpair vulnerability) + ([PR #3445](https://github.com/benoitc/gunicorn/pull/3445)) -### Breaking changes +- **tornado**: Require tornado >= 6.5.0 to address CVE-2025-47287 (HTTP request + smuggling) and other security fixes + ([PR #3445](https://github.com/benoitc/gunicorn/pull/3445)) -- Dropped support for Python versions before 3.12 +### Changes + +- Documentation now hosted at https://gunicorn.org +- Updated license configuration to PEP 639 format for uv compatibility !!! warning "ASGI Worker Beta" The ASGI worker is a beta feature. While tested, the API and behavior diff --git a/docs/content/deploy.md b/docs/content/deploy.md index bb78674e46..63b807d9a4 100644 --- a/docs/content/deploy.md +++ b/docs/content/deploy.md @@ -78,6 +78,92 @@ proxy IP rather than the upstream client. To log the real client address, set When binding Gunicorn to a UNIX socket `REMOTE_ADDR` will be empty. +## PROXY Protocol + +The [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) +allows load balancers and reverse proxies to pass original client connection +information (IP address, port) to backend servers. This is especially useful +when TLS termination happens at the proxy layer. + +Gunicorn supports both PROXY protocol v1 (text format) and v2 (binary format). + +### Configuration + +Enable PROXY protocol with the `--proxy-protocol` option: + +```bash +# Auto-detect v1 or v2 (recommended) +gunicorn --proxy-protocol auto app:app + +# Force v1 only (text format) +gunicorn --proxy-protocol v1 app:app + +# Force v2 only (binary format, more efficient) +gunicorn --proxy-protocol v2 app:app +``` + +Using `--proxy-protocol` without a value is equivalent to `auto`. + +!!! warning "Security" + Only enable PROXY protocol when Gunicorn is behind a trusted proxy that sends + PROXY headers. Configure [`--proxy-allow-from`](reference/settings.md#proxy_allow_ips) + to restrict which IPs can send PROXY protocol headers. + +### HAProxy + +HAProxy can send PROXY protocol headers to backends. Example configuration: + +```haproxy +frontend https_front + bind *:443 ssl crt /etc/ssl/certs/site.pem + default_backend gunicorn_back + +backend gunicorn_back + # Send PROXY protocol v2 (binary, more efficient) + server gunicorn 127.0.0.1:8000 send-proxy-v2 + + # Or use v1 (text format) + # server gunicorn 127.0.0.1:8000 send-proxy +``` + +Start Gunicorn to accept PROXY protocol: + +```bash +gunicorn -b 127.0.0.1:8000 --proxy-protocol v2 --proxy-allow-from 127.0.0.1 app:app +``` + +### stunnel + +[stunnel](https://www.stunnel.org/) can terminate TLS and forward connections +with PROXY protocol headers: + +```ini +# /etc/stunnel/stunnel.conf +[https] +accept = 443 +connect = 127.0.0.1:8000 +cert = /etc/ssl/certs/stunnel.pem +key = /etc/ssl/certs/stunnel.key +protocol = proxy +``` + +The `protocol = proxy` directive tells stunnel to prepend PROXY protocol v1 +headers to forwarded connections. + +### AWS/ELB + +AWS Network Load Balancers (NLB) and Application Load Balancers (ALB) support +PROXY protocol v2. Enable it in the target group settings, then configure +Gunicorn: + +```bash +gunicorn --proxy-protocol v2 --proxy-allow-from '*' app:app +``` + +!!! note + When using `--proxy-allow-from '*'` ensure Gunicorn is not directly + accessible from the internet—only through the load balancer. + ## Using virtual environments Install Gunicorn inside your project @@ -214,7 +300,6 @@ Type=notify NotifyAccess=main User=someuser Group=someuser -RuntimeDirectory=gunicorn WorkingDirectory=/home/someuser/applicationroot ExecStart=/usr/bin/gunicorn applicationname.wsgi ExecReload=/bin/kill -s HUP $MAINPID diff --git a/docs/content/guides/docker.md b/docs/content/guides/docker.md index 036b38b1ab..c05afdfb3f 100644 --- a/docs/content/guides/docker.md +++ b/docs/content/guides/docker.md @@ -4,6 +4,105 @@ Running Gunicorn in Docker containers is the most common deployment pattern for modern Python applications. This guide covers best practices for containerizing Gunicorn applications. +## Official Docker Image + +Gunicorn provides an official Docker image on GitHub Container Registry: + +```bash +docker pull ghcr.io/benoitc/gunicorn:latest +``` + +### Quick Start + +Mount your application directory and run: + +```bash +docker run -p 8000:8000 -v $(pwd):/app ghcr.io/benoitc/gunicorn app:app +``` + +### Running in Background + +Use `-d` (detached mode) to run the container in the background: + +```bash +# Start in background +docker run -d --name myapp -p 8000:8000 -v $(pwd):/app ghcr.io/benoitc/gunicorn app:app + +# View logs +docker logs myapp + +# Follow logs in real-time +docker logs -f myapp + +# Stop the container +docker stop myapp + +# Start it again +docker start myapp + +# Remove the container +docker rm myapp +``` + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `GUNICORN_BIND` | Full bind address | `0.0.0.0:8000` | +| `GUNICORN_HOST` | Bind host | `0.0.0.0` | +| `GUNICORN_PORT` | Bind port | `8000` | +| `GUNICORN_WORKERS` | Number of workers | `(2 * CPU) + 1` | +| `GUNICORN_ARGS` | Additional arguments | (none) | + +### With Configuration + +```bash +docker run -p 9000:9000 -v $(pwd):/app \ + -e GUNICORN_PORT=9000 \ + -e GUNICORN_WORKERS=4 \ + -e GUNICORN_ARGS="--timeout 120 --access-logfile -" \ + ghcr.io/benoitc/gunicorn app:app +``` + +### As Base Image (Recommended for Production) + +```dockerfile +FROM ghcr.io/benoitc/gunicorn:24.1.0 + +# Install app dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY --chown=gunicorn:gunicorn . . + +CMD ["myapp:app", "--workers", "4"] +``` + +### With Docker Compose + +```yaml +services: + web: + image: ghcr.io/benoitc/gunicorn:latest + ports: + - "8000:8000" + volumes: + - ./app:/app + command: ["myapp:app", "--workers", "4"] +``` + +### Available Tags + +- `ghcr.io/benoitc/gunicorn:latest` - Latest release +- `ghcr.io/benoitc/gunicorn:24.1.0` - Specific version +- `ghcr.io/benoitc/gunicorn:24.1` - Minor version +- `ghcr.io/benoitc/gunicorn:24` - Major version + +## Building Your Own Image + +For more control, build a custom image using the patterns below. + ## Basic Dockerfile ```dockerfile diff --git a/docs/content/install.md b/docs/content/install.md index 27c73fed52..95b68df9d8 100644 --- a/docs/content/install.md +++ b/docs/content/install.md @@ -20,8 +20,8 @@ === "Docker" ```bash - docker run -p 8000:8000 -v $(pwd):/app -w /app \ - python:3.12-slim sh -c "pip install gunicorn && gunicorn app:app" + docker pull ghcr.io/benoitc/gunicorn:latest + docker run -p 8000:8000 -v $(pwd):/app ghcr.io/benoitc/gunicorn app:app ``` See the [Docker guide](guides/docker.md) for production configurations. diff --git a/docs/content/news.md b/docs/content/news.md index 7a855fa2fd..11d0e34cb2 100644 --- a/docs/content/news.md +++ b/docs/content/news.md @@ -17,9 +17,11 @@ - **Documentation Migration**: Migrated to MkDocs with Material theme -### Breaking changes +### Security -- Minimum Python version is now 3.12 +- **eventlet**: Require eventlet >= 0.40.3 (CVE-2021-21419, CVE-2025-58068) +- **gevent**: Require gevent >= 24.10.1 (CVE-2023-41419, CVE-2024-3219) +- **tornado**: Require tornado >= 6.5.0 (CVE-2025-47287) --- diff --git a/docs/content/reference/settings.md b/docs/content/reference/settings.md index e24227bfd8..76b58d5b69 100644 --- a/docs/content/reference/settings.md +++ b/docs/content/reference/settings.md @@ -285,7 +285,14 @@ Format: https://docs.python.org/3/library/logging.config.html#logging.config.jso **Command line:** `--log-syslog-to SYSLOG_ADDR` -**Default:** `'unix:///var/run/syslog'` +**Default:** + +Platform-specific: + +* macOS: ``'unix:///var/run/syslog'`` +* FreeBSD/DragonFly: ``'unix:///var/run/log'`` +* OpenBSD: ``'unix:///dev/log'`` +* Linux/other: ``'udp://localhost:514'`` Address to send syslog messages. @@ -363,7 +370,7 @@ Address is a string of the form: **Default:** `''` A comma-delimited list of datadog statsd (dogstatsd) tags to append to -statsd metrics. +statsd metrics. e.g. ``'tag1:value1,tag2:value2'`` !!! info "Added in 20" @@ -378,6 +385,17 @@ if not provided). !!! info "Added in 19.2" +### `enable_backlog_metric` + +**Command line:** `--enable-backlog-metric` + +**Default:** `False` + +Enable socket backlog metric (only supported on Linux). + +When enabled, gunicorn will emit a ``gunicorn.backlog`` histogram metric +showing the number of connections waiting in the socket backlog. + ## Process Naming ### `proc_name` @@ -719,7 +737,8 @@ def post_request(worker, req, environ, resp): Called after a worker processes the request. The callable needs to accept two instance variables for the Worker and -the Request. +the Request. If a third parameter is defined it will be passed the +environment. If a fourth parameter is defined it will be passed the Response. ### `child_exit` @@ -1024,8 +1043,11 @@ the headers defined here can not be passed directly from the client. **Default:** `'127.0.0.1,::1'` -Front-end's IPs from which allowed to handle set secure headers. -(comma separated). +Front-end's IP addresses or networks from which allowed to handle +set secure headers. (comma separated). + +Supports both individual IP addresses (e.g., ``192.168.1.1``) and +CIDR networks (e.g., ``192.168.0.0/16``). Set to ``*`` to disable checking of front-end IPs. This is useful for setups where you don't know in advance the IP address of front-end, but @@ -1126,16 +1148,27 @@ command line arguments to control server configuration instead. ### `proxy_protocol` -**Command line:** `--proxy-protocol` +**Command line:** `--proxy-protocol MODE` -**Default:** `False` +**Default:** `'off'` + +Enable PROXY protocol support. -Enable detect PROXY protocol (PROXY mode). +Allow using HTTP and PROXY protocol together. It may be useful for work +with stunnel as HTTPS frontend and Gunicorn as HTTP server, or with +HAProxy. -Allow using HTTP and Proxy together. It may be useful for work with -stunnel as HTTPS frontend and Gunicorn as HTTP server. +Accepted values: -PROXY protocol: http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt +* ``off`` - Disabled (default) +* ``v1`` - PROXY protocol v1 only (text format) +* ``v2`` - PROXY protocol v2 only (binary format) +* ``auto`` - Auto-detect v1 or v2 + +Using ``--proxy-protocol`` without a value is equivalent to ``auto``. + +PROXY protocol v1: http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt +PROXY protocol v2: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt Example for stunnel config:: @@ -1146,13 +1179,20 @@ Example for stunnel config:: cert = /etc/ssl/certs/stunnel.pem key = /etc/ssl/certs/stunnel.key +!!! info "Changed in 24.1.0" + Extended to support version selection (v1, v2, auto). + ### `proxy_allow_ips` **Command line:** `--proxy-allow-from` **Default:** `'127.0.0.1,::1'` -Front-end's IPs from which allowed accept proxy requests (comma separated). +Front-end's IP addresses or networks from which allowed accept +proxy requests (comma separated). + +Supports both individual IP addresses (e.g., ``192.168.1.1``) and +CIDR networks (e.g., ``192.168.0.0/16``). Set to ``*`` to disable checking of front-end IPs. This is useful for setups where you don't know in advance the IP address of front-end, but @@ -1442,11 +1482,11 @@ libraries may be installed using setuptools' ``extras_require`` feature. A string referring to one of the following bundled classes: * ``sync`` -* ``eventlet`` - Requires eventlet >= 0.24.1 (or install it via +* ``eventlet`` - Requires eventlet >= 0.40.3 (or install it via ``pip install gunicorn[eventlet]``) -* ``gevent`` - Requires gevent >= 1.4 (or install it via +* ``gevent`` - Requires gevent >= 24.10.1 (or install it via ``pip install gunicorn[gevent]``) -* ``tornado`` - Requires tornado >= 0.2 (or install it via +* ``tornado`` - Requires tornado >= 6.5.0 (or install it via ``pip install gunicorn[tornado]``) * ``gthread`` - Python 2 requires the futures package to be installed (or install it via ``pip install gunicorn[gthread]``) diff --git a/examples/example_config.py b/examples/example_config.py index 592882424c..f42b3d8638 100644 --- a/examples/example_config.py +++ b/examples/example_config.py @@ -34,14 +34,14 @@ # worker_class - The type of workers to use. The default # sync class should handle most 'normal' types of work # loads. You'll want to read -# http://docs.gunicorn.org/en/latest/design.html#choosing-a-worker-type +# https://gunicorn.org/design/#choosing-a-worker-type # for information on when you might want to choose one # of the other worker classes. # # A string referring to a Python path to a subclass of # gunicorn.workers.base.Worker. The default provided values # can be seen at -# http://docs.gunicorn.org/en/latest/settings.html#worker-class +# https://gunicorn.org/reference/settings/#worker_class # # worker_connections - For the eventlet and gevent worker classes # this limits the maximum number of simultaneous clients that diff --git a/gunicorn/__init__.py b/gunicorn/__init__.py index 347557ce81..8b90daf118 100644 --- a/gunicorn/__init__.py +++ b/gunicorn/__init__.py @@ -2,7 +2,7 @@ # This file is part of gunicorn released under the MIT license. # See the NOTICE for more information. -version_info = (24, 0, 0) +version_info = (24, 1, 0) __version__ = ".".join([str(v) for v in version_info]) SERVER = "gunicorn" SERVER_SOFTWARE = "%s/%s" % (SERVER, __version__) diff --git a/gunicorn/arbiter.py b/gunicorn/arbiter.py index 9b9c8d6d88..f8c64b4b50 100644 --- a/gunicorn/arbiter.py +++ b/gunicorn/arbiter.py @@ -237,6 +237,10 @@ def handle_chld(self): """SIGCHLD handling - called from main loop, safe to log.""" self.reap_workers() + # SIGCLD is an alias for SIGCHLD on Linux. The SIG_NAMES dict may map + # to either "chld" or "cld" depending on iteration order of dir(signal). + handle_cld = handle_chld + def handle_hup(self): """\ HUP handling. @@ -551,8 +555,11 @@ def reap_workers(self): if sig == signal.SIGKILL: msg += " Perhaps out of memory?" self.log.error(msg) + elif sig == signal.SIGTERM: + # SIGTERM is expected during graceful shutdown + self.log.info(msg) else: - # SIGTERM/SIGQUIT are expected during shutdown + # Other signals are unexpected self.log.warning(msg) if exitcode is not None and exitcode != 0: @@ -597,6 +604,16 @@ def manage_workers(self): "value": active_worker_count, "mtype": "gauge"}) + if self.cfg.enable_backlog_metric: + backlog = sum(sock.get_backlog() or 0 + for sock in self.LISTENERS) + + if backlog >= 0: + self.log.debug("socket backlog: {0}".format(backlog), + extra={"metric": "gunicorn.backlog", + "value": backlog, + "mtype": "histogram"}) + def spawn_worker(self): self.worker_age += 1 worker = self.worker_class(self.worker_age, self.pid, self.LISTENERS, @@ -631,8 +648,10 @@ def spawn_worker(self): print("%s" % e, file=sys.stderr) sys.stderr.flush() sys.exit(self.APP_LOAD_ERROR) - except Exception: + except Exception as e: self.log.exception("Exception in worker process") + print("%s" % e, file=sys.stderr) + sys.stderr.flush() if not worker.booted: sys.exit(self.WORKER_BOOT_ERROR) sys.exit(-1) diff --git a/gunicorn/asgi/message.py b/gunicorn/asgi/message.py index a2d8e82507..1bb26b9963 100644 --- a/gunicorn/asgi/message.py +++ b/gunicorn/asgi/message.py @@ -9,17 +9,22 @@ """ import io +import ipaddress import re import socket +import struct from gunicorn.http.errors import ( InvalidHeader, InvalidHeaderName, NoMoreData, InvalidRequestLine, InvalidRequestMethod, InvalidHTTPVersion, LimitRequestLine, LimitRequestHeaders, UnsupportedTransferCoding, ObsoleteFolding, - InvalidProxyLine, ForbiddenProxyRequest, + InvalidProxyLine, InvalidProxyHeader, ForbiddenProxyRequest, InvalidSchemeHeaders, ) +from gunicorn.http.message import ( + PP_V2_SIGNATURE, PPCommand, PPFamily, PPProtocol +) from gunicorn.util import bytes_to_str, split_request_uri MAX_REQUEST_LINE = 8190 @@ -34,6 +39,22 @@ RFC9110_5_5_INVALID_AND_DANGEROUS = re.compile(r"[\0\r\n]") +def _ip_in_allow_list(ip_str, allow_list): + """Check if IP address is in the allow list (which may contain networks).""" + if '*' in allow_list: + return True + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return False + for network in allow_list: + if network == '*': + return True + if ip in network: + return True + return False + + class AsyncRequest: """Async HTTP request parser. @@ -111,33 +132,29 @@ async def parse(cls, cfg, unreader, peer_addr, req_number=1): async def _parse(self): """Parse the request from the unreader.""" - buf = io.BytesIO() - await self._get_data(buf, stop=True) + buf = bytearray() + await self._read_into(buf, stop=True) - # Get request line - line, rbuf = await self._read_line(buf, self.limit_request_line) + # Handle proxy protocol if enabled and this is the first request + mode = self.cfg.proxy_protocol + if mode != "off" and self.req_number == 1: + buf = await self._handle_proxy_protocol(buf, mode) - # Proxy protocol - if self._proxy_protocol(bytes_to_str(line)): - # Get next request line - buf = io.BytesIO() - buf.write(rbuf) - line, rbuf = await self._read_line(buf, self.limit_request_line) + # Get request line + line, buf = await self._read_line(buf, self.limit_request_line) self._parse_request_line(line) - buf = io.BytesIO() - buf.write(rbuf) # Headers - data = buf.getvalue() + data = bytes(buf) while True: idx = data.find(b"\r\n\r\n") done = data[:2] == b"\r\n" if idx < 0 and not done: - await self._get_data(buf) - data = buf.getvalue() + await self._read_into(buf) + data = bytes(buf) if len(data) > self.max_buffer_headers: raise LimitRequestHeaders("max buffer headers") else: @@ -151,18 +168,18 @@ async def _parse(self): self._set_body_reader() - async def _get_data(self, buf, stop=False): - """Read data from unreader into buffer.""" + async def _read_into(self, buf, stop=False): + """Read data from unreader and append to bytearray buffer.""" data = await self.unreader.read() if not data: if stop: raise StopIteration() - raise NoMoreData(buf.getvalue()) - buf.write(data) + raise NoMoreData(bytes(buf)) + buf.extend(data) async def _read_line(self, buf, limit=0): - """Read a line from the buffer/stream.""" - data = buf.getvalue() + """Read a line from buffer, returning (line, remaining_buffer).""" + data = bytes(buf) while True: idx = data.find(b"\r\n") @@ -172,36 +189,54 @@ async def _read_line(self, buf, limit=0): break if len(data) - 2 > limit > 0: raise LimitRequestLine(len(data), limit) - await self._get_data(buf) - data = buf.getvalue() + await self._read_into(buf) + data = bytes(buf) - return (data[:idx], data[idx + 2:]) + return (data[:idx], bytearray(data[idx + 2:])) - def _proxy_protocol(self, line): - """Detect, check and parse proxy protocol.""" - if not self.cfg.proxy_protocol: - return False + async def _handle_proxy_protocol(self, buf, mode): + """Handle PROXY protocol detection and parsing. - if self.req_number != 1: - return False + Returns the buffer with proxy protocol data consumed. + """ + # Ensure we have enough data to detect v2 signature (12 bytes) + while len(buf) < 12: + await self._read_into(buf) - if not line.startswith("PROXY"): - return False + # Check for v2 signature first + if mode in ("v2", "auto") and buf[:12] == PP_V2_SIGNATURE: + self._proxy_protocol_access_check() + return await self._parse_proxy_protocol_v2(buf) - self._proxy_protocol_access_check() - self._parse_proxy_protocol(line) + # Check for v1 prefix + if mode in ("v1", "auto") and buf[:6] == b"PROXY ": + self._proxy_protocol_access_check() + return await self._parse_proxy_protocol_v1(buf) - return True + # Not proxy protocol - return buffer unchanged + return buf def _proxy_protocol_access_check(self): """Check if proxy protocol is allowed from this peer.""" - if ("*" not in self.cfg.proxy_allow_ips and - isinstance(self.peer_addr, tuple) and - self.peer_addr[0] not in self.cfg.proxy_allow_ips): + if (isinstance(self.peer_addr, tuple) and + not _ip_in_allow_list(self.peer_addr[0], self.cfg.proxy_allow_ips)): raise ForbiddenProxyRequest(self.peer_addr[0]) - def _parse_proxy_protocol(self, line): - """Parse proxy protocol header line.""" + async def _parse_proxy_protocol_v1(self, buf): + """Parse PROXY protocol v1 (text format). + + Returns buffer with v1 header consumed. + """ + # Read until we find \r\n + data = bytes(buf) + while b"\r\n" not in data: + await self._read_into(buf) + data = bytes(buf) + + idx = data.find(b"\r\n") + line = bytes_to_str(data[:idx]) + remaining = bytearray(data[idx + 2:]) + bits = line.split(" ") if len(bits) != 6: @@ -244,6 +279,101 @@ def _parse_proxy_protocol(self, line): "proxy_port": d_port } + return remaining + + async def _parse_proxy_protocol_v2(self, buf): + """Parse PROXY protocol v2 (binary format). + + Returns buffer with v2 header consumed. + """ + # We need at least 16 bytes for the header (12 signature + 4 header) + while len(buf) < 16: + await self._read_into(buf) + + # Parse header fields (after 12-byte signature) + ver_cmd = buf[12] + fam_proto = buf[13] + length = struct.unpack(">H", bytes(buf[14:16]))[0] + + # Validate version (high nibble must be 0x2) + version = (ver_cmd & 0xF0) >> 4 + if version != 2: + raise InvalidProxyHeader("unsupported version %d" % version) + + # Extract command (low nibble) + command = ver_cmd & 0x0F + if command not in (PPCommand.LOCAL, PPCommand.PROXY): + raise InvalidProxyHeader("unsupported command %d" % command) + + # Ensure we have the complete header + total_header_size = 16 + length + while len(buf) < total_header_size: + await self._read_into(buf) + + # For LOCAL command, no address info is provided + if command == PPCommand.LOCAL: + self.proxy_protocol_info = { + "proxy_protocol": "LOCAL", + "client_addr": None, + "client_port": None, + "proxy_addr": None, + "proxy_port": None + } + return bytearray(buf[total_header_size:]) + + # Extract address family and protocol + family = (fam_proto & 0xF0) >> 4 + protocol = fam_proto & 0x0F + + # We only support TCP (STREAM) + if protocol != PPProtocol.STREAM: + raise InvalidProxyHeader("only TCP protocol is supported") + + addr_data = bytes(buf[16:16 + length]) + + if family == PPFamily.INET: # IPv4 + if length < 12: # 4+4+2+2 + raise InvalidProxyHeader("insufficient address data for IPv4") + s_addr = socket.inet_ntop(socket.AF_INET, addr_data[0:4]) + d_addr = socket.inet_ntop(socket.AF_INET, addr_data[4:8]) + s_port = struct.unpack(">H", addr_data[8:10])[0] + d_port = struct.unpack(">H", addr_data[10:12])[0] + proto = "TCP4" + + elif family == PPFamily.INET6: # IPv6 + if length < 36: # 16+16+2+2 + raise InvalidProxyHeader("insufficient address data for IPv6") + s_addr = socket.inet_ntop(socket.AF_INET6, addr_data[0:16]) + d_addr = socket.inet_ntop(socket.AF_INET6, addr_data[16:32]) + s_port = struct.unpack(">H", addr_data[32:34])[0] + d_port = struct.unpack(">H", addr_data[34:36])[0] + proto = "TCP6" + + elif family == PPFamily.UNSPEC: + # No address info provided with PROXY command + self.proxy_protocol_info = { + "proxy_protocol": "UNSPEC", + "client_addr": None, + "client_port": None, + "proxy_addr": None, + "proxy_port": None + } + return bytearray(buf[total_header_size:]) + + else: + raise InvalidProxyHeader("unsupported address family %d" % family) + + # Set data + self.proxy_protocol_info = { + "proxy_protocol": proto, + "client_addr": s_addr, + "client_port": s_port, + "proxy_addr": d_addr, + "proxy_port": d_port + } + + return bytearray(buf[total_header_size:]) + def _parse_request_line(self, line_bytes): """Parse the HTTP request line.""" bits = [bytes_to_str(bit) for bit in line_bytes.split(b" ", 2)] @@ -299,9 +429,8 @@ def _parse_headers(self, data, from_trailer=False): forwarder_headers = [] if from_trailer: pass - elif ('*' in cfg.forwarded_allow_ips or - not isinstance(self.peer_addr, tuple) - or self.peer_addr[0] in cfg.forwarded_allow_ips): + elif (not isinstance(self.peer_addr, tuple) + or _ip_in_allow_list(self.peer_addr[0], cfg.forwarded_allow_ips)): secure_scheme_headers = cfg.secure_scheme_headers forwarder_headers = cfg.forwarder_headers diff --git a/gunicorn/config.py b/gunicorn/config.py index 2dcf64d0d1..58141e55b2 100644 --- a/gunicorn/config.py +++ b/gunicorn/config.py @@ -62,6 +62,8 @@ def __str__(self): return "\n".join(lines) def __getattr__(self, name): + if name == "settings": + raise AttributeError() if name not in self.settings: raise AttributeError("No configuration setting for: %s" % name) return self.settings[name].get() @@ -405,12 +407,15 @@ def validate_list_of_existing_files(val): def validate_string_to_addr_list(val): val = validate_string_to_list(val) + result = [] for addr in val: if addr == "*": + result.append(addr) continue - _vaid_ip = ipaddress.ip_address(addr) + # Support both single IPs and CIDR networks + result.append(ipaddress.ip_network(addr, strict=False)) - return val + return result def validate_string_to_list(val): @@ -1276,8 +1281,11 @@ class ForwardedAllowIPS(Setting): validator = validate_string_to_addr_list default = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1,::1") desc = """\ - Front-end's IPs from which allowed to handle set secure headers. - (comma separated). + Front-end's IP addresses or networks from which allowed to handle + set secure headers. (comma separated). + + Supports both individual IP addresses (e.g., ``192.168.1.1``) and + CIDR networks (e.g., ``192.168.0.0/16``). Set to ``*`` to disable checking of front-end IPs. This is useful for setups where you don't know in advance the IP address of front-end, but @@ -1568,6 +1576,15 @@ class SyslogTo(Setting): else: default = "udp://localhost:514" + default_doc = """\ + Platform-specific: + + * macOS: ``'unix:///var/run/syslog'`` + * FreeBSD/DragonFly: ``'unix:///var/run/log'`` + * OpenBSD: ``'unix:///dev/log'`` + * Linux/other: ``'udp://localhost:514'`` + """ + desc = """\ Address to send syslog messages. @@ -1672,7 +1689,7 @@ class DogstatsdTags(Setting): validator = validate_string desc = """\ A comma-delimited list of datadog statsd (dogstatsd) tags to append to - statsd metrics. + statsd metrics. e.g. ``'tag1:value1,tag2:value2'`` .. versionadded:: 20 """ @@ -1693,6 +1710,21 @@ class StatsdPrefix(Setting): """ +class BacklogMetric(Setting): + name = "enable_backlog_metric" + section = "Logging" + cli = ["--enable-backlog-metric"] + validator = validate_bool + default = False + action = "store_true" + desc = """\ + Enable socket backlog metric (only supported on Linux). + + When enabled, gunicorn will emit a ``gunicorn.backlog`` histogram metric + showing the number of connections waiting in the socket backlog. + """ + + class Procname(Setting): name = "proc_name" section = "Process Naming" @@ -1938,7 +1970,8 @@ def post_request(worker, req, environ, resp): Called after a worker processes the request. The callable needs to accept two instance variables for the Worker and - the Request. + the Request. If a third parameter is defined it will be passed the + environment. If a fourth parameter is defined it will be passed the Response. """ @@ -2049,20 +2082,57 @@ def ssl_context(conf, default_ssl_context_factory): """ +def validate_proxy_protocol(val): + """Validate proxy_protocol setting. + + Accepts: off, false, v1, v2, auto, true + Returns normalized value: off, v1, v2, or auto + """ + if val is None: + return "off" + if isinstance(val, bool): + return "auto" if val else "off" + if not isinstance(val, str): + raise TypeError("proxy_protocol must be string or bool") + + val = val.lower().strip() + mapping = { + "false": "off", "off": "off", "0": "off", "none": "off", + "true": "auto", "auto": "auto", "1": "auto", + "v1": "v1", "v2": "v2", + } + if val not in mapping: + raise ValueError("proxy_protocol must be: off, v1, v2, or auto") + return mapping[val] + + class ProxyProtocol(Setting): name = "proxy_protocol" section = "Server Mechanics" cli = ["--proxy-protocol"] - validator = validate_bool - default = False - action = "store_true" + meta = "MODE" + validator = validate_proxy_protocol + default = "off" + nargs = "?" + const = "auto" desc = """\ - Enable detect PROXY protocol (PROXY mode). + Enable PROXY protocol support. + + Allow using HTTP and PROXY protocol together. It may be useful for work + with stunnel as HTTPS frontend and Gunicorn as HTTP server, or with + HAProxy. - Allow using HTTP and Proxy together. It may be useful for work with - stunnel as HTTPS frontend and Gunicorn as HTTP server. + Accepted values: - PROXY protocol: http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt + * ``off`` - Disabled (default) + * ``v1`` - PROXY protocol v1 only (text format) + * ``v2`` - PROXY protocol v2 only (binary format) + * ``auto`` - Auto-detect v1 or v2 + + Using ``--proxy-protocol`` without a value is equivalent to ``auto``. + + PROXY protocol v1: http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt + PROXY protocol v2: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt Example for stunnel config:: @@ -2072,6 +2142,9 @@ class ProxyProtocol(Setting): connect = 80 cert = /etc/ssl/certs/stunnel.pem key = /etc/ssl/certs/stunnel.key + + .. versionchanged:: 24.1.0 + Extended to support version selection (v1, v2, auto). """ @@ -2082,7 +2155,11 @@ class ProxyAllowFrom(Setting): validator = validate_string_to_addr_list default = "127.0.0.1,::1" desc = """\ - Front-end's IPs from which allowed accept proxy requests (comma separated). + Front-end's IP addresses or networks from which allowed accept + proxy requests (comma separated). + + Supports both individual IP addresses (e.g., ``192.168.1.1``) and + CIDR networks (e.g., ``192.168.0.0/16``). Set to ``*`` to disable checking of front-end IPs. This is useful for setups where you don't know in advance the IP address of front-end, but diff --git a/gunicorn/glogging.py b/gunicorn/glogging.py index e34fcd5f78..ade25eee14 100644 --- a/gunicorn/glogging.py +++ b/gunicorn/glogging.py @@ -237,7 +237,7 @@ def setup(self, cfg): ValueError, TypeError ) as exc: - raise RuntimeError(str(exc)) + raise RuntimeError(str(exc)) from exc elif cfg.logconfig_json: config = CONFIG_DEFAULTS.copy() if os.path.exists(cfg.logconfig_json): @@ -252,7 +252,7 @@ def setup(self, cfg): ValueError, TypeError ) as exc: - raise RuntimeError(str(exc)) + raise RuntimeError(str(exc)) from exc elif cfg.logconfig: if os.path.exists(cfg.logconfig): defaults = CONFIG_DEFAULTS.copy() @@ -442,8 +442,8 @@ def _set_syslog_handler(self, log, cfg, fmt, name): # syslog facility try: facility = SYSLOG_FACILITIES[cfg.syslog_facility.lower()] - except KeyError: - raise RuntimeError("unknown facility name") + except KeyError as exc: + raise RuntimeError("unknown facility name") from exc # parse syslog address socktype, addr = parse_syslog_address(cfg.syslog_addr) diff --git a/gunicorn/http/errors.py b/gunicorn/http/errors.py index bcb9700725..e9c24917bd 100644 --- a/gunicorn/http/errors.py +++ b/gunicorn/http/errors.py @@ -131,6 +131,15 @@ def __str__(self): return "Invalid PROXY line: %r" % self.line +class InvalidProxyHeader(ParseException): + def __init__(self, msg): + self.msg = msg + self.code = 400 + + def __str__(self): + return "Invalid PROXY header: %s" % self.msg + + class ForbiddenProxyRequest(ParseException): def __init__(self, host): self.host = host diff --git a/gunicorn/http/message.py b/gunicorn/http/message.py index 59ce0bf4be..81132b3424 100644 --- a/gunicorn/http/message.py +++ b/gunicorn/http/message.py @@ -2,9 +2,11 @@ # This file is part of gunicorn released under the MIT license. # See the NOTICE for more information. -import io +from enum import IntEnum +import ipaddress import re import socket +import struct from gunicorn.http.body import ChunkedReader, LengthReader, EOFReader, Body from gunicorn.http.errors import ( @@ -13,10 +15,36 @@ LimitRequestLine, LimitRequestHeaders, UnsupportedTransferCoding, ObsoleteFolding, ) -from gunicorn.http.errors import InvalidProxyLine, ForbiddenProxyRequest +from gunicorn.http.errors import InvalidProxyLine, InvalidProxyHeader, ForbiddenProxyRequest from gunicorn.http.errors import InvalidSchemeHeaders from gunicorn.util import bytes_to_str, split_request_uri + +# PROXY protocol v2 constants +PP_V2_SIGNATURE = b"\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A" + + +class PPCommand(IntEnum): + """PROXY protocol v2 commands.""" + LOCAL = 0x0 + PROXY = 0x1 + + +class PPFamily(IntEnum): + """PROXY protocol v2 address families.""" + UNSPEC = 0x0 + INET = 0x1 # IPv4 + INET6 = 0x2 # IPv6 + UNIX = 0x3 + + +class PPProtocol(IntEnum): + """PROXY protocol v2 transport protocols.""" + UNSPEC = 0x0 + STREAM = 0x1 # TCP + DGRAM = 0x2 # UDP + + MAX_REQUEST_LINE = 8190 MAX_HEADERS = 32768 DEFAULT_MAX_HEADERFIELD_SIZE = 8190 @@ -30,6 +58,22 @@ RFC9110_5_5_INVALID_AND_DANGEROUS = re.compile(r"[\0\r\n]") +def _ip_in_allow_list(ip_str, allow_list): + """Check if IP address is in the allow list (which may contain networks).""" + if '*' in allow_list: + return True + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return False + for network in allow_list: + if network == '*': + return True + if ip in network: + return True + return False + + class Message: def __init__(self, cfg, unreader, peer_addr): self.cfg = cfg @@ -82,9 +126,8 @@ def parse_headers(self, data, from_trailer=False): # nonsense. either a request is https from the beginning # .. or we are just behind a proxy who does not remove conflicting trailers pass - elif ('*' in cfg.forwarded_allow_ips or - not isinstance(self.peer_addr, tuple) - or self.peer_addr[0] in cfg.forwarded_allow_ips): + elif (not isinstance(self.peer_addr, tuple) + or _ip_in_allow_list(self.peer_addr[0], cfg.forwarded_allow_ips)): secure_scheme_headers = cfg.secure_scheme_headers forwarder_headers = cfg.forwarder_headers @@ -267,26 +310,21 @@ def get_data(self, unreader, buf, stop=False): buf.write(data) def parse(self, unreader): - buf = io.BytesIO() - self.get_data(unreader, buf, stop=True) + buf = bytearray() + self.read_into(unreader, buf, stop=True) - # get request line - line, rbuf = self.read_line(unreader, buf, self.limit_request_line) + # Handle proxy protocol if enabled and this is the first request + mode = self.cfg.proxy_protocol + if mode != "off" and self.req_number == 1: + buf = self._handle_proxy_protocol(unreader, buf, mode) - # proxy protocol - if self.proxy_protocol(bytes_to_str(line)): - # get next request line - buf = io.BytesIO() - buf.write(rbuf) - line, rbuf = self.read_line(unreader, buf, self.limit_request_line) + # Get request line + line, buf = self.read_line(unreader, buf, self.limit_request_line) self.parse_request_line(line) - buf = io.BytesIO() - buf.write(rbuf) # Headers - data = buf.getvalue() - idx = data.find(b"\r\n\r\n") + data = bytes(buf) done = data[:2] == b"\r\n" while True: @@ -294,8 +332,8 @@ def parse(self, unreader): done = data[:2] == b"\r\n" if idx < 0 and not done: - self.get_data(unreader, buf) - data = buf.getvalue() + self.read_into(unreader, buf) + data = bytes(buf) if len(data) > self.max_buffer_headers: raise LimitRequestHeaders("max buffer headers") else: @@ -308,11 +346,20 @@ def parse(self, unreader): self.headers = self.parse_headers(data[:idx], from_trailer=False) ret = data[idx + 4:] - buf = None return ret + def read_into(self, unreader, buf, stop=False): + """Read data from unreader and append to bytearray buffer.""" + data = unreader.read() + if not data: + if stop: + raise StopIteration() + raise NoMoreData(bytes(buf)) + buf.extend(data) + def read_line(self, unreader, buf, limit=0): - data = buf.getvalue() + """Read a line from buffer, returning (line, remaining_buffer).""" + data = bytes(buf) while True: idx = data.find(b"\r\n") @@ -323,41 +370,61 @@ def read_line(self, unreader, buf, limit=0): break if len(data) - 2 > limit > 0: raise LimitRequestLine(len(data), limit) - self.get_data(unreader, buf) - data = buf.getvalue() + self.read_into(unreader, buf) + data = bytes(buf) return (data[:idx], # request line, - data[idx + 2:]) # residue in the buffer, skip \r\n + bytearray(data[idx + 2:])) # residue in the buffer, skip \r\n - def proxy_protocol(self, line): - """\ - Detect, check and parse proxy protocol. + def read_bytes(self, unreader, buf, count): + """Read exactly count bytes from buffer/unreader.""" + while len(buf) < count: + self.read_into(unreader, buf) + return bytes(buf[:count]), bytearray(buf[count:]) - :raises: ForbiddenProxyRequest, InvalidProxyLine. - :return: True for proxy protocol line else False - """ - if not self.cfg.proxy_protocol: - return False + def _handle_proxy_protocol(self, unreader, buf, mode): + """Handle PROXY protocol detection and parsing. - if self.req_number != 1: - return False + Returns the buffer with proxy protocol data consumed. + """ + # Ensure we have enough data to detect v2 signature (12 bytes) + while len(buf) < 12: + self.read_into(unreader, buf) - if not line.startswith("PROXY"): - return False + # Check for v2 signature first + if mode in ("v2", "auto") and buf[:12] == PP_V2_SIGNATURE: + self.proxy_protocol_access_check() + return self._parse_proxy_protocol_v2(unreader, buf) - self.proxy_protocol_access_check() - self.parse_proxy_protocol(line) + # Check for v1 prefix + if mode in ("v1", "auto") and buf[:6] == b"PROXY ": + self.proxy_protocol_access_check() + return self._parse_proxy_protocol_v1(unreader, buf) - return True + # Not proxy protocol - return buffer unchanged + return buf def proxy_protocol_access_check(self): - # check in allow list - if ("*" not in self.cfg.proxy_allow_ips and - isinstance(self.peer_addr, tuple) and - self.peer_addr[0] not in self.cfg.proxy_allow_ips): + """Check if proxy protocol is allowed from this peer.""" + if (isinstance(self.peer_addr, tuple) and + not _ip_in_allow_list(self.peer_addr[0], self.cfg.proxy_allow_ips)): raise ForbiddenProxyRequest(self.peer_addr[0]) - def parse_proxy_protocol(self, line): + def _parse_proxy_protocol_v1(self, unreader, buf): + """Parse PROXY protocol v1 (text format). + + Returns buffer with v1 header consumed. + """ + # Read until we find \r\n + data = bytes(buf) + while b"\r\n" not in data: + self.read_into(unreader, buf) + data = bytes(buf) + + idx = data.find(b"\r\n") + line = bytes_to_str(data[:idx]) + remaining = bytearray(data[idx + 2:]) + bits = line.split(" ") if len(bits) != 6: @@ -402,6 +469,101 @@ def parse_proxy_protocol(self, line): "proxy_port": d_port } + return remaining + + def _parse_proxy_protocol_v2(self, unreader, buf): + """Parse PROXY protocol v2 (binary format). + + Returns buffer with v2 header consumed. + """ + # We need at least 16 bytes for the header (12 signature + 4 header) + while len(buf) < 16: + self.read_into(unreader, buf) + + # Parse header fields (after 12-byte signature) + ver_cmd = buf[12] + fam_proto = buf[13] + length = struct.unpack(">H", bytes(buf[14:16]))[0] + + # Validate version (high nibble must be 0x2) + version = (ver_cmd & 0xF0) >> 4 + if version != 2: + raise InvalidProxyHeader("unsupported version %d" % version) + + # Extract command (low nibble) + command = ver_cmd & 0x0F + if command not in (PPCommand.LOCAL, PPCommand.PROXY): + raise InvalidProxyHeader("unsupported command %d" % command) + + # Ensure we have the complete header + total_header_size = 16 + length + while len(buf) < total_header_size: + self.read_into(unreader, buf) + + # For LOCAL command, no address info is provided + if command == PPCommand.LOCAL: + self.proxy_protocol_info = { + "proxy_protocol": "LOCAL", + "client_addr": None, + "client_port": None, + "proxy_addr": None, + "proxy_port": None + } + return bytearray(buf[total_header_size:]) + + # Extract address family and protocol + family = (fam_proto & 0xF0) >> 4 + protocol = fam_proto & 0x0F + + # We only support TCP (STREAM) + if protocol != PPProtocol.STREAM: + raise InvalidProxyHeader("only TCP protocol is supported") + + addr_data = bytes(buf[16:16 + length]) + + if family == PPFamily.INET: # IPv4 + if length < 12: # 4+4+2+2 + raise InvalidProxyHeader("insufficient address data for IPv4") + s_addr = socket.inet_ntop(socket.AF_INET, addr_data[0:4]) + d_addr = socket.inet_ntop(socket.AF_INET, addr_data[4:8]) + s_port = struct.unpack(">H", addr_data[8:10])[0] + d_port = struct.unpack(">H", addr_data[10:12])[0] + proto = "TCP4" + + elif family == PPFamily.INET6: # IPv6 + if length < 36: # 16+16+2+2 + raise InvalidProxyHeader("insufficient address data for IPv6") + s_addr = socket.inet_ntop(socket.AF_INET6, addr_data[0:16]) + d_addr = socket.inet_ntop(socket.AF_INET6, addr_data[16:32]) + s_port = struct.unpack(">H", addr_data[32:34])[0] + d_port = struct.unpack(">H", addr_data[34:36])[0] + proto = "TCP6" + + elif family == PPFamily.UNSPEC: + # No address info provided with PROXY command + self.proxy_protocol_info = { + "proxy_protocol": "UNSPEC", + "client_addr": None, + "client_port": None, + "proxy_addr": None, + "proxy_port": None + } + return bytearray(buf[total_header_size:]) + + else: + raise InvalidProxyHeader("unsupported address family %d" % family) + + # Set data + self.proxy_protocol_info = { + "proxy_protocol": proto, + "client_addr": s_addr, + "client_port": s_port, + "proxy_addr": d_addr, + "proxy_port": d_port + } + + return bytearray(buf[total_header_size:]) + def parse_request_line(self, line_bytes): bits = [bytes_to_str(bit) for bit in line_bytes.split(b" ", 2)] if len(bits) != 3: diff --git a/gunicorn/http/parser.py b/gunicorn/http/parser.py index 05ee6ca664..260beafa22 100644 --- a/gunicorn/http/parser.py +++ b/gunicorn/http/parser.py @@ -2,6 +2,8 @@ # This file is part of gunicorn released under the MIT license. # See the NOTICE for more information. +import ssl + from gunicorn.http.message import Request from gunicorn.http.unreader import SocketUnreader, IterUnreader @@ -33,9 +35,13 @@ def finish_body(self): leftover body bytes. """ if self.mesg: - data = self.mesg.body.read(8192) - while data: - data = self.mesg.body.read(8192) + try: + data = self.mesg.body.read(1024) + while data: + data = self.mesg.body.read(1024) + except ssl.SSLWantReadError: + # SSL socket has no more application data available + pass def __next__(self): # Stop if HTTP dictates a stop. diff --git a/gunicorn/http/unreader.py b/gunicorn/http/unreader.py index 9aadfbcff8..1138e02fb0 100644 --- a/gunicorn/http/unreader.py +++ b/gunicorn/http/unreader.py @@ -49,8 +49,10 @@ def read(self, size=None): return data[:size] def unread(self, data): - self.buf.seek(0, os.SEEK_END) + rest = self.buf.getvalue() + self.buf = io.BytesIO() self.buf.write(data) + self.buf.write(rest) class SocketUnreader(Unreader): diff --git a/gunicorn/instrument/statsd.py b/gunicorn/instrument/statsd.py index 7bc4e6ffdf..708a1d6bf9 100644 --- a/gunicorn/instrument/statsd.py +++ b/gunicorn/instrument/statsd.py @@ -17,6 +17,7 @@ GAUGE_TYPE = "gauge" COUNTER_TYPE = "counter" HISTOGRAM_TYPE = "histogram" +TIMER_TYPE = "timer" class Statsd(Logger): @@ -80,6 +81,8 @@ def log(self, lvl, msg, *args, **kwargs): self.increment(metric, value) elif typ == HISTOGRAM_TYPE: self.histogram(metric, value) + elif typ == TIMER_TYPE: + self.timer(metric, value) else: pass @@ -101,7 +104,7 @@ def access(self, resp, req, environ, request_time): status = status.decode('utf-8') if isinstance(status, str): status = int(status.split(None, 1)[0]) - self.histogram("gunicorn.request.duration", duration_in_ms) + self.timer("gunicorn.request.duration", duration_in_ms) self.increment("gunicorn.requests", 1) self.increment("gunicorn.request.status.%d" % status, 1) @@ -116,9 +119,12 @@ def increment(self, name, value, sampling_rate=1.0): def decrement(self, name, value, sampling_rate=1.0): self._sock_send("{0}{1}:-{2}|c|@{3}".format(self.prefix, name, value, sampling_rate)) - def histogram(self, name, value): + def timer(self, name, value): self._sock_send("{0}{1}:{2}|ms".format(self.prefix, name, value)) + def histogram(self, name, value): + self._sock_send("{0}{1}:{2}|h".format(self.prefix, name, value)) + def _sock_send(self, msg): try: if isinstance(msg, str): diff --git a/gunicorn/reloader.py b/gunicorn/reloader.py index 1c67f2a7dc..3952aad481 100644 --- a/gunicorn/reloader.py +++ b/gunicorn/reloader.py @@ -13,7 +13,7 @@ COMPILED_EXT_RE = re.compile(r'py[co]$') -class Reloader(threading.Thread): +class ReloaderBase(threading.Thread): def __init__(self, extra_files=None, interval=1, callback=None): super().__init__() self.daemon = True @@ -35,6 +35,8 @@ def get_files(self): return fnames + +class Reloader(ReloaderBase): def run(self): mtimes = {} while True: @@ -65,25 +67,21 @@ def run(self): if has_inotify: - class InotifyReloader(threading.Thread): + class InotifyReloader(ReloaderBase): event_mask = (inotify.constants.IN_CREATE | inotify.constants.IN_DELETE | inotify.constants.IN_DELETE_SELF | inotify.constants.IN_MODIFY | inotify.constants.IN_MOVE_SELF | inotify.constants.IN_MOVED_FROM | inotify.constants.IN_MOVED_TO) def __init__(self, extra_files=None, callback=None): - super().__init__() - self.daemon = True - self._callback = callback + super().__init__(extra_files=extra_files, callback=callback) self._dirs = set() self._watcher = Inotify() - for extra_file in extra_files: - self.add_extra_file(extra_file) - def add_extra_file(self, filename): - dirname = os.path.dirname(filename) + super().add_extra_file(filename) + dirname = os.path.dirname(filename) if dirname in self._dirs: return @@ -91,23 +89,22 @@ def add_extra_file(self, filename): self._dirs.add(dirname) def get_dirs(self): - fnames = [ - os.path.dirname(os.path.abspath(COMPILED_EXT_RE.sub('py', module.__file__))) - for module in tuple(sys.modules.values()) - if getattr(module, '__file__', None) - ] + dirnames = [os.path.dirname(os.path.abspath(fname)) for fname in self.get_files()] + return set(dirnames) - return set(fnames) + def refresh_dirs(self): + new_dirs = self.get_dirs().difference(self._dirs) + self._dirs.update(new_dirs) + for new_dir in new_dirs: + if os.path.isdir(new_dir): + self._watcher.add_watch(new_dir, mask=self.event_mask) def run(self): - self._dirs = self.get_dirs() - - for dirname in self._dirs: - if os.path.isdir(dirname): - self._watcher.add_watch(dirname, mask=self.event_mask) + self.refresh_dirs() for event in self._watcher.event_gen(): if event is None: + self.refresh_dirs() continue filename = event[3] diff --git a/gunicorn/sock.py b/gunicorn/sock.py index eb2b6fa9c6..d89d752cf7 100644 --- a/gunicorn/sock.py +++ b/gunicorn/sock.py @@ -7,11 +7,14 @@ import socket import ssl import stat +import struct import sys import time from gunicorn import util +PLATFORM = sys.platform + class BaseSocket: @@ -70,6 +73,9 @@ def close(self): self.sock = None + def get_backlog(self): + return -1 + class TCPSocket(BaseSocket): @@ -88,6 +94,23 @@ def set_options(self, sock, bound=False): sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) return super().set_options(sock, bound=bound) + if PLATFORM == "linux": + def get_backlog(self): + if self.sock: + # tcp_info struct from include/uapi/linux/tcp.h + fmt = 'B' * 8 + 'I' * 24 + try: + tcp_info_struct = self.sock.getsockopt(socket.IPPROTO_TCP, + socket.TCP_INFO, 104) + # 12 is tcpi_unacked + return struct.unpack(fmt, tcp_info_struct)[12] + except (AttributeError, OSError): + pass + return 0 + else: + def get_backlog(self): + return -1 + class TCP6Socket(TCPSocket): diff --git a/gunicorn/workers/gthread.py b/gunicorn/workers/gthread.py index 7cab992003..1665f4e6c1 100644 --- a/gunicorn/workers/gthread.py +++ b/gunicorn/workers/gthread.py @@ -344,6 +344,12 @@ def handle(self, conn): """Handle a request on a connection. Runs in a worker thread.""" req = None try: + # Always ensure blocking mode in worker thread. + # Critical for keepalive connections: the socket is set to non-blocking + # for the selector in finish_request(), but must be blocking for + # request/body reading to avoid SSLWantReadError on SSL connections. + conn.sock.setblocking(True) + # Initialize connection in worker thread to handle SSL errors gracefully # (ENOTCONN from ssl_wrap_socket would crash main thread otherwise) conn.init() diff --git a/pyproject.toml b/pyproject.toml index c176784f6f..5bdf6e952f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,10 +41,10 @@ dynamic = ["version"] [project.urls] Homepage = "https://gunicorn.org" -Documentation = "https://docs.gunicorn.org" +Documentation = "https://gunicorn.org" "Issue tracker" = "https://github.com/benoitc/gunicorn/issues" "Source code" = "https://github.com/benoitc/gunicorn" -Changelog = "https://docs.gunicorn.org/en/stable/news.html" +Changelog = "https://gunicorn.org/news/" [project.optional-dependencies] gevent = ["gevent>=24.10.1"] diff --git a/tests/requests/valid/pp_03.http b/tests/requests/valid/pp_03.http new file mode 100644 index 0000000000..5a2f784f2a --- /dev/null +++ b/tests/requests/valid/pp_03.http @@ -0,0 +1,4 @@ +GET /no/proxy/header HTTP/1.1\r\n +Host: example.com\r\n +Content-Length: 0\r\n +\r\n diff --git a/tests/requests/valid/pp_03.py b/tests/requests/valid/pp_03.py new file mode 100644 index 0000000000..70112876aa --- /dev/null +++ b/tests/requests/valid/pp_03.py @@ -0,0 +1,15 @@ +from gunicorn.config import Config + +cfg = Config() +cfg.set("proxy_protocol", True) + +request = { + "method": "GET", + "uri": uri("/no/proxy/header"), + "version": (1, 1), + "headers": [ + ("HOST", "example.com"), + ("CONTENT-LENGTH", "0") + ], + "body": b"" +} diff --git a/tests/requests/valid/pp_04.http b/tests/requests/valid/pp_04.http new file mode 100644 index 0000000000..f4e9ec9566 --- /dev/null +++ b/tests/requests/valid/pp_04.http @@ -0,0 +1,4 @@ +\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A\x21\x11\x00\x0C\xC0\xA8\x01\x0A\xC0\xA8\x01\x01\x30\x39\x01\xBBGET /proxy/v2/ipv4 HTTP/1.1\r\n +Host: example.com\r\n +Content-Length: 0\r\n +\r\n diff --git a/tests/requests/valid/pp_04.py b/tests/requests/valid/pp_04.py new file mode 100644 index 0000000000..cbf6e7a8c4 --- /dev/null +++ b/tests/requests/valid/pp_04.py @@ -0,0 +1,15 @@ +from gunicorn.config import Config + +cfg = Config() +cfg.set("proxy_protocol", True) + +request = { + "method": "GET", + "uri": uri("/proxy/v2/ipv4"), + "version": (1, 1), + "headers": [ + ("HOST", "example.com"), + ("CONTENT-LENGTH", "0") + ], + "body": b"" +} diff --git a/tests/requests/valid/pp_05.http b/tests/requests/valid/pp_05.http new file mode 100644 index 0000000000..616bde2955 --- /dev/null +++ b/tests/requests/valid/pp_05.http @@ -0,0 +1,4 @@ +\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A\x21\x21\x00\x24\x20\x01\x0D\xB8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x20\x01\x0D\xB8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\xD4\x31\x00\x50GET /proxy/v2/ipv6 HTTP/1.1\r\n +Host: example.com\r\n +Content-Length: 0\r\n +\r\n diff --git a/tests/requests/valid/pp_05.py b/tests/requests/valid/pp_05.py new file mode 100644 index 0000000000..80e2b76458 --- /dev/null +++ b/tests/requests/valid/pp_05.py @@ -0,0 +1,15 @@ +from gunicorn.config import Config + +cfg = Config() +cfg.set("proxy_protocol", True) + +request = { + "method": "GET", + "uri": uri("/proxy/v2/ipv6"), + "version": (1, 1), + "headers": [ + ("HOST", "example.com"), + ("CONTENT-LENGTH", "0") + ], + "body": b"" +} diff --git a/tests/test_arbiter.py b/tests/test_arbiter.py index e9d03e1950..ff855b46b7 100644 --- a/tests/test_arbiter.py +++ b/tests/test_arbiter.py @@ -407,8 +407,8 @@ def test_reap_killed_by_signal(self, mock_waitpid): mock_worker = mock.Mock() arbiter.WORKERS = {42: mock_worker} - # SIGTERM should be logged as warning (not error) - with mock.patch.object(arbiter.log, 'warning') as mock_log: + # SIGTERM should be logged as info (expected during graceful shutdown) + with mock.patch.object(arbiter.log, 'info') as mock_log: arbiter.reap_workers() # Should log the signal diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 227f7ea2c9..e39ae91a73 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -8,6 +8,7 @@ import asyncio import io +import ipaddress import pytest from unittest import mock @@ -48,9 +49,9 @@ class MockConfig: def __init__(self): self.is_ssl = False - self.proxy_protocol = False - self.proxy_allow_ips = ["127.0.0.1"] - self.forwarded_allow_ips = ["127.0.0.1"] + self.proxy_protocol = "off" + self.proxy_allow_ips = [ipaddress.ip_network("127.0.0.1")] + self.forwarded_allow_ips = [ipaddress.ip_network("127.0.0.1")] self.secure_scheme_headers = {} self.forwarder_headers = [] self.limit_request_line = 8190 diff --git a/tests/test_config.py b/tests/test_config.py index 6ca014b6a5..0aff90f5fc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,7 @@ # This file is part of gunicorn released under the MIT license. # See the NOTICE for more information. +import ipaddress import os import re import sys @@ -165,10 +166,30 @@ def test_str_validation(): def test_str_to_addr_list_validation(): c = config.Config() - assert c.proxy_allow_ips == ["127.0.0.1", "::1"] - assert c.forwarded_allow_ips == ["127.0.0.1", "::1"] + # Default values are now network objects + assert c.proxy_allow_ips == [ + ipaddress.ip_network("127.0.0.1/32"), + ipaddress.ip_network("::1/128") + ] + assert c.forwarded_allow_ips == [ + ipaddress.ip_network("127.0.0.1/32"), + ipaddress.ip_network("::1/128") + ] + # Single IPs are converted to /32 or /128 networks c.set("forwarded_allow_ips", "127.0.0.1,192.0.2.1") - assert c.forwarded_allow_ips == ["127.0.0.1", "192.0.2.1"] + assert c.forwarded_allow_ips == [ + ipaddress.ip_network("127.0.0.1/32"), + ipaddress.ip_network("192.0.2.1/32") + ] + # CIDR networks are supported + c.set("forwarded_allow_ips", "127.0.0.0/8,192.168.0.0/16") + assert c.forwarded_allow_ips == [ + ipaddress.ip_network("127.0.0.0/8"), + ipaddress.ip_network("192.168.0.0/16") + ] + # Wildcard is preserved as string + c.set("forwarded_allow_ips", "*") + assert c.forwarded_allow_ips == ["*"] c.set("forwarded_allow_ips", "") assert c.forwarded_allow_ips == [] c.set("forwarded_allow_ips", None) diff --git a/tests/test_gthread.py b/tests/test_gthread.py index b8dbea1499..b8839fa13e 100644 --- a/tests/test_gthread.py +++ b/tests/test_gthread.py @@ -5,19 +5,17 @@ """Tests for the gthread worker.""" import errno +import fcntl import os -import queue import selectors import threading import time from collections import deque from concurrent import futures -from functools import partial from unittest import mock import pytest -from gunicorn import http from gunicorn.config import Config from gunicorn.workers import gthread @@ -85,7 +83,7 @@ def test_tconn_init_sets_blocking_false(self): sock = FakeSocket() sock.setblocking(True) - conn = gthread.TConn(cfg, sock, ('127.0.0.1', 12345), ('127.0.0.1', 8000)) + gthread.TConn(cfg, sock, ('127.0.0.1', 12345), ('127.0.0.1', 8000)) # TConn sets socket to non-blocking in __init__ assert sock.blocking is False @@ -147,7 +145,7 @@ def test_queue_defer_and_run(self): q.init() results = [] - q.defer(lambda x: results.append(x), 42) + q.defer(results.append, 42) # Simulate the selector reading from the pipe q.run_callbacks(None) @@ -162,7 +160,7 @@ def test_queue_multiple_callbacks(self): results = [] for i in range(5): - q.defer(lambda x: results.append(x), i) + q.defer(results.append, i) q.run_callbacks(None) @@ -220,9 +218,6 @@ def callback(): def test_queue_nonblocking_pipe(self): """Test that pipe is non-blocking (BSD compatibility).""" - import os - import fcntl - q = gthread.PollableMethodQueue() q.init() @@ -889,18 +884,22 @@ def test_worker_notifies_in_run_loop(self): # Track notify calls notify_calls = [] original_notify = worker.notify + def tracking_notify(): notify_calls.append(time.monotonic()) original_notify() + worker.notify = tracking_notify # Mock poller.select to exit after first iteration call_count = [0] + def mock_select(timeout): call_count[0] += 1 if call_count[0] > 1: worker.alive = False return [] + worker.poller.select.side_effect = mock_select # Mock is_parent_alive to return True @@ -1010,6 +1009,7 @@ def test_graceful_shutdown_drains_connections(self): # Track iterations iterations = [0] + def mock_select(timeout): iterations[0] += 1 if iterations[0] == 1: @@ -1022,6 +1022,7 @@ def mock_select(timeout): # Connection finishes worker.nr_conns = 0 return [] + worker.poller.select.side_effect = mock_select worker.is_parent_alive = mock.Mock(return_value=True) @@ -1096,9 +1097,11 @@ def test_worker_exits_on_parent_death(self): worker.ppid = 99999999 # Invalid ppid iterations = [0] + def mock_select(timeout): iterations[0] += 1 return [] + worker.poller.select.side_effect = mock_select worker.run() @@ -1282,3 +1285,232 @@ def test_connections_tracked_during_signal(self): assert worker.alive is False # But shutting down worker.method_queue.close() + + +class TestKeepaliveBlockingMode: + """Tests for socket blocking mode on keepalive connections (issue #3448).""" + + def create_worker(self): + """Create a worker for testing.""" + cfg = Config() + cfg.set('workers', 1) + cfg.set('threads', 4) + cfg.set('worker_connections', 1000) + cfg.set('keepalive', 2) + + worker = gthread.ThreadWorker( + age=1, + ppid=os.getpid(), + sockets=[], + app=mock.Mock(), + timeout=30, + cfg=cfg, + log=mock.Mock(), + ) + return worker + + def test_handle_sets_blocking_on_keepalive_connection(self): + """Test that handle() sets socket to blocking mode on keepalive connections. + + On keepalive connections, the socket is in non-blocking mode (set by + finish_request() for the selector). handle() must set it back to blocking + before reading request/body to avoid SSLWantReadError on SSL connections. + """ + worker = self.create_worker() + worker.wsgi = mock.Mock(return_value=[b'response']) + + # Create a connection that simulates a keepalive reuse + cfg = Config() + sock = FakeSocket() + conn = gthread.TConn(cfg, sock, ('127.0.0.1', 12345), ('127.0.0.1', 8000)) + + # Simulate the state after finish_request() for keepalive: + # - socket is non-blocking (for selector registration) + # - connection is already initialized + conn.init() # First request initialized the connection + sock.setblocking(False) # finish_request() set non-blocking for selector + assert sock.blocking is False + assert conn.initialized is True + + # Verify that handle() sets the socket to blocking mode + # Mock the parser to avoid actually parsing + mock_parser = mock.Mock() + mock_parser.__next__ = mock.Mock(return_value=None) # No request + conn.parser = mock_parser + + worker.handle(conn) + + # Socket should be set to blocking mode by handle() + assert sock.blocking is True + + def test_handle_sets_blocking_before_body_read(self): + """Test that socket is blocking before WSGI app reads request body. + + This is the core fix for issue #3448: Flask's request.get_json() + reads the body, which triggers socket.recv(). If the socket is + non-blocking, this raises SSLWantReadError on SSL connections. + """ + worker = self.create_worker() + + cfg = Config() + sock = FakeSocket() + conn = gthread.TConn(cfg, sock, ('127.0.0.1', 12345), ('127.0.0.1', 8000)) + + # Simulate keepalive state + conn.init() + sock.setblocking(False) + + # Track when blocking is set vs when body would be read + blocking_state_at_body_read = [None] + + def mock_wsgi(environ, start_response): + # This simulates Flask's request.get_json() reading the body + # The socket must be blocking at this point + blocking_state_at_body_read[0] = sock.blocking + start_response('200 OK', []) + return [b'response'] + + worker.wsgi = mock_wsgi + + # Mock parser to return a request + mock_request = mock.Mock() + mock_request.headers = [] + mock_request.unreader = mock.Mock() + mock_request.body = mock.Mock() + mock_request.body.read.return_value = b'' + + mock_parser = mock.Mock() + mock_parser.__next__ = mock.Mock(return_value=mock_request) + mock_parser.finish_body = mock.Mock() + conn.parser = mock_parser + + # Mock handle_request to invoke wsgi + _ = worker.handle_request # save reference before overwriting + + def mock_handle_request(req, conn): + # Simplified version that just calls wsgi + worker.wsgi({}, lambda s, h: None) + return True + + worker.handle_request = mock_handle_request + + worker.handle(conn) + + # Socket must be blocking when WSGI app reads body + assert blocking_state_at_body_read[0] is True + + +class TestFinishBodySSL: + """Tests for SSL error handling in finish_body().""" + + def test_finish_body_handles_ssl_want_read_error(self): + """Test that finish_body() handles SSLWantReadError gracefully. + + When discarding unread body data on SSL connections, the socket + may raise SSLWantReadError if there's no application data available. + This should be treated as "no more data" rather than an error. + """ + import ssl + from gunicorn.http.parser import RequestParser + + # Create a mock SSL socket that raises SSLWantReadError on recv + class MockSSLSocket: + def __init__(self): + self._fileno = 123 + + def fileno(self): + return self._fileno + + def recv(self, size): + raise ssl.SSLWantReadError("The operation did not complete") + + def setblocking(self, blocking): + pass + + cfg = Config() + sock = MockSSLSocket() + parser = RequestParser(cfg, sock, ('127.0.0.1', 12345)) + + # Create a mock message with a body that will trigger socket read + mock_body = mock.Mock() + mock_body.read.side_effect = ssl.SSLWantReadError("The operation did not complete") + + mock_mesg = mock.Mock() + mock_mesg.body = mock_body + parser.mesg = mock_mesg + + # finish_body() should handle SSLWantReadError without raising + parser.finish_body() # Should not raise + + # Verify body.read was called + mock_body.read.assert_called_once_with(1024) + + def test_finish_body_reads_all_data_before_ssl_error(self): + """Test that finish_body() reads all available data before SSLWantReadError.""" + import ssl + from gunicorn.http.parser import RequestParser + + cfg = Config() + + # Create a mock socket + class MockSocket: + def recv(self, size): + return b'' + + def setblocking(self, blocking): + pass + + sock = MockSocket() + parser = RequestParser(cfg, sock, ('127.0.0.1', 12345)) + + # Create a mock message body that returns data then raises SSLWantReadError + call_count = [0] + + def mock_read(size): + call_count[0] += 1 + if call_count[0] <= 2: + return b'x' * size # Return data first two times + raise ssl.SSLWantReadError("The operation did not complete") + + mock_body = mock.Mock() + mock_body.read.side_effect = mock_read + + mock_mesg = mock.Mock() + mock_mesg.body = mock_body + parser.mesg = mock_mesg + + # finish_body() should read all data and handle SSLWantReadError + parser.finish_body() # Should not raise + + # Verify body.read was called multiple times (2 data reads + 1 error) + assert call_count[0] == 3 + + def test_finish_body_normal_operation(self): + """Test that finish_body() works normally when no SSL error occurs.""" + from gunicorn.http.parser import RequestParser + + cfg = Config() + + class MockSocket: + def recv(self, size): + return b'' + + def setblocking(self, blocking): + pass + + sock = MockSocket() + parser = RequestParser(cfg, sock, ('127.0.0.1', 12345)) + + # Create a mock message body that returns empty (end of data) + mock_body = mock.Mock() + mock_body.read.return_value = b'' + + mock_mesg = mock.Mock() + mock_mesg.body = mock_body + parser.mesg = mock_mesg + + # finish_body() should work normally + parser.finish_body() + + # Verify body.read was called once and returned empty + mock_body.read.assert_called_once_with(1024) diff --git a/tests/test_http.py b/tests/test_http.py index 3aa4808f91..94a3a9d395 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -137,6 +137,14 @@ def test_unreader_unread(): assert b'hi there' in unreader.read() +def test_unreader_unread_should_place_data_at_the_beginning_of_the_buffer(): + unreader = IterUnreader([b"abc", b"def"]) + ab = unreader.read(2) + unreader.unread(ab) + + assert unreader.read(None) == b"abc" + + def test_unreader_read_zero_size(): unreader = Unreader() unreader.chunk = mock.MagicMock(side_effect=[b'qwerty', b'asdfgh']) diff --git a/tests/treq.py b/tests/treq.py index fbe54700e5..e341780cd0 100644 --- a/tests/treq.py +++ b/tests/treq.py @@ -39,6 +39,27 @@ def load_py(fname): return vars(mod) +def decode_hex_escapes(data): + """Decode hex escape sequences like \\xAB in test data.""" + import re + result = bytearray() + i = 0 + while i < len(data): + # Check for \xHH hex escape + if i + 3 < len(data) and data[i:i+2] == b'\\x': + hex_chars = data[i+2:i+4] + try: + byte_val = int(hex_chars, 16) + result.append(byte_val) + i += 4 + continue + except ValueError: + pass + result.append(data[i]) + i += 1 + return bytes(result) + + class request: def __init__(self, fname, expect): self.fname = fname @@ -52,8 +73,10 @@ def __init__(self, fname, expect): self.data = handle.read() self.data = self.data.replace(b"\n", b"").replace(b"\\r\\n", b"\r\n") self.data = self.data.replace(b"\\0", b"\000").replace(b"\\n", b"\n").replace(b"\\t", b"\t") + # Handle hex escape sequences for binary data (e.g., \x0D for PROXY v2) + self.data = decode_hex_escapes(self.data) if b"\\" in self.data: - raise AssertionError("Unexpected backslash in test data - only handling HTAB, NUL and CRLF") + raise AssertionError("Unexpected backslash in test data - only handling HTAB, NUL, CRLF, and hex escapes") # Functions for sending data to the parser. # These functions mock out reading from a diff --git a/tox.ini b/tox.ini index 359cb90b5f..b328e7e2d3 100644 --- a/tox.ini +++ b/tox.ini @@ -33,6 +33,7 @@ commands = gunicorn \ tests/test_arbiter.py \ tests/test_config.py \ + tests/test_gthread.py \ tests/test_http.py \ tests/test_invalid_requests.py \ tests/test_logger.py \ @@ -48,16 +49,11 @@ deps = [testenv:docs-lint] no_package = true -allowlist_externals = - rst-lint - bash - grep deps = restructuredtext_lint pygments commands = rst-lint README.rst docs/README.rst - bash -c "(set -o pipefail; rst-lint --encoding utf-8 docs/source/*.rst | grep -v 'Unknown interpreted text role\|Unknown directive type'); test $? == 1" [testenv:pycodestyle] no_package = true