Repository: aio-libs/aiohttp-devtools

Issue:
runserver doesn't respect host setting
**aiohttp-devtools version: 1.0.post0**
**aiohttp version: 3.8.1**
**python version: 3.10**
**Platform: mac**

### Issue Summary

`adev runserver` doesn't respect the host setting - it always uses `0.0.0.0` which means the server can be always accused from any computer on the network.

### Steps to reproduce

create a file  `test.py`

```
from aiohttp import web

class Main:
    def __init__(self):
        self.app = web.Application()
        self.app.router.add_get('/', self.handle_request)

    async def handle_request(self, request):
        return web.Response(text="testing", content_type='text/html')

def get_app():
    page = Main()
    return page.app
```

and run command `adev runserver test.py --host localhost`

As expected we can access the page from the computer it is running on. However it is also possible to access the page from any other computer on the network, which (according to my understanding) shouldn't be possible with this host setting.

If this is indeed and issue then I suspect its because of using `HOST` instead of `host` here:
https://github.com/aio-libs/aiohttp-devtools/blob/3e517d2a6e3a40da04c2bad26baa6738e3e474b6/aiohttp_devtools/runserver/main.py#L65


If it is not an issue how could I restrict the dev server to only be accessible from localhost?

Discussion:
Cycloctane: > Hmm, there's a weird test failure... Otherwise, looks good.

This test failure happens in macos only. It works well on my machine and other linux ci action. I don't have a macos environment to debug it...
Cycloctane: I managed to make the test pass by setting bind address to 0.0.0.0. https://github.com/aio-libs/aiohttp-devtools/pull/708/commits/19e89ad67d2edb9f38bf10fb112af44b057fca82

Have no idea why binding to localhost in this test causes pytest internal error on macos.

Patch:
diff --git a/aiohttp_devtools/cli.py b/aiohttp_devtools/cli.py
index eea7973..37080cb 100644
--- a/aiohttp_devtools/cli.py
+++ b/aiohttp_devtools/cli.py
@@ -28,21 +28,23 @@ livereload_help = ('Whether to inject livereload.js into html page footers to au
                    'env variable AIO_LIVERELOAD')
 browser_cache_help = ("When disabled (the default), sends no-cache headers to "
                       "disable browser caching.")
+bind_address_help = "Network address to listen, default localhost. env variable: AIO_BIND_ADDRESS"
 
 
 @cli.command()
 @click.argument('path', type=_dir_existing, required=True)
 @click.option('--livereload/--no-livereload', envvar='AIO_LIVERELOAD', default=True, help=livereload_help)
+@click.option("-b", "--bind", "bind_address", envvar="AIO_BIND_ADDRESS", default="localhost", help=bind_address_help)
 @click.option('-p', '--port', default=8000, type=int)
 @click.option('-v', '--verbose', is_flag=True, help=verbose_help)
 @click.option("--browser-cache/--no-browser-cache", envvar="AIO_BROWSER_CACHE", default=False,
               help=browser_cache_help)
-def serve(path: str, livereload: bool, port: int, verbose: bool, browser_cache: bool) -> None:
+def serve(path: str, livereload: bool, bind_address: str, port: int, verbose: bool, browser_cache: bool) -> None:
     """
     Serve static files from a directory.
     """
     setup_logging(verbose)
-    run_app(**serve_static(static_path=path, livereload=livereload, port=port,
+    run_app(**serve_static(static_path=path, livereload=livereload, bind_address=bind_address, port=port,
                            browser_cache=browser_cache))
 
 
@@ -55,7 +57,7 @@ shutdown_by_url_help = ("The development server will be stopped via a request to
                         "added to the server, instead of via signals (this is the default on Windows). "
                         "env variable: AIO_SHUTDOWN_BY_URL")
 host_help = ('host used when referencing livereload and static files, if blank host is taken from the request header '
-             'with default of localhost. env variable AIO_HOST')
+             "with default of bind network address. env variable AIO_HOST")
 app_factory_help = ('name of the app factory to create an aiohttp.web.Application with, if missing default app-factory '
                     'names are tried. This can be either a function with signature '
                     '"def create_app(loop): -> Application" or "def create_app(): -> Application" '
@@ -75,6 +77,7 @@ aux_port_help = 'Port to serve auxiliary app (reload and static) on, default por
 @click.option('--livereload/--no-livereload', envvar='AIO_LIVERELOAD', default=None, help=livereload_help)
 @click.option('--host', default=INFER_HOST, help=host_help)
 @click.option('--app-factory', 'app_factory_name', envvar='AIO_APP_FACTORY', help=app_factory_help)
+@click.option("-b", "--bind", "bind_address", envvar="AIO_BIND_ADDRESS", default="localhost", help=bind_address_help)
 @click.option('-p', '--port', 'main_port', envvar='AIO_PORT', type=click.INT, help=port_help)
 @click.option('--aux-port', envvar='AIO_AUX_PORT', type=click.INT, help=aux_port_help)
 @click.option('-v', '--verbose', is_flag=True, help=verbose_help)
diff --git a/aiohttp_devtools/runserver/config.py b/aiohttp_devtools/runserver/config.py
index 836e68d..5c65b17 100644
--- a/aiohttp_devtools/runserver/config.py
+++ b/aiohttp_devtools/runserver/config.py
@@ -42,6 +42,7 @@ class Config:
                  path_prefix: str = "/_devtools",
                  app_factory_name: Optional[str] = None,
                  host: str = INFER_HOST,
+                 bind_address: str = "localhost",
                  main_port: int = 8000,
                  aux_port: Optional[int] = None,
                  browser_cache: bool = False):
@@ -73,7 +74,15 @@ class Config:
         self.path_prefix = path_prefix
         self.app_factory_name = app_factory_name
         self.infer_host = host == INFER_HOST
-        self.host = 'localhost' if self.infer_host else host
+
+        if not self.infer_host:
+            self.host = host
+        elif bind_address == "0.0.0.0":
+            self.host = "localhost"
+        else:
+            self.host = bind_address
+
+        self.bind_address = bind_address
         self.main_port = main_port
         self.aux_port = aux_port or (main_port + 1)
         self.browser_cache = browser_cache
@@ -190,5 +199,5 @@ class Config:
 
     def __str__(self) -> str:
         fields = ("py_file", "static_path", "static_url", "livereload", "shutdown_by_url",
-                  "path_prefix", "app_factory_name", "host", "main_port", "aux_port")
+                  "path_prefix", "app_factory_name", "host", "bind_address", "main_port", "aux_port")
         return 'Config:\n' + '\n'.join('  {0}: {1!r}'.format(f, getattr(self, f)) for f in fields)
diff --git a/aiohttp_devtools/runserver/main.py b/aiohttp_devtools/runserver/main.py
index d9391de..0dd3505 100644
--- a/aiohttp_devtools/runserver/main.py
+++ b/aiohttp_devtools/runserver/main.py
@@ -9,7 +9,7 @@ from aiohttp.web import Application
 from ..logs import rs_dft_logger as logger
 from .config import Config
 from .log_handlers import AuxAccessLogger
-from .serve import HOST, check_port_open, create_auxiliary_app
+from .serve import check_port_open, create_auxiliary_app
 from .watch import AppTask, LiveReloadTask
 
 
@@ -33,7 +33,7 @@ def runserver(**config_kwargs: Any) -> RunServer:
     config = Config(**config_kwargs)
     config.import_app_factory()
 
-    asyncio.run(check_port_open(config.main_port))
+    asyncio.run(check_port_open(config.main_port, host=config.bind_address))
 
     aux_app = create_auxiliary_app(
         static_path=config.static_path_str,
@@ -56,11 +56,11 @@ def runserver(**config_kwargs: Any) -> RunServer:
         rel_path = config.static_path.relative_to(os.getcwd())
         logger.info('serving static files from ./%s/ at %s%s', rel_path, url, config.static_url)
 
-    return {"app": aux_app, "host": HOST, "port": config.aux_port,
+    return {"app": aux_app, "host": config.bind_address, "port": config.aux_port,
             "shutdown_timeout": 0.01, "access_log_class": AuxAccessLogger}
 
 
-def serve_static(*, static_path: str, livereload: bool = True, port: int = 8000,
+def serve_static(*, static_path: str, livereload: bool = True, bind_address: str = "localhost", port: int = 8000,
                  browser_cache: bool = False) -> RunServer:
     logger.debug('Config: path="%s", livereload=%s, port=%s', static_path, livereload, port)
 
@@ -73,6 +73,6 @@ def serve_static(*, static_path: str, livereload: bool = True, port: int = 8000,
         app.cleanup_ctx.append(livereload_manager.cleanup_ctx)
 
     livereload_status = 'ON' if livereload else 'OFF'
-    logger.info('Serving "%s" at http://localhost:%d, livereload %s', static_path, port, livereload_status)
-    return {"app": app, "host": HOST, "port": port,
+    logger.info('Serving "%s" at http://%s:%d, livereload %s', static_path, bind_address, port, livereload_status)
+    return {"app": app, "host": bind_address, "port": port,
             "shutdown_timeout": 0.01, "access_log_class": AuxAccessLogger}
diff --git a/aiohttp_devtools/runserver/serve.py b/aiohttp_devtools/runserver/serve.py
index 99b29ef..9be8e6b 100644
--- a/aiohttp_devtools/runserver/serve.py
+++ b/aiohttp_devtools/runserver/serve.py
@@ -32,7 +32,6 @@ except ImportError:
 
 LIVE_RELOAD_HOST_SNIPPET = '\n<script src="http://{}:{}/livereload.js"></script>\n'
 LIVE_RELOAD_LOCAL_SNIPPET = b'\n<script src="/livereload.js"></script>\n'
-HOST = '0.0.0.0'
 
 LAST_RELOAD = web.AppKey("LAST_RELOAD", List[float])
 LIVERELOAD_SCRIPT = web.AppKey("LIVERELOAD_SCRIPT", bytes)
@@ -129,17 +128,17 @@ def modify_main_app(app: web.Application, config: Config) -> None:  # noqa: C901
         _set_static_url(app, static_url)
 
 
-async def check_port_open(port: int, delay: float = 1) -> None:
+async def check_port_open(port: int, host: str = "0.0.0.0", delay: float = 1) -> None:
     loop = asyncio.get_running_loop()
     # the "s = socket.socket; s.bind" approach sometimes says a port is in use when it's not
     # this approach replicates aiohttp so should always give the same answer
     for i in range(5, 0, -1):
         try:
-            server = await loop.create_server(asyncio.Protocol, host=HOST, port=port)
+            server = await loop.create_server(asyncio.Protocol, host=host, port=port)
         except OSError as e:
             if e.errno != EADDRINUSE:
                 raise
-            dft_logger.warning('port %d is already in use, waiting %d...', port, i)
+            dft_logger.warning("%s:%d is already in use, waiting %d...", host, port, i)
             await asyncio.sleep(delay)
         else:
             server.close()
@@ -170,7 +169,7 @@ def serve_main_app(config: Config, tty_path: Optional[str]) -> None:
             with asyncio.Runner() as runner:
                 app_runner = runner.run(create_main_app(config, app_factory))
                 try:
-                    runner.run(start_main_app(app_runner, config.main_port))
+                    runner.run(start_main_app(app_runner, config.bind_address, config.main_port))
                     runner.get_loop().run_forever()
                 except KeyboardInterrupt:
                     pass
@@ -181,7 +180,7 @@ def serve_main_app(config: Config, tty_path: Optional[str]) -> None:
             loop = asyncio.new_event_loop()
             runner = loop.run_until_complete(create_main_app(config, app_factory))
             try:
-                loop.run_until_complete(start_main_app(runner, config.main_port))
+                loop.run_until_complete(start_main_app(runner, config.bind_address, config.main_port))
                 loop.run_forever()
             except KeyboardInterrupt:  # pragma: no cover
                 pass
@@ -194,13 +193,13 @@ async def create_main_app(config: Config, app_factory: AppFactory) -> web.AppRun
     app = await config.load_app(app_factory)
     modify_main_app(app, config)
 
-    await check_port_open(config.main_port)
+    await check_port_open(config.main_port, host=config.bind_address)
     return web.AppRunner(app, access_log_class=AccessLogger, shutdown_timeout=0.1)
 
 
-async def start_main_app(runner: web.AppRunner, port: int) -> None:
+async def start_main_app(runner: web.AppRunner, host: str, port: int) -> None:
     await runner.setup()
-    site = web.TCPSite(runner, host=HOST, port=port)
+    site = web.TCPSite(runner, host=host, port=port)
     await site.start()
 
 
diff --git a/aiohttp_devtools/runserver/watch.py b/aiohttp_devtools/runserver/watch.py
index 2dc6516..0735239 100644
--- a/aiohttp_devtools/runserver/watch.py
+++ b/aiohttp_devtools/runserver/watch.py
@@ -107,7 +107,7 @@ class AppTask(WatchTask):
         assert self._app is not None and self._session is not None
 
         if self._app[WS]:
-            url = 'http://localhost:{.main_port}/?_checking_alive=1'.format(self._config)
+            url = "http://{0.host}:{0.main_port}/?_checking_alive=1".format(self._config)
             logger.debug('checking app at "%s" is running before prompting reload...', url)
             for i in range(checks):
                 await asyncio.sleep(0.1)
@@ -141,7 +141,7 @@ class AppTask(WatchTask):
         if self._process.is_alive():
             logger.debug('stopping server process...')
             if self._config.shutdown_by_url:  # Workaround for signals not working on Windows
-                url = "http://localhost:{}{}/shutdown".format(self._config.main_port, self._config.path_prefix)
+                url = "http://{0.host}:{0.main_port}{0.path_prefix}/shutdown".format(self._config)
                 logger.debug("Attempting to stop process via shutdown endpoint {}".format(url))
                 try:
                     with suppress(ClientConnectionError):


Test patch:
diff --git a/tests/test_runserver_config.py b/tests/test_runserver_config.py
index 822c3ff..5b22747 100644
--- a/tests/test_runserver_config.py
+++ b/tests/test_runserver_config.py
@@ -13,6 +13,24 @@ async def test_load_simple_app(tmpworkdir):
     Config(app_path='app.py')
 
 
+def test_infer_host(tmpworkdir):
+    mktree(tmpworkdir, SIMPLE_APP)
+    bind_config = Config(app_path="app.py", bind_address="192.168.1.1")
+    assert bind_config.infer_host is True
+    assert bind_config.host == "192.168.1.1"
+    bind_any = Config(app_path="app.py", bind_address="0.0.0.0")
+    assert bind_any.infer_host is True
+    assert bind_any.host == "localhost"
+
+
+def test_host_override_addr(tmpworkdir):
+    mktree(tmpworkdir, SIMPLE_APP)
+    config = Config(app_path="app.py", host="foobar.com", bind_address="192.168.1.1")
+    assert config.infer_host is False
+    assert config.host == "foobar.com"
+    assert config.bind_address == "192.168.1.1"
+
+
 @forked
 async def test_create_app_wrong_name(tmpworkdir):
     mktree(tmpworkdir, SIMPLE_APP)
diff --git a/tests/test_runserver_main.py b/tests/test_runserver_main.py
index 04f090e..5e7938e 100644
--- a/tests/test_runserver_main.py
+++ b/tests/test_runserver_main.py
@@ -58,11 +58,13 @@ def create_app():
     })
     loop = asyncio.new_event_loop()
     asyncio.set_event_loop(loop)
-    args = runserver(app_path='app.py', static_path='static_dir')
+    args = runserver(app_path="app.py", static_path="static_dir", bind_address="0.0.0.0")
     aux_app = args["app"]
     aux_port = args["port"]
+    runapp_host = args["host"]
     assert isinstance(aux_app, aiohttp.web.Application)
     assert aux_port == 8001
+    assert runapp_host == "0.0.0.0"
     for startup in aux_app.on_startup:
         loop.run_until_complete(startup(aux_app))
 
@@ -108,8 +110,10 @@ app.router.add_get('/', hello)
     args = runserver(app_path="app.py", host="foobar.com", main_port=0, aux_port=8001)
     aux_app = args["app"]
     aux_port = args["port"]
+    runapp_host = args["host"]
     assert isinstance(aux_app, aiohttp.web.Application)
     assert aux_port == 8001
+    assert runapp_host == "localhost"
     assert len(aux_app.on_startup) == 1
     assert len(aux_app.on_shutdown) == 1
     assert len(aux_app.cleanup_ctx) == 1
@@ -202,7 +206,7 @@ async def test_serve_main_app(tmpworkdir, mocker):
 
     config = Config(app_path="app.py", main_port=0)
     runner = await create_main_app(config, config.import_app_factory())
-    await start_main_app(runner, config.main_port)
+    await start_main_app(runner, config.bind_address, config.main_port)
 
     mock_modify_main_app.assert_called_with(mock.ANY, config)
 
@@ -226,7 +230,7 @@ app.router.add_get('/', hello)
 
     config = Config(app_path="app.py", main_port=0)
     runner = await create_main_app(config, config.import_app_factory())
-    await start_main_app(runner, config.main_port)
+    await start_main_app(runner, config.bind_address, config.main_port)
 
     mock_modify_main_app.assert_called_with(mock.ANY, config)
 
diff --git a/tests/test_runserver_serve.py b/tests/test_runserver_serve.py
index 5a132ca..e577ed3 100644
--- a/tests/test_runserver_serve.py
+++ b/tests/test_runserver_serve.py
@@ -21,7 +21,7 @@ from .conftest import SIMPLE_APP, create_future
 
 async def test_check_port_open(unused_tcp_port_factory):
     port = unused_tcp_port_factory()
-    await check_port_open(port, 0.001)
+    await check_port_open(port, delay=0.001)
 
 
 async def test_check_port_not_open(unused_tcp_port_factory):
@@ -29,7 +29,7 @@ async def test_check_port_not_open(unused_tcp_port_factory):
     with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
         sock.bind(('0.0.0.0', port))
         with pytest.raises(AiohttpDevException):
-            await check_port_open(port, 0.001)
+            await check_port_open(port, delay=0.001)
 
 
 async def test_aux_reload(smart_caplog):
diff --git a/tests/test_runserver_watch.py b/tests/test_runserver_watch.py
index 4af690f..b8373d6 100644
--- a/tests/test_runserver_watch.py
+++ b/tests/test_runserver_watch.py
@@ -107,6 +107,7 @@ async def test_reload_server_running(aiohttp_client, mocker):
     mock_src_reload = mocker.patch('aiohttp_devtools.runserver.watch.src_reload', return_value=create_future())
     cli = await aiohttp_client(app)
     config = MagicMock()
+    config.host = "localhost"
     config.main_port = cli.server.port
 
     app_task = AppTask(config)