Compare commits

...

7 commits

Author SHA1 Message Date
Luc Didry
32d11c5598
— Add application API (fix #86) 2025-03-19 17:38:16 +01:00
Luc Didry
a601fccad3
🤕 — Fix order of tasks sent to agent 2025-03-19 17:36:36 +01:00
Luc Didry
be2b4f2114
📝 — Improve OpenAPI doc 2025-03-19 17:35:40 +01:00
Luc Didry
837cd548ad
🔒 — Logging out now invalidate tokens 2025-03-19 17:21:53 +01:00
Luc Didry
dbe05178b8
🔊 — Improve check agent log 2025-03-18 16:47:23 +01:00
Luc Didry
33fd5441e1
— Set recurring task to every minute 2025-03-18 15:59:00 +01:00
Luc Didry
0a02855e60
🚸 — Use ReconnectLDAPObject 2025-03-18 13:53:46 +01:00
11 changed files with 489 additions and 80 deletions

View file

@ -2,6 +2,14 @@
## [Unreleased]
- 🚸 — Use ReconnectLDAPObject
- ⏰ — Set recurring task to every minute
- 🔊 — Improve check agent log
- 🔒️ — Logging out now invalidate tokens
- 📝 — Improve OpenAPI doc
- 🤕 — Fix order of tasks sent to agent
- ✨ — Add application API (fix #86)
## 0.9.0
Date: 2025-02-18

View file

@ -1,5 +1,5 @@
from fastapi import Request
from fastapi.responses import RedirectResponse
from fastapi.responses import JSONResponse, RedirectResponse
class NotAuthenticatedException(Exception):
@ -10,7 +10,19 @@ def auth_exception_handler(request: Request, exc: NotAuthenticatedException):
"""
Redirect the user to the login page if not logged in
"""
response = RedirectResponse(url=request.url_for("login_view"))
print(request.headers.get("accept", ""))
print("application/json" in request.headers.get("accept", ""))
if "application/json" in request.headers.get("accept", ""):
return JSONResponse(
content={
"access": "denied",
"msg": "You are not authenticated or your token has expired",
}
)
response = RedirectResponse(
url=request.url_for("login_view").include_query_params(msg="not-authenticated")
)
manager = request.app.state.manager
manager.set_cookie(response, "")
return response

View file

@ -10,6 +10,7 @@ from psutil import Process
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from argos import VERSION
from argos.logging import logger, set_log_level
from argos.server import models, routes, queries
from argos.server.alerting import no_agent_alert
@ -30,7 +31,17 @@ def get_application() -> FastAPI:
root_path = root_path[:-1]
logger.info("Fixed root path for Argos: %s", root_path)
appli = FastAPI(lifespan=lifespan, root_path=root_path)
appli = FastAPI(
title="Argos Panoptès",
summary="A monitoring and status board for websites.",
version=VERSION,
license_info={
"name": "GNU Affero General Public License v3",
"url": "https://www.gnu.org/licenses/agpl-3.0.en.html",
},
lifespan=lifespan,
root_path=root_path,
)
# Config is the argos config object (built from yaml)
appli.state.config = config
@ -40,7 +51,9 @@ def get_application() -> FastAPI:
if config.general.ldap is not None:
import ldap
appli.state.ldap = ldap.initialize(config.general.ldap.uri)
appli.state.ldap = ldap.ldapobject.ReconnectLDAPObject(
config.general.ldap.uri, retry_max=5, retry_delay=5
)
@appli.state.manager.user_loader()
async def query_user(user: str) -> None | str | models.User:
@ -57,6 +70,7 @@ def get_application() -> FastAPI:
return await queries.get_user(appli.state.db, user)
appli.include_router(routes.api, prefix="/api")
appli.include_router(routes.api_app, prefix="/api/app")
appli.include_router(routes.views)
static_dir = Path(__file__).resolve().parent / "static"
@ -114,7 +128,7 @@ def create_manager(cookie_secret: str) -> LoginManager:
)
@repeat_every(seconds=120, logger=logger)
@repeat_every(seconds=60, logger=logger)
async def recurring_tasks() -> None:
"""Recurring DB cleanup and watch-agents tasks"""
# If we are using gunicorn
@ -137,7 +151,11 @@ async def recurring_tasks() -> None:
agents = await queries.get_recent_agents_count(db, config.time_without_agent)
if agents == 0:
no_agent_alert(app.state.config)
logger.info("Agent presence checked")
logger.info(
"Agent presence checked (%i agent(s) in the last %i minute(s))",
agents,
config.time_without_agent,
)
removed = await queries.remove_old_results(db, config.max_results_age)
logger.info("%i result(s) removed", removed)
@ -145,6 +163,9 @@ async def recurring_tasks() -> None:
updated = await queries.release_old_locks(db, config.max_lock_seconds)
logger.info("%i lock(s) released", updated)
removed_tokens = await queries.remove_old_tokens(db)
logger.info("%i old token(s) removed", removed_tokens)
processed_jobs = await queries.process_jobs(db)
logger.info("%i job(s) processed", processed_jobs)

View file

@ -0,0 +1,32 @@
"""Add blocked_tokens table
Revision ID: 1d0aaa07743c
Revises: 5f6cb30db996
Create Date: 2025-03-19 15:23:20.233843
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "1d0aaa07743c"
down_revision: Union[str, None] = "5f6cb30db996"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"blocked_tokens",
sa.Column("token", sa.String(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("excluded_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("token"),
)
def downgrade() -> None:
op.drop_table("blocked_tokens")

View file

@ -217,3 +217,18 @@ class User(Base):
def update_last_login_at(self):
self.last_login_at = datetime.now()
class BlockedToken(Base):
"""
List of tokens discarded by their users
(when they logout)
"""
__tablename__ = "blocked_tokens"
token: Mapped[str] = mapped_column(primary_key=True)
expires_at: Mapped[datetime] = mapped_column()
excluded_at: Mapped[datetime] = mapped_column(default=datetime.now())
def __str__(self) -> str:
return f"DB BlockedToken {self.token} - {self.expires_at} - {self.excluded_at}"

View file

@ -4,28 +4,63 @@ from hashlib import sha256
from typing import List
from urllib.parse import urljoin
import jwt
from fastapi import Request
from sqlalchemy import asc, func, Select
from sqlalchemy.orm import Session
from argos import schemas
from argos.logging import logger
from argos.server.models import ConfigCache, Job, Result, Task, User
from argos.server.models import BlockedToken, ConfigCache, Job, Result, Task, User
from argos.server.settings import read_config
async def list_tasks(db: Session, agent_id: str, limit: int = 100):
"""List tasks and mark them as selected"""
# Process tasks which never has been processed first
subquery = (
db.query(func.distinct(Task.task_group))
.filter(
Task.selected_by == None, # noqa: E711
((Task.next_run <= datetime.now()) | (Task.next_run == None)), # noqa: E711
Task.next_run == None, # noqa: E711
)
.limit(limit)
.subquery()
)
tasks = db.query(Task).filter(Task.task_group.in_(Select(subquery))).all()
if len(tasks):
now = datetime.now()
for task in tasks:
task.selected_at = now
task.selected_by = agent_id
db.commit()
return tasks
# Now we can process tasks normally
all_task_groups = (
db.query(Task.task_group)
.filter(
Task.selected_by == None, # noqa: E711
Task.next_run <= datetime.now(), # noqa: E711
)
.order_by(asc(Task.next_run))
.all()
)
# We need to do distinct(Task.task_group) in Python
# since distinct(Task.task_group) is not compatible with
# an order_by(asc(Task.next_run))
task_groups: list[str] = []
for row in all_task_groups:
if len(task_groups) > limit:
break
task_group = row.task_group
if task_group not in task_groups:
task_groups.append(task_group)
tasks = db.query(Task).filter(Task.task_group.in_(task_groups)).all()
now = datetime.now()
for task in tasks:
task.selected_at = now
@ -403,14 +438,14 @@ async def get_severity_counts(db: Session) -> dict:
async def reschedule_all(db: Session):
"""Reschedule checks of all non OK tasks ASAP"""
db.query(Task).filter(Task.severity.in_(["warning", "critical", "unknown"])).update(
db.query(Task).filter(Task.severity != "ok").update(
{Task.next_run: datetime.now() - timedelta(days=1)}
)
db.commit()
async def remove_old_results(db: Session, max_results_age: float):
"""Remove old results, base on age"""
"""Remove old results, based on age"""
max_acceptable_time = datetime.now() - timedelta(seconds=max_results_age)
deleted = (
db.query(Result).filter(Result.submitted_at < max_acceptable_time).delete()
@ -420,6 +455,30 @@ async def remove_old_results(db: Session, max_results_age: float):
return deleted
async def block_token(db: Session, request: Request):
"""Discard user token"""
manager = request.app.state.manager
token = await manager._get_token(request) # pylint: disable-msg=protected-access
payload = jwt.decode(
token, manager.secret.secret_for_decode, algorithms=[manager.algorithm]
)
blocked_token = BlockedToken(
token=token, expires_at=datetime.utcfromtimestamp(payload["exp"])
)
db.add(blocked_token)
db.commit()
async def remove_old_tokens(db: Session):
"""Remove expired discarded tokens"""
deleted = (
db.query(BlockedToken).filter(BlockedToken.expires_at < datetime.now()).delete()
)
db.commit()
return deleted
async def release_old_locks(db: Session, max_lock_seconds: int):
"""Remove outdated locks on tasks"""
max_acceptable_time = datetime.now() - timedelta(seconds=max_lock_seconds)

View file

@ -1,2 +1,3 @@
from .api import route as api
from .api_app import route as api_app
from .views import route as views

View file

@ -85,22 +85,6 @@ async def create_results( # pylint: disable-msg=too-many-positional-arguments
return {"result_ids": [r.id for r in db_results]}
@route.post(
"/reschedule/all",
responses={
200: {
"content": {
"application/json": {"example": {"msg": "Non OK tasks reschuled"}}
}
}
},
)
async def reschedule_all(request: Request, db: Session = Depends(get_db)):
"""Reschedule checks of all non OK tasks ASAP"""
await queries.reschedule_all(db)
return {"msg": "Non OK tasks reschuled"}
@route.get(
"/stats",
responses={

View file

@ -0,0 +1,220 @@
"""Web interface for humans"""
from pathlib import Path
from typing import Annotated, Dict, Literal
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import JSONResponse
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy.orm import Session
from argos import VERSION
from argos.schemas import Config
from argos.server import queries
from argos.server.models import Result, User
from argos.server.routes.dependencies import (
create_user_token,
get_config,
get_db,
get_manager,
good_user_credentials,
)
route = APIRouter()
current_dir = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=current_dir / ".." / "templates")
SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4}
class Version(BaseModel):
argos_monitoring: str
logged_in: bool
model_config = {
"json_schema_extra": {
"examples": [
{"argos_monitoring": VERSION, "logged_in": True},
{"argos_monitoring": VERSION, "logged_in": False},
]
}
}
@route.get("/version", response_model=Version)
async def version(
request: Request,
):
"""Return a JSON object containing argos version
and indicates if you are logged in"""
token = request.cookies.get("access-token")
if token is not None and token != "":
manager = request.app.state.manager
user = await manager.get_current_user(token)
return Version(argos_monitoring=VERSION, logged_in=user is not None)
return Version(argos_monitoring=VERSION, logged_in=False)
class GrantedLogin(BaseModel):
access: Literal["granted"]
msg: str
cookie_name: Literal["access-token"] | None
cookie: str | None
model_config = {
"json_schema_extra": {
"examples": [
{
"access": "granted",
"msg": "Login successful",
"cookie_name": "access-token",
"cookie": "foobarbaz",
},
{"access": "granted", "msg": "No authentication needed"},
]
}
}
class DeniedLogin(BaseModel):
access: Literal["deniel"]
msg: str
model_config = {
"json_schema_extra": {
"examples": [
{"access": "denied", "msg": "Sorry, invalid username or bad password."},
{
"access": "denied",
"msg": "Sorry, invalid username or bad password. "
"Or the LDAP server is unreachable (see logs to verify).",
},
]
}
}
@route.post(
"/login", response_model=GrantedLogin, responses={401: {"model": DeniedLogin}}
)
async def login(
request: Request,
db: Session = Depends(get_db),
data: OAuth2PasswordRequestForm = Depends(),
rememberme: Annotated[str | None, Form()] = None,
config: Config = Depends(get_config),
):
"""If the login is successful, sends a token to be included as cookie
in requests needing authentication.
"""
if config.general.unauthenticated_access == "all":
return {"access": "granted", "msg": "No authentication needed"}
good_credentials = await good_user_credentials(
config, request, data.username, data.password
)
if config.general.ldap is not None:
if not good_credentials:
return JSONResponse(
content={
"access": "denied",
"msg": "Sorry, invalid username or bad password. "
"Or the LDAP server is unreachable (see logs to verify).",
},
status_code=401,
)
elif not good_credentials:
return JSONResponse(
content={
"access": "denied",
"msg": "Sorry, invalid username or bad password.",
},
status_code=401,
)
manager = request.app.state.manager
token = await create_user_token(manager, config.general, data.username, rememberme)
return JSONResponse(
content={
"access": "granted",
"msg": "Login successful",
"cookie_name": manager.cookie_name,
"cookie": token["token"],
}
)
class SuccessLogout(BaseModel):
logout: Literal["success"]
msg: str
@route.get("/logout", response_model=SuccessLogout)
async def logout(
request: Request,
config: Config = Depends(get_config),
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
):
"""Discard the token used for the request, so logging out the user"""
if config.general.unauthenticated_access == "all":
return {"logout": "error", "msg": "No authentication needed"}
await queries.block_token(db, request)
response = JSONResponse(content={"logout": "success", "msg": "logout successful"})
return response
class SeverityCounts(BaseModel):
severities: Dict[Literal["ok", "warning", "critical", "unknown"], int]
agents: int
model_config = {
"json_schema_extra": {
"examples": [
{
"severities": {"ok": 10, "warning": 0, "critical": 2, "unknown": 0},
"agents": 1,
}
]
}
}
@route.get("/", response_model=SeverityCounts)
async def get_severity_counts(
request: Request,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
):
"""Shows the number of results per severity and number of known agents"""
counts_dict = await queries.get_severity_counts(db)
agents = db.query(Result.agent_id).distinct().all()
return {
"severities": counts_dict,
"agents": len(agents),
}
@route.post(
"/reschedule/all",
responses={
200: {
"content": {
"application/json": {"example": {"msg": "Non OK tasks reschuled"}}
}
}
},
)
async def reschedule_all(
request: Request,
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
):
"""Reschedule checks of all non OK tasks ASAP"""
await queries.reschedule_all(db)
return {"msg": "Non OK tasks reschuled"}

View file

@ -1,8 +1,17 @@
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi_login import LoginManager
from ldap import INVALID_CREDENTIALS # pylint: disable-msg=no-name-in-module
from ldap.ldapobject import ReconnectLDAPObject
from passlib.context import CryptContext
from argos.logging import logger
from argos.schemas import Config, General
from argos.server.exceptions import NotAuthenticatedException
from argos.server.models import BlockedToken
from argos.server.queries import get_user
auth_scheme = HTTPBearer()
@ -23,6 +32,11 @@ async def get_manager(request: Request) -> LoginManager:
if request.app.state.config.general.unauthenticated_access is not None:
return await request.app.state.manager.optional(request)
token = await request.app.state.manager._get_token(request) # pylint: disable-msg=protected-access
db = request.app.state.SessionLocal()
if db.query(BlockedToken).filter(BlockedToken.token == token).count() > 0:
raise NotAuthenticatedException
return await request.app.state.manager(request)
@ -35,6 +49,33 @@ async def verify_token(
return token
async def good_user_credentials(
config: Config, request: Request, username: str, password: str
):
if config.general.ldap is not None:
return await good_ldap_user_credentials(
config, request.app.state.ldap, username, password
)
return await good_internal_user_credentials(
request.app.state.SessionLocal(), username, password
)
async def good_ldap_user_credentials(
config: Config, ldapobj: ReconnectLDAPObject, username: str, password: str
) -> bool:
ldap_dn = await find_ldap_user(config, ldapobj, username)
if ldap_dn is None:
return False
try:
ldapobj.simple_bind_s(ldap_dn, password)
except INVALID_CREDENTIALS:
return False
return True
async def find_ldap_user(config, ldapobj, user: str) -> str | None:
"""Do a LDAP search for user and return its dn"""
import ldap
@ -65,3 +106,32 @@ async def find_ldap_user(config, ldapobj, user: str) -> str | None:
return result[0][0]
return None
async def good_internal_user_credentials(db, username, password) -> bool:
user = await get_user(db, username)
if user is None:
return False
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
if not pwd_context.verify(password, user.password):
return False
user.last_login_at = datetime.now()
db.commit()
return True
async def create_user_token(
manager, config: General, username: str, rememberme: str | None = None
):
session_duration = config.session_duration
if config.remember_me_duration is not None and rememberme == "on":
session_duration = config.remember_me_duration
delta = timedelta(minutes=session_duration)
return {
"token": manager.create_access_token(data={"sub": username}, expires=delta),
"delta": delta,
}

View file

@ -1,6 +1,6 @@
"""Web interface for humans"""
from collections import defaultdict
from datetime import datetime, timedelta
from datetime import timedelta
from functools import cmp_to_key
from pathlib import Path
from typing import Annotated
@ -10,7 +10,6 @@ from fastapi import APIRouter, Cookie, Depends, Form, Request, status
from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.templating import Jinja2Templates
from passlib.context import CryptContext
from sqlalchemy import func
from sqlalchemy.orm import Session
@ -19,7 +18,13 @@ from argos.schemas import Config
from argos.server import queries
from argos.server.exceptions import NotAuthenticatedException
from argos.server.models import Result, Task, User
from argos.server.routes.dependencies import get_config, get_db, get_manager
from argos.server.routes.dependencies import (
create_user_token,
get_config,
get_db,
get_manager,
good_user_credentials,
)
route = APIRouter()
@ -28,7 +33,7 @@ templates = Jinja2Templates(directory=current_dir / ".." / "templates")
SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4}
@route.get("/login")
@route.get("/login", include_in_schema=False)
async def login_view(
request: Request,
msg: str | None = None,
@ -52,6 +57,8 @@ async def login_view(
if msg == "logout":
msg = "You have been successfully disconnected."
elif msg == "not-authenticated":
msg = "You are not authenticated or your token has expired"
else:
msg = None
@ -65,7 +72,7 @@ async def login_view(
)
@route.post("/login")
@route.post("/login", include_in_schema=False)
async def post_login(
request: Request,
db: Session = Depends(get_db),
@ -79,70 +86,48 @@ async def post_login(
status_code=status.HTTP_303_SEE_OTHER,
)
username = data.username
invalid_credentials = templates.TemplateResponse(
"login.html",
{"request": request, "msg": "Sorry, invalid username or bad password."},
good_credentials = await good_user_credentials(
config, request, data.username, data.password
)
if config.general.ldap is not None:
from ldap import INVALID_CREDENTIALS # pylint: disable-msg=no-name-in-module
from argos.server.routes.dependencies import find_ldap_user
invalid_credentials = templates.TemplateResponse(
if not good_credentials:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"msg": "Sorry, invalid username or bad password. "
"Or the LDAP server is unreachable (see logs to verify).",
},
)
elif not good_credentials:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"msg": "Sorry, invalid username or bad password. "
"Or the LDAP server is unreachable (see logs to verify).",
},
{"request": request, "msg": "Sorry, invalid username or bad password."},
)
ldap_dn = await find_ldap_user(config, request.app.state.ldap, username)
if ldap_dn is None:
return invalid_credentials
try:
request.app.state.ldap.simple_bind_s(ldap_dn, data.password)
except INVALID_CREDENTIALS:
return invalid_credentials
else:
user = await queries.get_user(db, username)
if user is None:
return invalid_credentials
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
if not pwd_context.verify(data.password, user.password):
return invalid_credentials
user.last_login_at = datetime.now()
db.commit()
manager = request.app.state.manager
session_duration = config.general.session_duration
if config.general.remember_me_duration is not None and rememberme == "on":
session_duration = config.general.remember_me_duration
delta = timedelta(minutes=session_duration)
token = manager.create_access_token(data={"sub": username}, expires=delta)
token = await create_user_token(manager, config.general, data.username, rememberme)
response = RedirectResponse(
request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER,
)
response.set_cookie(
key=manager.cookie_name,
value=token,
value=token["token"],
httponly=True,
samesite="strict",
expires=int(delta.total_seconds()),
expires=int(token["delta"].total_seconds()),
)
return response
@route.get("/logout")
@route.get("/logout", include_in_schema=False)
async def logout_view(
request: Request,
config: Config = Depends(get_config),
user: User | None = Depends(get_manager),
db: Session = Depends(get_db),
):
if config.general.unauthenticated_access == "all":
return RedirectResponse(
@ -150,6 +135,8 @@ async def logout_view(
status_code=status.HTTP_303_SEE_OTHER,
)
await queries.block_token(db, request)
response = RedirectResponse(
request.url_for("login_view").include_query_params(msg="logout"),
status_code=status.HTTP_303_SEE_OTHER,
@ -158,7 +145,7 @@ async def logout_view(
return response
@route.get("/")
@route.get("/", include_in_schema=False)
async def get_severity_counts_view(
request: Request,
user: User | None = Depends(get_manager),
@ -184,7 +171,7 @@ async def get_severity_counts_view(
)
@route.get("/domains")
@route.get("/domains", include_in_schema=False)
async def get_domains_view(
request: Request,
user: User | None = Depends(get_manager),
@ -241,7 +228,7 @@ async def get_domains_view(
)
@route.get("/domain/{domain}")
@route.get("/domain/{domain}", include_in_schema=False)
async def get_domain_tasks_view(
request: Request,
domain: str,
@ -266,7 +253,7 @@ async def get_domain_tasks_view(
)
@route.get("/result/{result_id}")
@route.get("/result/{result_id}", include_in_schema=False)
async def get_result_view(
request: Request,
result_id: int,
@ -291,7 +278,7 @@ async def get_result_view(
)
@route.get("/task/{task_id}/results")
@route.get("/task/{task_id}/results", include_in_schema=False)
async def get_task_results_view(
request: Request,
task_id: int,
@ -327,7 +314,7 @@ async def get_task_results_view(
)
@route.get("/agents")
@route.get("/agents", include_in_schema=False)
async def get_agents_view(
request: Request,
user: User | None = Depends(get_manager),
@ -355,7 +342,7 @@ async def get_agents_view(
)
@route.post("/refresh")
@route.post("/refresh", include_in_schema=False)
async def set_refresh_cookies_view(
request: Request,
user: User | None = Depends(get_manager),