diff --git a/.env.example b/.env.example index e89da91..32824d4 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ DOCKER_REGISTRY=docker.io DOCKER_IMAGE=myorg/myapp IMAGE_TAG=latest +# Required for authentication +SECRET_KEY= +ADMIN_USER=admin +ADMIN_PASSWORD= + # Optional: check retention (limits DB growth) # CHECK_RETENTION_COUNT=5000 # keep last N checks per service (default 5000) # CHECK_RETENTION_DAYS=30 # also delete checks older than N days (0=disabled) diff --git a/.venv/bin/python b/.venv/bin/python new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/.venv/bin/python @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/.venv/bin/python3 b/.venv/bin/python3 new file mode 120000 index 0000000..ae65fda --- /dev/null +++ b/.venv/bin/python3 @@ -0,0 +1 @@ +/usr/bin/python3 \ No newline at end of file diff --git a/.venv/bin/python3.12 b/.venv/bin/python3.12 new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/.venv/bin/python3.12 @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/.venv/lib64 b/.venv/lib64 new file mode 120000 index 0000000..7951405 --- /dev/null +++ b/.venv/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/.venv/pyvenv.cfg b/.venv/pyvenv.cfg new file mode 100644 index 0000000..a2ab69a --- /dev/null +++ b/.venv/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /usr/bin +include-system-site-packages = false +version = 3.12.3 +executable = /usr/bin/python3.12 +command = /usr/bin/python3 -m venv /home/ryanv/jenkins-docker-deploy-example/.venv diff --git a/Dockerfile b/Dockerfile index ba96581..d3cacc1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,12 @@ COPY app/ app/ COPY templates/ templates/ COPY static/ static/ +# Run as non-root user +RUN addgroup --system --gid 1000 appgroup && \ + adduser --system --uid 1000 --gid 1000 --no-create-home appuser +RUN chown -R appuser:appgroup /app +USER appuser + EXPOSE 8080 CMD ["python", "-u", "app.py"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..68101ce --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: audit +audit: + pip install pip-audit + pip-audit diff --git a/README.md b/README.md index b211548..02c80bb 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A Statping-like status monitoring app that demonstrates a Jenkins pipeline for D - **TCP checks** – Verify connectivity to host:port - **History storage** – SQLite database persists check results - **Reports** – Uptime %, avg/min/max latency, recent check history +- **Authentication** – Session-based login; multi-user with admin-managed accounts ## Repository Structure @@ -34,14 +35,21 @@ A Statping-like status monitoring app that demonstrates a Jenkins pipeline for D ## Manual Test ```bash -# Build and run locally +# Build and run locally (set SECRET_KEY and ADMIN_* for auth) docker build -t myapp:test . -docker run -p 8080:8080 -v $(pwd)/data:/app/data myapp:test -# Visit http://localhost:8080 +docker run -p 8080:8080 -v $(pwd)/data:/app/data \ + -e SECRET_KEY=dev-secret-change-in-production \ + -e ADMIN_USER=admin -e ADMIN_PASSWORD=changeme \ + myapp:test +# Visit http://localhost:8080 and log in ``` Add services from the dashboard (e.g. `https://example.com`, `google.com:443` for TCP) and view reports. +### Authentication + +The app uses session-based authentication. On first run, if `ADMIN_USER` and `ADMIN_PASSWORD` are set and no users exist, an admin user is created. Admins can add more users at `/users`. Set `SECRET_KEY` to a random value (e.g. 32-byte hex) for production. + ### Check Retention and Rollups To limit database growth, the app **rolls up** old checks into hourly aggregates, then prunes raw data: @@ -136,7 +144,16 @@ sudo usermod -aG docker ryanv If multiple users deploy to the same host, use separate paths (e.g. `/opt/myapp-alice`, `/opt/myapp-bob`) and update `docker-compose.yml` to use different ports for each app. -The `docker-compose.yml` mounts `./data:/app/data` for SQLite persistence. Ensure the deploy directory is writable. +The `docker-compose.yml` mounts `./data:/app/data` for SQLite persistence. The container runs as UID 1000. Ensure the data directory is writable: + +```bash +mkdir -p data +chown 1000:1000 data +``` + +### Dependency Audit + +Before deploying, run `make audit` (or `pip-audit`) to check for known vulnerabilities in dependencies. ### Branch Behavior diff --git a/app/main.py b/app/main.py index 9746eb8..8dbb633 100644 --- a/app/main.py +++ b/app/main.py @@ -3,10 +3,42 @@ import os from datetime import datetime, timedelta, timezone from flask import Flask, redirect, render_template, request, url_for +from flask_login import LoginManager, current_user, login_required, login_user, logout_user from app import models +def _is_safe_redirect_url(url: str | None) -> bool: + """Check that redirect URL is relative to our app (prevents open redirect).""" + if not url: + return False + return url.startswith("/") and "//" not in url + + +class User: + """Flask-Login compatible user wrapper.""" + + def __init__(self, user_dict: dict): + self.id = user_dict["id"] + self.username = user_dict["username"] + self.is_admin = bool(user_dict.get("is_admin", 0)) + + def get_id(self) -> str: + return str(self.id) + + @property + def is_authenticated(self) -> bool: + return True + + @property + def is_active(self) -> bool: + return True + + @property + def is_anonymous(self) -> bool: + return False + + def _parse_report_dates(from_ts, to_ts, preset): """Parse from/to dates, applying preset if given. Returns (from_ts, to_ts, from_display, to_display).""" now = datetime.now(timezone.utc) @@ -36,22 +68,86 @@ app = Flask( template_folder=os.path.join(ROOT, "templates"), static_folder=os.path.join(ROOT, "static"), ) +app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-production") VERSION = os.environ.get("VERSION", "dev") +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message = "Please log in to access this page." + + +@login_manager.user_loader +def load_user(user_id: str): + user_dict = models.get_user_by_id(int(user_id)) if user_id.isdigit() else None + return User(user_dict) if user_dict else None + + +@login_manager.unauthorized_handler +def unauthorized(): + if request.is_json or request.accept_mimetypes.best == "application/json": + return {"error": "Authentication required"}, 401 + return redirect(url_for("login", next=request.url)) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("dashboard")) + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = request.form.get("password") or "" + user_dict = models.verify_user(username, password) + if user_dict: + login_user(User(user_dict)) + next_param = request.form.get("next") or request.args.get("next") + next_url = next_param if _is_safe_redirect_url(next_param) else url_for("dashboard") + return redirect(next_url) + return render_template("login.html", error="Invalid username or password", version=VERSION) + return render_template("login.html", version=VERSION) + + +@app.route("/logout") +def logout(): + logout_user() + return redirect(url_for("login")) + + +@app.route("/users", methods=["GET", "POST"]) +@login_required +def users(): + if not current_user.is_admin: + return "Forbidden", 403 + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = request.form.get("password") or "" + user_id = models.create_user(username, password, is_admin=False) + if user_id: + return redirect(url_for("users")) + return render_template( + "users.html", + users=models.list_users(), + error="Username already exists or invalid", + version=VERSION, + ) + return render_template("users.html", users=models.list_users(), version=VERSION) + @app.route("/") +@login_required def dashboard(): services = models.list_services() return render_template("dashboard.html", services=services, version=VERSION) @app.route("/api/services", methods=["GET"]) +@login_required def api_list_services(): services = models.list_services() return {"services": services} @app.route("/api/services", methods=["POST"]) +@login_required def api_add_service(): data = request.get_json(silent=True) if data is None and request.form: @@ -77,6 +173,7 @@ def api_add_service(): @app.route("/api/services/", methods=["DELETE"]) +@login_required def api_delete_service(service_id): if models.delete_service(service_id): return {"deleted": service_id} @@ -84,6 +181,7 @@ def api_delete_service(service_id): @app.route("/api/services/", methods=["PATCH"]) +@login_required def api_update_service(service_id): svc = models.get_service(service_id) if not svc: @@ -116,6 +214,7 @@ def api_update_service(service_id): @app.route("/api/services/") +@login_required def api_get_service(service_id): svc = models.get_service(service_id) if not svc: @@ -125,6 +224,7 @@ def api_get_service(service_id): @app.route("/api/services//edit") +@login_required def edit_service(service_id): svc = models.get_service(service_id) if not svc: @@ -133,6 +233,7 @@ def edit_service(service_id): @app.route("/api/services//report") +@login_required def report(service_id): svc = models.get_service(service_id) if not svc: @@ -191,6 +292,7 @@ def _format_period_label(from_display, to_display): @app.route("/api/services//history") +@login_required def api_history(service_id): svc = models.get_service(service_id) if not svc: @@ -207,6 +309,7 @@ def api_history(service_id): @app.route("/api/services//stats") +@login_required def api_report_stats(service_id): """JSON report stats with optional from/to query params for date range.""" svc = models.get_service(service_id) diff --git a/app/models.py b/app/models.py index 5a61b70..655caac 100644 --- a/app/models.py +++ b/app/models.py @@ -5,6 +5,8 @@ from contextlib import contextmanager from datetime import datetime, timedelta, timezone from pathlib import Path +from werkzeug.security import check_password_hash, generate_password_hash + DATA_PATH = os.environ.get("DATA_PATH", "/app/data") DB_PATH = Path(DATA_PATH) / "monitor.db" @@ -55,6 +57,84 @@ def _migrate_add_rollups(conn): conn.execute("ALTER TABLE uptime_rollups ADD COLUMN response_count INTEGER NOT NULL DEFAULT 0") +def _migrate_add_users(conn): + """Create users table for authentication.""" + conn.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)") + + +def _seed_admin_if_empty(conn): + """Create initial admin user from env if no users exist.""" + row = conn.execute("SELECT COUNT(*) FROM users").fetchone() + if row[0] > 0: + return + admin_user = os.environ.get("ADMIN_USER") + admin_password = os.environ.get("ADMIN_PASSWORD") + if not admin_user or not admin_password: + return + password_hash = generate_password_hash(admin_password) + conn.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?, ?, 1, ?)", + (admin_user, password_hash, datetime.utcnow().isoformat()), + ) + + +def create_user(username: str, password: str, is_admin: bool = False) -> int | None: + """Create a new user. Returns user id or None if username exists.""" + username = username.strip() + if not username or not password: + return None + password_hash = generate_password_hash(password) + with get_db() as conn: + try: + cur = conn.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?, ?, ?, ?)", + (username, password_hash, 1 if is_admin else 0, datetime.utcnow().isoformat()), + ) + return cur.lastrowid + except sqlite3.IntegrityError: + return None + + +def get_user_by_id(user_id: int) -> dict | None: + """Get a user by id.""" + with get_db() as conn: + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return dict(row) if row else None + + +def get_user_by_username(username: str) -> dict | None: + """Get a user by username.""" + with get_db() as conn: + row = conn.execute("SELECT * FROM users WHERE username = ?", (username.strip(),)).fetchone() + return dict(row) if row else None + + +def verify_user(username: str, password: str) -> dict | None: + """Verify credentials and return user dict if valid.""" + user = get_user_by_username(username) + if not user or not check_password_hash(user["password_hash"], password): + return None + return user + + +def list_users(): + """Return all users (id, username, is_admin, created_at).""" + with get_db() as conn: + rows = conn.execute( + "SELECT id, username, is_admin, created_at FROM users ORDER BY username" + ).fetchall() + return [dict(r) for r in rows] + + @contextmanager def get_db(): _ensure_data_dir() @@ -97,6 +177,8 @@ def init_db(): _migrate_add_status(conn) conn.execute("CREATE INDEX IF NOT EXISTS idx_checks_status ON checks(status)") _migrate_add_rollups(conn) + _migrate_add_users(conn) + _seed_admin_if_empty(conn) def list_services(): diff --git a/docker-compose.yml b/docker-compose.yml index 0c619ca..9d1a7fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,11 +4,15 @@ services: app: image: ${DOCKER_REGISTRY:-docker.io}/${DOCKER_IMAGE:-myapp}:${IMAGE_TAG:-latest} container_name: jenkins-deploy-app + user: "1000:1000" ports: - "8080:8080" volumes: - ./data:/app/data environment: - VERSION=${IMAGE_TAG:-latest} + - SECRET_KEY=${SECRET_KEY} + - ADMIN_USER=${ADMIN_USER} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} # Optional: CHECK_RETENTION_COUNT=5000, CHECK_RETENTION_DAYS=30 restart: unless-stopped diff --git a/requirements.txt b/requirements.txt index 81a76be..d6e5fc0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -flask>=3.0 -requests>=2.31 -apscheduler>=3.10 +flask>=3.0,<4 +requests>=2.31,<3 +apscheduler>=3.10,<4 +flask-login>=0.6.3,<1 diff --git a/static/style.css b/static/style.css index b1f3ab1..20fb038 100644 --- a/static/style.css +++ b/static/style.css @@ -56,6 +56,48 @@ header h1 a:hover { font-size: 0.875rem; } +.header-nav { + display: flex; + gap: 1rem; + margin-left: auto; +} + +.header-nav a { + font-size: 0.875rem; +} + +.error { + color: var(--down); + margin-bottom: 1rem; +} + +.login-form { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 300px; + padding: 1rem; + background: var(--surface); + border-radius: 6px; +} + +.login-form input { + padding: 0.5rem 0.75rem; + border: 1px solid var(--muted); + border-radius: 4px; + background: var(--bg); + color: var(--text); +} + +.login-form button { + padding: 0.5rem 1rem; + background: var(--accent); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + main { max-width: 900px; width: 100%; diff --git a/templates/base.html b/templates/base.html index e8735a3..61a8894 100644 --- a/templates/base.html +++ b/templates/base.html @@ -10,6 +10,14 @@

Status Monitor

v{{ version }} + {% if current_user.is_authenticated %} + + {% endif %}
{% block content %}{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..862a2b5 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block title %}Login - Status Monitor{% endblock %} + +{% block content %} +

Login

+{% if error %} +

{{ error }}

+{% endif %} + +{% endblock %} diff --git a/templates/users.html b/templates/users.html new file mode 100644 index 0000000..5fbb7be --- /dev/null +++ b/templates/users.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block title %}Users - Status Monitor{% endblock %} + +{% block content %} +

Users

+{% if error %} +

{{ error }}

+{% endif %} + +
+ + + +
+ + + + + + + + + + + {% for u in users %} + + + + + + {% else %} + + + + {% endfor %} + +
UsernameAdminCreated
{{ u.username }}{% if u.is_admin %}Yes{% else %}No{% endif %}{{ u.created_at[:19] if u.created_at else '-' }}
No users.
+ +

← Back to Dashboard

+{% endblock %}