ndev-stack 0.3.11__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.11 → ndev_stack-0.3.13}/PKG-INFO +1 -1
  2. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/__init__.py +1 -1
  3. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/doctor.py +16 -0
  4. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/setup.py +6 -0
  5. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/vhost.py +20 -5
  6. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/runtime/nginx_helper.py +195 -7
  7. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/tui.py +157 -62
  8. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev_stack.egg-info/PKG-INFO +1 -1
  9. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/pyproject.toml +1 -1
  10. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/tests/test_opensuse_compat.py +98 -0
  11. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/README.md +0 -0
  12. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/__main__.py +0 -0
  13. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/cli.py +0 -0
  14. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/__init__.py +0 -0
  15. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/config.py +0 -0
  16. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/constants.py +0 -0
  17. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/distro.py +0 -0
  18. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/github.py +0 -0
  19. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/logger.py +0 -0
  20. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/manifest.py +0 -0
  21. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/modules/__init__.py +0 -0
  22. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/modules/base.py +0 -0
  23. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/modules/builtin/mailpit/manifest.json +0 -0
  24. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/modules/builtin/mailpit/module.py +0 -0
  25. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/modules/builtin/mongodb/manifest.json +0 -0
  26. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/modules/builtin/mongodb/module.py +0 -0
  27. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/modules/builtin/postgres/manifest.json +0 -0
  28. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/modules/builtin/postgres/module.py +0 -0
  29. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/modules/builtin/redis/manifest.json +0 -0
  30. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/modules/builtin/redis/module.py +0 -0
  31. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/modules/manager.py +0 -0
  32. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/common/utils.py +0 -0
  33. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/__init__.py +0 -0
  34. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/chroot/manager.py +0 -0
  35. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/chroot/packages.py +0 -0
  36. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/chroot/shell.py +0 -0
  37. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/cli.py +0 -0
  38. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/available.py +0 -0
  39. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/clean.py +0 -0
  40. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/ctl.py +0 -0
  41. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/current.py +0 -0
  42. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/db.py +0 -0
  43. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/grok.py +0 -0
  44. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/install.py +0 -0
  45. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/list.py +0 -0
  46. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/logs.py +0 -0
  47. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/mailpit.py +0 -0
  48. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/modules.py +0 -0
  49. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/mongodb.py +0 -0
  50. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/postgres.py +0 -0
  51. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/reload.py +0 -0
  52. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/restart.py +0 -0
  53. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/start.py +0 -0
  54. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/status.py +0 -0
  55. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/stop.py +0 -0
  56. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/uninstall.py +0 -0
  57. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/update.py +0 -0
  58. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/upgrade.py +0 -0
  59. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/commands/use.py +0 -0
  60. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/php/builder.py +0 -0
  61. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/php/downloader.py +0 -0
  62. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/php/extensions.py +0 -0
  63. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/php/installer.py +0 -0
  64. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/php/resolver.py +0 -0
  65. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/php/templates.py +0 -0
  66. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/runtime/fpm.py +0 -0
  67. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/runtime/mailpit.py +0 -0
  68. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/runtime/pma.py +0 -0
  69. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/runtime/process.py +0 -0
  70. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/runtime/services.py +0 -0
  71. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/runtime/sockets.py +0 -0
  72. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/linux/runtime/upgrade.py +0 -0
  73. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/main.py +0 -0
  74. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/tui.py +0 -0
  75. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/__init__.py +0 -0
  76. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/bin/shim.exe +0 -0
  77. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/cli.py +0 -0
  78. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/commands/__init__.py +0 -0
  79. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/__init__.py +0 -0
  80. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/db.py +0 -0
  81. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/elevate.py +0 -0
  82. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/ext.py +0 -0
  83. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/fcgi.py +0 -0
  84. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/grok.py +0 -0
  85. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/logs.py +0 -0
  86. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/mailpit.py +0 -0
  87. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/mkcert.py +0 -0
  88. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/paths.py +0 -0
  89. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/php.py +0 -0
  90. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/pma.py +0 -0
  91. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/redis_core.py +0 -0
  92. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/services.py +0 -0
  93. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/setup.py +0 -0
  94. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/shim.py +0 -0
  95. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/upgrade.py +0 -0
  96. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/core/vhost.py +0 -0
  97. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/templates/vhost.conf.tmpl +0 -0
  98. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/templates/vhost_ssl.conf.tmpl +0 -0
  99. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev/win/tui.py +0 -0
  100. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev_stack.egg-info/SOURCES.txt +0 -0
  101. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev_stack.egg-info/dependency_links.txt +0 -0
  102. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev_stack.egg-info/entry_points.txt +0 -0
  103. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev_stack.egg-info/requires.txt +0 -0
  104. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/ndev_stack.egg-info/top_level.txt +0 -0
  105. {ndev_stack-0.3.11 → ndev_stack-0.3.13}/setup.cfg +0 -0
  106. {ndev_stack-0.3.11 → 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.11
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.11"
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
@@ -319,6 +319,9 @@ def vhost_cmd(
319
319
  get_nginx_vhost_dirs,
320
320
  generate_vhost_config,
321
321
  ensure_nginx_sites_enabled_included,
322
+ install_nginx_ssl_cert,
323
+ ensure_docroot_accessible,
324
+ ensure_selinux_httpd_permissions,
322
325
  add_to_hosts_privileged,
323
326
  reload_nginx,
324
327
  )
@@ -338,14 +341,26 @@ def vhost_cmd(
338
341
  if enabled_dir:
339
342
  ensure_nginx_sites_enabled_included()
340
343
 
344
+ # Install SSL certificates into /etc/nginx/ssl for systemd / SELinux compatibility
345
+ final_cert_path = cert_path
346
+ final_key_path = key_path
347
+ if ssl and cert_path and key_path:
348
+ try:
349
+ final_cert_path, final_key_path = install_nginx_ssl_cert(domain, cert_path, key_path)
350
+ except Exception as e:
351
+ logger.warning(f"Could not install SSL certificate to /etc/nginx/ssl: {e}")
352
+
353
+ ensure_docroot_accessible(root_path)
354
+ ensure_selinux_httpd_permissions()
355
+
341
356
  conf_file = vhost_dir / f"{domain}.conf"
342
357
  config_template = generate_vhost_config(
343
358
  domain=domain,
344
- root=root,
359
+ root=str(root_path),
345
360
  selected_sock=selected_sock,
346
361
  ssl=bool(ssl),
347
- cert_path=cert_path,
348
- key_path=key_path,
362
+ cert_path=final_cert_path,
363
+ key_path=final_key_path,
349
364
  )
350
365
 
351
366
  enabled_link = None
@@ -382,8 +397,8 @@ def vhost_cmd(
382
397
  if enabled_link:
383
398
  console.print(f"Symlink : {enabled_link}")
384
399
  if ssl:
385
- console.print(f"SSL Cert : {cert_path}")
386
- console.print(f"SSL Key : {key_path}")
400
+ console.print(f"SSL Cert : {final_cert_path}")
401
+ console.print(f"SSL Key : {final_key_path}")
387
402
  console.print(f"\nOpen: https://{domain}")
388
403
  else:
389
404
  console.print(f"\nOpen: http://{domain}")
@@ -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.
@@ -426,13 +516,24 @@ server {{
426
516
 
427
517
  def reload_nginx(password: Optional[str] = None) -> bool:
428
518
  """
429
- Reload Nginx configuration safely using available init system or nginx CLI.
519
+ Reload Nginx configuration if active, or start Nginx if stopped.
430
520
  """
521
+ if os.geteuid() != 0 and password:
522
+ validate_sudo_password(password)
523
+
431
524
  cmd = None
432
525
  if shutil.which("systemctl"):
433
- cmd = ["systemctl", "reload", "nginx"]
526
+ res = subprocess.run(["systemctl", "is-active", "--quiet", "nginx"])
527
+ if res.returncode == 0:
528
+ cmd = ["systemctl", "reload", "nginx"]
529
+ else:
530
+ cmd = ["systemctl", "start", "nginx"]
434
531
  elif shutil.which("service"):
435
- cmd = ["service", "nginx", "reload"]
532
+ res = subprocess.run(["service", "nginx", "status"], capture_output=True)
533
+ if res.returncode == 0:
534
+ cmd = ["service", "nginx", "reload"]
535
+ else:
536
+ cmd = ["service", "nginx", "start"]
436
537
  else:
437
538
  nginx_bin = which_binary("nginx") or shutil.which("nginx")
438
539
  if nginx_bin:
@@ -442,18 +543,102 @@ def reload_nginx(password: Optional[str] = None) -> bool:
442
543
  return False
443
544
 
444
545
  if os.geteuid() != 0:
445
- if password:
446
- validate_sudo_password(password)
447
546
  cmd = ["sudo", "-n"] + cmd
448
547
 
449
548
  try:
450
549
  res = subprocess.run(cmd, capture_output=True, timeout=10)
451
550
  return res.returncode == 0
452
551
  except Exception as e:
453
- logger.warning(f"Could not reload Nginx: {e}")
552
+ logger.warning(f"Could not reload/start Nginx: {e}")
454
553
  return False
455
554
 
456
555
 
556
+ def install_nginx_ssl_cert(
557
+ domain: str,
558
+ src_cert: Path | str,
559
+ src_key: Path | str,
560
+ password: Optional[str] = None
561
+ ) -> Tuple[Path, Path]:
562
+ """
563
+ Install SSL certificate and key into /etc/nginx/ssl/{domain}.* with proper permissions and SELinux label.
564
+ Fixes 'BIO_new_file() failed (SSL: error:8000000d:system library:BIO_new_file:Permission denied)'
565
+ caused by systemd ProtectHome and SELinux restricting access to ~/.ndev/certs.
566
+ Returns (installed_cert_path, installed_key_path).
567
+ """
568
+ ssl_dir = Path("/etc/nginx/ssl")
569
+ dest_cert = ssl_dir / f"{domain}.crt"
570
+ dest_key = ssl_dir / f"{domain}.key"
571
+
572
+ cert_content = Path(src_cert).read_text(encoding="utf-8")
573
+ key_content = Path(src_key).read_text(encoding="utf-8")
574
+
575
+ write_privileged_file(dest_cert, cert_content, password=password)
576
+ write_privileged_file(dest_key, key_content, password=password)
577
+
578
+ if os.geteuid() == 0:
579
+ try:
580
+ os.chmod(str(dest_cert), 0o644)
581
+ os.chmod(str(dest_key), 0o644)
582
+ except Exception:
583
+ pass
584
+ else:
585
+ try:
586
+ os.chmod(str(dest_cert), 0o644)
587
+ os.chmod(str(dest_key), 0o644)
588
+ except (PermissionError, OSError):
589
+ subprocess.run(["sudo", "-n", "chmod", "644", str(dest_cert), str(dest_key)], check=False)
590
+
591
+ if shutil.which("restorecon"):
592
+ cmd = ["restorecon", "-Rv", str(ssl_dir)]
593
+ if os.geteuid() != 0:
594
+ cmd = ["sudo", "-n"] + cmd
595
+ try:
596
+ subprocess.run(cmd, capture_output=True)
597
+ except Exception:
598
+ pass
599
+
600
+ return dest_cert, dest_key
601
+
602
+
603
+ def ensure_docroot_accessible(root_path: Path) -> None:
604
+ """
605
+ Ensure the parent directories up to root_path have execute permission (+x)
606
+ so the nginx worker process (e.g. user nginx) can traverse into document root.
607
+ """
608
+ home = Path.home()
609
+ sudo_user = os.environ.get("SUDO_USER")
610
+ if sudo_user:
611
+ try:
612
+ import pwd
613
+ home = Path(pwd.getpwnam(sudo_user).pw_dir)
614
+ except Exception:
615
+ pass
616
+
617
+ try:
618
+ resolved_root = root_path.resolve()
619
+ resolved_home = home.resolve()
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)
638
+ except Exception:
639
+ pass
640
+
641
+
457
642
  def list_vhosts() -> List[Dict[str, Any]]:
458
643
  """List all configured virtual hosts across all searched Nginx config directories."""
459
644
  vhosts = []
@@ -513,8 +698,11 @@ def remove_vhost(domain: str, password: Optional[str] = None) -> bool:
513
698
  except Exception as e:
514
699
  logger.warning(f"Could not remove {conf_file}: {e}")
515
700
 
516
- # Remove optional certificates
701
+ # Remove system and optional certificates
517
702
  for cert_ext in [".crt", ".key"]:
703
+ sys_cert = Path(f"/etc/nginx/ssl/{clean_domain}{cert_ext}")
704
+ unlink_privileged(sys_cert, password=password)
705
+
518
706
  cfile = NDEV_DIR / "certs" / f"{clean_domain}{cert_ext}"
519
707
  if cfile.exists():
520
708
  try: