From e8f6c4c6095deaed4049a7cb0ebfebcf0c33d37b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 5 Mar 2026 15:53:33 +0100 Subject: [PATCH] push --- .dockerignore | 15 + .gitignore | 20 ++ Dockerfile | 19 ++ README.md | 94 ++++++ app.py | 10 + deploy/nginx.conf | 24 ++ docker-compose.yml | 42 +++ docker/entrypoint.sh | 32 +++ migrations/README | 1 + migrations/alembic.ini | 2 + migrations/env.py | 62 ++++ migrations/script.py.mako | 0 migrations/versions/0001_initial.py | 177 ++++++++++++ mikromon/__init__.py | 136 +++++++++ mikromon/authn.py | 10 + mikromon/blueprints/admin.py | 59 ++++ mikromon/blueprints/api.py | 268 ++++++++++++++++++ mikromon/blueprints/auth.py | 133 +++++++++ mikromon/blueprints/dashboards.py | 282 ++++++++++++++++++ mikromon/blueprints/devices.py | 184 ++++++++++++ mikromon/blueprints/realtime.py | 107 +++++++ mikromon/cli.py | 43 +++ mikromon/config.py | 114 ++++++++ mikromon/connectors/base.py | 21 ++ mikromon/connectors/registry.py | 13 + mikromon/connectors/routeros_rest.py | 54 ++++ mikromon/connectors/routeros_ssh.py | 60 ++++ mikromon/errors.py | 24 ++ mikromon/forms.py | 63 ++++ mikromon/models.py | 163 +++++++++++ mikromon/presets/widget_presets.json | 149 ++++++++++ mikromon/security/crypto.py | 37 +++ mikromon/security/passwords.py | 13 + mikromon/services/__init__.py | 1 + mikromon/services/acl.py | 16 ++ mikromon/services/audit.py | 21 ++ mikromon/services/bootstrap.py | 29 ++ mikromon/services/mail.py | 39 +++ mikromon/services/poller.py | 394 ++++++++++++++++++++++++++ mikromon/services/presets.py | 6 + mikromon/template_helpers.py | 34 +++ mikromon/templates | 1 + mikromon/utils/static.py | 39 +++ requirements.txt | 23 ++ scripts/set_admin_password.py | 30 ++ static/css/app.css | 10 + static/js/app.js | 204 +++++++++++++ templates/admin/audit.html | 40 +++ templates/admin/index.html | 42 +++ templates/admin/smtp.html | 44 +++ templates/admin/users.html | 44 +++ templates/api/docs.html | 40 +++ templates/auth/forgot.html | 25 ++ templates/auth/login.html | 29 ++ templates/auth/register.html | 28 ++ templates/auth/reset.html | 26 ++ templates/base.html | 101 +++++++ templates/dashboards/index.html | 29 ++ templates/dashboards/new.html | 28 ++ templates/dashboards/public_view.html | 69 +++++ templates/dashboards/share.html | 65 +++++ templates/dashboards/view.html | 48 ++++ templates/dashboards/widget_new.html | 156 ++++++++++ templates/devices/edit.html | 87 ++++++ templates/devices/index.html | 32 +++ templates/devices/new.html | 87 ++++++ templates/devices/view.html | 78 +++++ templates/emails/reset_password.html | 10 + templates/emails/smtp_test.html | 8 + templates/error.html | 29 ++ tests/conftest.py | 19 ++ tests/test_acl_api.py | 27 ++ tests/test_auth.py | 11 + wsgi.py | 2 + 74 files changed, 4482 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 deploy/nginx.conf create mode 100644 docker-compose.yml create mode 100755 docker/entrypoint.sh create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/0001_initial.py create mode 100644 mikromon/__init__.py create mode 100644 mikromon/authn.py create mode 100644 mikromon/blueprints/admin.py create mode 100644 mikromon/blueprints/api.py create mode 100644 mikromon/blueprints/auth.py create mode 100644 mikromon/blueprints/dashboards.py create mode 100644 mikromon/blueprints/devices.py create mode 100644 mikromon/blueprints/realtime.py create mode 100644 mikromon/cli.py create mode 100644 mikromon/config.py create mode 100644 mikromon/connectors/base.py create mode 100644 mikromon/connectors/registry.py create mode 100644 mikromon/connectors/routeros_rest.py create mode 100644 mikromon/connectors/routeros_ssh.py create mode 100644 mikromon/errors.py create mode 100644 mikromon/forms.py create mode 100644 mikromon/models.py create mode 100644 mikromon/presets/widget_presets.json create mode 100644 mikromon/security/crypto.py create mode 100644 mikromon/security/passwords.py create mode 100644 mikromon/services/__init__.py create mode 100644 mikromon/services/acl.py create mode 100644 mikromon/services/audit.py create mode 100644 mikromon/services/bootstrap.py create mode 100644 mikromon/services/mail.py create mode 100644 mikromon/services/poller.py create mode 100644 mikromon/services/presets.py create mode 100644 mikromon/template_helpers.py create mode 120000 mikromon/templates create mode 100644 mikromon/utils/static.py create mode 100644 requirements.txt create mode 100644 scripts/set_admin_password.py create mode 100644 static/css/app.css create mode 100644 static/js/app.js create mode 100644 templates/admin/audit.html create mode 100644 templates/admin/index.html create mode 100644 templates/admin/smtp.html create mode 100644 templates/admin/users.html create mode 100644 templates/api/docs.html create mode 100644 templates/auth/forgot.html create mode 100644 templates/auth/login.html create mode 100644 templates/auth/register.html create mode 100644 templates/auth/reset.html create mode 100644 templates/base.html create mode 100644 templates/dashboards/index.html create mode 100644 templates/dashboards/new.html create mode 100644 templates/dashboards/public_view.html create mode 100644 templates/dashboards/share.html create mode 100644 templates/dashboards/view.html create mode 100644 templates/dashboards/widget_new.html create mode 100644 templates/devices/edit.html create mode 100644 templates/devices/index.html create mode 100644 templates/devices/new.html create mode 100644 templates/devices/view.html create mode 100644 templates/emails/reset_password.html create mode 100644 templates/emails/smtp_test.html create mode 100644 templates/error.html create mode 100644 tests/conftest.py create mode 100644 tests/test_acl_api.py create mode 100644 tests/test_auth.py create mode 100644 wsgi.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..769d304 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.pytest_cache +.mypy_cache +.ruff_cache +instance +*.db +*.zip +dist +build +node_modules diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e5df52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +instance/ +*.db + +.env +.env.* + +dist/ +build/ +*.egg-info/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5af7d85 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV FLASK_APP=app.py +RUN mkdir -p instance && chmod +x docker/entrypoint.sh + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python -c "import requests; requests.get('http://127.0.0.1:5000/realtime/health', timeout=3).raise_for_status()" + +CMD ["docker/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a499f21 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# MikroMon - MikroTik RouterOS Realtime Monitoring (Flask MVP) + +Minimalist, dark-themed web UI + full JSON API + realtime charts via Socket.IO. + +## Features +- Multi-user accounts (Argon2 password hashing) +- Devices: MikroTik RouterOS REST + optional SSH (paramiko) +- Dashboards with widgets (presets + configurable) +- Realtime streaming (polling workers -> Socket.IO rooms) +- Sharing: per-user ACL (view/edit/manage) + public read-only links +- Admin panel (master role) +- SMTP password reset (token + TTL) + admin SMTP test +- Audit log +- Server-side sessions (DB) +- CSRF (forms), rate-limiting for auth endpoints +- Static cache-busting with MD5 hash query param and long cache headers + +## Quick start (dev, no Docker) +### 1) Create venv + install +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +### 2) Configure env +Copy `.env.example` -> `.env` and edit. + +### 3) Init DB (SQLite by default) +```bash +export FLASK_APP=app.py +flask db upgrade +``` + +### 4) Run +```bash +python app.py +``` +Open: http://127.0.0.1:5000 + +Default admin: +- email: `admin@example.com` +- password: `Admin123!` (change immediately) + +## Realtime +- Browser joins rooms per dashboard/device +- Poller runs in-process by default (APScheduler) for dev. +- Production: use Redis + RQ worker (see Docker). + +## API +- JSON API lives under `/api/v1/...` +- API explorer: `/api/docs` (lists endpoints + basic try-it) +- Auth: session cookie (same as UI) + optional API token (personal token) can be added later. + +## Insecure TLS (self-signed) +Per-device `allow_insecure_tls` flag allows `verify=False` for REST. +UI shows a warning. Use only if you understand the risk. + +## Credentials encryption +Device credentials are encrypted using Fernet symmetric encryption. +Key comes from `CRED_ENC_KEY` env (base64). +### Rotate key +1) Set `CRED_ENC_KEY_OLD` to old key, `CRED_ENC_KEY` to new key. +2) Run: +```bash +flask devices rotate-cred-key +``` +3) Remove `CRED_ENC_KEY_OLD`. + +## Reset admin password (CLI) +### Option A (Flask CLI) +```bash +flask users set-password admin@example.com "NewStrongPassword123!" +``` +### Option B (script) +```bash +python scripts/set_admin_password.py admin@example.com "NewStrongPassword123!" +``` + +## Tests +```bash +pytest -q +``` + +## Docker +See `docker-compose.yml`. It can run app + Postgres + Redis + RQ worker. + +## Production notes +- Put behind HTTPS reverse proxy (nginx/Traefik/Caddy) +- Use Postgres/MySQL for multi-instance +- Run workers separately (RQ/Celery) + Redis +- Set `SECRET_KEY`, `SESSION_COOKIE_SECURE=1`, `PREFERRED_URL_SCHEME=https` +- Configure rate limits and global per-user limits + diff --git a/app.py b/app.py new file mode 100644 index 0000000..d7b2059 --- /dev/null +++ b/app.py @@ -0,0 +1,10 @@ +import os +from mikromon import create_app, socketio + +app = create_app() + +if __name__ == "__main__": + host = os.getenv("HOST", "127.0.0.1") + port = int(os.getenv("PORT", "5000")) + debug = os.getenv("FLASK_ENV", "development") == "development" + socketio.run(app, host=host, port=port, debug=debug) diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..5ec385d --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name _; + + location /static/ { + alias /app/static/; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + location /socket.io/ { + proxy_pass http://app:5000/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + location / { + proxy_pass http://app:5000/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2923341 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + app: + build: . + environment: + FLASK_ENV: production + HOST: 0.0.0.0 + PORT: 5000 + DATABASE_URL: postgresql+psycopg://mikromon:mikromon@db:5432/mikromon + SOCKETIO_MESSAGE_QUEUE: redis://redis:6379/0 + REDIS_URL: redis://redis:6379/0 + SECRET_KEY: change-me + CRED_ENC_KEY: REPLACE_WITH_FERNET_KEY + ports: + - "5000:5000" + depends_on: + - db + - redis + worker: + build: . + command: ["rq", "worker", "--with-scheduler", "-u", "redis://redis:6379/0"] + environment: + DATABASE_URL: postgresql+psycopg://mikromon:mikromon@db:5432/mikromon + REDIS_URL: redis://redis:6379/0 + SECRET_KEY: change-me + CRED_ENC_KEY: REPLACE_WITH_FERNET_KEY + DEV_INPROCESS_POLLER: "0" + SOCKETIO_MESSAGE_QUEUE: redis://redis:6379/0 + depends_on: + - db + - redis + db: + image: postgres:16 + environment: + POSTGRES_USER: mikromon + POSTGRES_PASSWORD: mikromon + POSTGRES_DB: mikromon + ports: + - "5432:5432" + redis: + image: redis:7-alpine + ports: + - "6379:6379" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..490d237 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p instance + +export FLASK_APP=app.py + +if [[ "${DATABASE_URL:-}" != "" ]]; then + echo "[mikromon] waiting for database" + python - <<'PY' +import os, time +from sqlalchemy import create_engine + +url = os.environ.get("DATABASE_URL") +engine = create_engine(url, pool_pre_ping=True) +for i in range(60): + try: + with engine.connect() as c: + c.exec_driver_sql("SELECT 1") + break + except Exception: + time.sleep(1) +else: + raise SystemExit("DB not reachable") +PY +fi + +echo "[mikromon] applying migrations" +MIKROMON_MIGRATING=1 flask db upgrade + +echo "[mikromon] starting app" +exec python app.py diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..87b0c82 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generated by Flask-Migrate. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..6077d13 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,2 @@ +[alembic] +script_location = migrations diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..11b8773 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,62 @@ +from __future__ import with_statement + +import logging +import os +from logging.config import fileConfig + +from flask import current_app +from alembic import context + +config = context.config + +# Safe logging configuration +if config.config_file_name and os.path.exists(config.config_file_name): + try: + fileConfig(config.config_file_name, disable_existing_loggers=False) + except Exception: + pass + +logger = logging.getLogger("alembic.env") + +from mikromon import db +from mikromon.models import * # noqa + +target_metadata = db.metadata + + +def get_url(): + return current_app.config.get("SQLALCHEMY_DATABASE_URI") + + +def run_migrations_offline(): + url = get_url() + + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = db.engine + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..e69de29 diff --git a/migrations/versions/0001_initial.py b/migrations/versions/0001_initial.py new file mode 100644 index 0000000..da7f1ef --- /dev/null +++ b/migrations/versions/0001_initial.py @@ -0,0 +1,177 @@ +"""initial + +Revision ID: 0001 +Revises: +Create Date: 2026-03-05 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "roles", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=32), nullable=False, unique=True), + ) + + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("role_id", sa.Integer(), sa.ForeignKey("roles.id"), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("is_active_flag", sa.Boolean(), nullable=False), + ) + op.create_index("ix_users_email", "users", ["email"], unique=True) + + op.create_table( + "devices", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("host", sa.String(length=255), nullable=False), + sa.Column("rest_port", sa.Integer(), nullable=False), + sa.Column("rest_base_path", sa.String(length=64), nullable=False), + sa.Column("api_enabled", sa.Boolean(), nullable=False), + sa.Column("ssh_enabled", sa.Boolean(), nullable=False), + sa.Column("ssh_port", sa.Integer(), nullable=False), + sa.Column("allow_insecure_tls", sa.Boolean(), nullable=False), + sa.Column("enc_credentials", sa.Text(), nullable=True), + sa.Column("last_seen_at", sa.DateTime(), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_devices_owner_name", "devices", ["owner_id", "name"]) + + op.create_table( + "dashboards", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("description", sa.String(length=500), nullable=True), + sa.Column("layout", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + + op.create_table( + "widgets", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("dashboard_id", sa.Integer(), sa.ForeignKey("dashboards.id"), nullable=False), + sa.Column("device_id", sa.Integer(), sa.ForeignKey("devices.id"), nullable=False), + sa.Column("title", sa.String(length=120), nullable=False), + sa.Column("widget_type", sa.String(length=64), nullable=False), + sa.Column("preset_key", sa.String(length=64), nullable=True), + sa.Column("query_json", sa.Text(), nullable=False), + sa.Column("refresh_seconds", sa.Integer(), nullable=False), + sa.Column("series_config", sa.Text(), nullable=True), + sa.Column("col_span", sa.Integer(), nullable=False, server_default="6"), + sa.Column("height_px", sa.Integer(), nullable=False, server_default="260"), + sa.Column("is_enabled", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_widgets_dashboard_id", "widgets", ["dashboard_id"]) + op.create_index("ix_widgets_device_id", "widgets", ["device_id"]) + + op.create_table( + "shares", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("target_type", sa.String(length=16), nullable=False), + sa.Column("target_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("permission", sa.String(length=16), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.UniqueConstraint("target_type","target_id","user_id",name="uq_share_target_user"), + ) + op.create_index("ix_shares_target", "shares", ["target_type","target_id"]) + + op.create_table( + "public_links", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("token", sa.String(length=64), nullable=False), + sa.Column("target_type", sa.String(length=16), nullable=False), + sa.Column("target_id", sa.Integer(), nullable=False), + sa.Column("read_only", sa.Boolean(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_public_links_token", "public_links", ["token"], unique=True) + op.create_index("ix_public_links_target", "public_links", ["target_type","target_id"]) + + op.create_table( + "password_reset_tokens", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("token", sa.String(length=128), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("used_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_password_reset_tokens_user_id", "password_reset_tokens", ["user_id"]) + op.create_index("ix_password_reset_tokens_token", "password_reset_tokens", ["token"], unique=True) + + op.create_table( + "audit_logs", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("actor_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("action", sa.String(length=64), nullable=False), + sa.Column("target_type", sa.String(length=32), nullable=True), + sa.Column("target_id", sa.Integer(), nullable=True), + sa.Column("details", sa.Text(), nullable=True), + sa.Column("request_id", sa.String(length=64), nullable=True), + sa.Column("ip", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_audit_logs_actor_user_id", "audit_logs", ["actor_user_id"]) + + op.create_table( + "metrics_last_values", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("device_id", sa.Integer(), sa.ForeignKey("devices.id"), nullable=False), + sa.Column("widget_id", sa.Integer(), sa.ForeignKey("widgets.id"), nullable=True), + sa.Column("key", sa.String(length=128), nullable=False), + sa.Column("value_json", sa.Text(), nullable=False), + sa.Column("ts", sa.DateTime(), nullable=False), + ) + op.create_index("ix_metrics_last_device_key", "metrics_last_values", ["device_id","key"]) + + # sessions table for Flask-Session (SQLAlchemy backend) + op.create_table( + "sessions", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("session_id", sa.String(255), unique=True, nullable=False), + sa.Column("data", sa.LargeBinary, nullable=False), + sa.Column("expiry", sa.DateTime, nullable=False), + ) + +def downgrade(): + op.drop_table("sessions") + op.drop_index("ix_metrics_last_device_key", table_name="metrics_last_values") + op.drop_table("metrics_last_values") + op.drop_index("ix_audit_logs_actor_user_id", table_name="audit_logs") + op.drop_table("audit_logs") + op.drop_index("ix_password_reset_tokens_token", table_name="password_reset_tokens") + op.drop_index("ix_password_reset_tokens_user_id", table_name="password_reset_tokens") + op.drop_table("password_reset_tokens") + op.drop_index("ix_public_links_target", table_name="public_links") + op.drop_index("ix_public_links_token", table_name="public_links") + op.drop_table("public_links") + op.drop_index("ix_shares_target", table_name="shares") + op.drop_table("shares") + op.drop_index("ix_widgets_device_id", table_name="widgets") + op.drop_index("ix_widgets_dashboard_id", table_name="widgets") + op.drop_table("widgets") + op.drop_table("dashboards") + op.drop_index("ix_devices_owner_name", table_name="devices") + op.drop_table("devices") + op.drop_index("ix_users_email", table_name="users") + op.drop_table("users") + op.drop_table("roles") diff --git a/mikromon/__init__.py b/mikromon/__init__.py new file mode 100644 index 0000000..24a7a83 --- /dev/null +++ b/mikromon/__init__.py @@ -0,0 +1,136 @@ +import os +import sys +import uuid # still used for SECRET_KEY fallback +from pathlib import Path +from dotenv import load_dotenv +from flask import Flask +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_login import LoginManager +from flask_migrate import Migrate +from flask_session import Session +from flask_socketio import SocketIO +from flask_sqlalchemy import SQLAlchemy +from flask_wtf.csrf import CSRFProtect + +db = SQLAlchemy() +migrate = Migrate() +login_manager = LoginManager() +csrf = CSRFProtect() + +limiter = Limiter( + key_func=get_remote_address, + default_limits=["200 per minute"], + # set RATELIMIT_STORAGE_URL=redis://... in prod to avoid in-memory + storage_uri=os.getenv("RATELIMIT_STORAGE_URL") or None, +) + +session_ext = Session() +socketio = SocketIO(async_mode="threading", cors_allowed_origins=[]) + + +def _is_migration_mode() -> bool: + # Explicit flag (recommended for Docker entrypoint) + if os.getenv("MIKROMON_MIGRATING", "0") == "1": + return True + + # Heuristic for local CLI: `flask db ...` + argv = " ".join(sys.argv).lower() + if "flask" in argv and " db " in f" {argv} ": + return True + + # Also treat alembic direct runs as migration mode + if "alembic" in argv: + return True + + return False + + +def create_app(): + load_dotenv() + + BASE_DIR = Path(__file__).resolve().parent.parent + app = Flask( + __name__, + instance_relative_config=True, + template_folder=str(BASE_DIR / "templates"), + static_folder=str(BASE_DIR / "static"), + ) + app.config.from_object("mikromon.config.Config") + os.makedirs(app.instance_path, exist_ok=True) + + # --- FIX: always use absolute sqlite path inside instance/ --- + uri = app.config.get("SQLALCHEMY_DATABASE_URI", "") + if uri.startswith("sqlite:///") and not uri.startswith("sqlite:////"): + # uri like sqlite:///instance/mikromon.db or sqlite:///mikromon.db (relative) + rel = uri[len("sqlite:///"):] + # jeśli to nie jest ścieżka absolutna -> wpinamy do instance_path + if not os.path.isabs(rel): + fname = os.path.basename(rel) # mikromon.db + db_path = os.path.join(app.instance_path, fname) + app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}" + # ----------------------------------------------------------- + + migrating = _is_migration_mode() + + # extensions + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + csrf.init_app(app) + limiter.init_app(app) + + # IMPORTANT: + # Flask-Session (SQLAlchemy backend) auto-creates the `sessions` table on init. + # During `flask db upgrade` this causes conflicts with Alembic migrations. + # So we skip session init in migration mode. + if not migrating: + app.config["SESSION_SQLALCHEMY"] = db + session_ext.init_app(app) + + socketio.init_app(app, message_queue=app.config.get("SOCKETIO_MESSAGE_QUEUE")) + + login_manager.login_view = "auth.login" + login_manager.login_message_category = "warning" + + # Request-ID removed (kept causing "Request ID: -" in UI and noise in logs) + # If you ever need it back, reintroduce g.request_id and set a header. + + from .utils.static import init_static_cache_headers + init_static_cache_headers(app) + + from .errors import register_error_handlers + register_error_handlers(app) + + from .template_helpers import register as register_template_helpers + register_template_helpers(app) + + # blueprints + from .blueprints.auth import bp as auth_bp + from .blueprints.devices import bp as devices_bp + from .blueprints.dashboards import bp as dashboards_bp + from .blueprints.admin import bp as admin_bp + from .blueprints.api import bp as api_bp + from .blueprints.realtime import bp as realtime_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(devices_bp) + app.register_blueprint(dashboards_bp) + app.register_blueprint(admin_bp) + app.register_blueprint(api_bp) + app.register_blueprint(realtime_bp) + + from .cli import register_cli + register_cli(app) + + # bootstrap (skip during migrations) + if not migrating: + with app.app_context(): + from .services.bootstrap import ensure_default_roles_and_admin + ensure_default_roles_and_admin() + + from .services.poller import start_dev_scheduler + if app.config.get("DEV_INPROCESS_POLLER"): + start_dev_scheduler(app) + + return app \ No newline at end of file diff --git a/mikromon/authn.py b/mikromon/authn.py new file mode 100644 index 0000000..e502fef --- /dev/null +++ b/mikromon/authn.py @@ -0,0 +1,10 @@ +from . import login_manager +from .models import User +from . import db + +@login_manager.user_loader +def load_user(user_id: str): + try: + return db.session.get(User, int(user_id)) + except Exception: + return None diff --git a/mikromon/blueprints/admin.py b/mikromon/blueprints/admin.py new file mode 100644 index 0000000..945d7d5 --- /dev/null +++ b/mikromon/blueprints/admin.py @@ -0,0 +1,59 @@ +from flask import Blueprint, render_template, abort, redirect, url_for, flash +from flask_login import login_required, current_user +from ..models import User, Device, Dashboard, AuditLog +from ..forms import SmtpTestForm +from ..services.mail import send_mail +from ..services import audit + +bp = Blueprint("admin", __name__, url_prefix="/admin") + +def _require_admin(): + if not current_user.is_authenticated or not current_user.is_admin(): + abort(403) + +@bp.get("/") +@login_required +def index(): + _require_admin() + return render_template("admin/index.html", + user_count=User.query.count(), + device_count=Device.query.count(), + dashboard_count=Dashboard.query.count()) + +@bp.get("/users") +@login_required +def users(): + _require_admin() + users = User.query.order_by(User.created_at.desc()).all() + return render_template("admin/users.html", users=users) + +@bp.get("/audit") +@login_required +def audit_logs(): + _require_admin() + logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(200).all() + return render_template("admin/audit.html", logs=logs) + +@bp.get("/smtp") +@login_required +def smtp(): + _require_admin() + form = SmtpTestForm() + return render_template("admin/smtp.html", form=form) + +@bp.post("/smtp/test") +@login_required +def smtp_test(): + _require_admin() + form = SmtpTestForm() + if not form.validate_on_submit(): + flash("Invalid email.", "danger") + return redirect(url_for("admin.smtp")) + try: + send_mail(form.to_email.data, "MikroMon SMTP test", "emails/smtp_test.html") + audit.log("admin.smtp_test_ok") + flash("Email sent.", "success") + except Exception as e: + audit.log("admin.smtp_test_failed", details=str(e)) + flash(f"SMTP error: {e}", "danger") + return redirect(url_for("admin.smtp")) diff --git a/mikromon/blueprints/api.py b/mikromon/blueprints/api.py new file mode 100644 index 0000000..3a55271 --- /dev/null +++ b/mikromon/blueprints/api.py @@ -0,0 +1,268 @@ +import json +from flask import Blueprint, jsonify, request, abort, render_template, url_for +from flask_login import login_required, current_user +from .. import db +from ..models import Device, Dashboard, Widget, Share, PublicLink +from ..services.acl import has_permission +from ..services.presets import load_presets +from ..connectors.registry import get_connector +from ..security.crypto import decrypt_json + +bp = Blueprint("api", __name__, url_prefix="/api") + +from .. import csrf +csrf.exempt(bp) + + +def _json_ok(data=None): + return jsonify({"ok": True, "data": data}) + +def _json_err(msg, code=400): + return jsonify({"ok": False, "error": msg}), code + +@bp.get("/docs") +def docs(): + routes = [ + {"method":"GET","path":"/api/v1/me","desc":"Current user"}, + {"method":"GET","path":"/api/v1/devices","desc":"List devices you can view"}, + {"method":"POST","path":"/api/v1/devices//test","desc":"Test REST connection"}, + {"method":"GET","path":"/api/v1/dashboards","desc":"List dashboards you can view"}, + {"method":"GET","path":"/api/v1/dashboards/","desc":"Dashboard detail + widgets"}, + {"method":"GET","path":"/api/v1/presets/widgets","desc":"Widget presets"}, + {"method":"GET","path":"/api/v1/public/","desc":"Public dashboard (read-only)"}, + {"method":"POST","path":"/api/v1/devices","desc":"Create device (JSON)"}, + {"method":"POST","path":"/api/v1/dashboards","desc":"Create dashboard (JSON)"}, + {"method":"POST","path":"/api/v1/dashboards//widgets","desc":"Add widget (JSON)"}, + {"method":"GET","path":"/api/v1/widgets//last","desc":"Last value (cache)"}, + ] + return render_template("api/docs.html", routes=routes) + +@bp.get("/v1/me") +@login_required +def me(): + return _json_ok({"id": current_user.id, "email": current_user.email, "role": current_user.role.name}) + +@bp.get("/v1/presets/widgets") +@login_required +def widget_presets(): + return _json_ok(load_presets()) + +@bp.get("/v1/devices") +@login_required +def devices(): + # owner + shares + owned = Device.query.filter_by(owner_id=current_user.id).all() + shared_ids = [s.target_id for s in Share.query.filter_by(target_type="device", user_id=current_user.id).all()] + shared = Device.query.filter(Device.id.in_(shared_ids)).all() if shared_ids else [] + uniq = {d.id: d for d in owned + shared} + data = [{"id": d.id, "name": d.name, "host": d.host, "last_seen_at": d.last_seen_at, "last_error": d.last_error} for d in uniq.values()] + return _json_ok(data) + +@bp.post("/v1/devices//test") +@login_required +def device_test(device_id): + from ..connectors.registry import get_connector + from ..security.crypto import decrypt_json + + d = db.session.get(Device, device_id) + if not d: + return _json_err("Not found", 404) + if not has_permission(current_user, "device", d.id, "manage", d.owner_id): + return _json_err("Forbidden", 403) + creds = decrypt_json(d.enc_credentials) if d.enc_credentials else None + res = get_connector("rest").test(d, creds) + return _json_ok({"ok": True}) if res.ok else _json_err(res.error, 400) + +@bp.get("/v1/dashboards") +@login_required +def dashboards(): + owned = Dashboard.query.filter_by(owner_id=current_user.id).all() + shared_ids = [s.target_id for s in Share.query.filter_by(target_type="dashboard", user_id=current_user.id).all()] + shared = Dashboard.query.filter(Dashboard.id.in_(shared_ids)).all() if shared_ids else [] + uniq = {d.id: d for d in owned + shared} + data = [{"id": d.id, "name": d.name, "description": d.description} for d in uniq.values()] + return _json_ok(data) + +@bp.get("/v1/dashboards/") +@login_required +def dashboard_detail(dashboard_id): + d = db.session.get(Dashboard, dashboard_id) + if not d: + return _json_err("Not found", 404) + if not has_permission(current_user, "dashboard", d.id, "view", d.owner_id): + return _json_err("Forbidden", 403) + widgets = Widget.query.filter_by(dashboard_id=d.id).all() + data = { + "id": d.id, + "name": d.name, + "description": d.description, + "widgets": [{ + "id": w.id, "title": w.title, "type": w.widget_type, "device_id": w.device_id, + "refresh_seconds": w.refresh_seconds, "preset_key": w.preset_key, + "query": json.loads(w.query_json) + } for w in widgets] + } + return _json_ok(data) + + +@bp.post("/v1/devices") +@login_required +def device_create(): + from ..security.crypto import encrypt_json + payload = request.get_json(silent=True) or {} + required = ["name","host","username","password"] + if any(k not in payload for k in required): + return _json_err("Missing fields: name, host, username, password", 400) + d = Device( + owner_id=current_user.id, + name=str(payload["name"]).strip(), + host=str(payload["host"]).strip(), + rest_port=int(payload.get("rest_port", 443)), + rest_base_path=str(payload.get("rest_base_path","/rest")).strip(), + allow_insecure_tls=bool(payload.get("allow_insecure_tls", False)), + ssh_enabled=bool(payload.get("ssh_enabled", False)), + ssh_port=int(payload.get("ssh_port", 22)), + ) + d.enc_credentials = encrypt_json({"username": payload["username"], "password": payload["password"]}) + db.session.add(d); db.session.commit() + return _json_ok({"id": d.id}) + +@bp.post("/v1/dashboards") +@login_required +def dashboard_create(): + payload = request.get_json(silent=True) or {} + if "name" not in payload: + return _json_err("Missing field: name", 400) + d = Dashboard(owner_id=current_user.id, name=str(payload["name"]).strip(), description=str(payload.get("description","")).strip()) + db.session.add(d); db.session.commit() + return _json_ok({"id": d.id}) + +@bp.post("/v1/dashboards//widgets") +@login_required +def widget_create(dashboard_id): + payload = request.get_json(silent=True) or {} + d = db.session.get(Dashboard, dashboard_id) + if not d: + return _json_err("Not found", 404) + if not has_permission(current_user, "dashboard", d.id, "edit", d.owner_id): + return _json_err("Forbidden", 403) + if "device_id" not in payload or "title" not in payload: + return _json_err("Missing fields: device_id, title", 400) + device = db.session.get(Device, int(payload["device_id"])) + if not device or not has_permission(current_user, "device", device.id, "view", device.owner_id): + return _json_err("Invalid device", 400) + presets = load_presets() + preset_key = payload.get("preset_key") + preset = presets.get(preset_key) if preset_key else None + q = payload.get("query") + if not q and preset: + q = { + "connector": preset.get("connector","rest"), + "endpoint": preset.get("endpoint"), + "params": preset.get("params") or {}, + "extract": preset.get("extract") or {}, + "preset_key": preset_key + } + if not q: + return _json_err("Missing preset_key or query", 400) + refresh = int(payload.get("refresh_seconds", (preset.get("refresh_seconds", 2) if preset else 2))) + from flask import current_app + refresh = max(refresh, current_app.config.get("USER_MIN_REFRESH_SECONDS", 2)) + w = Widget( + dashboard_id=d.id, + device_id=device.id, + title=str(payload["title"]).strip(), + widget_type=str(payload.get("widget_type", (preset.get("widget_type") if preset else "timeseries"))), + preset_key=preset_key, + query_json=json.dumps(q), + refresh_seconds=refresh, + series_config=json.dumps(payload.get("series_config") or (preset.get("series") if preset else [])), + col_span=int(payload.get("col_span", 6)), + height_px=int(payload.get("height_px", 260)), + ) + db.session.add(w); db.session.commit() + return _json_ok({"id": w.id}) + +@bp.get("/v1/widgets//last") +@login_required +def widget_last(widget_id): + from ..models import MetricLastValue + w = db.session.get(Widget, widget_id) + if not w: + return _json_err("Not found", 404) + d = db.session.get(Dashboard, w.dashboard_id) + if not has_permission(current_user, "dashboard", d.id, "view", d.owner_id): + return _json_err("Forbidden", 403) + last = (MetricLastValue.query + .filter_by(widget_id=w.id) + .order_by(MetricLastValue.ts.desc()) + .first()) + if not last: + return _json_ok(None) + return _json_ok({"ts": last.ts.isoformat(), "value": json.loads(last.value_json)}) + + +@bp.get("/v1/preset-items") +@login_required +def preset_items(): + """List item names for presets returning lists (interfaces, queues, ...).""" + device_id = int(request.args.get("device_id") or 0) + preset_key = (request.args.get("preset_key") or "").strip() + if not device_id or not preset_key: + return _json_ok({"items": []}) + + device = db.session.get(Device, device_id) + if not device or not has_permission(current_user, "device", device.id, "view", device.owner_id): + return _json_ok({"items": []}) + + presets = load_presets() + preset = presets.get(preset_key) + if not preset: + return _json_ok({"items": []}) + + if not device.enc_credentials: + return _json_ok({"items": []}) + try: + creds = decrypt_json(device.enc_credentials) + except Exception: + return _json_ok({"items": []}) + + q = { + "connector": preset.get("connector", "rest"), + "endpoint": preset.get("endpoint"), + "params": dict(preset.get("params") or {}), + "extract": preset.get("extract") or {}, + "preset_key": preset_key, + } + # ensure list call (remove default filter) + q.get("params", {}).pop("name", None) + + connector = get_connector(q.get("connector", "rest")) + res = connector.fetch(device, creds, q) + if not res.ok: + return _json_ok({"items": []}) + + items = [] + if isinstance(res.data, list): + for it in res.data: + if isinstance(it, dict) and it.get("name"): + items.append(str(it.get("name"))) + items = sorted(set(items))[:200] + return _json_ok({"items": items}) + +@bp.get("/v1/public/") +def public_dashboard(token): + import datetime as dt + pl = PublicLink.query.filter_by(token=token, target_type="dashboard").first() + if not pl: + return _json_err("Not found", 404) + if pl.expires_at and dt.datetime.utcnow() > pl.expires_at: + return _json_err("Expired", 404) + d = db.session.get(Dashboard, pl.target_id) + if not d: + return _json_err("Not found", 404) + widgets = Widget.query.filter_by(dashboard_id=d.id).all() + return _json_ok({ + "dashboard": {"id": d.id, "name": d.name, "description": d.description}, + "widgets": [{"id": w.id, "title": w.title, "type": w.widget_type, "device_id": w.device_id} for w in widgets] + }) diff --git a/mikromon/blueprints/auth.py b/mikromon/blueprints/auth.py new file mode 100644 index 0000000..ae11536 --- /dev/null +++ b/mikromon/blueprints/auth.py @@ -0,0 +1,133 @@ +import datetime as dt +import secrets +from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app +from flask_login import login_user, logout_user, login_required, current_user +from .. import db, limiter +from ..models import User, Role, RoleName, PasswordResetToken +from ..forms import LoginForm, RegisterForm, ForgotPasswordForm, ResetPasswordForm +from ..security.passwords import verify_password, hash_password +from ..services import audit +from ..services.mail import send_mail +from ..authn import load_user # noqa + +bp = Blueprint("auth", __name__, url_prefix="/auth") + +@bp.get("/login") +def login(): + if current_user.is_authenticated: + return redirect(url_for("dashboards.index")) + form = LoginForm() + return render_template("auth/login.html", form=form) + +@bp.post("/login") +@limiter.limit(lambda: current_app.config.get("LIMIT_LOGIN", "10 per minute")) +def login_post(): + form = LoginForm() + if not form.validate_on_submit(): + flash("Invalid credentials.", "danger") + return render_template("auth/login.html", form=form), 400 + u = User.query.filter_by(email=form.email.data.lower().strip()).first() + if not u or not verify_password(u.password_hash, form.password.data) or not u.is_active_flag: + flash("Invalid credentials.", "danger") + return render_template("auth/login.html", form=form), 401 + login_user(u) + audit.log("auth.login", "user", u.id) + return redirect(url_for("dashboards.index")) + +@bp.get("/logout") +@login_required +def logout(): + audit.log("auth.logout", "user", current_user.id) + logout_user() + return redirect(url_for("auth.login")) + +@bp.get("/register") +def register(): + # MVP: allow self-registration; in production you may disable. + form = RegisterForm() + return render_template("auth/register.html", form=form) + +@bp.post("/register") +def register_post(): + form = RegisterForm() + if not form.validate_on_submit(): + flash("Invalid input.", "danger") + return render_template("auth/register.html", form=form), 400 + email = form.email.data.lower().strip() + if User.query.filter_by(email=email).first(): + flash("Email already registered.", "warning") + return render_template("auth/register.html", form=form), 409 + user_role = Role.query.filter_by(name=RoleName.USER.value).first() + u = User(email=email, password_hash=hash_password(form.password.data), role_id=user_role.id) + db.session.add(u) + db.session.commit() + audit.log("auth.register", "user", u.id) + flash("Account created. Please log in.", "success") + return redirect(url_for("auth.login")) + +@bp.get("/forgot") +def forgot(): + form = ForgotPasswordForm() + return render_template("auth/forgot.html", form=form) + +@bp.post("/forgot") +@limiter.limit(lambda: current_app.config.get("LIMIT_RESET", "5 per hour")) +def forgot_post(): + form = ForgotPasswordForm() + if not form.validate_on_submit(): + flash("Invalid input.", "danger") + return render_template("auth/forgot.html", form=form), 400 + + email = form.email.data.lower().strip() + u = User.query.filter_by(email=email).first() + # always respond the same + if u: + token = secrets.token_urlsafe(48) + ttl_minutes = 30 + prt = PasswordResetToken( + user_id=u.id, + token=token, + expires_at=dt.datetime.utcnow() + dt.timedelta(minutes=ttl_minutes), + ) + db.session.add(prt) + db.session.commit() + reset_url = url_for("auth.reset", token=token, _external=True) + try: + send_mail( + to_email=u.email, + subject="Password reset", + template="emails/reset_password.html", + reset_url=reset_url, + ttl_minutes=ttl_minutes + ) + audit.log("auth.reset_email_sent", "user", u.id) + except Exception: + # do not leak SMTP issues to attacker + audit.log("auth.reset_email_failed", "user", u.id) + flash("If that email exists, a reset link has been sent.", "info") + return redirect(url_for("auth.login")) + +@bp.get("/reset/") +def reset(token): + form = ResetPasswordForm() + return render_template("auth/reset.html", form=form, token=token) + +@bp.post("/reset/") +def reset_post(token): + form = ResetPasswordForm() + if not form.validate_on_submit(): + flash("Invalid input.", "danger") + return render_template("auth/reset.html", form=form, token=token), 400 + + prt = PasswordResetToken.query.filter_by(token=token).first() + if not prt or not prt.is_valid(): + flash("Token invalid or expired.", "danger") + return render_template("auth/reset.html", form=form, token=token), 400 + + u = prt.user + u.password_hash = hash_password(form.password.data) + prt.used_at = dt.datetime.utcnow() + db.session.commit() + audit.log("auth.password_reset", "user", u.id) + flash("Password changed. Please log in.", "success") + return redirect(url_for("auth.login")) diff --git a/mikromon/blueprints/dashboards.py b/mikromon/blueprints/dashboards.py new file mode 100644 index 0000000..31fc7b8 --- /dev/null +++ b/mikromon/blueprints/dashboards.py @@ -0,0 +1,282 @@ +import json, copy +import secrets +import datetime as dt +from flask import Blueprint, render_template, redirect, url_for, flash, abort, request, jsonify +from flask_login import login_required, current_user +from .. import db +from ..models import Dashboard, Widget, Device, Share, PublicLink +from ..forms import DashboardForm, WidgetWizardForm, ShareForm +from ..services.acl import has_permission +from ..services.presets import load_presets +from ..services import audit + + +bp = Blueprint("dashboards", __name__, url_prefix="/") + + +@bp.get("/") +@login_required +def index(): + dashboards = Dashboard.query.filter_by(owner_id=current_user.id).order_by(Dashboard.created_at.desc()).all() + return render_template("dashboards/index.html", dashboards=dashboards) + + +@bp.get("/dashboards/new") +@login_required +def new(): + form = DashboardForm() + return render_template("dashboards/new.html", form=form) + + +@bp.post("/dashboards/new") +@login_required +def new_post(): + form = DashboardForm() + if not form.validate_on_submit(): + flash("Invalid input.", "danger") + return render_template("dashboards/new.html", form=form), 400 + d = Dashboard(owner_id=current_user.id, name=form.name.data.strip(), description=form.description.data.strip()) + db.session.add(d) + db.session.commit() + audit.log("dashboard.created", "dashboard", d.id) + return redirect(url_for("dashboards.view", dashboard_id=d.id)) + + +def _load_dashboard_or_404(dashboard_id): + d = db.session.get(Dashboard, dashboard_id) + if not d: + abort(404) + if not has_permission(current_user, "dashboard", d.id, "view", d.owner_id): + abort(403) + return d + + +@bp.get("/dashboards/") +@login_required +def view(dashboard_id): + d = _load_dashboard_or_404(dashboard_id) + widgets = Widget.query.filter_by(dashboard_id=d.id).order_by(Widget.created_at.asc()).all() + return render_template("dashboards/view.html", dashboard=d, widgets=widgets) + + +@bp.get("/dashboards//widgets/new") +@login_required +def widget_new(dashboard_id): + d = _load_dashboard_or_404(dashboard_id) + if not has_permission(current_user, "dashboard", d.id, "edit", d.owner_id): + abort(403) + + presets = load_presets() + form = WidgetWizardForm() + form.preset_key.choices = [(k, f"{v['title']} ({k})") for k, v in presets.items()] + form.refresh_seconds.data = 2 + + devices = Device.query.filter_by(owner_id=current_user.id).all() + return render_template("dashboards/widget_new.html", dashboard=d, form=form, presets=presets, devices=devices) + + +@bp.post("/dashboards//widgets/new") +@login_required +def widget_new_post(dashboard_id): + d = _load_dashboard_or_404(dashboard_id) + if not has_permission(current_user, "dashboard", d.id, "edit", d.owner_id): + abort(403) + + presets = load_presets() + form = WidgetWizardForm() + form.preset_key.choices = [(k, f"{v['title']} ({k})") for k, v in presets.items()] + + if not form.validate_on_submit(): + flash("Invalid input.", "danger") + devices = Device.query.filter_by(owner_id=current_user.id).all() + return render_template("dashboards/widget_new.html", dashboard=d, form=form, presets=presets, devices=devices), 400 + + device_id = int(request.form.get("device_id") or 0) + device = db.session.get(Device, device_id) + if not device or not has_permission(current_user, "device", device.id, "view", device.owner_id): + flash("Invalid device.", "danger") + return redirect(url_for("dashboards.widget_new", dashboard_id=d.id)) + + preset_key = form.preset_key.data + preset = presets.get(preset_key) + if not preset: + flash("Unknown preset.", "danger") + return redirect(url_for("dashboards.widget_new", dashboard_id=d.id)) + + # IMPORTANT: deepcopy to prevent cross-request / cross-widget mutation of dicts + preset = copy.deepcopy(preset) + + # base query from preset (always dict copies) + q = { + "connector": preset.get("connector", "rest"), + "endpoint": preset.get("endpoint"), + "params": copy.deepcopy(preset.get("params") or {}), + "extract": copy.deepcopy(preset.get("extract") or {}), + "preset_key": preset_key, + } + + # Optional selector (interfaces/queues etc.) + item_name = (form.item_name.data or "").strip() + + # If user typed raw query_json: merge it, don't fully replace (keeps preset_key etc.) + raw_q = (form.query_json.data or "").strip() + if raw_q: + try: + user_q = json.loads(raw_q) + if not isinstance(user_q, dict): + raise ValueError("query_json must be an object") + except Exception: + flash("Query JSON invalid.", "danger") + return redirect(url_for("dashboards.widget_new", dashboard_id=d.id)) + + # Merge: user_q overrides base q, but ensure required keys exist + merged = dict(q) + merged.update(user_q) + + merged.setdefault("params", {}) + merged.setdefault("extract", {}) + merged["preset_key"] = preset_key + + # if user forgot connector/endpoint -> fallback to preset + merged["connector"] = merged.get("connector") or q["connector"] + merged["endpoint"] = merged.get("endpoint") or q["endpoint"] + + q = merged + else: + # Only apply item_name convention when user did not provide custom JSON + if item_name: + q.setdefault("params", {}) + q["params"]["name"] = item_name + + refresh = max(int(form.refresh_seconds.data or 1), 1) + from flask import current_app + refresh = max(refresh, int(current_app.config.get("USER_MIN_REFRESH_SECONDS", 2))) + + title = (form.title.data or "").strip() or preset.get("title") or preset_key + + wdg = Widget( + dashboard_id=d.id, + device_id=device.id, + title=title, + widget_type=preset.get("widget_type", "timeseries"), + preset_key=preset_key, + query_json=json.dumps(q), + refresh_seconds=refresh, + series_config=json.dumps(preset.get("series") or []), + col_span=int(form.col_span.data or 6), + height_px=int(form.height_px.data or 260), + is_enabled=True, + ) + db.session.add(wdg) + db.session.commit() + audit.log("widget.created", "widget", wdg.id) + flash("Widget added.", "success") + return redirect(url_for("dashboards.view", dashboard_id=d.id)) + + +@bp.post("/dashboards//widgets//delete") +@login_required +def widget_delete(dashboard_id, widget_id): + d = _load_dashboard_or_404(dashboard_id) + if not has_permission(current_user, "dashboard", d.id, "edit", d.owner_id): + abort(403) + wdg = db.session.get(Widget, widget_id) + if not wdg or wdg.dashboard_id != d.id: + abort(404) + db.session.delete(wdg) + db.session.commit() + audit.log("widget.deleted", "widget", widget_id) + flash("Widget deleted.", "success") + return redirect(url_for("dashboards.view", dashboard_id=d.id)) + + +# Sharing +@bp.get("/dashboards//share") +@login_required +def share(dashboard_id): + d = _load_dashboard_or_404(dashboard_id) + if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id): + abort(403) + shares = Share.query.filter_by(target_type="dashboard", target_id=d.id).all() + public = PublicLink.query.filter_by(target_type="dashboard", target_id=d.id).first() + form = ShareForm() + return render_template("dashboards/share.html", dashboard=d, shares=shares, public=public, form=form) + + +@bp.post("/dashboards//share") +@login_required +def share_post(dashboard_id): + d = _load_dashboard_or_404(dashboard_id) + if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id): + abort(403) + from ..models import User, Share + form = ShareForm() + if not form.validate_on_submit(): + flash("Invalid input.", "danger") + return redirect(url_for("dashboards.share", dashboard_id=d.id)) + u = User.query.filter_by(email=form.email.data.lower().strip()).first() + if not u: + flash("User not found.", "warning") + return redirect(url_for("dashboards.share", dashboard_id=d.id)) + s = Share.query.filter_by(target_type="dashboard", target_id=d.id, user_id=u.id).first() + if not s: + s = Share(target_type="dashboard", target_id=d.id, user_id=u.id, permission=form.permission.data) + db.session.add(s) + else: + s.permission = form.permission.data + db.session.commit() + audit.log("dashboard.shared_user", "dashboard", d.id, f"{u.email}:{s.permission}") + flash("Shared updated.", "success") + return redirect(url_for("dashboards.share", dashboard_id=d.id)) + + +@bp.post("/dashboards//share/public") +@login_required +def share_public(dashboard_id): + d = _load_dashboard_or_404(dashboard_id) + if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id): + abort(403) + token = secrets.token_urlsafe(24) + pl = PublicLink.query.filter_by(target_type="dashboard", target_id=d.id).first() + if not pl: + pl = PublicLink(token=token, target_type="dashboard", target_id=d.id, read_only=True) + db.session.add(pl) + else: + pl.token = token + db.session.commit() + audit.log("dashboard.public_link_created", "dashboard", d.id) + flash("Public link created/refreshed.", "success") + return redirect(url_for("dashboards.share", dashboard_id=d.id)) + + +@bp.get("/public/") +def public_view(token): + pl = PublicLink.query.filter_by(token=token, target_type="dashboard").first() + if not pl: + abort(404) + if pl.expires_at and dt.datetime.utcnow() > pl.expires_at: + abort(404) + d = db.session.get(Dashboard, pl.target_id) + if not d: + abort(404) + widgets = Widget.query.filter_by(dashboard_id=d.id).order_by(Widget.created_at.asc()).all() + return render_template("dashboards/public_view.html", dashboard=d, widgets=widgets, token=token) + + +@bp.post("/dashboards//delete") +@login_required +def delete(dashboard_id): + d = _load_dashboard_or_404(dashboard_id) + if not has_permission(current_user, "dashboard", d.id, "manage", d.owner_id): + abort(403) + + Widget.query.filter_by(dashboard_id=d.id).delete(synchronize_session=False) + Share.query.filter_by(target_type="dashboard", target_id=d.id).delete(synchronize_session=False) + PublicLink.query.filter_by(target_type="dashboard", target_id=d.id).delete(synchronize_session=False) + + db.session.delete(d) + db.session.commit() + audit.log("dashboard.deleted", "dashboard", dashboard_id) + + flash("Dashboard deleted.", "success") + return redirect(url_for("dashboards.index")) \ No newline at end of file diff --git a/mikromon/blueprints/devices.py b/mikromon/blueprints/devices.py new file mode 100644 index 0000000..a67cfc1 --- /dev/null +++ b/mikromon/blueprints/devices.py @@ -0,0 +1,184 @@ +import json +from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify +from flask_login import login_required, current_user +from .. import db +from ..models import Device, ShareTarget +from ..forms import DeviceForm, EditDeviceForm +from ..security.crypto import encrypt_json, decrypt_json +from ..connectors.registry import get_connector +from ..services.acl import has_permission +from ..services import audit + +bp = Blueprint("devices", __name__, url_prefix="/devices") + +@bp.get("/") +@login_required +def index(): + devices = Device.query.filter_by(owner_id=current_user.id).order_by(Device.created_at.desc()).all() + return render_template("devices/index.html", devices=devices) + +@bp.get("/new") +@login_required +def new(): + if Device.query.filter_by(owner_id=current_user.id).count() >= int(request.app.config.get("USER_MAX_DEVICES", 20)) if hasattr(request, "app") else 10: + pass + form = DeviceForm() + form.rest_port.data = 443 + form.rest_base_path.data = "/rest" + form.ssh_port.data = 22 + return render_template("devices/new.html", form=form) + +@bp.post("/new") +@login_required +def new_post(): + form = DeviceForm() + if not form.validate_on_submit(): + flash("Invalid input.", "danger") + return render_template("devices/new.html", form=form), 400 + + # per-user limit + from flask import current_app + max_devices = current_app.config.get("USER_MAX_DEVICES", 20) + if Device.query.filter_by(owner_id=current_user.id).count() >= max_devices: + flash(f"Device limit reached ({max_devices}).", "warning") + return render_template("devices/new.html", form=form), 403 + + d = Device( + owner_id=current_user.id, + name=form.name.data.strip(), + host=form.host.data.strip(), + rest_port=form.rest_port.data, + rest_base_path=form.rest_base_path.data.strip(), + allow_insecure_tls=bool(form.allow_insecure_tls.data), + ssh_enabled=bool(form.ssh_enabled.data), + ssh_port=form.ssh_port.data or 22, + ) + d.enc_credentials = encrypt_json({"username": form.username.data, "password": form.password.data}) + db.session.add(d) + db.session.commit() + audit.log("device.created", "device", d.id) + + flash("Device created. You can test connection now.", "success") + return redirect(url_for("devices.view", device_id=d.id)) + +@bp.get("/") +@login_required +def view(device_id): + d = db.session.get(Device, device_id) + if not d: + abort(404) + if not has_permission(current_user, "device", d.id, "view", d.owner_id): + abort(403) + return render_template("devices/view.html", device=d) + +@bp.post("//test") +@login_required +def test(device_id): + d = db.session.get(Device, device_id) + if not d: + abort(404) + if not has_permission(current_user, "device", d.id, "manage", d.owner_id): + abort(403) + creds = decrypt_json(d.enc_credentials) if d.enc_credentials else None + if not creds: + return jsonify({"ok": False, "error": "Missing credentials"}), 400 + connector = get_connector("rest") + res = connector.test(d, creds) + if res.ok: + audit.log("device.test_ok", "device", d.id) + return jsonify({"ok": True}) + audit.log("device.test_failed", "device", d.id, res.error) + return jsonify({"ok": False, "error": res.error}), 400 + +@bp.get("//discover") +@login_required +def discover(device_id): + d = db.session.get(Device, device_id) + if not d: + abort(404) + if not has_permission(current_user, "device", d.id, "manage", d.owner_id): + abort(403) + creds = decrypt_json(d.enc_credentials) if d.enc_credentials else None + connector = get_connector("rest") + res = connector.discover(d, creds) + if res.ok: + return jsonify({"ok": True, "data": res.data}) + return jsonify({"ok": False, "error": res.error}), 400 + +@bp.get("//edit") +@login_required +def edit(device_id): + d = db.session.get(Device, device_id) + if not d: + abort(404) + if not has_permission(current_user, "device", d.id, "manage", d.owner_id): + abort(403) + + form = EditDeviceForm() + + # preload values + form.name.data = d.name + form.host.data = d.host + form.rest_port.data = d.rest_port + form.rest_base_path.data = d.rest_base_path + form.allow_insecure_tls.data = bool(d.allow_insecure_tls) + form.ssh_enabled.data = bool(d.ssh_enabled) + form.ssh_port.data = d.ssh_port + + creds = decrypt_json(d.enc_credentials) if d.enc_credentials else {} + form.username.data = creds.get("username", "") + + return render_template("devices/edit.html", device=d, form=form) + + +@bp.post("//edit") +@login_required +def edit_post(device_id): + d = db.session.get(Device, device_id) + if not d: + abort(404) + if not has_permission(current_user, "device", d.id, "manage", d.owner_id): + abort(403) + + form = EditDeviceForm() + if not form.validate_on_submit(): + flash("Invalid input.", "danger") + return render_template("devices/edit.html", device=d, form=form), 400 + + d.name = form.name.data.strip() + d.host = form.host.data.strip() + d.rest_port = form.rest_port.data + d.rest_base_path = form.rest_base_path.data.strip() + d.allow_insecure_tls = bool(form.allow_insecure_tls.data) + d.ssh_enabled = bool(form.ssh_enabled.data) + d.ssh_port = form.ssh_port.data or 22 + + # credentials: keep old password if blank + old = decrypt_json(d.enc_credentials) if d.enc_credentials else {} + username = (form.username.data or "").strip() + password = (form.password.data or "").strip() or (old.get("password") or "") + + d.enc_credentials = encrypt_json({"username": username, "password": password}) + + db.session.commit() + audit.log("device.updated", "device", d.id) + + flash("Device updated.", "success") + return redirect(url_for("devices.view", device_id=d.id)) + + +@bp.post("//delete") +@login_required +def delete(device_id): + d = db.session.get(Device, device_id) + if not d: + abort(404) + if not has_permission(current_user, "device", d.id, "manage", d.owner_id): + abort(403) + + db.session.delete(d) + db.session.commit() + audit.log("device.deleted", "device", device_id) + + flash("Device deleted.", "success") + return redirect(url_for("devices.index")) \ No newline at end of file diff --git a/mikromon/blueprints/realtime.py b/mikromon/blueprints/realtime.py new file mode 100644 index 0000000..dfaf0bf --- /dev/null +++ b/mikromon/blueprints/realtime.py @@ -0,0 +1,107 @@ +from flask import Blueprint, jsonify +from flask_login import current_user +from flask_socketio import join_room, leave_room, disconnect, emit + +from .. import socketio, db +from ..models import Dashboard, PublicLink +from ..services.acl import has_permission + +bp = Blueprint("realtime", __name__, url_prefix="/realtime") + + +@socketio.on("connect") +def sio_connect(auth=None): + # Cookie-based session auth is validated later during join/subscribe. + return True + + +@socketio.on("subscribe") +def sio_subscribe(data): + """ + data: {"dashboard_id": 123} + Auth required (no public token here). For public dashboards use join_dashboard. + """ + data = data or {} + dashboard_id = int(data.get("dashboard_id") or 0) + if not dashboard_id: + return + + if not current_user.is_authenticated: + emit("error", {"message": "unauthorized"}) + disconnect() + return + + d = db.session.get(Dashboard, dashboard_id) + if not d: + emit("error", {"message": "not_found"}) + disconnect() + return + + if not has_permission(current_user, "dashboard", d.id, "view", d.owner_id): + emit("error", {"message": "forbidden"}) + disconnect() + return + + join_room(f"dashboard:{d.id}") + emit("subscribed", {"dashboard_id": d.id}) + + +@socketio.on("join_dashboard") +def sio_join_dashboard(data): + """ + data: {"dashboard_id": 123, "public_token": "..."|null} + Allows either: + - authenticated session (cookie) + - public token for read-only dashboards + """ + data = data or {} + dashboard_id = int(data.get("dashboard_id") or 0) + public_token = data.get("public_token") + + if not dashboard_id: + return + + d = db.session.get(Dashboard, dashboard_id) + if not d: + emit("error", {"message": "not_found"}) + disconnect() + return + + # public link path + if public_token: + pl = PublicLink.query.filter_by(token=public_token, target_type="dashboard").first() + if not pl or pl.target_id != d.id: + emit("error", {"message": "forbidden"}) + disconnect() + return + join_room(f"dashboard:{d.id}") + emit("joined", {"dashboard_id": d.id, "mode": "public"}) + return + + # authenticated path + if not current_user.is_authenticated: + emit("error", {"message": "unauthorized"}) + disconnect() + return + + if not has_permission(current_user, "dashboard", d.id, "view", d.owner_id): + emit("error", {"message": "forbidden"}) + disconnect() + return + + join_room(f"dashboard:{d.id}") + emit("joined", {"dashboard_id": d.id, "mode": "user"}) + + +@socketio.on("leave_dashboard") +def sio_leave_dashboard(data): + data = data or {} + dashboard_id = int(data.get("dashboard_id") or 0) + if dashboard_id: + leave_room(f"dashboard:{dashboard_id}") + return True + + +@bp.get("/health") +def health(): + return jsonify({"ok": True}) \ No newline at end of file diff --git a/mikromon/cli.py b/mikromon/cli.py new file mode 100644 index 0000000..21ae55a --- /dev/null +++ b/mikromon/cli.py @@ -0,0 +1,43 @@ +import click +from flask.cli import with_appcontext +from . import db +from .models import User +from .security.passwords import hash_password +from .security.crypto import reencrypt +from .models import Device + +def register_cli(app): + @app.cli.group("users") + def users(): + """User management commands.""" + pass + + @users.command("set-password") + @click.argument("email") + @click.argument("password") + @with_appcontext + def set_password(email, password): + u = User.query.filter_by(email=email.lower().strip()).first() + if not u: + raise click.ClickException("User not found") + u.password_hash = hash_password(password) + db.session.commit() + click.echo("Password updated.") + + @app.cli.group("devices") + def devices(): + """Device maintenance.""" + pass + + @devices.command("rotate-cred-key") + @with_appcontext + def rotate_cred_key(): + # re-encrypt all credentials with new CRED_ENC_KEY, accepting old key via CRED_ENC_KEY_OLD + cnt = 0 + for d in Device.query.all(): + if not d.enc_credentials: + continue + d.enc_credentials = reencrypt(d.enc_credentials) + cnt += 1 + db.session.commit() + click.echo(f"Re-encrypted {cnt} device credential blobs.") diff --git a/mikromon/config.py b/mikromon/config.py new file mode 100644 index 0000000..84dd421 --- /dev/null +++ b/mikromon/config.py @@ -0,0 +1,114 @@ +import os + + +def _bool(name: str, default: bool = False) -> bool: + v = os.getenv(name) + if v is None: + return default + return str(v).lower() in ("1", "true", "yes", "on") + + +def _int(name: str, default: int) -> int: + v = os.getenv(name) + if v is None or v == "": + return default + return int(v) + + +def _float(name: str, default: float) -> float: + v = os.getenv(name) + if v is None or v == "": + return default + return float(v) + + +class Config: + # ------------------------------------------------- + # Core + # ------------------------------------------------- + SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me") + + # prefer DATABASE_URL (Docker / production) + SQLALCHEMY_DATABASE_URI = os.getenv( + "DATABASE_URL", + os.getenv("SQLALCHEMY_DATABASE_URI", "sqlite:///instance/mikromon.db"), + ) + + SQLALCHEMY_TRACK_MODIFICATIONS = _bool("SQLALCHEMY_TRACK_MODIFICATIONS", False) + + # ------------------------------------------------- + # Session + # ------------------------------------------------- + SESSION_TYPE = os.getenv("SESSION_TYPE", "sqlalchemy") + SESSION_SQLALCHEMY_TABLE = os.getenv("SESSION_SQLALCHEMY_TABLE", "sessions") + SESSION_SQLALCHEMY = None # set in app factory + SESSION_PERMANENT = _bool("SESSION_PERMANENT", False) + + SESSION_COOKIE_SECURE = _bool("SESSION_COOKIE_SECURE", False) + SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE", "Lax") + + SESSION_COOKIE_HTTPONLY = _bool("SESSION_COOKIE_HTTPONLY", True) + PERMANENT_SESSION_LIFETIME = _int("PERMANENT_SESSION_LIFETIME", 86400) + + # ------------------------------------------------- + # SocketIO + # ------------------------------------------------- + SOCKETIO_MESSAGE_QUEUE = os.getenv("SOCKETIO_MESSAGE_QUEUE") + SOCKETIO_CORS_ALLOWED_ORIGINS = os.getenv("SOCKETIO_CORS_ALLOWED_ORIGINS", "*") + + # ------------------------------------------------- + # Dev poller + # ------------------------------------------------- + DEV_INPROCESS_POLLER = _bool("DEV_INPROCESS_POLLER", True) + + # ------------------------------------------------- + # SMTP / Email + # ------------------------------------------------- + SMTP_HOST = os.getenv("SMTP_HOST", "") + SMTP_PORT = _int("SMTP_PORT", 587) + + SMTP_USER = os.getenv("SMTP_USER", "") + SMTP_PASS = os.getenv("SMTP_PASS", "") + + SMTP_USE_TLS = _bool("SMTP_USE_TLS", True) + SMTP_USE_SSL = _bool("SMTP_USE_SSL", False) + + SMTP_FROM = os.getenv("SMTP_FROM", "no-reply@mikromon.local") + + # ------------------------------------------------- + # Limits / quotas + # ------------------------------------------------- + USER_MAX_DEVICES = _int("USER_MAX_DEVICES", 20) + USER_MIN_REFRESH_SECONDS = _int("USER_MIN_REFRESH_SECONDS", 2) + + # ------------------------------------------------- + # RouterOS + # ------------------------------------------------- + ROUTEROS_REST_TIMEOUT = _float("ROUTEROS_REST_TIMEOUT", 6) + + ROUTEROS_VERIFY_TLS = _bool("ROUTEROS_VERIFY_TLS", False) + + # ------------------------------------------------- + # Encryption + # ------------------------------------------------- + CRED_ENC_KEY = os.getenv("CRED_ENC_KEY", "") + CRED_ENC_KEY_OLD = os.getenv("CRED_ENC_KEY_OLD", "") + + # ------------------------------------------------- + # Rate limiting + # ------------------------------------------------- + LIMIT_LOGIN = os.getenv("LIMIT_LOGIN", "10 per minute") + LIMIT_RESET = os.getenv("LIMIT_RESET", "5 per hour") + + RATELIMIT_STORAGE_URI = os.getenv("RATELIMIT_STORAGE_URI", "memory://") + + # ------------------------------------------------- + # App behaviour + # ------------------------------------------------- + APP_NAME = os.getenv("APP_NAME", "MikroMon") + + ENABLE_PUBLIC_DASHBOARDS = _bool("ENABLE_PUBLIC_DASHBOARDS", True) + + DEFAULT_WIDGET_REFRESH = _int("DEFAULT_WIDGET_REFRESH", 2) + + METRICS_HISTORY_LIMIT = _int("METRICS_HISTORY_LIMIT", 900) \ No newline at end of file diff --git a/mikromon/connectors/base.py b/mikromon/connectors/base.py new file mode 100644 index 0000000..f802012 --- /dev/null +++ b/mikromon/connectors/base.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from typing import Any + +@dataclass +class FetchResult: + ok: bool + data: Any = None + error: str|None = None + +class Connector: + name = "base" + + def test(self, device, creds: dict) -> FetchResult: + raise NotImplementedError + + def fetch(self, device, creds: dict, query: dict) -> FetchResult: + raise NotImplementedError + + def discover(self, device, creds: dict) -> FetchResult: + """Basic capabilities discovery: RouterOS version + common resources.""" + raise NotImplementedError diff --git a/mikromon/connectors/registry.py b/mikromon/connectors/registry.py new file mode 100644 index 0000000..4280679 --- /dev/null +++ b/mikromon/connectors/registry.py @@ -0,0 +1,13 @@ +from .routeros_rest import RouterOSRestConnector +from .routeros_ssh import RouterOSSshConnector + +REGISTRY = { + "rest": RouterOSRestConnector(), + "ssh": RouterOSSshConnector(), +} + +def get_connector(name: str): + c = REGISTRY.get(name) + if not c: + raise KeyError(f"Unknown connector: {name}") + return c diff --git a/mikromon/connectors/routeros_rest.py b/mikromon/connectors/routeros_rest.py new file mode 100644 index 0000000..25afcd6 --- /dev/null +++ b/mikromon/connectors/routeros_rest.py @@ -0,0 +1,54 @@ +import httpx +from typing import Any +from flask import current_app +from .base import Connector, FetchResult + +class RouterOSRestConnector(Connector): + name = "rest" + + def _client(self, device): + verify = not device.allow_insecure_tls + timeout = current_app.config.get("ROUTEROS_REST_TIMEOUT", 6) + return httpx.Client(verify=verify, timeout=timeout) + + def _url(self, device, endpoint: str) -> str: + base = device.rest_base_path.strip("/") + ep = endpoint.strip("/") + return f"https://{device.host}:{device.rest_port}/{base}/{ep}" + + def test(self, device, creds: dict) -> FetchResult: + try: + with self._client(device) as c: + r = c.get(self._url(device, "/system/resource"), auth=(creds["username"], creds["password"])) + r.raise_for_status() + return FetchResult(ok=True, data={"status": "ok"}) + except Exception as e: + return FetchResult(ok=False, error=str(e)) + + def fetch(self, device, creds: dict, query: dict) -> FetchResult: + try: + endpoint = query.get("endpoint", "") + params = query.get("params") or {} + with self._client(device) as c: + r = c.get(self._url(device, endpoint), params=params, auth=(creds["username"], creds["password"])) + r.raise_for_status() + return FetchResult(ok=True, data=r.json()) + except Exception as e: + return FetchResult(ok=False, error=str(e)) + + def discover(self, device, creds: dict) -> FetchResult: + try: + with self._client(device) as c: + r = c.get(self._url(device, "/system/resource"), auth=(creds["username"], creds["password"])) + r.raise_for_status() + res = r.json() + version = res.get("version") or res.get("routeros-version") + # lightweight capabilities (extend later) + caps = { + "routeros_version": version, + "has_rest": True, + "resources": ["system/resource","interface/*","queue/*","ip/firewall/*","ip/dhcp-server/*","routing/*"], + } + return FetchResult(ok=True, data=caps) + except Exception as e: + return FetchResult(ok=False, error=str(e)) diff --git a/mikromon/connectors/routeros_ssh.py b/mikromon/connectors/routeros_ssh.py new file mode 100644 index 0000000..630a3a2 --- /dev/null +++ b/mikromon/connectors/routeros_ssh.py @@ -0,0 +1,60 @@ +import paramiko +from flask import current_app +from .base import Connector, FetchResult + +class RouterOSSshConnector(Connector): + name = "ssh" + + def _connect(self, device, creds: dict): + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # user opted-in by enabling SSH + timeout = float(current_app.config.get("ROUTEROS_REST_TIMEOUT", 6)) + client.connect( + hostname=device.host, + port=device.ssh_port, + username=creds["username"], + password=creds["password"], + timeout=timeout, + banner_timeout=timeout, + auth_timeout=timeout, + ) + return client + + def test(self, device, creds: dict) -> FetchResult: + try: + c = self._connect(device, creds) + c.close() + return FetchResult(ok=True, data={"status":"ok"}) + except Exception as e: + return FetchResult(ok=False, error=str(e)) + + def fetch(self, device, creds: dict, query: dict) -> FetchResult: + # query: {"command": "/system/resource/print"} + cmd = query.get("command") + if not cmd: + return FetchResult(ok=False, error="Missing SSH command") + try: + c = self._connect(device, creds) + try: + _, stdout, stderr = c.exec_command(cmd) + out = stdout.read().decode("utf-8", errors="ignore") + err = stderr.read().decode("utf-8", errors="ignore") + if err.strip(): + return FetchResult(ok=False, error=err.strip()) + return FetchResult(ok=True, data={"raw": out}) + finally: + c.close() + except Exception as e: + return FetchResult(ok=False, error=str(e)) + + def discover(self, device, creds: dict) -> FetchResult: + try: + c = self._connect(device, creds) + try: + _, stdout, _ = c.exec_command(":put [/system/resource/get version]") + version = stdout.read().decode("utf-8", errors="ignore").strip() + return FetchResult(ok=True, data={"routeros_version": version, "has_ssh": True}) + finally: + c.close() + except Exception as e: + return FetchResult(ok=False, error=str(e)) diff --git a/mikromon/errors.py b/mikromon/errors.py new file mode 100644 index 0000000..fc7d5df --- /dev/null +++ b/mikromon/errors.py @@ -0,0 +1,24 @@ +from flask import render_template, g + +def register_error_handlers(app): + for code in (400, 401, 403, 404, 405, 429, 500): + app.register_error_handler(code, _handler(code)) + + @app.route("/favicon.ico") + def favicon(): + return ("", 204) + +def _handler(code): + def handler(err): + title = { + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 429: "Too Many Requests", + 500: "Server Error", + }.get(code, "Error") + desc = getattr(err, "description", None) or "Something went wrong." + return render_template("error.html", code=code, title=title, description=desc, request_id=getattr(g, "request_id", "-")), code + return handler diff --git a/mikromon/forms.py b/mikromon/forms.py new file mode 100644 index 0000000..002412c --- /dev/null +++ b/mikromon/forms.py @@ -0,0 +1,63 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, IntegerField, TextAreaField, SelectField +from wtforms.validators import DataRequired, Email, Length, NumberRange, Optional +from wtforms.validators import Optional, Length + + +class LoginForm(FlaskForm): + email = StringField("Email", validators=[DataRequired(), Email()]) + password = PasswordField("Password", validators=[DataRequired(), Length(min=8, max=128)]) + +class RegisterForm(FlaskForm): + email = StringField("Email", validators=[DataRequired(), Email()]) + password = PasswordField("Password", validators=[DataRequired(), Length(min=8, max=128)]) + +class DeviceForm(FlaskForm): + name = StringField("Name", validators=[DataRequired(), Length(max=120)]) + host = StringField("Host", validators=[DataRequired(), Length(max=255)]) + rest_port = IntegerField("REST Port", validators=[DataRequired(), NumberRange(min=1, max=65535)]) + rest_base_path = StringField("REST Base Path", validators=[DataRequired(), Length(max=64)]) + username = StringField("Username", validators=[DataRequired(), Length(max=128)]) + password = PasswordField("Password", validators=[DataRequired(), Length(max=128)]) + allow_insecure_tls = BooleanField("Allow insecure TLS (self-signed)") + ssh_enabled = BooleanField("Enable SSH connector") + ssh_port = IntegerField("SSH Port", validators=[Optional(), NumberRange(min=1, max=65535)]) + +class EditDeviceForm(DeviceForm): + password = PasswordField("Password", validators=[Optional(), Length(max=128)]) + +class DashboardForm(FlaskForm): + name = StringField("Name", validators=[DataRequired(), Length(max=120)]) + description = StringField("Description", validators=[Optional(), Length(max=500)]) + +class WidgetWizardForm(FlaskForm): + preset_key = SelectField("Preset", validators=[DataRequired()], choices=[]) + title = StringField("Title", validators=[DataRequired(), Length(max=120)]) + refresh_seconds = IntegerField("Refresh (seconds)", validators=[DataRequired(), NumberRange(min=1, max=3600)]) + + # Optional selector for presets that support it (interfaces/queues etc.). + item_name = SelectField("Item", validators=[Optional()], choices=[]) + + # Layout controls + col_span = SelectField( + "Width", + validators=[DataRequired()], + choices=[("12", "Full"), ("6", "Half"), ("4", "1/3"), ("3", "1/4")], + default="6", + ) + height_px = IntegerField("Height (px)", validators=[DataRequired(), NumberRange(min=160, max=1000)], default=260) + # JSON advanced override + query_json = TextAreaField("Query JSON (advanced)", validators=[Optional()]) + +class ForgotPasswordForm(FlaskForm): + email = StringField("Email", validators=[DataRequired(), Email()]) + +class ResetPasswordForm(FlaskForm): + password = PasswordField("New password", validators=[DataRequired(), Length(min=8, max=128)]) + +class ShareForm(FlaskForm): + email = StringField("User email", validators=[DataRequired(), Email()]) + permission = SelectField("Permission", validators=[DataRequired()], choices=[("view","View"),("edit","Edit"),("manage","Manage")]) + +class SmtpTestForm(FlaskForm): + to_email = StringField("To", validators=[DataRequired(), Email()]) diff --git a/mikromon/models.py b/mikromon/models.py new file mode 100644 index 0000000..a8984c9 --- /dev/null +++ b/mikromon/models.py @@ -0,0 +1,163 @@ +import enum +import datetime as dt +from sqlalchemy import UniqueConstraint, Index +from flask_login import UserMixin +from . import db + +class RoleName(str, enum.Enum): + USER = "user" + ADMIN = "admin" + +class ShareTarget(str, enum.Enum): + DEVICE = "device" + DASHBOARD = "dashboard" + +class Permission(str, enum.Enum): + VIEW = "view" + EDIT = "edit" + MANAGE = "manage" + +class Role(db.Model): + __tablename__ = "roles" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(32), unique=True, nullable=False) + +class User(db.Model, UserMixin): + __tablename__ = "users" + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + role_id = db.Column(db.Integer, db.ForeignKey("roles.id"), nullable=False) + created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False) + is_active_flag = db.Column(db.Boolean, default=True, nullable=False) + + role = db.relationship("Role") + + def is_admin(self) -> bool: + return self.role and self.role.name == RoleName.ADMIN.value + +class Device(db.Model): + __tablename__ = "devices" + id = db.Column(db.Integer, primary_key=True) + owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + + name = db.Column(db.String(120), nullable=False) + host = db.Column(db.String(255), nullable=False) + rest_port = db.Column(db.Integer, default=443, nullable=False) + rest_base_path = db.Column(db.String(64), default="/rest", nullable=False) # allow custom www path + api_enabled = db.Column(db.Boolean, default=True, nullable=False) + ssh_enabled = db.Column(db.Boolean, default=False, nullable=False) + ssh_port = db.Column(db.Integer, default=22, nullable=False) + allow_insecure_tls = db.Column(db.Boolean, default=False, nullable=False) + + # encrypted payload: json {"username": "...", "password":"..."} + enc_credentials = db.Column(db.Text, nullable=True) + + last_seen_at = db.Column(db.DateTime, nullable=True) + last_error = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False) + + owner = db.relationship("User") + + __table_args__ = (Index("ix_devices_owner_name", "owner_id", "name"),) + +class Dashboard(db.Model): + __tablename__ = "dashboards" + id = db.Column(db.Integer, primary_key=True) + owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + description = db.Column(db.String(500), nullable=True) + layout = db.Column(db.Text, nullable=True) # json + created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False) + + owner = db.relationship("User") + +class Widget(db.Model): + __tablename__ = "widgets" + id = db.Column(db.Integer, primary_key=True) + dashboard_id = db.Column(db.Integer, db.ForeignKey("dashboards.id"), nullable=False, index=True) + device_id = db.Column(db.Integer, db.ForeignKey("devices.id"), nullable=False, index=True) + + title = db.Column(db.String(120), nullable=False) + widget_type = db.Column(db.String(64), nullable=False) # e.g. timeseries + preset_key = db.Column(db.String(64), nullable=True) # cpu, ram... + query_json = db.Column(db.Text, nullable=False) # json with connector + endpoint + params + refresh_seconds = db.Column(db.Integer, default=2, nullable=False) + series_config = db.Column(db.Text, nullable=True) # json + + # UI/layout + col_span = db.Column(db.Integer, default=6, nullable=False) # Bootstrap grid 1..12 + height_px = db.Column(db.Integer, default=260, nullable=False) + + is_enabled = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False) + + dashboard = db.relationship("Dashboard") + device = db.relationship("Device") + +class Share(db.Model): + __tablename__ = "shares" + id = db.Column(db.Integer, primary_key=True) + target_type = db.Column(db.String(16), nullable=False) # device/dashboard + target_id = db.Column(db.Integer, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + permission = db.Column(db.String(16), nullable=False) # view/edit/manage + created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False) + + user = db.relationship("User") + + __table_args__ = ( + UniqueConstraint("target_type", "target_id", "user_id", name="uq_share_target_user"), + Index("ix_shares_target", "target_type", "target_id"), + ) + +class PublicLink(db.Model): + __tablename__ = "public_links" + id = db.Column(db.Integer, primary_key=True) + token = db.Column(db.String(64), unique=True, nullable=False, index=True) + target_type = db.Column(db.String(16), nullable=False) + target_id = db.Column(db.Integer, nullable=False) + read_only = db.Column(db.Boolean, default=True, nullable=False) + expires_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False) + + __table_args__ = (Index("ix_public_links_target", "target_type", "target_id"),) + +class PasswordResetToken(db.Model): + __tablename__ = "password_reset_tokens" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + token = db.Column(db.String(128), unique=True, nullable=False, index=True) + expires_at = db.Column(db.DateTime, nullable=False) + used_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False) + + user = db.relationship("User") + + def is_valid(self) -> bool: + return self.used_at is None and dt.datetime.utcnow() < self.expires_at + +class AuditLog(db.Model): + __tablename__ = "audit_logs" + id = db.Column(db.Integer, primary_key=True) + actor_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) + action = db.Column(db.String(64), nullable=False) + target_type = db.Column(db.String(32), nullable=True) + target_id = db.Column(db.Integer, nullable=True) + details = db.Column(db.Text, nullable=True) + request_id = db.Column(db.String(64), nullable=True) + ip = db.Column(db.String(64), nullable=True) + created_at = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False) + + actor = db.relationship("User") + +class MetricLastValue(db.Model): + __tablename__ = "metrics_last_values" + id = db.Column(db.Integer, primary_key=True) + device_id = db.Column(db.Integer, db.ForeignKey("devices.id"), nullable=False, index=True) + widget_id = db.Column(db.Integer, db.ForeignKey("widgets.id"), nullable=True, index=True) + key = db.Column(db.String(128), nullable=False) # e.g. "system.cpu.load" + value_json = db.Column(db.Text, nullable=False) + ts = db.Column(db.DateTime, default=dt.datetime.utcnow, nullable=False) + + __table_args__ = (Index("ix_metrics_last_device_key", "device_id", "key"),) diff --git a/mikromon/presets/widget_presets.json b/mikromon/presets/widget_presets.json new file mode 100644 index 0000000..f106baa --- /dev/null +++ b/mikromon/presets/widget_presets.json @@ -0,0 +1,149 @@ +{ + "cpu": { + "title": "CPU Load", + "widget_type": "timeseries", + "connector": "rest", + "endpoint": "/system/resource", + "extract": { "path": "cpu-load", "unit": "%" }, + "refresh_seconds": 2 + }, + + "ram": { + "title": "RAM Usage", + "widget_type": "timeseries", + "connector": "rest", + "endpoint": "/system/resource", + "extract": { + "needs": ["total-memory", "free-memory"], + "calc": "100*(total_memory-free_memory)/total_memory", + "unit": "%" + }, + "refresh_seconds": 2 + }, + + "iface_traffic": { + "title": "Interface Traffic (Rx/Tx)", + "widget_type": "timeseries", + "connector": "rest", + "endpoint": "/interface", + "params": { "name": "" }, + "extract": { + "rx_counter": "rx-byte", + "tx_counter": "tx-byte", + "rate_unit": "bps" + }, + "refresh_seconds": 2 + }, + + "firewall_rates": { + "title": "Firewall Traffic (bytes/s)", + "widget_type": "timeseries", + "connector": "rest", + "endpoint": "/ip/firewall/filter", + "extract": { + "id_key": ".id", + "series_key": "comment", + "value_key": "bytes", + "rate": true, + "unit": "B/s", + "max_series": 12, + "ignore_dynamic": true, + "ignore_disabled": true + }, + "refresh_seconds": 5 + }, + + "firewall_bytes": { + "title": "Firewall Counters (bytes)", + "widget_type": "timeseries", + "connector": "rest", + "endpoint": "/ip/firewall/filter", + "extract": { + "id_key": ".id", + "series_key": "comment", + "value_key": "bytes", + "rate": false, + "unit": "B", + "max_series": 12, + "ignore_dynamic": true, + "ignore_disabled": true + }, + "refresh_seconds": 30 + }, + + "queue_simple_rate": { + "title": "Simple Queues (RX/TX bytes/s)", + "widget_type": "timeseries", + "connector": "rest", + "endpoint": "/queue/simple", + "extract": { + "id_key": ".id", + "series_key": "name", + "value_key": "bytes", + "split_duplex": true, + "duplex_labels": ["rx", "tx"], + "rate": true, + "unit": "B/s", + "max_series": 12, + "ignore_dynamic": true, + "ignore_disabled": true + }, + "refresh_seconds": 5 + }, + + "queue_simple_bytes": { + "title": "Simple Queues (RX/TX bytes)", + "widget_type": "timeseries", + "connector": "rest", + "endpoint": "/queue/simple", + "extract": { + "id_key": ".id", + "series_key": "name", + "value_key": "bytes", + "split_duplex": true, + "duplex_labels": ["rx", "tx"], + "rate": false, + "unit": "B", + "max_series": 12, + "ignore_dynamic": true, + "ignore_disabled": true + }, + "refresh_seconds": 30 + }, + + "queue_tree_rate": { + "title": "Queue Tree (bytes/s)", + "widget_type": "timeseries", + "connector": "rest", + "endpoint": "/queue/tree", + "extract": { + "id_key": ".id", + "series_key": "name", + "value_key": "bytes", + "rate": true, + "unit": "B/s", + "max_series": 12, + "ignore_dynamic": true, + "ignore_disabled": true + }, + "refresh_seconds": 5 + }, + + "queue_tree_bytes": { + "title": "Queue Tree (bytes)", + "widget_type": "timeseries", + "connector": "rest", + "endpoint": "/queue/tree", + "extract": { + "id_key": ".id", + "series_key": "name", + "value_key": "bytes", + "rate": false, + "unit": "B", + "max_series": 12, + "ignore_dynamic": true, + "ignore_disabled": true + }, + "refresh_seconds": 30 + } +} \ No newline at end of file diff --git a/mikromon/security/crypto.py b/mikromon/security/crypto.py new file mode 100644 index 0000000..f1a9f13 --- /dev/null +++ b/mikromon/security/crypto.py @@ -0,0 +1,37 @@ +import json +from cryptography.fernet import Fernet, InvalidToken +from flask import current_app + +def _fernet(): + key = current_app.config.get("CRED_ENC_KEY", "") + if not key: + raise RuntimeError("CRED_ENC_KEY is not set") + return Fernet(key.encode() if isinstance(key, str) else key) + +def _fernet_old(): + key = current_app.config.get("CRED_ENC_KEY_OLD", "") + if not key: + return None + return Fernet(key.encode() if isinstance(key, str) else key) + +def encrypt_json(data: dict) -> str: + f = _fernet() + token = f.encrypt(json.dumps(data).encode("utf-8")) + return token.decode("utf-8") + +def decrypt_json(token: str) -> dict: + f = _fernet() + try: + raw = f.decrypt(token.encode("utf-8")) + return json.loads(raw.decode("utf-8")) + except InvalidToken: + # allow old key during rotation + f_old = _fernet_old() + if not f_old: + raise + raw = f_old.decrypt(token.encode("utf-8")) + return json.loads(raw.decode("utf-8")) + +def reencrypt(token: str) -> str: + data = decrypt_json(token) + return encrypt_json(data) diff --git a/mikromon/security/passwords.py b/mikromon/security/passwords.py new file mode 100644 index 0000000..ee2ffa9 --- /dev/null +++ b/mikromon/security/passwords.py @@ -0,0 +1,13 @@ +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +_ph = PasswordHasher() + +def hash_password(password: str) -> str: + return _ph.hash(password) + +def verify_password(hash_: str, password: str) -> bool: + try: + return _ph.verify(hash_, password) + except VerifyMismatchError: + return False diff --git a/mikromon/services/__init__.py b/mikromon/services/__init__.py new file mode 100644 index 0000000..c42d591 --- /dev/null +++ b/mikromon/services/__init__.py @@ -0,0 +1 @@ +from . import audit diff --git a/mikromon/services/acl.py b/mikromon/services/acl.py new file mode 100644 index 0000000..9223fe8 --- /dev/null +++ b/mikromon/services/acl.py @@ -0,0 +1,16 @@ +from ..models import Share, Permission, ShareTarget + +def permission_rank(p: str) -> int: + return {"view": 1, "edit": 2, "manage": 3}.get(p, 0) + +def has_permission(user, target_type: str, target_id: int, required: str, owner_id: int) -> bool: + if user.is_authenticated and getattr(user, "is_admin", lambda: False)(): + return True + if user.is_authenticated and user.id == owner_id: + return True + if not user.is_authenticated: + return False + share = Share.query.filter_by(target_type=target_type, target_id=target_id, user_id=user.id).first() + if not share: + return False + return permission_rank(share.permission) >= permission_rank(required) diff --git a/mikromon/services/audit.py b/mikromon/services/audit.py new file mode 100644 index 0000000..376bbbf --- /dev/null +++ b/mikromon/services/audit.py @@ -0,0 +1,21 @@ +from flask import request, g +from flask_login import current_user +from .. import db +from ..models import AuditLog + +def log(action: str, target_type: str|None=None, target_id: int|None=None, details: str|None=None): + try: + actor_id = current_user.id if current_user.is_authenticated else None + except Exception: + actor_id = None + entry = AuditLog( + actor_user_id=actor_id, + action=action, + target_type=target_type, + target_id=target_id, + details=details, + request_id=getattr(g, "request_id", None), + ip=request.headers.get("X-Forwarded-For", request.remote_addr), + ) + db.session.add(entry) + db.session.commit() diff --git a/mikromon/services/bootstrap.py b/mikromon/services/bootstrap.py new file mode 100644 index 0000000..9a3579c --- /dev/null +++ b/mikromon/services/bootstrap.py @@ -0,0 +1,29 @@ +from .. import db +from ..models import Role, RoleName, User +from ..security.passwords import hash_password + +from sqlalchemy import inspect +from sqlalchemy.exc import OperationalError + +def ensure_default_roles_and_admin(): + try: + insp = inspect(db.engine) + if not insp.has_table("roles"): + return + except OperationalError: + return + # roles + for rn in (RoleName.USER.value, RoleName.ADMIN.value): + if not Role.query.filter_by(name=rn).first(): + db.session.add(Role(name=rn)) + db.session.commit() + + # admin + admin_role = Role.query.filter_by(name=RoleName.ADMIN.value).first() + if not User.query.filter_by(email="admin@example.com").first(): + db.session.add(User( + email="admin@example.com", + password_hash=hash_password("Admin123!"), + role_id=admin_role.id + )) + db.session.commit() diff --git a/mikromon/services/mail.py b/mikromon/services/mail.py new file mode 100644 index 0000000..65f3993 --- /dev/null +++ b/mikromon/services/mail.py @@ -0,0 +1,39 @@ +import smtplib +from email.message import EmailMessage +from flask import current_app, render_template + +def send_mail(to_email: str, subject: str, template: str, **ctx): + app = current_app + host = app.config.get("SMTP_HOST") + if not host: + raise RuntimeError("SMTP_HOST not configured") + port = app.config.get("SMTP_PORT") + user = app.config.get("SMTP_USER") + pw = app.config.get("SMTP_PASS") + use_tls = app.config.get("SMTP_USE_TLS") + use_ssl = app.config.get("SMTP_USE_SSL") + from_email = app.config.get("SMTP_FROM") + + html = render_template(template, **ctx) + + msg = EmailMessage() + msg["From"] = from_email + msg["To"] = to_email + msg["Subject"] = subject + msg.set_content("This email requires HTML support.") + msg.add_alternative(html, subtype="html") + + if use_ssl: + smtp = smtplib.SMTP_SSL(host, port, timeout=10) + else: + smtp = smtplib.SMTP(host, port, timeout=10) + try: + smtp.ehlo() + if use_tls and not use_ssl: + smtp.starttls() + smtp.ehlo() + if user: + smtp.login(user, pw) + smtp.send_message(msg) + finally: + smtp.quit() diff --git a/mikromon/services/poller.py b/mikromon/services/poller.py new file mode 100644 index 0000000..d940134 --- /dev/null +++ b/mikromon/services/poller.py @@ -0,0 +1,394 @@ +import json +import time +import datetime as dt +from apscheduler.schedulers.background import BackgroundScheduler +from flask import current_app +from sqlalchemy.orm import joinedload + +from .. import db, socketio +from ..models import Widget, MetricLastValue +from ..security.crypto import decrypt_json +from ..connectors.registry import get_connector + +_scheduler = None +_last_emit = {} # (device_id, widget_id) -> datetime +_last_counters = {} # (device_id, iface_name) -> {"ts": float, "rx": int, "tx": int} +_last_series_counters = {} # (device_id, widget_id, series_id) -> {"ts": float, "v": float} + + +def start_dev_scheduler(app): + global _scheduler + if _scheduler: + return + _scheduler = BackgroundScheduler(daemon=True) + _scheduler.add_job(lambda: _tick(app), "interval", seconds=1, max_instances=1, coalesce=True) + _scheduler.start() + + +def _tick(app): + with app.app_context(): + now = dt.datetime.utcnow() + widgets = ( + db.session.query(Widget) + .options(joinedload(Widget.device), joinedload(Widget.dashboard)) + .filter(Widget.is_enabled.is_(True)) + .all() + ) + + min_refresh = current_app.config.get("USER_MIN_REFRESH_SECONDS", 2) + + for w in widgets: + if w.refresh_seconds < min_refresh: + continue + + key = (w.device_id, w.id) + last = _last_emit.get(key) + if last and (now - last).total_seconds() < w.refresh_seconds: + continue + + _last_emit[key] = now + _poll_widget(w) + + +def _poll_widget(widget: Widget): + device = widget.device + if not device or not device.enc_credentials: + return + + try: + creds = decrypt_json(device.enc_credentials) + except Exception: + device.last_error = "Credential decrypt failed (check CRED_ENC_KEY)" + db.session.commit() + return + + try: + q = json.loads(widget.query_json or "{}") + if not isinstance(q, dict): + raise ValueError() + except Exception: + device.last_error = "Invalid widget query JSON" + db.session.commit() + return + + connector = get_connector(q.get("connector", "rest")) + + res = connector.fetch(device, creds, q) + if not res.ok: + device.last_error = res.error + db.session.commit() + return + + device.last_error = None + device.last_seen_at = dt.datetime.utcnow() + db.session.commit() + + payload = _transform(widget, res.data, q) + if payload is None: + return + + db.session.add( + MetricLastValue( + device_id=device.id, + widget_id=widget.id, + key=f"widget.{widget.id}", + value_json=json.dumps(payload), + ts=dt.datetime.utcnow(), + ) + ) + db.session.commit() + + socketio.emit("metric", payload, room=f"dashboard:{widget.dashboard_id}") + socketio.emit("metric", payload, room=f"device:{device.id}") + + +def _transform(widget: Widget, data, query: dict): + now_ms = int(time.time() * 1000) + extract = query.get("extract") or {} + + # ---- TABLE ---- + if widget.widget_type == "table": + cols = extract.get("columns") or [] + rows = [] + if isinstance(data, list): + for item in data[:200]: + if isinstance(item, dict): + rows.append({c: item.get(c) for c in cols}) + elif isinstance(data, dict): + rows.append({c: data.get(c) for c in cols}) + + return { + "type": "table", + "widget_id": widget.id, + "dashboard_id": widget.dashboard_id, + "device_id": widget.device_id, + "title": widget.title, + "ts": now_ms, + "columns": cols, + "rows": rows, + } + + # ---- TIMESERIES ---- + points = [] + + def _add(series, value, unit): + if value is None: + return + try: + points.append({"series": str(series), "value": float(value), "unit": unit}) + except Exception: + return + + def _ctx_from_needs(src: dict, needs: list): + ctx = {} + for k in needs: + if not isinstance(k, str): + continue + val = _to_float(src.get(k)) + ctx[k] = val + if "-" in k: + ctx[k.replace("-", "_")] = val + if "_" in k: + ctx[k.replace("_", "-")] = val + return ctx + + def _ram_percent(src: dict): + total = _to_float(src.get("total-memory")) + free = _to_float(src.get("free-memory")) + if total is None or free is None: + total = _to_float(src.get("total_memory")) + free = _to_float(src.get("free_memory")) + if total is None or free is None or total <= 0: + return None + return 100.0 * (total - free) / total + + def _parse_duplex_counter(v): + """ + RouterOS potrafi dawać bytes jako "a/b" (simple queues). + Zwraca (a,b) jako float albo (None,None). + """ + if v is None: + return (None, None) + if isinstance(v, (int, float)): + return (float(v), None) + s = str(v).strip() + if "/" not in s: + return (_to_float(s), None) + a, b = s.split("/", 1) + return (_to_float(a), _to_float(b)) + + # ---- dict ---- + if isinstance(data, dict): + if "path" in extract: + _add(widget.title, _to_float(data.get(extract["path"])), extract.get("unit")) + + if "calc" in extract and "needs" in extract: + try: + ctx = _ctx_from_needs(data, extract.get("needs") or []) + v = _safe_calc(extract["calc"], ctx) + _add(widget.title, v, extract.get("unit")) + except Exception: + pass + + # RAM fallback + if not points and (extract.get("unit") == "%") and ("ram" in widget.title.lower() or "memory" in widget.title.lower()): + _add(widget.title, _ram_percent(data), "%") + + # ---- list ---- + elif isinstance(data, list): + + # A) Multi-series (firewall/queues) with optional rate(delta) + if "series_key" in extract and "value_key" in extract: + label_key = extract["series_key"] # label e.g. comment/name + value_key = extract["value_key"] # bytes + id_key = extract.get("id_key", ".id") # stable ID + want_rate = bool(extract.get("rate")) + unit = extract.get("unit") or ("B/s" if want_rate else "B") + max_series = int(extract.get("max_series", 20)) + + ignore_dynamic = bool(extract.get("ignore_dynamic", True)) + ignore_disabled = bool(extract.get("ignore_disabled", False)) + + split_duplex = bool(extract.get("split_duplex", False)) + duplex_labels = extract.get("duplex_labels") or ["rx", "tx"] # a/b + + def _label(it: dict): + lab = it.get(label_key) + if lab is not None and str(lab).strip(): + return str(lab).strip() + ch = str(it.get("chain") or "").strip() + ac = str(it.get("action") or "").strip() + rid = str(it.get(id_key) or "").strip() + base = ":".join([x for x in [ch, ac, rid] if x]) + return base or "unnamed" + + items = [] + for it in data: + if not isinstance(it, dict): + continue + if ignore_dynamic and str(it.get("dynamic", "false")).lower() == "true": + continue + if ignore_disabled and str(it.get("disabled", "false")).lower() == "true": + continue + + rid = it.get(id_key) + if rid is None: + continue + + v_raw = it.get(value_key) + lab = _label(it) + + if split_duplex: + a, b = _parse_duplex_counter(v_raw) + if a is not None: + items.append((f"{rid}:a", f"{lab} {duplex_labels[0]}", a)) + if b is not None: + items.append((f"{rid}:b", f"{lab} {duplex_labels[1]}", b)) + else: + v = _to_float(v_raw) + if v is not None: + items.append((str(rid), lab, v)) + + # top N po liczniku (dla duplex liczymy po wartości serii) + items.sort(key=lambda x: x[2], reverse=True) + items = items[:max_series] + + now = time.time() + if want_rate: + for sid, lab, v in items: + key = (widget.device_id, widget.id, sid) + prev = _last_series_counters.get(key) + if prev and now > prev["ts"]: + dt_s = now - prev["ts"] + dv = v - prev["v"] + if dv >= 0: + _add(lab, dv / dt_s, unit) + _last_series_counters[key] = {"ts": now, "v": v} + else: + for sid, lab, v in items: + _add(lab, v, unit) + + # B) Select one element (interfaces) + params = query.get("params") or {} + name = params.get("name") + target = None + + if name: + for it in data: + if isinstance(it, dict) and it.get("name") == name: + target = it + break + target = target or (data[0] if data else None) + + if target and isinstance(target, dict): + # interface rate from counters + if "rx_counter" in extract or "tx_counter" in extract: + iface = target.get("name") or name or "iface" + rx_c = _to_int(target.get(extract.get("rx_counter", ""))) + tx_c = _to_int(target.get(extract.get("tx_counter", ""))) + now = time.time() + key = (widget.device_id, str(iface)) + prev = _last_counters.get(key) + + rate_unit = extract.get("rate_unit", "B/s") + + def _to_unit(bytes_per_s, unit_str): + if bytes_per_s is None: + return None + if str(unit_str).lower() == "bps": + return bytes_per_s * 8.0 + return bytes_per_s + + if prev and prev.get("ts") and now > prev["ts"] and rx_c is not None and tx_c is not None: + dt_s = now - prev["ts"] + rx_rate_b = (rx_c - prev["rx"]) / dt_s + tx_rate_b = (tx_c - prev["tx"]) / dt_s + if rx_rate_b >= 0: + _add("rx", _to_unit(rx_rate_b, rate_unit), rate_unit) + if tx_rate_b >= 0: + _add("tx", _to_unit(tx_rate_b, rate_unit), rate_unit) + + if rx_c is not None and tx_c is not None: + _last_counters[key] = {"ts": now, "rx": rx_c, "tx": tx_c} + + if "path" in extract: + _add(widget.title, _to_float(target.get(extract["path"])), extract.get("unit")) + + if "calc" in extract and "needs" in extract: + try: + ctx = _ctx_from_needs(target, extract.get("needs") or []) + v = _safe_calc(extract["calc"], ctx) + _add(widget.title, v, extract.get("unit")) + except Exception: + pass + + if not points: + return None + + return { + "type": "timeseries", + "widget_id": widget.id, + "dashboard_id": widget.dashboard_id, + "device_id": widget.device_id, + "title": widget.title, + "ts": now_ms, + "points": points, + } + + +def _to_float(v): + try: + if v is None: + return None + if isinstance(v, (int, float)): + return float(v) + s = str(v).strip() + if s.endswith("%"): + s = s[:-1] + return float(s) + except Exception: + return None + + +def _to_int(v): + try: + if v is None: + return None + if isinstance(v, int): + return v + if isinstance(v, float): + return int(v) + return int(str(v).strip()) + except Exception: + return None + + +def _safe_calc(expr: str, ctx: dict): + import ast + import operator as op + + allowed_ops = { + ast.Add: op.add, + ast.Sub: op.sub, + ast.Mult: op.mul, + ast.Div: op.truediv, + ast.Pow: op.pow, + ast.USub: op.neg, + ast.Mod: op.mod, + } + + def _eval(node): + if isinstance(node, ast.Num): + return node.n + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): + return node.value + if isinstance(node, ast.Name): + return ctx[node.id] + if isinstance(node, ast.BinOp): + return allowed_ops[type(node.op)](_eval(node.left), _eval(node.right)) + if isinstance(node, ast.UnaryOp): + return allowed_ops[type(node.op)](_eval(node.operand)) + raise ValueError("Unsafe expression") + + tree = ast.parse(expr, mode="eval") + return _eval(tree.body) \ No newline at end of file diff --git a/mikromon/services/presets.py b/mikromon/services/presets.py new file mode 100644 index 0000000..8ff6570 --- /dev/null +++ b/mikromon/services/presets.py @@ -0,0 +1,6 @@ +import json +from pathlib import Path + +def load_presets() -> dict: + fp = Path(__file__).resolve().parents[1] / "presets" / "widget_presets.json" + return json.loads(fp.read_text(encoding="utf-8")) diff --git a/mikromon/template_helpers.py b/mikromon/template_helpers.py new file mode 100644 index 0000000..74f8bd0 --- /dev/null +++ b/mikromon/template_helpers.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from flask import current_app, url_for + + +def get_file_hash(filepath: Path) -> str: + """Generate MD5 hash for cache busting""" + try: + if filepath.exists(): + with open(filepath, "rb") as f: + return hashlib.md5(f.read()).hexdigest()[:8] + except Exception: + pass + return "v1" + + +def static_url(path: str) -> str: + """ + Returns /static/?v= + Works even if static folder is outside the package (symlink etc.). + """ + path = path.lstrip("/") + + static_folder = Path(current_app.static_folder) # absolute path + file_path = static_folder / path + v = get_file_hash(file_path) + + return url_for("static", filename=path, v=v) + + +def register(app): + app.jinja_env.globals["static_url"] = static_url \ No newline at end of file diff --git a/mikromon/templates b/mikromon/templates new file mode 120000 index 0000000..564a409 --- /dev/null +++ b/mikromon/templates @@ -0,0 +1 @@ +../templates \ No newline at end of file diff --git a/mikromon/utils/static.py b/mikromon/utils/static.py new file mode 100644 index 0000000..206e0f6 --- /dev/null +++ b/mikromon/utils/static.py @@ -0,0 +1,39 @@ +import hashlib +from pathlib import Path +from flask import url_for + +def get_file_hash(filepath: Path) -> str: + """Generate MD5 hash for cache busting""" + try: + if filepath.exists(): + with open(filepath, "rb") as f: + return hashlib.md5(f.read()).hexdigest()[:8] + except Exception: + pass + return "v1" + +def static_url(path: str) -> str: + # path is like 'css/app.css' + static_folder = Path(__file__).resolve().parents[2] / "static" + fp = static_folder / path + v = get_file_hash(fp) + return url_for("static", filename=path, v=v) + +def init_static_cache_headers(app): + @app.after_request + def _static_cache(resp): + # Long cache for hashed static. Safe because we add ?v= + if resp.direct_passthrough: + return resp + try: + if resp.status_code == 200 and (resp.mimetype.startswith("text/") or resp.mimetype in ("application/javascript","application/json","image/svg+xml")): + pass + except Exception: + pass + if request_is_static(): + resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return resp + + def request_is_static() -> bool: + from flask import request + return request.path.startswith("/static/") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ea91d12 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +Flask==3.0.3 +Flask-SQLAlchemy==3.1.1 +Flask-Migrate==4.0.7 +Flask-Login==0.6.3 +Flask-WTF==1.2.1 +Flask-Limiter==3.7.0 +Flask-Session==0.8.0 +Flask-SocketIO==5.3.6 +simple-websocket==1.0.0 +python-dotenv==1.0.1 +argon2-cffi==23.1.0 +cryptography==43.0.1 +requests==2.32.3 +httpx==0.27.2 +paramiko==3.5.0 +APScheduler==3.10.4 +redis==5.0.8 +rq==1.16.2 +email-validator==2.2.0 +itsdangerous==2.2.0 +pytest==8.3.2 +pytest-flask==1.3.0 +coverage==7.6.1 \ No newline at end of file diff --git a/scripts/set_admin_password.py b/scripts/set_admin_password.py new file mode 100644 index 0000000..c628956 --- /dev/null +++ b/scripts/set_admin_password.py @@ -0,0 +1,30 @@ +import sys +import os +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from mikromon import create_app, db +from mikromon.models import User +from mikromon.security.passwords import hash_password + +def main(): + if len(sys.argv) != 3: + print("Usage: python scripts/set_admin_password.py ") + sys.exit(2) + email = sys.argv[1].lower().strip() + pw = sys.argv[2] + app = create_app() + with app.app_context(): + u = User.query.filter_by(email=email).first() + if not u: + print("User not found") + sys.exit(1) + u.password_hash = hash_password(pw) + db.session.commit() + print("Password updated.") + +if __name__ == "__main__": + main() diff --git a/static/css/app.css b/static/css/app.css new file mode 100644 index 0000000..06cae4e --- /dev/null +++ b/static/css/app.css @@ -0,0 +1,10 @@ +.chart-wrap{position:relative;height:260px} +.chart-wrap canvas{width:100%!important;height:260px!important} + +/* Layout helpers */ +.chart-wrap{height:260px;} +.chart{width:100% !important; height:100% !important;} +.truncate-40{max-width:40ch;} + +/* Small UI tweaks */ +pre{white-space:pre-wrap;word-break:break-word;} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..d3beb7c --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,204 @@ +(function(){ + function ringPush(arr, item, max){ + arr.push(item); + if(arr.length > max) arr.splice(0, arr.length-max); + } + function ensureLibs(){ + return new Promise((resolve)=>{ + const tick=()=>{ if(window.io && window.Chart) return resolve(); setTimeout(tick,50); }; + tick(); + }); + } + + const charts=new Map(); + const seriesData=new Map(); // widgetId -> {name: [{x,y}]} + const seriesUnit=new Map(); // widgetId -> {name: unit} + const maxPoints=900; + + function isNum(v){ return typeof v==='number' && Number.isFinite(v); } + + function scale(v, unit){ + if(!isNum(v)) return {v, u:''}; + const u=(unit||'').toLowerCase(); + + if(u==='%' || u==='pct' || u==='percent') return {v, u:'%'}; + + if(u==='bps' || u==='bit/s' || u==='bits/s'){ + const steps=['bps','Kbps','Mbps','Gbps','Tbps']; + let i=0, x=v; + while(Math.abs(x)>=1000 && i=1024 && i=1024 && i=100 ? s.v.toFixed(0) : a>=10 ? s.v.toFixed(1) : s.v.toFixed(2); + return n + (s.u ? ' '+s.u : ''); + } + + function hash32(str){ + let h=2166136261; + for(let i=0;i>>0; + } + function colorFor(name){ + const hue = (hash32(String(name)) % 360); + return { + border:`hsl(${hue} 70% 45%)`, + fill:`hsl(${hue} 70% 45% / 0.20)` + }; + } + + function upsertChart(widgetId){ + const el=document.getElementById('chart-'+widgetId); + if(!el) return null; + if(charts.has(widgetId)) return charts.get(widgetId); + + const chart=new Chart(el.getContext('2d'),{ + type:'line', + data:{datasets:[]}, + options:{ + animation:false, + parsing:false, + normalized:true, + responsive:true, + maintainAspectRatio:false, + interaction:{mode:'nearest', intersect:false}, + scales:{ + x:{type:'time', time:{unit:'minute'}}, + y:{ + beginAtZero:false, + ticks:{ + callback:(tick)=>{ + // jeśli w widżecie jest 1 unit -> formatuj, jeśli różne -> surowo + const uMap = seriesUnit.get(widgetId) || {}; + const units = Object.values(uMap).filter(Boolean); + const uniq = [...new Set(units)]; + const u = uniq.length===1 ? uniq[0] : ''; + return u ? fmt(Number(tick), u) : tick; + } + } + } + }, + plugins:{ + legend:{display:true}, + tooltip:{ + callbacks:{ + label:(ctx)=>{ + const name=ctx.dataset.label||''; + const y=ctx.parsed && isNum(ctx.parsed.y) ? ctx.parsed.y : null; + const u=(seriesUnit.get(widgetId)||{})[name] || ''; + return name+': '+(y===null?'':fmt(y,u)); + } + } + } + }, + elements:{point:{radius:0}} + } + }); + + charts.set(widgetId, chart); + seriesData.set(widgetId, {}); + seriesUnit.set(widgetId, {}); + return chart; + } + + function setMeta(widgetId, txt){ + const m=document.getElementById('meta-'+widgetId); + if(m) m.textContent=txt; + } + + function escapeHtml(s){ + return String(s) + .replaceAll('&','&') + .replaceAll('<','<') + .replaceAll('>','>') + .replaceAll('"','"') + .replaceAll("'",'''); + } + + function handleTable(msg){ + const tbl=document.querySelector("table[data-table-widget='"+msg.widget_id+"']"); + if(!tbl) return; + const thead=tbl.querySelector('thead'); + const tbody=tbl.querySelector('tbody'); + const cols=msg.columns||[]; + thead.innerHTML=''+cols.map(c=>''+escapeHtml(c)+'').join('')+''; + tbody.innerHTML=(msg.rows||[]).map(r=>''+cols.map(c=>''+escapeHtml(r[c] ?? '')+'').join('')+'').join(''); + } + + function handleTimeseries(msg){ + const chart=upsertChart(msg.widget_id); + if(!chart) return; + + const t=new Date(msg.ts); + const store=seriesData.get(msg.widget_id) || {}; + const uMap=seriesUnit.get(msg.widget_id) || {}; + + for(const p of (msg.points||[])){ + if(p.value===null || p.value===undefined) continue; + const s=p.series || 'value'; + if(!store[s]) store[s]=[]; + ringPush(store[s], {x:t, y:Number(p.value)}, maxPoints); + if(p.unit) uMap[s]=String(p.unit); + } + + seriesData.set(msg.widget_id, store); + seriesUnit.set(msg.widget_id, uMap); + + const names = Object.keys(store); + chart.data.datasets = names.map(name=>{ + const c=colorFor(name); + return { + label:name, + data:store[name], + borderWidth:2, + pointRadius:0, + tension:0.15, + fill:true, + borderColor:c.border, + backgroundColor:c.fill + }; + }); + + chart.update('none'); + setMeta(msg.widget_id, 'Updated: '+t.toLocaleTimeString()); + } + + async function main(){ + if(!window.MIKROMON || !window.MIKROMON.dashboardId) return; + await ensureLibs(); + + const socket=io({transports:['polling'], upgrade:false}); + socket.on('connect', ()=>{ + socket.emit('join_dashboard', {dashboard_id: window.MIKROMON.dashboardId, public_token: window.MIKROMON.publicToken}); + }); + socket.on('metric', (msg)=>{ + if(!msg || !msg.widget_id) return; + if(msg.type==='table') return handleTable(msg); + return handleTimeseries(msg); + }); + } + + document.addEventListener('DOMContentLoaded', main); +})(); \ No newline at end of file diff --git a/templates/admin/audit.html b/templates/admin/audit.html new file mode 100644 index 0000000..63fc527 --- /dev/null +++ b/templates/admin/audit.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Audit - Admin - MikroMon{% endblock %} +{% block content %} +
+
+

Audit log

+
Last logs (limit 200).
+
+ Back +
+ +
+
+ + + + + + + + + + + + {% for l in logs %} + + + + + + + + {% else %} + + {% endfor %} + +
TimeActionTypeIDDetails
{{ l.created_at }}{{ l.action }}{{ l.target_type or '-' }}{{ l.target_id or '-' }}{{ l.details or '' }}
No data.
+
+
+{% endblock %} diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000..6443e10 --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}Admin - MikroMon{% endblock %} +{% block content %} +
+
+

Admin panel

+
Administrative tools and system overview.
+
+
+ Users + Audit + SMTP +
+
+ +
+
+
+
+
Users
+
{{ user_count }}
+
+
+
+
+
+
+
Devices
+
{{ device_count }}
+
+
+
+
+
+
+
Dashboards
+
{{ dashboard_count }}
+
+
+
+
+{% endblock %} diff --git a/templates/admin/smtp.html b/templates/admin/smtp.html new file mode 100644 index 0000000..c263433 --- /dev/null +++ b/templates/admin/smtp.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}SMTP - Admin - MikroMon{% endblock %} +{% block content %} +
+
+

SMTP

+
Test email sending configuration.
+
+ Back +
+ +
+
+
+
+
Send test
+
+ {{ form.hidden_tag() }} +
+ + {{ form.to_email(class_="form-control", placeholder="email@example.com") }} +
Will send HTML email: emails/smtp_test.html
+
+ +
+
+
+
+ +
+
+
+
Wymagane zmienne
+
    +
  • SMTP_HOST, SMTP_PORT
  • +
  • SMTP_FROM
  • +
  • opcjonalnie: SMTP_USER, SMTP_PASS
  • +
  • opcjonalnie: SMTP_USE_TLS / SMTP_USE_SSL
  • +
+
+
+
+
+{% endblock %} diff --git a/templates/admin/users.html b/templates/admin/users.html new file mode 100644 index 0000000..c17d237 --- /dev/null +++ b/templates/admin/users.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Users - Admin - MikroMon{% endblock %} +{% block content %} +
+
+

Users

+
Lista kont w systemie.
+
+ Back +
+ +
+
+ + + + + + + + + + + + {% for u in users %} + + + + + + + + {% endfor %} + +
IDEmailRolaStatusUtworzono
{{ u.id }}{{ u.email }}{{ u.role.name }} + {% if u.is_active_flag %} + active + {% else %} + disabled + {% endif %} + {{ u.created_at }}
+
+
+{% endblock %} diff --git a/templates/api/docs.html b/templates/api/docs.html new file mode 100644 index 0000000..e99ac8b --- /dev/null +++ b/templates/api/docs.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}API - MikroMon{% endblock %} +{% block content %} +
+
+

API

+
Endpoints overview (UI reference only).
+
+ {% if current_user.is_authenticated %} + Back + {% endif %} +
+ +
+
+ + + + + + + + + + {% for r in routes %} + + + + + + {% endfor %} + +
MethodPathDescription
{{ r.method }}{{ r.path }}{{ r.desc }}
+
+
+ +
+ Auth: most endpoints require a logged-in session (cookie). For external integrations use your own reverse-proxy / tokens (MVP has no dedicated API tokens). +
+{% endblock %} diff --git a/templates/auth/forgot.html b/templates/auth/forgot.html new file mode 100644 index 0000000..82c35d2 --- /dev/null +++ b/templates/auth/forgot.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Password reset - MikroMon{% endblock %} +{% block content %} +
+
+
+
+

Password reset

+
Enter your email. If the account exists, we'll send a reset link.
+
+ {{ form.hidden_tag() }} +
+ + {{ form.email(class_="form-control", placeholder="email@example.com") }} +
+ +
+ +
+
+
+
+{% endblock %} diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..1678d75 --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Login - MikroMon{% endblock %} +{% block content %} +
+
+
+
+

Login

+
+ {{ form.hidden_tag() }} +
+ + {{ form.email(class_="form-control", placeholder="email@example.com") }} +
+
+ + {{ form.password(class_="form-control", placeholder="••••••••") }} +
+ +
+
+ No account? Register
+ Forgot password? Reset +
+
+
+
+
+{% endblock %} diff --git a/templates/auth/register.html b/templates/auth/register.html new file mode 100644 index 0000000..0993b9c --- /dev/null +++ b/templates/auth/register.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Register - MikroMon{% endblock %} +{% block content %} +
+
+
+
+

Register

+
+ {{ form.hidden_tag() }} +
+ + {{ form.email(class_="form-control", placeholder="email@example.com") }} +
+
+ + {{ form.password(class_="form-control", placeholder="Min. 8 characters") }} +
+ +
+
+ Already have an account? Login +
+
+
+
+
+{% endblock %} diff --git a/templates/auth/reset.html b/templates/auth/reset.html new file mode 100644 index 0000000..38bda04 --- /dev/null +++ b/templates/auth/reset.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Set new password - MikroMon{% endblock %} +{% block content %} +
+
+
+
+

Set new password

+
This link is valid for a limited time.
+
+ {{ form.hidden_tag() }} +
+ + {{ form.password(class_="form-control", placeholder="••••••••") }} +
Min. 8 characters.
+
+ +
+ +
+
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..0801f46 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,101 @@ + + + + + + {% block title %}MikroMon{% endblock %} + + + + + + {% block head %}{% endblock %} + + + + + +{% if current_user.is_authenticated %} +
+
+
MikroMon
+ +
+
+
+ Dashboards + Devices + API + {% if current_user.is_admin() %} + Admin + {% endif %} +
+
+
+ Signed in as: {{ current_user.email }} +
+
+
+{% endif %} + +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + {% set cls = 'info' %} + {% if category in ['danger','warning','success','info'] %}{% set cls = category %}{% endif %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+
+ + + + + + +{% block scripts %}{% endblock %} + + diff --git a/templates/dashboards/index.html b/templates/dashboards/index.html new file mode 100644 index 0000000..c125a1b --- /dev/null +++ b/templates/dashboards/index.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Dashboards - MikroMon{% endblock %} +{% block content %} +
+
+

Dashboards

+
Your monitoring dashboards.
+
+ New dashboard +
+ +
+ {% for d in dashboards %} +
+
+
+
{{ d.name }}
+
{{ d.description or '' }}
+
+ +
+
+ {% else %} +
No dashboards yet. Create the first one.
+ {% endfor %} +
+{% endblock %} diff --git a/templates/dashboards/new.html b/templates/dashboards/new.html new file mode 100644 index 0000000..7ec3238 --- /dev/null +++ b/templates/dashboards/new.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}New dashboard - MikroMon{% endblock %} +{% block content %} +
+
+
+
+

New dashboard

+
+ {{ form.hidden_tag() }} +
+ + {{ form.name(class_="form-control") }} +
+
+ + {{ form.description(class_="form-control") }} +
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} diff --git a/templates/dashboards/public_view.html b/templates/dashboards/public_view.html new file mode 100644 index 0000000..e9b0338 --- /dev/null +++ b/templates/dashboards/public_view.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}{{ dashboard.name }} - Public - MikroMon{% endblock %} + +{% block head %} + + +{% endblock %} + +{% block content %} +
+

{{ dashboard.name }}

+ + {% if dashboard.description %} +
{{ dashboard.description }}
+ {% endif %} + +
+ + Public view (read-only) +
+
+ +
+ {% for w in widgets %} +
+
+ +
+
{{ w.title }}
+
+ +
+ + {% if w.widget_type == 'table' %} +
+ + + +
+
+ {% else %} +
+ +
+
+ Waiting for data... +
+ {% endif %} + +
+
+
+ + {% else %} +
+
+ No widgets available. +
+
+ {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/dashboards/share.html b/templates/dashboards/share.html new file mode 100644 index 0000000..c3a6219 --- /dev/null +++ b/templates/dashboards/share.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Sharing - {{ dashboard.name }} - MikroMon{% endblock %} +{% block content %} +
+
+

Sharing

+
Dashboard: {{ dashboard.name }}
+
+ Back +
+ +
+
+
+
+
Share with user
+
+ {{ form.hidden_tag() }} +
+
{{ form.email(class_="form-control", placeholder="email@example.com") }}
+
{{ form.permission(class_="form-select") }}
+
+
+
+ +
+ +
Current shares
+
+ + + + {% for s in shares %} + + {% else %} + + {% endfor %} + +
EmailPermission
{{ s.user.email }}{{ s.permission }}
None.
+
+
+
+
+ +
+
+
+
Public link
+ {% if public %} + + {% else %} +
No active public link.
+ {% endif %} + +
+ + +
+
+
+
+
+{% endblock %} diff --git a/templates/dashboards/view.html b/templates/dashboards/view.html new file mode 100644 index 0000000..95a661a --- /dev/null +++ b/templates/dashboards/view.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}{{ dashboard.name }} - MikroMon{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +
+
+

{{ dashboard.name }}

+ {% if dashboard.description %}
{{ dashboard.description }}
{% endif %} +
+ +
+ +
+ {% for w in widgets %} +
+
+
+
{{ w.title }}
+
+ + +
+
+
+ {% if w.widget_type == 'table' %} +
+ + + +
+
+ {% else %} +
+
Waiting for data…
+ {% endif %} +
+
+
+ {% else %} +
No widgets yet. Add the first one.
+ {% endfor %} +
+{% endblock %} diff --git a/templates/dashboards/widget_new.html b/templates/dashboards/widget_new.html new file mode 100644 index 0000000..80828fc --- /dev/null +++ b/templates/dashboards/widget_new.html @@ -0,0 +1,156 @@ +{% extends "base.html" %} +{% block title %}Add widget - {{ dashboard.name }} - MikroMon{% endblock %} +{% block content %} +
+
+

Add widget

+
Dashboard: {{ dashboard.name }}
+
+ Back +
+ +
+
+
+
+
+ {{ form.hidden_tag() }} + +
+ + {{ form.title(class_="form-control") }} +
+ +
+
+ + {{ form.preset_key(class_="form-select", id="presetSelect") }} +
+
+ + +
+
+ +
+ + +
E.g. interface / queue (depends on preset).
+
+ +
+ + {{ form.refresh_seconds(class_="form-control", type="number", min="1") }} +
+ +
+ +
+ {{ form.query_json(class_="form-control font-monospace", rows="6", placeholder='{"connector":"rest","endpoint":"/...","params":{}}') }} +
+
+ +
+ + Cancel +
+
+
+
+
+ +
+
+
+
Wizard mode
+
+ If the preset requires selecting an item (e.g. interface), the list will load automatically after choosing a device. + If you fill the JSON manually, the JSON will be used. +
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/devices/edit.html b/templates/devices/edit.html new file mode 100644 index 0000000..9aa96bb --- /dev/null +++ b/templates/devices/edit.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}Edit device - {{ device.name }} - MikroMon{% endblock %} +{% block content %} +
+
+

Edit device

+
{{ device.name }} ({{ device.host }})
+
+ Back +
+ +
+
+
+
+
+ {{ form.hidden_tag() }} + +
+
+ + {{ form.name(class_="form-control") }} +
+
+ + {{ form.host(class_="form-control") }} +
+ +
+ + {{ form.rest_port(class_="form-control") }} +
+
+ + {{ form.rest_base_path(class_="form-control") }} +
+ +
+ + {{ form.username(class_="form-control") }} +
+
+ + {{ form.password(class_="form-control", placeholder="Leave blank to keep unchanged") }} +
Leave blank to keep the current password.
+
+ +
+
+ {{ form.allow_insecure_tls(class_="form-check-input") }} + +
+
+ +
+
+ {{ form.ssh_enabled(class_="form-check-input", id="sshEnabled") }} + +
+
+
+ + {{ form.ssh_port(class_="form-control") }} +
Used only when SSH is enabled.
+
+
+ +
+ +
+
+
+
+ +
+
+
+
Notes
+
    +
  • Changing credentials updates the encrypted secret stored in the database.
  • +
  • If REST fails, verify host/port/path and TLS setting.
  • +
+
+
+
+
+{% endblock %} diff --git a/templates/devices/index.html b/templates/devices/index.html new file mode 100644 index 0000000..25178e9 --- /dev/null +++ b/templates/devices/index.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Devices - MikroMon{% endblock %} +{% block content %} +
+
+

Devices

+
Routers / hosts to monitor.
+
+ Add device +
+ +
+ {% for d in devices %} +
+
+
+
{{ d.name }}
+
{{ d.host }}
+ {% if d.last_error %} +
{{ d.last_error }}
+ {% endif %} +
+ +
+
+ {% else %} +
No devices.
+ {% endfor %} +
+{% endblock %} diff --git a/templates/devices/new.html b/templates/devices/new.html new file mode 100644 index 0000000..a4a6f94 --- /dev/null +++ b/templates/devices/new.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}New device - MikroMon{% endblock %} +{% block content %} +
+
+

Add device

+
Configure REST/SSH access.
+
+ Back +
+ +
+
+
+
+
+ {{ form.hidden_tag() }} + +
+
+ + {{ form.name(class_="form-control", placeholder="e.g. MikroTik RB4011") }} +
+
+ + {{ form.host(class_="form-control", placeholder="192.168.1.1 or router.example.com") }} +
+ +
+ + {{ form.rest_port(class_="form-control") }} +
+
+ + {{ form.rest_base_path(class_="form-control", placeholder="/rest") }} +
+ +
+ + {{ form.username(class_="form-control") }} +
+
+ + {{ form.password(class_="form-control", placeholder="••••••••") }} +
+ +
+
+ {{ form.allow_insecure_tls(class_="form-check-input") }} + +
+
+ +
+
+ {{ form.ssh_enabled(class_="form-check-input", id="sshEnabled") }} + +
+
+
+ + {{ form.ssh_port(class_="form-control") }} +
Used only when SSH is enabled.
+
+
+ +
+ +
+
+
+
+ +
+
+
+
Tips
+
    +
  • REST uses the MikroTik API (/rest).
  • +
  • If you use a self-signed cert, enable insecure TLS.
  • +
  • SSH is optional (e.g. for commands/reads).
  • +
+
+
+
+
+{% endblock %} diff --git a/templates/devices/view.html b/templates/devices/view.html new file mode 100644 index 0000000..88e48b5 --- /dev/null +++ b/templates/devices/view.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}{{ device.name }} - Device - MikroMon{% endblock %} +{% block content %} +
+
+

{{ device.name }}

+
{{ device.host }}
+
+
+ Back + Edit +
+ + +
+ + +
+
+ +
+
+
+
+
Configuration
+
+
REST
{{ device.host }}:{{ device.rest_port }}{{ device.rest_base_path }}
+
TLS
{{ 'insecure' if device.allow_insecure_tls else 'strict' }}
+
SSH
{{ 'enabled' if device.ssh_enabled else 'disabled' }}{% if device.ssh_enabled %} ({{ device.ssh_port }}){% endif %}
+
Last error
{{ device.last_error or '-' }}
+
Created
{{ device.created_at }}
+
+
+
+
+ +
+
+
+
Result
+
{}
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/emails/reset_password.html b/templates/emails/reset_password.html new file mode 100644 index 0000000..8655738 --- /dev/null +++ b/templates/emails/reset_password.html @@ -0,0 +1,10 @@ + + + + +

MikroMon — password reset

+

We received a request to reset your password.

+

Set a new password

+

This link expires in {{ ttl_minutes }} minutes. If this wasn't you, ignore this email.

+ + diff --git a/templates/emails/smtp_test.html b/templates/emails/smtp_test.html new file mode 100644 index 0000000..5d72349 --- /dev/null +++ b/templates/emails/smtp_test.html @@ -0,0 +1,8 @@ + + + + +

MikroMon — SMTP test

+

If you can read this message, SMTP is working correctly.

+ + diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..37f8080 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}{{ code }} - {{ title }} - MikroMon{% endblock %} +{% block content %} +
+
+
+
+
+
{{ code }}
+
+

{{ title }}

+
{{ description }}
+
+
+
+
+ {% if current_user.is_authenticated %} + Dashboards + {% else %} + Logowanie + {% endif %} + API +
+
Request ID: {{ request_id }}
+
+
+
+
+{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5a56b86 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +import os +import pytest +from mikromon import create_app, db + +@pytest.fixture() +def app(tmp_path): + os.environ["DATABASE_URL"] = f"sqlite:///{tmp_path/'test.db'}" + os.environ["DEV_INPROCESS_POLLER"] = "0" + os.environ["SECRET_KEY"] = "test-secret" + # Fernet key + os.environ["CRED_ENC_KEY"] = "WmQxY2pKQ0FqV0lJdVFSWFBzYlJKUTZkdmJpZGFjY0k=" # dummy base64, will fail if used; tests avoid decrypt + app = create_app() + with app.app_context(): + db.create_all() + yield app + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/tests/test_acl_api.py b/tests/test_acl_api.py new file mode 100644 index 0000000..4322433 --- /dev/null +++ b/tests/test_acl_api.py @@ -0,0 +1,27 @@ +import json +from mikromon import db +from mikromon.models import User, Role, RoleName, Dashboard +from mikromon.security.passwords import hash_password + +def _login(client, email, password): + return client.post("/auth/login", data={"email":email,"password":password}, follow_redirects=False) + +def test_api_me_requires_login(client, app): + r = client.get("/api/v1/me") + assert r.status_code in (302, 401) + +def test_dashboard_acl(client, app): + with app.app_context(): + user_role = Role.query.filter_by(name=RoleName.USER.value).first() + if not user_role: + user_role = Role(name=RoleName.USER.value) + db.session.add(user_role); db.session.commit() + u1 = User(email="a@example.com", password_hash=hash_password("Password123!"), role_id=user_role.id) + u2 = User(email="b@example.com", password_hash=hash_password("Password123!"), role_id=user_role.id) + db.session.add_all([u1,u2]); db.session.commit() + d = Dashboard(owner_id=u1.id, name="D1", description="") + db.session.add(d); db.session.commit() + did = d.id + _login(client, "b@example.com", "Password123!") + r = client.get(f"/api/v1/dashboards/{did}") + assert r.status_code == 403 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..683fe41 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,11 @@ +def test_login_page(client): + r = client.get("/auth/login") + assert r.status_code == 200 + +def test_register_and_login(client): + # register + r = client.post("/auth/register", data={"email":"u1@example.com","password":"Password123!"}, follow_redirects=True) + assert r.status_code in (200, 302) + # login + r = client.post("/auth/login", data={"email":"u1@example.com","password":"Password123!"}, follow_redirects=False) + assert r.status_code in (302,) diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..3c46bda --- /dev/null +++ b/wsgi.py @@ -0,0 +1,2 @@ +from mikromon import create_app, socketio +app = create_app()