mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-02 18:51:31 +08:00
Add uWSGI protocol support to ASGI worker (#3467)
Add uWSGI protocol support to ASGI worker - Implements AsyncUWSGIRequest class extending sync UWSGIRequest to reuse parsing logic with async I/O - ASGI protocol handler selects between HTTP and uWSGI based on --protocol config option - Allows gunicorn's ASGI worker to receive requests from nginx using uwsgi_pass directive - Includes unit tests and Docker integration tests
This commit is contained in:
parent
be6f3b97ab
commit
8663740907
@ -33,6 +33,7 @@ The ASGI worker provides:
|
|||||||
- **Lifespan protocol** for startup/shutdown hooks
|
- **Lifespan protocol** for startup/shutdown hooks
|
||||||
- **Optional uvloop** for improved performance
|
- **Optional uvloop** for improved performance
|
||||||
- **SSL/TLS** support
|
- **SSL/TLS** support
|
||||||
|
- **uWSGI protocol** for nginx `uwsgi_pass` integration
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@ -151,7 +152,7 @@ app = Starlette(routes=[
|
|||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
|
|
||||||
### With Nginx
|
### With Nginx (HTTP Proxy)
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
upstream gunicorn {
|
upstream gunicorn {
|
||||||
@ -181,6 +182,36 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### With Nginx (uWSGI Protocol)
|
||||||
|
|
||||||
|
For better performance, you can use nginx's native uWSGI protocol support:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunicorn myapp:app --worker-class asgi --protocol uwsgi --bind 127.0.0.1:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
upstream gunicorn {
|
||||||
|
server 127.0.0.1:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
uwsgi_pass gunicorn;
|
||||||
|
include uwsgi_params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
WebSocket connections are not supported when using the uWSGI protocol.
|
||||||
|
Use HTTP proxy for WebSocket endpoints.
|
||||||
|
|
||||||
|
See [uWSGI Protocol](uwsgi.md) for more details on uWSGI protocol configuration.
|
||||||
|
|
||||||
### Recommended Settings
|
### Recommended Settings
|
||||||
|
|
||||||
For production ASGI deployments:
|
For production ASGI deployments:
|
||||||
|
|||||||
@ -4,6 +4,8 @@ Gunicorn supports the uWSGI binary protocol, allowing it to receive requests fro
|
|||||||
nginx using the `uwsgi_pass` directive. This provides efficient communication
|
nginx using the `uwsgi_pass` directive. This provides efficient communication
|
||||||
between nginx and Gunicorn without HTTP overhead.
|
between nginx and Gunicorn without HTTP overhead.
|
||||||
|
|
||||||
|
Both **WSGI** and **ASGI** workers support the uWSGI protocol.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
This is the **uWSGI binary protocol**, not the uWSGI server. Gunicorn
|
This is the **uWSGI binary protocol**, not the uWSGI server. Gunicorn
|
||||||
implements the protocol to receive requests from nginx, similar to how
|
implements the protocol to receive requests from nginx, similar to how
|
||||||
@ -14,7 +16,11 @@ between nginx and Gunicorn without HTTP overhead.
|
|||||||
Enable uWSGI protocol support:
|
Enable uWSGI protocol support:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# WSGI application
|
||||||
gunicorn myapp:app --protocol uwsgi --bind 127.0.0.1:8000
|
gunicorn myapp:app --protocol uwsgi --bind 127.0.0.1:8000
|
||||||
|
|
||||||
|
# ASGI application
|
||||||
|
gunicorn myapp:app --worker-class asgi --protocol uwsgi --bind 127.0.0.1:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
Configure nginx to forward requests:
|
Configure nginx to forward requests:
|
||||||
|
|||||||
@ -14,7 +14,9 @@ from datetime import datetime
|
|||||||
|
|
||||||
from gunicorn.asgi.unreader import AsyncUnreader
|
from gunicorn.asgi.unreader import AsyncUnreader
|
||||||
from gunicorn.asgi.message import AsyncRequest
|
from gunicorn.asgi.message import AsyncRequest
|
||||||
|
from gunicorn.asgi.uwsgi import AsyncUWSGIRequest
|
||||||
from gunicorn.http.errors import NoMoreData
|
from gunicorn.http.errors import NoMoreData
|
||||||
|
from gunicorn.uwsgi.errors import UWSGIParseException
|
||||||
|
|
||||||
|
|
||||||
class ASGIResponseInfo:
|
class ASGIResponseInfo:
|
||||||
@ -92,19 +94,31 @@ class ASGIProtocol(asyncio.Protocol):
|
|||||||
self.req_count += 1
|
self.req_count += 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse HTTP request
|
# Parse request based on protocol
|
||||||
request = await AsyncRequest.parse(
|
protocol = getattr(self.cfg, 'protocol', 'http')
|
||||||
self.cfg,
|
if protocol == 'uwsgi':
|
||||||
unreader,
|
request = await AsyncUWSGIRequest.parse(
|
||||||
peername,
|
self.cfg,
|
||||||
self.req_count
|
unreader,
|
||||||
)
|
peername,
|
||||||
|
self.req_count
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
request = await AsyncRequest.parse(
|
||||||
|
self.cfg,
|
||||||
|
unreader,
|
||||||
|
peername,
|
||||||
|
self.req_count
|
||||||
|
)
|
||||||
except StopIteration:
|
except StopIteration:
|
||||||
# No more data, close connection
|
# No more data, close connection
|
||||||
break
|
break
|
||||||
except NoMoreData:
|
except NoMoreData:
|
||||||
# Client disconnected
|
# Client disconnected
|
||||||
break
|
break
|
||||||
|
except UWSGIParseException as e:
|
||||||
|
self.log.debug("uWSGI parse error: %s", e)
|
||||||
|
break
|
||||||
|
|
||||||
# Check for WebSocket upgrade
|
# Check for WebSocket upgrade
|
||||||
if self._is_websocket_upgrade(request):
|
if self._is_websocket_upgrade(request):
|
||||||
|
|||||||
172
gunicorn/asgi/uwsgi.py
Normal file
172
gunicorn/asgi/uwsgi.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
#
|
||||||
|
# This file is part of gunicorn released under the MIT license.
|
||||||
|
# See the NOTICE for more information.
|
||||||
|
|
||||||
|
"""Async uWSGI protocol parser for ASGI workers.
|
||||||
|
|
||||||
|
Reuses the parsing logic from gunicorn/uwsgi/message.py, only async I/O differs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from gunicorn.uwsgi.message import UWSGIRequest
|
||||||
|
from gunicorn.uwsgi.errors import (
|
||||||
|
InvalidUWSGIHeader,
|
||||||
|
UnsupportedModifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncUWSGIRequest(UWSGIRequest):
|
||||||
|
"""Async version of UWSGIRequest.
|
||||||
|
|
||||||
|
Reuses all parsing logic from the sync version, only async I/O differs.
|
||||||
|
The following methods are reused from the parent class:
|
||||||
|
- _parse_vars() - pure parsing, no I/O
|
||||||
|
- _extract_request_info() - pure transformation
|
||||||
|
- _check_allowed_ip() - no I/O
|
||||||
|
- should_close() - simple logic
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=super-init-not-called
|
||||||
|
def __init__(self, cfg, unreader, peer_addr, req_number=1):
|
||||||
|
# Don't call super().__init__ - it does sync parsing
|
||||||
|
# Just initialize attributes
|
||||||
|
self.cfg = cfg
|
||||||
|
self.unreader = unreader
|
||||||
|
self.peer_addr = peer_addr
|
||||||
|
self.remote_addr = peer_addr
|
||||||
|
self.req_number = req_number
|
||||||
|
|
||||||
|
# Initialize all attributes (same as sync version)
|
||||||
|
self.method = None
|
||||||
|
self.uri = None
|
||||||
|
self.path = None
|
||||||
|
self.query = None
|
||||||
|
self.fragment = ""
|
||||||
|
self.version = (1, 1)
|
||||||
|
self.headers = []
|
||||||
|
self.trailers = []
|
||||||
|
self.body = None
|
||||||
|
self.scheme = "https" if cfg.is_ssl else "http"
|
||||||
|
self.must_close = False
|
||||||
|
self.uwsgi_vars = {}
|
||||||
|
self.modifier1 = 0
|
||||||
|
self.modifier2 = 0
|
||||||
|
self.proxy_protocol_info = None
|
||||||
|
|
||||||
|
# Body state
|
||||||
|
self.content_length = 0
|
||||||
|
self.chunked = False
|
||||||
|
self._body_remaining = 0
|
||||||
|
|
||||||
|
# Async factory method - intentionally differs from sync parent:
|
||||||
|
# - async instead of sync (invalid-overridden-method)
|
||||||
|
# - different signature for async I/O (arguments-differ)
|
||||||
|
# pylint: disable=arguments-differ,invalid-overridden-method
|
||||||
|
@classmethod
|
||||||
|
async def parse(cls, cfg, unreader, peer_addr, req_number=1):
|
||||||
|
"""Parse a uWSGI request asynchronously.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cfg: gunicorn config object
|
||||||
|
unreader: AsyncUnreader instance
|
||||||
|
peer_addr: client address tuple
|
||||||
|
req_number: request number on this connection (for keepalive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AsyncUWSGIRequest: Parsed request object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidUWSGIHeader: If the uWSGI header is malformed
|
||||||
|
UnsupportedModifier: If modifier1 is not 0
|
||||||
|
ForbiddenUWSGIRequest: If source IP is not allowed
|
||||||
|
"""
|
||||||
|
req = cls(cfg, unreader, peer_addr, req_number)
|
||||||
|
req._check_allowed_ip() # Reuse from parent
|
||||||
|
await req._async_parse()
|
||||||
|
return req
|
||||||
|
|
||||||
|
async def _async_parse(self):
|
||||||
|
"""Async version of parse() - reads data then uses sync parsing."""
|
||||||
|
# Read 4-byte header
|
||||||
|
header = await self._async_read_exact(4)
|
||||||
|
if len(header) < 4:
|
||||||
|
raise InvalidUWSGIHeader("incomplete header")
|
||||||
|
|
||||||
|
self.modifier1 = header[0]
|
||||||
|
datasize = int.from_bytes(header[1:3], 'little')
|
||||||
|
self.modifier2 = header[3]
|
||||||
|
|
||||||
|
if self.modifier1 != 0:
|
||||||
|
raise UnsupportedModifier(self.modifier1)
|
||||||
|
|
||||||
|
# Read vars block
|
||||||
|
if datasize > 0:
|
||||||
|
vars_data = await self._async_read_exact(datasize)
|
||||||
|
if len(vars_data) < datasize:
|
||||||
|
raise InvalidUWSGIHeader("incomplete vars block")
|
||||||
|
self._parse_vars(vars_data) # Reuse sync method
|
||||||
|
|
||||||
|
self._extract_request_info() # Reuse sync method
|
||||||
|
self._set_body_reader()
|
||||||
|
|
||||||
|
async def _async_read_exact(self, size):
|
||||||
|
"""Read exactly size bytes asynchronously."""
|
||||||
|
buf = bytearray()
|
||||||
|
while len(buf) < size:
|
||||||
|
chunk = await self.unreader.read(size - len(buf))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf.extend(chunk)
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
def _set_body_reader(self):
|
||||||
|
"""Set up body state for async reading."""
|
||||||
|
content_length = 0
|
||||||
|
if 'CONTENT_LENGTH' in self.uwsgi_vars:
|
||||||
|
try:
|
||||||
|
content_length = max(int(self.uwsgi_vars['CONTENT_LENGTH']), 0)
|
||||||
|
except ValueError:
|
||||||
|
content_length = 0
|
||||||
|
self.content_length = content_length
|
||||||
|
self._body_remaining = content_length
|
||||||
|
|
||||||
|
async def read_body(self, size=8192):
|
||||||
|
"""Read body chunk asynchronously.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size: Maximum bytes to read
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bytes: Body data, empty bytes when body is exhausted
|
||||||
|
"""
|
||||||
|
if self._body_remaining <= 0:
|
||||||
|
return b""
|
||||||
|
to_read = min(size, self._body_remaining)
|
||||||
|
data = await self.unreader.read(to_read)
|
||||||
|
if data:
|
||||||
|
self._body_remaining -= len(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def drain_body(self):
|
||||||
|
"""Drain unread body data.
|
||||||
|
|
||||||
|
Should be called before reusing connection for keepalive.
|
||||||
|
"""
|
||||||
|
while self._body_remaining > 0:
|
||||||
|
data = await self.read_body(8192)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
|
||||||
|
def get_header(self, name):
|
||||||
|
"""Get header by name (case-insensitive).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Header name to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Header value if found, None otherwise
|
||||||
|
"""
|
||||||
|
name = name.upper()
|
||||||
|
for h, v in self.headers:
|
||||||
|
if h == name:
|
||||||
|
return v
|
||||||
|
return None
|
||||||
18
tests/docker/test_asgi_uwsgi/Dockerfile
Normal file
18
tests/docker/test_asgi_uwsgi/Dockerfile
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy gunicorn source
|
||||||
|
COPY . /build/
|
||||||
|
|
||||||
|
# Install gunicorn from source
|
||||||
|
RUN pip install --no-cache-dir -e .
|
||||||
|
|
||||||
|
# Copy test app
|
||||||
|
WORKDIR /app
|
||||||
|
COPY tests/docker/test_asgi_uwsgi/app.py /app/
|
||||||
|
|
||||||
|
# Expose uWSGI port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["gunicorn", "--worker-class", "asgi", "--protocol", "uwsgi", "--bind", "0.0.0.0:8000", "app:app"]
|
||||||
45
tests/docker/test_asgi_uwsgi/app.py
Normal file
45
tests/docker/test_asgi_uwsgi/app.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"""Simple ASGI test application for uWSGI protocol testing."""
|
||||||
|
|
||||||
|
|
||||||
|
async def app(scope, receive, send):
|
||||||
|
"""Simple ASGI application that echoes request info."""
|
||||||
|
if scope["type"] == "lifespan":
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "lifespan.startup":
|
||||||
|
await send({"type": "lifespan.startup.complete"})
|
||||||
|
elif message["type"] == "lifespan.shutdown":
|
||||||
|
await send({"type": "lifespan.shutdown.complete"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if scope["type"] != "http":
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read body
|
||||||
|
body = b""
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
body += message.get("body", b"")
|
||||||
|
if not message.get("more_body", False):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
method = scope["method"]
|
||||||
|
path = scope["path"]
|
||||||
|
query = scope.get("query_string", b"").decode("utf-8")
|
||||||
|
|
||||||
|
response_body = f"Method: {method}\nPath: {path}\nQuery: {query}\nBody: {body.decode('utf-8')}\n"
|
||||||
|
response_bytes = response_body.encode("utf-8")
|
||||||
|
|
||||||
|
await send({
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": 200,
|
||||||
|
"headers": [
|
||||||
|
[b"content-type", b"text/plain"],
|
||||||
|
[b"content-length", str(len(response_bytes)).encode()],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await send({
|
||||||
|
"type": "http.response.body",
|
||||||
|
"body": response_bytes,
|
||||||
|
})
|
||||||
24
tests/docker/test_asgi_uwsgi/docker-compose.yml
Normal file
24
tests/docker/test_asgi_uwsgi/docker-compose.yml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
gunicorn:
|
||||||
|
build:
|
||||||
|
context: ../../..
|
||||||
|
dockerfile: tests/docker/test_asgi_uwsgi/Dockerfile
|
||||||
|
command: >
|
||||||
|
gunicorn
|
||||||
|
--worker-class asgi
|
||||||
|
--protocol uwsgi
|
||||||
|
--uwsgi-allow-from '*'
|
||||||
|
--bind 0.0.0.0:8000
|
||||||
|
--workers 1
|
||||||
|
--log-level debug
|
||||||
|
app:app
|
||||||
|
working_dir: /app
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- gunicorn
|
||||||
14
tests/docker/test_asgi_uwsgi/nginx.conf
Normal file
14
tests/docker/test_asgi_uwsgi/nginx.conf
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
uwsgi_pass gunicorn:8000;
|
||||||
|
include uwsgi_params;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
return 200 "OK";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
tests/docker/test_asgi_uwsgi/test_uwsgi.sh
Executable file
86
tests/docker/test_asgi_uwsgi/test_uwsgi.sh
Executable file
@ -0,0 +1,86 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Integration test for ASGI uWSGI protocol support
|
||||||
|
#
|
||||||
|
# This script tests that gunicorn's ASGI worker correctly handles
|
||||||
|
# the uWSGI protocol when nginx forwards requests using uwsgi_pass.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Use IPv4 explicitly to avoid Docker IPv6 issues
|
||||||
|
BASE_URL="http://127.0.0.1:8080"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo "Cleaning up..."
|
||||||
|
docker compose down -v 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "=== Building and starting containers ==="
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
echo "=== Waiting for services to be ready ==="
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
echo "=== Running tests ==="
|
||||||
|
|
||||||
|
# Test 1: Simple GET request
|
||||||
|
echo "Test 1: Simple GET request"
|
||||||
|
RESPONSE=$(curl -s "$BASE_URL/")
|
||||||
|
if echo "$RESPONSE" | grep -q "Method: GET"; then
|
||||||
|
echo " PASS: GET request works"
|
||||||
|
else
|
||||||
|
echo " FAIL: GET request failed"
|
||||||
|
echo " Response: $RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 2: GET with query string
|
||||||
|
echo "Test 2: GET with query string"
|
||||||
|
RESPONSE=$(curl -s "$BASE_URL/search?q=test&page=1")
|
||||||
|
if echo "$RESPONSE" | grep -q "Query: q=test&page=1"; then
|
||||||
|
echo " PASS: Query string works"
|
||||||
|
else
|
||||||
|
echo " FAIL: Query string failed"
|
||||||
|
echo " Response: $RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 3: POST with body
|
||||||
|
echo "Test 3: POST with body"
|
||||||
|
RESPONSE=$(curl -s -X POST -d "hello=world" "$BASE_URL/submit")
|
||||||
|
if echo "$RESPONSE" | grep -q "Method: POST" && echo "$RESPONSE" | grep -q "Body: hello=world"; then
|
||||||
|
echo " PASS: POST with body works"
|
||||||
|
else
|
||||||
|
echo " FAIL: POST with body failed"
|
||||||
|
echo " Response: $RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 4: Path handling
|
||||||
|
echo "Test 4: Path handling"
|
||||||
|
RESPONSE=$(curl -s "$BASE_URL/api/v1/users")
|
||||||
|
if echo "$RESPONSE" | grep -q "Path: /api/v1/users"; then
|
||||||
|
echo " PASS: Path handling works"
|
||||||
|
else
|
||||||
|
echo " FAIL: Path handling failed"
|
||||||
|
echo " Response: $RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 5: Multiple requests (keepalive)
|
||||||
|
echo "Test 5: Multiple requests (keepalive)"
|
||||||
|
for i in 1 2 3; do
|
||||||
|
RESPONSE=$(curl -s "$BASE_URL/request/$i")
|
||||||
|
if ! echo "$RESPONSE" | grep -q "Path: /request/$i"; then
|
||||||
|
echo " FAIL: Request $i failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo " PASS: Multiple requests work"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== All tests passed! ==="
|
||||||
472
tests/test_asgi_uwsgi.py
Normal file
472
tests/test_asgi_uwsgi.py
Normal file
@ -0,0 +1,472 @@
|
|||||||
|
#
|
||||||
|
# This file is part of gunicorn released under the MIT license.
|
||||||
|
# See the NOTICE for more information.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Tests for ASGI uWSGI protocol parser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gunicorn.asgi.unreader import AsyncUnreader
|
||||||
|
from gunicorn.asgi.uwsgi import AsyncUWSGIRequest
|
||||||
|
from gunicorn.uwsgi.errors import (
|
||||||
|
InvalidUWSGIHeader,
|
||||||
|
UnsupportedModifier,
|
||||||
|
ForbiddenUWSGIRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MockStreamReader:
|
||||||
|
"""Mock asyncio.StreamReader for testing."""
|
||||||
|
|
||||||
|
def __init__(self, data):
|
||||||
|
self.data = data
|
||||||
|
self.pos = 0
|
||||||
|
|
||||||
|
async def read(self, size=-1):
|
||||||
|
if self.pos >= len(self.data):
|
||||||
|
return b""
|
||||||
|
if size < 0:
|
||||||
|
result = self.data[self.pos:]
|
||||||
|
self.pos = len(self.data)
|
||||||
|
else:
|
||||||
|
result = self.data[self.pos:self.pos + size]
|
||||||
|
self.pos += size
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class MockConfig:
|
||||||
|
"""Mock gunicorn config for testing."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.is_ssl = False
|
||||||
|
self.uwsgi_allow_ips = ['*'] # Allow all for most tests
|
||||||
|
|
||||||
|
|
||||||
|
def build_uwsgi_packet(vars_dict, modifier1=0, modifier2=0):
|
||||||
|
"""Build a uWSGI packet from a dictionary of variables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vars_dict: Dictionary of uWSGI variables
|
||||||
|
modifier1: uWSGI modifier1 (default 0 for WSGI)
|
||||||
|
modifier2: uWSGI modifier2 (default 0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bytes: Complete uWSGI packet
|
||||||
|
"""
|
||||||
|
vars_data = b""
|
||||||
|
for key, value in vars_dict.items():
|
||||||
|
key_bytes = key.encode('latin-1')
|
||||||
|
value_bytes = value.encode('latin-1')
|
||||||
|
vars_data += len(key_bytes).to_bytes(2, 'little')
|
||||||
|
vars_data += key_bytes
|
||||||
|
vars_data += len(value_bytes).to_bytes(2, 'little')
|
||||||
|
vars_data += value_bytes
|
||||||
|
|
||||||
|
# Build header: modifier1 (1 byte) + datasize (2 bytes LE) + modifier2 (1 byte)
|
||||||
|
header = bytes([modifier1])
|
||||||
|
header += len(vars_data).to_bytes(2, 'little')
|
||||||
|
header += bytes([modifier2])
|
||||||
|
|
||||||
|
return header + vars_data
|
||||||
|
|
||||||
|
|
||||||
|
# Basic parsing tests
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_simple_get():
|
||||||
|
"""Test parsing a simple GET request."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/test',
|
||||||
|
'QUERY_STRING': '',
|
||||||
|
'HTTP_HOST': 'localhost',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
assert request.method == "GET"
|
||||||
|
assert request.path == "/test"
|
||||||
|
assert request.query == ""
|
||||||
|
assert request.uri == "/test"
|
||||||
|
assert request.version == (1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_get_with_query():
|
||||||
|
"""Test parsing GET request with query string."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/search',
|
||||||
|
'QUERY_STRING': 'q=test&page=1',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
assert request.method == "GET"
|
||||||
|
assert request.path == "/search"
|
||||||
|
assert request.query == "q=test&page=1"
|
||||||
|
assert request.uri == "/search?q=test&page=1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_post_with_content_length():
|
||||||
|
"""Test parsing POST request with content length."""
|
||||||
|
body = b"hello=world"
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'POST',
|
||||||
|
'PATH_INFO': '/submit',
|
||||||
|
'CONTENT_LENGTH': str(len(body)),
|
||||||
|
'CONTENT_TYPE': 'application/x-www-form-urlencoded',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict) + body
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
assert request.method == "POST"
|
||||||
|
assert request.path == "/submit"
|
||||||
|
assert request.content_length == len(body)
|
||||||
|
|
||||||
|
# Read body
|
||||||
|
read_body = await request.read_body(100)
|
||||||
|
assert read_body == body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_headers():
|
||||||
|
"""Test that HTTP headers are correctly extracted."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
'HTTP_HOST': 'example.com',
|
||||||
|
'HTTP_ACCEPT': 'text/html',
|
||||||
|
'HTTP_X_CUSTOM_HEADER': 'custom-value',
|
||||||
|
'CONTENT_TYPE': 'text/plain',
|
||||||
|
'CONTENT_LENGTH': '0',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
# Check headers were extracted correctly
|
||||||
|
assert request.get_header('HOST') == 'example.com'
|
||||||
|
assert request.get_header('ACCEPT') == 'text/html'
|
||||||
|
assert request.get_header('X-CUSTOM-HEADER') == 'custom-value'
|
||||||
|
assert request.get_header('CONTENT-TYPE') == 'text/plain'
|
||||||
|
assert request.get_header('CONTENT-LENGTH') == '0'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_https_scheme():
|
||||||
|
"""Test HTTPS scheme detection."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
'HTTPS': 'on',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
assert request.scheme == 'https'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_wsgi_url_scheme():
|
||||||
|
"""Test wsgi.url_scheme variable."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
'wsgi.url_scheme': 'https',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
assert request.scheme == 'https'
|
||||||
|
|
||||||
|
|
||||||
|
# Body reading tests
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_body_chunks():
|
||||||
|
"""Test reading body in chunks."""
|
||||||
|
body = b"a" * 100
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'POST',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
'CONTENT_LENGTH': str(len(body)),
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict) + body
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
# Read in chunks
|
||||||
|
chunks = []
|
||||||
|
while True:
|
||||||
|
chunk = await request.read_body(30)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
assert b"".join(chunks) == body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_drain_body():
|
||||||
|
"""Test draining unread body."""
|
||||||
|
body = b"x" * 50
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'POST',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
'CONTENT_LENGTH': str(len(body)),
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict) + body
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
# Drain without reading
|
||||||
|
await request.drain_body()
|
||||||
|
|
||||||
|
# Further reads should return empty
|
||||||
|
chunk = await request.read_body()
|
||||||
|
assert chunk == b""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_body():
|
||||||
|
"""Test request with no body."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
assert request.content_length == 0
|
||||||
|
chunk = await request.read_body()
|
||||||
|
assert chunk == b""
|
||||||
|
|
||||||
|
|
||||||
|
# Connection handling tests
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_should_close_default():
|
||||||
|
"""Test default keepalive behavior."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
# Default should be keep-alive (HTTP/1.1 behavior)
|
||||||
|
assert request.should_close() is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_should_close_connection_close():
|
||||||
|
"""Test connection close header."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
'HTTP_CONNECTION': 'close',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
assert request.should_close() is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_should_close_keepalive():
|
||||||
|
"""Test connection keep-alive header."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
'HTTP_CONNECTION': 'keep-alive',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
assert request.should_close() is False
|
||||||
|
|
||||||
|
|
||||||
|
# Error handling tests
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_incomplete_header():
|
||||||
|
"""Test incomplete header raises error."""
|
||||||
|
# Only 2 bytes instead of 4
|
||||||
|
data = b"\x00\x00"
|
||||||
|
reader = MockStreamReader(data)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
with pytest.raises(InvalidUWSGIHeader):
|
||||||
|
await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsupported_modifier():
|
||||||
|
"""Test unsupported modifier1 raises error."""
|
||||||
|
# modifier1 = 1 (not WSGI)
|
||||||
|
header = bytes([1, 0, 0, 0]) # modifier1=1, datasize=0, modifier2=0
|
||||||
|
reader = MockStreamReader(header)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
with pytest.raises(UnsupportedModifier):
|
||||||
|
await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_incomplete_vars_block():
|
||||||
|
"""Test incomplete vars block raises error."""
|
||||||
|
# Header says 100 bytes of vars, but only 10 provided
|
||||||
|
header = bytes([0]) # modifier1=0
|
||||||
|
header += (100).to_bytes(2, 'little') # datasize=100
|
||||||
|
header += bytes([0]) # modifier2=0
|
||||||
|
header += b"x" * 10 # Only 10 bytes
|
||||||
|
|
||||||
|
reader = MockStreamReader(header)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
with pytest.raises(InvalidUWSGIHeader):
|
||||||
|
await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forbidden_ip():
|
||||||
|
"""Test forbidden IP raises error."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
cfg.uwsgi_allow_ips = ['10.0.0.1'] # Only allow 10.0.0.1
|
||||||
|
|
||||||
|
with pytest.raises(ForbiddenUWSGIRequest):
|
||||||
|
await AsyncUWSGIRequest.parse(cfg, unreader, ("192.168.1.1", 8000))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_allowed_ip():
|
||||||
|
"""Test allowed IP succeeds."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
cfg.uwsgi_allow_ips = ['192.168.1.1']
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("192.168.1.1", 8000))
|
||||||
|
assert request.method == "GET"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unix_socket_allowed():
|
||||||
|
"""Test UNIX socket connections are always allowed."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
cfg.uwsgi_allow_ips = ['10.0.0.1'] # Restrictive IP list
|
||||||
|
|
||||||
|
# UNIX socket peer_addr is not a tuple
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, "/tmp/gunicorn.sock")
|
||||||
|
assert request.method == "GET"
|
||||||
|
|
||||||
|
|
||||||
|
# Empty vars block test
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_vars_block():
|
||||||
|
"""Test request with empty vars block uses defaults."""
|
||||||
|
# Header with datasize=0
|
||||||
|
header = bytes([0, 0, 0, 0])
|
||||||
|
reader = MockStreamReader(header)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
# Should use defaults
|
||||||
|
assert request.method == "GET"
|
||||||
|
assert request.path == "/"
|
||||||
|
assert request.query == ""
|
||||||
|
|
||||||
|
|
||||||
|
# SSL config test
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ssl_config_scheme():
|
||||||
|
"""Test SSL config sets https scheme."""
|
||||||
|
vars_dict = {
|
||||||
|
'REQUEST_METHOD': 'GET',
|
||||||
|
'PATH_INFO': '/',
|
||||||
|
}
|
||||||
|
packet = build_uwsgi_packet(vars_dict)
|
||||||
|
reader = MockStreamReader(packet)
|
||||||
|
unreader = AsyncUnreader(reader)
|
||||||
|
cfg = MockConfig()
|
||||||
|
cfg.is_ssl = True
|
||||||
|
|
||||||
|
request = await AsyncUWSGIRequest.parse(cfg, unreader, ("127.0.0.1", 8000))
|
||||||
|
|
||||||
|
assert request.scheme == 'https'
|
||||||
Loading…
x
Reference in New Issue
Block a user