fix
This commit is contained in:
@@ -4,6 +4,11 @@ DOCKER_REGISTRY=docker.io
|
||||
DOCKER_IMAGE=myorg/myapp
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# Required for authentication
|
||||
SECRET_KEY=<random-32-byte-hex>
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=<strong-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)
|
||||
|
||||
1
.venv/bin/python
Symbolic link
1
.venv/bin/python
Symbolic link
@@ -0,0 +1 @@
|
||||
python3
|
||||
1
.venv/bin/python3
Symbolic link
1
.venv/bin/python3
Symbolic link
@@ -0,0 +1 @@
|
||||
/usr/bin/python3
|
||||
1
.venv/bin/python3.12
Symbolic link
1
.venv/bin/python3.12
Symbolic link
@@ -0,0 +1 @@
|
||||
python3
|
||||
1
.venv/lib64
Symbolic link
1
.venv/lib64
Symbolic link
@@ -0,0 +1 @@
|
||||
lib
|
||||
5
.venv/pyvenv.cfg
Normal file
5
.venv/pyvenv.cfg
Normal file
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
4
Makefile
Normal file
4
Makefile
Normal file
@@ -0,0 +1,4 @@
|
||||
.PHONY: audit
|
||||
audit:
|
||||
pip install pip-audit
|
||||
pip-audit
|
||||
25
README.md
25
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
|
||||
|
||||
|
||||
103
app/main.py
103
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/<int:service_id>", 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/<int:service_id>", 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/<int:service_id>")
|
||||
@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/<int:service_id>/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/<int:service_id>/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/<int:service_id>/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/<int:service_id>/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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
<header>
|
||||
<h1><a href="/">Status Monitor</a></h1>
|
||||
<span class="version">v{{ version }}</span>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="header-nav">
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('users') }}">Users</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('logout') }}">Logout</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
18
templates/login.html
Normal file
18
templates/login.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Status Monitor{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Login</h2>
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('login') }}" class="login-form">
|
||||
<input type="hidden" name="next" value="{{ request.args.get('next') or '' }}">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
41
templates/users.html
Normal file
41
templates/users.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Users - Status Monitor{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Users</h2>
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('users') }}" class="add-form">
|
||||
<input type="text" name="username" placeholder="Username" required>
|
||||
<input type="password" name="password" placeholder="Password" required>
|
||||
<button type="submit">Add User</button>
|
||||
</form>
|
||||
|
||||
<table class="services-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Admin</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{% if u.is_admin %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ u.created_at[:19] if u.created_at else '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3">No users.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><a href="{{ url_for('dashboard') }}">← Back to Dashboard</a></p>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user