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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user