diff --git a/CHANGELOG.md b/CHANGELOG.md index 8215f07..86cf372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- ✨ — IPv4/IPv6 choice for checks, and choice for a dual-stack check (#69) + ## 0.6.1 Date: 2024-11-28 diff --git a/argos/agent.py b/argos/agent.py index d791641..4e17597 100644 --- a/argos/agent.py +++ b/argos/agent.py @@ -33,7 +33,7 @@ def log_failure(retry_state): ) -class ArgosAgent: +class ArgosAgent: # pylint: disable-msg=too-many-instance-attributes """The Argos agent is responsible for running the checks and reporting the results.""" def __init__(self, server: str, auth: str, max_tasks: int, wait_time: int): @@ -42,17 +42,30 @@ class ArgosAgent: self.wait_time = wait_time self.auth = auth self._http_client = None + self._http_client_v4 = None + self._http_client_v6 = None self.agent_id = socket.gethostname() @retry(after=log_failure, wait=wait_random(min=1, max=2)) async def run(self): - headers = { + auth_header = { "Authorization": f"Bearer {self.auth}", + } + self._http_client = httpx.AsyncClient(headers=auth_header) + + ua_header = { "User-Agent": f"Argos Panoptes {VERSION} " "(about: https://argos-monitoring.framasoft.org/)", } - self._http_client = httpx.AsyncClient(headers=headers) + self._http_client_v4 = httpx.AsyncClient( + headers=ua_header, + transport=httpx.AsyncHTTPTransport(local_address="0.0.0.0"), + ) + self._http_client_v6 = httpx.AsyncClient( + headers=ua_header, transport=httpx.AsyncHTTPTransport(local_address="::") + ) + logger.info("Running agent against %s", self.server) async with self._http_client: while "forever": @@ -70,14 +83,24 @@ class ArgosAgent: 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 - ) + if task.ip_version == "4": + response = await self._http_client_v4.request( # type: ignore[attr-defined] + method=task.method, url=url, timeout=60 + ) + else: + response = await self._http_client_v6.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 - ) + if task.ip_version == "4": + response = await self._http_client_v4.request( # type: ignore[attr-defined] + method=task.method, url=url, timeout=60 + ) + else: + response = await self._http_client_v6.request( # type: ignore[attr-defined] + method=task.method, url=url, timeout=60 + ) check_class = get_registered_check(task.check) check = check_class(task) diff --git a/argos/config-example.yaml b/argos/config-example.yaml index b20c48f..e44e9f3 100644 --- a/argos/config-example.yaml +++ b/argos/config-example.yaml @@ -64,6 +64,15 @@ general: # For ex., to re-try a check one minute after a failure: # recheck_delay: "1m" + # Defaults settings for IPv4/IPv6 + # Can be superseeded in domain configuration. + # By default, Argos will check both IPv4 and IPv6 addresses of a domain + # (i.e. by default, both `ipv4` and `ipv6` are set to true). + # To disable the IPv4 check of domains: + # ipv4: false + # To disable the IPv6 check of domains: + # ipv6: false + # Which way do you want to be warned when a check goes to that severity? # "local" emits a message in the server log # You’ll need to configure mail, gotify or apprise below to be able to use @@ -213,6 +222,8 @@ websites: - domain: "https://munin.example.org" frequency: "20m" recheck_delay: "5m" + # Let’s say it’s an IPv6 only web site + ipv4: false paths: - path: "/" checks: diff --git a/argos/schemas/config.py b/argos/schemas/config.py index 13119b0..ae0f9f2 100644 --- a/argos/schemas/config.py +++ b/argos/schemas/config.py @@ -117,6 +117,8 @@ class WebsitePath(BaseModel): class Website(BaseModel): domain: HttpUrl + ipv4: bool | None = None + ipv6: bool | None = None frequency: float | None = None recheck_delay: float | None = None paths: List[WebsitePath] @@ -204,6 +206,8 @@ class General(BaseModel): ldap: LdapSettings | None = None frequency: float recheck_delay: float | None = None + ipv4: bool = True + ipv6: bool = True root_path: str = "" alerts: Alert mail: Mail | None = None diff --git a/argos/schemas/models.py b/argos/schemas/models.py index a4a37c2..8f9daeb 100644 --- a/argos/schemas/models.py +++ b/argos/schemas/models.py @@ -8,7 +8,7 @@ from typing import Literal from pydantic import BaseModel, ConfigDict -from argos.schemas.utils import Method +from argos.schemas.utils import IPVersion, Method # XXX Refactor using SQLModel to avoid duplication of model data @@ -19,6 +19,7 @@ class Task(BaseModel): id: int url: str domain: str + ip_version: IPVersion check: str method: Method expected: str @@ -31,7 +32,8 @@ class Task(BaseModel): task_id = self.id url = self.url check = self.check - return f"Task ({task_id}): {url} - {check}" + ip_version = self.ip_version + return f"Task ({task_id}): {url} (IPv{ip_version}) - {check}" class SerializableException(BaseModel): diff --git a/argos/schemas/utils.py b/argos/schemas/utils.py index 05d716a..a160ee1 100644 --- a/argos/schemas/utils.py +++ b/argos/schemas/utils.py @@ -1,6 +1,8 @@ from typing import Literal +IPVersion = Literal["4", "6"] + Method = Literal[ "GET", "HEAD", "POST", "OPTIONS", "CONNECT", "TRACE", "PUT", "PATCH", "DELETE" ] diff --git a/argos/server/alerting.py b/argos/server/alerting.py index 4c82a9b..2511ffd 100644 --- a/argos/server/alerting.py +++ b/argos/server/alerting.py @@ -74,9 +74,9 @@ def notify_with_apprise( # pylint: disable-msg=too-many-positional-arguments apobj.add(channel) icon = get_icon_from_severity(severity) - title = f"[Argos] {icon} {urlparse(task.url).netloc}: status {severity}" + title = f"[Argos] {icon} {urlparse(task.url).netloc} (IPv{task.ip_version}): status {severity}" msg = f"""\ -URL: {task.url} +URL: {task.url} (IPv{task.ip_version}) Check: {task.check} Status: {severity} Time: {result.submitted_at} @@ -97,7 +97,7 @@ def notify_by_mail( # pylint: disable-msg=too-many-positional-arguments icon = get_icon_from_severity(severity) msg = f"""\ -URL: {task.url} +URL: {task.url} (IPv{task.ip_version}) Check: {task.check} Status: {severity} Time: {result.submitted_at} @@ -109,7 +109,9 @@ See results of task on {request.url_for('get_task_results_view', task_id=task.id """ mail = EmailMessage() - mail["Subject"] = f"[Argos] {icon} {urlparse(task.url).netloc}: status {severity}" + mail[ + "Subject" + ] = f"[Argos] {icon} {urlparse(task.url).netloc} (IPv{task.ip_version}): status {severity}" mail["From"] = config.mailfrom mail.set_content(msg) @@ -152,9 +154,11 @@ def notify_with_gotify( # pylint: disable-msg=too-many-positional-arguments elif severity == Severity.UNKNOWN: priority = 5 - subject = f"{icon} {urlparse(task.url).netloc}: status {severity}" + subject = ( + f"{icon} {urlparse(task.url).netloc} (IPv{task.ip_version}): status {severity}" + ) msg = f"""\ -URL: <{task.url}>\\ +URL: <{task.url}> (IPv{task.ip_version})\\ Check: {task.check}\\ Status: {severity}\\ Time: {result.submitted_at}\\ diff --git a/argos/server/migrations/versions/64f73a79b7d8_add_ip_version_to_checks.py b/argos/server/migrations/versions/64f73a79b7d8_add_ip_version_to_checks.py new file mode 100644 index 0000000..6eeddb8 --- /dev/null +++ b/argos/server/migrations/versions/64f73a79b7d8_add_ip_version_to_checks.py @@ -0,0 +1,32 @@ +"""Add IP version to checks + +Revision ID: 64f73a79b7d8 +Revises: a1e98cf72a5c +Create Date: 2024-12-02 14:12:40.558033 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "64f73a79b7d8" +down_revision: Union[str, None] = "a1e98cf72a5c" +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( + "ip_version", sa.Enum("4", "6"), server_default="4", nullable=False + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("tasks", schema=None) as batch_op: + batch_op.drop_column("ip_version") diff --git a/argos/server/models.py b/argos/server/models.py index 33b05b9..e4503e4 100644 --- a/argos/server/models.py +++ b/argos/server/models.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from argos.checks import BaseCheck, get_registered_check from argos.schemas import WebsiteCheck -from argos.schemas.utils import Method +from argos.schemas.utils import IPVersion, Method class Base(DeclarativeBase): @@ -33,6 +33,9 @@ class Task(Base): # Info needed to run the task url: Mapped[str] = mapped_column() domain: Mapped[str] = mapped_column() + ip_version: Mapped[IPVersion] = mapped_column( + Enum("4", "6"), + ) check: Mapped[str] = mapped_column() expected: Mapped[str] = mapped_column() frequency: Mapped[float] = mapped_column() @@ -73,7 +76,7 @@ class Task(Base): ) def __str__(self): - return f"DB Task {self.url} - {self.check} - {self.expected}" + return f"DB Task {self.url} (IPv{self.ip_version}) - {self.check} - {self.expected}" def get_check(self) -> BaseCheck: """Returns a check instance for this specific task""" diff --git a/argos/server/queries.py b/argos/server/queries.py index 94fc0f4..60deaa8 100644 --- a/argos/server/queries.py +++ b/argos/server/queries.py @@ -133,7 +133,7 @@ async def has_config_changed(db: Session, config: schemas.Config) -> bool: return True -async def update_from_config(db: Session, config: schemas.Config): +async def update_from_config(db: Session, config: schemas.Config): # pylint: disable-msg=too-many-branches """Update tasks from config file""" config_changed = await has_config_changed(db, config) if not config_changed: @@ -145,61 +145,76 @@ async def update_from_config(db: Session, config: schemas.Config): tasks = [] unique_properties = [] seen_tasks: List[int] = [] - for website in config.websites: + for website in config.websites: # pylint: disable-msg=too-many-nested-blocks domain = str(website.domain) frequency = website.frequency or config.general.frequency recheck_delay = website.recheck_delay or config.general.recheck_delay + ipv4 = website.ipv4 if website.ipv4 is not None else config.general.ipv4 + ipv6 = website.ipv6 if website.ipv6 is not None else config.general.ipv6 + if ipv4 is False and ipv6 is False: + logger.warning("IPv4 AND IPv6 are disabled on website %s!", domain) + continue - for p in website.paths: - url = urljoin(domain, str(p.path)) - for check_key, expected in p.checks: - # Check the db for already existing tasks. - existing_tasks = ( - db.query(Task) - .filter( - Task.url == url, - Task.method == p.method, - Task.check == check_key, - Task.expected == expected, - ) - .all() - ) - if existing_tasks: - existing_task = existing_tasks[0] - seen_tasks.append(existing_task.id) + for ip_version in ["4", "6"]: + if ip_version == "4" and ipv4 is False: + continue - if frequency != existing_task.frequency: - existing_task.frequency = frequency - if recheck_delay != existing_task.recheck_delay: - existing_task.recheck_delay = recheck_delay # type: ignore[assignment] - logger.debug( - "Skipping db task creation for url=%s, " - "method=%s, check_key=%s, expected=%s, " - "frequency=%s, recheck_delay=%s.", - url, - p.method, - check_key, - expected, - frequency, - recheck_delay, - ) + if ip_version == "6" and ipv6 is False: + continue - else: - properties = (url, check_key, expected) - if properties not in unique_properties: - unique_properties.append(properties) - task = Task( - domain=domain, - url=url, - method=p.method, - check=check_key, - expected=expected, - frequency=frequency, - recheck_delay=recheck_delay, - already_retried=False, + for p in website.paths: + url = urljoin(domain, str(p.path)) + for check_key, expected in p.checks: + # Check the db for already existing tasks. + existing_tasks = ( + db.query(Task) + .filter( + Task.url == url, + Task.method == p.method, + Task.check == check_key, + Task.expected == expected, + Task.ip_version == ip_version, ) - logger.debug("Adding a new task in the db: %s", task) - tasks.append(task) + .all() + ) + if existing_tasks: + existing_task = existing_tasks[0] + seen_tasks.append(existing_task.id) + + if frequency != existing_task.frequency: + existing_task.frequency = frequency + if recheck_delay != existing_task.recheck_delay: + existing_task.recheck_delay = recheck_delay # type: ignore[assignment] + logger.debug( + "Skipping db task creation for url=%s, " + "method=%s, check_key=%s, expected=%s, " + "frequency=%s, recheck_delay=%s, ip_version=%s.", + url, + p.method, + check_key, + expected, + frequency, + recheck_delay, + ip_version, + ) + + else: + properties = (url, p.method, check_key, expected, ip_version) + if properties not in unique_properties: + unique_properties.append(properties) + task = Task( + domain=domain, + url=url, + ip_version=ip_version, + method=p.method, + check=check_key, + expected=expected, + frequency=frequency, + recheck_delay=recheck_delay, + already_retried=False, + ) + logger.debug("Adding a new task in the db: %s", task) + tasks.append(task) db.add_all(tasks) db.commit() diff --git a/argos/server/templates/domain.html b/argos/server/templates/domain.html index fae614f..57d0700 100644 --- a/argos/server/templates/domain.html +++ b/argos/server/templates/domain.html @@ -16,7 +16,7 @@
{% for task in tasks %}