fix: auth-guard / and /settings, add Logout link to static navs
All checks were successful
Build ISO / build-iso (push) Successful in 17m14s
CI / lint (push) Successful in 26s
CI / test (push) Successful in 1m2s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m26s
All checks were successful
Build ISO / build-iso (push) Successful in 17m14s
CI / lint (push) Successful in 26s
CI / test (push) Successful in 1m2s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m26s
Since 26.11 shipped login, two of the three nav pages were secretly
unauthenticated. The Caddyfile only reverse-proxied /api/*, /apps*,
/login*, /logout* to the Python auth-gated handler. Everything else —
including / (landing page) and /settings/ — fell through to Caddy's
catch-all file_server straight out of assets/www/, skipping the
session check entirely.
LAN visitor effect: they could read the box's hostname, IP, Furtka
version, uptime, and see all the Update-now / Reboot / HTTPS-toggle
buttons on /settings/. The API calls those buttons fired were
themselves 401-gated so nothing actually happened — but the info leak
plus "looks open" UX was real. Caught in the 26.13 SSH test session
when the user noticed Logout only appeared in the nav on /apps, and
not on / or /settings/.
Fix:
- Caddyfile: new `handle /settings*` and `handle /` blocks in the
shared `(furtka_routes)` snippet reverse-proxy to localhost:7000,
so both hit the Python auth-guard before the HTML goes out.
- api.py: new `_serve_static_www(relative_path)` helper reads
assets/www/{index.html, settings/index.html} with a path-traversal
clamp (resolved path must stay under static_www_dir). `do_GET`
routes `/` and `/settings[/]` to it. Removed the `/` branch from
the old combined-with-/apps line — those are different pages now.
- paths.py: new `static_www_dir()` helper with `FURTKA_STATIC_WWW`
env override for tests.
- assets/www/*.html: both nav bars get the Logout link + a shared
`doLogout()` inline script matching the _HTML pattern. Users never
see the link unauthed (the Python handler 302s them before the
page renders), but authed users get consistent navigation across
all three pages.
Tests: 5 new cases in test_api.py — unauth / redirects, unauth
/settings redirects (both trailing-slash and not), authed / serves
index.html, authed /settings serves settings/index.html,
regression guard that / and /apps serve different content.
Existing test updated (the one that used / as a proxy for /apps).
Static /style.css, /rootCA.crt, /status.json, /furtka.json,
/update-state.json stay served by Caddy's catch-all — those are
public by design (login page needs style.css, fresh users need the
CA to trust HTTPS, runtime JSON is metadata not creds).
272 tests pass, ruff check + format clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8c1fd1da2b
commit
26f0424ae3
8 changed files with 193 additions and 5 deletions
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -7,6 +7,34 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [26.14-alpha] - 2026-04-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Landing page and `/settings/` were silently bypassing the auth
|
||||||
|
guard.** Since 26.11 shipped login, the Caddyfile only
|
||||||
|
reverse-proxied `/api/*`, `/apps*`, `/login*`, and `/logout*` to
|
||||||
|
Python. Everything else — including `/` and `/settings/` — fell
|
||||||
|
through to Caddy's catch-all `file_server` and was served straight
|
||||||
|
from `assets/www/` without ever hitting the session check. The
|
||||||
|
effect: a LAN visitor saw the box's hostname, IP, Furtka version,
|
||||||
|
and the buttons for Update-now / Reboot / HTTPS-toggle. The API
|
||||||
|
calls those buttons fired were all 401-auth-gated so actions didn't
|
||||||
|
land, but the information leak and the "looks open" UX was a real
|
||||||
|
bug. Caught in the 26.13 SSH test session when the user noticed
|
||||||
|
Logout only showed up on `/apps`. Now Caddy routes `/` and
|
||||||
|
`/settings*` through Python; a new `_serve_static_www` handler
|
||||||
|
checks the session cookie, redirects to `/login` if unauthed, and
|
||||||
|
reads the HTML from `assets/www/` otherwise. Catch-all still
|
||||||
|
serves `/style.css`, `/rootCA.crt`, and the runtime JSON files
|
||||||
|
publicly — those don't need auth.
|
||||||
|
- **Logout link now shows on every authed page, not just `/apps`.**
|
||||||
|
The static HTML for `/` and `/settings/` maintained their own nav
|
||||||
|
separate from `_HTML` in `api.py`, so they never got the Logout
|
||||||
|
entry when it was added in 26.11. Both nav bars now include it
|
||||||
|
plus an inline `doLogout()` that POSTs `/logout` and bounces to
|
||||||
|
`/login`, matching the pattern in `_HTML`.
|
||||||
|
|
||||||
## [26.13-alpha] - 2026-04-21
|
## [26.13-alpha] - 2026-04-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
@ -279,7 +307,8 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
|
||||||
- **Containers:** Docker + Compose
|
- **Containers:** Docker + Compose
|
||||||
- **License:** AGPL-3.0
|
- **License:** AGPL-3.0
|
||||||
|
|
||||||
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.13-alpha...HEAD
|
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.14-alpha...HEAD
|
||||||
|
[26.14-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.14-alpha
|
||||||
[26.13-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.13-alpha
|
[26.13-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.13-alpha
|
||||||
[26.12-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.12-alpha
|
[26.12-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.12-alpha
|
||||||
[26.11-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.11-alpha
|
[26.11-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.11-alpha
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,20 @@
|
||||||
handle /logout* {
|
handle /logout* {
|
||||||
reverse_proxy localhost:7000
|
reverse_proxy localhost:7000
|
||||||
}
|
}
|
||||||
|
# /settings and / — these previously served as static HTML straight
|
||||||
|
# from the catch-all file_server, which meant the auth-guard was
|
||||||
|
# bypassed: a LAN visitor could see the box's version, IP, and
|
||||||
|
# reach the Update-now / Reboot buttons (the API calls behind them
|
||||||
|
# are auth-gated, but the page itself rendered without a redirect
|
||||||
|
# to /login). Route them through the Python handler which checks
|
||||||
|
# the session cookie and either serves the static HTML from
|
||||||
|
# assets/www/ or redirects to /login.
|
||||||
|
handle /settings* {
|
||||||
|
reverse_proxy localhost:7000
|
||||||
|
}
|
||||||
|
handle / {
|
||||||
|
reverse_proxy localhost:7000
|
||||||
|
}
|
||||||
# Runtime JSON lives under /var/lib/furtka/ so it survives self-updates
|
# Runtime JSON lives under /var/lib/furtka/ so it survives self-updates
|
||||||
# (which only swap /opt/furtka/current).
|
# (which only swap /opt/furtka/current).
|
||||||
handle /status.json {
|
handle /status.json {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<a href="/" aria-current="page">Home</a>
|
<a href="/" aria-current="page">Home</a>
|
||||||
<a href="/apps">Apps</a>
|
<a href="/apps">Apps</a>
|
||||||
<a href="/settings/">Settings</a>
|
<a href="/settings/">Settings</a>
|
||||||
|
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -67,6 +68,17 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Revoke the cookie server-side and bounce to /login. Shared
|
||||||
|
// shape with the _HTML in furtka/api.py so the two logout
|
||||||
|
// links behave identically.
|
||||||
|
async function doLogout(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
try { await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); }
|
||||||
|
catch (e) { /* server may already be down */ }
|
||||||
|
window.location.href = '/login';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Hostname + install metadata — written once at install time to
|
// Hostname + install metadata — written once at install time to
|
||||||
// /var/lib/furtka/furtka.json (see _furtka_json_cmd in the installer).
|
// /var/lib/furtka/furtka.json (see _furtka_json_cmd in the installer).
|
||||||
// Separate from status.json because these facts don't change between
|
// Separate from status.json because these facts don't change between
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
<a href="/apps">Apps</a>
|
<a href="/apps">Apps</a>
|
||||||
<a href="/settings/" aria-current="page">Settings</a>
|
<a href="/settings/" aria-current="page">Settings</a>
|
||||||
|
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -121,6 +122,15 @@
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Logout button in the nav — same shape as /apps and / pages.
|
||||||
|
async function doLogout(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
try { await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); }
|
||||||
|
catch (e) { /* server may already be down */ }
|
||||||
|
window.location.href = '/login';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/status.json', { cache: 'no-store' });
|
const r = await fetch('/status.json', { cache: 'no-store' });
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
from furtka import auth, dockerops, install_runner, installer, reconciler, sources
|
from furtka import auth, dockerops, install_runner, installer, reconciler, sources
|
||||||
from furtka.manifest import ManifestError, load_manifest
|
from furtka.manifest import ManifestError, load_manifest
|
||||||
from furtka.paths import apps_dir
|
from furtka.paths import apps_dir, static_www_dir
|
||||||
from furtka.scanner import scan
|
from furtka.scanner import scan
|
||||||
|
|
||||||
_ICON_MAX_BYTES = 16 * 1024
|
_ICON_MAX_BYTES = 16 * 1024
|
||||||
|
|
@ -1108,6 +1108,26 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(b)
|
self.wfile.write(b)
|
||||||
|
|
||||||
|
def _serve_static_www(self, relative_path: str):
|
||||||
|
"""Read an HTML asset from assets/www/ and serve it as 200.
|
||||||
|
|
||||||
|
Only reached after the do_GET auth-guard — so the caller is
|
||||||
|
already authed. Relative_path is hard-coded at the call site
|
||||||
|
(``index.html`` or ``settings/index.html``), not user-supplied,
|
||||||
|
so there's no path-traversal surface here — but we still clamp
|
||||||
|
the resolved path to static_www_dir() as a defensive check in
|
||||||
|
case a future refactor wires a dynamic path through.
|
||||||
|
"""
|
||||||
|
root = static_www_dir().resolve()
|
||||||
|
target = (root / relative_path).resolve()
|
||||||
|
if root not in target.parents and target != root:
|
||||||
|
return self._html(500, "<h1>internal error</h1>")
|
||||||
|
try:
|
||||||
|
body = target.read_text(encoding="utf-8")
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
return self._html(404, "<h1>not found</h1>")
|
||||||
|
return self._html(200, body)
|
||||||
|
|
||||||
def _redirect(self, location, extra_headers=None):
|
def _redirect(self, location, extra_headers=None):
|
||||||
self.send_response(302)
|
self.send_response(302)
|
||||||
self.send_header("Location", location)
|
self.send_header("Location", location)
|
||||||
|
|
@ -1216,8 +1236,21 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
return self._json(401, {"error": "not authenticated"})
|
return self._json(401, {"error": "not authenticated"})
|
||||||
return self._redirect("/login")
|
return self._redirect("/login")
|
||||||
|
|
||||||
if self.path in ("/", "/apps", "/apps/"):
|
if self.path in ("/apps", "/apps/"):
|
||||||
return self._html(200, _HTML)
|
return self._html(200, _HTML)
|
||||||
|
# Landing page + settings page used to be served directly by
|
||||||
|
# Caddy as static HTML, which silently bypassed this auth
|
||||||
|
# guard (26.11-era regression that shipped and nobody noticed
|
||||||
|
# until the 26.13 SSH test session — LAN visitors could read
|
||||||
|
# the box version, IP and fire pre-authed clicks at the
|
||||||
|
# update/reboot/https-toggle buttons even though the API calls
|
||||||
|
# themselves would 401). Python reads the static HTML from
|
||||||
|
# assets/www/ and serves it behind the session check; Caddy
|
||||||
|
# now proxies / and /settings* here (see Caddyfile).
|
||||||
|
if self.path == "/":
|
||||||
|
return self._serve_static_www("index.html")
|
||||||
|
if self.path in ("/settings", "/settings/"):
|
||||||
|
return self._serve_static_www("settings/index.html")
|
||||||
if self.path == "/api/apps":
|
if self.path == "/api/apps":
|
||||||
return self._json(200, _list_installed())
|
return self._json(200, _list_installed())
|
||||||
# /api/bundled is the pre-26.6 name for this list; kept as an alias
|
# /api/bundled is the pre-26.6 name for this list; kept as an alias
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ DEFAULT_CATALOG_DIR = Path("/var/lib/furtka/catalog")
|
||||||
# enforced by furtka.auth.save_users (same atomic-write pattern as the app
|
# enforced by furtka.auth.save_users (same atomic-write pattern as the app
|
||||||
# .env files).
|
# .env files).
|
||||||
DEFAULT_USERS_FILE = Path("/var/lib/furtka/users.json")
|
DEFAULT_USERS_FILE = Path("/var/lib/furtka/users.json")
|
||||||
|
# Static-web asset dir served by the Python handler for / and
|
||||||
|
# /settings* so those pages pick up the auth-guard. Caddy also serves
|
||||||
|
# /style.css and other assets directly from here for the login page.
|
||||||
|
DEFAULT_STATIC_WWW = Path("/opt/furtka/current/assets/www")
|
||||||
|
|
||||||
|
|
||||||
def apps_dir() -> Path:
|
def apps_dir() -> Path:
|
||||||
|
|
@ -36,3 +40,7 @@ def catalog_apps_dir() -> Path:
|
||||||
|
|
||||||
def users_file() -> Path:
|
def users_file() -> Path:
|
||||||
return Path(os.environ.get("FURTKA_USERS_FILE", DEFAULT_USERS_FILE))
|
return Path(os.environ.get("FURTKA_USERS_FILE", DEFAULT_USERS_FILE))
|
||||||
|
|
||||||
|
|
||||||
|
def static_www_dir() -> Path:
|
||||||
|
return Path(os.environ.get("FURTKA_STATIC_WWW", DEFAULT_STATIC_WWW))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "furtka"
|
name = "furtka"
|
||||||
version = "26.13-alpha"
|
version = "26.14-alpha"
|
||||||
description = "Open-source home server OS — simple enough for everyone."
|
description = "Open-source home server OS — simple enough for everyone."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,18 @@ def fake_dirs(tmp_path, monkeypatch):
|
||||||
bundled = tmp_path / "bundled"
|
bundled = tmp_path / "bundled"
|
||||||
catalog = tmp_path / "catalog"
|
catalog = tmp_path / "catalog"
|
||||||
users_file = tmp_path / "users.json"
|
users_file = tmp_path / "users.json"
|
||||||
|
static_www = tmp_path / "www"
|
||||||
apps.mkdir()
|
apps.mkdir()
|
||||||
bundled.mkdir()
|
bundled.mkdir()
|
||||||
|
static_www.mkdir()
|
||||||
|
(static_www / "index.html").write_text("<html>landing page</html>")
|
||||||
|
(static_www / "settings").mkdir()
|
||||||
|
(static_www / "settings" / "index.html").write_text("<html>settings page</html>")
|
||||||
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
|
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
|
||||||
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
|
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
|
||||||
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(catalog))
|
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(catalog))
|
||||||
monkeypatch.setenv("FURTKA_USERS_FILE", str(users_file))
|
monkeypatch.setenv("FURTKA_USERS_FILE", str(users_file))
|
||||||
|
monkeypatch.setenv("FURTKA_STATIC_WWW", str(static_www))
|
||||||
# install_runner writes to /var/lib/furtka/install-state.json and
|
# install_runner writes to /var/lib/furtka/install-state.json and
|
||||||
# /run/furtka/install.lock by default — redirect into tmp_path so
|
# /run/furtka/install.lock by default — redirect into tmp_path so
|
||||||
# test code doesn't need root.
|
# test code doesn't need root.
|
||||||
|
|
@ -278,7 +284,7 @@ def test_http_get_apps_route(fake_dirs, no_docker, admin_session):
|
||||||
assert r.status == 200
|
assert r.status == 200
|
||||||
data = json.loads(r.read())
|
data = json.loads(r.read())
|
||||||
assert data == []
|
assert data == []
|
||||||
with urllib.request.urlopen(_request(port, "/", cookie=admin_session)) as r:
|
with urllib.request.urlopen(_request(port, "/apps", cookie=admin_session)) as r:
|
||||||
assert r.status == 200
|
assert r.status == 200
|
||||||
assert b"Furtka Apps" in r.read()
|
assert b"Furtka Apps" in r.read()
|
||||||
# Unknown route → 404 JSON.
|
# Unknown route → 404 JSON.
|
||||||
|
|
@ -365,6 +371,82 @@ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauth_root_redirects_to_login(fake_dirs):
|
||||||
|
"""/ was previously Caddy-direct static HTML, bypassing auth. Now
|
||||||
|
Python serves it and the auth-guard applies — unauth visitor gets
|
||||||
|
bounced to /login just like /apps does."""
|
||||||
|
server, port = _start_server()
|
||||||
|
try:
|
||||||
|
opener = urllib.request.build_opener(_NoRedirectHandler())
|
||||||
|
try:
|
||||||
|
opener.open(_request(port, "/"))
|
||||||
|
raise AssertionError("expected 302")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
assert e.code == 302
|
||||||
|
assert e.headers["Location"] == "/login"
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauth_settings_redirects_to_login(fake_dirs):
|
||||||
|
server, port = _start_server()
|
||||||
|
try:
|
||||||
|
opener = urllib.request.build_opener(_NoRedirectHandler())
|
||||||
|
for path in ("/settings", "/settings/"):
|
||||||
|
try:
|
||||||
|
opener.open(_request(port, path))
|
||||||
|
raise AssertionError(f"expected 302 for {path}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
assert e.code == 302
|
||||||
|
assert e.headers["Location"] == "/login"
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_authed_root_serves_static_index(fake_dirs, admin_session):
|
||||||
|
server, port = _start_server()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(_request(port, "/", cookie=admin_session)) as r:
|
||||||
|
assert r.status == 200
|
||||||
|
assert r.read() == b"<html>landing page</html>"
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_authed_settings_serves_static(fake_dirs, admin_session):
|
||||||
|
server, port = _start_server()
|
||||||
|
try:
|
||||||
|
for path in ("/settings", "/settings/"):
|
||||||
|
with urllib.request.urlopen(_request(port, path, cookie=admin_session)) as r:
|
||||||
|
assert r.status == 200
|
||||||
|
assert r.read() == b"<html>settings page</html>"
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_authed_root_does_not_serve_apps_html(fake_dirs, admin_session):
|
||||||
|
"""Regression guard: the pre-26.14 do_GET had `if self.path in ("/",
|
||||||
|
"/apps", ...)` which served _HTML (the apps page) for / too, since
|
||||||
|
Caddy wasn't proxying / so nobody noticed. Now that Caddy does
|
||||||
|
proxy /, the two paths must serve different content."""
|
||||||
|
server, port = _start_server()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(_request(port, "/", cookie=admin_session)) as r:
|
||||||
|
root_body = r.read()
|
||||||
|
with urllib.request.urlopen(_request(port, "/apps", cookie=admin_session)) as r:
|
||||||
|
apps_body = r.read()
|
||||||
|
assert root_body != apps_body
|
||||||
|
assert b"Furtka Apps" in apps_body
|
||||||
|
assert b"landing page" in root_body
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
def test_get_login_renders_login_form_when_admin_exists(fake_dirs):
|
def test_get_login_renders_login_form_when_admin_exists(fake_dirs):
|
||||||
auth.create_admin("daniel", "hunter2-pw")
|
auth.create_admin("daniel", "hunter2-pw")
|
||||||
server, port = _start_server()
|
server, port = _start_server()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue