Skip to content

Commit 4cf2534

Browse files
authored
Merge pull request #20 from icgood/want-read
Make parsing independent of transport
2 parents f6f6985 + d325d8d commit 4cf2534

17 files changed

Lines changed: 227 additions & 111 deletions

File tree

README.md

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,20 @@ from functools import partial
3737

3838
from proxyprotocol.base import ProxyProtocol
3939
from proxyprotocol.detect import ProxyProtocolDetect
40+
from proxyprotocol.reader import ProxyProtocolReader
4041
from proxyprotocol.socket import SocketInfo
4142

4243
async def run(host: str, port: int) -> None:
43-
pp = ProxyProtocolDetect()
44-
callback = partial(on_connection, pp)
44+
pp_detect = ProxyProtocolDetect()
45+
pp_reader = ProxyProtocolReader(pp_detect)
46+
callback = partial(on_connection, pp_reader)
4547
server = await asyncio.start_server(callback, host, port)
4648
async with server:
4749
await server.serve_forever()
4850

49-
async def on_connection(pp: ProxyProtocolDetect,
51+
async def on_connection(pp_reader: ProxyProtocolReader,
5052
reader: StreamReader, writer: StreamWriter) -> None:
51-
result = await pp.read(reader)
53+
result = await pp_reader.read(reader)
5254
info = SocketInfo(writer, result)
5355
print(info.family, info.peername)
5456
# ... continue using connection
@@ -139,10 +141,6 @@ $ mypy --strict proxyprotocol test
139141
$ flake8 proxyprotocol test
140142
```
141143

142-
A py.test run executes both unit and integration tests. The integration tests
143-
use mocked sockets to simulate the sending and receiving of commands and
144-
responses, and are kept in the `test/server/` subdirectory.
145-
146144
### Type Hinting
147145

148146
This project makes heavy use of Python's [type hinting][6] system, with the

doc/source/proxyprotocol.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@
6060
.. automodule:: proxyprotocol.sock
6161
:members:
6262

63+
``proxyprotocol.reader``
64+
------------------------
65+
66+
.. automodule:: proxyprotocol.reader
67+
:members:
68+
6369
``proxyprotocol.server``
6470
------------------------
6571

proxyprotocol/__init__.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
from socket import AddressFamily, SocketKind
66
from ssl import SSLSocket, SSLObject
77
from typing import Any, Union, Optional, Sequence
8+
from typing_extensions import Final
89

910
from .tlv import ProxyProtocolTLV
10-
from .typing import Address, StreamReaderProtocol
11+
from .typing import Address
1112

12-
__all__ = ['__version__', 'ProxyProtocolError', 'ProxyProtocolResult',
13-
'ProxyProtocol']
13+
__all__ = ['__version__', 'ProxyProtocolError', 'ProxyProtocolWantRead',
14+
'ProxyProtocolResult', 'ProxyProtocol']
1415

1516
#: The package version string.
1617
__version__: str = pkg_resources.require('proxy-protocol')[0].version
@@ -26,7 +27,30 @@ class ProxyProtocolError(ValueError):
2627
and closed.
2728
2829
"""
29-
pass
30+
31+
__slots__: Sequence[str] = []
32+
33+
34+
class ProxyProtocolWantRead(Exception):
35+
"""Thrown when the PROXY protocol header cannot be parsed because the
36+
provided data is not enough to be parsed. Additional data must be read
37+
before parsing can succeed or fail.
38+
39+
Either *want_bytes* or *want_line* must be given, but not both.
40+
41+
Args:
42+
want_bytes: Number of bytes needed before parsing may proceed.
43+
want_line: Additional data should be read until the end of a line.
44+
45+
"""
46+
47+
__slots__ = ['want_bytes', 'want_line']
48+
49+
def __init__(self, want_bytes: Optional[int] = None, *,
50+
want_line: bool = False) -> None:
51+
super().__init__('Additional data needed')
52+
self.want_bytes: Final = want_bytes
53+
self.want_line: Final = want_line
3054

3155

3256
class ProxyProtocolResult(metaclass=ABCMeta):
@@ -100,16 +124,16 @@ def is_valid(self, signature: bytes) -> bool:
100124
...
101125

102126
@abstractmethod
103-
async def read(self, reader: StreamReaderProtocol, *,
104-
signature: bytes = b'') -> ProxyProtocolResult:
105-
"""Read a PROXY protocol header from the given stream and return
127+
def parse(self, data: bytes) -> ProxyProtocolResult:
128+
"""Parse a PROXY protocol header from the given bytestring and return
106129
information about the original connection.
107130
108131
Args:
109-
reader: The input stream.
110-
signature: Any data that has already been read from the stream.
132+
data: The bytestring read for the header thus far.
111133
112134
Raises:
135+
:exc:`ProxyProtocolWantRead`: The header was incomplete and must
136+
be extended with additional bytes or lines to finish parsing.
113137
:exc:`ProxyProtocolError`: The header failed to parse due to a
114138
syntax error or unsupported format.
115139
:exc:`ValueError`: Malformed or out-of-range data was encountered

proxyprotocol/detect.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
from ssl import SSLSocket, SSLObject
44
from typing import Union, Optional
55

6-
from . import ProxyProtocolError, ProxyProtocolResult, ProxyProtocol
7-
from .result import ProxyProtocolResultUnknown
8-
from .typing import Address, StreamReaderProtocol
6+
from . import ProxyProtocolError, ProxyProtocolWantRead, \
7+
ProxyProtocolResult, ProxyProtocol
8+
from .typing import Address
99
from .v1 import ProxyProtocolV1
1010
from .v2 import ProxyProtocolV2
1111

@@ -31,15 +31,11 @@ def __init__(self, *versions: ProxyProtocol) -> None:
3131
def is_valid(self, signature: bytes) -> bool:
3232
return any(v.is_valid(signature) for v in self.versions)
3333

34-
async def read(self, reader: StreamReaderProtocol, *,
35-
signature: bytes = b'') \
36-
-> ProxyProtocolResult: # pragma: no cover
37-
try:
38-
signature += await reader.readexactly(8 - len(signature))
39-
except (EOFError, ConnectionResetError) as exc:
40-
return ProxyProtocolResultUnknown(exc)
41-
pp = self.choose_version(signature)
42-
return await pp.read(reader, signature=signature)
34+
def parse(self, data: bytes) -> ProxyProtocolResult:
35+
if len(data) < 8:
36+
raise ProxyProtocolWantRead(8 - len(data))
37+
pp = self.choose_version(data[0:8])
38+
return pp.parse(data)
4339

4440
def choose_version(self, signature: bytes) -> ProxyProtocol:
4541
"""Choose the PROXY protocol version based on the 8-byte signature.

proxyprotocol/noop.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from . import ProxyProtocol
77
from .result import ProxyProtocolResultLocal
8-
from .typing import Address, StreamReaderProtocol
8+
from .typing import Address
99

1010
__all__ = ['ProxyProtocolNoop']
1111

@@ -24,9 +24,7 @@ def is_valid(self, signature: bytes) -> NoReturn:
2424
# This implementation may not be detected
2525
raise NotImplementedError()
2626

27-
async def read(self, reader: StreamReaderProtocol, *,
28-
signature: bytes = b'') \
29-
-> ProxyProtocolResultLocal: # pragma: no cover
27+
def parse(self, data: bytes) -> ProxyProtocolResultLocal:
3028
return ProxyProtocolResultLocal()
3129

3230
def build(self, source: Address, dest: Address, *, family: AddressFamily,

proxyprotocol/reader.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
2+
from typing_extensions import Final
3+
4+
from . import ProxyProtocolWantRead, ProxyProtocol, ProxyProtocolResult
5+
from .result import ProxyProtocolResultUnknown
6+
from .typing import StreamReaderProtocol
7+
8+
__all__ = ['ProxyProtocolReader']
9+
10+
11+
class ProxyProtocolReader:
12+
"""Read a PROXY protocol header from a stream.
13+
14+
Args:
15+
pp: The PROXY protocol implementation.
16+
17+
"""
18+
19+
def __init__(self, pp: ProxyProtocol) -> None:
20+
super().__init__()
21+
self.pp: Final = pp
22+
23+
def _parse(self, data: bytearray) -> ProxyProtocolResult:
24+
view = memoryview(data)
25+
try:
26+
return self.pp.parse(view)
27+
finally:
28+
view.release()
29+
30+
async def _handle_want(self, reader: StreamReaderProtocol,
31+
want_read: ProxyProtocolWantRead) -> bytes:
32+
if want_read.want_bytes is not None:
33+
return await reader.readexactly(want_read.want_bytes)
34+
elif want_read.want_line:
35+
return await reader.readline()
36+
raise ValueError() from want_read
37+
38+
async def read(self, reader: StreamReaderProtocol) -> ProxyProtocolResult:
39+
"""Read a complete PROXY protocol header from the input stream and
40+
return the result.
41+
42+
Args:
43+
reader: The input stream.
44+
45+
"""
46+
data = bytearray()
47+
while True:
48+
try:
49+
return self._parse(data)
50+
except ProxyProtocolWantRead as want_read:
51+
try:
52+
data += await self._handle_want(reader, want_read)
53+
except (EOFError, ConnectionResetError) as exc:
54+
return ProxyProtocolResultUnknown(exc)

proxyprotocol/server/echo.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from functools import partial
1313

1414
from .. import ProxyProtocol
15+
from ..reader import ProxyProtocolReader
1516
from ..sock import SocketInfo
1617
from . import Address
1718

@@ -54,8 +55,9 @@ async def run(address: Address) -> int:
5455

5556
async def run_conn(pp: ProxyProtocol, reader: StreamReader,
5657
writer: StreamWriter) -> None:
58+
pp_reader = ProxyProtocolReader(pp)
5759
with closing(writer):
58-
result = await pp.read(reader)
60+
result = await pp_reader.read(reader)
5961
sock_info = SocketInfo(writer, result)
6062
_log.info('[%s] Connection received: %s',
6163
sock_info.unique_id.hex(), sock_info)

0 commit comments

Comments
 (0)