LinuxMonitor 1.5.6__tar.gz → 1.5.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: LinuxMonitor
3
- Version: 1.5.6
3
+ Version: 1.5.7
4
4
  Summary: Get information and warning status of Linux server like service, port, ping, ssl certificate, disk/folder/cpu/ram/swap usage, ip connection, ... (Python and shell library, Linux ONLY)
5
5
  Home-page: https://github.com/QuentinCG/Linux-Monitor-Python-Library
6
6
  Author: Quentin Comte-Gaz
@@ -25,6 +25,19 @@ License-File: LICENSE.md
25
25
  Requires-Dist: psutil
26
26
  Requires-Dist: typing
27
27
  Requires-Dist: asyncio
28
+ Dynamic: author
29
+ Dynamic: author-email
30
+ Dynamic: classifier
31
+ Dynamic: description
32
+ Dynamic: description-content-type
33
+ Dynamic: home-page
34
+ Dynamic: keywords
35
+ Dynamic: license
36
+ Dynamic: license-file
37
+ Dynamic: platform
38
+ Dynamic: requires-dist
39
+ Dynamic: requires-python
40
+ Dynamic: summary
28
41
 
29
42
  # Linux Monitor (Python library)
30
43
  [![PyPI version](https://badge.fury.io/py/LinuxMonitor.svg)](https://pypi.org/project/LinuxMonitor/) [![License: MIT](https://img.shields.io/badge/License-MIT-brightgreen.svg)](https://github.com/QuentinCG/Linux-Monitor-Python-Library/blob/master/LICENSE.md) [![Donate](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://paypal.me/QuentinCG) [![Downloads](https://static.pepy.tech/badge/LinuxMonitor)](https://pepy.tech/project/LinuxMonitor) [![Downloads](https://static.pepy.tech/badge/LinuxMonitor/month)](https://pepy.tech/project/LinuxMonitor)
@@ -162,10 +175,39 @@ python3 -m linuxmonitor --list_commands --config_file config-example.json --node
162
175
  # Execute a custom commands
163
176
  python3 -m linuxmonitor --execute_command CUSTOM_COMMAND_HERE --config_file config-example.json --nodebug
164
177
 
178
+ # Execute a custom command with parameters (only for commands with "accept_parameters": true or a {arg1}/{args} placeholder)
179
+ python3 -m linuxmonitor --execute_command unban_ip --parameters "1.2.3.4" --config_file config-example.json --nodebug
180
+
165
181
  # Execute all custom commands
166
182
  python3 -m linuxmonitor --execute_all_commands --config_file config-example.json --nodebug
167
183
  ```
168
184
 
185
+ ## Custom commands with parameters
186
+
187
+ A custom command (in the `commands` section of the config file) can receive parameters at execution time.
188
+ This is opt-in per command: set `"accept_parameters": true` and/or use a placeholder in the `command` string.
189
+
190
+ Placeholders inside the `command` string:
191
+ - `{arg1}`, `{arg2}`, ... : replaced by the Nth provided parameter
192
+ - `{args}` : replaced by all provided parameters
193
+ - no placeholder : all parameters are appended at the end of the command
194
+
195
+ Every parameter is shell-quoted before substitution, so it is always treated as a single literal
196
+ argument. This prevents command injection through the parameters.
197
+
198
+ Example config entry:
199
+ ```json
200
+ "unban_ip": {
201
+ "display_name": "Unban an IP from Fail2Ban",
202
+ "command": "sudo fail2ban-client unban {arg1}",
203
+ "is_private": true,
204
+ "accept_parameters": true,
205
+ "show_content_if_success": true,
206
+ "show_content_if_issue": true,
207
+ "timeout_in_sec": 60
208
+ }
209
+ ```
210
+
169
211
  ## How to use in python script
170
212
 
171
213
  Example of python script using this library:
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: LinuxMonitor
3
- Version: 1.5.6
3
+ Version: 1.5.7
4
4
  Summary: Get information and warning status of Linux server like service, port, ping, ssl certificate, disk/folder/cpu/ram/swap usage, ip connection, ... (Python and shell library, Linux ONLY)
5
5
  Home-page: https://github.com/QuentinCG/Linux-Monitor-Python-Library
6
6
  Author: Quentin Comte-Gaz
@@ -25,6 +25,19 @@ License-File: LICENSE.md
25
25
  Requires-Dist: psutil
26
26
  Requires-Dist: typing
27
27
  Requires-Dist: asyncio
28
+ Dynamic: author
29
+ Dynamic: author-email
30
+ Dynamic: classifier
31
+ Dynamic: description
32
+ Dynamic: description-content-type
33
+ Dynamic: home-page
34
+ Dynamic: keywords
35
+ Dynamic: license
36
+ Dynamic: license-file
37
+ Dynamic: platform
38
+ Dynamic: requires-dist
39
+ Dynamic: requires-python
40
+ Dynamic: summary
28
41
 
29
42
  # Linux Monitor (Python library)
30
43
  [![PyPI version](https://badge.fury.io/py/LinuxMonitor.svg)](https://pypi.org/project/LinuxMonitor/) [![License: MIT](https://img.shields.io/badge/License-MIT-brightgreen.svg)](https://github.com/QuentinCG/Linux-Monitor-Python-Library/blob/master/LICENSE.md) [![Donate](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://paypal.me/QuentinCG) [![Downloads](https://static.pepy.tech/badge/LinuxMonitor)](https://pepy.tech/project/LinuxMonitor) [![Downloads](https://static.pepy.tech/badge/LinuxMonitor/month)](https://pepy.tech/project/LinuxMonitor)
@@ -162,10 +175,39 @@ python3 -m linuxmonitor --list_commands --config_file config-example.json --node
162
175
  # Execute a custom commands
163
176
  python3 -m linuxmonitor --execute_command CUSTOM_COMMAND_HERE --config_file config-example.json --nodebug
164
177
 
178
+ # Execute a custom command with parameters (only for commands with "accept_parameters": true or a {arg1}/{args} placeholder)
179
+ python3 -m linuxmonitor --execute_command unban_ip --parameters "1.2.3.4" --config_file config-example.json --nodebug
180
+
165
181
  # Execute all custom commands
166
182
  python3 -m linuxmonitor --execute_all_commands --config_file config-example.json --nodebug
167
183
  ```
168
184
 
185
+ ## Custom commands with parameters
186
+
187
+ A custom command (in the `commands` section of the config file) can receive parameters at execution time.
188
+ This is opt-in per command: set `"accept_parameters": true` and/or use a placeholder in the `command` string.
189
+
190
+ Placeholders inside the `command` string:
191
+ - `{arg1}`, `{arg2}`, ... : replaced by the Nth provided parameter
192
+ - `{args}` : replaced by all provided parameters
193
+ - no placeholder : all parameters are appended at the end of the command
194
+
195
+ Every parameter is shell-quoted before substitution, so it is always treated as a single literal
196
+ argument. This prevents command injection through the parameters.
197
+
198
+ Example config entry:
199
+ ```json
200
+ "unban_ip": {
201
+ "display_name": "Unban an IP from Fail2Ban",
202
+ "command": "sudo fail2ban-client unban {arg1}",
203
+ "is_private": true,
204
+ "accept_parameters": true,
205
+ "show_content_if_success": true,
206
+ "show_content_if_issue": true,
207
+ "timeout_in_sec": 60
208
+ }
209
+ ```
210
+
169
211
  ## How to use in python script
170
212
 
171
213
  Example of python script using this library:
@@ -134,10 +134,39 @@ python3 -m linuxmonitor --list_commands --config_file config-example.json --node
134
134
  # Execute a custom commands
135
135
  python3 -m linuxmonitor --execute_command CUSTOM_COMMAND_HERE --config_file config-example.json --nodebug
136
136
 
137
+ # Execute a custom command with parameters (only for commands with "accept_parameters": true or a {arg1}/{args} placeholder)
138
+ python3 -m linuxmonitor --execute_command unban_ip --parameters "1.2.3.4" --config_file config-example.json --nodebug
139
+
137
140
  # Execute all custom commands
138
141
  python3 -m linuxmonitor --execute_all_commands --config_file config-example.json --nodebug
139
142
  ```
140
143
 
144
+ ## Custom commands with parameters
145
+
146
+ A custom command (in the `commands` section of the config file) can receive parameters at execution time.
147
+ This is opt-in per command: set `"accept_parameters": true` and/or use a placeholder in the `command` string.
148
+
149
+ Placeholders inside the `command` string:
150
+ - `{arg1}`, `{arg2}`, ... : replaced by the Nth provided parameter
151
+ - `{args}` : replaced by all provided parameters
152
+ - no placeholder : all parameters are appended at the end of the command
153
+
154
+ Every parameter is shell-quoted before substitution, so it is always treated as a single literal
155
+ argument. This prevents command injection through the parameters.
156
+
157
+ Example config entry:
158
+ ```json
159
+ "unban_ip": {
160
+ "display_name": "Unban an IP from Fail2Ban",
161
+ "command": "sudo fail2ban-client unban {arg1}",
162
+ "is_private": true,
163
+ "accept_parameters": true,
164
+ "show_content_if_success": true,
165
+ "show_content_if_issue": true,
166
+ "timeout_in_sec": 60
167
+ }
168
+ ```
169
+
141
170
  ## How to use in python script
142
171
 
143
172
  Example of python script using this library:
@@ -31,6 +31,7 @@ def main() -> None:
31
31
  parser.add_argument('--list_commands', action='store_true', help='📋 List all available commands 📋')
32
32
  parser.add_argument('execute_command', type=str, help='📋 Execute a command 📋')
33
33
  parser.add_argument('execute_all_commands', type=str, help='📋 Execute all commands 📋')
34
+ parser.add_argument('--parameters', type=str, default='', help='Optional parameters passed to --execute_command (space separated, quoted if needed)')
34
35
 
35
36
  parser.add_argument('--debug', action='store_true', help='Enable debug mode')
36
37
  parser.add_argument('--nodebug', action='store_true', help='Disable all logs')
@@ -163,7 +164,7 @@ def main() -> None:
163
164
  if args.execute_command is not None:
164
165
  handled = True
165
166
  print(f"Executing command: {args.execute_command}...")
166
- out_msg: str = asyncio.run(monitoring.execute_command(is_private=True, command_name=args.execute_command))
167
+ out_msg: str = asyncio.run(monitoring.execute_command(is_private=True, command_name=args.execute_command, parameters=args.parameters))
167
168
  print(out_msg)
168
169
 
169
170
  if args.execute_all_commands is not None:
@@ -33,7 +33,7 @@ __email__ = "quentin@comte-gaz.com"
33
33
  __license__ = "MIT License"
34
34
  __copyright__ = "Copyright Quentin Comte-Gaz (2024)"
35
35
  __python_version__ = "3.+"
36
- __version__ = "1.5.6 (2024/11/03)"
36
+ __version__ = "1.5.7 (2026/08/19)"
37
37
  __status__ = "Usable for any Linux project"
38
38
 
39
39
  import json
@@ -48,6 +48,7 @@ import socket
48
48
  from datetime import datetime, timedelta
49
49
  import platform
50
50
  import re
51
+ import shlex
51
52
  import logging
52
53
  import asyncio
53
54
  from http.client import responses
@@ -2020,40 +2021,166 @@ class LinuxMonitor:
2020
2021
  logging.exception(msg=out_msg)
2021
2022
  return out_msg
2022
2023
 
2023
- async def execute_command(self, is_private: bool, command_name: str) -> str:
2024
+ def _apply_command_parameters(self, command_template: str, parameters: str) -> str:
2025
+ """
2026
+ Substitute user-provided parameters into a command template in an injection-safe way.
2027
+
2028
+ Placeholders supported in the template:
2029
+ - {args} : replaced by all provided parameters (space separated)
2030
+ - {arg1}, {arg2} : replaced by the Nth provided parameter
2031
+ If the template contains no placeholder, all parameters are appended at the end.
2032
+
2033
+ Every parameter is shell-quoted (shlex.quote) so it is always treated as a single
2034
+ literal argument, which prevents command injection through the parameters.
2035
+
2036
+ :param command_template: The command as defined in the configuration file.
2037
+ :param parameters: The raw parameters string provided by the user.
2038
+
2039
+ :return: The final command ready to be executed.
2040
+ """
2041
+ param_list: List[str] = shlex.split(parameters) if parameters else []
2042
+ quoted_params: List[str] = [shlex.quote(param) for param in param_list]
2043
+
2044
+ has_placeholder: bool = ("{args}" in command_template) or bool(re.search(pattern=r"\{arg\d+\}", string=command_template))
2045
+
2046
+ # No placeholder: append every quoted parameter at the end of the command
2047
+ if not has_placeholder:
2048
+ if quoted_params:
2049
+ return command_template + " " + " ".join(quoted_params)
2050
+ return command_template
2051
+
2052
+ final_command: str = command_template
2053
+
2054
+ # Replace positional placeholders {arg1}, {arg2}, ...
2055
+ for index, quoted_param in enumerate(iterable=quoted_params, start=1):
2056
+ final_command = final_command.replace(f"{{arg{index}}}", quoted_param)
2057
+
2058
+ # Replace the catch-all {args} placeholder with every quoted parameter
2059
+ final_command = final_command.replace("{args}", " ".join(quoted_params))
2060
+
2061
+ # Any positional placeholder left unprovided is replaced by an empty string
2062
+ final_command = re.sub(pattern=r"\{arg\d+\}", repl="", string=final_command)
2063
+
2064
+ return final_command
2065
+
2066
+ def _normalize_command_name(self, name: str) -> str:
2067
+ """
2068
+ Normalize a command name for permissive matching: lowercase and ignore spaces, '-' and '_'.
2069
+ """
2070
+ return re.sub(pattern=r"[\s\-_]+", repl="", string=name.strip().lower())
2071
+
2072
+ def _levenshtein_distance(self, a: str, b: str) -> int:
2073
+ """
2074
+ Compute the Levenshtein (edit) distance between two strings (number of single-character
2075
+ insertions, deletions or substitutions needed to turn one into the other).
2076
+ """
2077
+ if a == b:
2078
+ return 0
2079
+ if len(a) == 0:
2080
+ return len(b)
2081
+ if len(b) == 0:
2082
+ return len(a)
2083
+
2084
+ previous_row: List[int] = list(range(len(b) + 1))
2085
+ for i, char_a in enumerate(iterable=a, start=1):
2086
+ current_row: List[int] = [i]
2087
+ for j, char_b in enumerate(iterable=b, start=1):
2088
+ insert_cost: int = current_row[j - 1] + 1
2089
+ delete_cost: int = previous_row[j] + 1
2090
+ replace_cost: int = previous_row[j - 1] + (0 if char_a == char_b else 1)
2091
+ current_row.append(min(insert_cost, delete_cost, replace_cost))
2092
+ previous_row = current_row
2093
+
2094
+ return previous_row[-1]
2095
+
2096
+ def _find_matching_command_key(self, command_name: str) -> Optional[str]:
2097
+ """
2098
+ Find the configured command key matching the requested name in a permissive way:
2099
+ exact match first, then case/'-'/'_'/space-insensitive match, then a small typo tolerance.
2100
+ Ambiguous matches (several equally good candidates) are rejected to avoid running the wrong command.
2101
+
2102
+ :param command_name: The command name requested by the user.
2103
+
2104
+ :return: The matching command key from the configuration, or None if no safe match is found.
2105
+ """
2106
+ command_keys: List[str] = list(self.config['commands'].keys())
2107
+
2108
+ # 1. Exact match
2109
+ if command_name in command_keys:
2110
+ return command_name
2111
+
2112
+ # 2. Normalized match (case-insensitive, '-'/'_'/spaces ignored)
2113
+ target: str = self._normalize_command_name(name=command_name)
2114
+ normalized_matches: List[str] = [key for key in command_keys if self._normalize_command_name(name=key) == target]
2115
+ if len(normalized_matches) == 1:
2116
+ return normalized_matches[0]
2117
+ if len(normalized_matches) > 1:
2118
+ # Several commands normalize to the same name: too ambiguous to guess
2119
+ return None
2120
+
2121
+ # 3. Fuzzy match tolerating small typos (on the normalized names)
2122
+ threshold: int = 1 if len(target) <= 4 else 2
2123
+ best_distance: Optional[int] = None
2124
+ best_keys: List[str] = []
2125
+ for key in command_keys:
2126
+ distance: int = self._levenshtein_distance(a=self._normalize_command_name(name=key), b=target)
2127
+ if best_distance is None or distance < best_distance:
2128
+ best_distance = distance
2129
+ best_keys = [key]
2130
+ elif distance == best_distance:
2131
+ best_keys.append(key)
2132
+
2133
+ # Only accept a typo-based match if it is close enough AND unambiguous
2134
+ if best_distance is not None and best_distance <= threshold and len(best_keys) == 1:
2135
+ return best_keys[0]
2136
+
2137
+ return None
2138
+
2139
+ async def execute_command(self, is_private: bool, command_name: str, parameters: str = "") -> str:
2024
2140
  try:
2025
2141
  out_msg: str = ""
2026
- display_name: str = command_name
2027
- for command_config_key in self.config['commands'].keys():
2028
- if command_config_key == command_name:
2029
- display_name = self.config['commands'][command_config_key]['display_name']
2030
- command: str = self.config['commands'][command_config_key]['command']
2031
- is_private_cmd: bool = self.config['commands'][command_config_key]['is_private']
2032
-
2033
- if is_private or is_private == is_private_cmd:
2034
- show_content_if_success: bool = self.config['commands'][command_config_key].get('show_content_if_success', False)
2035
- show_content_if_issue: bool = self.config['commands'][command_config_key].get('show_content_if_issue', False)
2036
- is_content_json: bool = self.config['commands'][command_config_key].get('is_content_json', False)
2037
- timeout_in_sec: int = self.config['commands'][command_config_key].get('timeout_in_sec', 10)
2038
-
2039
- res: str = await self._execute_command(command=command, display_name=display_name, show_content_if_success=show_content_if_success,
2040
- show_content_if_issue=show_content_if_issue, is_content_json=is_content_json,
2041
- timeout_in_sec=timeout_in_sec, display_only_if_critical=False)
2042
- if res != "":
2043
- if out_msg != "":
2044
- out_msg += "\n"
2045
- out_msg += f"{res}"
2046
- else:
2047
- out_msg = f"⚠️ **Command {display_name} is not allowed**"
2048
- logging.warning(msg=out_msg)
2049
2142
 
2050
- if out_msg != "":
2051
- out_msg = f"# 📜 Command {display_name} 📜\n{out_msg}"
2143
+ command_config_key: Optional[str] = self._find_matching_command_key(command_name=command_name)
2144
+ if command_config_key is None:
2145
+ out_msg = f"⚠️ **Command {command_name} not found**"
2146
+ logging.warning(msg=out_msg)
2147
+ return out_msg
2148
+
2149
+ display_name: str = self.config['commands'][command_config_key]['display_name']
2150
+ command: str = self.config['commands'][command_config_key]['command']
2151
+ is_private_cmd: bool = self.config['commands'][command_config_key]['is_private']
2152
+
2153
+ if is_private or is_private == is_private_cmd:
2154
+ parameters = parameters.strip()
2155
+ # A command accepts parameters only if it exposes a placeholder or explicitly opts in
2156
+ accept_parameters: bool = self.config['commands'][command_config_key].get('accept_parameters', False) \
2157
+ or ("{args}" in command) or bool(re.search(pattern=r"\{arg\d+\}", string=command))
2158
+
2159
+ if parameters != "" and not accept_parameters:
2160
+ out_msg = f"⚠️ **Command {display_name} does not accept parameters**"
2161
+ logging.warning(msg=out_msg)
2162
+ else:
2163
+ final_command: str = self._apply_command_parameters(command_template=command, parameters=parameters)
2052
2164
 
2053
- return out_msg
2165
+ show_content_if_success: bool = self.config['commands'][command_config_key].get('show_content_if_success', False)
2166
+ show_content_if_issue: bool = self.config['commands'][command_config_key].get('show_content_if_issue', False)
2167
+ is_content_json: bool = self.config['commands'][command_config_key].get('is_content_json', False)
2168
+ timeout_in_sec: int = self.config['commands'][command_config_key].get('timeout_in_sec', 10)
2169
+
2170
+ res: str = await self._execute_command(command=final_command, display_name=display_name, show_content_if_success=show_content_if_success,
2171
+ show_content_if_issue=show_content_if_issue, is_content_json=is_content_json,
2172
+ timeout_in_sec=timeout_in_sec, display_only_if_critical=False)
2173
+ if res != "":
2174
+ if out_msg != "":
2175
+ out_msg += "\n"
2176
+ out_msg += f"{res}"
2177
+ else:
2178
+ out_msg = f"⚠️ **Command {display_name} is not allowed**"
2179
+ logging.warning(msg=out_msg)
2180
+
2181
+ if out_msg != "":
2182
+ out_msg = f"# 📜 Command {display_name} 📜\n{out_msg}"
2054
2183
 
2055
- out_msg = f"⚠️ **Command {display_name} not found**"
2056
- logging.warning(msg=out_msg)
2057
2184
  return out_msg
2058
2185
  except Exception as e:
2059
2186
  return f"⚠️ **Error executing command {command_name}**:\n```sh\n{e}\n```"
@@ -6,7 +6,7 @@ with io.open(file='README.md', mode='r', encoding='utf-8') as readme_file:
6
6
 
7
7
  setup(
8
8
  name="LinuxMonitor",
9
- version="1.5.6",
9
+ version="1.5.7",
10
10
  description="Get information and warning status of Linux server like service, port, ping, ssl certificate, disk/folder/cpu/ram/swap usage, ip connection, ... (Python and shell library, Linux ONLY)",
11
11
  long_description=readme,
12
12
  long_description_content_type="text/markdown",
File without changes
File without changes