mmrelay 1.2.6__py3-none-any.whl
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.
- mmrelay/__init__.py +5 -0
- mmrelay/__main__.py +29 -0
- mmrelay/cli.py +2013 -0
- mmrelay/cli_utils.py +746 -0
- mmrelay/config.py +956 -0
- mmrelay/constants/__init__.py +65 -0
- mmrelay/constants/app.py +29 -0
- mmrelay/constants/config.py +78 -0
- mmrelay/constants/database.py +22 -0
- mmrelay/constants/formats.py +20 -0
- mmrelay/constants/messages.py +45 -0
- mmrelay/constants/network.py +45 -0
- mmrelay/constants/plugins.py +42 -0
- mmrelay/constants/queue.py +20 -0
- mmrelay/db_runtime.py +269 -0
- mmrelay/db_utils.py +1017 -0
- mmrelay/e2ee_utils.py +400 -0
- mmrelay/log_utils.py +274 -0
- mmrelay/main.py +439 -0
- mmrelay/matrix_utils.py +3091 -0
- mmrelay/meshtastic_utils.py +1245 -0
- mmrelay/message_queue.py +647 -0
- mmrelay/plugin_loader.py +1933 -0
- mmrelay/plugins/__init__.py +3 -0
- mmrelay/plugins/base_plugin.py +638 -0
- mmrelay/plugins/debug_plugin.py +30 -0
- mmrelay/plugins/drop_plugin.py +127 -0
- mmrelay/plugins/health_plugin.py +64 -0
- mmrelay/plugins/help_plugin.py +79 -0
- mmrelay/plugins/map_plugin.py +353 -0
- mmrelay/plugins/mesh_relay_plugin.py +222 -0
- mmrelay/plugins/nodes_plugin.py +92 -0
- mmrelay/plugins/ping_plugin.py +128 -0
- mmrelay/plugins/telemetry_plugin.py +179 -0
- mmrelay/plugins/weather_plugin.py +312 -0
- mmrelay/runtime_utils.py +35 -0
- mmrelay/setup_utils.py +828 -0
- mmrelay/tools/__init__.py +27 -0
- mmrelay/tools/mmrelay.service +19 -0
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
- mmrelay/tools/sample-docker-compose.yaml +30 -0
- mmrelay/tools/sample.env +10 -0
- mmrelay/tools/sample_config.yaml +120 -0
- mmrelay/windows_utils.py +346 -0
- mmrelay-1.2.6.dist-info/METADATA +145 -0
- mmrelay-1.2.6.dist-info/RECORD +50 -0
- mmrelay-1.2.6.dist-info/WHEEL +5 -0
- mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
- mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
- mmrelay-1.2.6.dist-info/top_level.txt +1 -0
mmrelay/cli.py
ADDED
|
@@ -0,0 +1,2013 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface handling for the Meshtastic Matrix Relay.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib.resources
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
# Import version from package
|
|
15
|
+
from mmrelay import __version__
|
|
16
|
+
from mmrelay.cli_utils import (
|
|
17
|
+
get_command,
|
|
18
|
+
get_deprecation_warning,
|
|
19
|
+
msg_for_e2ee_support,
|
|
20
|
+
msg_or_run_auth_login,
|
|
21
|
+
msg_run_auth_login,
|
|
22
|
+
msg_setup_auth,
|
|
23
|
+
msg_setup_authentication,
|
|
24
|
+
msg_suggest_generate_config,
|
|
25
|
+
)
|
|
26
|
+
from mmrelay.config import (
|
|
27
|
+
get_config_paths,
|
|
28
|
+
set_secure_file_permissions,
|
|
29
|
+
validate_yaml_syntax,
|
|
30
|
+
)
|
|
31
|
+
from mmrelay.constants.app import WINDOWS_PLATFORM
|
|
32
|
+
from mmrelay.constants.config import (
|
|
33
|
+
CONFIG_KEY_ACCESS_TOKEN,
|
|
34
|
+
CONFIG_KEY_BOT_USER_ID,
|
|
35
|
+
CONFIG_KEY_HOMESERVER,
|
|
36
|
+
CONFIG_SECTION_MATRIX,
|
|
37
|
+
CONFIG_SECTION_MESHTASTIC,
|
|
38
|
+
)
|
|
39
|
+
from mmrelay.constants.network import (
|
|
40
|
+
CONFIG_KEY_BLE_ADDRESS,
|
|
41
|
+
CONFIG_KEY_CONNECTION_TYPE,
|
|
42
|
+
CONFIG_KEY_HOST,
|
|
43
|
+
CONFIG_KEY_SERIAL_PORT,
|
|
44
|
+
CONNECTION_TYPE_BLE,
|
|
45
|
+
CONNECTION_TYPE_NETWORK,
|
|
46
|
+
CONNECTION_TYPE_SERIAL,
|
|
47
|
+
CONNECTION_TYPE_TCP,
|
|
48
|
+
)
|
|
49
|
+
from mmrelay.tools import get_sample_config_path
|
|
50
|
+
|
|
51
|
+
# =============================================================================
|
|
52
|
+
# CLI Argument Parsing and Command Handling
|
|
53
|
+
# =============================================================================
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_arguments():
|
|
57
|
+
"""
|
|
58
|
+
Parse command-line arguments for the Meshtastic Matrix Relay CLI.
|
|
59
|
+
|
|
60
|
+
Builds a modern grouped CLI with subcommands for config (generate, check), auth (login, status),
|
|
61
|
+
and service (install), while preserving hidden legacy flags (--generate-config, --install-service,
|
|
62
|
+
--check-config, --auth) for backward compatibility. Supports global options: --config,
|
|
63
|
+
--data-dir, --log-level, --logfile, and --version.
|
|
64
|
+
|
|
65
|
+
Unknown arguments are ignored when running outside of test environments (parsed via
|
|
66
|
+
parse_known_args); a warning is printed if unknown args are present and the process does not
|
|
67
|
+
appear to be a test run.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
argparse.Namespace: The parsed arguments namespace.
|
|
71
|
+
"""
|
|
72
|
+
parser = argparse.ArgumentParser(
|
|
73
|
+
description="Meshtastic Matrix Relay - Bridge between Meshtastic and Matrix"
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument("--config", help="Path to config file", default=None)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--data-dir",
|
|
78
|
+
help="Base directory for all data (logs, database, plugins)",
|
|
79
|
+
default=None,
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--log-level",
|
|
83
|
+
choices=["error", "warning", "info", "debug"],
|
|
84
|
+
help="Set logging level",
|
|
85
|
+
default=None,
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--logfile",
|
|
89
|
+
help="Path to log file (can be overridden by --data-dir)",
|
|
90
|
+
default=None,
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument("--version", action="store_true", help="Show version and exit")
|
|
93
|
+
# Deprecated flags (hidden from help but still functional)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--generate-config",
|
|
96
|
+
action="store_true",
|
|
97
|
+
help=argparse.SUPPRESS,
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--install-service",
|
|
101
|
+
action="store_true",
|
|
102
|
+
help=argparse.SUPPRESS,
|
|
103
|
+
)
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--check-config",
|
|
106
|
+
action="store_true",
|
|
107
|
+
help=argparse.SUPPRESS,
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--auth",
|
|
111
|
+
action="store_true",
|
|
112
|
+
help=argparse.SUPPRESS,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Add grouped subcommands for modern CLI interface
|
|
116
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
117
|
+
|
|
118
|
+
# CONFIG group
|
|
119
|
+
config_parser = subparsers.add_parser(
|
|
120
|
+
"config",
|
|
121
|
+
help="Configuration management",
|
|
122
|
+
description="Manage configuration files and validation",
|
|
123
|
+
)
|
|
124
|
+
config_subparsers = config_parser.add_subparsers(
|
|
125
|
+
dest="config_command", help="Config commands", required=True
|
|
126
|
+
)
|
|
127
|
+
config_subparsers.add_parser(
|
|
128
|
+
"generate",
|
|
129
|
+
help="Create sample config.yaml file",
|
|
130
|
+
description="Generate a sample configuration file with default settings",
|
|
131
|
+
)
|
|
132
|
+
config_subparsers.add_parser(
|
|
133
|
+
"check",
|
|
134
|
+
help="Validate configuration file",
|
|
135
|
+
description="Check configuration file syntax and completeness",
|
|
136
|
+
)
|
|
137
|
+
config_subparsers.add_parser(
|
|
138
|
+
"diagnose",
|
|
139
|
+
help="Diagnose configuration system issues",
|
|
140
|
+
description="Test config generation capabilities and troubleshoot platform-specific issues",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# AUTH group
|
|
144
|
+
auth_parser = subparsers.add_parser(
|
|
145
|
+
"auth",
|
|
146
|
+
help="Authentication management",
|
|
147
|
+
description="Manage Matrix authentication and credentials",
|
|
148
|
+
)
|
|
149
|
+
auth_subparsers = auth_parser.add_subparsers(
|
|
150
|
+
dest="auth_command", help="Auth commands"
|
|
151
|
+
)
|
|
152
|
+
login_parser = auth_subparsers.add_parser(
|
|
153
|
+
"login",
|
|
154
|
+
help="Authenticate with Matrix",
|
|
155
|
+
description="Set up Matrix authentication for E2EE support",
|
|
156
|
+
)
|
|
157
|
+
login_parser.add_argument(
|
|
158
|
+
"--homeserver",
|
|
159
|
+
help="Matrix homeserver URL (e.g., https://matrix.org). If provided, --username and --password are also required.",
|
|
160
|
+
)
|
|
161
|
+
login_parser.add_argument(
|
|
162
|
+
"--username",
|
|
163
|
+
help="Matrix username (with or without @ and :server). If provided, --homeserver and --password are also required.",
|
|
164
|
+
)
|
|
165
|
+
login_parser.add_argument(
|
|
166
|
+
"--password",
|
|
167
|
+
metavar="PWD",
|
|
168
|
+
help="Matrix password (can be empty). If provided, --homeserver and --username are also required. For security, prefer interactive mode.",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
auth_subparsers.add_parser(
|
|
172
|
+
"status",
|
|
173
|
+
help="Check authentication status",
|
|
174
|
+
description="Display current Matrix authentication status",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
logout_parser = auth_subparsers.add_parser(
|
|
178
|
+
"logout",
|
|
179
|
+
help="Log out and clear all sessions",
|
|
180
|
+
description="Clear all Matrix authentication data and E2EE store",
|
|
181
|
+
)
|
|
182
|
+
logout_parser.add_argument(
|
|
183
|
+
"--password",
|
|
184
|
+
nargs="?",
|
|
185
|
+
const="",
|
|
186
|
+
help="Password for verification. If no value provided, will prompt securely.",
|
|
187
|
+
type=str,
|
|
188
|
+
)
|
|
189
|
+
logout_parser.add_argument(
|
|
190
|
+
"-y",
|
|
191
|
+
"--yes",
|
|
192
|
+
action="store_true",
|
|
193
|
+
help="Do not prompt for confirmation (useful for non-interactive environments)",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# SERVICE group
|
|
197
|
+
service_parser = subparsers.add_parser(
|
|
198
|
+
"service",
|
|
199
|
+
help="Service management",
|
|
200
|
+
description="Manage systemd user service for MMRelay",
|
|
201
|
+
)
|
|
202
|
+
service_subparsers = service_parser.add_subparsers(
|
|
203
|
+
dest="service_command", help="Service commands", required=True
|
|
204
|
+
)
|
|
205
|
+
service_subparsers.add_parser(
|
|
206
|
+
"install",
|
|
207
|
+
help="Install systemd user service",
|
|
208
|
+
description="Install or update the systemd user service for MMRelay",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Use parse_known_args to handle unknown arguments gracefully (e.g., pytest args)
|
|
212
|
+
args, unknown = parser.parse_known_args()
|
|
213
|
+
# If there are unknown arguments and we're not in a test invocation, warn about them
|
|
214
|
+
# Heuristic: suppress warning when pytest appears in argv (unit tests may pass extra args)
|
|
215
|
+
if unknown and not any("pytest" in arg or "py.test" in arg for arg in sys.argv):
|
|
216
|
+
print(f"Warning: Unknown arguments ignored: {unknown}", file=sys.stderr)
|
|
217
|
+
|
|
218
|
+
return args
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def get_version():
|
|
222
|
+
"""
|
|
223
|
+
Returns the current version of the application.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
str: The version string
|
|
227
|
+
"""
|
|
228
|
+
return __version__
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def print_version():
|
|
232
|
+
"""
|
|
233
|
+
Print the version in a simple format.
|
|
234
|
+
"""
|
|
235
|
+
print(f"MMRelay version {__version__}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _validate_e2ee_dependencies():
|
|
239
|
+
"""
|
|
240
|
+
Check whether end-to-end encryption (E2EE) is usable on the current platform.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
bool: True if the platform is supported and required E2EE libraries can be imported;
|
|
244
|
+
False otherwise.
|
|
245
|
+
|
|
246
|
+
Notes:
|
|
247
|
+
- This function performs only local checks (platform and importability) and does not perform
|
|
248
|
+
network I/O.
|
|
249
|
+
- It emits user-facing messages to indicate missing platform support or missing dependencies.
|
|
250
|
+
"""
|
|
251
|
+
if sys.platform == WINDOWS_PLATFORM:
|
|
252
|
+
print("❌ Error: E2EE is not supported on Windows")
|
|
253
|
+
print(" Reason: python-olm library requires native C libraries")
|
|
254
|
+
print(" Solution: Use Linux or macOS for E2EE support")
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
# Check if E2EE dependencies are available
|
|
258
|
+
try:
|
|
259
|
+
import olm # noqa: F401
|
|
260
|
+
from nio.crypto import OlmDevice # noqa: F401
|
|
261
|
+
from nio.store import SqliteStore # noqa: F401
|
|
262
|
+
|
|
263
|
+
print("✅ E2EE dependencies are installed")
|
|
264
|
+
return True
|
|
265
|
+
except ImportError:
|
|
266
|
+
print("❌ Error: E2EE dependencies not installed")
|
|
267
|
+
print(" End-to-end encryption features require additional dependencies")
|
|
268
|
+
print(" Install E2EE support: pipx install 'mmrelay[e2e]'")
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _validate_credentials_json(config_path):
|
|
273
|
+
"""
|
|
274
|
+
Validate that a credentials.json file exists (adjacent to config_path or in the base directory) and contains the required Matrix session fields.
|
|
275
|
+
|
|
276
|
+
Checks for a credentials.json via _find_credentials_json_path(config_path). If found, the file is parsed as JSON and must include non-empty string values for the keys "homeserver", "access_token", "user_id", and "device_id". On validation failure the function prints a brief error and guidance to run the auth login flow.
|
|
277
|
+
|
|
278
|
+
Parameters:
|
|
279
|
+
config_path (str): Path to the configuration file used to determine the primary search directory for credentials.json.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
bool: True if a credentials.json was found and contains all required non-empty fields; False otherwise.
|
|
283
|
+
"""
|
|
284
|
+
try:
|
|
285
|
+
import json
|
|
286
|
+
|
|
287
|
+
# Look for credentials.json using helper function
|
|
288
|
+
credentials_path = _find_credentials_json_path(config_path)
|
|
289
|
+
if not credentials_path:
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
# Load and validate credentials
|
|
293
|
+
with open(credentials_path, "r", encoding="utf-8") as f:
|
|
294
|
+
credentials = json.load(f)
|
|
295
|
+
|
|
296
|
+
# Check for required fields
|
|
297
|
+
required_fields = ["homeserver", "access_token", "user_id", "device_id"]
|
|
298
|
+
missing_fields = [
|
|
299
|
+
field
|
|
300
|
+
for field in required_fields
|
|
301
|
+
if not _is_valid_non_empty_string((credentials or {}).get(field))
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
if missing_fields:
|
|
305
|
+
print(
|
|
306
|
+
f"❌ Error: credentials.json missing required fields: {', '.join(missing_fields)}"
|
|
307
|
+
)
|
|
308
|
+
print(f" {msg_run_auth_login()}")
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
return True
|
|
312
|
+
except Exception as e:
|
|
313
|
+
print(f"❌ Error: Could not validate credentials.json: {e}")
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _is_valid_non_empty_string(value) -> bool:
|
|
318
|
+
"""
|
|
319
|
+
Return True if value is a string containing non-whitespace characters.
|
|
320
|
+
|
|
321
|
+
Checks that the input is an instance of `str` and that stripping whitespace
|
|
322
|
+
does not produce an empty string.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
bool: True when value is a non-empty, non-whitespace-only string; otherwise False.
|
|
326
|
+
"""
|
|
327
|
+
return isinstance(value, str) and value.strip() != ""
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _has_valid_password_auth(matrix_section):
|
|
331
|
+
"""
|
|
332
|
+
Return True if the given Matrix config section contains valid password-based authentication settings.
|
|
333
|
+
|
|
334
|
+
The function expects matrix_section to be a dict-like mapping from configuration keys to values.
|
|
335
|
+
It validates that:
|
|
336
|
+
- `homeserver` and `bot_user_id` are present and are non-empty strings (after trimming),
|
|
337
|
+
- `password` is present and is a string (it may be an empty string, which is accepted).
|
|
338
|
+
|
|
339
|
+
If matrix_section is not a dict, the function returns False.
|
|
340
|
+
|
|
341
|
+
Parameters:
|
|
342
|
+
matrix_section: dict-like Matrix configuration section (may be the parsed "matrix" config).
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
bool: True when password-based authentication is correctly configured as described above; otherwise False.
|
|
346
|
+
"""
|
|
347
|
+
if not isinstance(matrix_section, Mapping):
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
pwd = matrix_section.get("password")
|
|
351
|
+
homeserver = matrix_section.get(CONFIG_KEY_HOMESERVER)
|
|
352
|
+
bot_user_id = matrix_section.get(CONFIG_KEY_BOT_USER_ID)
|
|
353
|
+
|
|
354
|
+
# Allow empty password strings (some environments legitimately use empty passwords).
|
|
355
|
+
# Homeserver and bot_user_id must still be valid non-empty strings.
|
|
356
|
+
return (
|
|
357
|
+
isinstance(pwd, str)
|
|
358
|
+
and _is_valid_non_empty_string(homeserver)
|
|
359
|
+
and _is_valid_non_empty_string(bot_user_id)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _validate_matrix_authentication(config_path, matrix_section):
|
|
364
|
+
"""
|
|
365
|
+
Determine whether Matrix authentication is configured and usable.
|
|
366
|
+
|
|
367
|
+
Checks for a valid credentials.json (located relative to the provided config path) and, if not present,
|
|
368
|
+
falls back to an access_token in the provided matrix_section. Returns True when authentication
|
|
369
|
+
information is found and usable; returns False when no authentication is configured.
|
|
370
|
+
|
|
371
|
+
Parameters:
|
|
372
|
+
config_path (str | os.PathLike): Path to the application's YAML config file; used to locate a
|
|
373
|
+
credentials.json candidate in the same directory or standard locations.
|
|
374
|
+
matrix_section (Mapping | None): The parsed "matrix" configuration section (mapping-like). If
|
|
375
|
+
provided, an "access_token" key will be considered as a valid fallback when credentials.json
|
|
376
|
+
is absent.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
bool: True if a valid authentication method (credentials.json or access_token) is available,
|
|
380
|
+
False otherwise.
|
|
381
|
+
|
|
382
|
+
Notes:
|
|
383
|
+
- The function prefers credentials.json over an access_token if both are present.
|
|
384
|
+
- The function emits user-facing status messages describing which authentication source is used
|
|
385
|
+
and whether E2EE support is available.
|
|
386
|
+
"""
|
|
387
|
+
has_valid_credentials = _validate_credentials_json(config_path)
|
|
388
|
+
token = (matrix_section or {}).get(CONFIG_KEY_ACCESS_TOKEN)
|
|
389
|
+
has_access_token = _is_valid_non_empty_string(token)
|
|
390
|
+
|
|
391
|
+
has_password = _has_valid_password_auth(matrix_section)
|
|
392
|
+
|
|
393
|
+
if has_valid_credentials:
|
|
394
|
+
print("✅ Using credentials.json for Matrix authentication")
|
|
395
|
+
if sys.platform != WINDOWS_PLATFORM:
|
|
396
|
+
print(" E2EE support available (if enabled)")
|
|
397
|
+
return True
|
|
398
|
+
|
|
399
|
+
elif has_password:
|
|
400
|
+
print(
|
|
401
|
+
"✅ Using password in config for initial authentication (credentials.json will be created on first run)"
|
|
402
|
+
)
|
|
403
|
+
print(f" {msg_for_e2ee_support()}")
|
|
404
|
+
return True
|
|
405
|
+
elif has_access_token:
|
|
406
|
+
print(
|
|
407
|
+
"✅ Using access_token for Matrix authentication (deprecated — consider 'mmrelay auth login' to create credentials.json)"
|
|
408
|
+
)
|
|
409
|
+
print(f" {msg_for_e2ee_support()}")
|
|
410
|
+
return True
|
|
411
|
+
|
|
412
|
+
else:
|
|
413
|
+
print("❌ Error: No Matrix authentication configured")
|
|
414
|
+
print(f" {msg_setup_auth()}")
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _validate_e2ee_config(config, matrix_section, config_path):
|
|
419
|
+
"""
|
|
420
|
+
Validate end-to-end encryption (E2EE) configuration and Matrix authentication for the given config.
|
|
421
|
+
|
|
422
|
+
Performs these checks:
|
|
423
|
+
- Confirms Matrix authentication is available (via credentials.json or matrix access token); returns False if authentication is missing or invalid.
|
|
424
|
+
- If no matrix section is present, treats E2EE as not configured and returns True.
|
|
425
|
+
- If E2EE/encryption is enabled in the matrix config, verifies platform/dependency support and inspects the configured store path. If the store directory does not yet exist, a note is printed indicating it will be created.
|
|
426
|
+
|
|
427
|
+
Parameters:
|
|
428
|
+
config_path (str): Path to the active configuration file (used to locate credentials.json and related auth artifacts).
|
|
429
|
+
matrix_section (dict | None): The "matrix" subsection of the parsed config (may be None or empty).
|
|
430
|
+
config (dict): Full parsed configuration (unused for most checks but kept for consistency with caller signature).
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
bool: True if configuration and required authentication/dependencies are valid (or E2EE is not configured); False if validation fails.
|
|
434
|
+
|
|
435
|
+
Side effects:
|
|
436
|
+
Prints informational or error messages about authentication, dependency checks, and E2EE store path status.
|
|
437
|
+
"""
|
|
438
|
+
# First validate authentication
|
|
439
|
+
if not _validate_matrix_authentication(config_path, matrix_section):
|
|
440
|
+
return False
|
|
441
|
+
|
|
442
|
+
# Check for E2EE configuration
|
|
443
|
+
if not matrix_section:
|
|
444
|
+
return True # No matrix section means no E2EE config to validate
|
|
445
|
+
|
|
446
|
+
e2ee_config = matrix_section.get("e2ee", {})
|
|
447
|
+
encryption_config = matrix_section.get("encryption", {}) # Legacy support
|
|
448
|
+
|
|
449
|
+
e2ee_enabled = e2ee_config.get("enabled", False) or encryption_config.get(
|
|
450
|
+
"enabled", False
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
if e2ee_enabled:
|
|
454
|
+
# Platform and dependency check
|
|
455
|
+
if not _validate_e2ee_dependencies():
|
|
456
|
+
return False
|
|
457
|
+
|
|
458
|
+
# Store path validation
|
|
459
|
+
store_path = e2ee_config.get("store_path") or encryption_config.get(
|
|
460
|
+
"store_path"
|
|
461
|
+
)
|
|
462
|
+
if store_path:
|
|
463
|
+
expanded_path = os.path.expanduser(store_path)
|
|
464
|
+
if not os.path.exists(expanded_path):
|
|
465
|
+
print(f"ℹ️ Note: E2EE store directory will be created: {expanded_path}")
|
|
466
|
+
|
|
467
|
+
print("✅ E2EE configuration is valid")
|
|
468
|
+
|
|
469
|
+
return True
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _analyze_e2ee_setup(config, config_path):
|
|
473
|
+
"""
|
|
474
|
+
Analyze local E2EE readiness without contacting Matrix.
|
|
475
|
+
|
|
476
|
+
Performs an offline inspection of the environment and configuration to determine
|
|
477
|
+
whether end-to-end encryption (E2EE) can be used. Checks platform support
|
|
478
|
+
(Windows is considered unsupported), presence of required Python dependencies
|
|
479
|
+
(olm and selected nio components), whether E2EE is enabled in the provided
|
|
480
|
+
config, and whether a credentials.json is available adjacent to the supplied
|
|
481
|
+
config_path or in the standard base directory.
|
|
482
|
+
|
|
483
|
+
Parameters:
|
|
484
|
+
config (dict): Parsed configuration (typically from config.yaml). Only the
|
|
485
|
+
"matrix" section is consulted to detect E2EE/encryption enablement.
|
|
486
|
+
config_path (str): Path to the configuration file used to locate a
|
|
487
|
+
credentials.json sibling; also used to resolve an alternate standard
|
|
488
|
+
credentials location.
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
dict: Analysis summary with these keys:
|
|
492
|
+
- config_enabled (bool): True if E2EE/encryption is enabled in config.
|
|
493
|
+
- dependencies_available (bool): True if required E2EE packages are
|
|
494
|
+
importable.
|
|
495
|
+
- credentials_available (bool): True if a usable credentials.json was
|
|
496
|
+
found.
|
|
497
|
+
- platform_supported (bool): False on unsupported platforms (Windows).
|
|
498
|
+
- overall_status (str): One of "ready", "disabled", "not_supported",
|
|
499
|
+
"incomplete", or "unknown" describing the combined readiness.
|
|
500
|
+
- recommendations (list): Human-actionable strings suggesting fixes or
|
|
501
|
+
next steps (e.g., enable E2EE in config, install dependencies, run
|
|
502
|
+
auth login).
|
|
503
|
+
"""
|
|
504
|
+
analysis = {
|
|
505
|
+
"config_enabled": False,
|
|
506
|
+
"dependencies_available": False,
|
|
507
|
+
"credentials_available": False,
|
|
508
|
+
"platform_supported": True,
|
|
509
|
+
"overall_status": "unknown",
|
|
510
|
+
"recommendations": [],
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
# Check platform support
|
|
514
|
+
if sys.platform == WINDOWS_PLATFORM:
|
|
515
|
+
analysis["platform_supported"] = False
|
|
516
|
+
analysis["recommendations"].append(
|
|
517
|
+
"E2EE is not supported on Windows. Use Linux/macOS for E2EE support."
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# Check dependencies
|
|
521
|
+
try:
|
|
522
|
+
import olm # noqa: F401
|
|
523
|
+
from nio.crypto import OlmDevice # noqa: F401
|
|
524
|
+
from nio.store import SqliteStore # noqa: F401
|
|
525
|
+
|
|
526
|
+
analysis["dependencies_available"] = True
|
|
527
|
+
except ImportError:
|
|
528
|
+
analysis["dependencies_available"] = False
|
|
529
|
+
analysis["recommendations"].append(
|
|
530
|
+
"Install E2EE dependencies: pipx install 'mmrelay[e2e]'"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# Check config setting
|
|
534
|
+
matrix_section = config.get("matrix", {})
|
|
535
|
+
e2ee_config = matrix_section.get("e2ee", {})
|
|
536
|
+
encryption_config = matrix_section.get("encryption", {}) # Legacy support
|
|
537
|
+
analysis["config_enabled"] = e2ee_config.get(
|
|
538
|
+
"enabled", False
|
|
539
|
+
) or encryption_config.get("enabled", False)
|
|
540
|
+
|
|
541
|
+
if not analysis["config_enabled"]:
|
|
542
|
+
analysis["recommendations"].append(
|
|
543
|
+
"Enable E2EE in config.yaml under matrix section: e2ee: enabled: true"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Check credentials file existence
|
|
547
|
+
credentials_path = _find_credentials_json_path(config_path)
|
|
548
|
+
analysis["credentials_available"] = bool(credentials_path)
|
|
549
|
+
|
|
550
|
+
if not analysis["credentials_available"]:
|
|
551
|
+
analysis["recommendations"].append(
|
|
552
|
+
"Set up Matrix authentication: mmrelay auth login"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Determine overall status based on setup only
|
|
556
|
+
if not analysis["platform_supported"]:
|
|
557
|
+
analysis["overall_status"] = "not_supported"
|
|
558
|
+
elif (
|
|
559
|
+
analysis["config_enabled"]
|
|
560
|
+
and analysis["dependencies_available"]
|
|
561
|
+
and analysis["credentials_available"]
|
|
562
|
+
):
|
|
563
|
+
analysis["overall_status"] = "ready"
|
|
564
|
+
elif not analysis["config_enabled"]:
|
|
565
|
+
analysis["overall_status"] = "disabled"
|
|
566
|
+
else:
|
|
567
|
+
analysis["overall_status"] = "incomplete"
|
|
568
|
+
|
|
569
|
+
return analysis
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _find_credentials_json_path(config_path: str | None) -> str | None:
|
|
573
|
+
"""
|
|
574
|
+
Return the filesystem path to a credentials.json file if one can be found, otherwise None.
|
|
575
|
+
|
|
576
|
+
Search order:
|
|
577
|
+
1. A credentials.json file located in the same directory as the provided config_path.
|
|
578
|
+
2. A credentials.json file in the application's base directory (get_base_dir()).
|
|
579
|
+
|
|
580
|
+
Parameters:
|
|
581
|
+
config_path (str | None): Path to the configuration file used to derive the adjacent credentials.json location.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
str | None: Absolute path to the discovered credentials.json, or None if no file is found.
|
|
585
|
+
"""
|
|
586
|
+
if not config_path:
|
|
587
|
+
from mmrelay.config import get_base_dir
|
|
588
|
+
|
|
589
|
+
standard = os.path.join(get_base_dir(), "credentials.json")
|
|
590
|
+
return standard if os.path.exists(standard) else None
|
|
591
|
+
|
|
592
|
+
config_dir = os.path.dirname(config_path)
|
|
593
|
+
candidate = os.path.join(config_dir, "credentials.json")
|
|
594
|
+
if os.path.exists(candidate):
|
|
595
|
+
return candidate
|
|
596
|
+
from mmrelay.config import get_base_dir
|
|
597
|
+
|
|
598
|
+
standard = os.path.join(get_base_dir(), "credentials.json")
|
|
599
|
+
return standard if os.path.exists(standard) else None
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _print_unified_e2ee_analysis(e2ee_status):
|
|
603
|
+
"""
|
|
604
|
+
Print a concise, user-facing analysis of E2EE readiness.
|
|
605
|
+
|
|
606
|
+
Given a status dictionary produced by the E2EE analysis routines, prints platform support,
|
|
607
|
+
dependency availability, whether E2EE is enabled in configuration, whether credentials.json
|
|
608
|
+
is available, and the overall status. If the overall status is not "ready", prints actionable
|
|
609
|
+
fix instructions.
|
|
610
|
+
|
|
611
|
+
Parameters:
|
|
612
|
+
e2ee_status (dict): Status dictionary with (at least) the following keys:
|
|
613
|
+
- platform_supported (bool): whether the current OS/platform supports E2EE.
|
|
614
|
+
- dependencies_installed or dependencies_available (bool): whether required E2EE
|
|
615
|
+
Python packages and runtime dependencies are present.
|
|
616
|
+
- enabled or config_enabled (bool): whether E2EE is enabled in the configuration.
|
|
617
|
+
- credentials_available (bool): whether a usable credentials.json is present.
|
|
618
|
+
- overall_status (str): high-level status ("ready", "disabled", "incomplete", etc.).
|
|
619
|
+
"""
|
|
620
|
+
print("\n🔐 E2EE Configuration Analysis:")
|
|
621
|
+
|
|
622
|
+
# Platform support
|
|
623
|
+
if e2ee_status.get("platform_supported", True):
|
|
624
|
+
print("✅ Platform: E2EE supported")
|
|
625
|
+
else:
|
|
626
|
+
print("❌ Platform: E2EE not supported on Windows")
|
|
627
|
+
|
|
628
|
+
# Dependencies
|
|
629
|
+
if e2ee_status.get(
|
|
630
|
+
"dependencies_installed", e2ee_status.get("dependencies_available", False)
|
|
631
|
+
):
|
|
632
|
+
print("✅ Dependencies: E2EE dependencies installed")
|
|
633
|
+
else:
|
|
634
|
+
print("❌ Dependencies: E2EE dependencies not fully installed")
|
|
635
|
+
|
|
636
|
+
# Configuration
|
|
637
|
+
if e2ee_status.get("enabled", e2ee_status.get("config_enabled", False)):
|
|
638
|
+
print("✅ Configuration: E2EE enabled")
|
|
639
|
+
else:
|
|
640
|
+
print("❌ Configuration: E2EE disabled")
|
|
641
|
+
|
|
642
|
+
# Authentication
|
|
643
|
+
if e2ee_status.get("credentials_available", False):
|
|
644
|
+
print("✅ Authentication: credentials.json found")
|
|
645
|
+
else:
|
|
646
|
+
print("❌ Authentication: credentials.json not found")
|
|
647
|
+
|
|
648
|
+
# Overall status
|
|
649
|
+
print(
|
|
650
|
+
f"\n📊 Overall Status: {e2ee_status.get('overall_status', 'unknown').upper()}"
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
# Show fix instructions if needed
|
|
654
|
+
if e2ee_status.get("overall_status") != "ready":
|
|
655
|
+
from mmrelay.e2ee_utils import get_e2ee_fix_instructions
|
|
656
|
+
|
|
657
|
+
instructions = get_e2ee_fix_instructions(e2ee_status)
|
|
658
|
+
print("\n🔧 To fix E2EE issues:")
|
|
659
|
+
for instruction in instructions:
|
|
660
|
+
print(f" {instruction}")
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _print_e2ee_analysis(analysis):
|
|
664
|
+
"""
|
|
665
|
+
Print a user-facing analysis of end-to-end encryption (E2EE) readiness to standard output.
|
|
666
|
+
|
|
667
|
+
Parameters:
|
|
668
|
+
analysis (dict): Analysis results with the following keys:
|
|
669
|
+
- dependencies_available (bool): True if required E2EE dependencies (e.g., python-olm) are present.
|
|
670
|
+
- credentials_available (bool): True if a usable credentials.json was found.
|
|
671
|
+
- platform_supported (bool): True if the current platform supports E2EE (Windows is considered unsupported).
|
|
672
|
+
- config_enabled (bool): True if E2EE is enabled in the configuration.
|
|
673
|
+
- overall_status (str): One of "ready", "disabled", "not_supported", or "incomplete" indicating the aggregated readiness.
|
|
674
|
+
- recommendations (list[str]): User-facing remediation steps or suggestions (may be empty).
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
None
|
|
678
|
+
|
|
679
|
+
Notes:
|
|
680
|
+
- This function only prints a human-readable report and does not modify state.
|
|
681
|
+
"""
|
|
682
|
+
print("\n🔐 E2EE Configuration Analysis:")
|
|
683
|
+
|
|
684
|
+
# Current settings
|
|
685
|
+
print("\n📋 Current Settings:")
|
|
686
|
+
|
|
687
|
+
# Dependencies
|
|
688
|
+
if analysis["dependencies_available"]:
|
|
689
|
+
print(" ✅ Dependencies: Installed (python-olm available)")
|
|
690
|
+
else:
|
|
691
|
+
print(" ❌ Dependencies: Missing (python-olm not installed)")
|
|
692
|
+
|
|
693
|
+
# Credentials
|
|
694
|
+
if analysis["credentials_available"]:
|
|
695
|
+
print(" ✅ Authentication: Ready (credentials.json found)")
|
|
696
|
+
else:
|
|
697
|
+
print(" ❌ Authentication: Missing (no credentials.json)")
|
|
698
|
+
|
|
699
|
+
# Platform
|
|
700
|
+
if not analysis["platform_supported"]:
|
|
701
|
+
print(" ❌ Platform: Windows (E2EE not supported)")
|
|
702
|
+
else:
|
|
703
|
+
print(" ✅ Platform: Supported")
|
|
704
|
+
|
|
705
|
+
# Config setting
|
|
706
|
+
if analysis["config_enabled"]:
|
|
707
|
+
print(" ✅ Configuration: ENABLED (e2ee.enabled: true)")
|
|
708
|
+
else:
|
|
709
|
+
print(" ❌ Configuration: DISABLED (e2ee.enabled: false)")
|
|
710
|
+
|
|
711
|
+
# Predicted behavior
|
|
712
|
+
print("\n🚨 PREDICTED BEHAVIOR:")
|
|
713
|
+
if analysis["overall_status"] == "ready":
|
|
714
|
+
print(" ✅ E2EE is fully configured and ready")
|
|
715
|
+
print(" ✅ Encrypted rooms will receive encrypted messages")
|
|
716
|
+
print(" ✅ Unencrypted rooms will receive normal messages")
|
|
717
|
+
elif analysis["overall_status"] == "disabled":
|
|
718
|
+
print(" ⚠️ E2EE is disabled in configuration")
|
|
719
|
+
print(" ❌ Messages to encrypted rooms will be BLOCKED")
|
|
720
|
+
print(" ✅ Messages to unencrypted rooms will work normally")
|
|
721
|
+
elif analysis["overall_status"] == "not_supported":
|
|
722
|
+
print(" ❌ E2EE not supported on Windows")
|
|
723
|
+
print(" ❌ Messages to encrypted rooms will be BLOCKED")
|
|
724
|
+
else:
|
|
725
|
+
print(" ⚠️ E2EE setup incomplete - some issues need to be resolved")
|
|
726
|
+
print(" ❌ Messages to encrypted rooms may be BLOCKED")
|
|
727
|
+
|
|
728
|
+
print(
|
|
729
|
+
"\n💡 Note: Room encryption status will be checked when mmrelay connects to Matrix"
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
# Recommendations
|
|
733
|
+
if analysis["recommendations"]:
|
|
734
|
+
print("\n🔧 TO FIX:")
|
|
735
|
+
for i, rec in enumerate(analysis["recommendations"], 1):
|
|
736
|
+
print(f" {i}. {rec}")
|
|
737
|
+
|
|
738
|
+
if analysis["overall_status"] == "ready":
|
|
739
|
+
print(
|
|
740
|
+
"\n✅ E2EE setup is complete! Run 'mmrelay' to start with E2EE support."
|
|
741
|
+
)
|
|
742
|
+
else:
|
|
743
|
+
print(
|
|
744
|
+
"\n⚠️ After fixing issues above, run 'mmrelay config check' again to verify."
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _print_environment_summary():
|
|
749
|
+
"""
|
|
750
|
+
Print a concise environment summary including platform, Python version, and Matrix E2EE capability.
|
|
751
|
+
|
|
752
|
+
Provides:
|
|
753
|
+
- Platform and Python version.
|
|
754
|
+
- Whether E2EE is supported on the current platform (Windows is reported as not supported).
|
|
755
|
+
- Whether the `olm` dependency is installed when E2EE is supported, and a brief installation hint if missing.
|
|
756
|
+
|
|
757
|
+
This function writes human-facing lines to standard output and returns None.
|
|
758
|
+
"""
|
|
759
|
+
print("\n🖥️ Environment Summary:")
|
|
760
|
+
print(f" Platform: {sys.platform}")
|
|
761
|
+
print(f" Python: {sys.version.split()[0]}")
|
|
762
|
+
|
|
763
|
+
# E2EE capability check
|
|
764
|
+
if sys.platform == WINDOWS_PLATFORM:
|
|
765
|
+
print(" E2EE Support: ❌ Not available (Windows limitation)")
|
|
766
|
+
print(" Matrix Support: ✅ Available")
|
|
767
|
+
else:
|
|
768
|
+
try:
|
|
769
|
+
import olm # noqa: F401
|
|
770
|
+
from nio.crypto import OlmDevice # noqa: F401
|
|
771
|
+
from nio.store import SqliteStore # noqa: F401
|
|
772
|
+
|
|
773
|
+
print(" E2EE Support: ✅ Available and installed")
|
|
774
|
+
except ImportError:
|
|
775
|
+
print(" E2EE Support: ⚠️ Available but not installed")
|
|
776
|
+
print(" Install: pipx install 'mmrelay[e2e]'")
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def check_config(args=None):
|
|
780
|
+
"""
|
|
781
|
+
Validate the application's YAML configuration file and its required sections.
|
|
782
|
+
|
|
783
|
+
Performs these checks: locates the first existing config file from get_config_paths(args),
|
|
784
|
+
validates YAML syntax and non-empty content, verifies Matrix authentication (prefers
|
|
785
|
+
credentials.json but accepts access_token or password in the matrix section), performs
|
|
786
|
+
E2EE configuration and dependency checks, validates matrix_rooms and meshtastic sections
|
|
787
|
+
(including connection-specific requirements), checks a set of optional meshtastic settings
|
|
788
|
+
(and value constraints), and warns about deprecated sections.
|
|
789
|
+
|
|
790
|
+
Side effects:
|
|
791
|
+
- Prints human-readable errors, warnings, and status messages to stdout.
|
|
792
|
+
|
|
793
|
+
Parameters:
|
|
794
|
+
args (argparse.Namespace | None): Parsed CLI arguments; if None, CLI arguments are parsed internally.
|
|
795
|
+
|
|
796
|
+
Returns:
|
|
797
|
+
bool: True if a configuration file was found and passed all checks; False otherwise.
|
|
798
|
+
"""
|
|
799
|
+
|
|
800
|
+
# If args is None, parse them now
|
|
801
|
+
if args is None:
|
|
802
|
+
args = parse_arguments()
|
|
803
|
+
|
|
804
|
+
config_paths = get_config_paths(args)
|
|
805
|
+
config_path = None
|
|
806
|
+
|
|
807
|
+
# Try each config path in order until we find one that exists
|
|
808
|
+
for path in config_paths:
|
|
809
|
+
if os.path.isfile(path):
|
|
810
|
+
config_path = path
|
|
811
|
+
print(f"Found configuration file at: {config_path}")
|
|
812
|
+
try:
|
|
813
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
814
|
+
config_content = f.read()
|
|
815
|
+
|
|
816
|
+
# Validate YAML syntax first
|
|
817
|
+
is_valid, message, config = validate_yaml_syntax(
|
|
818
|
+
config_content, config_path
|
|
819
|
+
)
|
|
820
|
+
if not is_valid:
|
|
821
|
+
print(f"YAML Syntax Error:\n{message}")
|
|
822
|
+
return False
|
|
823
|
+
elif message: # Warnings
|
|
824
|
+
print(f"YAML Style Warnings:\n{message}\n")
|
|
825
|
+
|
|
826
|
+
# Check if config is empty
|
|
827
|
+
if not config:
|
|
828
|
+
print(
|
|
829
|
+
"Error: Configuration file is empty or contains only comments"
|
|
830
|
+
)
|
|
831
|
+
return False
|
|
832
|
+
|
|
833
|
+
# Check if we have valid credentials.json first
|
|
834
|
+
has_valid_credentials = _validate_credentials_json(config_path)
|
|
835
|
+
|
|
836
|
+
# Check matrix section requirements based on credentials.json availability
|
|
837
|
+
if has_valid_credentials:
|
|
838
|
+
# With credentials.json, no matrix section fields are required
|
|
839
|
+
# (homeserver, access_token, user_id, device_id all come from credentials.json)
|
|
840
|
+
if CONFIG_SECTION_MATRIX not in config:
|
|
841
|
+
# Create empty matrix section if missing - no fields required
|
|
842
|
+
config[CONFIG_SECTION_MATRIX] = {}
|
|
843
|
+
matrix_section = config[CONFIG_SECTION_MATRIX]
|
|
844
|
+
if not isinstance(matrix_section, dict):
|
|
845
|
+
print("Error: 'matrix' section must be a mapping (YAML object)")
|
|
846
|
+
return False
|
|
847
|
+
required_matrix_fields = (
|
|
848
|
+
[]
|
|
849
|
+
) # No fields required from config when using credentials.json
|
|
850
|
+
else:
|
|
851
|
+
# Without credentials.json, require full matrix section
|
|
852
|
+
if CONFIG_SECTION_MATRIX not in config:
|
|
853
|
+
print("Error: Missing 'matrix' section in config")
|
|
854
|
+
print(
|
|
855
|
+
" Either add matrix section with access_token or password and bot_user_id,"
|
|
856
|
+
)
|
|
857
|
+
print(f" {msg_or_run_auth_login()}")
|
|
858
|
+
return False
|
|
859
|
+
|
|
860
|
+
matrix_section = config[CONFIG_SECTION_MATRIX]
|
|
861
|
+
if not isinstance(matrix_section, dict):
|
|
862
|
+
print("Error: 'matrix' section must be a mapping (YAML object)")
|
|
863
|
+
return False
|
|
864
|
+
|
|
865
|
+
required_matrix_fields = [
|
|
866
|
+
CONFIG_KEY_HOMESERVER,
|
|
867
|
+
CONFIG_KEY_BOT_USER_ID,
|
|
868
|
+
]
|
|
869
|
+
token = matrix_section.get(CONFIG_KEY_ACCESS_TOKEN)
|
|
870
|
+
pwd = matrix_section.get("password")
|
|
871
|
+
has_token = _is_valid_non_empty_string(token)
|
|
872
|
+
# Allow explicitly empty password strings; require the value to be a string
|
|
873
|
+
# (reject unquoted numeric types)
|
|
874
|
+
has_password = isinstance(pwd, str)
|
|
875
|
+
if not (has_token or has_password):
|
|
876
|
+
print(
|
|
877
|
+
"Error: Missing authentication in 'matrix' section: provide 'access_token' or 'password'"
|
|
878
|
+
)
|
|
879
|
+
print(f" {msg_or_run_auth_login()}")
|
|
880
|
+
return False
|
|
881
|
+
|
|
882
|
+
missing_matrix_fields = [
|
|
883
|
+
field
|
|
884
|
+
for field in required_matrix_fields
|
|
885
|
+
if not _is_valid_non_empty_string(matrix_section.get(field))
|
|
886
|
+
]
|
|
887
|
+
|
|
888
|
+
if missing_matrix_fields:
|
|
889
|
+
if has_valid_credentials:
|
|
890
|
+
print(
|
|
891
|
+
f"Error: Missing required fields in 'matrix' section: {', '.join(missing_matrix_fields)}"
|
|
892
|
+
)
|
|
893
|
+
print(
|
|
894
|
+
" Note: credentials.json provides authentication; no matrix.* fields are required in config"
|
|
895
|
+
)
|
|
896
|
+
else:
|
|
897
|
+
print(
|
|
898
|
+
f"Error: Missing required fields in 'matrix' section: {', '.join(missing_matrix_fields)}"
|
|
899
|
+
)
|
|
900
|
+
print(f" {msg_setup_authentication()}")
|
|
901
|
+
return False
|
|
902
|
+
|
|
903
|
+
# Perform comprehensive E2EE analysis using centralized utilities
|
|
904
|
+
try:
|
|
905
|
+
from mmrelay.e2ee_utils import (
|
|
906
|
+
get_e2ee_status,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
e2ee_status = get_e2ee_status(config, config_path)
|
|
910
|
+
_print_unified_e2ee_analysis(e2ee_status)
|
|
911
|
+
|
|
912
|
+
# Check if there are critical E2EE issues
|
|
913
|
+
if not e2ee_status.get("platform_supported", True):
|
|
914
|
+
print("\n⚠️ Warning: E2EE is not supported on Windows")
|
|
915
|
+
print(" Messages to encrypted rooms will be blocked")
|
|
916
|
+
except Exception as e:
|
|
917
|
+
print(f"\n⚠️ Could not perform E2EE analysis: {e}")
|
|
918
|
+
print(" Falling back to basic E2EE validation...")
|
|
919
|
+
if not _validate_e2ee_config(config, matrix_section, config_path):
|
|
920
|
+
return False
|
|
921
|
+
|
|
922
|
+
# Check matrix_rooms section
|
|
923
|
+
if "matrix_rooms" not in config or not config["matrix_rooms"]:
|
|
924
|
+
print("Error: Missing or empty 'matrix_rooms' section in config")
|
|
925
|
+
return False
|
|
926
|
+
|
|
927
|
+
if not isinstance(config["matrix_rooms"], list):
|
|
928
|
+
print("Error: 'matrix_rooms' must be a list")
|
|
929
|
+
return False
|
|
930
|
+
|
|
931
|
+
for i, room in enumerate(config["matrix_rooms"]):
|
|
932
|
+
if not isinstance(room, dict):
|
|
933
|
+
print(
|
|
934
|
+
f"Error: Room {i+1} in 'matrix_rooms' must be a dictionary"
|
|
935
|
+
)
|
|
936
|
+
return False
|
|
937
|
+
|
|
938
|
+
if "id" not in room:
|
|
939
|
+
print(
|
|
940
|
+
f"Error: Room {i+1} in 'matrix_rooms' is missing the 'id' field"
|
|
941
|
+
)
|
|
942
|
+
return False
|
|
943
|
+
|
|
944
|
+
# Check meshtastic section
|
|
945
|
+
if CONFIG_SECTION_MESHTASTIC not in config:
|
|
946
|
+
print("Error: Missing 'meshtastic' section in config")
|
|
947
|
+
return False
|
|
948
|
+
|
|
949
|
+
meshtastic_section = config[CONFIG_SECTION_MESHTASTIC]
|
|
950
|
+
if "connection_type" not in meshtastic_section:
|
|
951
|
+
print("Error: Missing 'connection_type' in 'meshtastic' section")
|
|
952
|
+
return False
|
|
953
|
+
|
|
954
|
+
connection_type = meshtastic_section[CONFIG_KEY_CONNECTION_TYPE]
|
|
955
|
+
if connection_type not in [
|
|
956
|
+
CONNECTION_TYPE_TCP,
|
|
957
|
+
CONNECTION_TYPE_SERIAL,
|
|
958
|
+
CONNECTION_TYPE_BLE,
|
|
959
|
+
CONNECTION_TYPE_NETWORK,
|
|
960
|
+
]:
|
|
961
|
+
print(
|
|
962
|
+
f"Error: Invalid 'connection_type': {connection_type}. Must be "
|
|
963
|
+
f"'{CONNECTION_TYPE_TCP}', '{CONNECTION_TYPE_SERIAL}', '{CONNECTION_TYPE_BLE}'"
|
|
964
|
+
f" or '{CONNECTION_TYPE_NETWORK}' (deprecated)"
|
|
965
|
+
)
|
|
966
|
+
return False
|
|
967
|
+
|
|
968
|
+
# Check for deprecated connection_type
|
|
969
|
+
if connection_type == CONNECTION_TYPE_NETWORK:
|
|
970
|
+
print(
|
|
971
|
+
"\nWarning: 'network' connection_type is deprecated. Please use 'tcp' instead."
|
|
972
|
+
)
|
|
973
|
+
print(
|
|
974
|
+
"This option still works but may be removed in future versions.\n"
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
# Check connection-specific fields
|
|
978
|
+
if (
|
|
979
|
+
connection_type == CONNECTION_TYPE_SERIAL
|
|
980
|
+
and CONFIG_KEY_SERIAL_PORT not in meshtastic_section
|
|
981
|
+
):
|
|
982
|
+
print("Error: Missing 'serial_port' for 'serial' connection type")
|
|
983
|
+
return False
|
|
984
|
+
|
|
985
|
+
if (
|
|
986
|
+
connection_type in [CONNECTION_TYPE_TCP, CONNECTION_TYPE_NETWORK]
|
|
987
|
+
and CONFIG_KEY_HOST not in meshtastic_section
|
|
988
|
+
):
|
|
989
|
+
print("Error: Missing 'host' for 'tcp' connection type")
|
|
990
|
+
return False
|
|
991
|
+
|
|
992
|
+
if (
|
|
993
|
+
connection_type == CONNECTION_TYPE_BLE
|
|
994
|
+
and CONFIG_KEY_BLE_ADDRESS not in meshtastic_section
|
|
995
|
+
):
|
|
996
|
+
print("Error: Missing 'ble_address' for 'ble' connection type")
|
|
997
|
+
return False
|
|
998
|
+
|
|
999
|
+
# Check for other important optional configurations and provide guidance
|
|
1000
|
+
optional_configs = {
|
|
1001
|
+
"broadcast_enabled": {
|
|
1002
|
+
"type": bool,
|
|
1003
|
+
"description": "Enable Matrix to Meshtastic message forwarding (required for two-way communication)",
|
|
1004
|
+
},
|
|
1005
|
+
"detection_sensor": {
|
|
1006
|
+
"type": bool,
|
|
1007
|
+
"description": "Enable forwarding of Meshtastic detection sensor messages",
|
|
1008
|
+
},
|
|
1009
|
+
"message_delay": {
|
|
1010
|
+
"type": (int, float),
|
|
1011
|
+
"description": "Delay in seconds between messages sent to mesh (minimum: 2.0)",
|
|
1012
|
+
},
|
|
1013
|
+
"meshnet_name": {
|
|
1014
|
+
"type": str,
|
|
1015
|
+
"description": "Name displayed for your meshnet in Matrix messages",
|
|
1016
|
+
},
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
warnings = []
|
|
1020
|
+
for option, config_info in optional_configs.items():
|
|
1021
|
+
if option in meshtastic_section:
|
|
1022
|
+
value = meshtastic_section[option]
|
|
1023
|
+
expected_type = config_info["type"]
|
|
1024
|
+
if not isinstance(value, expected_type):
|
|
1025
|
+
if isinstance(expected_type, tuple):
|
|
1026
|
+
type_name = " or ".join(
|
|
1027
|
+
t.__name__ for t in expected_type
|
|
1028
|
+
)
|
|
1029
|
+
else:
|
|
1030
|
+
type_name = (
|
|
1031
|
+
expected_type.__name__
|
|
1032
|
+
if hasattr(expected_type, "__name__")
|
|
1033
|
+
else str(expected_type)
|
|
1034
|
+
)
|
|
1035
|
+
print(
|
|
1036
|
+
f"Error: '{option}' must be of type {type_name}, got: {value}"
|
|
1037
|
+
)
|
|
1038
|
+
return False
|
|
1039
|
+
|
|
1040
|
+
# Special validation for message_delay
|
|
1041
|
+
if option == "message_delay" and value < 2.0:
|
|
1042
|
+
print(
|
|
1043
|
+
f"Error: 'message_delay' must be at least 2.0 seconds (firmware limitation), got: {value}",
|
|
1044
|
+
file=sys.stderr,
|
|
1045
|
+
)
|
|
1046
|
+
return False
|
|
1047
|
+
else:
|
|
1048
|
+
warnings.append(f" - {option}: {config_info['description']}")
|
|
1049
|
+
|
|
1050
|
+
if warnings:
|
|
1051
|
+
print("\nOptional configurations not found (using defaults):")
|
|
1052
|
+
for warning in warnings:
|
|
1053
|
+
print(warning)
|
|
1054
|
+
|
|
1055
|
+
# Check for deprecated db section
|
|
1056
|
+
if "db" in config:
|
|
1057
|
+
print(
|
|
1058
|
+
"\nWarning: 'db' section is deprecated. Please use 'database' instead.",
|
|
1059
|
+
file=sys.stderr,
|
|
1060
|
+
)
|
|
1061
|
+
print(
|
|
1062
|
+
"This option still works but may be removed in future versions.\n",
|
|
1063
|
+
file=sys.stderr,
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
print("\n✅ Configuration file is valid!")
|
|
1067
|
+
return True
|
|
1068
|
+
except (OSError, ValueError, UnicodeDecodeError) as e:
|
|
1069
|
+
print(
|
|
1070
|
+
f"Error checking configuration: {e.__class__.__name__}: {e}",
|
|
1071
|
+
file=sys.stderr,
|
|
1072
|
+
)
|
|
1073
|
+
except Exception as e:
|
|
1074
|
+
print(f"Error checking configuration: {e}", file=sys.stderr)
|
|
1075
|
+
return False
|
|
1076
|
+
|
|
1077
|
+
print("Error: No configuration file found in any of the following locations:")
|
|
1078
|
+
for path in config_paths:
|
|
1079
|
+
print(f" - {path}")
|
|
1080
|
+
print(f"\n{msg_suggest_generate_config()}")
|
|
1081
|
+
return False
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def main():
|
|
1085
|
+
"""
|
|
1086
|
+
Entry point for the MMRelay command-line interface; parses arguments, dispatches commands, and returns an appropriate process exit code.
|
|
1087
|
+
|
|
1088
|
+
This function:
|
|
1089
|
+
- Parses CLI arguments (modern grouped subcommands and hidden legacy flags).
|
|
1090
|
+
- If a modern subcommand is provided, dispatches to the grouped subcommand handlers.
|
|
1091
|
+
- If legacy flags are present, emits deprecation warnings and executes the corresponding legacy behavior (config check/generate, service install, auth, version).
|
|
1092
|
+
- If no command flags are present, attempts to run the main runtime.
|
|
1093
|
+
- Catches and reports import or unexpected errors and maps success/failure to exit codes.
|
|
1094
|
+
|
|
1095
|
+
Returns:
|
|
1096
|
+
int: Exit code (0 on success, non-zero on failure).
|
|
1097
|
+
"""
|
|
1098
|
+
try:
|
|
1099
|
+
# Set up Windows console for better compatibility
|
|
1100
|
+
try:
|
|
1101
|
+
from mmrelay.windows_utils import setup_windows_console
|
|
1102
|
+
|
|
1103
|
+
setup_windows_console()
|
|
1104
|
+
except (ImportError, OSError, AttributeError):
|
|
1105
|
+
# windows_utils not available or Windows console setup failed
|
|
1106
|
+
# This is intentional - we want to continue if Windows utils fail
|
|
1107
|
+
pass
|
|
1108
|
+
|
|
1109
|
+
args = parse_arguments()
|
|
1110
|
+
|
|
1111
|
+
# Handle the --data-dir option
|
|
1112
|
+
if args and args.data_dir:
|
|
1113
|
+
import mmrelay.config
|
|
1114
|
+
|
|
1115
|
+
# Set the global custom_data_dir variable
|
|
1116
|
+
mmrelay.config.custom_data_dir = os.path.abspath(args.data_dir)
|
|
1117
|
+
# Create the directory if it doesn't exist
|
|
1118
|
+
os.makedirs(mmrelay.config.custom_data_dir, exist_ok=True)
|
|
1119
|
+
|
|
1120
|
+
# Handle subcommands first (modern interface)
|
|
1121
|
+
if hasattr(args, "command") and args.command:
|
|
1122
|
+
return handle_subcommand(args)
|
|
1123
|
+
|
|
1124
|
+
# Handle legacy flags (with deprecation warnings)
|
|
1125
|
+
if args.check_config:
|
|
1126
|
+
print(get_deprecation_warning("--check-config"))
|
|
1127
|
+
return 0 if check_config(args) else 1
|
|
1128
|
+
|
|
1129
|
+
if args.install_service:
|
|
1130
|
+
print(get_deprecation_warning("--install-service"))
|
|
1131
|
+
try:
|
|
1132
|
+
from mmrelay.setup_utils import install_service
|
|
1133
|
+
|
|
1134
|
+
return 0 if install_service() else 1
|
|
1135
|
+
except ImportError as e:
|
|
1136
|
+
print(f"Error importing setup utilities: {e}")
|
|
1137
|
+
return 1
|
|
1138
|
+
|
|
1139
|
+
if args.generate_config:
|
|
1140
|
+
print(get_deprecation_warning("--generate-config"))
|
|
1141
|
+
return 0 if generate_sample_config() else 1
|
|
1142
|
+
|
|
1143
|
+
if args.version:
|
|
1144
|
+
print_version()
|
|
1145
|
+
return 0
|
|
1146
|
+
|
|
1147
|
+
if args.auth:
|
|
1148
|
+
print(get_deprecation_warning("--auth"))
|
|
1149
|
+
return handle_auth_command(args)
|
|
1150
|
+
|
|
1151
|
+
# If no command was specified, run the main functionality
|
|
1152
|
+
try:
|
|
1153
|
+
from mmrelay.main import run_main
|
|
1154
|
+
|
|
1155
|
+
return run_main(args)
|
|
1156
|
+
except ImportError as e:
|
|
1157
|
+
print(f"Error importing main module: {e}")
|
|
1158
|
+
return 1
|
|
1159
|
+
|
|
1160
|
+
except (OSError, PermissionError, KeyboardInterrupt) as e:
|
|
1161
|
+
# Handle common system-level errors
|
|
1162
|
+
print(f"System error: {e.__class__.__name__}: {e}", file=sys.stderr)
|
|
1163
|
+
return 1
|
|
1164
|
+
except Exception as e:
|
|
1165
|
+
# Default error message
|
|
1166
|
+
error_msg = f"Unexpected error: {e.__class__.__name__}: {e}"
|
|
1167
|
+
# Provide Windows-specific error guidance if available
|
|
1168
|
+
try:
|
|
1169
|
+
from mmrelay.windows_utils import get_windows_error_message, is_windows
|
|
1170
|
+
|
|
1171
|
+
if is_windows():
|
|
1172
|
+
error_msg = f"Error: {get_windows_error_message(e)}"
|
|
1173
|
+
except ImportError:
|
|
1174
|
+
pass # Use default message
|
|
1175
|
+
print(error_msg, file=sys.stderr)
|
|
1176
|
+
return 1
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def handle_subcommand(args):
|
|
1180
|
+
"""
|
|
1181
|
+
Dispatch the modern grouped CLI subcommand to the appropriate handler and return an exit code.
|
|
1182
|
+
|
|
1183
|
+
The function expects an argparse.Namespace from parse_arguments() with a `command`
|
|
1184
|
+
attribute set to one of: "config", "auth", or "service". Delegates to the
|
|
1185
|
+
corresponding handler and returns its exit code. If `command` is unknown,
|
|
1186
|
+
prints an error and returns 1.
|
|
1187
|
+
"""
|
|
1188
|
+
if args.command == "config":
|
|
1189
|
+
return handle_config_command(args)
|
|
1190
|
+
elif args.command == "auth":
|
|
1191
|
+
return handle_auth_command(args)
|
|
1192
|
+
elif args.command == "service":
|
|
1193
|
+
return handle_service_command(args)
|
|
1194
|
+
|
|
1195
|
+
else:
|
|
1196
|
+
print(f"Unknown command: {args.command}")
|
|
1197
|
+
return 1
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def handle_config_command(args):
|
|
1201
|
+
"""
|
|
1202
|
+
Dispatch the "config" command group to the selected subcommand handler.
|
|
1203
|
+
|
|
1204
|
+
Supported subcommands:
|
|
1205
|
+
- "generate": create or update the sample configuration file at the preferred location.
|
|
1206
|
+
- "check": validate the resolved configuration file (delegates to check_config).
|
|
1207
|
+
- "diagnose": run a sequence of non-destructive diagnostics and print a report (delegates to handle_config_diagnose).
|
|
1208
|
+
|
|
1209
|
+
Parameters:
|
|
1210
|
+
args (argparse.Namespace): CLI namespace containing `config_command` (one of "generate", "check", "diagnose") and any subcommand-specific options.
|
|
1211
|
+
|
|
1212
|
+
Returns:
|
|
1213
|
+
int: Exit code (0 on success, 1 on failure or for unknown subcommands).
|
|
1214
|
+
"""
|
|
1215
|
+
if args.config_command == "generate":
|
|
1216
|
+
return 0 if generate_sample_config() else 1
|
|
1217
|
+
elif args.config_command == "check":
|
|
1218
|
+
return 0 if check_config(args) else 1
|
|
1219
|
+
elif args.config_command == "diagnose":
|
|
1220
|
+
return handle_config_diagnose(args)
|
|
1221
|
+
else:
|
|
1222
|
+
print(f"Unknown config command: {args.config_command}")
|
|
1223
|
+
return 1
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def handle_auth_command(args):
|
|
1227
|
+
"""
|
|
1228
|
+
Dispatch the "auth" CLI subcommand to the appropriate handler.
|
|
1229
|
+
|
|
1230
|
+
If args.auth_command is "status" calls handle_auth_status; if "logout" calls handle_auth_logout;
|
|
1231
|
+
any other value (or missing attribute) defaults to handle_auth_login.
|
|
1232
|
+
|
|
1233
|
+
Parameters:
|
|
1234
|
+
args (argparse.Namespace): Parsed CLI arguments. Expected to optionally provide `auth_command`
|
|
1235
|
+
with one of "login", "status", or "logout".
|
|
1236
|
+
|
|
1237
|
+
Returns:
|
|
1238
|
+
int: Exit code from the invoked handler (0 = success, non-zero = failure).
|
|
1239
|
+
"""
|
|
1240
|
+
if hasattr(args, "auth_command"):
|
|
1241
|
+
if args.auth_command == "status":
|
|
1242
|
+
return handle_auth_status(args)
|
|
1243
|
+
elif args.auth_command == "logout":
|
|
1244
|
+
return handle_auth_logout(args)
|
|
1245
|
+
else:
|
|
1246
|
+
# Default to login for auth login command
|
|
1247
|
+
return handle_auth_login(args)
|
|
1248
|
+
else:
|
|
1249
|
+
# Default to login for legacy --auth
|
|
1250
|
+
return handle_auth_login(args)
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def handle_auth_login(args):
|
|
1254
|
+
"""
|
|
1255
|
+
Run the Matrix bot login flow and return a CLI-style exit code.
|
|
1256
|
+
|
|
1257
|
+
Performs Matrix bot authentication either interactively (prompts the user) or non-interactively
|
|
1258
|
+
when all three parameters (--homeserver, --username, --password) are provided on the command line.
|
|
1259
|
+
For non-interactive mode, --homeserver and --username must be non-empty strings; --password may be
|
|
1260
|
+
an empty string (some flows will prompt). Supplying some but not all of the three parameters
|
|
1261
|
+
is treated as an error and the function exits with a non-zero status.
|
|
1262
|
+
|
|
1263
|
+
Returns:
|
|
1264
|
+
int: 0 on successful authentication, 1 on failure, cancellation (KeyboardInterrupt), or unexpected errors.
|
|
1265
|
+
|
|
1266
|
+
Parameters:
|
|
1267
|
+
args: Parsed CLI namespace; may contain attributes `homeserver`, `username`, and `password`.
|
|
1268
|
+
"""
|
|
1269
|
+
import asyncio
|
|
1270
|
+
|
|
1271
|
+
from mmrelay.matrix_utils import login_matrix_bot
|
|
1272
|
+
|
|
1273
|
+
# Extract arguments
|
|
1274
|
+
homeserver = getattr(args, "homeserver", None)
|
|
1275
|
+
username = getattr(args, "username", None)
|
|
1276
|
+
password = getattr(args, "password", None)
|
|
1277
|
+
|
|
1278
|
+
# Count provided parameters (empty strings count as provided)
|
|
1279
|
+
provided_params = [p for p in [homeserver, username, password] if p is not None]
|
|
1280
|
+
|
|
1281
|
+
# Determine mode based on parameters provided
|
|
1282
|
+
if len(provided_params) == 3:
|
|
1283
|
+
# All parameters provided - validate required non-empty fields
|
|
1284
|
+
if not _is_valid_non_empty_string(homeserver) or not _is_valid_non_empty_string(
|
|
1285
|
+
username
|
|
1286
|
+
):
|
|
1287
|
+
print(
|
|
1288
|
+
"❌ Error: --homeserver and --username must be non-empty for non-interactive login."
|
|
1289
|
+
)
|
|
1290
|
+
return 1
|
|
1291
|
+
# Password may be empty (flows may prompt)
|
|
1292
|
+
elif len(provided_params) > 0:
|
|
1293
|
+
# Some but not all parameters provided - show error
|
|
1294
|
+
missing_params = []
|
|
1295
|
+
if homeserver is None:
|
|
1296
|
+
missing_params.append("--homeserver")
|
|
1297
|
+
if username is None:
|
|
1298
|
+
missing_params.append("--username")
|
|
1299
|
+
if password is None:
|
|
1300
|
+
missing_params.append("--password")
|
|
1301
|
+
|
|
1302
|
+
error_message = f"""❌ Error: All authentication parameters are required when using command-line options.
|
|
1303
|
+
Missing: {', '.join(missing_params)}
|
|
1304
|
+
|
|
1305
|
+
💡 Options:
|
|
1306
|
+
• For secure interactive authentication: mmrelay auth login
|
|
1307
|
+
• For automated authentication: provide all three parameters
|
|
1308
|
+
|
|
1309
|
+
⚠️ Security Note: Command-line passwords may be visible in process lists and shell history.
|
|
1310
|
+
Interactive mode is recommended for manual use."""
|
|
1311
|
+
print(error_message)
|
|
1312
|
+
return 1
|
|
1313
|
+
else:
|
|
1314
|
+
# No parameters provided - run in interactive mode
|
|
1315
|
+
# Check if E2EE is actually configured before mentioning it
|
|
1316
|
+
# Use silent checking to avoid warnings during initial setup
|
|
1317
|
+
try:
|
|
1318
|
+
from mmrelay.config import check_e2ee_enabled_silently
|
|
1319
|
+
|
|
1320
|
+
e2ee_enabled = check_e2ee_enabled_silently(args)
|
|
1321
|
+
|
|
1322
|
+
if e2ee_enabled:
|
|
1323
|
+
print("Matrix Bot Authentication for E2EE")
|
|
1324
|
+
print("===================================")
|
|
1325
|
+
else:
|
|
1326
|
+
print("\nMatrix Bot Authentication")
|
|
1327
|
+
print("=========================")
|
|
1328
|
+
except (OSError, PermissionError, ImportError, ValueError) as e:
|
|
1329
|
+
# Fallback if silent checking fails due to config file or import issues
|
|
1330
|
+
from mmrelay.log_utils import get_logger
|
|
1331
|
+
|
|
1332
|
+
logger = get_logger("CLI")
|
|
1333
|
+
logger.debug(f"Failed to silently check E2EE status: {e}")
|
|
1334
|
+
print("\nMatrix Bot Authentication")
|
|
1335
|
+
print("=========================")
|
|
1336
|
+
|
|
1337
|
+
try:
|
|
1338
|
+
result = asyncio.run(
|
|
1339
|
+
login_matrix_bot(
|
|
1340
|
+
homeserver=homeserver,
|
|
1341
|
+
username=username,
|
|
1342
|
+
password=password,
|
|
1343
|
+
logout_others=False,
|
|
1344
|
+
)
|
|
1345
|
+
)
|
|
1346
|
+
return 0 if result else 1
|
|
1347
|
+
except KeyboardInterrupt:
|
|
1348
|
+
print("\nAuthentication cancelled by user.")
|
|
1349
|
+
return 1
|
|
1350
|
+
except Exception as e:
|
|
1351
|
+
print(f"\nError during authentication: {e}")
|
|
1352
|
+
return 1
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def handle_auth_status(args):
|
|
1356
|
+
"""
|
|
1357
|
+
Print the Matrix authentication status by locating and reading a credentials.json file.
|
|
1358
|
+
|
|
1359
|
+
Searches for credentials.json next to each discovered config file (in preference order),
|
|
1360
|
+
then falls back to the application's base directory. If a readable credentials.json is
|
|
1361
|
+
found, prints its path and the homeserver, user_id, and device_id values.
|
|
1362
|
+
|
|
1363
|
+
Parameters:
|
|
1364
|
+
args: argparse.Namespace
|
|
1365
|
+
Parsed CLI arguments (used to locate config file paths).
|
|
1366
|
+
|
|
1367
|
+
Returns:
|
|
1368
|
+
int: Exit code — 0 if a valid credentials.json was found and read, 1 otherwise.
|
|
1369
|
+
|
|
1370
|
+
Side effects:
|
|
1371
|
+
Writes human-readable status messages to stdout.
|
|
1372
|
+
"""
|
|
1373
|
+
import json
|
|
1374
|
+
|
|
1375
|
+
from mmrelay.config import get_base_dir, get_config_paths
|
|
1376
|
+
|
|
1377
|
+
print("Matrix Authentication Status")
|
|
1378
|
+
print("============================")
|
|
1379
|
+
|
|
1380
|
+
config_paths = get_config_paths(args)
|
|
1381
|
+
|
|
1382
|
+
# Developer note: Build a de-duplicated sequence of candidate locations,
|
|
1383
|
+
# preserving preference order: each config-adjacent credentials.json first,
|
|
1384
|
+
# then the standard base-dir fallback.
|
|
1385
|
+
seen = set()
|
|
1386
|
+
candidate_paths = []
|
|
1387
|
+
for p in (
|
|
1388
|
+
os.path.join(os.path.dirname(cp), "credentials.json") for cp in config_paths
|
|
1389
|
+
):
|
|
1390
|
+
if p not in seen:
|
|
1391
|
+
candidate_paths.append(p)
|
|
1392
|
+
seen.add(p)
|
|
1393
|
+
base_candidate = os.path.join(get_base_dir(), "credentials.json")
|
|
1394
|
+
if base_candidate not in seen:
|
|
1395
|
+
candidate_paths.append(base_candidate)
|
|
1396
|
+
|
|
1397
|
+
for credentials_path in candidate_paths:
|
|
1398
|
+
if os.path.exists(credentials_path):
|
|
1399
|
+
try:
|
|
1400
|
+
with open(credentials_path, "r", encoding="utf-8") as f:
|
|
1401
|
+
credentials = json.load(f)
|
|
1402
|
+
|
|
1403
|
+
required = ("homeserver", "access_token", "user_id", "device_id")
|
|
1404
|
+
if not all(
|
|
1405
|
+
isinstance(credentials.get(k), str) and credentials.get(k).strip()
|
|
1406
|
+
for k in required
|
|
1407
|
+
):
|
|
1408
|
+
print(
|
|
1409
|
+
f"❌ Error: credentials.json at {credentials_path} is missing required fields"
|
|
1410
|
+
)
|
|
1411
|
+
print(f"Run '{get_command('auth_login')}' to authenticate")
|
|
1412
|
+
return 1
|
|
1413
|
+
print(f"✅ Found credentials.json at: {credentials_path}")
|
|
1414
|
+
print(f" Homeserver: {credentials.get('homeserver')}")
|
|
1415
|
+
print(f" User ID: {credentials.get('user_id')}")
|
|
1416
|
+
print(f" Device ID: {credentials.get('device_id')}")
|
|
1417
|
+
return 0
|
|
1418
|
+
except Exception as e:
|
|
1419
|
+
print(f"❌ Error reading credentials.json: {e}")
|
|
1420
|
+
return 1
|
|
1421
|
+
|
|
1422
|
+
print("❌ No credentials.json found")
|
|
1423
|
+
print(f"Run '{get_command('auth_login')}' to authenticate")
|
|
1424
|
+
return 1
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def handle_auth_logout(args):
|
|
1428
|
+
"""
|
|
1429
|
+
Log out the Matrix bot and remove local session artifacts.
|
|
1430
|
+
|
|
1431
|
+
Prompts for a verification password (unless a non-empty password is provided via args.password),
|
|
1432
|
+
optionally asks for interactive confirmation (skipped if args.yes is True), and attempts to clear
|
|
1433
|
+
local session data (credentials, E2EE store) and invalidate the bot's access token.
|
|
1434
|
+
|
|
1435
|
+
Parameters:
|
|
1436
|
+
args (argparse.Namespace): CLI arguments with the following relevant attributes:
|
|
1437
|
+
password (str | None): If a non-empty string is provided, it will be used as the
|
|
1438
|
+
verification password. If None or an empty string, the function prompts securely.
|
|
1439
|
+
yes (bool): If True, skip the confirmation prompt.
|
|
1440
|
+
|
|
1441
|
+
Returns:
|
|
1442
|
+
int: 0 on successful logout, 1 on failure or if the operation is cancelled (including
|
|
1443
|
+
KeyboardInterrupt).
|
|
1444
|
+
"""
|
|
1445
|
+
import asyncio
|
|
1446
|
+
|
|
1447
|
+
from mmrelay.cli_utils import logout_matrix_bot
|
|
1448
|
+
|
|
1449
|
+
# Show header
|
|
1450
|
+
print("Matrix Bot Logout")
|
|
1451
|
+
print("=================")
|
|
1452
|
+
print()
|
|
1453
|
+
print("This will log out from Matrix and clear all local session data:")
|
|
1454
|
+
print("• Remove credentials.json")
|
|
1455
|
+
print("• Clear E2EE encryption store")
|
|
1456
|
+
print("• Invalidate Matrix access token")
|
|
1457
|
+
print()
|
|
1458
|
+
|
|
1459
|
+
try:
|
|
1460
|
+
# Handle password input
|
|
1461
|
+
password = getattr(args, "password", None)
|
|
1462
|
+
|
|
1463
|
+
if (
|
|
1464
|
+
password is None
|
|
1465
|
+
or password
|
|
1466
|
+
== "" # nosec B105 (user-entered secret; prompting securely via getpass)
|
|
1467
|
+
):
|
|
1468
|
+
# No --password flag or --password with no value, prompt securely
|
|
1469
|
+
import getpass
|
|
1470
|
+
|
|
1471
|
+
password = getpass.getpass("Enter Matrix password for verification: ")
|
|
1472
|
+
else:
|
|
1473
|
+
# --password VALUE provided, warn about security
|
|
1474
|
+
print(
|
|
1475
|
+
"⚠️ Warning: Supplying password as argument exposes it in shell history and process list."
|
|
1476
|
+
)
|
|
1477
|
+
print(
|
|
1478
|
+
" For better security, use --password without a value to prompt securely."
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1481
|
+
# Confirm the action unless forced
|
|
1482
|
+
if not getattr(args, "yes", False):
|
|
1483
|
+
confirm = input("Are you sure you want to logout? (y/N): ").lower().strip()
|
|
1484
|
+
if not confirm.startswith("y"):
|
|
1485
|
+
print("Logout cancelled.")
|
|
1486
|
+
return 0
|
|
1487
|
+
|
|
1488
|
+
# Run the logout process
|
|
1489
|
+
result = asyncio.run(logout_matrix_bot(password=password))
|
|
1490
|
+
return 0 if result else 1
|
|
1491
|
+
except KeyboardInterrupt:
|
|
1492
|
+
print("\nLogout cancelled by user.")
|
|
1493
|
+
return 1
|
|
1494
|
+
except Exception as e:
|
|
1495
|
+
print(f"\nError during logout: {e}")
|
|
1496
|
+
return 1
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def handle_service_command(args):
|
|
1500
|
+
"""
|
|
1501
|
+
Dispatch service-related subcommands.
|
|
1502
|
+
|
|
1503
|
+
Currently supports the "install" subcommand which imports and runs mmrelay.setup_utils.install_service().
|
|
1504
|
+
Returns 0 on successful installation, 1 on failure or for unknown subcommands.
|
|
1505
|
+
|
|
1506
|
+
Parameters:
|
|
1507
|
+
args: argparse.Namespace with a `service_command` attribute indicating the requested action.
|
|
1508
|
+
|
|
1509
|
+
Returns:
|
|
1510
|
+
int: Exit code (0 on success, 1 on error).
|
|
1511
|
+
"""
|
|
1512
|
+
if args.service_command == "install":
|
|
1513
|
+
try:
|
|
1514
|
+
from mmrelay.setup_utils import install_service
|
|
1515
|
+
|
|
1516
|
+
return 0 if install_service() else 1
|
|
1517
|
+
except ImportError as e:
|
|
1518
|
+
print(f"Error importing setup utilities: {e}")
|
|
1519
|
+
return 1
|
|
1520
|
+
else:
|
|
1521
|
+
print(f"Unknown service command: {args.service_command}")
|
|
1522
|
+
return 1
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def _diagnose_config_paths(args):
|
|
1526
|
+
"""
|
|
1527
|
+
Prints a diagnostic summary of resolved configuration file search paths and their directory accessibility.
|
|
1528
|
+
|
|
1529
|
+
Each candidate config path is printed with a status icon:
|
|
1530
|
+
- ✅ directory exists and is writable
|
|
1531
|
+
- ⚠️ directory exists but is not writable
|
|
1532
|
+
- ❌ directory does not exist
|
|
1533
|
+
|
|
1534
|
+
Parameters:
|
|
1535
|
+
args (argparse.Namespace): CLI arguments used to determine the ordered list of candidate config paths (passed to get_config_paths).
|
|
1536
|
+
"""
|
|
1537
|
+
print("1. Testing configuration paths...")
|
|
1538
|
+
from mmrelay.config import get_config_paths
|
|
1539
|
+
|
|
1540
|
+
paths = get_config_paths(args)
|
|
1541
|
+
print(f" Config search paths: {len(paths)} locations")
|
|
1542
|
+
for i, path in enumerate(paths, 1):
|
|
1543
|
+
dir_path = os.path.dirname(path)
|
|
1544
|
+
dir_exists = os.path.exists(dir_path)
|
|
1545
|
+
dir_writable = os.access(dir_path, os.W_OK) if dir_exists else False
|
|
1546
|
+
status = "✅" if dir_exists and dir_writable else "⚠️" if dir_exists else "❌"
|
|
1547
|
+
print(f" {i}. {path} {status}")
|
|
1548
|
+
print()
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _diagnose_sample_config_accessibility():
|
|
1552
|
+
"""
|
|
1553
|
+
Check availability of the bundled sample configuration and print a short diagnostic.
|
|
1554
|
+
|
|
1555
|
+
Performs two non-destructive checks and prints human-readable results:
|
|
1556
|
+
1) Verifies whether the sample config file exists at the path returned by mmrelay.tools.get_sample_config_path().
|
|
1557
|
+
2) Attempts to read the embedded resource "sample_config.yaml" from the mmrelay.tools package via importlib.resources and reports success and the content length.
|
|
1558
|
+
|
|
1559
|
+
Returns:
|
|
1560
|
+
bool: True if a filesystem sample config exists at the resolved path; False otherwise.
|
|
1561
|
+
"""
|
|
1562
|
+
print("2. Testing sample config accessibility...")
|
|
1563
|
+
from mmrelay.tools import get_sample_config_path
|
|
1564
|
+
|
|
1565
|
+
sample_path = get_sample_config_path()
|
|
1566
|
+
sample_exists = os.path.exists(sample_path)
|
|
1567
|
+
print(f" Sample config path: {sample_path}")
|
|
1568
|
+
print(f" Sample config exists: {'✅' if sample_exists else '❌'}")
|
|
1569
|
+
|
|
1570
|
+
# Test importlib.resources fallback
|
|
1571
|
+
try:
|
|
1572
|
+
import importlib.resources
|
|
1573
|
+
|
|
1574
|
+
content = (
|
|
1575
|
+
importlib.resources.files("mmrelay.tools")
|
|
1576
|
+
.joinpath("sample_config.yaml")
|
|
1577
|
+
.read_text()
|
|
1578
|
+
)
|
|
1579
|
+
print(f" importlib.resources fallback: ✅ ({len(content)} chars)")
|
|
1580
|
+
except (FileNotFoundError, ImportError, OSError) as e:
|
|
1581
|
+
print(f" importlib.resources fallback: ❌ ({e})")
|
|
1582
|
+
print()
|
|
1583
|
+
|
|
1584
|
+
return sample_exists
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
def _diagnose_platform_specific(args):
|
|
1588
|
+
"""
|
|
1589
|
+
Run platform-specific diagnostic checks and print a concise report.
|
|
1590
|
+
|
|
1591
|
+
On Windows, executes Windows-specific requirement checks and a configuration-generation test using the provided CLI arguments; on non-Windows platforms, reports that platform-specific tests are not required.
|
|
1592
|
+
|
|
1593
|
+
Parameters:
|
|
1594
|
+
args (argparse.Namespace): CLI arguments forwarded to the Windows configuration-generation test (used only when running on Windows).
|
|
1595
|
+
|
|
1596
|
+
Returns:
|
|
1597
|
+
bool: `True` if Windows checks were executed (running on Windows), `False` otherwise.
|
|
1598
|
+
"""
|
|
1599
|
+
print("3. Platform-specific diagnostics...")
|
|
1600
|
+
import sys
|
|
1601
|
+
|
|
1602
|
+
from mmrelay.constants.app import WINDOWS_PLATFORM
|
|
1603
|
+
|
|
1604
|
+
on_windows = sys.platform == WINDOWS_PLATFORM
|
|
1605
|
+
print(f" Platform: {sys.platform}")
|
|
1606
|
+
print(f" Windows: {'Yes' if on_windows else 'No'}")
|
|
1607
|
+
|
|
1608
|
+
if on_windows:
|
|
1609
|
+
try:
|
|
1610
|
+
from mmrelay.windows_utils import (
|
|
1611
|
+
check_windows_requirements,
|
|
1612
|
+
test_config_generation_windows,
|
|
1613
|
+
)
|
|
1614
|
+
|
|
1615
|
+
# Check Windows requirements
|
|
1616
|
+
warnings = check_windows_requirements()
|
|
1617
|
+
if warnings:
|
|
1618
|
+
print(" Windows warnings: ⚠️")
|
|
1619
|
+
for line in warnings.split("\n"):
|
|
1620
|
+
if line.strip():
|
|
1621
|
+
print(f" {line}")
|
|
1622
|
+
else:
|
|
1623
|
+
print(" Windows compatibility: ✅")
|
|
1624
|
+
|
|
1625
|
+
# Run Windows-specific tests
|
|
1626
|
+
print("\n Windows config generation test:")
|
|
1627
|
+
results = test_config_generation_windows(args)
|
|
1628
|
+
|
|
1629
|
+
for component, result in results.items():
|
|
1630
|
+
if component == "overall_status":
|
|
1631
|
+
continue
|
|
1632
|
+
if isinstance(result, dict):
|
|
1633
|
+
status_icon = (
|
|
1634
|
+
"✅"
|
|
1635
|
+
if result["status"] == "ok"
|
|
1636
|
+
else "❌" if result["status"] == "error" else "⚠️"
|
|
1637
|
+
)
|
|
1638
|
+
print(f" {component}: {status_icon}")
|
|
1639
|
+
|
|
1640
|
+
overall = results.get("overall_status", "unknown")
|
|
1641
|
+
print(
|
|
1642
|
+
f" Overall Windows status: {'✅' if overall == 'ok' else '⚠️' if overall == 'partial' else '❌'}"
|
|
1643
|
+
)
|
|
1644
|
+
|
|
1645
|
+
except ImportError:
|
|
1646
|
+
print(" Windows utilities: ❌ (not available)")
|
|
1647
|
+
else:
|
|
1648
|
+
print(" Platform-specific tests: ✅ (Unix-like system)")
|
|
1649
|
+
|
|
1650
|
+
print()
|
|
1651
|
+
return on_windows
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
def _get_minimal_config_template():
|
|
1655
|
+
"""
|
|
1656
|
+
Return a minimal MMRelay YAML configuration template used as a fallback when the packaged sample_config.yaml cannot be located.
|
|
1657
|
+
|
|
1658
|
+
The template contains a small, functional configuration (matrix connection hints, a serial meshtastic connection, one room entry, and basic logging) that users can edit to create a working config file.
|
|
1659
|
+
|
|
1660
|
+
Returns:
|
|
1661
|
+
str: A YAML-formatted minimal configuration template.
|
|
1662
|
+
"""
|
|
1663
|
+
return """# MMRelay Configuration File
|
|
1664
|
+
# This is a minimal template created when the full sample config was unavailable
|
|
1665
|
+
# For complete configuration options, visit:
|
|
1666
|
+
# https://github.com/jeremiah-k/meshtastic-matrix-relay/wiki
|
|
1667
|
+
|
|
1668
|
+
matrix:
|
|
1669
|
+
homeserver: https://matrix.example.org
|
|
1670
|
+
# Use 'mmrelay auth login' to set up authentication
|
|
1671
|
+
# access_token: your_access_token_here
|
|
1672
|
+
# bot_user_id: '@your_bot:matrix.example.org'
|
|
1673
|
+
|
|
1674
|
+
meshtastic:
|
|
1675
|
+
connection_type: serial
|
|
1676
|
+
serial_port: /dev/ttyUSB0 # Windows: COM3, macOS: /dev/cu.usbserial-*
|
|
1677
|
+
# host: meshtastic.local # For network connection
|
|
1678
|
+
# ble_address: "your_device_address" # For BLE connection
|
|
1679
|
+
|
|
1680
|
+
matrix_rooms:
|
|
1681
|
+
- id: '#your-room:matrix.example.org'
|
|
1682
|
+
meshtastic_channel: 0
|
|
1683
|
+
|
|
1684
|
+
logging:
|
|
1685
|
+
level: info
|
|
1686
|
+
|
|
1687
|
+
# Uncomment and configure as needed:
|
|
1688
|
+
# database:
|
|
1689
|
+
# msg_map:
|
|
1690
|
+
# msgs_to_keep: 100
|
|
1691
|
+
|
|
1692
|
+
# plugins:
|
|
1693
|
+
# ping:
|
|
1694
|
+
# active: true
|
|
1695
|
+
# weather:
|
|
1696
|
+
# active: true
|
|
1697
|
+
# units: metric
|
|
1698
|
+
"""
|
|
1699
|
+
|
|
1700
|
+
|
|
1701
|
+
def _diagnose_minimal_config_template():
|
|
1702
|
+
"""
|
|
1703
|
+
Validate the built-in minimal YAML configuration template and print a concise pass/fail status.
|
|
1704
|
+
|
|
1705
|
+
Attempts to parse the string returned by _get_minimal_config_template() using yaml.safe_load. Prints a single-line result showing a ✅ with the template character length when the template is valid YAML, or a ❌ with the YAML parsing error when invalid. This is a non-destructive diagnostic helper that prints output and does not return a value.
|
|
1706
|
+
"""
|
|
1707
|
+
print("4. Testing minimal config template fallback...")
|
|
1708
|
+
try:
|
|
1709
|
+
template = _get_minimal_config_template()
|
|
1710
|
+
yaml.safe_load(template)
|
|
1711
|
+
print(f" Minimal template: ✅ ({len(template)} chars, valid YAML)")
|
|
1712
|
+
except yaml.YAMLError as e:
|
|
1713
|
+
print(f" Minimal template: ❌ ({e})")
|
|
1714
|
+
|
|
1715
|
+
print()
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def handle_config_diagnose(args):
|
|
1719
|
+
"""
|
|
1720
|
+
Run a set of non-destructive diagnostics for the MMRelay configuration subsystem and print a concise, human-readable report.
|
|
1721
|
+
|
|
1722
|
+
Performs four checks without modifying user files: (1) resolves and reports candidate configuration file paths and their directory accessibility, (2) verifies availability and readability of the packaged sample configuration, (3) executes platform-specific diagnostics (Windows checks when applicable), and (4) validates the built-in minimal YAML configuration template. Results and actionable guidance are written to stdout/stderr; additional Windows-specific guidance may be printed to stderr on unexpected failures.
|
|
1723
|
+
|
|
1724
|
+
Parameters:
|
|
1725
|
+
args (argparse.Namespace): Parsed CLI arguments used to determine configuration search paths and to control platform-specific diagnostic behavior.
|
|
1726
|
+
|
|
1727
|
+
Returns:
|
|
1728
|
+
int: Exit code where `0` indicates diagnostics completed successfully and `1` indicates a failure occurred (an error summary is printed to stderr).
|
|
1729
|
+
"""
|
|
1730
|
+
print("MMRelay Configuration System Diagnostics")
|
|
1731
|
+
print("=" * 40)
|
|
1732
|
+
print()
|
|
1733
|
+
|
|
1734
|
+
try:
|
|
1735
|
+
# Test 1: Basic config path resolution
|
|
1736
|
+
_diagnose_config_paths(args)
|
|
1737
|
+
|
|
1738
|
+
# Test 2: Sample config accessibility
|
|
1739
|
+
sample_exists = _diagnose_sample_config_accessibility()
|
|
1740
|
+
|
|
1741
|
+
# Test 3: Platform-specific diagnostics
|
|
1742
|
+
on_windows = _diagnose_platform_specific(args)
|
|
1743
|
+
|
|
1744
|
+
# Test 4: Minimal config template
|
|
1745
|
+
_diagnose_minimal_config_template()
|
|
1746
|
+
|
|
1747
|
+
print("=" * 40)
|
|
1748
|
+
print("Diagnostics complete!")
|
|
1749
|
+
|
|
1750
|
+
# Provide guidance based on results
|
|
1751
|
+
if on_windows and not sample_exists:
|
|
1752
|
+
print("\n💡 Windows Troubleshooting Tips:")
|
|
1753
|
+
print(" • Try: pip install --upgrade --force-reinstall mmrelay")
|
|
1754
|
+
print(" • Use: python -m mmrelay config generate")
|
|
1755
|
+
print(" • Check antivirus software for quarantined files")
|
|
1756
|
+
|
|
1757
|
+
return 0
|
|
1758
|
+
|
|
1759
|
+
except Exception as e:
|
|
1760
|
+
print(f"❌ Diagnostics failed: {e}", file=sys.stderr)
|
|
1761
|
+
|
|
1762
|
+
# Provide platform-specific guidance
|
|
1763
|
+
try:
|
|
1764
|
+
from mmrelay.windows_utils import get_windows_error_message, is_windows
|
|
1765
|
+
|
|
1766
|
+
if is_windows():
|
|
1767
|
+
error_msg = get_windows_error_message(e)
|
|
1768
|
+
print(f"\nWindows-specific guidance: {error_msg}", file=sys.stderr)
|
|
1769
|
+
except ImportError:
|
|
1770
|
+
pass
|
|
1771
|
+
|
|
1772
|
+
return 1
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
if __name__ == "__main__":
|
|
1776
|
+
import sys
|
|
1777
|
+
|
|
1778
|
+
sys.exit(main())
|
|
1779
|
+
|
|
1780
|
+
|
|
1781
|
+
def handle_cli_commands(args):
|
|
1782
|
+
"""
|
|
1783
|
+
Handle legacy CLI flags (--version, --install-service, --generate-config, --check-config).
|
|
1784
|
+
|
|
1785
|
+
This helper processes backward-compatible flags and may call sys.exit() for flags that perform an immediate action
|
|
1786
|
+
(e.g., install service, check config). Prefer the modern grouped subcommands (e.g., `mmrelay config`, `mmrelay auth`)
|
|
1787
|
+
when available.
|
|
1788
|
+
|
|
1789
|
+
Parameters:
|
|
1790
|
+
args (argparse.Namespace): Parsed command-line arguments produced by parse_arguments().
|
|
1791
|
+
|
|
1792
|
+
Returns:
|
|
1793
|
+
bool: True if a legacy command was handled (the process may have already exited), False to continue normal flow.
|
|
1794
|
+
"""
|
|
1795
|
+
# Handle --version
|
|
1796
|
+
if args.version:
|
|
1797
|
+
print_version()
|
|
1798
|
+
return True
|
|
1799
|
+
|
|
1800
|
+
# Handle --install-service
|
|
1801
|
+
if args.install_service:
|
|
1802
|
+
from mmrelay.setup_utils import install_service
|
|
1803
|
+
|
|
1804
|
+
success = install_service()
|
|
1805
|
+
import sys
|
|
1806
|
+
|
|
1807
|
+
sys.exit(0 if success else 1)
|
|
1808
|
+
|
|
1809
|
+
# Handle --generate-config
|
|
1810
|
+
if args.generate_config:
|
|
1811
|
+
if generate_sample_config():
|
|
1812
|
+
# Exit with success if config was generated
|
|
1813
|
+
return True
|
|
1814
|
+
else:
|
|
1815
|
+
# Exit with error if config generation failed
|
|
1816
|
+
import sys
|
|
1817
|
+
|
|
1818
|
+
sys.exit(1)
|
|
1819
|
+
|
|
1820
|
+
# Handle --check-config
|
|
1821
|
+
if args.check_config:
|
|
1822
|
+
import sys
|
|
1823
|
+
|
|
1824
|
+
sys.exit(0 if check_config() else 1)
|
|
1825
|
+
|
|
1826
|
+
# No commands were handled
|
|
1827
|
+
return False
|
|
1828
|
+
|
|
1829
|
+
|
|
1830
|
+
def generate_sample_config():
|
|
1831
|
+
"""
|
|
1832
|
+
Generate a sample configuration file at the highest-priority config path if no config already exists.
|
|
1833
|
+
|
|
1834
|
+
If an existing config file is found in any candidate path (from get_config_paths()), this function aborts and prints its location. Otherwise it creates a sample config at the first candidate path. Sources tried, in order, are:
|
|
1835
|
+
- the path returned by get_sample_config_path(),
|
|
1836
|
+
- the packaged resource mmrelay.tools:sample_config.yaml via importlib.resources,
|
|
1837
|
+
- a set of fallback filesystem locations relative to the package and current working directory,
|
|
1838
|
+
- a minimal configuration template as a last resort.
|
|
1839
|
+
|
|
1840
|
+
On success the sample is written to disk and (on Unix-like systems) secure file permissions are applied (owner read/write, 0o600). Returns True when a sample config is successfully generated and False on any error or if a config already exists.
|
|
1841
|
+
"""
|
|
1842
|
+
|
|
1843
|
+
# Get the first config path (highest priority)
|
|
1844
|
+
config_paths = get_config_paths()
|
|
1845
|
+
|
|
1846
|
+
# Check if any config file exists
|
|
1847
|
+
existing_config = None
|
|
1848
|
+
for path in config_paths:
|
|
1849
|
+
if os.path.isfile(path):
|
|
1850
|
+
existing_config = path
|
|
1851
|
+
break
|
|
1852
|
+
|
|
1853
|
+
if existing_config:
|
|
1854
|
+
print(f"A config file already exists at: {existing_config}")
|
|
1855
|
+
print(
|
|
1856
|
+
"Use --config to specify a different location if you want to generate a new one."
|
|
1857
|
+
)
|
|
1858
|
+
return False
|
|
1859
|
+
|
|
1860
|
+
# No config file exists, generate one in the first location
|
|
1861
|
+
target_path = config_paths[0]
|
|
1862
|
+
|
|
1863
|
+
# Directory should already exist from get_config_paths() call
|
|
1864
|
+
|
|
1865
|
+
# Use the helper function to get the sample config path
|
|
1866
|
+
sample_config_path = get_sample_config_path()
|
|
1867
|
+
|
|
1868
|
+
if os.path.exists(sample_config_path):
|
|
1869
|
+
# Copy the sample config file to the target path
|
|
1870
|
+
|
|
1871
|
+
try:
|
|
1872
|
+
shutil.copy2(sample_config_path, target_path)
|
|
1873
|
+
|
|
1874
|
+
# Set secure permissions on Unix systems (600 - owner read/write)
|
|
1875
|
+
set_secure_file_permissions(target_path)
|
|
1876
|
+
|
|
1877
|
+
print(f"Generated sample config file at: {target_path}")
|
|
1878
|
+
print(
|
|
1879
|
+
"\nEdit this file with your Matrix and Meshtastic settings before running mmrelay."
|
|
1880
|
+
)
|
|
1881
|
+
return True
|
|
1882
|
+
except (IOError, OSError) as e:
|
|
1883
|
+
# Provide Windows-specific error guidance if available
|
|
1884
|
+
try:
|
|
1885
|
+
from mmrelay.windows_utils import get_windows_error_message, is_windows
|
|
1886
|
+
|
|
1887
|
+
if is_windows():
|
|
1888
|
+
error_msg = get_windows_error_message(e)
|
|
1889
|
+
print(f"Error copying sample config file: {error_msg}")
|
|
1890
|
+
else:
|
|
1891
|
+
print(f"Error copying sample config file: {e}")
|
|
1892
|
+
except ImportError:
|
|
1893
|
+
print(f"Error copying sample config file: {e}")
|
|
1894
|
+
return False
|
|
1895
|
+
|
|
1896
|
+
# If the helper function failed, try using importlib.resources directly
|
|
1897
|
+
try:
|
|
1898
|
+
# Try to get the sample config from the package resources
|
|
1899
|
+
sample_config_content = (
|
|
1900
|
+
importlib.resources.files("mmrelay.tools")
|
|
1901
|
+
.joinpath("sample_config.yaml")
|
|
1902
|
+
.read_text()
|
|
1903
|
+
)
|
|
1904
|
+
|
|
1905
|
+
# Write the sample config to the target path
|
|
1906
|
+
with open(target_path, "w", encoding="utf-8") as f:
|
|
1907
|
+
f.write(sample_config_content)
|
|
1908
|
+
|
|
1909
|
+
# Set secure permissions on Unix systems (600 - owner read/write)
|
|
1910
|
+
set_secure_file_permissions(target_path)
|
|
1911
|
+
|
|
1912
|
+
print(f"Generated sample config file at: {target_path}")
|
|
1913
|
+
print(
|
|
1914
|
+
"\nEdit this file with your Matrix and Meshtastic settings before running mmrelay."
|
|
1915
|
+
)
|
|
1916
|
+
return True
|
|
1917
|
+
except (FileNotFoundError, ImportError, OSError) as e:
|
|
1918
|
+
print(f"Error accessing sample_config.yaml via importlib.resources: {e}")
|
|
1919
|
+
|
|
1920
|
+
# Provide Windows-specific guidance if needed
|
|
1921
|
+
try:
|
|
1922
|
+
from mmrelay.windows_utils import is_windows
|
|
1923
|
+
|
|
1924
|
+
if is_windows():
|
|
1925
|
+
print("This may be due to Windows installer packaging differences.")
|
|
1926
|
+
print("Trying alternative methods...")
|
|
1927
|
+
except ImportError:
|
|
1928
|
+
pass
|
|
1929
|
+
|
|
1930
|
+
# Fallback to traditional file paths if importlib.resources fails
|
|
1931
|
+
# First, check in the package directory
|
|
1932
|
+
package_dir = os.path.dirname(__file__)
|
|
1933
|
+
sample_config_paths = [
|
|
1934
|
+
# Check in the tools subdirectory of the package
|
|
1935
|
+
os.path.join(package_dir, "tools", "sample_config.yaml"),
|
|
1936
|
+
# Check in the package directory
|
|
1937
|
+
os.path.join(package_dir, "sample_config.yaml"),
|
|
1938
|
+
# Check in the repository root
|
|
1939
|
+
os.path.join(
|
|
1940
|
+
os.path.dirname(os.path.dirname(package_dir)), "sample_config.yaml"
|
|
1941
|
+
),
|
|
1942
|
+
# Check in the current directory
|
|
1943
|
+
os.path.join(os.getcwd(), "sample_config.yaml"),
|
|
1944
|
+
]
|
|
1945
|
+
|
|
1946
|
+
for path in sample_config_paths:
|
|
1947
|
+
if os.path.exists(path):
|
|
1948
|
+
try:
|
|
1949
|
+
shutil.copy(path, target_path)
|
|
1950
|
+
print(f"Generated sample config file at: {target_path}")
|
|
1951
|
+
print(
|
|
1952
|
+
"\nEdit this file with your Matrix and Meshtastic settings before running mmrelay."
|
|
1953
|
+
)
|
|
1954
|
+
return True
|
|
1955
|
+
except (IOError, OSError) as e:
|
|
1956
|
+
# Provide Windows-specific error guidance if available
|
|
1957
|
+
try:
|
|
1958
|
+
from mmrelay.windows_utils import (
|
|
1959
|
+
get_windows_error_message,
|
|
1960
|
+
is_windows,
|
|
1961
|
+
)
|
|
1962
|
+
|
|
1963
|
+
if is_windows():
|
|
1964
|
+
error_msg = get_windows_error_message(e)
|
|
1965
|
+
print(
|
|
1966
|
+
f"Error copying sample config file from {path}: {error_msg}"
|
|
1967
|
+
)
|
|
1968
|
+
else:
|
|
1969
|
+
print(f"Error copying sample config file from {path}: {e}")
|
|
1970
|
+
except ImportError:
|
|
1971
|
+
print(f"Error copying sample config file from {path}: {e}")
|
|
1972
|
+
return False
|
|
1973
|
+
|
|
1974
|
+
print("Error: Could not find sample_config.yaml in any location")
|
|
1975
|
+
|
|
1976
|
+
# Last resort: create a minimal config template
|
|
1977
|
+
print("\nAttempting to create minimal config template...")
|
|
1978
|
+
try:
|
|
1979
|
+
minimal_config = _get_minimal_config_template()
|
|
1980
|
+
with open(target_path, "w", encoding="utf-8") as f:
|
|
1981
|
+
f.write(minimal_config)
|
|
1982
|
+
|
|
1983
|
+
# Set secure permissions on Unix systems
|
|
1984
|
+
set_secure_file_permissions(target_path)
|
|
1985
|
+
|
|
1986
|
+
print(f"Created minimal config template at: {target_path}")
|
|
1987
|
+
print(
|
|
1988
|
+
"\n⚠️ This is a minimal template. Please refer to documentation for full configuration options."
|
|
1989
|
+
)
|
|
1990
|
+
print("Visit: https://github.com/jeremiah-k/meshtastic-matrix-relay/wiki")
|
|
1991
|
+
return True
|
|
1992
|
+
|
|
1993
|
+
except (IOError, OSError) as e:
|
|
1994
|
+
print(f"Failed to create minimal config template: {e}")
|
|
1995
|
+
|
|
1996
|
+
# Provide Windows-specific troubleshooting guidance
|
|
1997
|
+
try:
|
|
1998
|
+
from mmrelay.windows_utils import is_windows
|
|
1999
|
+
|
|
2000
|
+
if is_windows():
|
|
2001
|
+
print("\nWindows Troubleshooting:")
|
|
2002
|
+
print("1. Check if MMRelay was installed correctly")
|
|
2003
|
+
print("2. Try reinstalling with: pipx install --force mmrelay")
|
|
2004
|
+
print(
|
|
2005
|
+
"3. Use alternative entry point: python -m mmrelay config generate"
|
|
2006
|
+
)
|
|
2007
|
+
print("4. Check antivirus software - it may have quarantined files")
|
|
2008
|
+
print("5. Run diagnostics: python -m mmrelay config diagnose")
|
|
2009
|
+
print("6. Manually create config file using documentation")
|
|
2010
|
+
except ImportError:
|
|
2011
|
+
pass
|
|
2012
|
+
|
|
2013
|
+
return False
|