Skip to content

Commit 41d81cf

Browse files
committed
feat: implement HTTP server for remote UEFI shell capability
Signed-off-by: Lasota, Adrian <adrian.lasota@intel.com>
1 parent 6f028ea commit 41d81cf

File tree

7 files changed

+554
-2
lines changed

7 files changed

+554
-2
lines changed

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,6 +1101,32 @@ if code cannot establish connection, it will retry using deploy port (+1 for def
11011101

11021102
If code cannot establish connection, it will start deployment of python using [Deployment setup tool](#deployment-setup-tool) and establish connection again.
11031103

1104+
## RShell connection
1105+
1106+
`RShellConnection` is a connection type that leverages `httplib.client` library to establish and manage connections using RESTful API over HTTP protocol in EFI Shell environment. `rshell_client.py` must be present on the EFI Shell target system.
1107+
1108+
`rshell_server.py` is a server script that needs to be executed on the host machine to facilitate communication between the host and the EFI Shell target system. Server works on queue with address ip -> command to execute, it provides a RESTful API that allows the host to send commands and receive responses from the EFI Shell:
1109+
1110+
* `/execute_command` - Endpoint to execute commands on the EFI Shell target system:
1111+
Form fields:
1112+
* `timeout` - Timeout for command execution.
1113+
* `command` - Command to be executed.
1114+
* `ip` - IP address of the EFI Shell target system.
1115+
* `/post_result` - Endpoint to post results back to the host.
1116+
Headers fields:
1117+
* `CommandID` - Unique identifier for the command.
1118+
* `rc` - Return code of the executed command.
1119+
Body:
1120+
* Command output.
1121+
* `/exception` - Endpoint to handle exceptions that may occur during communication.
1122+
Headers fields:
1123+
* `CommandID` - Unique identifier for the command.
1124+
Body:
1125+
* Exception details.
1126+
* `/getCommandToExecute` - Endpoint to retrieve commands to be executed on the EFI Shell target system. Returns commandline with generated CommandID.
1127+
* `/health/<ip>` - Endpoint to check the health status of the connection.
1128+
1129+
`rshell.py` is a Connection class that calls RESTful API endpoints provided by `rshell_server.py` to execute commands on the EFI Shell target system. If required, starts `rshell_server.py` on the host machine.
11041130

11051131
## OS supported:
11061132
* LNX

examples/rshell_example.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Copyright (C) 2025 Intel Corporation
2+
# SPDX-License-Identifier: MIT
3+
import logging
4+
logging.basicConfig(level=logging.DEBUG)
5+
from mfd_connect.rshell import RShellConnection
6+
7+
# LINUX
8+
conn = RShellConnection(ip="10.10.10.10") # start and connect to rshell server
9+
# conn = RShellConnection(ip="10.10.10.10", server_ip="10.10.10.11") # connect to rshell server
10+
conn.execute_command("ls")
11+
conn.disconnect(True)

mfd_connect/rshell.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
# Copyright (C) 2025 Intel Corporation
2+
# SPDX-License-Identifier: MIT
3+
"""RShell Connection Class."""
4+
5+
import logging
6+
import sys
7+
import time
8+
import typing
9+
from ipaddress import IPv4Address, IPv6Address
10+
from subprocess import CalledProcessError
11+
12+
import requests
13+
from mfd_common_libs import add_logging_level, log_levels, TimeoutCounter
14+
from mfd_typing.cpu_values import CPUArchitecture
15+
from mfd_typing.os_values import OSBitness, OSName, OSType
16+
17+
from mfd_connect.local import LocalConnection
18+
from mfd_connect.pathlib.path import CustomPath, custom_path_factory
19+
from mfd_connect.process.base import RemoteProcess
20+
21+
from .base import Connection, ConnectionCompletedProcess
22+
23+
if typing.TYPE_CHECKING:
24+
from pydantic import (
25+
BaseModel, # from pytest_mfd_config.models.topology import ConnectionModel
26+
)
27+
28+
29+
logger = logging.getLogger(__name__)
30+
add_logging_level(level_name="MODULE_DEBUG", level_value=log_levels.MODULE_DEBUG)
31+
add_logging_level(level_name="CMD", level_value=log_levels.CMD)
32+
add_logging_level(level_name="OUT", level_value=log_levels.OUT)
33+
34+
35+
class RShellConnection(Connection):
36+
"""RShell Connection Class."""
37+
38+
def __init__(
39+
self,
40+
ip: str | IPv4Address | IPv6Address,
41+
server_ip: str | IPv4Address | IPv6Address | None = "127.0.0.1",
42+
model: "BaseModel | None" = None,
43+
cache_system_data: bool = True,
44+
connection_timeout: int = 60,
45+
):
46+
"""
47+
Initialize RShellConnection.
48+
49+
:param ip: The IP address of the RShell server.
50+
:param server_ip: The IP address of the server to connect to (optional).
51+
:param model: The Pydantic model to use for the connection (optional).
52+
:param cache_system_data: Whether to cache system data (default: True).
53+
"""
54+
super().__init__(model=model, cache_system_data=cache_system_data)
55+
self._ip = ip
56+
self.server_ip = server_ip if server_ip else "127.0.0.1"
57+
self.server_process = None
58+
if server_ip == "127.0.0.1":
59+
# start Rshell server
60+
self.server_process = self._run_server()
61+
time.sleep(5)
62+
timeout = TimeoutCounter(connection_timeout)
63+
while not timeout:
64+
logger.log(level=log_levels.MODULE_DEBUG, msg="Checking RShell server health")
65+
status_code = requests.get(
66+
f"http://{self.server_ip}/health/{self._ip}", proxies={"no_proxy": "*"}
67+
).status_code
68+
if status_code == 200:
69+
logger.log(level=log_levels.MODULE_DEBUG, msg="RShell server is healthy")
70+
break
71+
time.sleep(5)
72+
else:
73+
raise TimeoutError("Connection of Client to RShell server timed out")
74+
75+
def disconnect(self, stop_client: bool = False) -> None:
76+
"""
77+
Disconnect connection.
78+
79+
Stop local RShell server if established.
80+
81+
:param stop_client: Whether to stop the RShell client (default: False).
82+
"""
83+
if stop_client:
84+
logger.log(level=log_levels.MODULE_DEBUG, msg="Stopping RShell client")
85+
self.execute_command("end")
86+
if self.server_process:
87+
logger.log(level=log_levels.MODULE_DEBUG, msg="Stopping RShell server")
88+
self.server_process.kill()
89+
logger.log(level=log_levels.MODULE_DEBUG, msg="RShell server stopped")
90+
logger.log(level=log_levels.MODULE_DEBUG, msg=self.server_process.stdout_text)
91+
92+
def _run_server(self) -> RemoteProcess:
93+
"""Run RShell server locally."""
94+
conn = LocalConnection()
95+
server_file = conn.path(__file__).parent / "rshell_server.py"
96+
return conn.start_process(f"{conn.modules().sys.executable} {server_file}")
97+
98+
def execute_command(
99+
self,
100+
command: str,
101+
*,
102+
input_data: str | None = None,
103+
cwd: str | None = None,
104+
timeout: int | None = None,
105+
env: dict | None = None,
106+
stderr_to_stdout: bool = False,
107+
discard_stdout: bool = False,
108+
discard_stderr: bool = False,
109+
skip_logging: bool = False,
110+
expected_return_codes: list[int] | None = None,
111+
shell: bool = False,
112+
custom_exception: type[CalledProcessError] | None = None,
113+
) -> ConnectionCompletedProcess:
114+
"""
115+
Execute a command on the remote server.
116+
117+
:param command: The command to execute.
118+
:param timeout: The timeout for the command execution (optional).
119+
:return: The result of the command execution.
120+
"""
121+
if input_data is not None:
122+
logger.log(
123+
level=log_levels.MODULE_DEBUG,
124+
msg="Input data is not supported for RShellConnection and will be ignored.",
125+
)
126+
127+
if cwd is not None:
128+
logger.log(
129+
level=log_levels.MODULE_DEBUG,
130+
msg="CWD is not supported for RShellConnection and will be ignored.",
131+
)
132+
133+
if env is not None:
134+
logger.log(
135+
level=log_levels.MODULE_DEBUG,
136+
msg="Environment variables are not supported for RShellConnection and will be ignored.",
137+
)
138+
139+
if stderr_to_stdout:
140+
logger.log(
141+
level=log_levels.MODULE_DEBUG,
142+
msg="Redirecting stderr to stdout is not supported for RShellConnection and will be ignored.",
143+
)
144+
145+
if discard_stdout:
146+
logger.log(
147+
level=log_levels.MODULE_DEBUG,
148+
msg="Discarding stdout is not supported for RShellConnection and will be ignored.",
149+
)
150+
151+
if discard_stderr:
152+
logger.log(
153+
level=log_levels.MODULE_DEBUG,
154+
msg="Discarding stderr is not supported for RShellConnection and will be ignored.",
155+
)
156+
157+
if skip_logging:
158+
logger.log(
159+
level=log_levels.MODULE_DEBUG,
160+
msg="Skipping logging is not supported for RShellConnection and will be ignored.",
161+
)
162+
163+
if expected_return_codes is not None:
164+
logger.log(
165+
level=log_levels.MODULE_DEBUG,
166+
msg="Expected return codes are not supported for RShellConnection and will be ignored.",
167+
)
168+
169+
if shell:
170+
logger.log(
171+
level=log_levels.MODULE_DEBUG,
172+
msg="Shell execution is not supported for RShellConnection and will be ignored.",
173+
)
174+
175+
if custom_exception:
176+
logger.log(
177+
level=log_levels.MODULE_DEBUG,
178+
msg="Custom exceptions are not supported for RShellConnection and will be ignored.",
179+
)
180+
timeout_string = f" with timeout {timeout} seconds" if timeout is not None else ""
181+
logger.log(level=log_levels.CMD, msg=f"Executing >{self._ip}> '{command}',{timeout_string}")
182+
183+
response = requests.post(
184+
f"http://{self.server_ip}/execute_command",
185+
data={"command": command, "timeout": timeout, "ip": self._ip},
186+
proxies={"no_proxy": "*"},
187+
)
188+
completed_process = ConnectionCompletedProcess(
189+
args=command,
190+
stdout=response.text,
191+
return_code=int(response.headers.get("rc", -1)),
192+
)
193+
logger.log(
194+
level=log_levels.MODULE_DEBUG,
195+
msg=f"Finished executing '{command}', rc={completed_process.return_code}",
196+
)
197+
if skip_logging:
198+
return completed_process
199+
200+
stdout = completed_process.stdout
201+
if stdout:
202+
logger.log(level=log_levels.OUT, msg=f"stdout>>\n{stdout}")
203+
204+
return completed_process
205+
206+
def path(self, *args, **kwargs) -> CustomPath:
207+
"""Path represents a filesystem path."""
208+
if sys.version_info >= (3, 12):
209+
kwargs["owner"] = self
210+
return custom_path_factory(*args, **kwargs)
211+
212+
return CustomPath(*args, owner=self, **kwargs)
213+
214+
def get_os_name(self) -> OSName: # noqa: D102
215+
raise NotImplementedError
216+
217+
def get_os_type(self) -> OSType: # noqa: D102
218+
raise NotImplementedError
219+
220+
def get_os_bitness(self) -> OSBitness: # noqa: D102
221+
raise NotImplementedError
222+
223+
def get_cpu_architecture(self) -> CPUArchitecture: # noqa: D102
224+
raise NotImplementedError
225+
226+
def restart_platform(self) -> None: # noqa: D102
227+
raise NotImplementedError
228+
229+
def shutdown_platform(self) -> None: # noqa: D102
230+
raise NotImplementedError
231+
232+
def wait_for_host(self, timeout: int = 60) -> None: # noqa: D102
233+
raise NotImplementedError

mfd_connect/rshell_client.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Copyright (C) 2025 Intel Corporation
2+
# SPDX-License-Identifier: MIT
3+
"""
4+
RShell Client Script.
5+
6+
Make sure that the Python UEFI interpreter is compiled with
7+
Socket module support.
8+
"""
9+
10+
__version__ = "1.0.0"
11+
12+
try:
13+
import httplib as client
14+
except ImportError:
15+
from http import client
16+
import sys
17+
import os
18+
import time
19+
20+
# get http server ip
21+
http_server = sys.argv[1]
22+
if len(sys.argv) > 2:
23+
source_address = sys.argv[2]
24+
else:
25+
source_address = None
26+
27+
os_name = os.name
28+
29+
30+
def _sleep(interval): # noqa: ANN001, ANN202
31+
"""
32+
Simulate the sleep function for EFI shell as the sleep API from time module is not working on EFI shell.
33+
34+
:param interval time period the system to be in idle
35+
"""
36+
start_ts = time.time()
37+
while time.time() < start_ts + interval:
38+
pass
39+
40+
41+
time.sleep = _sleep
42+
43+
44+
def _get_command(): # noqa: ANN202
45+
"""Get the command from server to execute on client machine."""
46+
# construct the list of tests by interacting with server
47+
conn.request("GET", "getCommandToExecute")
48+
rsp = conn.getresponse()
49+
status = rsp.status
50+
_id = rsp.getheader("CommandID")
51+
if status == 204:
52+
return None
53+
54+
print("Waiting for command from server: ")
55+
data_received = rsp.read()
56+
print(data_received)
57+
test_list = data_received.split(b",")
58+
59+
return test_list[0], _id # return only the first command
60+
61+
62+
while True:
63+
# Connect to server
64+
source_address_parameter = (source_address, 80) if source_address else None
65+
conn = client.HTTPConnection(http_server, source_address=source_address_parameter)
66+
# get the command from server
67+
_command = _get_command()
68+
if not _command:
69+
conn.close()
70+
time.sleep(5)
71+
continue
72+
cmd_str, _id = _command
73+
cmd_str = cmd_str.decode("utf-8")
74+
cmd_name = cmd_str.split(" ")[0]
75+
if cmd_name == "end":
76+
print("No more commands available to run")
77+
conn.close()
78+
exit(0)
79+
80+
print("Executing", cmd_str)
81+
82+
out = cmd_name + ".txt"
83+
cmd = cmd_str + " > " + out
84+
85+
time.sleep(5)
86+
rc = os.system(cmd) # execute command on machine
87+
print("Executed the command")
88+
time.sleep(5)
89+
90+
print("Posting the results to server")
91+
# send response to server
92+
try:
93+
if os_name == "edk2":
94+
encoding = "utf-16"
95+
else:
96+
encoding = "utf-8"
97+
98+
f = open(out, "r", encoding=encoding)
99+
100+
conn.request(
101+
"POST",
102+
"post_result",
103+
body=f.read(),
104+
headers={"Content-Type": "text/plain", "Connection": "keep-alive", "CommandID": _id, "rc": rc},
105+
)
106+
f.close()
107+
os.system("del " + out)
108+
except Exception as exp:
109+
conn.request(
110+
"POST",
111+
"exception",
112+
body=cmd + str(exp),
113+
headers={"Content-Type": "text/plain", "Connection": "keep-alive", "CommandID": _id},
114+
)
115+
116+
print("output posted to server")
117+
conn.close()
118+
print("closed the connection")
119+
time.sleep(1)

0 commit comments

Comments
 (0)