Skip to content

Commit 620bd6a

Browse files
itsDNNSclaude
andcommitted
Add optional web UI authentication and CONTRIBUTING.md
- Session-based login with configurable admin password - All routes except /health and /setup protected when enabled - Admin password encrypted at rest, configurable in Settings - Logout link in sidebar, login page with i18n (EN/DE) - CONTRIBUTING.md with dev setup, guidelines, modem driver docs - 82 tests (12 new auth tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1130689 commit 620bd6a

File tree

10 files changed

+375
-3
lines changed

10 files changed

+375
-3
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ POLL_INTERVAL=300
1919
WEB_PORT=8765
2020
HISTORY_DAYS=7
2121
DATA_DIR=/data
22+
23+
# Authentication (optional - leave empty to disable)
24+
ADMIN_PASSWORD=

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44

55
Versioning: `YYYY-MM-DD.N` (date + sequential build number per day)
66

7+
## [2026-02-09.13]
8+
9+
### Added
10+
- **Web UI authentication**: Optional admin password protects all routes except `/health`; configurable in Settings
11+
- **Login page**: Clean login form with i18n support (EN/DE)
12+
- **Logout**: Session-based auth with logout link in sidebar
13+
- **CONTRIBUTING.md**: Guidelines for contributors and modem driver development
14+
715
## [2026-02-09.12]
816

917
### Added

CONTRIBUTING.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Contributing to DOCSight
2+
3+
Thanks for your interest in contributing!
4+
5+
## Development Setup
6+
7+
```bash
8+
git clone https://github.com/itsDNNS/docsight.git
9+
cd docsight
10+
pip install -r requirements.txt
11+
pip install pytest
12+
```
13+
14+
## Running Tests
15+
16+
```bash
17+
python -m pytest tests/ -v
18+
```
19+
20+
## Running Locally
21+
22+
```bash
23+
python -m app.main
24+
```
25+
26+
Open `http://localhost:8765` to access the setup wizard.
27+
28+
## Project Structure
29+
30+
```
31+
app/
32+
main.py - Entrypoint, polling loop, thread management
33+
web.py - Flask routes and API endpoints
34+
analyzer.py - DOCSIS channel health analysis
35+
fritzbox.py - FritzBox data.lua API client
36+
config.py - Configuration management (env + config.json)
37+
storage.py - SQLite snapshot storage
38+
mqtt_publisher.py - MQTT Auto-Discovery for Home Assistant
39+
i18n.py - Translation strings (EN/DE)
40+
templates/ - Jinja2 HTML templates
41+
tests/ - pytest test suite
42+
```
43+
44+
## Guidelines
45+
46+
- Keep changes focused and minimal
47+
- Add tests for new functionality
48+
- Maintain English and German translations in `app/i18n.py`
49+
- CHANGELOG entries must be in English
50+
- Run the full test suite before submitting a PR
51+
52+
## Adding Modem Support
53+
54+
DOCSight currently supports AVM FRITZ!Box Cable routers. To add support for another modem:
55+
56+
1. Create a new module in `app/` (e.g., `app/arris.py`)
57+
2. Implement `login()`, `get_docsis_data()`, and `get_device_info()` matching the FritzBox API
58+
3. Return data in the same format as `fritzbox.get_docsis_data()` so the analyzer works unchanged
59+
4. Update `main.py` to select the modem driver based on configuration

app/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
POLL_MIN = 60
1313
POLL_MAX = 3600
1414

15-
SECRET_KEYS = {"fritz_password", "mqtt_password"}
15+
SECRET_KEYS = {"fritz_password", "mqtt_password", "admin_password"}
1616
PASSWORD_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
1717

1818
DEFAULTS = {
@@ -31,6 +31,7 @@
3131
"theme": "dark",
3232
"language": "en",
3333
"isp_name": "",
34+
"admin_password": "",
3435
}
3536

3637
ENV_MAP = {
@@ -46,6 +47,7 @@
4647
"web_port": "WEB_PORT",
4748
"history_days": "HISTORY_DAYS",
4849
"data_dir": "DATA_DIR",
50+
"admin_password": "ADMIN_PASSWORD",
4951
}
5052

5153
INT_KEYS = {"mqtt_port", "poll_interval", "web_port", "history_days"}

app/i18n.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@
163163
"copied": "Copied!",
164164
"close": "Close",
165165
"export_no_data": "No data available yet. Wait for the first poll.",
166+
167+
# Auth
168+
"login_title": "Login",
169+
"login_hint": "Enter the admin password to access DOCSight.",
170+
"login_failed": "Invalid password",
171+
"login_button": "Login",
172+
"logout": "Logout",
173+
"admin_password": "Admin Password",
174+
"admin_password_hint": "Leave empty to disable authentication",
166175
}
167176

168177
DE = {
@@ -310,6 +319,14 @@
310319
"copied": "Kopiert!",
311320
"close": "Schliessen",
312321
"export_no_data": "Noch keine Daten vorhanden. Warte auf die erste Abfrage.",
322+
323+
"login_title": "Anmeldung",
324+
"login_hint": "Gib das Admin-Passwort ein, um auf DOCSight zuzugreifen.",
325+
"login_failed": "Falsches Passwort",
326+
"login_button": "Anmelden",
327+
"logout": "Abmelden",
328+
"admin_password": "Admin-Passwort",
329+
"admin_password_hint": "Leer lassen um Authentifizierung zu deaktivieren",
313330
}
314331

315332
_TRANSLATIONS = {"en": EN, "de": DE}

app/templates/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,11 @@
364364
<a class="sidebar-link" id="export-link" onclick="exportForLLM()">
365365
<span class="icon">&#128196;</span> {{ t.export_llm }}
366366
</a>
367+
{% if auth_enabled %}
368+
<a class="sidebar-link" href="/logout">
369+
<span class="icon">&#128682;</span> {{ t.logout }}
370+
</a>
371+
{% endif %}
367372
<div class="sidebar-divider"></div>
368373
<details class="sidebar-ref">
369374
<summary>{{ t.reference_values }}</summary>

app/templates/login.html

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<!DOCTYPE html>
2+
<html lang="{{ lang }}" data-theme="{{ theme|default('dark') }}">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>DOCSight - {{ t.login_title }}</title>
7+
<style>
8+
:root, [data-theme="dark"] {
9+
--bg: #1a1a2e;
10+
--surface: #16213e;
11+
--card: #0f3460;
12+
--text: #e0e0e0;
13+
--muted: #888;
14+
--crit: #f44336;
15+
--accent: #00adb5;
16+
--input-bg: #0d1b3e;
17+
--input-border: rgba(255,255,255,0.15);
18+
}
19+
[data-theme="light"] {
20+
--bg: #f0f2f5;
21+
--surface: #ffffff;
22+
--card: #e8edf2;
23+
--text: #1a1a2e;
24+
--muted: #666;
25+
--crit: #c62828;
26+
--accent: #00838f;
27+
--input-bg: #f8f9fa;
28+
--input-border: rgba(0,0,0,0.15);
29+
}
30+
* { margin: 0; padding: 0; box-sizing: border-box; }
31+
body {
32+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
33+
background: var(--bg);
34+
color: var(--text);
35+
min-height: 100vh;
36+
display: flex;
37+
align-items: center;
38+
justify-content: center;
39+
}
40+
.login-box {
41+
background: var(--surface);
42+
border-radius: 12px;
43+
padding: 2.5rem;
44+
width: 100%;
45+
max-width: 380px;
46+
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
47+
}
48+
.login-box h1 {
49+
font-size: 1.5rem;
50+
margin-bottom: 0.25rem;
51+
}
52+
.login-box .hint {
53+
color: var(--muted);
54+
font-size: 0.85rem;
55+
margin-bottom: 1.5rem;
56+
}
57+
.login-box .error {
58+
background: rgba(244,67,54,0.15);
59+
color: var(--crit);
60+
padding: 0.5rem 0.75rem;
61+
border-radius: 6px;
62+
font-size: 0.85rem;
63+
margin-bottom: 1rem;
64+
}
65+
.login-box input[type="password"] {
66+
width: 100%;
67+
padding: 0.65rem 0.75rem;
68+
border-radius: 6px;
69+
border: 1px solid var(--input-border);
70+
background: var(--input-bg);
71+
color: var(--text);
72+
font-size: 1rem;
73+
margin-bottom: 1rem;
74+
}
75+
.login-box input:focus {
76+
outline: none;
77+
border-color: var(--accent);
78+
}
79+
.login-box button {
80+
width: 100%;
81+
padding: 0.65rem;
82+
border-radius: 6px;
83+
border: none;
84+
background: var(--accent);
85+
color: #fff;
86+
font-size: 1rem;
87+
font-weight: 600;
88+
cursor: pointer;
89+
}
90+
.login-box button:hover {
91+
opacity: 0.9;
92+
}
93+
</style>
94+
</head>
95+
<body>
96+
<div class="login-box">
97+
<h1>DOCSight</h1>
98+
<p class="hint">{{ t.login_hint }}</p>
99+
{% if error %}
100+
<div class="error">{{ error }}</div>
101+
{% endif %}
102+
<form method="POST">
103+
<input type="password" name="password" placeholder="{{ t.password }}" autofocus required>
104+
<button type="submit">{{ t.login_button }}</button>
105+
</form>
106+
</div>
107+
</body>
108+
</html>

app/templates/settings.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,11 @@ <h2>{{ t.general }}</h2>
246246
{% endfor %}
247247
</select>
248248
</div>
249+
<div class="form-row">
250+
<label for="admin_password">{{ t.admin_password }}</label>
251+
<input type="password" id="admin_password" name="admin_password" value="{{ config.admin_password }}" placeholder="{{ t.admin_password_hint }}">
252+
<span class="hint">{{ t.admin_password_hint }}</span>
253+
</div>
249254
</div>
250255
</div>
251256

@@ -286,7 +291,7 @@ <h2>{{ t.general }}</h2>
286291
}
287292

288293
var MASK = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022';
289-
var SECRET_FIELDS = ['fritz_password', 'mqtt_password'];
294+
var SECRET_FIELDS = ['fritz_password', 'mqtt_password', 'admin_password'];
290295

291296
function getFormData() {
292297
var form = document.getElementById('settings-form');

app/web.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
"""Flask web UI for DOCSight – DOCSIS channel monitoring."""
22

3+
import functools
34
import logging
5+
import os
46
import time
57
from datetime import datetime, timedelta
68

7-
from flask import Flask, render_template, request, jsonify, redirect
9+
from flask import Flask, render_template, request, jsonify, redirect, session, url_for
810

911
from .config import POLL_MIN, POLL_MAX, PASSWORD_MASK, SECRET_KEYS
1012
from .i18n import get_translations, LANGUAGES
1113

1214
log = logging.getLogger("docsis.web")
1315

1416
app = Flask(__name__, template_folder="templates")
17+
app.secret_key = os.environ.get("FLASK_SECRET_KEY", os.urandom(32))
1518

1619

1720
@app.template_filter("fmt_k")
@@ -67,6 +70,56 @@ def init_config(config_manager, on_config_changed=None):
6770
_on_config_changed = on_config_changed
6871

6972

73+
def _auth_required():
74+
"""Check if auth is enabled and user is not logged in."""
75+
if not _config_manager:
76+
return False
77+
admin_pw = _config_manager.get("admin_password", "")
78+
if not admin_pw:
79+
return False
80+
return not session.get("authenticated")
81+
82+
83+
def require_auth(f):
84+
"""Decorator: redirect to /login if auth is enabled and not logged in."""
85+
@functools.wraps(f)
86+
def decorated(*args, **kwargs):
87+
if _auth_required():
88+
return redirect("/login")
89+
return f(*args, **kwargs)
90+
return decorated
91+
92+
93+
@app.route("/login", methods=["GET", "POST"])
94+
def login():
95+
if not _config_manager or not _config_manager.get("admin_password", ""):
96+
return redirect("/")
97+
lang = _get_lang()
98+
t = get_translations(lang)
99+
theme = _config_manager.get_theme() if _config_manager else "dark"
100+
error = None
101+
if request.method == "POST":
102+
pw = request.form.get("password", "")
103+
if pw == _config_manager.get("admin_password", ""):
104+
session["authenticated"] = True
105+
return redirect("/")
106+
error = t.get("login_failed", "Invalid password")
107+
return render_template("login.html", t=t, lang=lang, theme=theme, error=error)
108+
109+
110+
@app.route("/logout")
111+
def logout():
112+
session.pop("authenticated", None)
113+
return redirect("/login")
114+
115+
116+
@app.context_processor
117+
def inject_auth():
118+
"""Make auth_enabled available in all templates."""
119+
auth_enabled = bool(_config_manager and _config_manager.get("admin_password", ""))
120+
return {"auth_enabled": auth_enabled}
121+
122+
70123
def update_state(analysis=None, error=None, poll_interval=None, connection_info=None):
71124
"""Update the shared web state from the main loop."""
72125
if analysis is not None:
@@ -82,6 +135,7 @@ def update_state(analysis=None, error=None, poll_interval=None, connection_info=
82135

83136

84137
@app.route("/")
138+
@require_auth
85139
def index():
86140
if _config_manager and not _config_manager.is_configured():
87141
return redirect("/setup")
@@ -134,6 +188,7 @@ def setup():
134188

135189

136190
@app.route("/settings")
191+
@require_auth
137192
def settings():
138193
config = _config_manager.get_all(mask_secrets=True) if _config_manager else {}
139194
theme = _config_manager.get_theme() if _config_manager else "dark"
@@ -143,6 +198,7 @@ def settings():
143198

144199

145200
@app.route("/api/config", methods=["POST"])
201+
@require_auth
146202
def api_config():
147203
"""Save configuration."""
148204
if not _config_manager:
@@ -168,6 +224,7 @@ def api_config():
168224

169225

170226
@app.route("/api/test-fritz", methods=["POST"])
227+
@require_auth
171228
def api_test_fritz():
172229
"""Test FritzBox connection."""
173230
try:
@@ -189,6 +246,7 @@ def api_test_fritz():
189246

190247

191248
@app.route("/api/test-mqtt", methods=["POST"])
249+
@require_auth
192250
def api_test_mqtt():
193251
"""Test MQTT broker connection."""
194252
try:

0 commit comments

Comments
 (0)