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:
@Dreamsorcerer: I'll take a closer look later, but the documentation just says that it is the host to access the static files:
https://github.com/aio-libs/aiohttp-devtools/blob/master/aiohttp_devtools/cli.py#L49-L50

Maybe you're confusing it with the `--hosts` option in aiohttp-debugtoolbar?
https://github.com/aio-libs/aiohttp-debugtoolbar#settings@mikh3x4: It also doesn’t change the host of the static server

https://github.com/aio-libs/aiohttp-devtools/blob/3e517d2a6e3a40da04c2bad26baa6738e3e474b6/aiohttp_devtools/runserver/main.py#L81@Dreamsorcerer: OK, so the setting appears to get used here:
https://github.com/aio-libs/aiohttp-devtools/blob/master/aiohttp_devtools/runserver/serve.py#L55

To change the host used for the static server.

I'm not really sure how well that works, or if it's worth changing it. We could probably add a new option for setting the binding address, or maybe repurpose this option. Feel free to give it a go.

Solution patch:
diff --git a/aiohttp_devtools/cli.py b/aiohttp_devtools/cli.py
index eea79730..37080cb5 100644
--- a/aiohttp_devtools/cli.py
+++ b/aiohttp_devtools/cli.py
@@ -28,21 +28,23 @@ def cli() -> None:
                    '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 @@ def serve(path: str, livereload: bool, port: int, verbose: bool, browser_cache:
                         "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 @@ def serve(path: str, livereload: bool, port: int, verbose: bool, browser_cache:
 @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 836e68dd..5c65b17d 100644
--- a/aiohttp_devtools/runserver/config.py
+++ b/aiohttp_devtools/runserver/config.py
@@ -42,6 +42,7 @@ def __init__(self, *,
                  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 @@ def __init__(self, *,
         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 @@ async def load_app(self, app_factory: AppFactory) -> web.Application:
 
     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 d9391de2..0dd35052 100644
--- a/aiohttp_devtools/runserver/main.py
+++ b/aiohttp_devtools/runserver/main.py
@@ -9,7 +9,7 @@
 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 99b29ef9..9be8e6ba 100644
--- a/aiohttp_devtools/runserver/serve.py
+++ b/aiohttp_devtools/runserver/serve.py
@@ -32,7 +32,6 @@
 
 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 shutdown() -> NoReturn:
         _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 2dc6516c..07352397 100644
--- a/aiohttp_devtools/runserver/watch.py
+++ b/aiohttp_devtools/runserver/watch.py
@@ -107,7 +107,7 @@ async def _src_reload_when_live(self, checks: int) -> None:
         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 @@ async def _stop_dev_server(self) -> None:
         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 822c3ff6..5b22747a 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 04f090ed..5e7938e9 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 @@ async def hello(request):
     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 @@ async def hello(request):
 
     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 5a132caf..e577ed31 100644
--- a/tests/test_runserver_serve.py
+++ b/tests/test_runserver_serve.py
@@ -21,7 +21,7 @@
 
 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 4af690ff..b8373d68 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)