🔀 Merge remote-tracking branch 'origin/develop'

This commit is contained in:
Luc Didry 2024-11-28 15:41:37 +01:00
commit 594fbd6881
No known key found for this signature in database
GPG key ID: EA868E12D0257E3C
29 changed files with 623 additions and 242 deletions

View file

@ -1,3 +1,4 @@
---
image: python:3.11 image: python:3.11
stages: stages:
@ -18,6 +19,9 @@ default:
install: install:
stage: install stage: install
before_script:
- apt-get update
- apt-get install -y build-essential libldap-dev libsasl2-dev
script: script:
- make venv - make venv
- make develop - make develop

View file

@ -2,6 +2,20 @@
## [Unreleased] ## [Unreleased]
- 💄 — Show only not-OK domains by default in domains list, to reduce the load on browser
- ♿️ — Fix not-OK domains display if javascript is disabled
- ✨ — Retry check right after a httpx.ReadError
- ✨ — The HTTP method used by checks is now configurable
- ♻️ — Refactor some agent code
- 💄 — Filter form on domains list (#66)
- ✨ — Add "Remember me" checkbox on login (#65)
- ✨ — Add a setting to set a reschedule delay if check failed (#67)
BREAKING CHANGE: `mo` is no longer accepted for declaring a duration in month in the configuration
You need to use `M`, `month` or `months`
- ✨ - Allow to choose a frequency smaller than a minute
- ✨🛂 — Allow partial or total anonymous access to web interface (#63)
- ✨🛂 — Allow to use a LDAP server for authentication (#64)
## 0.5.0 ## 0.5.0
Date: 2024-09-26 Date: 2024-09-26
@ -68,7 +82,7 @@ Date: 2024-06-24
- 💄📯 — Improve notifications and result(s) pages - 💄📯 — Improve notifications and result(s) pages
- 🔊 — Add level of log before the log message - 🔊 — Add level of log before the log message
— 🔊 — Add a warning messages in the logs if there is no tasks in database. (fix #41) - 🔊 — Add a warning message in the logs if there is no tasks in database. (fix #41)
- ✨ — Add command to generate example configuration (fix #38) - ✨ — Add command to generate example configuration (fix #38)
- 📝 — Improve documentation - 📝 — Improve documentation
- ✨ — Add command to warn if its been long since last viewing an agent (fix #49) - ✨ — Add command to warn if its been long since last viewing an agent (fix #49)

View file

@ -10,7 +10,7 @@ NC=\033[0m # No Color
venv: ## Create the venv venv: ## Create the venv
python3 -m venv venv python3 -m venv venv
develop: venv ## Install the dev dependencies develop: venv ## Install the dev dependencies
venv/bin/pip install -e ".[dev,docs]" venv/bin/pip install -e ".[dev,docs,ldap]"
docs: cog ## Build the docs docs: cog ## Build the docs
venv/bin/sphinx-build docs public venv/bin/sphinx-build docs public
if [ ! -e "public/mermaid.min.js" ]; then curl -sL $$(grep mermaid.min.js public/search.html | cut -f 2 -d '"') --output public/mermaid.min.js; fi if [ ! -e "public/mermaid.min.js" ]; then curl -sL $$(grep mermaid.min.js public/search.html | cut -f 2 -d '"') --output public/mermaid.min.js; fi

View file

@ -6,6 +6,7 @@ import asyncio
import json import json
import logging import logging
import socket import socket
from time import sleep
from typing import List from typing import List
import httpx import httpx
@ -63,9 +64,24 @@ class ArgosAgent:
async def _complete_task(self, _task: dict) -> AgentResult: async def _complete_task(self, _task: dict) -> AgentResult:
try: try:
task = Task(**_task) task = Task(**_task)
url = task.url
if task.check == "http-to-https":
url = str(httpx.URL(task.url).copy_with(scheme="http"))
try:
response = await self._http_client.request( # type: ignore[attr-defined]
method=task.method, url=url, timeout=60
)
except httpx.ReadError:
sleep(1)
response = await self._http_client.request( # type: ignore[attr-defined]
method=task.method, url=url, timeout=60
)
check_class = get_registered_check(task.check) check_class = get_registered_check(task.check)
check = check_class(self._http_client, task) check = check_class(task)
result = await check.run() result = await check.run(response)
status = result.status status = result.status
context = result.context context = result.context

View file

@ -3,7 +3,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Type from typing import Type
import httpx
from pydantic import BaseModel from pydantic import BaseModel
from argos.schemas.models import Task from argos.schemas.models import Task
@ -92,8 +91,7 @@ class BaseCheck:
raise CheckNotFound(name) raise CheckNotFound(name)
return check return check
def __init__(self, http_client: httpx.AsyncClient, task: Task): def __init__(self, task: Task):
self.http_client = http_client
self.task = task self.task = task
@property @property

View file

@ -4,7 +4,7 @@ import json
import re import re
from datetime import datetime from datetime import datetime
from httpx import URL from httpx import Response
from jsonpointer import resolve_pointer, JsonPointerException from jsonpointer import resolve_pointer, JsonPointerException
from argos.checks.base import ( from argos.checks.base import (
@ -22,13 +22,7 @@ class HTTPStatus(BaseCheck):
config = "status-is" config = "status-is"
expected_cls = ExpectedIntValue expected_cls = ExpectedIntValue
async def run(self) -> dict: async def run(self, response: Response) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
return self.response( return self.response(
status=response.status_code == self.expected, status=response.status_code == self.expected,
expected=self.expected, expected=self.expected,
@ -42,13 +36,7 @@ class HTTPStatusIn(BaseCheck):
config = "status-in" config = "status-in"
expected_cls = ExpectedStringValue expected_cls = ExpectedStringValue
async def run(self) -> dict: async def run(self, response: Response) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
return self.response( return self.response(
status=response.status_code in json.loads(self.expected), status=response.status_code in json.loads(self.expected),
expected=self.expected, expected=self.expected,
@ -62,11 +50,7 @@ class HTTPToHTTPS(BaseCheck):
config = "http-to-https" config = "http-to-https"
expected_cls = ExpectedStringValue expected_cls = ExpectedStringValue
async def run(self) -> dict: async def run(self, response: Response) -> dict:
task = self.task
url = URL(task.url).copy_with(scheme="http")
response = await self.http_client.request(method="get", url=url, timeout=60)
expected_dict = json.loads(self.expected) expected_dict = json.loads(self.expected)
expected = range(300, 400) expected = range(300, 400)
if "range" in expected_dict: if "range" in expected_dict:
@ -90,13 +74,7 @@ class HTTPHeadersContain(BaseCheck):
config = "headers-contain" config = "headers-contain"
expected_cls = ExpectedStringValue expected_cls = ExpectedStringValue
async def run(self) -> dict: async def run(self, response: Response) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
status = True status = True
for header in json.loads(self.expected): for header in json.loads(self.expected):
if header not in response.headers: if header not in response.headers:
@ -116,13 +94,7 @@ class HTTPHeadersHave(BaseCheck):
config = "headers-have" config = "headers-have"
expected_cls = ExpectedStringValue expected_cls = ExpectedStringValue
async def run(self) -> dict: async def run(self, response: Response) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
status = True status = True
for header, value in json.loads(self.expected).items(): for header, value in json.loads(self.expected).items():
if header not in response.headers: if header not in response.headers:
@ -146,13 +118,7 @@ class HTTPHeadersLike(BaseCheck):
config = "headers-like" config = "headers-like"
expected_cls = ExpectedStringValue expected_cls = ExpectedStringValue
async def run(self) -> dict: async def run(self, response: Response) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
status = True status = True
for header, value in json.loads(self.expected).items(): for header, value in json.loads(self.expected).items():
if header not in response.headers: if header not in response.headers:
@ -175,10 +141,7 @@ class HTTPBodyContains(BaseCheck):
config = "body-contains" config = "body-contains"
expected_cls = ExpectedStringValue expected_cls = ExpectedStringValue
async def run(self) -> dict: async def run(self, response: Response) -> dict:
response = await self.http_client.request(
method="get", url=self.task.url, timeout=60
)
return self.response(status=self.expected in response.text) return self.response(status=self.expected in response.text)
@ -188,10 +151,7 @@ class HTTPBodyLike(BaseCheck):
config = "body-like" config = "body-like"
expected_cls = ExpectedStringValue expected_cls = ExpectedStringValue
async def run(self) -> dict: async def run(self, response: Response) -> dict:
response = await self.http_client.request(
method="get", url=self.task.url, timeout=60
)
if re.search(rf"{self.expected}", response.text): if re.search(rf"{self.expected}", response.text):
return self.response(status=True) return self.response(status=True)
@ -205,13 +165,7 @@ class HTTPJsonContains(BaseCheck):
config = "json-contains" config = "json-contains"
expected_cls = ExpectedStringValue expected_cls = ExpectedStringValue
async def run(self) -> dict: async def run(self, response: Response) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
obj = response.json() obj = response.json()
status = True status = True
@ -235,13 +189,7 @@ class HTTPJsonHas(BaseCheck):
config = "json-has" config = "json-has"
expected_cls = ExpectedStringValue expected_cls = ExpectedStringValue
async def run(self) -> dict: async def run(self, response: Response) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
obj = response.json() obj = response.json()
status = True status = True
@ -269,13 +217,7 @@ class HTTPJsonLike(BaseCheck):
config = "json-like" config = "json-like"
expected_cls = ExpectedStringValue expected_cls = ExpectedStringValue
async def run(self) -> dict: async def run(self, response: Response) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
obj = response.json() obj = response.json()
status = True status = True
@ -302,13 +244,7 @@ class HTTPJsonIs(BaseCheck):
config = "json-is" config = "json-is"
expected_cls = ExpectedStringValue expected_cls = ExpectedStringValue
async def run(self) -> dict: async def run(self, response: Response) -> dict:
# XXX Get the method from the task
task = self.task
response = await self.http_client.request(
method="get", url=task.url, timeout=60
)
obj = response.json() obj = response.json()
status = response.json() == json.loads(self.expected) status = response.json() == json.loads(self.expected)
@ -326,10 +262,8 @@ class SSLCertificateExpiration(BaseCheck):
config = "ssl-certificate-expiration" config = "ssl-certificate-expiration"
expected_cls = ExpectedStringValue expected_cls = ExpectedStringValue
async def run(self): async def run(self, response: Response) -> dict:
"""Returns the number of days in which the certificate will expire.""" """Returns the number of days in which the certificate will expire."""
response = await self.http_client.get(self.task.url, timeout=60)
network_stream = response.extensions["network_stream"] network_stream = response.extensions["network_stream"]
ssl_obj = network_stream.get_extra_info("ssl_object") ssl_obj = network_stream.get_extra_info("ssl_object")
cert = ssl_obj.getpeercert() cert = ssl_obj.getpeercert()

View file

@ -1,5 +1,7 @@
--- ---
general: general:
# Except for frequency and recheck_delay settings, changes in general
# section of the configuration will need a restart of argos server.
db: db:
# The database URL, as defined in SQLAlchemy docs : # The database URL, as defined in SQLAlchemy docs :
# https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls # https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls
@ -14,13 +16,54 @@ general:
# Can be "production", "dev", "test". # Can be "production", "dev", "test".
# If not present, default value is "production" # If not present, default value is "production"
env: "production" env: "production"
# to get a good string for cookie_secret, run: # To get a good string for cookie_secret, run:
# openssl rand -hex 32 # openssl rand -hex 32
cookie_secret: "foo_bar_baz" cookie_secret: "foo_bar_baz"
# Session duration
# Use m for minutes, h for hours, d for days
# w for weeks, M for months, y for years
# See https://github.com/timwedde/durations_nlp#scales-reference for details
# If not present, default value is "7d"
session_duration: "7d"
# Session opened with "Remember me" checked
# If not present, the "Remember me" feature is not available
# remember_me_duration: "1M"
# Unauthenticated access
# If can grant an unauthenticated access to the dashboard or to all pages
# To do so, choose either "dashboard", or "all"
# If not present, all pages needs authentication
# unauthenticated_access: "all"
# LDAP authentication
# Instead of relying on Argos users, use a LDAP server to authenticate users.
# If not present, Argos native user system is used.
# ldap:
# # Server URI
# uri: "ldaps://ldap.example.org"
# # Search base DN
# user_tree: "ou=users,dc=example,dc=org"
# # Search bind DN
# bind_dn: "uid=ldap_user,ou=users,dc=example,dc=org"
# # Search bind password
# bind_pwd: "secr3t"
# # User attribute (uid, mail, sAMAccountName, etc.)
# user_attr: "uid"
# # User filter (to exclude some users, etc.)
# user_filter: "(!(uid=ldap_user))"
# Default delay for checks. # Default delay for checks.
# Can be superseeded in domain configuration. # Can be superseeded in domain configuration.
# For ex., to run checks every minute: # For ex., to run checks every 5 minutes:
frequency: "1m" frequency: "5m"
# Default re-check delay if a check has failed.
# Can be superseeded in domain configuration.
# If not present, failed checked wont be re-checked (they will be
# run again like if they succeded
# For ex., to re-try a check one minute after a failure:
# recheck_delay: "1m"
# Which way do you want to be warned when a check goes to that severity? # Which way do you want to be warned when a check goes to that severity?
# "local" emits a message in the server log # "local" emits a message in the server log
# Youll need to configure mail, gotify or apprise below to be able to use # Youll need to configure mail, gotify or apprise below to be able to use
@ -93,6 +136,11 @@ websites:
- domain: "https://mypads.example.org" - domain: "https://mypads.example.org"
paths: paths:
- path: "/mypads/" - path: "/mypads/"
# Specify the method of the HTTP request
# Valid values are "GET", "HEAD", "POST", "OPTIONS",
# "CONNECT", "TRACE", "PUT", "PATCH" and "DELETE"
# default is "GET" if omitted
method: "GET"
checks: checks:
# Check that the returned HTTP status is 200 # Check that the returned HTTP status is 200
- status-is: 200 - status-is: 200
@ -164,6 +212,7 @@ websites:
- json-is: '{"foo": "bar", "baz": 42}' - json-is: '{"foo": "bar", "baz": 42}'
- domain: "https://munin.example.org" - domain: "https://munin.example.org"
frequency: "20m" frequency: "20m"
recheck_delay: "5m"
paths: paths:
- path: "/" - path: "/"
checks: checks:

View file

@ -5,8 +5,9 @@ For database models, see argos.server.models.
import json import json
from typing import Dict, List, Literal, Optional, Tuple from typing import Dict, List, Literal, Tuple
from durations_nlp import Duration
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
ConfigDict, ConfigDict,
@ -22,10 +23,11 @@ from pydantic.networks import UrlConstraints
from pydantic_core import Url from pydantic_core import Url
from typing_extensions import Annotated from typing_extensions import Annotated
from argos.schemas.utils import string_to_duration from argos.schemas.utils import Method
Severity = Literal["warning", "error", "critical", "unknown"] Severity = Literal["warning", "error", "critical", "unknown"]
Environment = Literal["dev", "test", "production"] Environment = Literal["dev", "test", "production"]
Unauthenticated = Literal["dashboard", "all"]
SQLiteDsn = Annotated[ SQLiteDsn = Annotated[
Url, Url,
UrlConstraints( UrlConstraints(
@ -37,7 +39,7 @@ SQLiteDsn = Annotated[
def parse_threshold(value): def parse_threshold(value):
"""Parse duration threshold for SSL certificate validity""" """Parse duration threshold for SSL certificate validity"""
for duration_str, severity in value.items(): for duration_str, severity in value.items():
days = string_to_duration(duration_str, "days") days = Duration(duration_str).to_days()
# Return here because it's one-item dicts. # Return here because it's one-item dicts.
return (days, severity) return (days, severity)
@ -104,6 +106,7 @@ def parse_checks(value):
class WebsitePath(BaseModel): class WebsitePath(BaseModel):
path: str path: str
method: Method = "GET"
checks: List[ checks: List[
Annotated[ Annotated[
Tuple[str, str], Tuple[str, str],
@ -114,14 +117,23 @@ class WebsitePath(BaseModel):
class Website(BaseModel): class Website(BaseModel):
domain: HttpUrl domain: HttpUrl
frequency: Optional[int] = None frequency: float | None = None
recheck_delay: float | None = None
paths: List[WebsitePath] paths: List[WebsitePath]
@field_validator("frequency", mode="before") @field_validator("frequency", mode="before")
def parse_frequency(cls, value): def parse_frequency(cls, value):
"""Convert the configured frequency to minutes""" """Convert the configured frequency to minutes"""
if value: if value:
return string_to_duration(value, "minutes") return Duration(value).to_minutes()
return None
@field_validator("recheck_delay", mode="before")
def parse_recheck_delay(cls, value):
"""Convert the configured recheck delay to minutes"""
if value:
return Duration(value).to_minutes()
return None return None
@ -147,7 +159,7 @@ class Mail(BaseModel):
port: PositiveInt = 25 port: PositiveInt = 25
ssl: StrictBool = False ssl: StrictBool = False
starttls: StrictBool = False starttls: StrictBool = False
auth: Optional[MailAuth] = None auth: MailAuth | None = None
addresses: List[EmailStr] addresses: List[EmailStr]
@ -171,23 +183,58 @@ class DbSettings(BaseModel):
max_overflow: int = 20 max_overflow: int = 20
class LdapSettings(BaseModel):
uri: str
user_tree: str
bind_dn: str | None = None
bind_pwd: str | None = None
user_attr: str
user_filter: str | None = None
class General(BaseModel): class General(BaseModel):
"""Frequency for the checks and alerts""" """Frequency for the checks and alerts"""
cookie_secret: str
frequency: int
db: DbSettings db: DbSettings
env: Environment = "production" env: Environment = "production"
cookie_secret: str
session_duration: int = 10080 # 7 days
remember_me_duration: int | None = None
unauthenticated_access: Unauthenticated | None = None
ldap: LdapSettings | None = None
frequency: float
recheck_delay: float | None = None
root_path: str = "" root_path: str = ""
alerts: Alert alerts: Alert
mail: Optional[Mail] = None mail: Mail | None = None
gotify: Optional[List[GotifyUrl]] = None gotify: List[GotifyUrl] | None = None
apprise: Optional[Dict[str, List[str]]] = None apprise: Dict[str, List[str]] | None = None
@field_validator("session_duration", mode="before")
def parse_session_duration(cls, value):
"""Convert the configured session duration to minutes"""
return Duration(value).to_minutes()
@field_validator("remember_me_duration", mode="before")
def parse_remember_me_duration(cls, value):
"""Convert the configured session duration with remember me feature to minutes"""
if value:
return int(Duration(value).to_minutes())
return None
@field_validator("frequency", mode="before") @field_validator("frequency", mode="before")
def parse_frequency(cls, value): def parse_frequency(cls, value):
"""Convert the configured frequency to minutes""" """Convert the configured frequency to minutes"""
return string_to_duration(value, "minutes") return Duration(value).to_minutes()
@field_validator("recheck_delay", mode="before")
def parse_recheck_delay(cls, value):
"""Convert the configured recheck delay to minutes"""
if value:
return Duration(value).to_minutes()
return None
class Config(BaseModel): class Config(BaseModel):

View file

@ -8,6 +8,8 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from argos.schemas.utils import Method
# XXX Refactor using SQLModel to avoid duplication of model data # XXX Refactor using SQLModel to avoid duplication of model data
@ -18,6 +20,7 @@ class Task(BaseModel):
url: str url: str
domain: str domain: str
check: str check: str
method: Method
expected: str expected: str
selected_at: datetime | None selected_at: datetime | None
selected_by: str | None selected_by: str | None

View file

@ -1,42 +1,6 @@
from typing import Literal from typing import Literal
def string_to_duration( Method = Literal[
value: str, target: Literal["days", "hours", "minutes"] "GET", "HEAD", "POST", "OPTIONS", "CONNECT", "TRACE", "PUT", "PATCH", "DELETE"
) -> int | float: ]
"""Convert a string to a number of hours, days or minutes"""
num = int("".join(filter(str.isdigit, value)))
# It's not possible to convert from a smaller unit to a greater one:
# - hours and minutes cannot be converted to days
# - minutes cannot be converted to hours
if (target == "days" and ("h" in value or "m" in value.replace("mo", ""))) or (
target == "hours" and "m" in value.replace("mo", "")
):
msg = (
"Durations cannot be converted from a smaller to a greater unit. "
f"(trying to convert '{value}' to {target})"
)
raise ValueError(msg, value)
# Consider we're converting to minutes, do the eventual multiplication at the end.
if "h" in value:
num = num * 60
elif "d" in value:
num = num * 60 * 24
elif "w" in value:
num = num * 60 * 24 * 7
elif "mo" in value:
num = num * 60 * 24 * 30 # considers 30d in a month
elif "y" in value:
num = num * 60 * 24 * 365 # considers 365d in a year
elif "m" not in value:
raise ValueError("Invalid duration value", value)
if target == "hours":
return num / 60
if target == "days":
return num / 60 / 24
# target == "minutes"
return num

View file

@ -25,7 +25,7 @@ def get_icon_from_severity(severity: str) -> str:
return icon return icon
def handle_alert(config: Config, result, task, severity, old_severity, request): def handle_alert(config: Config, result, task, severity, old_severity, request): # pylint: disable-msg=too-many-positional-arguments
"""Dispatch alert through configured alert channels""" """Dispatch alert through configured alert channels"""
if "local" in getattr(config.general.alerts, severity): if "local" in getattr(config.general.alerts, severity):
@ -64,7 +64,7 @@ def handle_alert(config: Config, result, task, severity, old_severity, request):
) )
def notify_with_apprise( def notify_with_apprise( # pylint: disable-msg=too-many-positional-arguments
result, task, severity: str, old_severity: str, group: List[str], request result, task, severity: str, old_severity: str, group: List[str], request
) -> None: ) -> None:
logger.debug("Will send apprise notification") logger.debug("Will send apprise notification")
@ -90,7 +90,7 @@ See results of task on {request.url_for('get_task_results_view', task_id=task.id
apobj.notify(title=title, body=msg) apobj.notify(title=title, body=msg)
def notify_by_mail( def notify_by_mail( # pylint: disable-msg=too-many-positional-arguments
result, task, severity: str, old_severity: str, config: Mail, request result, task, severity: str, old_severity: str, config: Mail, request
) -> None: ) -> None:
logger.debug("Will send mail notification") logger.debug("Will send mail notification")
@ -137,7 +137,7 @@ See results of task on {request.url_for('get_task_results_view', task_id=task.id
smtp.send_message(mail, to_addrs=address) smtp.send_message(mail, to_addrs=address)
def notify_with_gotify( def notify_with_gotify( # pylint: disable-msg=too-many-positional-arguments
result, task, severity: str, old_severity: str, config: List[GotifyUrl], request result, task, severity: str, old_severity: str, config: List[GotifyUrl], request
) -> None: ) -> None:
logger.debug("Will send gotify notification") logger.debug("Will send gotify notification")

View file

@ -36,13 +36,25 @@ def get_application() -> FastAPI:
appli.add_exception_handler(NotAuthenticatedException, auth_exception_handler) appli.add_exception_handler(NotAuthenticatedException, auth_exception_handler)
appli.state.manager = create_manager(config.general.cookie_secret) appli.state.manager = create_manager(config.general.cookie_secret)
if config.general.ldap is not None:
import ldap
l = ldap.initialize(config.general.ldap.uri)
l.simple_bind_s(config.general.ldap.bind_dn, config.general.ldap.bind_pwd)
appli.state.ldap = l
@appli.state.manager.user_loader() @appli.state.manager.user_loader()
async def query_user(user: str) -> None | models.User: async def query_user(user: str) -> None | str | models.User:
""" """
Get a user from the db Get a user from the db or LDAP
:param user: name of the user :param user: name of the user
:return: None or the user object :return: None or the user object
""" """
if appli.state.config.general.ldap is not None:
from argos.server.routes.dependencies import find_ldap_user
return await find_ldap_user(appli.state.config, appli.state.ldap, user)
return await queries.get_user(appli.state.db, user) return await queries.get_user(appli.state.db, user)
appli.include_router(routes.api, prefix="/api") appli.include_router(routes.api, prefix="/api")
@ -100,7 +112,7 @@ def setup_database(appli):
models.Base.metadata.create_all(bind=engine) models.Base.metadata.create_all(bind=engine)
def create_manager(cookie_secret): def create_manager(cookie_secret: str) -> LoginManager:
if cookie_secret == "foo_bar_baz": if cookie_secret == "foo_bar_baz":
logger.warning( logger.warning(
"You should change the cookie_secret secret in your configuration file." "You should change the cookie_secret secret in your configuration file."

View file

@ -0,0 +1,30 @@
"""Add recheck delay
Revision ID: 127d74c770bb
Revises: dcf73fa19fce
Create Date: 2024-11-27 16:04:58.138768
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "127d74c770bb"
down_revision: Union[str, None] = "dcf73fa19fce"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("tasks", schema=None) as batch_op:
batch_op.add_column(sa.Column("recheck_delay", sa.Float(), nullable=True))
batch_op.add_column(sa.Column("already_retried", sa.Boolean(), nullable=False))
def downgrade() -> None:
with op.batch_alter_table("tasks", schema=None) as batch_op:
batch_op.drop_column("already_retried")
batch_op.drop_column("recheck_delay")

View file

@ -0,0 +1,38 @@
"""Make frequency a float
Revision ID: a1e98cf72a5c
Revises: 127d74c770bb
Create Date: 2024-11-27 16:10:13.000705
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a1e98cf72a5c"
down_revision: Union[str, None] = "127d74c770bb"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("tasks", schema=None) as batch_op:
batch_op.alter_column(
"frequency",
existing_type=sa.INTEGER(),
type_=sa.Float(),
existing_nullable=False,
)
def downgrade() -> None:
with op.batch_alter_table("tasks", schema=None) as batch_op:
batch_op.alter_column(
"frequency",
existing_type=sa.Float(),
type_=sa.INTEGER(),
existing_nullable=False,
)

View file

@ -0,0 +1,45 @@
"""Specify check method
Revision ID: dcf73fa19fce
Revises: c780864dc407
Create Date: 2024-11-26 14:40:27.510587
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "dcf73fa19fce"
down_revision: Union[str, None] = "c780864dc407"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("tasks", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"method",
sa.Enum(
"GET",
"HEAD",
"POST",
"OPTIONS",
"CONNECT",
"TRACE",
"PUT",
"PATCH",
"DELETE",
name="method",
),
nullable=False,
)
)
def downgrade() -> None:
with op.batch_alter_table("tasks", schema=None) as batch_op:
batch_op.drop_column("method")

View file

@ -12,6 +12,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from argos.checks import BaseCheck, get_registered_check from argos.checks import BaseCheck, get_registered_check
from argos.schemas import WebsiteCheck from argos.schemas import WebsiteCheck
from argos.schemas.utils import Method
class Base(DeclarativeBase): class Base(DeclarativeBase):
@ -34,7 +35,24 @@ class Task(Base):
domain: Mapped[str] = mapped_column() domain: Mapped[str] = mapped_column()
check: Mapped[str] = mapped_column() check: Mapped[str] = mapped_column()
expected: Mapped[str] = mapped_column() expected: Mapped[str] = mapped_column()
frequency: Mapped[int] = mapped_column() frequency: Mapped[float] = mapped_column()
recheck_delay: Mapped[float] = mapped_column(nullable=True)
already_retried: Mapped[bool] = mapped_column(insert_default=False)
method: Mapped[Method] = mapped_column(
Enum(
"GET",
"HEAD",
"POST",
"OPTIONS",
"CONNECT",
"TRACE",
"PUT",
"PATCH",
"DELETE",
name="method",
),
insert_default="GET",
)
# Orchestration-related # Orchestration-related
selected_by: Mapped[str] = mapped_column(nullable=True) selected_by: Mapped[str] = mapped_column(nullable=True)
@ -70,7 +88,16 @@ class Task(Base):
now = datetime.now() now = datetime.now()
self.completed_at = now self.completed_at = now
if (
self.recheck_delay is not None
and severity != "ok"
and not self.already_retried
):
self.next_run = now + timedelta(minutes=self.recheck_delay)
self.already_retried = True
else:
self.next_run = now + timedelta(minutes=self.frequency) self.next_run = now + timedelta(minutes=self.frequency)
self.already_retried = False
@property @property
def last_result(self): def last_result(self):

View file

@ -100,6 +100,11 @@ async def has_config_changed(db: Session, config: schemas.Config) -> bool:
same_config = False same_config = False
conf.val = str(config.general.frequency) conf.val = str(config.general.frequency)
conf.updated_at = datetime.now() conf.updated_at = datetime.now()
case "general_recheck_delay":
if conf.val != str(config.general.recheck_delay):
same_config = False
conf.val = str(config.general.recheck_delay)
conf.updated_at = datetime.now()
db.commit() db.commit()
@ -115,8 +120,14 @@ async def has_config_changed(db: Session, config: schemas.Config) -> bool:
val=str(config.general.frequency), val=str(config.general.frequency),
updated_at=datetime.now(), updated_at=datetime.now(),
) )
gen_recheck = ConfigCache(
name="general_recheck_delay",
val=str(config.general.recheck_delay),
updated_at=datetime.now(),
)
db.add(web_hash) db.add(web_hash)
db.add(gen_freq) db.add(gen_freq)
db.add(gen_recheck)
db.commit() db.commit()
return True return True
@ -137,6 +148,7 @@ async def update_from_config(db: Session, config: schemas.Config):
for website in config.websites: for website in config.websites:
domain = str(website.domain) domain = str(website.domain)
frequency = website.frequency or config.general.frequency frequency = website.frequency or config.general.frequency
recheck_delay = website.recheck_delay or config.general.recheck_delay
for p in website.paths: for p in website.paths:
url = urljoin(domain, str(p.path)) url = urljoin(domain, str(p.path))
@ -146,6 +158,7 @@ async def update_from_config(db: Session, config: schemas.Config):
db.query(Task) db.query(Task)
.filter( .filter(
Task.url == url, Task.url == url,
Task.method == p.method,
Task.check == check_key, Task.check == check_key,
Task.expected == expected, Task.expected == expected,
) )
@ -157,13 +170,18 @@ async def update_from_config(db: Session, config: schemas.Config):
if frequency != existing_task.frequency: if frequency != existing_task.frequency:
existing_task.frequency = frequency existing_task.frequency = frequency
if recheck_delay != existing_task.recheck_delay:
existing_task.recheck_delay = recheck_delay # type: ignore[assignment]
logger.debug( logger.debug(
"Skipping db task creation for url=%s, " "Skipping db task creation for url=%s, "
"check_key=%s, expected=%s, frequency=%s.", "method=%s, check_key=%s, expected=%s, "
"frequency=%s, recheck_delay=%s.",
url, url,
p.method,
check_key, check_key,
expected, expected,
frequency, frequency,
recheck_delay,
) )
else: else:
@ -173,9 +191,12 @@ async def update_from_config(db: Session, config: schemas.Config):
task = Task( task = Task(
domain=domain, domain=domain,
url=url, url=url,
method=p.method,
check=check_key, check=check_key,
expected=expected, expected=expected,
frequency=frequency, frequency=frequency,
recheck_delay=recheck_delay,
already_retried=False,
) )
logger.debug("Adding a new task in the db: %s", task) logger.debug("Adding a new task in the db: %s", task)
tasks.append(task) tasks.append(task)

View file

@ -30,7 +30,7 @@ async def read_tasks(
@route.post("/results", status_code=201, dependencies=[Depends(verify_token)]) @route.post("/results", status_code=201, dependencies=[Depends(verify_token)])
async def create_results( async def create_results( # pylint: disable-msg=too-many-positional-arguments
request: Request, request: Request,
results: List[AgentResult], results: List[AgentResult],
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,

View file

@ -18,6 +18,9 @@ def get_config(request: Request):
async def get_manager(request: Request) -> LoginManager: 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)
return await request.app.state.manager(request) return await request.app.state.manager(request)
@ -28,3 +31,28 @@ async def verify_token(
if token.credentials not in request.app.state.config.service.secrets: if token.credentials not in request.app.state.config.service.secrets:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
return token return token
async def find_ldap_user(config, ldap, user: str) -> str | None:
"""Do a LDAP search for user and return its dn"""
import ldap.filter as ldap_filter
from ldapurl import LDAP_SCOPE_SUBTREE
result = ldap.search_s(
config.general.ldap.user_tree,
LDAP_SCOPE_SUBTREE,
filterstr=ldap_filter.filter_format(
f"(&(%s=%s){config.general.ldap.user_filter})",
[
config.general.ldap.user_attr,
user,
],
),
attrlist=[config.general.ldap.user_attr],
)
# If there is a result, there should, logically, be only one entry
if len(result) > 0:
return result[0][0]
return None

View file

@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
from argos.checks.base import Status from argos.checks.base import Status
from argos.schemas import Config from argos.schemas import Config
from argos.server import queries from argos.server import queries
from argos.server.exceptions import NotAuthenticatedException
from argos.server.models import Result, Task, User 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 get_config, get_db, get_manager
@ -28,7 +29,17 @@ SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4}
@route.get("/login") @route.get("/login")
async def login_view(request: Request, msg: str | None = None): async def login_view(
request: Request,
msg: str | None = None,
config: Config = Depends(get_config),
):
if config.general.unauthenticated_access == "all":
return RedirectResponse(
request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER,
)
token = request.cookies.get("access-token") token = request.cookies.get("access-token")
if token is not None and token != "": if token is not None and token != "":
manager = request.app.state.manager manager = request.app.state.manager
@ -44,7 +55,14 @@ async def login_view(request: Request, msg: str | None = None):
else: else:
msg = None msg = None
return templates.TemplateResponse("login.html", {"request": request, "msg": msg}) return templates.TemplateResponse(
"login.html",
{
"request": request,
"msg": msg,
"remember": config.general.remember_me_duration,
},
)
@route.post("/login") @route.post("/login")
@ -52,13 +70,35 @@ async def post_login(
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
data: OAuth2PasswordRequestForm = Depends(), data: OAuth2PasswordRequestForm = Depends(),
rememberme: Annotated[str | None, Form()] = None,
config: Config = Depends(get_config),
): ):
if config.general.unauthenticated_access == "all":
return RedirectResponse(
request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER,
)
username = data.username username = data.username
user = await queries.get_user(db, username)
invalid_credentials = templates.TemplateResponse( invalid_credentials = templates.TemplateResponse(
"login.html", "login.html",
{"request": request, "msg": "Sorry, invalid username or bad password."}, {"request": request, "msg": "Sorry, invalid username or bad 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
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: if user is None:
return invalid_credentials return invalid_credentials
@ -70,19 +110,37 @@ async def post_login(
db.commit() db.commit()
manager = request.app.state.manager manager = request.app.state.manager
token = manager.create_access_token( session_duration = config.general.session_duration
data={"sub": username}, expires=timedelta(days=7) 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)
response = RedirectResponse( response = RedirectResponse(
request.url_for("get_severity_counts_view"), request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
) )
manager.set_cookie(response, token) response.set_cookie(
key=manager.cookie_name,
value=token,
httponly=True,
samesite="strict",
expires=int(delta.total_seconds()),
)
return response return response
@route.get("/logout") @route.get("/logout")
async def logout_view(request: Request, user: User | None = Depends(get_manager)): async def logout_view(
request: Request,
config: Config = Depends(get_config),
user: User | None = Depends(get_manager),
):
if config.general.unauthenticated_access == "all":
return RedirectResponse(
request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER,
)
response = RedirectResponse( response = RedirectResponse(
request.url_for("login_view").include_query_params(msg="logout"), request.url_for("login_view").include_query_params(msg="logout"),
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
@ -112,6 +170,7 @@ async def get_severity_counts_view(
"agents": agents, "agents": agents,
"auto_refresh_enabled": auto_refresh_enabled, "auto_refresh_enabled": auto_refresh_enabled,
"auto_refresh_seconds": auto_refresh_seconds, "auto_refresh_seconds": auto_refresh_seconds,
"user": user,
}, },
) )
@ -120,9 +179,14 @@ async def get_severity_counts_view(
async def get_domains_view( async def get_domains_view(
request: Request, request: Request,
user: User | None = Depends(get_manager), user: User | None = Depends(get_manager),
config: Config = Depends(get_config),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Show all tasks and their current state""" """Show all tasks and their current state"""
if config.general.unauthenticated_access == "dashboard":
if user is None:
raise NotAuthenticatedException
tasks = db.query(Task).all() tasks = db.query(Task).all()
domains_severities = defaultdict(list) domains_severities = defaultdict(list)
@ -163,6 +227,7 @@ async def get_domains_view(
"last_checks": domains_last_checks, "last_checks": domains_last_checks,
"total_task_count": len(tasks), "total_task_count": len(tasks),
"agents": agents, "agents": agents,
"user": user,
}, },
) )
@ -172,12 +237,23 @@ async def get_domain_tasks_view(
request: Request, request: Request,
domain: str, domain: str,
user: User | None = Depends(get_manager), user: User | None = Depends(get_manager),
config: Config = Depends(get_config),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Show all tasks attached to a domain""" """Show all tasks attached to a domain"""
if config.general.unauthenticated_access == "dashboard":
if user is None:
raise NotAuthenticatedException
tasks = db.query(Task).filter(Task.domain.contains(f"//{domain}")).all() tasks = db.query(Task).filter(Task.domain.contains(f"//{domain}")).all()
return templates.TemplateResponse( return templates.TemplateResponse(
"domain.html", {"request": request, "domain": domain, "tasks": tasks} "domain.html",
{
"request": request,
"domain": domain,
"tasks": tasks,
"user": user,
},
) )
@ -186,12 +262,23 @@ async def get_result_view(
request: Request, request: Request,
result_id: int, result_id: int,
user: User | None = Depends(get_manager), user: User | None = Depends(get_manager),
config: Config = Depends(get_config),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Show the details of a result""" """Show the details of a result"""
if config.general.unauthenticated_access == "dashboard":
if user is None:
raise NotAuthenticatedException
result = db.query(Result).get(result_id) result = db.query(Result).get(result_id)
return templates.TemplateResponse( return templates.TemplateResponse(
"result.html", {"request": request, "result": result, "error": Status.ERROR} "result.html",
{
"request": request,
"result": result,
"error": Status.ERROR,
"user": user,
},
) )
@ -204,6 +291,10 @@ async def get_task_results_view(
config: Config = Depends(get_config), config: Config = Depends(get_config),
): ):
"""Show history of a tasks results""" """Show history of a tasks results"""
if config.general.unauthenticated_access == "dashboard":
if user is None:
raise NotAuthenticatedException
results = ( results = (
db.query(Result) db.query(Result)
.filter(Result.task_id == task_id) .filter(Result.task_id == task_id)
@ -222,6 +313,7 @@ async def get_task_results_view(
"task": task, "task": task,
"description": description, "description": description,
"error": Status.ERROR, "error": Status.ERROR,
"user": user,
}, },
) )
@ -230,9 +322,14 @@ async def get_task_results_view(
async def get_agents_view( async def get_agents_view(
request: Request, request: Request,
user: User | None = Depends(get_manager), user: User | None = Depends(get_manager),
config: Config = Depends(get_config),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Show argos agents and the last time the server saw them""" """Show argos agents and the last time the server saw them"""
if config.general.unauthenticated_access == "dashboard":
if user is None:
raise NotAuthenticatedException
last_seen = ( last_seen = (
db.query(Result.agent_id, func.max(Result.submitted_at).label("submitted_at")) db.query(Result.agent_id, func.max(Result.submitted_at).label("submitted_at"))
.group_by(Result.agent_id) .group_by(Result.agent_id)
@ -240,7 +337,12 @@ async def get_agents_view(
) )
return templates.TemplateResponse( return templates.TemplateResponse(
"agents.html", {"request": request, "last_seen": last_seen} "agents.html",
{
"request": request,
"last_seen": last_seen,
"user": user,
},
) )

View file

@ -63,6 +63,8 @@
Agents Agents
</a> </a>
</li> </li>
{% set unauthenticated_access = request.app.state.config.general.unauthenticated_access %}
{% if (user is defined and user is not none) or unauthenticated_access == "all" %}
<li> <li>
<a href="#" <a href="#"
id="reschedule-all" id="reschedule-all"
@ -72,13 +74,24 @@
Reschedule non-ok checks Reschedule non-ok checks
</a> </a>
</li> </li>
{% endif %}
{% if user is defined and user is not none %}
<li> <li>
<a href="{{ url_for('logout_view') }}" <a href="{{ url_for('logout_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}" class="outline }}"
role="button"> role="button">
Logout Logout
</a> </a>
</li> </li>
{% elif unauthenticated_access != "all" %}
<li>
<a href="{{ url_for('login_view') }}"
class="outline }}"
role="button">
Login
</a>
</li>
{% endif %}
</ul> </ul>
</details> </details>
</li> </li>

View file

@ -12,15 +12,25 @@
</a> </a>
</li> </li>
</ul> </ul>
<ul> {# djlint:off H021 #}
<ul id="js-only" style="display: none; ">{# djlint:on #}
<li>
<input id="domain-search"
type="search"
spellcheck="false"
placeholder="Filter domains list"
aria-label="Filter domains list"
/>
</li>
<li> <li>
<label for="select-status">Show domains with status:</label> <label for="select-status">Show domains with status:</label>
<select id="select-status"> <select id="select-status">
<option value="all">All</option> <option value="not-ok" selected>Not OK</option>
<option value="ok">✅ OK</option> <option value="ok">✅ OK</option>
<option value="warning">⚠️ Warning</option> <option value="warning">⚠️ Warning</option>
<option value="critical">❌ Critical</option> <option value="critical">❌ Critical</option>
<option value="unknown">❔ Unknown</option> <option value="unknown">❔ Unknown</option>
<option value="all">All</option>
</select> </select>
</li> </li>
</ul> </ul>
@ -36,7 +46,8 @@
<tbody id="domains-body"> <tbody id="domains-body">
{% for (domain, status) in domains %} {% for (domain, status) in domains %}
<tr data-status={{ status }}> <tr data-status="{{ status }}"
data-domain="{{ domain }}">
<td> <td>
<a href="{{ url_for('get_domain_tasks_view', domain=domain) }}"> <a href="{{ url_for('get_domain_tasks_view', domain=domain) }}">
{{ domain }} {{ domain }}
@ -60,20 +71,53 @@
</table> </table>
</div> </div>
<script> <script>
document.getElementById('select-status').addEventListener('change', (e) => { function filterDomains(e) {
if (e.currentTarget.value === 'all') { let status = document.getElementById('select-status');
let filter = document.getElementById('domain-search').value;
console.log(filter)
if (status.value === 'all') {
document.querySelectorAll('[data-status]').forEach((item) => { document.querySelectorAll('[data-status]').forEach((item) => {
if (filter && item.dataset.domain.indexOf(filter) == -1) {
item.style.display = 'none';
} else {
item.style.display = null; item.style.display = null;
}
})
} else if (status.value === 'not-ok') {
document.querySelectorAll('[data-status]').forEach((item) => {
if (item.dataset.status !== 'ok') {
if (filter && item.dataset.domain.indexOf(filter) == -1) {
item.style.display = 'none';
} else {
item.style.display = null;
}
} else {
item.style.display = 'none';
}
}) })
} else { } else {
document.querySelectorAll('[data-status]').forEach((item) => { document.querySelectorAll('[data-status]').forEach((item) => {
if (item.dataset.status === e.currentTarget.value) { if (item.dataset.status === status.value) {
if (filter && item.dataset.domain.indexOf(filter) == -1) {
item.style.display = 'none';
} else {
item.style.display = null; item.style.display = null;
}
} else { } else {
item.style.display = 'none'; item.style.display = 'none';
} }
}) })
} }
}); }
document.getElementById('select-status').addEventListener('change', filterDomains);
document.getElementById('domain-search').addEventListener('input', filterDomains);
document.querySelectorAll('[data-status]').forEach((item) => {
if (item.dataset.status !== 'ok') {
item.style.display = null;
} else {
item.style.display = 'none';
}
})
document.getElementById('js-only').style.display = null;
</script> </script>
{% endblock content %} {% endblock content %}

View file

@ -16,6 +16,14 @@
name="password" name="password"
type="password" type="password"
form="login"> form="login">
{% if remember is not none %}
<label>
<input type="checkbox"
name="rememberme"
form="login">
Remember me
</label>
{% endif %}
<form id="login" <form id="login"
method="post" method="post"
action="{{ url_for('post_login') }}"> action="{{ url_for('post_login') }}">

View file

@ -276,9 +276,15 @@ Options:
### Server user management ### Server user management
To access Argos web interface, you need to create at least one user. You can choose to protect Argos web interface with a user system, in which case youll need to create at least one user.
You can manage users only through CLI. See [`unauthenticated_access` in the configuration file](configuration.md) to allow partial or total unauthenticated access to Argos.
See [`ldap` in the configuration file](configuration.md) to authenticate users against a LDAP server instead of Argos database.
You can manage Argos users only through CLI.
NB: you cant manage the LDAP users with Argos.
<!-- <!--
.. [[[cog .. [[[cog
@ -473,7 +479,7 @@ Options:
<!--[[[end]]] <!--[[[end]]]
--> -->
#### Use as a nagios probe ### Use as a nagios probe
You can directly use Argos to get an output and an exit code usable with Nagios. You can directly use Argos to get an output and an exit code usable with Nagios.
@ -497,7 +503,7 @@ Options:
<!--[[[end]]] <!--[[[end]]]
--> -->
#### Test the email settings ### Test the email settings
You can verify that your mail settings are ok by sending a test email. You can verify that your mail settings are ok by sending a test email.
@ -522,7 +528,7 @@ Options:
<!--[[[end]]] <!--[[[end]]]
--> -->
#### Test the Gotify settings ### Test the Gotify settings
You can verify that your Gotify settings are ok by sending a test notification. You can verify that your Gotify settings are ok by sending a test notification.
@ -547,7 +553,7 @@ Options:
<!--[[[end]]] <!--[[[end]]]
--> -->
#### Test the Apprise settings ### Test the Apprise settings
You can verify that your Apprise settings are ok by sending a test notification. You can verify that your Apprise settings are ok by sending a test notification.

View file

@ -10,7 +10,8 @@ First, do your changes in the code, change the model, add new tables, etc. Once
you're done, you can create a new migration. you're done, you can create a new migration.
```bash ```bash
venv/bin/alembic -c argos/server/migrations/alembic.ini revision --autogenerate -m "migration reason" venv/bin/alembic -c argos/server/migrations/alembic.ini revision \
--autogenerate -m "migration reason"
``` ```
Edit the created file to remove comments and adapt it to make sure the migration is complete (Alembic is not powerful enough to cover all the corner cases). Edit the created file to remove comments and adapt it to make sure the migration is complete (Alembic is not powerful enough to cover all the corner cases).

View file

@ -10,6 +10,14 @@ NB: if you want a quick-installation guide, we [got you covered](tl-dr.md).
- Python 3.11+ - Python 3.11+
- PostgreSQL 13+ (for production) - PostgreSQL 13+ (for production)
### Optional dependencies
If you want to use LDAP authentication, you will need to install some packages (here for a Debian-based system):
```bash
apt-get install build-essential python3-dev libldap-dev libsasl2-dev
```
## Recommendation ## Recommendation
Create a dedicated user for argos: Create a dedicated user for argos:
@ -45,6 +53,18 @@ For production, we recommend the use of [Gunicorn](https://gunicorn.org/), which
pip install "argos-monitoring[gunicorn]" pip install "argos-monitoring[gunicorn]"
``` ```
If you want to use LDAP authentication, youll need to install Argos this way:
```bash
pip install "argos-monitoring[ldap]"
```
And for an installation with Gunicorn and LDAP authentication:
```bash
pip install "argos-monitoring[gunicorn,ldap]"
```
## Install from sources ## Install from sources
Once you got the source locally, create a virtualenv and install the dependencies: Once you got the source locally, create a virtualenv and install the dependencies:

View file

@ -25,6 +25,7 @@ dependencies = [
"apprise>=1.9.0,<2", "apprise>=1.9.0,<2",
"bcrypt>=4.1.3,<5", "bcrypt>=4.1.3,<5",
"click>=8.1,<9", "click>=8.1,<9",
"durations-nlp>=1.0.1,<2",
"fastapi>=0.103,<0.104", "fastapi>=0.103,<0.104",
"fastapi-login>=1.10.0,<2", "fastapi-login>=1.10.0,<2",
"httpx>=0.27.2,<1", "httpx>=0.27.2,<1",
@ -52,7 +53,7 @@ dev = [
"ipython>=8.16,<9", "ipython>=8.16,<9",
"isort==5.11.5", "isort==5.11.5",
"mypy>=1.10.0,<2", "mypy>=1.10.0,<2",
"pylint>=3.0.2", "pylint>=3.2.5",
"pytest-asyncio>=0.21,<1", "pytest-asyncio>=0.21,<1",
"pytest>=6.2.5", "pytest>=6.2.5",
"respx>=0.20,<1", "respx>=0.20,<1",
@ -71,6 +72,9 @@ docs = [
gunicorn = [ gunicorn = [
"gunicorn>=21.2,<22", "gunicorn>=21.2,<22",
] ]
ldap = [
"python-ldap>=3.4.4,<4",
]
[project.urls] [project.urls]
homepage = "https://argos-monitoring.framasoft.org/" homepage = "https://argos-monitoring.framasoft.org/"

View file

@ -35,6 +35,7 @@ def ssl_task(now):
id=1, id=1,
url="https://example.org", url="https://example.org",
domain="https://example.org", domain="https://example.org",
method="GET",
check="ssl-certificate-expiration", check="ssl-certificate-expiration",
expected="on-check", expected="on-check",
selected_at=now, selected_at=now,
@ -51,6 +52,9 @@ async def test_ssl_check_accepts_statuts(
return_value=httpx.Response(http_status, extensions=httpx_extensions_ssl), return_value=httpx.Response(http_status, extensions=httpx_extensions_ssl),
) )
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
check = SSLCertificateExpiration(client, ssl_task) check = SSLCertificateExpiration(ssl_task)
check_response = await check.run() response = await client.request(
method=ssl_task.method, url=ssl_task.url, timeout=60
)
check_response = await check.run(response)
assert check_response.status == "on-check" assert check_response.status == "on-check"

View file

@ -1,51 +0,0 @@
import pytest
from argos.schemas.utils import string_to_duration
def test_string_to_duration_days():
assert string_to_duration("1d", target="days") == 1
assert string_to_duration("1w", target="days") == 7
assert string_to_duration("3w", target="days") == 21
assert string_to_duration("3mo", target="days") == 90
assert string_to_duration("1y", target="days") == 365
with pytest.raises(ValueError):
string_to_duration("3h", target="days")
with pytest.raises(ValueError):
string_to_duration("1", target="days")
def test_string_to_duration_hours():
assert string_to_duration("1h", target="hours") == 1
assert string_to_duration("1d", target="hours") == 24
assert string_to_duration("1w", target="hours") == 7 * 24
assert string_to_duration("3w", target="hours") == 21 * 24
assert string_to_duration("3mo", target="hours") == 3 * 30 * 24
with pytest.raises(ValueError):
string_to_duration("1", target="hours")
def test_string_to_duration_minutes():
assert string_to_duration("1m", target="minutes") == 1
assert string_to_duration("1h", target="minutes") == 60
assert string_to_duration("1d", target="minutes") == 60 * 24
assert string_to_duration("3mo", target="minutes") == 60 * 24 * 30 * 3
with pytest.raises(ValueError):
string_to_duration("1", target="minutes")
def test_conversion_to_greater_units_throws():
# hours and minutes cannot be converted to days
with pytest.raises(ValueError):
string_to_duration("1h", target="days")
with pytest.raises(ValueError):
string_to_duration("1m", target="days")
# minutes cannot be converted to hours
with pytest.raises(ValueError):
string_to_duration("1m", target="hours")