ext_http_server 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: ext_http_server
3
+ Version: 1.0.0
4
+ Summary: An extended version of Python's SimpleHTTPServer that supports https, authentication, rate limiting, and download resuming.
5
+ Project-URL: Homepage, https://github.com/bboe/extended_http_server
6
+ Author-email: Bryce Boe <bbzbryce@gmail.com>
7
+ License-Expression: BSD-2-Clause
8
+ License-File: LICENSE.txt
9
+ Keywords: http authentication,http rate limit,http resume
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+
22
+ # ext_http_server
23
+
24
+ An extended version of Python's `http.server` that turns a simple static file
25
+ server into one supporting HTTPS, HTTP Basic authentication, server-to-client
26
+ rate limiting, and resumable downloads via HTTP `Range` requests.
27
+
28
+ ### Requirements
29
+
30
+ `ext_http_server` supports Python 3.10 through 3.14.
31
+
32
+ ### Installation
33
+
34
+ Install the `ext_http_server` command with [uv](https://docs.astral.sh/uv/):
35
+
36
+ uv tool install ext_http_server
37
+
38
+ ### Generate a certificate
39
+
40
+ Generate a self-signed certificate, writing the private key and certificate
41
+ together into `cert.pem` (the single file `--cert` expects). This uses a modern
42
+ ECDSA P-256 key, which every common TLS client supports (Ed25519 keys are
43
+ newer but are rejected by some clients, including the LibreSSL-based `curl` that
44
+ ships with macOS as of June 2026):
45
+
46
+ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -noenc -keyout cert.pem -out cert.pem -days 365 -subj "/CN=localhost"
47
+
48
+ ### Running ext_http_server
49
+
50
+ If you have files you want to serve in `/tmp/path/to/files/` run the following
51
+ to serve them up with a max outgoing throughput of 16KBps:
52
+
53
+ ext_http_server --cert cert.pem -d /tmp/path/to/files -r16 -a foo:bar
54
+
55
+ Or run it once without installing:
56
+
57
+ uvx ext_http_server --cert cert.pem -d /tmp/path/to/files -r16 -a foo:bar
58
+
59
+ By default, you will be able to access the webserver at
60
+ [https://localhost:8000](https://localhost:8000). To authenticate, use the
61
+ username `foo` and the password `bar` as indicated by the `-a foo:bar`
62
+ argument. Note that multiple `-a` arguments can be added to add more than one
63
+ user.
@@ -0,0 +1,6 @@
1
+ ext_http_server.py,sha256=3zYoYIH8plkGk59yY-IE8imIZQ_-9ds2W-H4ABMrTts,11492
2
+ ext_http_server-1.0.0.dist-info/METADATA,sha256=JCtAS_OAPwJPJrQHcYo8pJiRX73LFA2SR3XAQisR-yg,2532
3
+ ext_http_server-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
4
+ ext_http_server-1.0.0.dist-info/entry_points.txt,sha256=pwP3ctpOwcNIc1zWSXERQWG6Qke0WsLPeXAHqHWRgMY,57
5
+ ext_http_server-1.0.0.dist-info/licenses/LICENSE.txt,sha256=VtRw2c06n1S5lcOnvtPaueLhmsHzR_IpTcZOajSHFgg,1296
6
+ ext_http_server-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ext_http_server = ext_http_server:main
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012, Bryce Boe
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+ 2. Redistributions in binary form must reproduce the above copyright notice,
10
+ this list of conditions and the following disclaimer in the documentation
11
+ and/or other materials provided with the distribution.
12
+
13
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
14
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
15
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
17
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
18
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
19
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
20
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
21
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
22
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
ext_http_server.py ADDED
@@ -0,0 +1,324 @@
1
+ """A small set of improvements upon the Simple and BaseHTTPServers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import base64
7
+ import errno
8
+ import io
9
+ import os
10
+ import socketserver
11
+ import ssl
12
+ import sys
13
+ import threading
14
+ import time
15
+ from http import HTTPStatus
16
+ from http.server import BaseHTTPRequestHandler, HTTPServer, SimpleHTTPRequestHandler
17
+ from importlib.metadata import PackageNotFoundError, version
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, Any, AnyStr, ClassVar, cast
20
+ from warnings import warn
21
+
22
+ if TYPE_CHECKING:
23
+ from socket import socket
24
+
25
+ from _typeshed import SupportsRead, SupportsWrite
26
+
27
+ try:
28
+ __version__ = version("ext_http_server")
29
+ except PackageNotFoundError: # running from a source checkout without an install
30
+ __version__ = "unknown"
31
+
32
+
33
+ class AuthHandler(SimpleHTTPRequestHandler):
34
+ """A handler that supports basic HTTP authentication/authorization."""
35
+
36
+ message = "Authentication required."
37
+ realm = "Something"
38
+ users: ClassVar[set[str]] = set()
39
+
40
+ @classmethod
41
+ def add_user(cls, username: str, password: str) -> None:
42
+ """Add a set of credentials."""
43
+ token = base64.b64encode(f"{username}:{password}".encode()).decode()
44
+ cls.users.add(token)
45
+
46
+ def do_GET(self) -> None:
47
+ """Call the parent's do_GET function if the user is authorized."""
48
+ if self.handle_auth():
49
+ super().do_GET()
50
+
51
+ def do_HEAD(self) -> None:
52
+ """Call the parent's do_HEAD function if the user is authorized."""
53
+ if self.handle_auth(head=True):
54
+ super().do_HEAD()
55
+
56
+ def handle_auth(self, *, head: bool = False) -> bool:
57
+ """Output the authentication headers if the user is not valid.
58
+
59
+ Returns:
60
+ ``True`` when the request carries valid credentials, ``False`` otherwise.
61
+
62
+ """
63
+ if auth := self.headers.get("Authorization"):
64
+ try:
65
+ _, encoded = auth.split(" ", 1)
66
+ except ValueError:
67
+ encoded = None
68
+ # Verify the user
69
+ if encoded in AuthHandler.users:
70
+ return True
71
+ # Send authentication header information
72
+ self.send_response(HTTPStatus.UNAUTHORIZED)
73
+ self.send_header("WWW-Authenticate", f'Basic realm="{AuthHandler.realm}"')
74
+ self.send_header("Content-Type", "text/html")
75
+ self.send_header("Content-Length", str(len(AuthHandler.message)))
76
+ self.end_headers()
77
+ if not head:
78
+ self.wfile.write(AuthHandler.message.encode())
79
+ return False
80
+
81
+
82
+ class RangeHandler(SimpleHTTPRequestHandler):
83
+ """A handler that supports HTTP requests with the Range header.
84
+
85
+ The Range header allows for the resume download functionality.
86
+
87
+ """
88
+
89
+ is_ranged: bool
90
+ range_begin: int
91
+ range_end: int | None
92
+
93
+ def copyfile(self, source: SupportsRead[AnyStr], outputfile: SupportsWrite[AnyStr]) -> None:
94
+ """Copy only the ranged part of the file when appropriate."""
95
+ if self.is_ranged and isinstance(source, io.IOBase):
96
+ source.seek(self.range_begin)
97
+ super().copyfile(source, outputfile)
98
+
99
+ def do_GET(self) -> None:
100
+ """Set is_ranged flag if a valid Range header is sent."""
101
+ self.handle_range()
102
+ super().do_GET()
103
+
104
+ def do_HEAD(self) -> None:
105
+ """Set is_ranged flag if a valid Range header is sent."""
106
+ self.handle_range()
107
+ super().do_HEAD()
108
+
109
+ def handle_range(self) -> None:
110
+ """Parse the Range header if it exists."""
111
+ self.is_ranged = False
112
+ raw = self.headers.get("range")
113
+ if not raw or "=" not in raw:
114
+ return
115
+ range_unit, _, other = raw.partition("=")
116
+ if range_unit != "bytes" or "-" not in other:
117
+ return
118
+ if "," in other: # Handle only a single range
119
+ warn("Multiple ranges are not supported.", stacklevel=2)
120
+ return
121
+ begin, _, end = other.partition("-")
122
+ if end:
123
+ warn("Shortened ranges are not supported.", stacklevel=2)
124
+ return
125
+ if begin and not begin.isdigit():
126
+ return
127
+ self.range_begin = int(begin) if begin else 0
128
+ self.range_end = None
129
+ self.is_ranged = True
130
+
131
+ def send_header(self, keyword: str, value: str) -> None:
132
+ """Modify Content-Length and add Content-Range when ranged."""
133
+ if keyword == "Content-Length" and self.is_ranged:
134
+ length = int(value)
135
+ end = length - 1 if self.range_end is None else min(self.range_end, length - 1)
136
+ value = str(1 + end - self.range_begin)
137
+ self.send_header("Content-Range", f"bytes {self.range_begin}-{end}/{length}")
138
+ super().send_header(keyword, value)
139
+
140
+ def send_response(self, code: int, message: str | None = None) -> None:
141
+ """Send 206 status for ranged responses."""
142
+ if self.is_ranged and code == HTTPStatus.OK:
143
+ code = HTTPStatus.PARTIAL_CONTENT
144
+ super().send_response(code, message)
145
+
146
+ def setup(self) -> None:
147
+ """Set HTTP/1.1 as Range is supported only on HTTP/1.1."""
148
+ super().setup()
149
+ self.protocol_version = "HTTP/1.1"
150
+ self.is_ranged = False
151
+
152
+
153
+ class RateLimitHandler(SimpleHTTPRequestHandler):
154
+ """A handler that supports rate limiting from server to client.
155
+
156
+ This handler will not properly rate limit if a ForkingMixIn is used in the
157
+ HTTPServer object. However, it works great in combination with the ThreadingMixIn.
158
+
159
+ """
160
+
161
+ def handle(self) -> None:
162
+ """Set up rate limiting on the outgoing connection."""
163
+ # RateLimitWriter is a transparent write-proxy, not a BufferedIOBase subclass.
164
+ self.wfile = cast("io.BufferedIOBase", RateLimitWriter(self.wfile))
165
+ super().handle()
166
+
167
+
168
+ class MyHandler(AuthHandler, RangeHandler, RateLimitHandler):
169
+ """A handler that supports auth, download resuming, and throttling."""
170
+
171
+
172
+ class RateLimitWriter:
173
+ """A class that rate limits writing to associated file streams.
174
+
175
+ This method only supports threading and not forking (multiprocessing).
176
+
177
+ """
178
+
179
+ INTERVAL_LEN: ClassVar[float] = 0.125
180
+ block_sent: ClassVar[int] = 0
181
+ block_size: ClassVar[int] = 16384
182
+ block_start: ClassVar[float] = 0.0
183
+ lock: ClassVar = threading.Lock()
184
+
185
+ wrapped: io.BufferedIOBase
186
+
187
+ @classmethod
188
+ def bytes_to_write(cls, desired: int) -> int:
189
+ """Determine how many bytes to write and sleep when over the limit.
190
+
191
+ Returns:
192
+ The number of bytes the caller may write now.
193
+
194
+ """
195
+ to_send = 0
196
+ while not to_send:
197
+ with cls.lock:
198
+ now = time.time()
199
+ if not cls.block_start:
200
+ # First data of block, send it all
201
+ cls.block_start = now
202
+ to_send = min(desired, cls.block_size)
203
+ cls.block_sent = to_send
204
+ elif cls.block_sent < cls.block_size:
205
+ # Haven't sent a complete block, send remainder
206
+ to_send = min(desired, cls.block_size - cls.block_sent)
207
+ cls.block_sent += to_send
208
+ else:
209
+ # A complete block has been sent, sleep if necessary
210
+ sleep_time = cls.INTERVAL_LEN - (now - cls.block_start)
211
+ if sleep_time > 0:
212
+ time.sleep(sleep_time)
213
+ cls.block_start = 0.0
214
+ cls.block_sent = 0
215
+ return to_send
216
+
217
+ @classmethod
218
+ def set_rate_limit(cls, limit: float) -> None:
219
+ """Set the rate limit in kilobytes per second."""
220
+ cls.block_size = int(1024 * limit * cls.INTERVAL_LEN)
221
+
222
+ def __getattr__(self, attr: str) -> Any: # noqa: ANN401
223
+ """Redirect all attribute access to the wrapped output stream.
224
+
225
+ Returns:
226
+ The corresponding attribute of the wrapped stream.
227
+
228
+ """
229
+ return getattr(self.wrapped, attr)
230
+
231
+ def __init__(self, to_wrap: io.BufferedIOBase) -> None:
232
+ """Store the output stream we are wrapping."""
233
+ self.wrapped = to_wrap
234
+
235
+ def write(self, message: bytes) -> None:
236
+ """Perform a throttled write to the wrapped output stream."""
237
+ while message:
238
+ to_send = RateLimitWriter.bytes_to_write(len(message))
239
+ self.wrapped.write(message[:to_send])
240
+ message = message[to_send:]
241
+
242
+
243
+ class SecureHTTPServer(HTTPServer):
244
+ """A HTTP Server object that supports HTTPS."""
245
+
246
+ def __init__(
247
+ self,
248
+ address: tuple[str, int],
249
+ handler: type[BaseHTTPRequestHandler],
250
+ cert_file: str | os.PathLike[str],
251
+ ) -> None:
252
+ """Support TLS/SSL by wrapping the socket."""
253
+ super().__init__(address, handler)
254
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
255
+ context.load_cert_chain(cert_file)
256
+ self.socket = context.wrap_socket(self.socket, server_side=True)
257
+
258
+
259
+ class MyServer(socketserver.ThreadingMixIn, SecureHTTPServer):
260
+ """A threaded SecureHTTPServer with basic error filtering."""
261
+
262
+ def handle_error(self, request: socket | tuple[bytes, socket], client_address: Any) -> None: # noqa: ANN401
263
+ """Disable tracebacks on connection close errors."""
264
+ _, exc_value, _ = sys.exc_info()
265
+ if isinstance(exc_value, OSError) and exc_value.errno == errno.EPIPE:
266
+ print(f"{client_address} closed connection")
267
+ elif isinstance(exc_value, ssl.SSLError) and exc_value.errno == 1:
268
+ print(f"{client_address} SSL Error: bad write retry")
269
+ else:
270
+ super().handle_error(request, client_address)
271
+
272
+
273
+ def main() -> int:
274
+ """Run a secure threaded server with auth resume and rate limit support.
275
+
276
+ Returns:
277
+ The process exit status.
278
+
279
+ """
280
+ parser = argparse.ArgumentParser()
281
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
282
+ parser.add_argument("-p", "--port", default=8000, type=int)
283
+ parser.add_argument("-c", "--cert", help="The TLS/SSL certificate file")
284
+ parser.add_argument("-d", "--directory", help="The directory to serve")
285
+ parser.add_argument("-r", "--ratelimit", default=128, help="The ratelimit in KBps", type=int)
286
+ parser.add_argument("-a", "--add-auth", action="append", help="Add user:password combination")
287
+ options = parser.parse_args()
288
+
289
+ # Configure Services
290
+ if not options.add_auth:
291
+ parser.error("At least one user must be added via --add-auth")
292
+ for auth in options.add_auth:
293
+ try:
294
+ username, password = auth.split(":", 1)
295
+ except ValueError:
296
+ parser.error(f"{auth!r} is not a valid username:password")
297
+ AuthHandler.add_user(username, password)
298
+ RateLimitWriter.set_rate_limit(options.ratelimit)
299
+
300
+ # Verify cert file
301
+ if not options.cert:
302
+ parser.error("--cert must be provided")
303
+ cert_path = Path(options.cert).resolve()
304
+ if not cert_path.is_file():
305
+ parser.error("Invalid cert file")
306
+
307
+ # Change into serving directory
308
+ if options.directory:
309
+ try:
310
+ os.chdir(options.directory)
311
+ except OSError:
312
+ parser.error("Invalid --directory")
313
+
314
+ server = MyServer(("", options.port), MyHandler, cert_path)
315
+ print(f"Server listening on port {options.port}")
316
+ try:
317
+ server.serve_forever()
318
+ except KeyboardInterrupt:
319
+ print("\nGoodbye")
320
+ return 0
321
+
322
+
323
+ if __name__ == "__main__":
324
+ sys.exit(main())