Add HTTP Basic Auth to textual-serve web dashboard

Browser prompts for username/password before showing the TUI.
Credentials from gniza.conf: WEB_USER (default: admin) + WEB_API_KEY.

- Monkey-patches textual-serve's aiohttp app with auth middleware
- Uses secrets.compare_digest for timing-safe comparison
- Install script generates credentials and prints them
- Skips auth if no WEB_API_KEY configured

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-06 06:14:51 +02:00
parent fa7eb14369
commit 1800babbc2
2 changed files with 78 additions and 5 deletions

View File

@@ -163,7 +163,10 @@ done
enable_web="n"
read -rp "Enable web dashboard (TUI in browser)? (y/n) [n]: " enable_web </dev/tty || true
if [ "$enable_web" = "y" ] || [ "$enable_web" = "Y" ]; then
# Generate API key if not already set
# Set up web credentials
if ! grep -q "^WEB_USER=" "$CONFIG_DIR/gniza.conf" 2>/dev/null; then
echo 'WEB_USER="admin"' >> "$CONFIG_DIR/gniza.conf"
fi
if ! grep -q "^WEB_API_KEY=" "$CONFIG_DIR/gniza.conf" 2>/dev/null || \
[ -z "$(grep '^WEB_API_KEY=' "$CONFIG_DIR/gniza.conf" 2>/dev/null | sed 's/^WEB_API_KEY="//' | sed 's/"$//')" ]; then
api_key="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
@@ -172,10 +175,10 @@ if [ "$enable_web" = "y" ] || [ "$enable_web" = "Y" ]; then
else
echo "WEB_API_KEY=\"${api_key}\"" >> "$CONFIG_DIR/gniza.conf"
fi
info "Generated Web API key: $api_key"
echo "Save this key -- you will need it to access the dashboard."
info "Web credentials: user=admin password=$api_key"
echo "Save these -- you will need them to access the dashboard."
else
info "Web API key already configured."
info "Web credentials already configured."
fi
# Install systemd service
if [ "$MODE" = "root" ]; then

View File

@@ -1,4 +1,7 @@
import base64
import os
import re
import secrets
import socket
import subprocess
import sys
@@ -57,11 +60,43 @@ def _parse_web_args():
return host, port
def _load_web_credentials() -> tuple[str, str]:
"""Load WEB_USER and WEB_API_KEY from gniza.conf."""
config_dir = os.environ.get("GNIZA_CONFIG_DIR", "")
if not config_dir:
# Detect from mode
if os.geteuid() == 0:
config_dir = "/etc/gniza"
else:
config_dir = os.path.join(
os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
"gniza",
)
conf_path = Path(config_dir) / "gniza.conf"
user = "admin"
api_key = ""
if conf_path.is_file():
kv_re = re.compile(r'^([A-Z_][A-Z0-9_]*)="(.*)"$')
for line in conf_path.read_text().splitlines():
m = kv_re.match(line.strip())
if m:
if m.group(1) == "WEB_USER":
user = m.group(2)
elif m.group(1) == "WEB_API_KEY":
api_key = m.group(2)
return user, api_key
def main():
if "--web" in sys.argv:
from aiohttp import web as aio_web
from textual_serve.server import Server
host, port = _parse_web_args()
web_user, web_key = _load_web_credentials()
if not web_key:
print("WARNING: No WEB_API_KEY in gniza.conf. Web dashboard is unprotected.")
os.environ["PYTHONPATH"] = f"{_ROOT}:{os.environ.get('PYTHONPATH', '')}"
os.environ["GNIZA_DIR"] = _ROOT
@@ -74,14 +109,49 @@ def main():
public_url = f"http://{public_host}:{port}"
print(f"GNIZA web: serving TUI at {public_url}")
if web_key:
print(f"GNIZA web: login with user={web_user!r}")
server = Server(
f"python3 -m tui",
"python3 -m tui",
host=host,
port=port,
title="GNIZA Backup",
public_url=public_url,
)
# Add HTTP Basic Auth middleware if API key is configured
if web_key:
_orig_make_app = server._make_app
async def _authed_make_app():
app = await _orig_make_app()
@aio_web.middleware
async def basic_auth(request, handler):
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Basic "):
try:
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
req_user, req_pass = decoded.split(":", 1)
if (
secrets.compare_digest(req_user, web_user)
and secrets.compare_digest(req_pass, web_key)
):
return await handler(request)
except Exception:
pass
return aio_web.Response(
status=401,
headers={"WWW-Authenticate": 'Basic realm="GNIZA"'},
text="Authentication required",
)
app.middlewares.insert(0, basic_auth)
return app
server._make_app = _authed_make_app
server.serve()
else:
app = GnizaApp()