Compare commits

...

9 commits

Author SHA1 Message Date
Luc Didry
a1600cb08e
🏷 — Bump version (0.6.1) 2024-11-28 16:59:58 +01:00
Luc Didry
0da1f4986e
🔀 Merge remote-tracking branch 'origin/develop' 2024-11-28 16:59:10 +01:00
Luc Didry
1853b4fead
💚 — Fix tests in CI 2024-11-28 16:51:28 +01:00
Luc Didry
bb4db3ca84
🐛 - Fix domain status selector’s bug on page refresh 2024-11-28 16:16:53 +01:00
Luc Didry
7d21d8d271
🐛 - Fix database migrations without default values 2024-11-28 16:13:30 +01:00
Luc Didry
868e91b866
🔨 — Update hatch 2024-11-28 15:51:32 +01:00
Luc Didry
ffd24173e5
🏷 — Bump version (0.6.0) 2024-11-28 15:42:39 +01:00
Luc Didry
594fbd6881
🔀 Merge remote-tracking branch 'origin/develop' 2024-11-28 15:41:37 +01:00
Luc Didry
04e33a8d24
🛂 — Allow to use a LDAP server for authentication (fix #64) 2024-11-28 15:37:07 +01:00
18 changed files with 155 additions and 31 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
@ -64,7 +68,7 @@ release_job:
- if: $CI_COMMIT_TAG - if: $CI_COMMIT_TAG
script: script:
- sed -n '/^## '$CI_COMMIT_TAG'/,/^#/p' CHANGELOG.md | sed -e '/^\(#\|$\|Date\)/d' > release.md - sed -n '/^## '$CI_COMMIT_TAG'/,/^#/p' CHANGELOG.md | sed -e '/^\(#\|$\|Date\)/d' > release.md
release: # See https://docs.gitlab.com/ee/ci/yaml/#release for available properties release: # See https://docs.gitlab.com/ee/ci/yaml/#release for available properties
tag_name: '$CI_COMMIT_TAG' tag_name: '$CI_COMMIT_TAG'
description: './release.md' description: './release.md'
assets: assets:

View file

@ -2,6 +2,17 @@
## [Unreleased] ## [Unreleased]
## 0.6.1
Date: 2024-11-28
- 🐛 - Fix database migrations without default values
- 🐛 - Fix domain status selectors bug on page refresh
## 0.6.0
Date: 2024-11-28
- 💄 — Show only not-OK domains by default in domains list, to reduce the load on browser - 💄 — 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 - ♿️ — Fix not-OK domains display if javascript is disabled
- ✨ — Retry check right after a httpx.ReadError - ✨ — Retry check right after a httpx.ReadError
@ -14,6 +25,7 @@
You need to use `M`, `month` or `months` You need to use `M`, `month` or `months`
- ✨ - Allow to choose a frequency smaller than a minute - ✨ - Allow to choose a frequency smaller than a minute
- ✨🛂 — Allow partial or total anonymous access to web interface (#63) - ✨🛂 — 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

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

@ -1 +1 @@
VERSION = "0.5.0" VERSION = "0.6.1"

View file

@ -36,6 +36,23 @@ general:
# If not present, all pages needs authentication # If not present, all pages needs authentication
# unauthenticated_access: "all" # 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 5 minutes: # For ex., to run checks every 5 minutes:

View file

@ -183,6 +183,15 @@ 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"""
@ -192,6 +201,7 @@ class General(BaseModel):
session_duration: int = 10080 # 7 days session_duration: int = 10080 # 7 days
remember_me_duration: int | None = None remember_me_duration: int | None = None
unauthenticated_access: Unauthenticated | None = None unauthenticated_access: Unauthenticated | None = None
ldap: LdapSettings | None = None
frequency: float frequency: float
recheck_delay: float | None = None recheck_delay: float | None = None
root_path: str = "" root_path: str = ""

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")

View file

@ -21,7 +21,14 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
with op.batch_alter_table("tasks", schema=None) as batch_op: 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("recheck_delay", sa.Float(), nullable=True))
batch_op.add_column(sa.Column("already_retried", sa.Boolean(), nullable=False)) batch_op.add_column(
sa.Column(
"already_retried",
sa.Boolean(),
nullable=False,
server_default=sa.sql.false(),
)
)
def downgrade() -> None: def downgrade() -> None:

View file

@ -36,6 +36,7 @@ def upgrade() -> None:
name="method", name="method",
), ),
nullable=False, nullable=False,
server_default="GET",
) )
) )

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

@ -31,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

@ -80,20 +80,34 @@ async def post_login(
) )
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 user is None:
return invalid_credentials
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") if config.general.ldap is not None:
if not pwd_context.verify(data.password, user.password): from ldap import INVALID_CREDENTIALS # pylint: disable-msg=no-name-in-module
return invalid_credentials from argos.server.routes.dependencies import find_ldap_user
user.last_login_at = datetime.now() ldap_dn = await find_ldap_user(config, request.app.state.ldap, username)
db.commit() 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 manager = request.app.state.manager
session_duration = config.general.session_duration session_duration = config.general.session_duration

View file

@ -71,7 +71,7 @@
</table> </table>
</div> </div>
<script> <script>
function filterDomains(e) { function filterDomains() {
let status = document.getElementById('select-status'); let status = document.getElementById('select-status');
let filter = document.getElementById('domain-search').value; let filter = document.getElementById('domain-search').value;
console.log(filter) console.log(filter)
@ -111,13 +111,7 @@
} }
document.getElementById('select-status').addEventListener('change', filterDomains); document.getElementById('select-status').addEventListener('change', filterDomains);
document.getElementById('domain-search').addEventListener('input', filterDomains); document.getElementById('domain-search').addEventListener('input', filterDomains);
document.querySelectorAll('[data-status]').forEach((item) => { filterDomains()
if (item.dataset.status !== 'ok') {
item.style.display = null;
} else {
item.style.display = 'none';
}
})
document.getElementById('js-only').style.display = null; document.getElementById('js-only').style.display = null;
</script> </script>
{% endblock content %} {% endblock content %}

View file

@ -280,7 +280,11 @@ You can choose to protect Argos web interface with a user system, in which ca
See [`unauthenticated_access` in the configuration file](configuration.md) to allow partial or total unauthenticated access to Argos. See [`unauthenticated_access` in the configuration file](configuration.md) to allow partial or total unauthenticated access to Argos.
You can manage users only through CLI. 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

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

@ -28,7 +28,7 @@ dependencies = [
"durations-nlp>=1.0.1,<2", "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,<0.28.0",
"Jinja2>=3.0,<4", "Jinja2>=3.0,<4",
"jsonpointer>=3.0,<4", "jsonpointer>=3.0,<4",
"passlib>=1.7.4,<2", "passlib>=1.7.4,<2",
@ -48,12 +48,12 @@ dependencies = [
dev = [ dev = [
"black==23.3.0", "black==23.3.0",
"djlint>=1.34.0", "djlint>=1.34.0",
"hatch==1.9.4", "hatch==1.13.0",
"ipdb>=0.13,<0.14", "ipdb>=0.13,<0.14",
"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",
@ -72,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

@ -60,6 +60,7 @@ def ssl_task(db):
task = models.Task( task = models.Task(
url="https://exemple.com/", url="https://exemple.com/",
domain="https://exemple.com/", domain="https://exemple.com/",
method="GET",
check="ssl-certificate-expiration", check="ssl-certificate-expiration",
expected="on-check", expected="on-check",
frequency=1, frequency=1,