Socket binding implemented properly for IPv6 and UNIX sockets. (#1641)
* Socket binding implemented properly for IPv6 and UNIX sockets.
- app.run("::1") for IPv6
- app.run("unix:/tmp/server.sock") for UNIX sockets
- app.run("localhost") retains old functionality (randomly either IPv4 or IPv6)
Do note that IPv6 and UNIX sockets are not fully supported by other Sanic facilities.
In particular, request.server_name and request.server_port are currently unreliable.
* Fix Windows compatibility by not referring to socket.AF_UNIX unless needed.
* Compatibility fix.
* Fix test of existing unix socket.
* Cleaner unix socket removal.
* Remove unix socket on exit also with workers=1.
* More pedantic UNIX socket implementation.
* Refactor app to take unix= argument instead of unix:-prefixed host. Goin' fast @ unix-socket fixed.
* Linter
* Proxy properties cleanup. Slight changes of semantics. SERVER_NAME now overrides everything.
* Have server fill in connection info instead of request asking the socket.
- Would be a good idea to remove request.transport entirely but I didn't dare to touch it yet.
* Linter 💣🌟✊💀
* Fix typing issues. request.server_name returns empty string if host header is missing.
* Fix tests
* Tests were failing, fix connection info.
* Linter nazi says you need that empty line.
* Rename a to addr, leave client empty for unix sockets.
* Add --unix support when sanic is run as module.
* Remove remove_route, deprecated in 19.6.
* Improved unix socket binding.
* More robust creating and unlinking of sockets. Show proper and not temporary name in conn_info.
* Add comprehensive tests for unix socket mode.
* Hide some imports inside functions to avoid Windows failure.
* Mention unix socket mode in deployment docs.
* Fix merge commit.
* Make test_unix_connection_multiple_workers pickleable for spawn mode multiprocessing.
Co-authored-by: L. Kärkkäinen <tronic@users.noreply.github.com>
Co-authored-by: Adam Hopkins <admhpkns@gmail.com>
This commit is contained in:
@@ -454,11 +454,13 @@ def test_standard_forwarded(app):
|
||||
"X-Real-IP": "127.0.0.2",
|
||||
"X-Forwarded-For": "127.0.1.1",
|
||||
"X-Scheme": "ws",
|
||||
"Host": "local.site",
|
||||
}
|
||||
request, response = app.test_client.get("/", headers=headers)
|
||||
assert response.json == {"for": "127.0.0.2", "proto": "ws"}
|
||||
assert request.remote_addr == "127.0.0.2"
|
||||
assert request.scheme == "ws"
|
||||
assert request.server_name == "local.site"
|
||||
assert request.server_port == 80
|
||||
|
||||
app.config.FORWARDED_SECRET = "mySecret"
|
||||
@@ -1807,13 +1809,17 @@ def test_request_port(app):
|
||||
port = request.port
|
||||
assert isinstance(port, int)
|
||||
|
||||
delattr(request, "_socket")
|
||||
delattr(request, "_port")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_port_asgi(app):
|
||||
@app.get("/")
|
||||
def handler(request):
|
||||
return text("OK")
|
||||
|
||||
request, response = await app.asgi_client.get("/")
|
||||
|
||||
port = request.port
|
||||
assert isinstance(port, int)
|
||||
assert hasattr(request, "_socket")
|
||||
assert hasattr(request, "_port")
|
||||
|
||||
|
||||
def test_request_socket(app):
|
||||
@@ -1832,12 +1838,6 @@ def test_request_socket(app):
|
||||
assert ip == request.ip
|
||||
assert port == request.port
|
||||
|
||||
delattr(request, "_socket")
|
||||
|
||||
socket = request.socket
|
||||
assert isinstance(socket, tuple)
|
||||
assert hasattr(request, "_socket")
|
||||
|
||||
|
||||
def test_request_server_name(app):
|
||||
@app.get("/")
|
||||
@@ -1866,7 +1866,7 @@ def test_request_server_name_in_host_header(app):
|
||||
request, response = app.test_client.get(
|
||||
"/", headers={"Host": "mal_formed"}
|
||||
)
|
||||
assert request.server_name == None # For now (later maybe 127.0.0.1)
|
||||
assert request.server_name == ""
|
||||
|
||||
|
||||
def test_request_server_name_forwarded(app):
|
||||
@@ -1893,7 +1893,7 @@ def test_request_server_port(app):
|
||||
|
||||
test_client = SanicTestClient(app)
|
||||
request, response = test_client.get("/", headers={"Host": "my-server"})
|
||||
assert request.server_port == test_client.port
|
||||
assert request.server_port == 80
|
||||
|
||||
|
||||
def test_request_server_port_in_host_header(app):
|
||||
@@ -1952,12 +1952,12 @@ def test_server_name_and_url_for(app):
|
||||
def handler(request):
|
||||
return text("ok")
|
||||
|
||||
app.config.SERVER_NAME = "my-server"
|
||||
app.config.SERVER_NAME = "my-server" # This means default port
|
||||
assert app.url_for("handler", _external=True) == "http://my-server/foo"
|
||||
request, response = app.test_client.get("/foo")
|
||||
assert (
|
||||
request.url_for("handler")
|
||||
== f"http://my-server:{request.server_port}/foo"
|
||||
== f"http://my-server/foo"
|
||||
)
|
||||
|
||||
app.config.SERVER_NAME = "https://my-server/path"
|
||||
|
||||
235
tests/test_unix_socket.py
Normal file
235
tests/test_unix_socket.py
Normal file
@@ -0,0 +1,235 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.response import text
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.name != "posix", reason="UNIX only")
|
||||
SOCKPATH = "/tmp/sanictest.sock"
|
||||
SOCKPATH2 = "/tmp/sanictest2.sock"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def socket_cleanup():
|
||||
try:
|
||||
os.unlink(SOCKPATH)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(SOCKPATH2)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
# Run test function
|
||||
yield
|
||||
try:
|
||||
os.unlink(SOCKPATH2)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(SOCKPATH)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def test_unix_socket_creation(caplog):
|
||||
from socket import AF_UNIX, socket
|
||||
|
||||
with socket(AF_UNIX) as sock:
|
||||
sock.bind(SOCKPATH)
|
||||
assert os.path.exists(SOCKPATH)
|
||||
ino = os.stat(SOCKPATH).st_ino
|
||||
|
||||
app = Sanic(name=__name__)
|
||||
|
||||
@app.listener("after_server_start")
|
||||
def running(app, loop):
|
||||
assert os.path.exists(SOCKPATH)
|
||||
assert ino != os.stat(SOCKPATH).st_ino
|
||||
app.stop()
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
app.run(unix=SOCKPATH)
|
||||
|
||||
assert (
|
||||
"sanic.root",
|
||||
logging.INFO,
|
||||
f"Goin' Fast @ {SOCKPATH} http://...",
|
||||
) in caplog.record_tuples
|
||||
assert not os.path.exists(SOCKPATH)
|
||||
|
||||
|
||||
def test_invalid_paths():
|
||||
app = Sanic(name=__name__)
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
app.run(unix=".")
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
app.run(unix="no-such-directory/sanictest.sock")
|
||||
|
||||
|
||||
def test_dont_replace_file():
|
||||
with open(SOCKPATH, "w") as f:
|
||||
f.write("File, not socket")
|
||||
|
||||
app = Sanic(name=__name__)
|
||||
|
||||
@app.listener("after_server_start")
|
||||
def stop(app, loop):
|
||||
app.stop()
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
app.run(unix=SOCKPATH)
|
||||
|
||||
|
||||
def test_dont_follow_symlink():
|
||||
from socket import AF_UNIX, socket
|
||||
|
||||
with socket(AF_UNIX) as sock:
|
||||
sock.bind(SOCKPATH2)
|
||||
os.symlink(SOCKPATH2, SOCKPATH)
|
||||
|
||||
app = Sanic(name=__name__)
|
||||
|
||||
@app.listener("after_server_start")
|
||||
def stop(app, loop):
|
||||
app.stop()
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
app.run(unix=SOCKPATH)
|
||||
|
||||
|
||||
def test_socket_deleted_while_running():
|
||||
app = Sanic(name=__name__)
|
||||
|
||||
@app.listener("after_server_start")
|
||||
async def hack(app, loop):
|
||||
os.unlink(SOCKPATH)
|
||||
app.stop()
|
||||
|
||||
app.run(host="myhost.invalid", unix=SOCKPATH)
|
||||
|
||||
|
||||
def test_socket_replaced_with_file():
|
||||
app = Sanic(name=__name__)
|
||||
|
||||
@app.listener("after_server_start")
|
||||
async def hack(app, loop):
|
||||
os.unlink(SOCKPATH)
|
||||
with open(SOCKPATH, "w") as f:
|
||||
f.write("Not a socket")
|
||||
app.stop()
|
||||
|
||||
app.run(host="myhost.invalid", unix=SOCKPATH)
|
||||
|
||||
|
||||
def test_unix_connection():
|
||||
app = Sanic(name=__name__)
|
||||
|
||||
@app.get("/")
|
||||
def handler(request):
|
||||
return text(f"{request.conn_info.server}")
|
||||
|
||||
@app.listener("after_server_start")
|
||||
async def client(app, loop):
|
||||
try:
|
||||
async with httpx.AsyncClient(uds=SOCKPATH) as client:
|
||||
r = await client.get("http://myhost.invalid/")
|
||||
assert r.status_code == 200
|
||||
assert r.text == os.path.abspath(SOCKPATH)
|
||||
finally:
|
||||
app.stop()
|
||||
|
||||
app.run(host="myhost.invalid", unix=SOCKPATH)
|
||||
|
||||
|
||||
app_multi = Sanic(name=__name__)
|
||||
|
||||
|
||||
def handler(request):
|
||||
return text(f"{request.conn_info.server}")
|
||||
|
||||
|
||||
async def client(app, loop):
|
||||
try:
|
||||
async with httpx.AsyncClient(uds=SOCKPATH) as client:
|
||||
r = await client.get("http://myhost.invalid/")
|
||||
assert r.status_code == 200
|
||||
assert r.text == os.path.abspath(SOCKPATH)
|
||||
finally:
|
||||
app.stop()
|
||||
|
||||
|
||||
def test_unix_connection_multiple_workers():
|
||||
app_multi.get("/")(handler)
|
||||
app_multi.listener("after_server_start")(client)
|
||||
app_multi.run(host="myhost.invalid", unix=SOCKPATH, workers=2)
|
||||
|
||||
|
||||
async def test_zero_downtime():
|
||||
"""Graceful server termination and socket replacement on restarts"""
|
||||
from signal import SIGINT
|
||||
from time import monotonic as current_time
|
||||
|
||||
async def client():
|
||||
for _ in range(40):
|
||||
async with httpx.AsyncClient(uds=SOCKPATH) as client:
|
||||
r = await client.get("http://localhost/sleep/0.1")
|
||||
assert r.status_code == 200
|
||||
assert r.text == f"Slept 0.1 seconds.\n"
|
||||
|
||||
def spawn():
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"sanic",
|
||||
"--unix",
|
||||
SOCKPATH,
|
||||
"examples.delayed_response.app",
|
||||
]
|
||||
DN = subprocess.DEVNULL
|
||||
return subprocess.Popen(
|
||||
command, stdin=DN, stdout=DN, stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
try:
|
||||
processes = [spawn()]
|
||||
while not os.path.exists(SOCKPATH):
|
||||
if processes[0].poll() is not None:
|
||||
raise Exception("Worker did not start properly")
|
||||
await asyncio.sleep(0.0001)
|
||||
ino = os.stat(SOCKPATH).st_ino
|
||||
task = asyncio.get_event_loop().create_task(client())
|
||||
start_time = current_time()
|
||||
while current_time() < start_time + 4:
|
||||
# Start a new one and wait until the socket is replaced
|
||||
processes.append(spawn())
|
||||
while ino == os.stat(SOCKPATH).st_ino:
|
||||
await asyncio.sleep(0.001)
|
||||
ino = os.stat(SOCKPATH).st_ino
|
||||
# Graceful termination of the previous one
|
||||
processes[-2].send_signal(SIGINT)
|
||||
# Wait until client has completed all requests
|
||||
await task
|
||||
processes[-1].send_signal(SIGINT)
|
||||
for worker in processes:
|
||||
try:
|
||||
worker.wait(1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Exception(
|
||||
f"Worker would not terminate:\n{worker.stderr}"
|
||||
)
|
||||
finally:
|
||||
for worker in processes:
|
||||
worker.kill()
|
||||
# Test for clean run and termination
|
||||
assert len(processes) > 5
|
||||
assert [worker.poll() for worker in processes] == len(processes) * [0]
|
||||
assert not os.path.exists(SOCKPATH)
|
||||
Reference in New Issue
Block a user