Skip to content

Commit 8c1774b

Browse files
committed
Add MMS ObtainFile service implementation
--- + Add "put" command to mmsclient.py + Add origon configuration control to iedctrl.py
1 parent 53b3384 commit 8c1774b

5 files changed

Lines changed: 272 additions & 8 deletions

File tree

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,7 @@ critical.
5656
If you decide to build on this work, we encourage you to adopt secure coding
5757
practices, apply a structured security development process, and consider how
5858
you’ll generate and track indicators of compromise in line with your specific
59-
goals. Together, we can strengthen the security and resilience of ICS
60-
technologies while keeping experimentation safe and responsible.
59+
goals.
6160

6261
## License
6362

src/icspacket/examples/iedctrl.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ def cli2data(value: str) -> Any | None:
5454
return doc
5555

5656

57+
def parse_origin(origin: str) -> tuple[int, bytes]:
58+
if ":" not in origin:
59+
logging.warning(f"Invalid origin format: {origin!r}")
60+
return 0, bytes()
61+
62+
cat, id = origin.split(":")
63+
try:
64+
ident = bytes.fromhex(id)
65+
except ValueError:
66+
ident = id.encode()
67+
return int(cat), ident
68+
69+
5770
def cli_main():
5871
from icspacket import __version__
5972

@@ -78,6 +91,13 @@ def cli_main():
7891
)
7992
group.add_argument("--toggle", action="store_true", help="Toggle the value of the specified variable (queries first and assumes boolean).")
8093

94+
group = parser.add_argument_group("Control Options")
95+
group.add_argument("--origin", metavar="CAT:ID", default=None, help=(
96+
"The origin of the control request. \n"
97+
"If not specified the default origin will be used."
98+
))
99+
group.add_argument("--synchro-check", action="store_true", help="Enables synchro check for the control request.")
100+
group.add_argument("--interlock-check", action="store_true", help="Enables interlock check for the control request.")
81101
# fmt: on
82102
add_mms_connection_options(parser)
83103
add_logging_options(parser)
@@ -140,6 +160,13 @@ def cli_main():
140160
)
141161
sys.exit(1)
142162

163+
if args.synchro_check:
164+
co.synchro_check = True
165+
if args.interlock_check:
166+
co.interlock_check = True
167+
if args.origin:
168+
co.origin_cat, co.origin_ident = parse_origin(args.origin)
169+
143170
if args.toggle:
144171
logging.debug("Toggle: Requesting current value for node...")
145172
value = client.get_data_values(ref / "Oper" / "ctlVal")
@@ -196,10 +223,10 @@ def cli_main():
196223
_ = client.await_command_termination()
197224
except LastApplError as e:
198225
logging.error(
199-
"Failed to operate on node %s: error: %s, cause: %s",
226+
"Failed to operate on node %s: [bold red]%s[/], cause: [red]%s",
200227
parts[-1],
201-
repr(e.error),
202-
repr(e.cause),
228+
e.error.name,
229+
e.cause.name,
203230
)
204231
except ConnectionError as e:
205232
logging.error(

src/icspacket/examples/mms_utility.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def data_to_str(data: Data) -> str | dict | list:
7373
case Data.PRESENT.PR_bit_string | Data.PRESENT.PR_booleanArray:
7474
return data.bit_string.value.to01(sep=" ") if data.bit_string else "<EMPTY>"
7575
case Data.PRESENT.PR_boolean:
76-
return "[green]TRUE[/]" if data.boolean else "FALSE"
76+
return "[green]True[/]" if data.boolean else "[red]False[/]"
7777
case Data.PRESENT.PR_array:
7878
return [data_to_str(item) for item in data.array]
7979
case Data.PRESENT.PR_utc_time:

src/icspacket/examples/mmsclient.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# You should have received a copy of the GNU General Public License
1515
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1616
# pyright: reportUnusedCallResult=false, reportGeneralTypeIssues=false
17+
import datetime
1718
import logging
1819
import sys
1920
import pathlib
@@ -29,7 +30,6 @@
2930

3031
from icspacket.core.connection import ConnectionClosedError
3132
from icspacket.core import hexdump
32-
from icspacket.proto.cotp.structs import TPDU_Size
3333
from icspacket.proto.mms._mms import FileName, ServiceError
3434
from icspacket.proto.mms.connection import MMS_Connection
3535
from icspacket.proto.mms.exceptions import MMSConnectionError, MMSUnknownServiceError
@@ -202,8 +202,16 @@ def do_ls(self, args) -> None:
202202
raw_path = "".join(list(entry.fileName))
203203
path = pathlib.Path(raw_path.removeprefix(str(remote_dir) + "/"))
204204
# TODO: parse time
205+
try:
206+
mtime = str(
207+
datetime.datetime.strptime(
208+
entry.fileAttributes.lastModified, "%Y%m%d%H%M%S.%fZ"
209+
)
210+
)
211+
except ValueError as e:
212+
mtime = "N/A"
205213
table.add_row(
206-
str(path), str(entry.fileAttributes.sizeOfFile), "N/A"
214+
str(path), str(entry.fileAttributes.sizeOfFile), mtime
207215
)
208216
console.print(table)
209217
else:
@@ -310,6 +318,29 @@ def do_del(self, args) -> None:
310318
error, service_error, f"Could not delete file {str(remote_file_path)!r}"
311319
)
312320

321+
put_parser = cmd2.Cmd2ArgumentParser(add_help=True)
322+
put_parser.add_argument("local_name", type=str, help="Local file name")
323+
put_parser.add_argument(
324+
"remote_name", type=str, nargs="?", help="Optional remote file name"
325+
)
326+
327+
@cmd2.with_argparser(put_parser)
328+
def do_put(self, args) -> None:
329+
"""Upload a file to the remote MMS server."""
330+
local_file_path = pathlib.Path(args.local_name)
331+
if not args.remote_name:
332+
args.remote_name = local_file_path.name
333+
334+
logging.info("Uploading '%s' to '%s'", local_file_path, args.remote_name)
335+
try:
336+
with local_file_path.open("rb") as local_fp:
337+
self.conn.file_transfer(local_file_path, args.remote_name)
338+
except MMSConnectionError as error:
339+
service_error = error.error
340+
self._handle_file_service_error(
341+
error, service_error, f"Could not upload file {str(local_file_path)!r}"
342+
)
343+
313344
@override
314345
def pexcept(self, msg: Any, *, end: str = "\n", apply_style: bool = True) -> None:
315346
match msg:
@@ -328,16 +359,53 @@ def cli_main():
328359
from icspacket import __version__
329360
from icspacket.core import logger
330361

362+
class _HelpAction(argparse.Action):
363+
def __init__(
364+
self,
365+
option_strings,
366+
dest=argparse.SUPPRESS,
367+
default=argparse.SUPPRESS,
368+
help=None,
369+
):
370+
super(_HelpAction, self).__init__(
371+
option_strings=option_strings,
372+
dest=dest,
373+
default=default,
374+
nargs="?",
375+
help=help,
376+
)
377+
378+
def __call__(self, parser, namespace, values, option_string=None):
379+
if not values:
380+
parser.print_help()
381+
else:
382+
parser_name = f"{values}_parser"
383+
if hasattr(MMSClient, parser_name):
384+
getattr(MMSClient, parser_name).print_help()
385+
else:
386+
parser.error(f"no such command: {values}")
387+
parser.exit()
388+
331389
parser = argparse.ArgumentParser(
332390
usage="%(prog)s [options] host [command [args...]]",
391+
add_help=False,
333392
)
393+
# parser.register("action", "cmd2_help", Cmd2Action)
334394
parser.add_argument(
335395
"-i",
336396
"--interactive",
337397
action="store_true",
338398
help="Continue in interactive mode acter executing the first command (only if given)",
339399
default=False,
340400
)
401+
parser.add_argument(
402+
"-h",
403+
"--help",
404+
action=_HelpAction,
405+
help="Show this help message and exit. Optionally: show help for command",
406+
default=None,
407+
dest="help",
408+
)
341409
add_mms_connection_options(parser)
342410
add_logging_options(parser)
343411

src/icspacket/proto/mms/connection.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,25 @@
1414
# You should have received a copy of the GNU General Public License
1515
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1616

17+
import datetime
1718
import logging
1819

1920
from collections.abc import Iterable
21+
import os
22+
from pathlib import Path
23+
import random
2024
from typing_extensions import Callable, override
2125

2226
from icspacket.core.connection import connection
2327
from icspacket.proto.mms._mms import (
28+
ApplicationReference,
29+
Confirmed_ResponsePDU,
30+
FileClose_Response,
31+
FileOpen_Response,
32+
FileRead_Response,
2433
GetNamedVariableListAttributes_Request,
2534
GetNamedVariableListAttributes_Response,
35+
ObtainFile_Request,
2636
Unconfirmed_PDU,
2737
UnconfirmedService,
2838
)
@@ -933,6 +943,166 @@ def variable_list_attributes(
933943
response = self.service_request(service)
934944
return response.getNamedVariableListAttributes
935945

946+
# ---------------------------------------------------------------------------
947+
# Annex C - File Access service
948+
# ---------------------------------------------------------------------------
949+
def obtain_file(
950+
self,
951+
source: str | Path,
952+
destination: str | Path,
953+
remote: ApplicationReference | None = None,
954+
) -> None:
955+
"""
956+
Perform the MMS :term:`ObtainFile` service to transfer a file
957+
between the client and a remote MMS server.
958+
959+
The ObtainFile service may be used by an MMS client to instruct an
960+
MMS server to obtain a specified file from a file server. Depending
961+
on the usage, this can either be a request to *pull* a file from
962+
a remote source or to *serve* a local file to the requesting MMS
963+
peer.
964+
965+
The method implements the complete reques-response sequence defined
966+
in IEC 61850 Annex C, including ``FileOpen``, ``FileRead``, ``FileClose``,
967+
and the final ``ObtainFile`` confirmation.
968+
969+
:param source:
970+
Path to the source file. If ``remote`` is provided, this denotes the
971+
file path to be retrieved from the remote server. Otherwise, this
972+
must point to a local file which will be transferred.
973+
:type source: str | Path
974+
975+
:param destination:
976+
Path where the file should be stored on the remote server.
977+
For local serving, this is the name advertised to the requesting peer.
978+
:type destination: str | Path
979+
980+
:param remote:
981+
Optional reference to an external application server from which
982+
the file should be obtained. If ``None``, this MMS connection
983+
instance will act as the file source.
984+
:type remote: ApplicationReference | None
985+
986+
:raises MMSServiceError:
987+
If the MMS server responds with an unexpected PDU type or an error
988+
occurs during the transfer sequence.
989+
990+
:raises OSError:
991+
If the local file system access fails (e.g., file not found or
992+
permission denied).
993+
994+
.. versionadded:: 0.2.4
995+
"""
996+
request = ObtainFile_Request(
997+
sourceFile=FileName([os.path.basename(str(source))]),
998+
destinationFile=FileName([str(destination)]),
999+
)
1000+
if remote:
1001+
request.sourceFileServer = remote
1002+
1003+
obtain_service = ConfirmedServiceRequest(obtainFile=request)
1004+
pdu = Confirmed_RequestPDU()
1005+
pdu.invokeID = self.next_invoke_id
1006+
pdu.service = obtain_service
1007+
1008+
self.send_mms_data(MMSpdu(confirmed_RequestPDU=pdu))
1009+
response = self.recv_mms_data()
1010+
error = self._error_from_service_response(
1011+
obtain_service, response, need_response=remote is not None
1012+
)
1013+
if error is not None:
1014+
raise error
1015+
1016+
if remote:
1017+
return
1018+
1019+
if response.present != MMSpdu.PRESENT.PR_confirmed_RequestPDU:
1020+
raise MMSServiceError(
1021+
f"Failed ObtainFile request with unexpected response: {response.present!r} - Expected FileOpen"
1022+
)
1023+
1024+
service = response.confirmed_RequestPDU.service
1025+
if service.present != ConfirmedServiceRequest.PRESENT.PR_fileOpen:
1026+
raise MMSServiceError(
1027+
f"Failed ObtainFile request with unexpected response: {service.present!r} - Expected FileOpen"
1028+
)
1029+
1030+
invoke_id = response.confirmed_RequestPDU.invokeID.value
1031+
# We simply generate a new handle
1032+
handle = random.randint(0, 0xFFFF)
1033+
pdu = ConfirmedServiceResponse()
1034+
1035+
source_path = Path(source)
1036+
source_stat = source_path.stat()
1037+
mtime = datetime.datetime.fromtimestamp(source_stat.st_mtime)
1038+
1039+
file_open = FileOpen_Response(frsmID=handle)
1040+
file_open.fileAttributes.sizeOfFile = source_stat.st_size
1041+
file_open.fileAttributes.lastModified = mtime.strftime("%Y%m%d%H%M%S.%fZ")
1042+
pdu.fileOpen = file_open
1043+
1044+
response = Confirmed_ResponsePDU()
1045+
response.invokeID = invoke_id
1046+
response.service = pdu
1047+
self.send_mms_data(MMSpdu(confirmed_ResponsePDU=response))
1048+
1049+
read_req = self.recv_mms_data()
1050+
if read_req.present != MMSpdu.PRESENT.PR_confirmed_RequestPDU:
1051+
raise MMSServiceError(
1052+
f"Failed ObtainFile request with unexpected response: {read_req.present!r} - Expected FileRead"
1053+
)
1054+
1055+
request = read_req.confirmed_RequestPDU
1056+
if request.service.present != ConfirmedServiceRequest.PRESENT.PR_fileRead:
1057+
raise MMSServiceError(
1058+
f"Failed ObtainFile request with unexpected response: {request.service.present!r} - Expected FileRead"
1059+
)
1060+
1061+
invoke_id = request.invokeID.value
1062+
pdu = ConfirmedServiceResponse()
1063+
1064+
file_read = FileRead_Response()
1065+
file_read.fileData = source_path.read_bytes()
1066+
file_read.moreFollows = False
1067+
1068+
pdu.fileRead = file_read
1069+
response = Confirmed_ResponsePDU()
1070+
response.invokeID = invoke_id
1071+
response.service = pdu
1072+
self.send_mms_data(MMSpdu(confirmed_ResponsePDU=response))
1073+
1074+
# expect fileClose and obtainFile response
1075+
close_req = self.recv_mms_data()
1076+
if close_req.present != MMSpdu.PRESENT.PR_confirmed_RequestPDU:
1077+
raise MMSServiceError(
1078+
f"Failed ObtainFile request with unexpected response: {close_req.present!r} - Expected FileClose"
1079+
)
1080+
1081+
close_req = close_req.confirmed_RequestPDU
1082+
if close_req.service.present != ConfirmedServiceRequest.PRESENT.PR_fileClose:
1083+
raise MMSServiceError(
1084+
f"Failed ObtainFile request with unexpected response: {close_req.service.present!r} - Expected FileClose"
1085+
)
1086+
1087+
invoke_id = close_req.invokeID.value
1088+
pdu = ConfirmedServiceResponse()
1089+
pdu.fileClose = FileClose_Response(value=None)
1090+
response = Confirmed_ResponsePDU()
1091+
response.invokeID = invoke_id
1092+
response.service = pdu
1093+
self.send_mms_data(MMSpdu(confirmed_ResponsePDU=response))
1094+
1095+
obtain_response = self.recv_mms_data()
1096+
error = self._error_from_service_response(obtain_service, obtain_response, True)
1097+
if error is not None:
1098+
raise error
1099+
1100+
# alias to match file_XXX naming convention
1101+
file_transfer = obtain_file
1102+
"""
1103+
.. versionadded:: 0.2.4
1104+
"""
1105+
9361106
# ---------------------------------------------------------------------------
9371107
# Annex D - File Management
9381108
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)