mirror of
https://framagit.org/framasoft/framaspace/argos.git
synced 2025-04-28 09:52:38 +02:00
✨ — IPv4/IPv6 choice for checks, and choice for a dual-stack check (fix #69)
This commit is contained in:
parent
a1600cb08e
commit
ea23ea7c1f
14 changed files with 182 additions and 75 deletions
|
@ -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
|
||||
|
|
|
@ -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,12 +83,22 @@ class ArgosAgent:
|
|||
url = str(httpx.URL(task.url).copy_with(scheme="http"))
|
||||
|
||||
try:
|
||||
response = await self._http_client.request( # type: ignore[attr-defined]
|
||||
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]
|
||||
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
|
||||
)
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
from typing import Literal
|
||||
|
||||
|
||||
IPVersion = Literal["4", "6"]
|
||||
|
||||
Method = Literal[
|
||||
"GET", "HEAD", "POST", "OPTIONS", "CONNECT", "TRACE", "PUT", "PATCH", "DELETE"
|
||||
]
|
||||
|
|
|
@ -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}\\
|
||||
|
|
|
@ -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")
|
|
@ -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"""
|
||||
|
|
|
@ -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,10 +145,22 @@ 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 ip_version in ["4", "6"]:
|
||||
if ip_version == "4" and ipv4 is False:
|
||||
continue
|
||||
|
||||
if ip_version == "6" and ipv6 is False:
|
||||
continue
|
||||
|
||||
for p in website.paths:
|
||||
url = urljoin(domain, str(p.path))
|
||||
|
@ -161,6 +173,7 @@ async def update_from_config(db: Session, config: schemas.Config):
|
|||
Task.method == p.method,
|
||||
Task.check == check_key,
|
||||
Task.expected == expected,
|
||||
Task.ip_version == ip_version,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
@ -175,22 +188,24 @@ async def update_from_config(db: Session, config: schemas.Config):
|
|||
logger.debug(
|
||||
"Skipping db task creation for url=%s, "
|
||||
"method=%s, check_key=%s, expected=%s, "
|
||||
"frequency=%s, recheck_delay=%s.",
|
||||
"frequency=%s, recheck_delay=%s, ip_version=%s.",
|
||||
url,
|
||||
p.method,
|
||||
check_key,
|
||||
expected,
|
||||
frequency,
|
||||
recheck_delay,
|
||||
ip_version,
|
||||
)
|
||||
|
||||
else:
|
||||
properties = (url, check_key, expected)
|
||||
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,
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
<tbody id="domains-body">
|
||||
{% for task in tasks %}
|
||||
<tr scope="row">
|
||||
<td>{{ task.url }}</td>
|
||||
<td>{{ task.url }} (IPv{{ task.ip_version }})</td>
|
||||
<td>{{ task.check }}</td>
|
||||
<td class="status highlight">
|
||||
{% if task.status %}
|
||||
|
|
|
@ -21,7 +21,7 @@ def test_tasks_retrieval_and_results(authorized_client, app):
|
|||
assert response.status_code == 200
|
||||
|
||||
tasks = response.json()
|
||||
assert len(tasks) == 2
|
||||
assert len(tasks) == 4
|
||||
|
||||
results = []
|
||||
for task in tasks:
|
||||
|
@ -33,7 +33,7 @@ def test_tasks_retrieval_and_results(authorized_client, app):
|
|||
response = client.post("/api/results", json=data)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert app.state.db.query(models.Result).count() == 2
|
||||
assert app.state.db.query(models.Result).count() == 4
|
||||
|
||||
# The list of tasks should be empty now
|
||||
response = client.get("/api/tasks")
|
||||
|
@ -60,6 +60,7 @@ def ssl_task(db):
|
|||
task = models.Task(
|
||||
url="https://exemple.com/",
|
||||
domain="https://exemple.com/",
|
||||
ip_version="6",
|
||||
method="GET",
|
||||
check="ssl-certificate-expiration",
|
||||
expected="on-check",
|
||||
|
|
|
@ -35,6 +35,7 @@ def ssl_task(now):
|
|||
id=1,
|
||||
url="https://example.org",
|
||||
domain="https://example.org",
|
||||
ip_version="6",
|
||||
method="GET",
|
||||
check="ssl-certificate-expiration",
|
||||
expected="on-check",
|
||||
|
|
|
@ -70,7 +70,7 @@ async def test_update_from_config_with_duplicate_tasks(db, empty_config): # py
|
|||
await queries.update_from_config(db, empty_config)
|
||||
|
||||
# Only one path has been saved in the database
|
||||
assert db.query(Task).count() == 1
|
||||
assert db.query(Task).count() == 2
|
||||
|
||||
# Calling again with the same data works, and will not result in more tasks being
|
||||
# created.
|
||||
|
@ -87,6 +87,7 @@ async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
|
|||
same_task = Task(
|
||||
url=task.url,
|
||||
domain=task.domain,
|
||||
ip_version="6",
|
||||
check=task.check,
|
||||
expected=task.expected,
|
||||
frequency=task.frequency,
|
||||
|
@ -108,7 +109,7 @@ async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
|
|||
empty_config.websites = [website]
|
||||
|
||||
await queries.update_from_config(db, empty_config)
|
||||
assert db.query(Task).count() == 2
|
||||
assert db.query(Task).count() == 4
|
||||
|
||||
website = schemas.config.Website(
|
||||
domain=task.domain,
|
||||
|
@ -122,7 +123,7 @@ async def test_update_from_config_db_can_remove_duplicates_and_old_tasks(
|
|||
empty_config.websites = [website]
|
||||
|
||||
await queries.update_from_config(db, empty_config)
|
||||
assert db.query(Task).count() == 1
|
||||
assert db.query(Task).count() == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
@ -136,7 +137,7 @@ async def test_update_from_config_db_updates_existing_tasks(db, empty_config, ta
|
|||
empty_config.websites = [website]
|
||||
|
||||
await queries.update_from_config(db, empty_config)
|
||||
assert db.query(Task).count() == 1
|
||||
assert db.query(Task).count() == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
@ -212,6 +213,7 @@ def task(db):
|
|||
_task = Task(
|
||||
url="https://www.example.com",
|
||||
domain="https://www.example.com",
|
||||
ip_version="6",
|
||||
check="body-contains",
|
||||
expected="foo",
|
||||
frequency=1,
|
||||
|
@ -271,6 +273,7 @@ def ten_locked_tasks(db):
|
|||
_task = Task(
|
||||
url="https://www.example.com",
|
||||
domain="example.com",
|
||||
ip_version="6",
|
||||
check="body-contains",
|
||||
expected="foo",
|
||||
frequency=1,
|
||||
|
@ -291,6 +294,7 @@ def ten_tasks(db):
|
|||
_task = Task(
|
||||
url="https://www.example.com",
|
||||
domain="example.com",
|
||||
ip_version="6",
|
||||
check="body-contains",
|
||||
expected="foo",
|
||||
frequency=1,
|
||||
|
@ -311,6 +315,7 @@ def ten_warning_tasks(db):
|
|||
_task = Task(
|
||||
url="https://www.example.com",
|
||||
domain="example.com",
|
||||
ip_version="6",
|
||||
check="body-contains",
|
||||
expected="foo",
|
||||
frequency=1,
|
||||
|
@ -331,6 +336,7 @@ def ten_critical_tasks(db):
|
|||
_task = Task(
|
||||
url="https://www.example.com",
|
||||
domain="example.com",
|
||||
ip_version="6",
|
||||
check="body-contains",
|
||||
expected="foo",
|
||||
frequency=1,
|
||||
|
@ -351,6 +357,7 @@ def ten_ok_tasks(db):
|
|||
_task = Task(
|
||||
url="https://www.example.com",
|
||||
domain="example.com",
|
||||
ip_version="6",
|
||||
check="body-contains",
|
||||
expected="foo",
|
||||
frequency=1,
|
||||
|
|
Loading…
Reference in a new issue