Skip to content

Commit 4c6ace9

Browse files
Add UI Action to deactivate IPMI SOL sessions
Harden SOL deactivate endpoint: POST+CSRF, secure ipmitool execution, timeout handling add allowlist validation to BMC host and IPMI username before subprocess execution and added test that checks that unsafe input is rejected and not executed Queue SOL deactivation via SSH task - move SOL deactivate from request path to async task queue - add dedicated DeactivateSerialOverLan task executed from cscreen server - enqueue through signal receiver and keep model-side permission checks - extend SSH.execute to support remote environment vars for IPMI_PASSWORD - update frontend wording to explicit queued/background behavior - add/adjust focused tests and user-guide notes remove unused imports SOL precondition checks now warn and return False instead of raising exceptions add note to setup.rst about paramiko remove crsftoken remove getCookie funtion, no needed Fix linting: isort, black, flake8
1 parent 4f79d30 commit 4c6ace9

14 files changed

Lines changed: 458 additions & 13 deletions

File tree

docs/adminguide/setup.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,9 @@ the manual setup of a cscreen server. Follow these steps to ensure that Orthos c
9696
9797
3. Setup passwordless SSH keys between the ``orthos`` (Orthos server) to the ``_cscreen`` user (console server).
9898

99+
.. note:: Orthos can pass environment variables to remote commands via Paramiko ``exec_command``.
100+
See `Paramiko SSHClient.exec_command docs <https://docs.paramiko.org/en/stable/api/client.html#paramiko.client.SSHClient.exec_command>`_.
101+
SSH servers may silently reject environment variables, so do not rely on them unless the server-side
102+
SSH daemon is configured to accept them.
103+
99104
4. Enable and start the systemd service for cscreen ``systemctl enable --now cscreend.service``

docs/userguide/machine_page.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ Machine Actions
6060
- Setup Machine: Here you can install your machine according to your needs. You have the possibility to install SLES,
6161
SLED, Opensuse Leap, Opensuse and Tumbleweed. During the installation you have several options: install, install ssh
6262
install ssh auto, install auto etc.
63+
- Queue SOL Deactivation: For machines with IPMI serial consoles, this action queues a background task to deactivate
64+
Serial-over-LAN (SOL) via the configured serial console server.
6365
- Report Problem: If you unexpectedly encounter a problem with the machine, you can create a support ticket here.
6466

6567
.. image:: ../img/userguide/10_machine_release.jpg

orthos2/data/models/machine.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,6 +1393,43 @@ def powercycle(self, action: Optional[str], user: Any = None) -> bool:
13931393

13941394
return False
13951395

1396+
@check_permission
1397+
def deactivate_sol(self, user: Any = None) -> bool:
1398+
"""Queue asynchronous deactivation of the machine Serial-over-LAN session."""
1399+
from orthos2.data.signals import signal_serialconsole_sol_deactivate
1400+
1401+
if not self.has_serialconsole():
1402+
logger.warning(
1403+
"SOL deactivate skipped for %s: no serial console available", self.fqdn
1404+
)
1405+
return False
1406+
1407+
if self.serialconsole.stype.name != "IPMI":
1408+
logger.warning(
1409+
"SOL deactivate skipped for %s: serial console type is not IPMI",
1410+
self.fqdn,
1411+
)
1412+
return False
1413+
1414+
if not self.has_bmc():
1415+
logger.warning("SOL deactivate skipped for %s: no BMC available", self.fqdn)
1416+
return False
1417+
1418+
if not self.fqdn_domain.cscreen_server:
1419+
logger.warning(
1420+
"SOL deactivate skipped for %s: no serial console server configured for domain %s",
1421+
self.fqdn,
1422+
self.fqdn_domain,
1423+
)
1424+
return False
1425+
1426+
signal_serialconsole_sol_deactivate.send( # type: ignore
1427+
sender=Machine,
1428+
machine_id=self.pk,
1429+
)
1430+
1431+
return True
1432+
13961433
@check_permission
13971434
def ssh_shutdown(self, user: Any = None, reboot: bool = False) -> bool:
13981435
"""Power off/reboot the machine using SSH."""

orthos2/data/signals.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
signal_cobbler_sync_dhcp = Signal()
2121
signal_cobbler_machine_update = Signal()
2222
signal_serialconsole_regenerate = Signal()
23+
signal_serialconsole_sol_deactivate = Signal()
2324
signal_motd_regenerate = Signal()
2425

2526

@@ -163,6 +164,15 @@ def regenerate_serialconsole(
163164
TaskManager.add(task)
164165

165166

167+
@receiver(signal_serialconsole_sol_deactivate)
168+
def deactivate_serialconsole_sol(
169+
sender: Any, machine_id: int, *args: Any, **kwargs: Any
170+
) -> None:
171+
"""Create `DeactivateSerialOverLan()` task here."""
172+
task = tasks.DeactivateSerialOverLan(machine_id)
173+
TaskManager.add(task)
174+
175+
166176
@receiver(signal_cobbler_regenerate)
167177
def regenerate_cobbler(
168178
sender: Any, domain_id: Optional[int], *args: Any, **kwargs: Any

orthos2/frontend/templates/frontend/machines/detail/overview.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,24 @@
5555
});
5656
}
5757

58+
59+
function deactivate_sol() {
60+
$.ajax({
61+
type: 'POST',
62+
url: '{% url 'frontend:ajax_deactivate_sol' machine.id %}',
63+
beforeSend: function() {
64+
// Avoid double-submits while the backend deactivation request is running.
65+
$('.deactivate-sol').addClass('disabled')
66+
},
67+
complete: function() {
68+
$('.deactivate-sol').removeClass('disabled')
69+
},
70+
success: function(data) {
71+
showMachineStatusBarMessage(data);
72+
},
73+
});
74+
}
75+
5876
function regenerate_domain_cscreen() {
5977
$.ajax({
6078
url: '{% url 'frontend:regenerate_domain_cscreen' machine.id %}',

orthos2/frontend/templates/frontend/machines/detail/snippets/sidebar.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ <h5>Actions</h5>
7979
<a class="btn btn-secondary btn-sm regenerate-domain-cobbler" href="javascript:void(0);" onclick="regenerate_domain_cobbler()" role="button">Regenerate Cobbler Server</a>
8080
{% endif %}
8181
</div>
82+
83+
{% if machine.has_serialconsole and machine.serialconsole.stype.name == "IPMI" %}
84+
<br/>
85+
<div class="btn-group-vertical d-flex">
86+
<a class="btn btn-secondary btn-sm deactivate-sol" href="javascript:void(0);" onclick="deactivate_sol()" role="button">Queue SOL Deactivation</a>
87+
</div>
88+
{% endif %}
8289
{% endif %}
8390

8491
<hr/>
Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,41 @@
1+
from unittest import mock
2+
13
from django.test import TestCase
2-
from django_webtest import WebTest # type: ignore
34

5+
from orthos2.data.models import Machine
6+
7+
8+
class DeactivateSolMachineTest(TestCase):
9+
10+
fixtures = ["orthos2/utils/tests/fixtures/machines.json"]
11+
12+
@mock.patch("orthos2.data.signals.signal_serialconsole_sol_deactivate.send")
13+
def test_deactivate_sol_enqueues_task(self, mocked_send: mock.MagicMock) -> None:
14+
"""The model action should enqueue a task instead of running SOL deactivate inline."""
15+
16+
machine = Machine.objects.get(pk=2)
17+
machine.fqdn_domain.__class__.objects.filter(pk=machine.fqdn_domain.pk).update(
18+
cscreen_server=machine
19+
)
20+
machine.refresh_from_db()
421

5-
class AddMachine(WebTest):
6-
def test_add_new_machine(self) -> None:
7-
pass
22+
result = machine.deactivate_sol()
823

24+
self.assertTrue(result)
25+
mocked_send.assert_called_once_with(
26+
sender=Machine,
27+
machine_id=machine.pk,
28+
)
929

10-
class EditMachine(TestCase):
30+
def test_deactivate_sol_requires_serialconsole(self) -> None:
31+
"""The model action should return False when no serial console is configured."""
1132

12-
pass
33+
machine = Machine.objects.get(pk=1)
1334

35+
self.assertFalse(machine.deactivate_sol())
1436

15-
class DeleteMachine(TestCase):
37+
def test_deactivate_sol_requires_cscreen_server(self) -> None:
38+
"""The model action should return False when no serial console server is configured."""
39+
machine = Machine.objects.get(pk=2)
1640

17-
pass
41+
self.assertFalse(machine.deactivate_sol())

orthos2/frontend/tests/admin/test_machine_views.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import json
12
from unittest import mock
23

4+
from django.contrib.auth.models import User
35
from django.urls import reverse # type: ignore
46
from django_webtest import WebTest # type: ignore
57

6-
from orthos2.data.models import Architecture, Machine, ServerConfig, System
8+
from orthos2.data.models import (
9+
BMC,
10+
Architecture,
11+
Machine,
12+
RemotePowerType,
13+
SerialConsole,
14+
SerialConsoleType,
15+
ServerConfig,
16+
System,
17+
)
718
from orthos2.data.models.domain import Domain
819

920

@@ -52,6 +63,34 @@ def setUp(self, m_is_dns_resolvable: mock.MagicMock):
5263

5364
m2.save()
5465

66+
ipmi_fence_agent = RemotePowerType.objects.create(
67+
name="ipmilanplus", device="bmc"
68+
)
69+
ipmi_console_type = SerialConsoleType.objects.create(
70+
name="IPMI",
71+
command=(
72+
"ipmitool -I lanplus -H {{ machine.bmc.fqdn }} "
73+
"-U {{ ipmi.user}} -P {{ ipmi.password }} sol activate"
74+
),
75+
comment="IPMI",
76+
)
77+
78+
BMC.objects.create(
79+
username="root",
80+
password="root",
81+
fqdn="testsys-sp.orthos2.test",
82+
mac="AA:BB:CC:DD:EE:FF",
83+
machine=m2,
84+
fence_agent=ipmi_fence_agent,
85+
)
86+
SerialConsole.objects.create(
87+
machine=m2,
88+
stype=ipmi_console_type,
89+
kernel_device="ttyS",
90+
kernel_device_num=1,
91+
baud_rate=115200,
92+
)
93+
5594
def test_visible_fieldsets_non_administrative_systems(self) -> None:
5695
"""Test for fieldsets."""
5796
# Act
@@ -93,3 +132,50 @@ def test_visible_inlines_administrative_systems(self) -> None:
93132
# Assert
94133
self.assertContains(page, "Add another Serial Console") # type: ignore
95134
self.assertContains(page, "Remote Power") # type: ignore
135+
136+
def test_deactivate_sol_button_visible_for_ipmi_console(self) -> None:
137+
"""The machine detail page should expose the SOL deactivate action for IPMI consoles."""
138+
139+
page = self.app.get( # type: ignore
140+
reverse("frontend:detail", args=["2"]), user="superuser"
141+
)
142+
143+
self.assertContains(page, "Queue SOL Deactivation") # type: ignore
144+
145+
def test_deactivate_sol_button_hidden_without_serialconsole(self) -> None:
146+
"""The machine detail page should not expose the SOL action if no serial console exists."""
147+
148+
page = self.app.get( # type: ignore
149+
reverse("frontend:detail", args=["1"]), user="superuser"
150+
)
151+
152+
self.assertNotContains(page, "Queue SOL Deactivation") # type: ignore
153+
154+
@mock.patch("orthos2.frontend.views.ajax.Machine.deactivate_sol")
155+
def test_ajax_deactivate_sol(self, mocked_deactivate_sol: mock.MagicMock) -> None:
156+
"""The AJAX endpoint should queue the machine action and return a success payload."""
157+
158+
mocked_deactivate_sol.return_value = True
159+
self.client.force_login(User.objects.get(username="superuser"))
160+
161+
response = self.client.post(reverse("frontend:ajax_deactivate_sol", args=["2"]))
162+
163+
self.assertEqual(response.status_code, 200)
164+
payload = json.loads(response.content)
165+
self.assertEqual(payload["cls"], "success")
166+
self.assertEqual(
167+
payload["message"],
168+
"SOL deactivation was queued and will run in the background.",
169+
)
170+
mocked_deactivate_sol.assert_called_once_with(user=mock.ANY)
171+
172+
def test_ajax_deactivate_sol_rejects_get(self) -> None:
173+
"""The SOL deactivation endpoint should reject GET requests."""
174+
175+
page = self.app.get( # type: ignore
176+
reverse("frontend:ajax_deactivate_sol", args=["2"]),
177+
user="superuser",
178+
expect_errors=True,
179+
)
180+
181+
self.assertEqual(page.status_int, 405) # type: ignore[attr-defined]

orthos2/frontend/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@
139139
views.ajax.powercycle,
140140
name="ajax_powercycle",
141141
),
142+
re_path(
143+
r"^ajax/machine/(?P<machine_id>[0-9]+)/sol/deactivate$",
144+
views.ajax.deactivate_sol,
145+
name="ajax_deactivate_sol",
146+
),
142147
re_path(
143148
r"^ajax/machine/(?P<host_id>[0-9]+)/virtualization/list$",
144149
views.ajax.virtualization_list,

orthos2/frontend/views/ajax.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.contrib.auth.decorators import login_required
55
from django.http import HttpRequest, JsonResponse
66
from django.template.defaultfilters import urlize
7+
from django.views.decorators.http import require_POST
78

89
from orthos2.data.models import Annotation, Machine, RemotePower
910
from orthos2.frontend.decorators import check_permissions
@@ -77,6 +78,39 @@ def powercycle(request: HttpRequest, machine_id: int) -> JsonResponse:
7778
return JsonResponse({"type": "status", "cls": "danger", "message": str(e)})
7879

7980

81+
@require_POST
82+
@login_required
83+
@check_permissions(key="machine_id")
84+
def deactivate_sol(request: HttpRequest, machine_id: int) -> JsonResponse:
85+
"""Deactivate the machine SOL session and return result as JSON."""
86+
87+
try:
88+
machine = Machine.objects.get(pk=machine_id)
89+
# Keep queueing logic in the model layer so permissions and validation stay centralized.
90+
result = machine.deactivate_sol(user=request.user)
91+
92+
if result:
93+
return JsonResponse(
94+
{
95+
"type": "status",
96+
"cls": "success",
97+
"message": "SOL deactivation was queued and will run in the background.",
98+
}
99+
)
100+
101+
return JsonResponse(
102+
{"type": "status", "cls": "danger", "message": "SOL deactivate failed!"}
103+
)
104+
105+
except Machine.DoesNotExist:
106+
return JsonResponse(
107+
{"type": "status", "cls": "danger", "message": "Machine does not exist!"}
108+
)
109+
except Exception as e:
110+
logger.exception(e)
111+
return JsonResponse({"type": "status", "cls": "danger", "message": str(e)})
112+
113+
80114
@login_required
81115
def virtualization_list(request: HttpRequest, host_id: int) -> JsonResponse:
82116
"""Return VM list (libvirt)."""

0 commit comments

Comments
 (0)