Skip to content

Commit 4da8d3e

Browse files
Merge branch 'master' into issues/741-add-docker-test-container
2 parents 6b8edbe + 512c659 commit 4da8d3e

16 files changed

Lines changed: 724 additions & 37 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ jobs:
3232
django-version: django~=4.2.0
3333

3434
steps:
35-
- uses: actions/checkout@v6
35+
- uses: actions/checkout@v7
3636
with:
3737
ref: ${{ github.event.pull_request.head.sha }}
3838

3939
- name: Cache APT packages
40-
uses: actions/cache@v5
40+
uses: actions/cache@v6
4141
with:
4242
path: /var/cache/apt/archives
4343
key: apt-${{ runner.os }}-${{ hashFiles('.github/workflows/ci.yml') }}

.github/workflows/pypi.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
permissions:
1818
id-token: write
1919
steps:
20-
- uses: actions/checkout@v6
20+
- uses: actions/checkout@v7
2121
- name: Set up Python
2222
uses: actions/setup-python@v6
2323
with:

docs/developer/utils.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,33 @@ completes successfully, just before the response is returned.
2727
The ``view`` argument can also be used to access the ``request`` object
2828
i.e. ``view.request``.
2929

30+
``radius_accounting_closed``
31+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
32+
33+
**Path**: ``openwisp_radius.signals.radius_accounting_closed``
34+
35+
**Arguments**:
36+
37+
- ``sender``: ``RadiusAccounting`` model class
38+
- ``instance``: closed ``RadiusAccounting`` instance
39+
40+
This signal is emitted when a ``RadiusAccounting`` session is closed. For
41+
regular ``save()`` paths, it is emitted only when ``stop_time`` changes
42+
from ``None`` to any value, or when a closed session is created directly.
43+
Editing an already closed session does not emit this signal again.
44+
45+
The signal is emitted after the database transaction is committed.
46+
47+
Integrations which need to react to closed accounting sessions should
48+
listen to this signal. For example, the monitoring integration uses this
49+
signal to write RADIUS traffic snapshots for sessions closed by regular
50+
``post_save`` paths, ``Accounting-On`` packets and automatic stale-session
51+
cleanup.
52+
53+
When closing multiple sessions with ``bulk_update()``, use
54+
``RadiusAccounting.emit_radius_accounting_closed()`` after the database
55+
update to emit this signal once for each closed session.
56+
3057
.. _radius_captive_portal_mock_views:
3158

3259
Captive portal mock views

openwisp_radius/base/models.py

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import string
66
from datetime import timedelta
77
from io import StringIO
8+
from typing import Iterable
89

910
import django
1011
import phonenumbers
@@ -52,6 +53,7 @@
5253
BATCH_MAIL_SUBJECT,
5354
DEFAULT_PASSWORD_RESET_URL,
5455
)
56+
from ..signals import radius_accounting_closed
5557
from ..utils import (
5658
SmsMessage,
5759
decode_byte_data,
@@ -529,20 +531,66 @@ class AbstractRadiusAccounting(OrgMixin, models.Model):
529531
blank=True,
530532
)
531533

532-
def save(self, *args, **kwargs):
533-
if not self.start_time:
534-
self.start_time = now()
535-
super(AbstractRadiusAccounting, self).save(*args, **kwargs)
536-
537534
class Meta:
538535
db_table = "radacct"
539536
verbose_name = _("accounting")
540537
verbose_name_plural = _("accountings")
541538
abstract = True
542539

540+
def __init__(self, *args, **kwargs):
541+
super().__init__(*args, **kwargs)
542+
# used for radius_accounting_closed signal
543+
self._set_initial_stop_time()
544+
545+
def refresh_from_db(self, *args, **kwargs):
546+
super().refresh_from_db(*args, **kwargs)
547+
fields = kwargs.get("fields")
548+
self._set_initial_stop_time(fields=fields)
549+
550+
def save(self, *args, **kwargs):
551+
created = self._state.adding
552+
update_fields = kwargs.get("update_fields")
553+
if not self.start_time:
554+
self.start_time = now()
555+
if update_fields is not None:
556+
update_fields = set(update_fields) | {"start_time"}
557+
kwargs["update_fields"] = update_fields
558+
super(AbstractRadiusAccounting, self).save(*args, **kwargs)
559+
self._emit_radius_accounting_closed(
560+
created=created, update_fields=update_fields
561+
)
562+
# reset after save
563+
self._set_initial_stop_time(update_fields)
564+
565+
def _set_initial_stop_time(self, fields=None):
566+
if fields is None or "stop_time" in fields:
567+
self._initial_stop_time = self.stop_time
568+
569+
def _emit_radius_accounting_closed(self, created, update_fields=None):
570+
"""Detect whether this save closed the session and emit the signal."""
571+
if update_fields is not None and "stop_time" not in update_fields:
572+
return
573+
being_closed = self.stop_time is not None and (
574+
created or self._initial_stop_time is None
575+
)
576+
if being_closed:
577+
self.emit_radius_accounting_closed([self])
578+
543579
def __str__(self):
544580
return self.unique_id
545581

582+
@classmethod
583+
def emit_radius_accounting_closed(
584+
cls, sessions: Iterable["AbstractRadiusAccounting"]
585+
) -> None:
586+
"""Emit radius_accounting_closed after commit for closed sessions."""
587+
for session in sessions:
588+
transaction.on_commit(
589+
lambda session=session: radius_accounting_closed.send(
590+
sender=session.__class__, instance=session
591+
)
592+
)
593+
546594
@classmethod
547595
def close_stale_sessions(cls, days=None, hours=None):
548596
if hours:
@@ -579,13 +627,39 @@ def _close_stale_sessions_on_nas_boot(cls, called_station_id):
579627
"""
580628
if not called_station_id:
581629
return 0
582-
stale_sessions = cls.objects.filter(
583-
called_station_id=called_station_id,
584-
stop_time__isnull=True,
585-
)
586-
closed_count = stale_sessions.update(
587-
stop_time=now(), terminate_cause="NAS-Reboot"
588-
)
630+
stop_time = now()
631+
closed_count = 0
632+
batch_size = 1000
633+
has_more_sessions = True
634+
while has_more_sessions:
635+
with transaction.atomic():
636+
closed_sessions = list(
637+
cls.objects.select_for_update()
638+
.filter(
639+
called_station_id=called_station_id,
640+
stop_time__isnull=True,
641+
)
642+
.only(
643+
"unique_id",
644+
"username",
645+
"organization_id",
646+
"input_octets",
647+
"output_octets",
648+
"calling_station_id",
649+
"called_station_id",
650+
"stop_time",
651+
)[:batch_size]
652+
)
653+
has_more_sessions = len(closed_sessions) == batch_size
654+
if not closed_sessions:
655+
continue
656+
for session in closed_sessions:
657+
session.stop_time = stop_time
658+
session.terminate_cause = "NAS-Reboot"
659+
closed_count += cls.objects.bulk_update(
660+
closed_sessions, fields=["stop_time", "terminate_cause"]
661+
)
662+
cls.emit_radius_accounting_closed(closed_sessions)
589663
return closed_count
590664

591665

openwisp_radius/integrations/monitoring/apps.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
from django.db import models
55
from django.db.models import Count, Sum
66
from django.db.models.functions import Cast, Round
7-
from django.db.models.signals import post_save
87
from django.utils.translation import gettext_lazy as _
98
from openwisp_monitoring.monitoring.configuration import (
109
_register_chart_configuration_choice,
1110
register_metric,
1211
)
1312
from swapper import load_model
1413

14+
from openwisp_radius.signals import radius_accounting_closed
1515
from openwisp_utils.admin_theme import register_dashboard_chart
1616

1717
from .utils import (
@@ -121,12 +121,12 @@ def register_radius_metrics(self):
121121
_register_chart_configuration_choice(chart_key, chart_config)
122122

123123
def connect_signal_receivers(self):
124-
from .receivers import post_save_radiusaccounting
124+
from .receivers import radius_accounting_closed_handler
125125

126126
RadiusAccounting = load_model("openwisp_radius", "RadiusAccounting")
127127

128-
post_save.connect(
129-
post_save_radiusaccounting,
128+
radius_accounting_closed.connect(
129+
radius_accounting_closed_handler,
130130
sender=RadiusAccounting,
131-
dispatch_uid="post_save_radiusaccounting_radius_acc_metric",
131+
dispatch_uid="radius_accounting_closed_radius_acc_metric",
132132
)

openwisp_radius/integrations/monitoring/management/__init__.py

Whitespace-only changes.

openwisp_radius/integrations/monitoring/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
from django.core.management.base import BaseCommand, CommandError
2+
from django.utils import timezone
3+
from django.utils.dateparse import parse_datetime
4+
from openwisp_monitoring.db import timeseries_db
5+
from swapper import load_model
6+
7+
from openwisp_radius.integrations.monitoring import tasks
8+
from openwisp_radius.integrations.monitoring.utils import sha1_hash
9+
10+
RadiusAccounting = load_model("openwisp_radius", "RadiusAccounting")
11+
12+
13+
class Command(BaseCommand):
14+
"""
15+
Rebuild RADIUS accounting metrics missed by the NAS-Reboot bulk update path.
16+
17+
See https://github.com/openwisp/openwisp-radius/issues/734.
18+
TODO: remove in version 1.4.
19+
"""
20+
21+
help = "Private command to rebuild monitoring metrics for closed RADIUS sessions."
22+
23+
def add_arguments(self, parser):
24+
parser.add_argument(
25+
"--commit",
26+
action="store_true",
27+
help="Write metrics. Without this flag the command only reports the count.",
28+
)
29+
parser.add_argument(
30+
"--start",
31+
help=(
32+
"Only process sessions with stop_time greater than or equal "
33+
"to this date."
34+
),
35+
)
36+
parser.add_argument(
37+
"--end",
38+
help=(
39+
"Only process sessions with stop_time lower than or equal "
40+
"to this date."
41+
),
42+
)
43+
parser.add_argument(
44+
"--chunk-size",
45+
type=int,
46+
default=1000,
47+
help="Number of sessions fetched per database batch.",
48+
)
49+
50+
def handle(self, *args, **options):
51+
queryset = RadiusAccounting.objects.filter(
52+
stop_time__isnull=False,
53+
terminate_cause="NAS-Reboot",
54+
)
55+
if options["start"]:
56+
queryset = queryset.filter(
57+
stop_time__gte=self._parse_datetime(options["start"], "start")
58+
)
59+
if options["end"]:
60+
queryset = queryset.filter(
61+
stop_time__lte=self._parse_datetime(options["end"], "end")
62+
)
63+
queryset = queryset.order_by("stop_time", "unique_id").only(
64+
"unique_id",
65+
"username",
66+
"organization_id",
67+
"input_octets",
68+
"output_octets",
69+
"calling_station_id",
70+
"called_station_id",
71+
"stop_time",
72+
"terminate_cause",
73+
)
74+
count = queryset.count()
75+
if not options["commit"]:
76+
self.stdout.write(f"Dry run: {count} closed sessions would be processed.")
77+
return
78+
processed = 0
79+
chunk_size = options["chunk_size"]
80+
self.stdout.write(f"Starting to rebuild {count} accounting metrics.")
81+
for session in queryset.iterator(chunk_size=chunk_size):
82+
self._delete_radius_accounting_metric(session)
83+
tasks.post_save_radiusaccounting(
84+
username=session.username,
85+
organization_id=str(session.organization_id),
86+
input_octets=session.input_octets,
87+
output_octets=session.output_octets,
88+
calling_station_id=session.calling_station_id,
89+
called_station_id=session.called_station_id,
90+
time=session.stop_time,
91+
)
92+
processed += 1
93+
if processed % chunk_size == 0 or processed == count:
94+
self.stdout.write(
95+
f"Processed {processed} of {count} accounting metrics."
96+
)
97+
98+
def _parse_datetime(self, value, option):
99+
parsed = parse_datetime(value)
100+
if parsed is None:
101+
raise CommandError(f"Invalid --{option} datetime: {value}")
102+
if timezone.is_naive(parsed):
103+
parsed = timezone.make_aware(parsed)
104+
return parsed
105+
106+
def _delete_radius_accounting_metric(self, session):
107+
tags = {
108+
"organization_id": str(session.organization_id),
109+
"calling_station_id": sha1_hash(session.calling_station_id),
110+
"called_station_id": session.called_station_id,
111+
}
112+
where = " AND ".join(
113+
f"\"{key}\" = '{self._escape_tag_value(value)}'"
114+
for key, value in tags.items()
115+
)
116+
timeseries_db.query(
117+
"DELETE FROM radius_acc "
118+
f"WHERE time = '{session.stop_time.isoformat()}' AND {where}"
119+
)
120+
121+
def _escape_tag_value(self, value):
122+
return str(value).replace("'", r"\'")

0 commit comments

Comments
 (0)