Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copy to .env for local development (do not commit real secrets).
# Environment variables override values in this file.
APP_NAME=RPi-NMS
ENV=development
DB_PATH=data/rpi_nms.db
DEFAULT_NETWORK=192.168.1.0/24
SCAN_INTERVAL_MINUTES=5

# Set these only when enabling notifications.
# TELEGRAM_TOKEN=
# TELEGRAM_CHAT_ID=
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASS=
# SMTP_FROM=
# WEBHOOK_URLS=

# Required: replace with a fresh, strong secret before any public deployment.
SECRET_KEY=replace-me-with-secure-random
ACCESS_TOKEN_EXPIRE_MINUTES=60
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Never commit real local environment settings or credentials.
.env
.env.*
!.env.example

# Python/test artifacts
__pycache__/
*.py[cod]
.pytest_cache/

# Local SQLite databases
*.db
*.sqlite
10 changes: 7 additions & 3 deletions app/alertrules.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@

import time
from .models import Database
from .config import settings
from .alerts import Alerts

class AlertRules:
def __init__(self, db_path='data/rpi_nms.db', telegram_token=None, telegram_chat_id=None):
self.db = Database(db_path)
self.alerts = Alerts(token=telegram_token, chat_id=telegram_chat_id)
def __init__(self, db_path=None, telegram_token=None, telegram_chat_id=None):
self.db = Database(db_path if db_path is not None else settings.db_path)
self.alerts = Alerts(
token=telegram_token if telegram_token is not None else settings.telegram_token,
chat_id=telegram_chat_id if telegram_chat_id is not None else settings.telegram_chat_id,
)
self.db.init_db()
# ensure history table exists
with self.db.conn() as c:
Expand Down
5 changes: 3 additions & 2 deletions app/autodiscover.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
"""
import subprocess
from .models import Database
from .config import settings
import re

class ArpDiscover:
def __init__(self, db_path='data/rpi_nms.db'):
self.db = Database(db_path)
def __init__(self, db_path=None):
self.db = Database(db_path if db_path is not None else settings.db_path)
self.db.init_db()

def _parse_arp_output(self, text):
Expand Down
8 changes: 7 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from pydantic import BaseSettings, AnyHttpUrl
from typing import Optional

# Pydantic 1.x exports BaseSettings directly. Pydantic 2.x retains the v1
# compatibility namespace so existing deployments can upgrade safely.
try:
from pydantic.v1 import BaseSettings
except ImportError:
from pydantic import BaseSettings

class Settings(BaseSettings):
# app
app_name: str = "RPi-NMS"
Expand Down
7 changes: 4 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
from pydantic import BaseModel
from typing import List
import uvicorn
from .models import Database, Device
from .models import Database
from .config import settings

app = FastAPI(title="RPi Network Monitoring System")
db = Database('data/rpi_nms.db')
app = FastAPI(title=settings.app_name)
db = Database(settings.db_path)

class DeviceOut(BaseModel):
id: int
Expand Down
42 changes: 42 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Environment configuration

The backend reads configuration from `app.config.settings` using Pydantic
`BaseSettings`. All variables are optional locally; the defaults are declared
in `app/config.py`. Python environment variables take precedence over values
in an optional repository-root `.env` file.

## Run locally

1. Install dependencies: `python -m pip install -r requirements.txt`.
2. Copy `.env.example` to `.env` and change values for your machine.
3. Launch from the repository root: `python -m uvicorn app.main:app --reload`.
4. Open `http://127.0.0.1:8000/docs` for the generated API schema.

**Never commit real `.env` credentials.** For deployments exposed beyond a
trusted development machine, replace the example `SECRET_KEY` with a strong,
random secret and configure any authentication layer before sharing endpoints.

| Variable | Purpose | Default |
| --- | --- | --- |
| `APP_NAME` | FastAPI application title | `RPi-NMS` |
| `ENV` | Deployment environment label | `development` |
| `DB_PATH` | SQLite database file used by API, discovery and alerts | `data/rpi_nms.db` |
| `DEFAULT_NETWORK` | Configured discovery network | `192.168.1.0/24` |
| `SCAN_INTERVAL_MINUTES` | Discovery scheduling interval | `5` |
| `TELEGRAM_TOKEN` / `TELEGRAM_CHAT_ID` | Alert destination (optional) | Unset |
| `SECRET_KEY` | Auth token signing secret (must be changed for production) | Development placeholder |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT expiration in minutes | `60` |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM` | Optional mail notifications | Unset / port `587` |
| `WEBHOOK_URLS` | Optional comma-separated webhook list | Unset |

In Python, import `from app.config import settings`. Use explicit values only
when a component intentionally overrides the deployment configuration; otherwise
reuse `settings.db_path` and notification settings.

## Compatibility

Pydantic 1.10 and Pydantic 2.x expose the established BaseSettings behavior
through the `pydantic.v1` namespace. The code falls back to the direct
Pydantic 1.x import where necessary. The `python-dotenv` dependency is required
to parse an actual `.env` file. The settings model does not require a real
Raspberry Pi or network connection to test.
9 changes: 9 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
fastapi>=0.100,<1
uvicorn>=0.20,<1
pydantic>=1.10,<3
python-dotenv>=1,<2
PyJWT>=2.8,<3
passlib[bcrypt]>=1.7,<2

# The upstream CI currently invokes pytest directly.
pytest>=7,<10
76 changes: 76 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Configuration regression tests: typed env, dotenv, and shared database path."""
import importlib

import pytest

from app import config


def test_defaults_without_env_file(monkeypatch):
for key in ("APP_NAME", "DB_PATH", "SCAN_INTERVAL_MINUTES", "TELEGRAM_TOKEN"):
monkeypatch.delenv(key, raising=False)
settings = config.Settings(_env_file=None)
assert settings.app_name == "RPi-NMS"
assert settings.db_path == "data/rpi_nms.db"
assert settings.scan_interval_minutes == 5
assert settings.telegram_token is None


def test_environment_variables_override_defaults(monkeypatch, tmp_path):
db_path = tmp_path / "configured" / "devices.db"
monkeypatch.setenv("DB_PATH", str(db_path))
monkeypatch.setenv("APP_NAME", "Testing RPi")
monkeypatch.setenv("SCAN_INTERVAL_MINUTES", "17")
monkeypatch.setenv("TELEGRAM_TOKEN", "dummy-test-value")
loaded = config.Settings(_env_file=None)
assert loaded.db_path == str(db_path)
assert loaded.app_name == "Testing RPi"
assert loaded.scan_interval_minutes == 17
assert loaded.telegram_token == "dummy-test-value"


def test_invalid_scan_interval_is_rejected(monkeypatch):
monkeypatch.setenv("SCAN_INTERVAL_MINUTES", "not-an-integer")
with pytest.raises(ValueError):
config.Settings(_env_file=None)


def test_dotenv_file_and_environment_precedence(monkeypatch, tmp_path):
pytest.importorskip("dotenv")
env_file = tmp_path / ".env"
env_file.write_text(
"APP_NAME=From file\nDB_PATH=from_file.db\nSCAN_INTERVAL_MINUTES=12\n",
encoding="utf-8",
)
monkeypatch.delenv("DB_PATH", raising=False)
monkeypatch.delenv("SCAN_INTERVAL_MINUTES", raising=False)
monkeypatch.setenv("APP_NAME", "From environment")
loaded = config.Settings(_env_file=env_file)
assert loaded.db_path == "from_file.db"
assert loaded.scan_interval_minutes == 12
assert loaded.app_name == "From environment"


def test_main_api_uses_shared_settings(monkeypatch, tmp_path):
db_path = tmp_path / "api" / "devices.db"
monkeypatch.setenv("APP_NAME", "My Raspberry Pis")
monkeypatch.setenv("DB_PATH", str(db_path))
new_settings = config.Settings(_env_file=None)
monkeypatch.setattr(config, "settings", new_settings)
from app import main
module = importlib.reload(main)
assert module.db.path == str(db_path)
assert module.app.title == "My Raspberry Pis"
module.db.init_db()
assert db_path.is_file()


def test_discovery_uses_configured_db_unless_explicitly_overridden(monkeypatch, tmp_path):
db_path = tmp_path / "discover" / "db.sqlite"
monkeypatch.setattr(config.settings, "db_path", str(db_path))
from app.autodiscover import ArpDiscover
instance = ArpDiscover()
assert instance.db.path == str(db_path)
assert db_path.exists()
custom = tmp_path / "custom" / "db.sqlite"
assert ArpDiscover(db_path=str(custom)).db.path == str(custom)