mmrelay 1.1.4__py3-none-any.whl → 1.2.1__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.

Potentially problematic release.


This version of mmrelay might be problematic. Click here for more details.

mmrelay/cli.py CHANGED
@@ -5,14 +5,27 @@ Command-line interface handling for the Meshtastic Matrix Relay.
5
5
  import argparse
6
6
  import importlib.resources
7
7
  import os
8
+ import shutil
8
9
  import sys
9
-
10
- import yaml
11
- from yaml.loader import SafeLoader
10
+ from collections.abc import Mapping
12
11
 
13
12
  # Import version from package
14
13
  from mmrelay import __version__
15
- from mmrelay.config import get_config_paths
14
+ from mmrelay.cli_utils import (
15
+ get_command,
16
+ get_deprecation_warning,
17
+ msg_for_e2ee_support,
18
+ msg_or_run_auth_login,
19
+ msg_run_auth_login,
20
+ msg_setup_auth,
21
+ msg_setup_authentication,
22
+ msg_suggest_generate_config,
23
+ )
24
+ from mmrelay.config import (
25
+ get_config_paths,
26
+ set_secure_file_permissions,
27
+ validate_yaml_syntax,
28
+ )
16
29
  from mmrelay.constants.app import WINDOWS_PLATFORM
17
30
  from mmrelay.constants.config import (
18
31
  CONFIG_KEY_ACCESS_TOKEN,
@@ -33,15 +46,26 @@ from mmrelay.constants.network import (
33
46
  )
34
47
  from mmrelay.tools import get_sample_config_path
35
48
 
49
+ # =============================================================================
50
+ # CLI Argument Parsing and Command Handling
51
+ # =============================================================================
52
+
36
53
 
37
54
  def parse_arguments():
38
55
  """
39
- Parse and validate command-line arguments for the Meshtastic Matrix Relay CLI.
56
+ Parse command-line arguments for the Meshtastic Matrix Relay CLI.
57
+
58
+ Builds a modern grouped CLI with subcommands for config (generate, check), auth (login, status),
59
+ and service (install), while preserving hidden legacy flags (--generate-config, --install-service,
60
+ --check-config, --auth) for backward compatibility. Supports global options: --config,
61
+ --data-dir, --log-level, --logfile, and --version.
40
62
 
41
- Supports options for specifying configuration file, data directory, logging preferences, version display, sample configuration generation, service installation, and configuration validation. On Windows, also accepts a deprecated positional argument for the config file path with a warning. Ignores unknown arguments outside of test environments and warns if any are present.
63
+ Unknown arguments are ignored when running outside of test environments (parsed via
64
+ parse_known_args); a warning is printed if unknown args are present and the process does not
65
+ appear to be a test run.
42
66
 
43
67
  Returns:
44
- argparse.Namespace: Parsed command-line arguments.
68
+ argparse.Namespace: The parsed arguments namespace.
45
69
  """
46
70
  parser = argparse.ArgumentParser(
47
71
  description="Meshtastic Matrix Relay - Bridge between Meshtastic and Matrix"
@@ -64,49 +88,125 @@ def parse_arguments():
64
88
  default=None,
65
89
  )
66
90
  parser.add_argument("--version", action="store_true", help="Show version and exit")
91
+ # Deprecated flags (hidden from help but still functional)
67
92
  parser.add_argument(
68
93
  "--generate-config",
69
94
  action="store_true",
70
- help="Generate a sample config.yaml file",
95
+ help=argparse.SUPPRESS,
71
96
  )
72
97
  parser.add_argument(
73
98
  "--install-service",
74
99
  action="store_true",
75
- help="Install or update the systemd user service",
100
+ help=argparse.SUPPRESS,
76
101
  )
77
102
  parser.add_argument(
78
103
  "--check-config",
79
104
  action="store_true",
80
- help="Check if the configuration file is valid",
105
+ help=argparse.SUPPRESS,
106
+ )
107
+ parser.add_argument(
108
+ "--auth",
109
+ action="store_true",
110
+ help=argparse.SUPPRESS,
81
111
  )
82
112
 
83
- # Windows-specific handling for backward compatibility
84
- # On Windows, add a positional argument for the config file path
85
- if sys.platform == WINDOWS_PLATFORM:
86
- parser.add_argument(
87
- "config_path", nargs="?", help=argparse.SUPPRESS, default=None
88
- )
113
+ # Add grouped subcommands for modern CLI interface
114
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
115
+
116
+ # CONFIG group
117
+ config_parser = subparsers.add_parser(
118
+ "config",
119
+ help="Configuration management",
120
+ description="Manage configuration files and validation",
121
+ )
122
+ config_subparsers = config_parser.add_subparsers(
123
+ dest="config_command", help="Config commands", required=True
124
+ )
125
+ config_subparsers.add_parser(
126
+ "generate",
127
+ help="Create sample config.yaml file",
128
+ description="Generate a sample configuration file with default settings",
129
+ )
130
+ config_subparsers.add_parser(
131
+ "check",
132
+ help="Validate configuration file",
133
+ description="Check configuration file syntax and completeness",
134
+ )
135
+
136
+ # AUTH group
137
+ auth_parser = subparsers.add_parser(
138
+ "auth",
139
+ help="Authentication management",
140
+ description="Manage Matrix authentication and credentials",
141
+ )
142
+ auth_subparsers = auth_parser.add_subparsers(
143
+ dest="auth_command", help="Auth commands"
144
+ )
145
+ login_parser = auth_subparsers.add_parser(
146
+ "login",
147
+ help="Authenticate with Matrix",
148
+ description="Set up Matrix authentication for E2EE support",
149
+ )
150
+ login_parser.add_argument(
151
+ "--homeserver",
152
+ help="Matrix homeserver URL (e.g., https://matrix.org). If provided, --username and --password are also required.",
153
+ )
154
+ login_parser.add_argument(
155
+ "--username",
156
+ help="Matrix username (with or without @ and :server). If provided, --homeserver and --password are also required.",
157
+ )
158
+ login_parser.add_argument(
159
+ "--password",
160
+ metavar="PWD",
161
+ help="Matrix password (can be empty). If provided, --homeserver and --username are also required. For security, prefer interactive mode.",
162
+ )
163
+
164
+ auth_subparsers.add_parser(
165
+ "status",
166
+ help="Check authentication status",
167
+ description="Display current Matrix authentication status",
168
+ )
169
+
170
+ logout_parser = auth_subparsers.add_parser(
171
+ "logout",
172
+ help="Log out and clear all sessions",
173
+ description="Clear all Matrix authentication data and E2EE store",
174
+ )
175
+ logout_parser.add_argument(
176
+ "--password",
177
+ nargs="?",
178
+ const="",
179
+ help="Password for verification. If no value provided, will prompt securely.",
180
+ type=str,
181
+ )
182
+ logout_parser.add_argument(
183
+ "-y",
184
+ "--yes",
185
+ action="store_true",
186
+ help="Do not prompt for confirmation (useful for non-interactive environments)",
187
+ )
188
+
189
+ # SERVICE group
190
+ service_parser = subparsers.add_parser(
191
+ "service",
192
+ help="Service management",
193
+ description="Manage systemd user service for MMRelay",
194
+ )
195
+ service_subparsers = service_parser.add_subparsers(
196
+ dest="service_command", help="Service commands", required=True
197
+ )
198
+ service_subparsers.add_parser(
199
+ "install",
200
+ help="Install systemd user service",
201
+ description="Install or update the systemd user service for MMRelay",
202
+ )
89
203
 
90
204
  # Use parse_known_args to handle unknown arguments gracefully (e.g., pytest args)
91
205
  args, unknown = parser.parse_known_args()
92
- # If there are unknown arguments and we're not in a test environment, warn about them
93
- if unknown and not any("pytest" in arg or "test" in arg for arg in sys.argv):
94
- print(f"Warning: Unknown arguments ignored: {unknown}")
95
-
96
- # If on Windows and a positional config path is provided but --config is not, use the positional one
97
- if (
98
- sys.platform == WINDOWS_PLATFORM
99
- and hasattr(args, "config_path")
100
- and args.config_path
101
- and not args.config
102
- ):
103
- args.config = args.config_path
104
- # Print a deprecation warning
105
- print("Warning: Using positional argument for config file is deprecated.")
106
- print(f"Please use --config {args.config_path} instead.")
107
- # Remove the positional argument from sys.argv to avoid issues with other argument parsers
108
- if args.config_path in sys.argv:
109
- sys.argv.remove(args.config_path)
206
+ # If there are unknown arguments and we're not in a test invocation, warn about them
207
+ # Heuristic: suppress warning when pytest appears in argv (unit tests may pass extra args)
208
+ if unknown and not any("pytest" in arg or "py.test" in arg for arg in sys.argv):
209
+ print(f"Warning: Unknown arguments ignored: {unknown}", file=sys.stderr)
110
210
 
111
211
  return args
112
212
 
@@ -125,20 +225,571 @@ def print_version():
125
225
  """
126
226
  Print the version in a simple format.
127
227
  """
128
- print(f"MMRelay v{__version__}")
228
+ print(f"MMRelay version {__version__}")
129
229
 
130
230
 
131
- def check_config(args=None):
231
+ def _validate_e2ee_dependencies():
232
+ """
233
+ Check whether end-to-end encryption (E2EE) runtime dependencies are available.
234
+
235
+ Performs a platform check and attempts to import required packages (python-olm, nio.crypto.OlmDevice,
236
+ and nio.store.SqliteStore). Prints a short user-facing status message and guidance.
237
+
238
+ Returns:
239
+ bool: True if the platform supports E2EE and all required dependencies can be imported;
240
+ False if running on an unsupported platform (Windows) or if any dependency is missing.
241
+ """
242
+ if sys.platform == WINDOWS_PLATFORM:
243
+ print("❌ Error: E2EE is not supported on Windows")
244
+ print(" Reason: python-olm library requires native C libraries")
245
+ print(" Solution: Use Linux or macOS for E2EE support")
246
+ return False
247
+
248
+ # Check if E2EE dependencies are available
249
+ try:
250
+ import olm # noqa: F401
251
+ from nio.crypto import OlmDevice # noqa: F401
252
+ from nio.store import SqliteStore # noqa: F401
253
+
254
+ print("✅ E2EE dependencies are installed")
255
+ return True
256
+ except ImportError:
257
+ print("❌ Error: E2EE enabled but dependencies not installed")
258
+ print(" Install E2EE support: pipx install 'mmrelay[e2e]'")
259
+ return False
260
+
261
+
262
+ def _validate_credentials_json(config_path):
263
+ """
264
+ Validate that a credentials.json file exists (adjacent to config_path or in the base directory) and contains the required Matrix session fields.
265
+
266
+ 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.
267
+
268
+ Parameters:
269
+ config_path (str): Path to the configuration file used to determine the primary search directory for credentials.json.
270
+
271
+ Returns:
272
+ bool: True if a credentials.json was found and contains all required non-empty fields; False otherwise.
273
+ """
274
+ try:
275
+ import json
276
+
277
+ # Look for credentials.json using helper function
278
+ credentials_path = _find_credentials_json_path(config_path)
279
+ if not credentials_path:
280
+ return False
281
+
282
+ # Load and validate credentials
283
+ with open(credentials_path, "r", encoding="utf-8") as f:
284
+ credentials = json.load(f)
285
+
286
+ # Check for required fields
287
+ required_fields = ["homeserver", "access_token", "user_id", "device_id"]
288
+ missing_fields = [
289
+ field
290
+ for field in required_fields
291
+ if not _is_valid_non_empty_string((credentials or {}).get(field))
292
+ ]
293
+
294
+ if missing_fields:
295
+ print(
296
+ f"❌ Error: credentials.json missing required fields: {', '.join(missing_fields)}"
297
+ )
298
+ print(f" {msg_run_auth_login()}")
299
+ return False
300
+
301
+ return True
302
+ except Exception as e:
303
+ print(f"❌ Error: Could not validate credentials.json: {e}")
304
+ return False
305
+
306
+
307
+ def _is_valid_non_empty_string(value) -> bool:
308
+ """
309
+ Return True if value is a string containing non-whitespace characters.
310
+
311
+ Checks that the input is an instance of `str` and that stripping whitespace
312
+ does not produce an empty string.
313
+
314
+ Returns:
315
+ bool: True when value is a non-empty, non-whitespace-only string; otherwise False.
316
+ """
317
+ return isinstance(value, str) and value.strip() != ""
318
+
319
+
320
+ def _has_valid_password_auth(matrix_section):
321
+ """
322
+ Return True if the given Matrix config section contains valid password-based authentication settings.
323
+
324
+ The function expects matrix_section to be a dict-like mapping from configuration keys to values.
325
+ It validates that:
326
+ - `homeserver` and `bot_user_id` are present and are non-empty strings (after trimming),
327
+ - `password` is present and is a string (it may be an empty string, which is accepted).
328
+
329
+ If matrix_section is not a dict, the function returns False.
330
+
331
+ Parameters:
332
+ matrix_section: dict-like Matrix configuration section (may be the parsed "matrix" config).
333
+
334
+ Returns:
335
+ bool: True when password-based authentication is correctly configured as described above; otherwise False.
336
+ """
337
+ if not isinstance(matrix_section, Mapping):
338
+ return False
339
+
340
+ pwd = matrix_section.get("password")
341
+ homeserver = matrix_section.get(CONFIG_KEY_HOMESERVER)
342
+ bot_user_id = matrix_section.get(CONFIG_KEY_BOT_USER_ID)
343
+
344
+ # Allow empty password strings (some environments legitimately use empty passwords).
345
+ # Homeserver and bot_user_id must still be valid non-empty strings.
346
+ return (
347
+ isinstance(pwd, str)
348
+ and _is_valid_non_empty_string(homeserver)
349
+ and _is_valid_non_empty_string(bot_user_id)
350
+ )
351
+
352
+
353
+ def _validate_matrix_authentication(config_path, matrix_section):
354
+ """
355
+ Determine whether Matrix authentication is configured and usable.
356
+
357
+ Checks for a valid credentials.json (located relative to the provided config path) and, if not present,
358
+ falls back to an access_token in the provided matrix_section. Returns True when authentication
359
+ information is found and usable; returns False when no authentication is configured.
360
+
361
+ Parameters:
362
+ config_path (str | os.PathLike): Path to the application's YAML config file; used to locate a
363
+ credentials.json candidate in the same directory or standard locations.
364
+ matrix_section (Mapping | None): The parsed "matrix" configuration section (mapping-like). If
365
+ provided, an "access_token" key will be considered as a valid fallback when credentials.json
366
+ is absent.
367
+
368
+ Returns:
369
+ bool: True if a valid authentication method (credentials.json or access_token) is available,
370
+ False otherwise.
371
+
372
+ Notes:
373
+ - The function prefers credentials.json over an access_token if both are present.
374
+ - The function emits user-facing status messages describing which authentication source is used
375
+ and whether E2EE support is available.
376
+ """
377
+ has_valid_credentials = _validate_credentials_json(config_path)
378
+ token = (matrix_section or {}).get(CONFIG_KEY_ACCESS_TOKEN)
379
+ has_access_token = _is_valid_non_empty_string(token)
380
+
381
+ has_password = _has_valid_password_auth(matrix_section)
382
+
383
+ if has_valid_credentials:
384
+ print("✅ Using credentials.json for Matrix authentication")
385
+ if sys.platform != WINDOWS_PLATFORM:
386
+ print(" E2EE support available (if enabled)")
387
+ return True
388
+
389
+ elif has_password:
390
+ print(
391
+ "✅ Using password in config for initial authentication (credentials.json will be created on first run)"
392
+ )
393
+ print(f" {msg_for_e2ee_support()}")
394
+ return True
395
+ elif has_access_token:
396
+ print(
397
+ "✅ Using access_token for Matrix authentication (deprecated — consider 'mmrelay auth login' to create credentials.json)"
398
+ )
399
+ print(f" {msg_for_e2ee_support()}")
400
+ return True
401
+
402
+ else:
403
+ print("❌ Error: No Matrix authentication configured")
404
+ print(f" {msg_setup_auth()}")
405
+ return False
406
+
407
+
408
+ def _validate_e2ee_config(config, matrix_section, config_path):
409
+ """
410
+ Validate end-to-end encryption (E2EE) configuration and Matrix authentication for the given config.
411
+
412
+ Performs these checks:
413
+ - Confirms Matrix authentication is available (via credentials.json or matrix access token); returns False if authentication is missing or invalid.
414
+ - If no matrix section is present, treats E2EE as not configured and returns True.
415
+ - 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.
416
+
417
+ Parameters:
418
+ config_path (str): Path to the active configuration file (used to locate credentials.json and related auth artifacts).
419
+ matrix_section (dict | None): The "matrix" subsection of the parsed config (may be None or empty).
420
+ config (dict): Full parsed configuration (unused for most checks but kept for consistency with caller signature).
421
+
422
+ Returns:
423
+ bool: True if configuration and required authentication/dependencies are valid (or E2EE is not configured); False if validation fails.
424
+
425
+ Side effects:
426
+ Prints informational or error messages about authentication, dependency checks, and E2EE store path status.
427
+ """
428
+ # First validate authentication
429
+ if not _validate_matrix_authentication(config_path, matrix_section):
430
+ return False
431
+
432
+ # Check for E2EE configuration
433
+ if not matrix_section:
434
+ return True # No matrix section means no E2EE config to validate
435
+
436
+ e2ee_config = matrix_section.get("e2ee", {})
437
+ encryption_config = matrix_section.get("encryption", {}) # Legacy support
438
+
439
+ e2ee_enabled = e2ee_config.get("enabled", False) or encryption_config.get(
440
+ "enabled", False
441
+ )
442
+
443
+ if e2ee_enabled:
444
+ # Platform and dependency check
445
+ if not _validate_e2ee_dependencies():
446
+ return False
447
+
448
+ # Store path validation
449
+ store_path = e2ee_config.get("store_path") or encryption_config.get(
450
+ "store_path"
451
+ )
452
+ if store_path:
453
+ expanded_path = os.path.expanduser(store_path)
454
+ if not os.path.exists(expanded_path):
455
+ print(f"ℹ️ Note: E2EE store directory will be created: {expanded_path}")
456
+
457
+ print("✅ E2EE configuration is valid")
458
+
459
+ return True
460
+
461
+
462
+ def _analyze_e2ee_setup(config, config_path):
463
+ """
464
+ Analyze local E2EE readiness without contacting Matrix.
465
+
466
+ Performs an offline inspection of the environment and configuration to determine
467
+ whether end-to-end encryption (E2EE) can be used. Checks platform support
468
+ (Windows is considered unsupported), presence of required Python dependencies
469
+ (olm and selected nio components), whether E2EE is enabled in the provided
470
+ config, and whether a credentials.json is available adjacent to the supplied
471
+ config_path or in the standard base directory.
472
+
473
+ Parameters:
474
+ config (dict): Parsed configuration (typically from config.yaml). Only the
475
+ "matrix" section is consulted to detect E2EE/encryption enablement.
476
+ config_path (str): Path to the configuration file used to locate a
477
+ credentials.json sibling; also used to resolve an alternate standard
478
+ credentials location.
479
+
480
+ Returns:
481
+ dict: Analysis summary with these keys:
482
+ - config_enabled (bool): True if E2EE/encryption is enabled in config.
483
+ - dependencies_available (bool): True if required E2EE packages are
484
+ importable.
485
+ - credentials_available (bool): True if a usable credentials.json was
486
+ found.
487
+ - platform_supported (bool): False on unsupported platforms (Windows).
488
+ - overall_status (str): One of "ready", "disabled", "not_supported",
489
+ "incomplete", or "unknown" describing the combined readiness.
490
+ - recommendations (list): Human-actionable strings suggesting fixes or
491
+ next steps (e.g., enable E2EE in config, install dependencies, run
492
+ auth login).
493
+ """
494
+ analysis = {
495
+ "config_enabled": False,
496
+ "dependencies_available": False,
497
+ "credentials_available": False,
498
+ "platform_supported": True,
499
+ "overall_status": "unknown",
500
+ "recommendations": [],
501
+ }
502
+
503
+ # Check platform support
504
+ if sys.platform == WINDOWS_PLATFORM:
505
+ analysis["platform_supported"] = False
506
+ analysis["recommendations"].append(
507
+ "E2EE is not supported on Windows. Use Linux/macOS for E2EE support."
508
+ )
509
+
510
+ # Check dependencies
511
+ try:
512
+ import olm # noqa: F401
513
+ from nio.crypto import OlmDevice # noqa: F401
514
+ from nio.store import SqliteStore # noqa: F401
515
+
516
+ analysis["dependencies_available"] = True
517
+ except ImportError:
518
+ analysis["dependencies_available"] = False
519
+ analysis["recommendations"].append(
520
+ "Install E2EE dependencies: pipx install 'mmrelay[e2e]'"
521
+ )
522
+
523
+ # Check config setting
524
+ matrix_section = config.get("matrix", {})
525
+ e2ee_config = matrix_section.get("e2ee", {})
526
+ encryption_config = matrix_section.get("encryption", {}) # Legacy support
527
+ analysis["config_enabled"] = e2ee_config.get(
528
+ "enabled", False
529
+ ) or encryption_config.get("enabled", False)
530
+
531
+ if not analysis["config_enabled"]:
532
+ analysis["recommendations"].append(
533
+ "Enable E2EE in config.yaml under matrix section: e2ee: enabled: true"
534
+ )
535
+
536
+ # Check credentials file existence
537
+ credentials_path = _find_credentials_json_path(config_path)
538
+ analysis["credentials_available"] = bool(credentials_path)
539
+
540
+ if not analysis["credentials_available"]:
541
+ analysis["recommendations"].append(
542
+ "Set up Matrix authentication: mmrelay auth login"
543
+ )
544
+
545
+ # Determine overall status based on setup only
546
+ if not analysis["platform_supported"]:
547
+ analysis["overall_status"] = "not_supported"
548
+ elif (
549
+ analysis["config_enabled"]
550
+ and analysis["dependencies_available"]
551
+ and analysis["credentials_available"]
552
+ ):
553
+ analysis["overall_status"] = "ready"
554
+ elif not analysis["config_enabled"]:
555
+ analysis["overall_status"] = "disabled"
556
+ else:
557
+ analysis["overall_status"] = "incomplete"
558
+
559
+ return analysis
560
+
561
+
562
+ def _find_credentials_json_path(config_path: str | None) -> str | None:
563
+ """
564
+ Return the filesystem path to a credentials.json file if one can be found, otherwise None.
565
+
566
+ Search order:
567
+ 1. A credentials.json file located in the same directory as the provided config_path.
568
+ 2. A credentials.json file in the application's base directory (get_base_dir()).
569
+
570
+ Parameters:
571
+ config_path (str | None): Path to the configuration file used to derive the adjacent credentials.json location.
572
+
573
+ Returns:
574
+ str | None: Absolute path to the discovered credentials.json, or None if no file is found.
575
+ """
576
+ if not config_path:
577
+ from mmrelay.config import get_base_dir
578
+
579
+ standard = os.path.join(get_base_dir(), "credentials.json")
580
+ return standard if os.path.exists(standard) else None
581
+
582
+ config_dir = os.path.dirname(config_path)
583
+ candidate = os.path.join(config_dir, "credentials.json")
584
+ if os.path.exists(candidate):
585
+ return candidate
586
+ from mmrelay.config import get_base_dir
587
+
588
+ standard = os.path.join(get_base_dir(), "credentials.json")
589
+ return standard if os.path.exists(standard) else None
590
+
591
+
592
+ def _print_unified_e2ee_analysis(e2ee_status):
593
+ """
594
+ Print a concise, user-facing analysis of E2EE readiness.
595
+
596
+ Given a status dictionary produced by the E2EE analysis routines, prints platform support,
597
+ dependency availability, whether E2EE is enabled in configuration, whether credentials.json
598
+ is available, and the overall status. If the overall status is not "ready", prints actionable
599
+ fix instructions.
600
+
601
+ Parameters:
602
+ e2ee_status (dict): Status dictionary with (at least) the following keys:
603
+ - platform_supported (bool): whether the current OS/platform supports E2EE.
604
+ - dependencies_installed or dependencies_available (bool): whether required E2EE
605
+ Python packages and runtime dependencies are present.
606
+ - enabled or config_enabled (bool): whether E2EE is enabled in the configuration.
607
+ - credentials_available (bool): whether a usable credentials.json is present.
608
+ - overall_status (str): high-level status ("ready", "disabled", "incomplete", etc.).
609
+ """
610
+ print("\n🔐 E2EE Configuration Analysis:")
611
+
612
+ # Platform support
613
+ if e2ee_status.get("platform_supported", True):
614
+ print("✅ Platform: E2EE supported")
615
+ else:
616
+ print("❌ Platform: E2EE not supported on Windows")
617
+
618
+ # Dependencies
619
+ if e2ee_status.get(
620
+ "dependencies_installed", e2ee_status.get("dependencies_available", False)
621
+ ):
622
+ print("✅ Dependencies: E2EE dependencies installed")
623
+ else:
624
+ print("❌ Dependencies: E2EE dependencies not fully installed")
625
+
626
+ # Configuration
627
+ if e2ee_status.get("enabled", e2ee_status.get("config_enabled", False)):
628
+ print("✅ Configuration: E2EE enabled")
629
+ else:
630
+ print("❌ Configuration: E2EE disabled")
631
+
632
+ # Authentication
633
+ if e2ee_status.get("credentials_available", False):
634
+ print("✅ Authentication: credentials.json found")
635
+ else:
636
+ print("❌ Authentication: credentials.json not found")
637
+
638
+ # Overall status
639
+ print(
640
+ f"\n📊 Overall Status: {e2ee_status.get('overall_status', 'unknown').upper()}"
641
+ )
642
+
643
+ # Show fix instructions if needed
644
+ if e2ee_status.get("overall_status") != "ready":
645
+ from mmrelay.e2ee_utils import get_e2ee_fix_instructions
646
+
647
+ instructions = get_e2ee_fix_instructions(e2ee_status)
648
+ print("\n🔧 To fix E2EE issues:")
649
+ for instruction in instructions:
650
+ print(f" {instruction}")
651
+
652
+
653
+ def _print_e2ee_analysis(analysis):
654
+ """
655
+ Print a user-facing analysis of end-to-end encryption (E2EE) readiness to standard output.
656
+
657
+ Parameters:
658
+ analysis (dict): Analysis results with the following keys:
659
+ - dependencies_available (bool): True if required E2EE dependencies (e.g., python-olm) are present.
660
+ - credentials_available (bool): True if a usable credentials.json was found.
661
+ - platform_supported (bool): True if the current platform supports E2EE (Windows is considered unsupported).
662
+ - config_enabled (bool): True if E2EE is enabled in the configuration.
663
+ - overall_status (str): One of "ready", "disabled", "not_supported", or "incomplete" indicating the aggregated readiness.
664
+ - recommendations (list[str]): User-facing remediation steps or suggestions (may be empty).
665
+
666
+ Returns:
667
+ None
668
+
669
+ Notes:
670
+ - This function only prints a human-readable report and does not modify state.
132
671
  """
133
- Validates the application's configuration file for required structure and fields.
672
+ print("\n🔐 E2EE Configuration Analysis:")
673
+
674
+ # Current settings
675
+ print("\n📋 Current Settings:")
676
+
677
+ # Dependencies
678
+ if analysis["dependencies_available"]:
679
+ print(" ✅ Dependencies: Installed (python-olm available)")
680
+ else:
681
+ print(" ❌ Dependencies: Missing (python-olm not installed)")
682
+
683
+ # Credentials
684
+ if analysis["credentials_available"]:
685
+ print(" ✅ Authentication: Ready (credentials.json found)")
686
+ else:
687
+ print(" ❌ Authentication: Missing (no credentials.json)")
688
+
689
+ # Platform
690
+ if not analysis["platform_supported"]:
691
+ print(" ❌ Platform: Windows (E2EE not supported)")
692
+ else:
693
+ print(" ✅ Platform: Supported")
694
+
695
+ # Config setting
696
+ if analysis["config_enabled"]:
697
+ print(" ✅ Configuration: ENABLED (e2ee.enabled: true)")
698
+ else:
699
+ print(" ❌ Configuration: DISABLED (e2ee.enabled: false)")
700
+
701
+ # Predicted behavior
702
+ print("\n🚨 PREDICTED BEHAVIOR:")
703
+ if analysis["overall_status"] == "ready":
704
+ print(" ✅ E2EE is fully configured and ready")
705
+ print(" ✅ Encrypted rooms will receive encrypted messages")
706
+ print(" ✅ Unencrypted rooms will receive normal messages")
707
+ elif analysis["overall_status"] == "disabled":
708
+ print(" ⚠️ E2EE is disabled in configuration")
709
+ print(" ❌ Messages to encrypted rooms will be BLOCKED")
710
+ print(" ✅ Messages to unencrypted rooms will work normally")
711
+ elif analysis["overall_status"] == "not_supported":
712
+ print(" ❌ E2EE not supported on Windows")
713
+ print(" ❌ Messages to encrypted rooms will be BLOCKED")
714
+ else:
715
+ print(" ⚠️ E2EE setup incomplete - some issues need to be resolved")
716
+ print(" ❌ Messages to encrypted rooms may be BLOCKED")
717
+
718
+ print(
719
+ "\n💡 Note: Room encryption status will be checked when mmrelay connects to Matrix"
720
+ )
134
721
 
135
- If a configuration file is found, checks for the presence and correctness of required sections and keys, including Matrix and Meshtastic settings, and validates the format of matrix rooms. Prints errors or warnings for missing or deprecated fields. Returns True if the configuration is valid, otherwise False.
722
+ # Recommendations
723
+ if analysis["recommendations"]:
724
+ print("\n🔧 TO FIX:")
725
+ for i, rec in enumerate(analysis["recommendations"], 1):
726
+ print(f" {i}. {rec}")
727
+
728
+ if analysis["overall_status"] == "ready":
729
+ print(
730
+ "\n✅ E2EE setup is complete! Run 'mmrelay' to start with E2EE support."
731
+ )
732
+ else:
733
+ print(
734
+ "\n⚠️ After fixing issues above, run 'mmrelay config check' again to verify."
735
+ )
736
+
737
+
738
+ def _print_environment_summary():
739
+ """
740
+ Print a concise environment summary including platform, Python version, and Matrix E2EE capability.
741
+
742
+ Provides:
743
+ - Platform and Python version.
744
+ - Whether E2EE is supported on the current platform (Windows is reported as not supported).
745
+ - Whether the `olm` dependency is installed when E2EE is supported, and a brief installation hint if missing.
746
+
747
+ This function writes human-facing lines to standard output and returns None.
748
+ """
749
+ print("\n🖥️ Environment Summary:")
750
+ print(f" Platform: {sys.platform}")
751
+ print(f" Python: {sys.version.split()[0]}")
752
+
753
+ # E2EE capability check
754
+ if sys.platform == WINDOWS_PLATFORM:
755
+ print(" E2EE Support: ❌ Not available (Windows limitation)")
756
+ print(" Matrix Support: ✅ Available")
757
+ else:
758
+ try:
759
+ import olm # noqa: F401
760
+ from nio.crypto import OlmDevice # noqa: F401
761
+ from nio.store import SqliteStore # noqa: F401
762
+
763
+ print(" E2EE Support: ✅ Available and installed")
764
+ except ImportError:
765
+ print(" E2EE Support: ⚠️ Available but not installed")
766
+ print(" Install: pipx install 'mmrelay[e2e]'")
767
+
768
+
769
+ def check_config(args=None):
770
+ """
771
+ Validate the application's YAML configuration file and its required sections.
772
+
773
+ Performs these checks:
774
+ - Locates the first existing config file from get_config_paths(args) (parses CLI args if args is None).
775
+ - Verifies YAML syntax and reports syntax errors or style warnings.
776
+ - Ensures the config is non-empty.
777
+ - Validates Matrix authentication: accepts credentials supplied via credentials.json or requires a matrix section with homeserver and bot_user_id plus either access_token or password when credentials.json is absent.
778
+ - Validates end-to-end-encryption (E2EE) configuration and dependencies.
779
+ - Ensures matrix_rooms exists, is a non-empty list, and each room is a dict containing an id.
780
+ - Validates the meshtastic section: requires connection_type and the connection-specific fields (serial_port for serial, host for tcp/network, ble_address for ble). Warns about deprecated connection types.
781
+ - Validates optional meshtastic fields and types (broadcast_enabled, detection_sensor, message_delay >= 2.0, meshnet_name) and reports missing optional settings as guidance.
782
+ - Warns if a deprecated db section is present.
783
+ - Prints a unified E2EE analysis summary on success.
784
+
785
+ Side effects:
786
+ - Prints errors, warnings, and status messages to stdout.
136
787
 
137
788
  Parameters:
138
- args: Parsed command-line arguments. If None, arguments are parsed internally.
789
+ args (argparse.Namespace | None): Parsed CLI arguments. If None, CLI arguments will be parsed internally.
139
790
 
140
791
  Returns:
141
- bool: True if the configuration file is valid, False otherwise.
792
+ bool: True if a configuration file was found and passed all checks; False otherwise.
142
793
  """
143
794
 
144
795
  # If args is None, parse them now
@@ -155,36 +806,114 @@ def check_config(args=None):
155
806
  print(f"Found configuration file at: {config_path}")
156
807
  try:
157
808
  with open(config_path, "r") as f:
158
- config = yaml.load(f, Loader=SafeLoader)
809
+ config_content = f.read()
810
+
811
+ # Validate YAML syntax first
812
+ is_valid, message, config = validate_yaml_syntax(
813
+ config_content, config_path
814
+ )
815
+ if not is_valid:
816
+ print(f"YAML Syntax Error:\n{message}")
817
+ return False
818
+ elif message: # Warnings
819
+ print(f"YAML Style Warnings:\n{message}\n")
159
820
 
160
821
  # Check if config is empty
161
822
  if not config:
162
- print("Error: Configuration file is empty or invalid")
823
+ print(
824
+ "Error: Configuration file is empty or contains only comments"
825
+ )
163
826
  return False
164
827
 
165
- # Check matrix section
166
- if CONFIG_SECTION_MATRIX not in config:
167
- print("Error: Missing 'matrix' section in config")
168
- return False
828
+ # Check if we have valid credentials.json first
829
+ has_valid_credentials = _validate_credentials_json(config_path)
830
+
831
+ # Check matrix section requirements based on credentials.json availability
832
+ if has_valid_credentials:
833
+ # With credentials.json, no matrix section fields are required
834
+ # (homeserver, access_token, user_id, device_id all come from credentials.json)
835
+ if CONFIG_SECTION_MATRIX not in config:
836
+ # Create empty matrix section if missing - no fields required
837
+ config[CONFIG_SECTION_MATRIX] = {}
838
+ matrix_section = config[CONFIG_SECTION_MATRIX]
839
+ if not isinstance(matrix_section, dict):
840
+ print("Error: 'matrix' section must be a mapping (YAML object)")
841
+ return False
842
+ required_matrix_fields = (
843
+ []
844
+ ) # No fields required from config when using credentials.json
845
+ else:
846
+ # Without credentials.json, require full matrix section
847
+ if CONFIG_SECTION_MATRIX not in config:
848
+ print("Error: Missing 'matrix' section in config")
849
+ print(
850
+ " Either add matrix section with access_token or password and bot_user_id,"
851
+ )
852
+ print(f" {msg_or_run_auth_login()}")
853
+ return False
854
+
855
+ matrix_section = config[CONFIG_SECTION_MATRIX]
856
+ if not isinstance(matrix_section, dict):
857
+ print("Error: 'matrix' section must be a mapping (YAML object)")
858
+ return False
859
+
860
+ required_matrix_fields = [
861
+ CONFIG_KEY_HOMESERVER,
862
+ CONFIG_KEY_BOT_USER_ID,
863
+ ]
864
+ token = matrix_section.get(CONFIG_KEY_ACCESS_TOKEN)
865
+ pwd = matrix_section.get("password")
866
+ has_token = _is_valid_non_empty_string(token)
867
+ # Allow explicitly empty password strings; require the value to be a string
868
+ # (reject unquoted numeric types)
869
+ has_password = isinstance(pwd, str)
870
+ if not (has_token or has_password):
871
+ print(
872
+ "Error: Missing authentication in 'matrix' section: provide 'access_token' or 'password'"
873
+ )
874
+ print(f" {msg_or_run_auth_login()}")
875
+ return False
169
876
 
170
- matrix_section = config[CONFIG_SECTION_MATRIX]
171
- required_matrix_fields = [
172
- CONFIG_KEY_HOMESERVER,
173
- CONFIG_KEY_ACCESS_TOKEN,
174
- CONFIG_KEY_BOT_USER_ID,
175
- ]
176
877
  missing_matrix_fields = [
177
878
  field
178
879
  for field in required_matrix_fields
179
- if field not in matrix_section
880
+ if not _is_valid_non_empty_string(matrix_section.get(field))
180
881
  ]
181
882
 
182
883
  if missing_matrix_fields:
183
- print(
184
- f"Error: Missing required fields in 'matrix' section: {', '.join(missing_matrix_fields)}"
185
- )
884
+ if has_valid_credentials:
885
+ print(
886
+ f"Error: Missing required fields in 'matrix' section: {', '.join(missing_matrix_fields)}"
887
+ )
888
+ print(
889
+ " Note: credentials.json provides authentication; no matrix.* fields are required in config"
890
+ )
891
+ else:
892
+ print(
893
+ f"Error: Missing required fields in 'matrix' section: {', '.join(missing_matrix_fields)}"
894
+ )
895
+ print(f" {msg_setup_authentication()}")
186
896
  return False
187
897
 
898
+ # Perform comprehensive E2EE analysis using centralized utilities
899
+ try:
900
+ from mmrelay.e2ee_utils import (
901
+ get_e2ee_status,
902
+ )
903
+
904
+ e2ee_status = get_e2ee_status(config, config_path)
905
+ _print_unified_e2ee_analysis(e2ee_status)
906
+
907
+ # Check if there are critical E2EE issues
908
+ if not e2ee_status.get("platform_supported", True):
909
+ print("\n⚠️ Warning: E2EE is not supported on Windows")
910
+ print(" Messages to encrypted rooms will be blocked")
911
+ except Exception as e:
912
+ print(f"\n⚠️ Could not perform E2EE analysis: {e}")
913
+ print(" Falling back to basic E2EE validation...")
914
+ if not _validate_e2ee_config(config, matrix_section, config_path):
915
+ return False
916
+
188
917
  # Check matrix_rooms section
189
918
  if "matrix_rooms" not in config or not config["matrix_rooms"]:
190
919
  print("Error: Missing or empty 'matrix_rooms' section in config")
@@ -225,7 +954,9 @@ def check_config(args=None):
225
954
  CONNECTION_TYPE_NETWORK,
226
955
  ]:
227
956
  print(
228
- f"Error: Invalid 'connection_type': {connection_type}. Must be '{CONNECTION_TYPE_TCP}', '{CONNECTION_TYPE_SERIAL}', or '{CONNECTION_TYPE_BLE}'"
957
+ f"Error: Invalid 'connection_type': {connection_type}. Must be "
958
+ f"'{CONNECTION_TYPE_TCP}', '{CONNECTION_TYPE_SERIAL}', '{CONNECTION_TYPE_BLE}'"
959
+ f" or '{CONNECTION_TYPE_NETWORK}' (deprecated)"
229
960
  )
230
961
  return False
231
962
 
@@ -260,6 +991,61 @@ def check_config(args=None):
260
991
  print("Error: Missing 'ble_address' for 'ble' connection type")
261
992
  return False
262
993
 
994
+ # Check for other important optional configurations and provide guidance
995
+ optional_configs = {
996
+ "broadcast_enabled": {
997
+ "type": bool,
998
+ "description": "Enable Matrix to Meshtastic message forwarding (required for two-way communication)",
999
+ },
1000
+ "detection_sensor": {
1001
+ "type": bool,
1002
+ "description": "Enable forwarding of Meshtastic detection sensor messages",
1003
+ },
1004
+ "message_delay": {
1005
+ "type": (int, float),
1006
+ "description": "Delay in seconds between messages sent to mesh (minimum: 2.0)",
1007
+ },
1008
+ "meshnet_name": {
1009
+ "type": str,
1010
+ "description": "Name displayed for your meshnet in Matrix messages",
1011
+ },
1012
+ }
1013
+
1014
+ warnings = []
1015
+ for option, config_info in optional_configs.items():
1016
+ if option in meshtastic_section:
1017
+ value = meshtastic_section[option]
1018
+ expected_type = config_info["type"]
1019
+ if not isinstance(value, expected_type):
1020
+ if isinstance(expected_type, tuple):
1021
+ type_name = " or ".join(
1022
+ t.__name__ for t in expected_type
1023
+ )
1024
+ else:
1025
+ type_name = (
1026
+ expected_type.__name__
1027
+ if hasattr(expected_type, "__name__")
1028
+ else str(expected_type)
1029
+ )
1030
+ print(
1031
+ f"Error: '{option}' must be of type {type_name}, got: {value}"
1032
+ )
1033
+ return False
1034
+
1035
+ # Special validation for message_delay
1036
+ if option == "message_delay" and value < 2.0:
1037
+ print(
1038
+ f"Error: 'message_delay' must be at least 2.0 seconds (firmware limitation), got: {value}"
1039
+ )
1040
+ return False
1041
+ else:
1042
+ warnings.append(f" - {option}: {config_info['description']}")
1043
+
1044
+ if warnings:
1045
+ print("\nOptional configurations not found (using defaults):")
1046
+ for warning in warnings:
1047
+ print(warning)
1048
+
263
1049
  # Check for deprecated db section
264
1050
  if "db" in config:
265
1051
  print(
@@ -269,11 +1055,8 @@ def check_config(args=None):
269
1055
  "This option still works but may be removed in future versions.\n"
270
1056
  )
271
1057
 
272
- print("Configuration file is valid!")
1058
+ print("\n✅ Configuration file is valid!")
273
1059
  return True
274
- except yaml.YAMLError as e:
275
- print(f"Error parsing YAML in {config_path}: {e}")
276
- return False
277
1060
  except Exception as e:
278
1061
  print(f"Error checking configuration: {e}")
279
1062
  return False
@@ -281,26 +1064,38 @@ def check_config(args=None):
281
1064
  print("Error: No configuration file found in any of the following locations:")
282
1065
  for path in config_paths:
283
1066
  print(f" - {path}")
284
- print("\nRun 'mmrelay --generate-config' to generate a sample configuration file.")
1067
+ print(f"\n{msg_suggest_generate_config()}")
285
1068
  return False
286
1069
 
287
1070
 
288
1071
  def main():
289
1072
  """
290
- Runs the Meshtastic Matrix Relay CLI, handling argument parsing, command execution, and error reporting.
1073
+ Entry point for the MMRelay command-line interface; parses arguments, dispatches commands, and returns an appropriate process exit code.
1074
+
1075
+ This function:
1076
+ - Parses CLI arguments (modern grouped subcommands and hidden legacy flags).
1077
+ - If a modern subcommand is provided, dispatches to the grouped subcommand handlers.
1078
+ - If legacy flags are present, emits deprecation warnings and executes the corresponding legacy behavior (config check/generate, service install, auth, version).
1079
+ - If no command flags are present, attempts to run the main runtime.
1080
+ - Catches and reports import or unexpected errors and maps success/failure to exit codes.
291
1081
 
292
1082
  Returns:
293
- int: Exit code indicating success (0) or failure (non-zero).
1083
+ int: Exit code (0 on success, non-zero on failure).
294
1084
  """
295
1085
  try:
296
1086
  args = parse_arguments()
297
1087
 
298
- # Handle --check-config
1088
+ # Handle subcommands first (modern interface)
1089
+ if hasattr(args, "command") and args.command:
1090
+ return handle_subcommand(args)
1091
+
1092
+ # Handle legacy flags (with deprecation warnings)
299
1093
  if args.check_config:
1094
+ print(get_deprecation_warning("--check-config"))
300
1095
  return 0 if check_config(args) else 1
301
1096
 
302
- # Handle --install-service
303
1097
  if args.install_service:
1098
+ print(get_deprecation_warning("--install-service"))
304
1099
  try:
305
1100
  from mmrelay.setup_utils import install_service
306
1101
 
@@ -309,15 +1104,18 @@ def main():
309
1104
  print(f"Error importing setup utilities: {e}")
310
1105
  return 1
311
1106
 
312
- # Handle --generate-config
313
1107
  if args.generate_config:
1108
+ print(get_deprecation_warning("--generate-config"))
314
1109
  return 0 if generate_sample_config() else 1
315
1110
 
316
- # Handle --version
317
1111
  if args.version:
318
1112
  print_version()
319
1113
  return 0
320
1114
 
1115
+ if args.auth:
1116
+ print(get_deprecation_warning("--auth"))
1117
+ return handle_auth_command(args)
1118
+
321
1119
  # If no command was specified, run the main functionality
322
1120
  try:
323
1121
  from mmrelay.main import run_main
@@ -332,6 +1130,323 @@ def main():
332
1130
  return 1
333
1131
 
334
1132
 
1133
+ def handle_subcommand(args):
1134
+ """
1135
+ Dispatch the modern grouped CLI subcommand to its handler and return an exit code.
1136
+
1137
+ Parameters:
1138
+ args (argparse.Namespace): Parsed CLI arguments (as produced by parse_arguments()). Must have a `command` attribute with one of: "config", "auth", or "service".
1139
+
1140
+ Returns:
1141
+ int: Process exit code — 0 on success, non-zero on error or unknown command.
1142
+ """
1143
+ if args.command == "config":
1144
+ return handle_config_command(args)
1145
+ elif args.command == "auth":
1146
+ return handle_auth_command(args)
1147
+ elif args.command == "service":
1148
+ return handle_service_command(args)
1149
+ else:
1150
+ print(f"Unknown command: {args.command}")
1151
+ return 1
1152
+
1153
+
1154
+ def handle_config_command(args):
1155
+ """
1156
+ Dispatch the 'config' subgroup commands: "generate" and "check".
1157
+
1158
+ If `args.config_command` is "generate", writes a sample config to the default location.
1159
+ If "check", validates the configuration referenced by `args` (see check_config).
1160
+
1161
+ Parameters:
1162
+ args (argparse.Namespace): Parsed CLI namespace with a `config_command` attribute.
1163
+
1164
+ Returns:
1165
+ int: Process exit code (0 on success, 1 on failure or unknown subcommand).
1166
+ """
1167
+ if args.config_command == "generate":
1168
+ return 0 if generate_sample_config() else 1
1169
+ elif args.config_command == "check":
1170
+ return 0 if check_config(args) else 1
1171
+ else:
1172
+ print(f"Unknown config command: {args.config_command}")
1173
+ return 1
1174
+
1175
+
1176
+ def handle_auth_command(args):
1177
+ """
1178
+ Dispatch the "auth" CLI subcommand to the appropriate handler.
1179
+
1180
+ If args.auth_command is "status" calls handle_auth_status; if "logout" calls handle_auth_logout;
1181
+ any other value (or missing attribute) defaults to handle_auth_login.
1182
+
1183
+ Parameters:
1184
+ args (argparse.Namespace): Parsed CLI arguments. Expected to optionally provide `auth_command`
1185
+ with one of "login", "status", or "logout".
1186
+
1187
+ Returns:
1188
+ int: Exit code from the invoked handler (0 = success, non-zero = failure).
1189
+ """
1190
+ if hasattr(args, "auth_command"):
1191
+ if args.auth_command == "status":
1192
+ return handle_auth_status(args)
1193
+ elif args.auth_command == "logout":
1194
+ return handle_auth_logout(args)
1195
+ else:
1196
+ # Default to login for auth login command
1197
+ return handle_auth_login(args)
1198
+ else:
1199
+ # Default to login for legacy --auth
1200
+ return handle_auth_login(args)
1201
+
1202
+
1203
+ def handle_auth_login(args):
1204
+ """
1205
+ Run the Matrix bot login flow and return a CLI-style exit code.
1206
+
1207
+ Performs Matrix bot authentication either interactively (prompts the user) or non-interactively
1208
+ when all three parameters (--homeserver, --username, --password) are provided on the command line.
1209
+ For non-interactive mode, --homeserver and --username must be non-empty strings; --password may be
1210
+ an empty string (some flows will prompt). Supplying some but not all of the three parameters
1211
+ is treated as an error and the function exits with a non-zero status.
1212
+
1213
+ Returns:
1214
+ int: 0 on successful authentication, 1 on failure, cancellation (KeyboardInterrupt), or unexpected errors.
1215
+
1216
+ Parameters:
1217
+ args: Parsed CLI namespace; may contain attributes `homeserver`, `username`, and `password`.
1218
+ """
1219
+ import asyncio
1220
+
1221
+ from mmrelay.matrix_utils import login_matrix_bot
1222
+
1223
+ # Extract arguments
1224
+ homeserver = getattr(args, "homeserver", None)
1225
+ username = getattr(args, "username", None)
1226
+ password = getattr(args, "password", None)
1227
+
1228
+ # Count provided parameters (empty strings count as provided)
1229
+ provided_params = [p for p in [homeserver, username, password] if p is not None]
1230
+
1231
+ # Determine mode based on parameters provided
1232
+ if len(provided_params) == 3:
1233
+ # All parameters provided - validate required non-empty fields
1234
+ if not _is_valid_non_empty_string(homeserver) or not _is_valid_non_empty_string(
1235
+ username
1236
+ ):
1237
+ print(
1238
+ "❌ Error: --homeserver and --username must be non-empty for non-interactive login."
1239
+ )
1240
+ return 1
1241
+ # Password may be empty (flows may prompt)
1242
+ elif len(provided_params) > 0:
1243
+ # Some but not all parameters provided - show error
1244
+ missing_params = []
1245
+ if homeserver is None:
1246
+ missing_params.append("--homeserver")
1247
+ if username is None:
1248
+ missing_params.append("--username")
1249
+ if password is None:
1250
+ missing_params.append("--password")
1251
+
1252
+ error_message = f"""❌ Error: All authentication parameters are required when using command-line options.
1253
+ Missing: {', '.join(missing_params)}
1254
+
1255
+ 💡 Options:
1256
+ • For secure interactive authentication: mmrelay auth login
1257
+ • For automated authentication: provide all three parameters
1258
+
1259
+ ⚠️ Security Note: Command-line passwords may be visible in process lists and shell history.
1260
+ Interactive mode is recommended for manual use."""
1261
+ print(error_message)
1262
+ return 1
1263
+ else:
1264
+ # No parameters provided - run in interactive mode
1265
+ print("Matrix Bot Authentication for E2EE")
1266
+ print("===================================")
1267
+
1268
+ try:
1269
+ result = asyncio.run(
1270
+ login_matrix_bot(
1271
+ homeserver=homeserver,
1272
+ username=username,
1273
+ password=password,
1274
+ logout_others=False,
1275
+ )
1276
+ )
1277
+ return 0 if result else 1
1278
+ except KeyboardInterrupt:
1279
+ print("\nAuthentication cancelled by user.")
1280
+ return 1
1281
+ except Exception as e:
1282
+ print(f"\nError during authentication: {e}")
1283
+ return 1
1284
+
1285
+
1286
+ def handle_auth_status(args):
1287
+ """
1288
+ Print the Matrix authentication status by locating and reading a credentials.json file.
1289
+
1290
+ Searches for credentials.json next to each discovered config file (in preference order),
1291
+ then falls back to the application's base directory. If a readable credentials.json is
1292
+ found, prints its path and the homeserver, user_id, and device_id values.
1293
+
1294
+ Parameters:
1295
+ args: argparse.Namespace
1296
+ Parsed CLI arguments (used to locate config file paths).
1297
+
1298
+ Returns:
1299
+ int: Exit code — 0 if a valid credentials.json was found and read, 1 otherwise.
1300
+
1301
+ Side effects:
1302
+ Writes human-readable status messages to stdout.
1303
+ """
1304
+ import json
1305
+
1306
+ from mmrelay.config import get_base_dir, get_config_paths
1307
+
1308
+ print("Matrix Authentication Status")
1309
+ print("============================")
1310
+
1311
+ config_paths = get_config_paths(args)
1312
+
1313
+ # Developer note: Build a de-duplicated sequence of candidate locations,
1314
+ # preserving preference order: each config-adjacent credentials.json first,
1315
+ # then the standard base-dir fallback.
1316
+ seen = set()
1317
+ candidate_paths = []
1318
+ for p in (
1319
+ os.path.join(os.path.dirname(cp), "credentials.json") for cp in config_paths
1320
+ ):
1321
+ if p not in seen:
1322
+ candidate_paths.append(p)
1323
+ seen.add(p)
1324
+ base_candidate = os.path.join(get_base_dir(), "credentials.json")
1325
+ if base_candidate not in seen:
1326
+ candidate_paths.append(base_candidate)
1327
+
1328
+ for credentials_path in candidate_paths:
1329
+ if os.path.exists(credentials_path):
1330
+ try:
1331
+ with open(credentials_path, "r", encoding="utf-8") as f:
1332
+ credentials = json.load(f)
1333
+
1334
+ required = ("homeserver", "access_token", "user_id", "device_id")
1335
+ if not all(
1336
+ isinstance(credentials.get(k), str) and credentials.get(k).strip()
1337
+ for k in required
1338
+ ):
1339
+ print(
1340
+ f"❌ Error: credentials.json at {credentials_path} is missing required fields"
1341
+ )
1342
+ print(f"Run '{get_command('auth_login')}' to authenticate")
1343
+ return 1
1344
+ print(f"✅ Found credentials.json at: {credentials_path}")
1345
+ print(f" Homeserver: {credentials.get('homeserver')}")
1346
+ print(f" User ID: {credentials.get('user_id')}")
1347
+ print(f" Device ID: {credentials.get('device_id')}")
1348
+ return 0
1349
+ except Exception as e:
1350
+ print(f"❌ Error reading credentials.json: {e}")
1351
+ return 1
1352
+
1353
+ print("❌ No credentials.json found")
1354
+ print(f"Run '{get_command('auth_login')}' to authenticate")
1355
+ return 1
1356
+
1357
+
1358
+ def handle_auth_logout(args):
1359
+ """
1360
+ Log out the Matrix bot and remove local session artifacts.
1361
+
1362
+ Prompts for a verification password (unless a non-empty password is provided via args.password),
1363
+ optionally asks for interactive confirmation (skipped if args.yes is True), and attempts to clear
1364
+ local session data (credentials, E2EE store) and invalidate the bot's access token.
1365
+
1366
+ Parameters:
1367
+ args (argparse.Namespace): CLI arguments with the following relevant attributes:
1368
+ password (str | None): If a non-empty string is provided, it will be used as the
1369
+ verification password. If None or an empty string, the function prompts securely.
1370
+ yes (bool): If True, skip the confirmation prompt.
1371
+
1372
+ Returns:
1373
+ int: 0 on successful logout, 1 on failure or if the operation is cancelled (including
1374
+ KeyboardInterrupt).
1375
+ """
1376
+ import asyncio
1377
+
1378
+ from mmrelay.cli_utils import logout_matrix_bot
1379
+
1380
+ # Show header
1381
+ print("Matrix Bot Logout")
1382
+ print("=================")
1383
+ print()
1384
+ print("This will log out from Matrix and clear all local session data:")
1385
+ print("• Remove credentials.json")
1386
+ print("• Clear E2EE encryption store")
1387
+ print("• Invalidate Matrix access token")
1388
+ print()
1389
+
1390
+ try:
1391
+ # Handle password input
1392
+ password = getattr(args, "password", None)
1393
+
1394
+ if (
1395
+ password is None
1396
+ or password
1397
+ == "" # nosec B105 (user-entered secret; prompting securely via getpass)
1398
+ ):
1399
+ # No --password flag or --password with no value, prompt securely
1400
+ import getpass
1401
+
1402
+ password = getpass.getpass("Enter Matrix password for verification: ")
1403
+ else:
1404
+ # --password VALUE provided, warn about security
1405
+ print(
1406
+ "⚠️ Warning: Supplying password as argument exposes it in shell history and process list."
1407
+ )
1408
+ print(
1409
+ " For better security, use --password without a value to prompt securely."
1410
+ )
1411
+
1412
+ # Confirm the action unless forced
1413
+ if not getattr(args, "yes", False):
1414
+ confirm = input("Are you sure you want to logout? (y/N): ").lower().strip()
1415
+ if not confirm.startswith("y"):
1416
+ print("Logout cancelled.")
1417
+ return 0
1418
+
1419
+ # Run the logout process
1420
+ result = asyncio.run(logout_matrix_bot(password=password))
1421
+ return 0 if result else 1
1422
+ except KeyboardInterrupt:
1423
+ print("\nLogout cancelled by user.")
1424
+ return 1
1425
+ except Exception as e:
1426
+ print(f"\nError during logout: {e}")
1427
+ return 1
1428
+
1429
+
1430
+ def handle_service_command(args):
1431
+ """
1432
+ Handle service-related CLI subcommands.
1433
+
1434
+ Currently supports the "install" subcommand which attempts to import and run mmrelay.setup_utils.install_service.
1435
+ Returns 0 on success, 1 on failure or for unknown subcommands. Prints an error message if setup utilities cannot be imported.
1436
+ """
1437
+ if args.service_command == "install":
1438
+ try:
1439
+ from mmrelay.setup_utils import install_service
1440
+
1441
+ return 0 if install_service() else 1
1442
+ except ImportError as e:
1443
+ print(f"Error importing setup utilities: {e}")
1444
+ return 1
1445
+ else:
1446
+ print(f"Unknown service command: {args.service_command}")
1447
+ return 1
1448
+
1449
+
335
1450
  if __name__ == "__main__":
336
1451
  import sys
337
1452
 
@@ -339,14 +1454,18 @@ if __name__ == "__main__":
339
1454
 
340
1455
 
341
1456
  def handle_cli_commands(args):
342
- """Handle CLI commands like --generate-config, --install-service, and --check-config.
1457
+ """
1458
+ Handle legacy CLI flags (--version, --install-service, --generate-config, --check-config).
1459
+
1460
+ This helper processes backward-compatible flags and may call sys.exit() for flags that perform an immediate action
1461
+ (e.g., install service, check config). Prefer the modern grouped subcommands (e.g., `mmrelay config`, `mmrelay auth`)
1462
+ when available.
343
1463
 
344
- Args:
345
- args: The parsed command-line arguments
1464
+ Parameters:
1465
+ args (argparse.Namespace): Parsed command-line arguments produced by parse_arguments().
346
1466
 
347
1467
  Returns:
348
- bool: True if a command was handled and the program should exit,
349
- False if normal execution should continue.
1468
+ bool: True if a legacy command was handled (the process may have already exited), False to continue normal flow.
350
1469
  """
351
1470
  # Handle --version
352
1471
  if args.version:
@@ -385,16 +1504,16 @@ def handle_cli_commands(args):
385
1504
 
386
1505
  def generate_sample_config():
387
1506
  """
388
- Generate a sample configuration file (`config.yaml`) in the default location if one does not already exist.
1507
+ Generate a sample configuration file at the highest-priority config path if no config already exists.
389
1508
 
390
- Attempts to copy a sample config from various sources, handling directory creation and file system errors gracefully. Prints informative messages on success or failure.
1509
+ 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:
1510
+ - the path returned by get_sample_config_path(),
1511
+ - the packaged resource mmrelay.tools:sample_config.yaml via importlib.resources,
1512
+ - a set of fallback filesystem locations relative to the package and current working directory.
391
1513
 
392
- Returns:
393
- bool: True if the sample config was generated successfully, False otherwise.
1514
+ 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.
394
1515
  """
395
1516
 
396
- import shutil
397
-
398
1517
  # Get the first config path (highest priority)
399
1518
  config_paths = get_config_paths()
400
1519
 
@@ -422,10 +1541,13 @@ def generate_sample_config():
422
1541
 
423
1542
  if os.path.exists(sample_config_path):
424
1543
  # Copy the sample config file to the target path
425
- import shutil
426
1544
 
427
1545
  try:
428
1546
  shutil.copy2(sample_config_path, target_path)
1547
+
1548
+ # Set secure permissions on Unix systems (600 - owner read/write)
1549
+ set_secure_file_permissions(target_path)
1550
+
429
1551
  print(f"Generated sample config file at: {target_path}")
430
1552
  print(
431
1553
  "\nEdit this file with your Matrix and Meshtastic settings before running mmrelay."
@@ -448,6 +1570,9 @@ def generate_sample_config():
448
1570
  with open(target_path, "w") as f:
449
1571
  f.write(sample_config_content)
450
1572
 
1573
+ # Set secure permissions on Unix systems (600 - owner read/write)
1574
+ set_secure_file_permissions(target_path)
1575
+
451
1576
  print(f"Generated sample config file at: {target_path}")
452
1577
  print(
453
1578
  "\nEdit this file with your Matrix and Meshtastic settings before running mmrelay."