Skip to content

Commit 3058ae3

Browse files
committed
[fix] Log metrics for bulk-closed RADIUS sessions #734
The RADIUS monitoring integration previously relied only on Django's ``post_save`` signal to write traffic snapshots. However, sessions closed via ``QuerySet.update()`` (``Accounting-On`` handling) and ``bulk_update()`` (``close_previous_radius_accounting_sessions``) bypassed this signal, causing traffic to be missing from RADIUS traffic charts. Sessions closed via ``QuerySet.update()`` and ``bulk_update()`` now emit the ``radius_accounting_closed`` signal, ensuring that traffic metrics are correctly snapshotted by the RADIUS monitoring integration. Closes #734
1 parent b315e84 commit 3058ae3

9 files changed

Lines changed: 469 additions & 8 deletions

File tree

docs/developer/utils.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ Editing an already closed session does not emit this signal again.
4545
The signal is emitted after the database transaction is committed.
4646

4747
Integrations which need to react to closed accounting sessions should
48-
listen to this signal.
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.
4952

5053
When closing multiple sessions with ``bulk_update()``, use
5154
``RadiusAccounting.emit_radius_accounting_closed()`` after the database

openwisp_radius/base/models.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -627,13 +627,39 @@ def _close_stale_sessions_on_nas_boot(cls, called_station_id):
627627
"""
628628
if not called_station_id:
629629
return 0
630-
stale_sessions = cls.objects.filter(
631-
called_station_id=called_station_id,
632-
stop_time__isnull=True,
633-
)
634-
closed_count = stale_sessions.update(
635-
stop_time=now(), terminate_cause="NAS-Reboot"
636-
)
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)
637663
return closed_count
638664

639665

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"\'")
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from io import StringIO
2+
from unittest.mock import patch
3+
4+
from django.contrib.contenttypes.models import ContentType
5+
from django.core.management import call_command
6+
from django.test import tag
7+
from django.utils import timezone
8+
from swapper import load_model
9+
10+
from openwisp_radius.integrations.monitoring.tests.mixins import (
11+
CreateDeviceMonitoringMixin,
12+
)
13+
from openwisp_radius.integrations.monitoring.utils import sha1_hash
14+
from openwisp_radius.tests import _RADACCT
15+
from openwisp_radius.tests.mixins import BaseTransactionTestCase
16+
17+
RegisteredUser = load_model("openwisp_radius", "RegisteredUser")
18+
RadiusAccounting = load_model("openwisp_radius", "RadiusAccounting")
19+
20+
21+
@tag("radius_monitoring", "rebuild_radius_accounting_metrics")
22+
class TestRebuildRadiusAccountingMetrics(
23+
CreateDeviceMonitoringMixin, BaseTransactionTestCase
24+
):
25+
def _create_registered_user(self, **kwargs):
26+
options = {
27+
"is_verified": False,
28+
"method": "mobile_phone",
29+
"organization": self.default_org,
30+
}
31+
options.update(**kwargs)
32+
if "user" not in options:
33+
options["user"] = self._create_user()
34+
reg_user = RegisteredUser(**options)
35+
reg_user.full_clean()
36+
reg_user.save()
37+
return reg_user
38+
39+
def _create_closed_accounting_without_metric(self, **kwargs):
40+
options = _RADACCT.copy()
41+
options.update(
42+
{
43+
"unique_id": "closed-without-metric",
44+
"calling_station_id": "00:00:00:00:00:00",
45+
"input_octets": 8000000000,
46+
"output_octets": 9000000000,
47+
}
48+
)
49+
options.update(kwargs)
50+
stop_time = options.pop("stop_time", timezone.now())
51+
terminate_cause = options.pop("terminate_cause", "NAS-Reboot")
52+
session = self._create_radius_accounting(**options)
53+
RadiusAccounting.objects.filter(pk=session.pk).update(
54+
stop_time=stop_time,
55+
terminate_cause=terminate_cause,
56+
)
57+
session.refresh_from_db()
58+
return session
59+
60+
def test_rebuild_radius_accounting_metrics_dry_run(self):
61+
user = self._create_user()
62+
device = self._create_device()
63+
self._create_registered_user(user=user)
64+
self._create_closed_accounting_without_metric(
65+
username=user.username,
66+
called_station_id=device.mac_address.replace("-", ":").upper(),
67+
)
68+
out = StringIO()
69+
call_command("rebuild_radius_accounting_metrics", stdout=out)
70+
self.assertIn("Dry run: 1 closed sessions would be processed.", out.getvalue())
71+
self.assertEqual(
72+
self.metric_model.objects.filter(configuration="radius_acc").count(), 0
73+
)
74+
75+
@patch(
76+
"openwisp_radius.integrations.monitoring.management.commands."
77+
"rebuild_radius_accounting_metrics.timeseries_db.query"
78+
)
79+
@patch("logging.Logger.warning")
80+
def test_rebuild_radius_accounting_metrics_commit(
81+
self, mocked_warning, mocked_query
82+
):
83+
user = self._create_user()
84+
reg_user = self._create_registered_user(user=user)
85+
device = self._create_device()
86+
device_loc = self._create_device_location(
87+
content_object=device,
88+
location=self._create_location(organization=device.organization),
89+
)
90+
session = self._create_closed_accounting_without_metric(
91+
username=user.username,
92+
called_station_id=device.mac_address.replace("-", ":").upper(),
93+
)
94+
out = StringIO()
95+
call_command("rebuild_radius_accounting_metrics", commit=True, stdout=out)
96+
output = out.getvalue()
97+
self.assertIn("Starting to rebuild 1 accounting metrics.", output)
98+
self.assertIn("Processed 1 of 1 accounting metrics.", output)
99+
delete_queries = [
100+
call.args[0]
101+
for call in mocked_query.call_args_list
102+
if call.args[0].startswith("DELETE FROM radius_acc")
103+
]
104+
self.assertEqual(len(delete_queries), 1)
105+
self.assertEqual(
106+
self.metric_model.objects.filter(
107+
configuration="radius_acc",
108+
name="RADIUS Accounting",
109+
key="radius_acc",
110+
object_id=str(device.id),
111+
content_type=ContentType.objects.get_for_model(self.device_model),
112+
extra_tags={
113+
"called_station_id": device.mac_address,
114+
"calling_station_id": sha1_hash(session.calling_station_id),
115+
"location_id": str(device_loc.location.id),
116+
"method": reg_user.method,
117+
"organization_id": str(self.default_org.id),
118+
},
119+
).count(),
120+
1,
121+
)
122+
123+
@patch("logging.Logger.warning")
124+
def test_rebuild_radius_accounting_metrics_nas_reboot_filter(self, *args):
125+
user = self._create_user()
126+
device = self._create_device()
127+
self._create_registered_user(user=user)
128+
self._create_closed_accounting_without_metric(
129+
unique_id="matching-session",
130+
username=user.username,
131+
called_station_id=device.mac_address.replace("-", ":").upper(),
132+
)
133+
self._create_closed_accounting_without_metric(
134+
unique_id="ignored-session",
135+
username=user.username,
136+
called_station_id=device.mac_address.replace("-", ":").upper(),
137+
terminate_cause="Session-Timeout",
138+
)
139+
out = StringIO()
140+
call_command(
141+
"rebuild_radius_accounting_metrics",
142+
commit=True,
143+
stdout=out,
144+
)
145+
output = out.getvalue()
146+
self.assertIn("Starting to rebuild 1 accounting metrics.", output)
147+
self.assertIn("Processed 1 of 1 accounting metrics.", output)
148+
metric = self.metric_model.objects.get(configuration="radius_acc")
149+
points = metric.chart_set.get(configuration="radius_traffic").read()
150+
self.assertEqual(points["summary"], {"upload": 9, "download": 8})

0 commit comments

Comments
 (0)