refactor: replace config.yml with .env for all configuration
All settings (auth credentials, scanner ranges, status_checker interval) now live in a single .env file via pydantic-settings. config.yml and config.yml.example are deleted. - Settings: add auth_username, auth_password_hash, scanner_ranges, status_checker_interval; add load_overrides()/save_overrides() for persisting runtime changes to data/scan_config.json - auth.py: read credentials directly from settings - scan.py: read ranges/interval from settings; write-back via save_overrides() - scheduler.py: read interval directly from settings - main.py: call settings.load_overrides() at startup - docker-compose.yml: remove config.yml volume mount, add new env vars - conftest.py: set settings fields directly instead of writing a temp config.yml
This commit is contained in:
@@ -41,9 +41,6 @@ build/
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
.coverage
|
.coverage
|
||||||
|
|
||||||
# App config — contains credentials, never commit
|
|
||||||
backend/config.yml
|
|
||||||
|
|
||||||
# SQLite
|
# SQLite
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|||||||
@@ -1,32 +1,18 @@
|
|||||||
# Homelable
|
# Homelable
|
||||||
|
|
||||||
A self-hosted, open-source tool to visually map, document and monitor your homelab infrastructure.
|
Homelable is a self-hosted infrastructure visualization solution. It provides a network scanning feature to accelerate the identification of machines and services deployed on your local infrastructure.
|
||||||
|
|
||||||
Interactive network canvas where each node is a physical machine, VM, LXC container, switch, or device. Nodes show live status, IPs, hostnames, and running services. Edges represent network links.
|
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Interactive canvas** — drag, zoom, pan, snap-to-grid (React Flow)
|
|
||||||
- **11 node types** — ISP, router, switch, server, Proxmox, VM, LXC, NAS, IoT, AP, generic
|
|
||||||
- **Proxmox nested nodes** — VM/LXC rendered inside a resizable Proxmox group container
|
|
||||||
- **5 edge types** — ethernet, Wi-Fi, IoT, VLAN (color-coded), virtual
|
|
||||||
- **Live status** — per-node checks via ping / HTTP / HTTPS / SSH / TCP / Prometheus
|
|
||||||
- **Network scanner** — nmap-based discovery, approve/hide/ignore new devices
|
|
||||||
- **Auto-layout** — one-click Dagre hierarchical arrangement
|
|
||||||
- **Export** — download canvas as PNG
|
|
||||||
- **Dark theme** — neon accent colors, JetBrains Mono for technical values
|
|
||||||
- **Self-contained** — SQLite database, single config file, no cloud dependency
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start — Docker
|
## Quick Start — Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/you/homelable.git
|
git clone https://github.com/Pouzor/homelable.git
|
||||||
cd homelable
|
cd homelable
|
||||||
|
cp .env.example .env
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -38,34 +24,12 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start — Development
|
|
||||||
|
|
||||||
**Backend (Python 3.13):**
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python3.13 -m venv .venv && source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
cp .env.example .env # edit SECRET_KEY
|
|
||||||
uvicorn app.main:app --reload --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend:**
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev # http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
Default login: `admin` / `admin`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proxmox LXC Install
|
## Proxmox LXC Install
|
||||||
|
|
||||||
Run inside a Debian/Ubuntu LXC container:
|
Run inside a Debian/Ubuntu LXC container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/you/homelable/main/scripts/lxc-install.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs the backend as a systemd service and serves the frontend via nginx.
|
This installs the backend as a systemd service and serves the frontend via nginx.
|
||||||
@@ -158,19 +122,25 @@ Proxmox nodes render as a resizable group container. VM and LXC nodes can be pla
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
|
## Development Mode
|
||||||
|
|
||||||
|
**Backend (Python 3.13):**
|
||||||
```bash
|
```bash
|
||||||
# Backend tests
|
cd backend
|
||||||
cd backend && source .venv/bin/activate
|
python3.13 -m venv .venv && source .venv/bin/activate
|
||||||
pytest # 40 tests
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # edit SECRET_KEY
|
||||||
# Backend lint
|
uvicorn app.main:app --reload --port 8000
|
||||||
ruff check .
|
|
||||||
|
|
||||||
# Frontend tests
|
|
||||||
cd frontend && npm test
|
|
||||||
|
|
||||||
# Frontend lint + typecheck
|
|
||||||
npm run lint && npm run typecheck
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Default login: `admin` / `admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+11
-1
@@ -1,4 +1,14 @@
|
|||||||
SECRET_KEY=<generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))">
|
SECRET_KEY=<generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))">
|
||||||
SQLITE_PATH=./data/homelab.db
|
SQLITE_PATH=./data/homelab.db
|
||||||
CONFIG_PATH=./config.yml
|
|
||||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||||
|
|
||||||
|
# Auth — set a strong password hash (generate with passlib):
|
||||||
|
# python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
||||||
|
AUTH_USERNAME=admin
|
||||||
|
AUTH_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# Scanner — JSON array of CIDR ranges to scan
|
||||||
|
SCANNER_RANGES=["192.168.1.0/24"]
|
||||||
|
|
||||||
|
# Status checker interval in seconds
|
||||||
|
STATUS_CHECKER_INTERVAL=60
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import hmac
|
import hmac
|
||||||
|
|
||||||
import yaml
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -20,20 +19,12 @@ class TokenResponse(BaseModel):
|
|||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
def _load_credentials() -> tuple[str, str]:
|
|
||||||
with open(settings.config_path) as f:
|
|
||||||
cfg = yaml.safe_load(f)
|
|
||||||
auth = cfg.get("auth", {})
|
|
||||||
return auth.get("username", "admin"), auth.get("password_hash", "")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(body: LoginRequest) -> TokenResponse:
|
async def login(body: LoginRequest) -> TokenResponse:
|
||||||
username, password_hash = _load_credentials()
|
|
||||||
# Always run both checks to prevent timing-based username enumeration.
|
# Always run both checks to prevent timing-based username enumeration.
|
||||||
# hmac.compare_digest is constant-time; verify_password (bcrypt) always runs.
|
# hmac.compare_digest is constant-time; verify_password (bcrypt) always runs.
|
||||||
username_ok = hmac.compare_digest(body.username, username)
|
username_ok = hmac.compare_digest(body.username, settings.auth_username)
|
||||||
password_ok = verify_password(body.password, password_hash)
|
password_ok = verify_password(body.password, settings.auth_password_hash)
|
||||||
if not username_ok or not password_ok:
|
if not username_ok or not password_ok:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
token = create_access_token(body.username)
|
token = create_access_token(body.username)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -20,19 +19,11 @@ class ScanConfig(BaseModel):
|
|||||||
ranges: list[str]
|
ranges: list[str]
|
||||||
interval_seconds: int
|
interval_seconds: int
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _load_ranges() -> list[str]:
|
|
||||||
try:
|
|
||||||
with open(settings.config_path) as f:
|
|
||||||
cfg: dict[str, Any] = yaml.safe_load(f) or {}
|
|
||||||
return list(cfg.get("scanner", {}).get("ranges", []))
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
await run_scan(ranges, db, run_id)
|
await run_scan(ranges, db, run_id)
|
||||||
@@ -44,7 +35,7 @@ async def trigger_scan(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> ScanRun:
|
) -> ScanRun:
|
||||||
ranges = _load_ranges()
|
ranges = settings.scanner_ranges
|
||||||
run = ScanRun(status="running", ranges=ranges)
|
run = ScanRun(status="running", ranges=ranges)
|
||||||
db.add(run)
|
db.add(run)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -112,25 +103,18 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
|
|||||||
|
|
||||||
@router.get("/config", response_model=ScanConfig)
|
@router.get("/config", response_model=ScanConfig)
|
||||||
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
||||||
try:
|
return ScanConfig(
|
||||||
with open(settings.config_path) as f:
|
ranges=settings.scanner_ranges,
|
||||||
cfg = yaml.safe_load(f)
|
interval_seconds=settings.status_checker_interval,
|
||||||
ranges = cfg.get("scanner", {}).get("ranges", [])
|
)
|
||||||
interval = int(cfg.get("status_checker", {}).get("interval_seconds", 60))
|
|
||||||
return ScanConfig(ranges=ranges, interval_seconds=interval)
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config", response_model=ScanConfig)
|
@router.post("/config", response_model=ScanConfig)
|
||||||
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
||||||
try:
|
try:
|
||||||
with open(settings.config_path) as f:
|
settings.scanner_ranges = payload.ranges
|
||||||
cfg = yaml.safe_load(f) or {}
|
settings.status_checker_interval = payload.interval_seconds
|
||||||
cfg.setdefault("scanner", {})["ranges"] = payload.ranges
|
settings.save_overrides()
|
||||||
cfg.setdefault("status_checker", {})["interval_seconds"] = payload.interval_seconds
|
|
||||||
with open(settings.config_path, "w") as f:
|
|
||||||
yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True)
|
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -6,12 +9,43 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
secret_key: str # Required — set SECRET_KEY in .env
|
secret_key: str # Required — set SECRET_KEY in .env
|
||||||
sqlite_path: str = "./data/homelab.db"
|
sqlite_path: str = "./data/homelab.db"
|
||||||
config_path: str = "./config.yml"
|
|
||||||
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
algorithm: str = "HS256"
|
algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 1440 # 24h
|
access_token_expire_minutes: int = 1440 # 24h
|
||||||
|
|
||||||
|
# Auth — set AUTH_USERNAME and AUTH_PASSWORD_HASH in .env
|
||||||
|
auth_username: str = "admin"
|
||||||
|
auth_password_hash: str = ""
|
||||||
|
|
||||||
settings = Settings() # type: ignore[call-arg] # pydantic-settings loads secret_key from env
|
# Scanner
|
||||||
|
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
||||||
|
|
||||||
|
# Status checker
|
||||||
|
status_checker_interval: int = 60
|
||||||
|
|
||||||
|
def _override_path(self) -> Path:
|
||||||
|
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||||
|
|
||||||
|
def load_overrides(self) -> None:
|
||||||
|
"""Load runtime scan config overrides written by the API."""
|
||||||
|
try:
|
||||||
|
data = json.loads(self._override_path().read_text())
|
||||||
|
if "scanner_ranges" in data:
|
||||||
|
self.scanner_ranges = data["scanner_ranges"]
|
||||||
|
if "status_checker_interval" in data:
|
||||||
|
self.status_checker_interval = int(data["status_checker_interval"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def save_overrides(self) -> None:
|
||||||
|
"""Persist scan config so it survives container restarts."""
|
||||||
|
self._override_path().parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._override_path().write_text(json.dumps({
|
||||||
|
"scanner_ranges": self.scanner_ranges,
|
||||||
|
"status_checker_interval": self.status_checker_interval,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings() # type: ignore[call-arg]
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import yaml
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -46,22 +45,12 @@ async def _run_status_checks() -> None:
|
|||||||
logger.error("Status check failed for node %s: %s", node.id, exc)
|
logger.error("Status check failed for node %s: %s", node.id, exc)
|
||||||
|
|
||||||
|
|
||||||
def _load_interval() -> int:
|
|
||||||
try:
|
|
||||||
with open(settings.config_path) as f:
|
|
||||||
cfg = yaml.safe_load(f)
|
|
||||||
return int(cfg.get("status_checker", {}).get("interval_seconds", 60))
|
|
||||||
except Exception:
|
|
||||||
return 60
|
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler() -> None:
|
def start_scheduler() -> None:
|
||||||
global scheduler
|
global scheduler
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
interval = _load_interval()
|
scheduler.add_job(_run_status_checks, "interval", seconds=settings.status_checker_interval, id="status_checks")
|
||||||
scheduler.add_job(_run_status_checks, "interval", seconds=interval, id="status_checks")
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("Scheduler started — status checks every %ds", interval)
|
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||||
|
|
||||||
|
|
||||||
def stop_scheduler() -> None:
|
def stop_scheduler() -> None:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.db.database import init_db
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
await init_db()
|
await init_db()
|
||||||
|
settings.load_overrides()
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
yield
|
yield
|
||||||
stop_scheduler()
|
stop_scheduler()
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
auth:
|
|
||||||
username: admin
|
|
||||||
password_hash: "" # Generate with: python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
|
||||||
scanner:
|
|
||||||
interval: null
|
|
||||||
ranges:
|
|
||||||
- 192.168.1.0/24
|
|
||||||
status_checker:
|
|
||||||
interval_seconds: 60
|
|
||||||
@@ -4,7 +4,6 @@ import os
|
|||||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
@@ -18,20 +17,11 @@ _pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True, scope="session")
|
@pytest.fixture(autouse=True, scope="session")
|
||||||
def test_config_file(tmp_path_factory):
|
def test_credentials():
|
||||||
"""Write a minimal config.yml for the test session and point settings at it."""
|
"""Configure test auth credentials directly on settings."""
|
||||||
cfg = {
|
|
||||||
"auth": {
|
|
||||||
"username": "admin",
|
|
||||||
"password_hash": _pwd_ctx.hash("admin"),
|
|
||||||
},
|
|
||||||
"scanner": {"ranges": []},
|
|
||||||
"status_checker": {"interval_seconds": 3600},
|
|
||||||
}
|
|
||||||
cfg_path = tmp_path_factory.mktemp("cfg") / "config.yml"
|
|
||||||
cfg_path.write_text(yaml.dump(cfg))
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
settings.config_path = str(cfg_path)
|
settings.auth_username = "admin"
|
||||||
|
settings.auth_password_hash = _pwd_ctx.hash("admin")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
async def test_login_success(client: AsyncClient):
|
||||||
def mock_credentials():
|
|
||||||
with patch("app.api.routes.auth._load_credentials", return_value=("admin", "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS")):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
async def test_login_success(client: AsyncClient, mock_credentials):
|
|
||||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
data = res.json()
|
data = res.json()
|
||||||
@@ -18,12 +9,12 @@ async def test_login_success(client: AsyncClient, mock_credentials):
|
|||||||
assert data["token_type"] == "bearer"
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
|
||||||
async def test_login_wrong_password(client: AsyncClient, mock_credentials):
|
async def test_login_wrong_password(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_login_wrong_username(client: AsyncClient, mock_credentials):
|
async def test_login_wrong_username(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/auth/login", json={"username": "notadmin", "password": "admin"})
|
res = await client.post("/api/v1/auth/login", json={"username": "notadmin", "password": "admin"})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
TOKEN_HASH = "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def headers(client: AsyncClient):
|
async def headers(client: AsyncClient):
|
||||||
with patch("app.api.routes.auth._load_credentials", return_value=("admin", TOKEN_HASH)):
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
|
||||||
return {"Authorization": f"Bearer {res.json()['access_token']}"}
|
return {"Authorization": f"Bearer {res.json()['access_token']}"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
TOKEN_HASH = "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def headers(client: AsyncClient):
|
async def headers(client: AsyncClient):
|
||||||
with patch("app.api.routes.auth._load_credentials", return_value=("admin", TOKEN_HASH)):
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
|
||||||
token = res.json()["access_token"]
|
token = res.json()["access_token"]
|
||||||
return {"Authorization": f"Bearer {token}"}
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
TOKEN_HASH = "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def headers(client: AsyncClient):
|
async def headers(client: AsyncClient):
|
||||||
with patch("app.api.routes.auth._load_credentials", return_value=("admin", TOKEN_HASH)):
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
|
||||||
token = res.json()["access_token"]
|
token = res.json()["access_token"]
|
||||||
return {"Authorization": f"Bearer {token}"}
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ async def test_trigger_scan_requires_auth(client: AsyncClient):
|
|||||||
async def test_trigger_scan_creates_run(client: AsyncClient, headers):
|
async def test_trigger_scan_creates_run(client: AsyncClient, headers):
|
||||||
with (
|
with (
|
||||||
patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
|
patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
|
||||||
patch("app.api.routes.scan._load_ranges", return_value=["192.168.1.0/24"]),
|
patch("app.api.routes.scan.settings") as mock_settings,
|
||||||
):
|
):
|
||||||
|
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||||
res = await client.post("/api/v1/scan/trigger", headers=headers)
|
res = await client.post("/api/v1/scan/trigger", headers=headers)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
data = res.json()
|
data = res.json()
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Tests for background scheduler: _load_interval, _run_status_checks, lifecycle."""
|
"""Tests for background scheduler: _run_status_checks, lifecycle."""
|
||||||
import uuid
|
import uuid
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from app.core.scheduler import _load_interval, _run_status_checks, start_scheduler, stop_scheduler
|
from app.core.scheduler import _run_status_checks, start_scheduler, stop_scheduler
|
||||||
from app.db.database import Base
|
from app.db.database import Base
|
||||||
from app.db.models import Node
|
from app.db.models import Node
|
||||||
|
|
||||||
@@ -25,35 +25,6 @@ def _make_node(**kwargs) -> Node:
|
|||||||
return Node(**{**defaults, **kwargs})
|
return Node(**{**defaults, **kwargs})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _load_interval
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_load_interval_reads_from_config(tmp_path):
|
|
||||||
"""_load_interval returns the value set in config.yml."""
|
|
||||||
cfg = tmp_path / "config.yml"
|
|
||||||
cfg.write_text("status_checker:\n interval_seconds: 30\n")
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.config_path = str(cfg)
|
|
||||||
assert _load_interval() == 30
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_interval_defaults_to_60_when_key_missing(tmp_path):
|
|
||||||
"""_load_interval returns 60 when status_checker section is absent."""
|
|
||||||
cfg = tmp_path / "config.yml"
|
|
||||||
cfg.write_text("auth:\n username: admin\n")
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.config_path = str(cfg)
|
|
||||||
assert _load_interval() == 60
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_interval_defaults_to_60_on_missing_file():
|
|
||||||
"""_load_interval returns 60 when config file does not exist."""
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.config_path = "/nonexistent/path/config.yml"
|
|
||||||
assert _load_interval() == 60
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _run_status_checks
|
# _run_status_checks
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -164,26 +135,23 @@ async def test_run_status_checks_handles_check_error_gracefully(mem_db):
|
|||||||
# start_scheduler / stop_scheduler
|
# start_scheduler / stop_scheduler
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_scheduler_uses_settings_interval():
|
||||||
|
"""Scheduler registers the job with the interval from settings."""
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||||
|
mock_settings.status_checker_interval = 45
|
||||||
|
start_scheduler()
|
||||||
|
_, kwargs = mock_sched.add_job.call_args
|
||||||
|
assert kwargs["seconds"] == 45
|
||||||
|
|
||||||
|
|
||||||
def test_start_and_stop_scheduler():
|
def test_start_and_stop_scheduler():
|
||||||
"""Scheduler can be started and stopped without errors."""
|
"""Scheduler can be started and stopped without errors."""
|
||||||
mock_sched = MagicMock()
|
mock_sched = MagicMock()
|
||||||
with patch("app.core.scheduler._load_interval", return_value=3600), \
|
with patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
stop_scheduler()
|
stop_scheduler()
|
||||||
mock_sched.add_job.assert_called_once()
|
mock_sched.add_job.assert_called_once()
|
||||||
mock_sched.start.assert_called_once()
|
mock_sched.start.assert_called_once()
|
||||||
mock_sched.shutdown.assert_called_once()
|
mock_sched.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_start_scheduler_uses_configured_interval():
|
|
||||||
"""Scheduler registers the status_checks job with the correct interval."""
|
|
||||||
mock_sched = MagicMock()
|
|
||||||
with patch("app.core.scheduler._load_interval", return_value=120) as mock_interval, \
|
|
||||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
|
||||||
start_scheduler()
|
|
||||||
mock_interval.assert_called_once()
|
|
||||||
mock_sched.add_job.assert_called_once()
|
|
||||||
_, kwargs = mock_sched.add_job.call_args
|
|
||||||
assert kwargs.get("seconds") == 120
|
|
||||||
mock_sched.start.assert_called_once()
|
|
||||||
|
|||||||
+4
-2
@@ -7,11 +7,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set in the environment or a .env file}
|
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set in the environment or a .env file}
|
||||||
SQLITE_PATH: /app/data/homelab.db
|
SQLITE_PATH: /app/data/homelab.db
|
||||||
CONFIG_PATH: /app/config.yml
|
|
||||||
CORS_ORIGINS: '["http://localhost:3000"]'
|
CORS_ORIGINS: '["http://localhost:3000"]'
|
||||||
|
AUTH_USERNAME: ${AUTH_USERNAME:-admin}
|
||||||
|
AUTH_PASSWORD_HASH: ${AUTH_PASSWORD_HASH:?AUTH_PASSWORD_HASH must be set}
|
||||||
|
SCANNER_RANGES: ${SCANNER_RANGES:-["192.168.1.0/24"]}
|
||||||
|
STATUS_CHECKER_INTERVAL: ${STATUS_CHECKER_INTERVAL:-60}
|
||||||
volumes:
|
volumes:
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
- ./backend/config.yml:/app/config.yml
|
|
||||||
networks:
|
networks:
|
||||||
- homelable
|
- homelable
|
||||||
# Required for ping-based status checks
|
# Required for ping-based status checks
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Homelable — LXC/VM bootstrap installer
|
# Homelable — LXC/VM bootstrap installer
|
||||||
# Compatible with Proxmox VE (Debian/Ubuntu LXC containers)
|
# Compatible with Proxmox VE (Debian/Ubuntu LXC containers)
|
||||||
# Usage: bash <(curl -fsSL https://raw.githubusercontent.com/you/homelable/main/scripts/lxc-install.sh)
|
# Usage: bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ if ! id "$SERVICE_USER" &>/dev/null; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Clone / update repo ───────────────────────────────────────────────────────
|
# ── Clone / update repo ───────────────────────────────────────────────────────
|
||||||
REPO_URL="https://github.com/you/homelable.git" # ← update before publishing
|
REPO_URL="https://github.com/Pouzor/homelable.git"
|
||||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||||
info "Updating existing installation..."
|
info "Updating existing installation..."
|
||||||
git -C "$INSTALL_DIR" pull
|
git -C "$INSTALL_DIR" pull
|
||||||
|
|||||||
Reference in New Issue
Block a user