ndev-stack 0.3.12__tar.gz → 0.3.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/PKG-INFO +1 -1
  2. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/__init__.py +1 -1
  3. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/doctor.py +16 -0
  4. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/setup.py +6 -0
  5. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/vhost.py +2 -0
  6. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/runtime/nginx_helper.py +108 -4
  7. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/tui.py +141 -60
  8. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev_stack.egg-info/PKG-INFO +1 -1
  9. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/pyproject.toml +1 -1
  10. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/tests/test_opensuse_compat.py +77 -0
  11. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/README.md +0 -0
  12. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/__main__.py +0 -0
  13. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/cli.py +0 -0
  14. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/__init__.py +0 -0
  15. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/config.py +0 -0
  16. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/constants.py +0 -0
  17. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/distro.py +0 -0
  18. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/github.py +0 -0
  19. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/logger.py +0 -0
  20. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/manifest.py +0 -0
  21. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/modules/__init__.py +0 -0
  22. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/modules/base.py +0 -0
  23. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/modules/builtin/mailpit/manifest.json +0 -0
  24. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/modules/builtin/mailpit/module.py +0 -0
  25. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/modules/builtin/mongodb/manifest.json +0 -0
  26. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/modules/builtin/mongodb/module.py +0 -0
  27. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/modules/builtin/postgres/manifest.json +0 -0
  28. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/modules/builtin/postgres/module.py +0 -0
  29. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/modules/builtin/redis/manifest.json +0 -0
  30. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/modules/builtin/redis/module.py +0 -0
  31. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/modules/manager.py +0 -0
  32. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/common/utils.py +0 -0
  33. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/__init__.py +0 -0
  34. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/chroot/manager.py +0 -0
  35. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/chroot/packages.py +0 -0
  36. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/chroot/shell.py +0 -0
  37. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/cli.py +0 -0
  38. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/available.py +0 -0
  39. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/clean.py +0 -0
  40. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/ctl.py +0 -0
  41. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/current.py +0 -0
  42. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/db.py +0 -0
  43. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/grok.py +0 -0
  44. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/install.py +0 -0
  45. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/list.py +0 -0
  46. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/logs.py +0 -0
  47. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/mailpit.py +0 -0
  48. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/modules.py +0 -0
  49. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/mongodb.py +0 -0
  50. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/postgres.py +0 -0
  51. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/reload.py +0 -0
  52. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/restart.py +0 -0
  53. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/start.py +0 -0
  54. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/status.py +0 -0
  55. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/stop.py +0 -0
  56. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/uninstall.py +0 -0
  57. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/update.py +0 -0
  58. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/upgrade.py +0 -0
  59. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/commands/use.py +0 -0
  60. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/php/builder.py +0 -0
  61. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/php/downloader.py +0 -0
  62. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/php/extensions.py +0 -0
  63. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/php/installer.py +0 -0
  64. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/php/resolver.py +0 -0
  65. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/php/templates.py +0 -0
  66. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/runtime/fpm.py +0 -0
  67. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/runtime/mailpit.py +0 -0
  68. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/runtime/pma.py +0 -0
  69. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/runtime/process.py +0 -0
  70. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/runtime/services.py +0 -0
  71. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/runtime/sockets.py +0 -0
  72. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/linux/runtime/upgrade.py +0 -0
  73. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/main.py +0 -0
  74. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/tui.py +0 -0
  75. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/__init__.py +0 -0
  76. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/bin/shim.exe +0 -0
  77. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/cli.py +0 -0
  78. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/commands/__init__.py +0 -0
  79. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/__init__.py +0 -0
  80. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/db.py +0 -0
  81. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/elevate.py +0 -0
  82. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/ext.py +0 -0
  83. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/fcgi.py +0 -0
  84. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/grok.py +0 -0
  85. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/logs.py +0 -0
  86. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/mailpit.py +0 -0
  87. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/mkcert.py +0 -0
  88. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/paths.py +0 -0
  89. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/php.py +0 -0
  90. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/pma.py +0 -0
  91. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/redis_core.py +0 -0
  92. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/services.py +0 -0
  93. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/setup.py +0 -0
  94. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/shim.py +0 -0
  95. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/upgrade.py +0 -0
  96. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/core/vhost.py +0 -0
  97. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/templates/vhost.conf.tmpl +0 -0
  98. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/templates/vhost_ssl.conf.tmpl +0 -0
  99. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev/win/tui.py +0 -0
  100. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev_stack.egg-info/SOURCES.txt +0 -0
  101. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev_stack.egg-info/dependency_links.txt +0 -0
  102. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev_stack.egg-info/entry_points.txt +0 -0
  103. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev_stack.egg-info/requires.txt +0 -0
  104. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/ndev_stack.egg-info/top_level.txt +0 -0
  105. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/setup.cfg +0 -0
  106. {ndev_stack-0.3.12 → ndev_stack-0.3.13}/tests/test_shim.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndev-stack
3
- Version: 0.3.12
3
+ Version: 0.3.13
4
4
  Summary: Cross-platform developer stack manager for PHP, FastCGI/PHP-FPM, MariaDB, Redis, PostgreSQL, MongoDB, Nginx, SSL, and Mailpit on Linux and Windows
5
5
  Author-email: Ankit Karan <ankit.karan99@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/ankitkaran99/ndev
@@ -1,7 +1,7 @@
1
1
  """
2
2
  ndev: Fast, cross-platform PHP developer environment for Windows and Linux.
3
3
  """
4
- __version__ = "0.3.12"
4
+ __version__ = "0.3.13"
5
5
 
6
6
  from . import common as common
7
7
  from .common.logger import logger as logger
@@ -74,6 +74,22 @@ def doctor_cmd():
74
74
  else:
75
75
  table.add_row("mkcert Local SSL", "[yellow]NOT INSTALLED[/yellow]", "Optional for SSL. Run 'ndev upgrade mkcert' to install.")
76
76
 
77
+ try:
78
+ from ndev.linux.runtime.nginx_helper import is_selinux_enforcing, is_selinux_httpd_permissive
79
+ if is_selinux_enforcing():
80
+ if is_selinux_httpd_permissive():
81
+ table.add_row("SELinux Web Server Access", "[green]OK[/green]", "Enforcing mode, httpd_t is permissive")
82
+ else:
83
+ table.add_row(
84
+ "SELinux Web Server Access",
85
+ "[yellow]RESTRICTED[/yellow]",
86
+ "Enforcing. Nginx cannot access ~/Sites or FastCGI sockets. Run: sudo /usr/sbin/semanage permissive -a httpd_t"
87
+ )
88
+ else:
89
+ table.add_row("SELinux Web Server Access", "[green]OK[/green]", "SELinux is permissive or disabled")
90
+ except Exception:
91
+ pass
92
+
77
93
  if NDEV_DIR.exists():
78
94
  table.add_row("Layout Directory (~/.ndev)", "[green]OK[/green]", f"Exists at {NDEV_DIR}")
79
95
  else:
@@ -71,6 +71,12 @@ def setup_cmd():
71
71
  except Exception:
72
72
  pass
73
73
 
74
+ try:
75
+ from ndev.linux.runtime.nginx_helper import ensure_selinux_httpd_permissions
76
+ ensure_selinux_httpd_permissions()
77
+ except Exception:
78
+ pass
79
+
74
80
  console.print("[bold green]MariaDB, Nginx, and Bubblewrap installed and configured successfully![/bold green]\n")
75
81
  else:
76
82
  # 1. Update Package Lists
@@ -321,6 +321,7 @@ def vhost_cmd(
321
321
  ensure_nginx_sites_enabled_included,
322
322
  install_nginx_ssl_cert,
323
323
  ensure_docroot_accessible,
324
+ ensure_selinux_httpd_permissions,
324
325
  add_to_hosts_privileged,
325
326
  reload_nginx,
326
327
  )
@@ -350,6 +351,7 @@ def vhost_cmd(
350
351
  logger.warning(f"Could not install SSL certificate to /etc/nginx/ssl: {e}")
351
352
 
352
353
  ensure_docroot_accessible(root_path)
354
+ ensure_selinux_httpd_permissions()
353
355
 
354
356
  conf_file = vhost_dir / f"{domain}.conf"
355
357
  config_template = generate_vhost_config(
@@ -66,6 +66,96 @@ def check_sudo_needs_password() -> bool:
66
66
  return True
67
67
 
68
68
 
69
+ def run_privileged_command(
70
+ cmd: list[str],
71
+ password: Optional[str] = None,
72
+ check: bool = True,
73
+ timeout: int = 60,
74
+ capture_output: bool = True
75
+ ) -> subprocess.CompletedProcess:
76
+ """
77
+ Run a command with elevated privileges using sudo -n if non-root.
78
+ Validates sudo password first if provided to refresh the sudo credential timestamp.
79
+ """
80
+ full_cmd = list(cmd)
81
+ if os.geteuid() != 0:
82
+ if password:
83
+ validate_sudo_password(password)
84
+ full_cmd = ["sudo", "-n"] + full_cmd
85
+ return subprocess.run(
86
+ full_cmd,
87
+ check=check,
88
+ timeout=timeout,
89
+ capture_output=capture_output,
90
+ text=True
91
+ )
92
+
93
+
94
+ def is_selinux_enforcing() -> bool:
95
+ """Check if SELinux is present and in Enforcing mode."""
96
+ enforce_file = Path("/sys/fs/selinux/enforce")
97
+ if enforce_file.exists():
98
+ try:
99
+ return enforce_file.read_text().strip() == "1"
100
+ except Exception:
101
+ pass
102
+ getenforce = shutil.which("getenforce") or "/usr/sbin/getenforce"
103
+ if Path(getenforce).exists():
104
+ try:
105
+ res = subprocess.run([getenforce], capture_output=True, text=True, timeout=2)
106
+ return res.stdout.strip().lower() == "enforcing"
107
+ except Exception:
108
+ pass
109
+ return False
110
+
111
+
112
+ def is_selinux_httpd_permissive() -> bool:
113
+ """Check if httpd_t is in SELinux permissive types."""
114
+ if not is_selinux_enforcing():
115
+ return True
116
+ seinfo = shutil.which("seinfo") or "/usr/bin/seinfo"
117
+ if Path(seinfo).exists():
118
+ try:
119
+ res = subprocess.run([seinfo, "--permissive"], capture_output=True, text=True, timeout=3)
120
+ return "httpd_t" in res.stdout
121
+ except Exception:
122
+ pass
123
+ return False
124
+
125
+
126
+ def ensure_selinux_httpd_permissions(password: Optional[str] = None) -> bool:
127
+ """
128
+ Ensure SELinux allows Nginx (httpd_t) to access user document roots and FastCGI sockets.
129
+ If SELinux is enforcing and httpd_t is not permissive, add httpd_t to permissive types
130
+ via semanage and enable relevant SELinux booleans.
131
+ """
132
+ if not is_selinux_enforcing() or is_selinux_httpd_permissive():
133
+ return True
134
+
135
+ success = False
136
+ semanage = shutil.which("semanage") or "/usr/sbin/semanage"
137
+ if Path(semanage).exists():
138
+ try:
139
+ run_privileged_command([semanage, "permissive", "-a", "httpd_t"], password=password, check=False)
140
+ success = is_selinux_httpd_permissive()
141
+ except Exception as e:
142
+ logger.warning(f"Failed to set httpd_t permissive via semanage: {e}")
143
+
144
+ # Also enable booleans as supplementary fallback
145
+ setsebool = shutil.which("setsebool") or "/usr/sbin/setsebool"
146
+ if Path(setsebool).exists():
147
+ try:
148
+ run_privileged_command(
149
+ [setsebool, "-P", "httpd_read_user_content=1", "httpd_enable_homedirs=1", "httpd_can_network_connect=1"],
150
+ password=password,
151
+ check=False
152
+ )
153
+ except Exception:
154
+ pass
155
+
156
+ return success or is_selinux_httpd_permissive()
157
+
158
+
69
159
  def write_privileged_file(target: Path, content: str, password: Optional[str] = None) -> None:
70
160
  """
71
161
  Write content to a file, using sudo tee if current process lacks permissions.
@@ -527,10 +617,24 @@ def ensure_docroot_accessible(root_path: Path) -> None:
527
617
  try:
528
618
  resolved_root = root_path.resolve()
529
619
  resolved_home = home.resolve()
530
- if resolved_root == resolved_home or resolved_root.is_relative_to(resolved_home):
531
- st = home.stat()
532
- if not (st.st_mode & 0o001):
533
- os.chmod(str(home), st.st_mode | 0o001)
620
+
621
+ # Traverse every directory from root_path up to home and ensure +x (0o001)
622
+ curr = resolved_root
623
+ while curr != curr.parent:
624
+ try:
625
+ st = curr.stat()
626
+ if not (st.st_mode & 0o001):
627
+ os.chmod(str(curr), st.st_mode | 0o001)
628
+ except Exception:
629
+ pass
630
+ if curr == resolved_home:
631
+ break
632
+ curr = curr.parent
633
+
634
+ # Also ensure home directory itself has +x
635
+ st = resolved_home.stat()
636
+ if not (st.st_mode & 0o001):
637
+ os.chmod(str(resolved_home), st.st_mode | 0o001)
534
638
  except Exception:
535
639
  pass
536
640
 
@@ -12,7 +12,7 @@ import subprocess
12
12
  import threading
13
13
  import webbrowser
14
14
  from pathlib import Path
15
- from typing import Any, Dict, List, Optional, Tuple
15
+ from typing import Any, Callable, Dict, List, Optional, Tuple
16
16
 
17
17
  from rich.text import Text
18
18
  from textual import on, work
@@ -265,6 +265,12 @@ class SudoPasswordModal(ModalScreen[Optional[str]]):
265
265
  yield Button("Authenticate", variant="primary", id="btn-auth-confirm")
266
266
  yield Button("Cancel", variant="default", id="btn-auth-cancel")
267
267
 
268
+ def on_mount(self) -> None:
269
+ try:
270
+ self.query_one("#input-sudo-pass", Input).focus()
271
+ except Exception:
272
+ pass
273
+
268
274
  def on_input_submitted(self, event: Input.Submitted) -> None:
269
275
  self.query_one("#btn-auth-confirm", Button).press()
270
276
 
@@ -327,6 +333,12 @@ class CreateVhostModal(ModalScreen[Optional[dict]]):
327
333
  yield Button("Create VHost", variant="success", id="btn-modal-create")
328
334
  yield Button("Cancel", variant="default", id="btn-modal-cancel")
329
335
 
336
+ def on_mount(self) -> None:
337
+ try:
338
+ self.query_one("#input-vhost-domain", Input).focus()
339
+ except Exception:
340
+ pass
341
+
330
342
  def on_input_submitted(self, event: Input.Submitted) -> None:
331
343
  self.query_one("#btn-modal-create", Button).press()
332
344
 
@@ -516,6 +528,8 @@ class NdevDashboard(App):
516
528
 
517
529
  def _needs_sudo(self) -> bool:
518
530
  """Check if elevated privileges are needed and require a password prompt."""
531
+ if os.geteuid() == 0:
532
+ return False
519
533
  from ndev.linux.runtime.nginx_helper import check_sudo_needs_password, validate_sudo_password
520
534
  if not check_sudo_needs_password():
521
535
  return False
@@ -523,6 +537,27 @@ class NdevDashboard(App):
523
537
  return False
524
538
  return True
525
539
 
540
+ def ensure_sudo(self, action_name: str, callback: Callable[[Optional[str]], None]) -> None:
541
+ """
542
+ Ensure sudo access is ready before executing a privileged action.
543
+ If sudo requires password and we don't have a valid cached password,
544
+ present SudoPasswordModal. Once authenticated, cache password and invoke callback(password).
545
+ If cancelled, notify user and abort action.
546
+ If sudo does not require a password, invoke callback(self._sudo_password) immediately.
547
+ """
548
+ if not self._needs_sudo():
549
+ callback(self._sudo_password)
550
+ return
551
+
552
+ def _on_pwd(pwd: Optional[str]) -> None:
553
+ if pwd:
554
+ self._sudo_password = pwd
555
+ callback(pwd)
556
+ else:
557
+ self.notify("Administrator authentication cancelled.", severity="warning")
558
+
559
+ self.push_screen(SudoPasswordModal(action_name), _on_pwd)
560
+
526
561
  def compose(self) -> ComposeResult:
527
562
  yield Header(show_clock=True)
528
563
 
@@ -938,19 +973,29 @@ class NdevDashboard(App):
938
973
 
939
974
  # ── ACTION WORKERS ────────────────────────────────────────────────────────
940
975
 
941
- @work(exclusive=True)
942
- async def action_start_all(self) -> None:
976
+ def action_start_all(self) -> None:
943
977
  """Start all services and FastCGI pools."""
978
+ self.ensure_sudo("Starting all services", self._start_all_worker)
979
+
980
+ @work(exclusive=True)
981
+ async def _start_all_worker(self, password: Optional[str] = None) -> None:
944
982
  self.log_message("[bold blue]Starting all services...[/bold blue]")
945
983
  self.notify("Starting all web services...", severity="information")
946
984
 
947
985
  def _do() -> None:
986
+ from ndev.linux.runtime.nginx_helper import run_privileged_command
948
987
  # 1. Nginx
949
988
  if shutil.which("systemctl"):
950
- subprocess.run(["sudo", "systemctl", "start", "nginx"])
989
+ try:
990
+ run_privileged_command(["systemctl", "start", "nginx"], password=password, check=False)
991
+ except Exception as e:
992
+ self.log_message(f"[yellow]Nginx start notice: {e}[/yellow]")
951
993
  # 2. MariaDB
952
994
  if shutil.which("systemctl"):
953
- subprocess.run(["sudo", "systemctl", "start", "mariadb"])
995
+ try:
996
+ run_privileged_command(["systemctl", "start", "mariadb"], password=password, check=False)
997
+ except Exception as e:
998
+ self.log_message(f"[yellow]MariaDB start notice: {e}[/yellow]")
954
999
  # 3. phpMyAdmin
955
1000
  try:
956
1001
  start_pma()
@@ -980,16 +1025,26 @@ class NdevDashboard(App):
980
1025
  self.notify("All services started.", severity="information")
981
1026
  self._refresh_all_data(full_rebuild=False)
982
1027
 
983
- @work(exclusive=True)
984
- async def action_stop_all(self) -> None:
1028
+ def action_stop_all(self) -> None:
985
1029
  """Stop all running services and FastCGI pools."""
1030
+ self.ensure_sudo("Stopping all services", self._stop_all_worker)
1031
+
1032
+ @work(exclusive=True)
1033
+ async def _stop_all_worker(self, password: Optional[str] = None) -> None:
986
1034
  self.log_message("[bold blue]Stopping all services...[/bold blue]")
987
1035
  self.notify("Stopping all web services...", severity="information")
988
1036
 
989
1037
  def _do() -> None:
1038
+ from ndev.linux.runtime.nginx_helper import run_privileged_command
990
1039
  if shutil.which("systemctl"):
991
- subprocess.run(["sudo", "systemctl", "stop", "nginx"])
992
- subprocess.run(["sudo", "systemctl", "stop", "mariadb"])
1040
+ try:
1041
+ run_privileged_command(["systemctl", "stop", "nginx"], password=password, check=False)
1042
+ except Exception:
1043
+ pass
1044
+ try:
1045
+ run_privileged_command(["systemctl", "stop", "mariadb"], password=password, check=False)
1046
+ except Exception:
1047
+ pass
993
1048
  stop_pma()
994
1049
  try:
995
1050
  from ndev.common.modules import get_module_manager
@@ -1007,16 +1062,26 @@ class NdevDashboard(App):
1007
1062
  self.notify("All services stopped.", severity="information")
1008
1063
  self._refresh_all_data(full_rebuild=False)
1009
1064
 
1010
- @work(exclusive=True)
1011
- async def action_restart_all(self) -> None:
1065
+ def action_restart_all(self) -> None:
1012
1066
  """Restart all services."""
1067
+ self.ensure_sudo("Restarting all services", self._restart_all_worker)
1068
+
1069
+ @work(exclusive=True)
1070
+ async def _restart_all_worker(self, password: Optional[str] = None) -> None:
1013
1071
  self.log_message("[bold blue]Restarting all services...[/bold blue]")
1014
1072
  self.notify("Restarting all web services...", severity="information")
1015
1073
 
1016
1074
  def _do() -> None:
1075
+ from ndev.linux.runtime.nginx_helper import run_privileged_command
1017
1076
  if shutil.which("systemctl"):
1018
- subprocess.run(["sudo", "systemctl", "restart", "nginx"])
1019
- subprocess.run(["sudo", "systemctl", "restart", "mariadb"])
1077
+ try:
1078
+ run_privileged_command(["systemctl", "restart", "nginx"], password=password, check=False)
1079
+ except Exception:
1080
+ pass
1081
+ try:
1082
+ run_privileged_command(["systemctl", "restart", "mariadb"], password=password, check=False)
1083
+ except Exception:
1084
+ pass
1020
1085
  restart_pma()
1021
1086
  try:
1022
1087
  from ndev.common.modules import get_module_manager
@@ -1034,29 +1099,25 @@ class NdevDashboard(App):
1034
1099
  self.notify("All services restarted.", severity="information")
1035
1100
  self._refresh_all_data(full_rebuild=False)
1036
1101
 
1037
- @work(exclusive=True)
1038
- async def action_reload_nginx(self) -> None:
1102
+ def action_reload_nginx(self) -> None:
1039
1103
  """Reload Nginx configuration."""
1104
+ self.ensure_sudo("Reloading Nginx", self._reload_nginx_worker)
1105
+
1106
+ @work(exclusive=True)
1107
+ async def _reload_nginx_worker(self, password: Optional[str] = None) -> None:
1040
1108
  self.log_message("[bold blue]Reloading Nginx configuration...[/bold blue]")
1041
1109
 
1042
- def _do_reload(pwd: Optional[str] = None) -> None:
1110
+ def _do_reload() -> bool:
1043
1111
  from ndev.linux.runtime.nginx_helper import reload_nginx
1044
- ok = reload_nginx(password=pwd or self._sudo_password)
1045
- if ok:
1046
- self.log_message("[bold green]✓ Nginx configuration reloaded successfully.[/bold green]")
1047
- self.notify("Nginx reloaded.", severity="information")
1048
- else:
1049
- self.log_message("[bold red]✗ Failed to reload Nginx.[/bold red]")
1050
- self.notify("Nginx reload failed.", severity="error")
1112
+ return reload_nginx(password=password)
1051
1113
 
1052
- if self._needs_sudo():
1053
- def _on_pwd(pwd: Optional[str]) -> None:
1054
- if pwd:
1055
- self._sudo_password = pwd
1056
- asyncio.create_task(asyncio.to_thread(_do_reload, pwd))
1057
- self.push_screen(SudoPasswordModal("Reloading Nginx"), _on_pwd)
1114
+ ok = await asyncio.to_thread(_do_reload)
1115
+ if ok:
1116
+ self.log_message("[bold green]✓ Nginx configuration reloaded successfully.[/bold green]")
1117
+ self.notify("Nginx reloaded.", severity="information")
1058
1118
  else:
1059
- await asyncio.to_thread(_do_reload, self._sudo_password)
1119
+ self.log_message("[bold red]✗ Failed to reload Nginx.[/bold red]")
1120
+ self.notify("Nginx reload failed.", severity="error")
1060
1121
 
1061
1122
  @work(exclusive=True)
1062
1123
  async def action_check_upgrades(self) -> None:
@@ -1187,7 +1248,14 @@ class NdevDashboard(App):
1187
1248
  pwd = res.get("password") or self._sudo_password
1188
1249
  if pwd:
1189
1250
  self._sudo_password = pwd
1190
- self._create_vhost_worker(res["domain"], res["root"], res["php"], res["ssl"], password=pwd)
1251
+ domain = res["domain"]
1252
+ root = res["root"]
1253
+ php_ver = res["php"]
1254
+ ssl = res["ssl"]
1255
+ self.ensure_sudo(
1256
+ f"Creating virtual host '{domain}'",
1257
+ lambda valid_pwd: self._create_vhost_worker(domain, root, php_ver, ssl, password=valid_pwd)
1258
+ )
1191
1259
 
1192
1260
  self.push_screen(CreateVhostModal(installed, curr, needs_sudo=self._needs_sudo()), _on_modal_result)
1193
1261
 
@@ -1205,6 +1273,7 @@ class NdevDashboard(App):
1205
1273
  ensure_nginx_sites_enabled_included,
1206
1274
  install_nginx_ssl_cert,
1207
1275
  ensure_docroot_accessible,
1276
+ ensure_selinux_httpd_permissions,
1208
1277
  write_privileged_file,
1209
1278
  symlink_privileged,
1210
1279
  add_to_hosts_privileged,
@@ -1233,6 +1302,7 @@ class NdevDashboard(App):
1233
1302
  logger.warning(f"Could not install SSL certificate into /etc/nginx/ssl: {e}")
1234
1303
 
1235
1304
  ensure_docroot_accessible(Path(root))
1305
+ ensure_selinux_httpd_permissions(password=password)
1236
1306
 
1237
1307
  mm = php_ver.replace(".", "")[:2] if "." in php_ver else php_ver
1238
1308
  selected_sock = NDEV_DIR / "run" / f"php{mm}.sock"
@@ -1280,20 +1350,12 @@ class NdevDashboard(App):
1280
1350
  row_key = vhost_table.coordinate_to_cell_key((vhost_table.cursor_row, 0)).row_key
1281
1351
  domain = str(row_key.value)
1282
1352
 
1283
- def _do_delete(pwd: Optional[str] = None) -> None:
1284
- if pwd:
1285
- self._sudo_password = pwd
1286
- self._delete_vhost_worker(domain, password=pwd or self._sudo_password)
1287
-
1288
1353
  def _on_confirm(confirmed: bool) -> None:
1289
1354
  if confirmed:
1290
- if self._needs_sudo():
1291
- self.push_screen(
1292
- SudoPasswordModal(f"Deleting virtual host '{domain}'"),
1293
- lambda pwd: _do_delete(pwd) if pwd else None
1294
- )
1295
- else:
1296
- _do_delete(self._sudo_password)
1355
+ self.ensure_sudo(
1356
+ f"Deleting virtual host '{domain}'",
1357
+ lambda pwd: self._delete_vhost_worker(domain, password=pwd)
1358
+ )
1297
1359
 
1298
1360
  msg = f"Are you sure you want to delete virtual host '{domain}'?\n\nThis will remove its Nginx configuration, SSL certificates, and hosts entry."
1299
1361
  self.push_screen(ConfirmActionModal("Delete Virtual Host", msg, confirm_label="Delete VHost", variant="error"), _on_confirm)
@@ -1315,8 +1377,6 @@ class NdevDashboard(App):
1315
1377
  except Exception as e:
1316
1378
  self.log_message(f"[bold red]✗ Failed to delete virtual host {domain}: {e}[/bold red]")
1317
1379
  self.notify(f"Delete failed: {e}", severity="error")
1318
- self.log_message(f"[bold red]✗ Failed to delete virtual host {domain}: {e}[/bold red]")
1319
- self.notify(f"Delete failed: {e}", severity="error")
1320
1380
 
1321
1381
  def action_install_php(self) -> None:
1322
1382
  """Open modal dialog to install a new PHP runtime."""
@@ -1385,7 +1445,7 @@ class NdevDashboard(App):
1385
1445
  self.notify(f"Uninstall failed: {e}", severity="error")
1386
1446
 
1387
1447
  def _handle_selected_service_install(self) -> None:
1388
- """Install or setup the selected service (PMA, Mailpit, Nginx, MariaDB)."""
1448
+ """Install or setup the selected service (PMA, Mailpit, Nginx, MariaDB, Redis)."""
1389
1449
  svc_table = self.query_one("#table-services", DataTable)
1390
1450
  if svc_table.cursor_row is not None and svc_table.row_count > 0:
1391
1451
  row_key = svc_table.coordinate_to_cell_key((svc_table.cursor_row, 0)).row_key
@@ -1396,34 +1456,41 @@ class NdevDashboard(App):
1396
1456
  self.notify("Please select a service row from the table.", severity="warning")
1397
1457
  return
1398
1458
 
1399
- self._install_service_worker(key)
1459
+ if key in ["nginx", "mariadb", "redis"]:
1460
+ self.ensure_sudo(
1461
+ f"Install {key}",
1462
+ lambda pwd: self._install_service_worker(key, password=pwd)
1463
+ )
1464
+ else:
1465
+ self._install_service_worker(key)
1400
1466
 
1401
1467
  @work(exclusive=True)
1402
- async def _install_service_worker(self, key: str) -> None:
1468
+ async def _install_service_worker(self, key: str, password: Optional[str] = None) -> None:
1403
1469
  self.query_one(TabbedContent).active = "tab-logs"
1404
1470
  self.log_message(f"[bold blue]Installing/setting up component '{key}'...[/bold blue]")
1405
1471
  self.notify(f"Installing {key}...", severity="information")
1406
1472
 
1407
1473
  def _do() -> None:
1474
+ from ndev.linux.runtime.nginx_helper import run_privileged_command
1408
1475
  if key == "pma":
1409
1476
  setup_pma()
1410
1477
  elif key == "mailpit":
1411
1478
  setup_mailpit()
1412
1479
  elif key == "redis":
1413
1480
  if shutil.which("zypper"):
1414
- subprocess.run(["sudo", "zypper", "--non-interactive", "install", "-y", "redis"], check=True)
1481
+ run_privileged_command(["zypper", "--non-interactive", "install", "-y", "redis"], password=password, timeout=300)
1415
1482
  elif shutil.which("apt") or shutil.which("apt-get"):
1416
- subprocess.run(["sudo", "apt-get", "install", "-y", "redis-server"], check=True)
1483
+ run_privileged_command(["apt-get", "install", "-y", "redis-server"], password=password, timeout=300)
1417
1484
  elif key == "nginx":
1418
1485
  if shutil.which("zypper"):
1419
- subprocess.run(["sudo", "zypper", "--non-interactive", "install", "-y", "nginx"], check=True)
1486
+ run_privileged_command(["zypper", "--non-interactive", "install", "-y", "nginx"], password=password, timeout=300)
1420
1487
  elif shutil.which("apt") or shutil.which("apt-get"):
1421
- subprocess.run(["sudo", "apt-get", "install", "-y", "nginx"], check=True)
1488
+ run_privileged_command(["apt-get", "install", "-y", "nginx"], password=password, timeout=300)
1422
1489
  elif key == "mariadb":
1423
1490
  if shutil.which("zypper"):
1424
- subprocess.run(["sudo", "zypper", "--non-interactive", "install", "-y", "mariadb", "mariadb-client"], check=True)
1491
+ run_privileged_command(["zypper", "--non-interactive", "install", "-y", "mariadb", "mariadb-client"], password=password, timeout=300)
1425
1492
  elif shutil.which("apt") or shutil.which("apt-get"):
1426
- subprocess.run(["sudo", "apt-get", "install", "-y", "mariadb-server"], check=True)
1493
+ run_privileged_command(["apt-get", "install", "-y", "mariadb-server"], password=password, timeout=300)
1427
1494
 
1428
1495
  elif key.startswith("mod:"):
1429
1496
  mod_name = key.split(":", 1)[1]
@@ -1522,8 +1589,7 @@ class NdevDashboard(App):
1522
1589
  if row_key:
1523
1590
  self._switch_php_version(row_key)
1524
1591
 
1525
- @work(exclusive=True)
1526
- async def _handle_selected_service_action(self, action: str) -> None:
1592
+ def _handle_selected_service_action(self, action: str) -> None:
1527
1593
  """Contextually start, stop, or restart the service highlighted in the table."""
1528
1594
  svc_table = self.query_one("#table-services", DataTable)
1529
1595
  if svc_table.cursor_row is not None and svc_table.row_count > 0:
@@ -1535,20 +1601,35 @@ class NdevDashboard(App):
1535
1601
  self.notify("Please select a service row from the table.", severity="warning")
1536
1602
  return
1537
1603
 
1604
+ if key in ["nginx", "mariadb"]:
1605
+ self.ensure_sudo(
1606
+ f"{action.capitalize()} {key}",
1607
+ lambda pwd: self._service_action_worker(key, action, password=pwd)
1608
+ )
1609
+ else:
1610
+ self._service_action_worker(key, action)
1611
+
1612
+ @work(exclusive=True)
1613
+ async def _service_action_worker(self, key: str, action: str, password: Optional[str] = None) -> None:
1538
1614
  self.log_message(f"[bold blue]Executing '{action}' on {key}...[/bold blue]")
1539
1615
 
1540
1616
  def _do() -> None:
1617
+ from ndev.linux.runtime.nginx_helper import run_privileged_command
1541
1618
  if key == "nginx":
1542
1619
  if shutil.which("systemctl"):
1543
- subprocess.run(["sudo", "systemctl", action, "nginx"], check=True)
1620
+ run_privileged_command(["systemctl", action, "nginx"], password=password)
1544
1621
  else:
1545
- subprocess.run(["sudo", "service", "nginx", action], check=True)
1622
+ run_privileged_command(["service", "nginx", action], password=password)
1546
1623
 
1547
1624
  elif key == "mariadb":
1625
+ svc_name = "mariadb"
1548
1626
  if shutil.which("systemctl"):
1549
- subprocess.run(["sudo", "systemctl", action, "mariadb"], check=True)
1627
+ chk = subprocess.run(["systemctl", "status", "mariadb"], capture_output=True)
1628
+ if chk.returncode == 4:
1629
+ svc_name = "mysql"
1630
+ run_privileged_command(["systemctl", action, svc_name], password=password)
1550
1631
  else:
1551
- subprocess.run(["sudo", "service", "mariadb", action], check=True)
1632
+ run_privileged_command(["service", svc_name, action], password=password)
1552
1633
 
1553
1634
  elif key == "pma":
1554
1635
  if action == "start":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndev-stack
3
- Version: 0.3.12
3
+ Version: 0.3.13
4
4
  Summary: Cross-platform developer stack manager for PHP, FastCGI/PHP-FPM, MariaDB, Redis, PostgreSQL, MongoDB, Nginx, SSL, and Mailpit on Linux and Windows
5
5
  Author-email: Ankit Karan <ankit.karan99@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/ankitkaran99/ndev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ndev-stack"
7
- version = "0.3.12"
7
+ version = "0.3.13"
8
8
  description = "Cross-platform developer stack manager for PHP, FastCGI/PHP-FPM, MariaDB, Redis, PostgreSQL, MongoDB, Nginx, SSL, and Mailpit on Linux and Windows"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,6 +4,7 @@ Includes tests for distro detection, caching, Nginx vhost management, service di
4
4
  log resolution, and process permission handling.
5
5
  """
6
6
  import os
7
+ import subprocess
7
8
  import unittest
8
9
  from unittest.mock import patch, MagicMock
9
10
  from pathlib import Path
@@ -331,6 +332,52 @@ class TestNginxHelper(unittest.TestCase):
331
332
  self.assertTrue(removed)
332
333
  self.assertFalse(conf_file.exists())
333
334
 
335
+ def test_run_privileged_command(self):
336
+ with patch("subprocess.run") as mock_run:
337
+ mock_run.return_value = subprocess.CompletedProcess(args=["systemctl", "status"], returncode=0)
338
+ with patch("os.geteuid", return_value=1000):
339
+ with patch("ndev.linux.runtime.nginx_helper.validate_sudo_password") as mock_val:
340
+ nginx_helper.run_privileged_command(["systemctl", "status", "nginx"], password="secret")
341
+ mock_val.assert_called_once_with("secret")
342
+ mock_run.assert_called_once()
343
+ self.assertEqual(mock_run.call_args[0][0], ["sudo", "-n", "systemctl", "status", "nginx"])
344
+
345
+ def test_is_selinux_enforcing_and_permissive(self):
346
+ with patch.object(Path, "exists", return_value=True):
347
+ with patch.object(Path, "read_text", return_value="1\n"):
348
+ self.assertTrue(nginx_helper.is_selinux_enforcing())
349
+
350
+ with patch("ndev.linux.runtime.nginx_helper.is_selinux_enforcing", return_value=True):
351
+ with patch("shutil.which", return_value="/usr/bin/seinfo"):
352
+ with patch("subprocess.run") as mock_run:
353
+ mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="Permissive Types: 2\n httpd_t\n xrdp_t\n")
354
+ self.assertTrue(nginx_helper.is_selinux_httpd_permissive())
355
+
356
+ mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="Permissive Types: 1\n xrdp_t\n")
357
+ self.assertFalse(nginx_helper.is_selinux_httpd_permissive())
358
+
359
+ def test_ensure_selinux_httpd_permissions(self):
360
+ with patch("ndev.linux.runtime.nginx_helper.is_selinux_enforcing", return_value=True):
361
+ with patch("ndev.linux.runtime.nginx_helper.is_selinux_httpd_permissive", side_effect=[False, True]):
362
+ with patch("ndev.linux.runtime.nginx_helper.run_privileged_command") as mock_priv:
363
+ res = nginx_helper.ensure_selinux_httpd_permissions(password="pwd")
364
+ self.assertTrue(res)
365
+ mock_priv.assert_called()
366
+
367
+ def test_ensure_docroot_accessible_ancestors(self):
368
+ home_dir = self.test_dir / "userhome"
369
+ sub_dir = home_dir / "opt" / "Sites" / "app" / "public"
370
+ sub_dir.mkdir(parents=True)
371
+ # Remove other execute
372
+ os.chmod(home_dir, 0o700)
373
+ os.chmod(home_dir / "opt", 0o700)
374
+
375
+ with patch("pathlib.Path.home", return_value=home_dir):
376
+ with patch.dict(os.environ, {}, clear=True):
377
+ nginx_helper.ensure_docroot_accessible(sub_dir)
378
+ self.assertTrue(bool(home_dir.stat().st_mode & 0o001))
379
+ self.assertTrue(bool((home_dir / "opt").stat().st_mode & 0o001))
380
+
334
381
 
335
382
  class TestSocketsAndProcess(unittest.TestCase):
336
383
  def test_get_major_minor_safe(self):
@@ -502,5 +549,35 @@ class TestTuiSelectEventHandling(unittest.TestCase):
502
549
  app._switch_php_version.assert_called_once_with("8.4.27")
503
550
 
504
551
 
552
+ class TestTuiEnsureSudo(unittest.TestCase):
553
+ def test_ensure_sudo_when_not_needed(self):
554
+ from ndev.linux.tui import NdevDashboard
555
+ app = NdevDashboard()
556
+ app._needs_sudo = MagicMock(return_value=False)
557
+ app._sudo_password = "cached_password"
558
+
559
+ callback = MagicMock()
560
+ app.ensure_sudo("Test Action", callback)
561
+ callback.assert_called_once_with("cached_password")
562
+
563
+ def test_ensure_sudo_prompts_modal_when_needed(self):
564
+ from ndev.linux.tui import NdevDashboard
565
+ app = NdevDashboard()
566
+ app._needs_sudo = MagicMock(return_value=True)
567
+ app.push_screen = MagicMock()
568
+
569
+ callback = MagicMock()
570
+ app.ensure_sudo("Privileged Action", callback)
571
+
572
+ app.push_screen.assert_called_once()
573
+ modal, on_dismiss = app.push_screen.call_args[0]
574
+ self.assertEqual(modal.action_name, "Privileged Action")
575
+
576
+ # Simulate user providing password
577
+ on_dismiss("new_password")
578
+ self.assertEqual(app._sudo_password, "new_password")
579
+ callback.assert_called_once_with("new_password")
580
+
581
+
505
582
  if __name__ == "__main__":
506
583
  unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes