commit 17dfd5e49d34363f196431851bfb43a38ac4bc88 Author: MeghdadFadaee Date: Tue Jun 16 18:55:05 2026 +0330 Initial public release diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6cbc618 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.php] +indent_size = 4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be9481b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Local machine configuration. Copy config.example.json to config.json. +/config.json + +# Runtime/test output. +/.php-dev-router.* +/tmp/ + +# Dependency and editor state. +/vendor/ +/.idea/ +/.vscode/ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f568a0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 php-dev-router contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f591bd --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# php-dev-router + +Local HTTPS router for PHP and Laravel projects. + +`php-dev-router` discovers PHP apps in a projects directory, generates nginx +virtual hosts, adds exact local host records to `/etc/hosts`, and can run as a +small systemd watcher so newly added projects become available automatically. + +It is built for development workstations, not production servers. + +## Features + +- Detects Laravel apps, public front-controller apps, and generic PHP apps. +- Generates HTTPS nginx server blocks backed by PHP-FPM. +- Maps only detected project hostnames to `127.0.0.1`. +- Avoids wildcard DNS hijacking, so unrelated real domains keep resolving + normally. +- Supports nested apps such as `monorepo/api`. +- Has no runtime Composer dependencies. + +## Requirements + +- Linux with systemd +- PHP 8.2 or newer +- nginx +- PHP-FPM +- A local or trusted development certificate/key for the configured domain + +The default example config uses `php.test`. Browsers will still require a +certificate trusted by your machine for whatever domain you configure. + +## Quick Start + +```bash +git clone +cd php-dev-router +cp config.example.json config.json +$EDITOR config.json + +./bin/php-dev-router scan +./bin/php-dev-router doctor +sudo ./bin/php-dev-router apply +sudo ./bin/php-dev-router install +``` + +`install.sh` performs the same install flow and creates `config.json` from the +example if it does not already exist: + +```bash +./install.sh +``` + +## Configuration + +Local settings live in `config.json`, which is intentionally ignored by git. +Start from `config.example.json`. + +Important options: + +- `projects_root`: directory that contains your PHP projects. +- `domain`: suffix used for generated hostnames. +- `ssl_certificate` / `ssl_certificate_key`: certificate used by nginx. +- `php_fpm_socket`: PHP-FPM socket passed to nginx. +- `nginx_conf`: generated nginx config target. +- `hosts_file`: hosts file target, usually `/etc/hosts`. +- `systemd_service`: watcher service target. + +Paths may use `~`. + +## Commands + +```bash +./bin/php-dev-router scan [--json] +./bin/php-dev-router doctor +./bin/php-dev-router apply [--dry-run] [--no-reload] [--no-nginx-test] +./bin/php-dev-router watch [--interval=3] +./bin/php-dev-router install [--dry-run] +``` + +Use `apply --dry-run` to review generated nginx and hosts output before writing +system files. + +`--no-nginx-test` is intended only for temporary non-root tests. Normal system +installs should let the tool run `nginx -t` before reload. + +## Detection Rules + +- Laravel app: `artisan` and `public/index.php`, served from `public`. +- Public front-controller app: `public/index.php`, served from `public`. +- Generic PHP app: `index.php`, served from that directory. + +Ignored directories include hidden/tooling directories, `vendor`, `node_modules`, +`storage`, and common IDE/cache paths. + +## Hostnames + +Hostnames are built from the relative project path and the configured domain. +Path parts are joined with hyphens. + +Examples for `domain = "php.test"`: + +- `billing_admin` -> `billing-admin.php.test` +- `platform/api` -> `platform-api.php.test` + +If two projects collide after slugging, a short deterministic hash is appended. + +## What Install Writes + +`install` writes: + +- the generated nginx config from `nginx_conf` +- a managed `php-dev-router` block in `hosts_file` +- the watcher service from `systemd_service` + +It then runs `nginx -t`, reloads nginx, enables PHP-FPM/nginx when their systemd +units are available, and enables `php-dev-router.service`. + +## Publishing Notes + +Do not commit `config.json`; it contains machine-specific paths, domains, and +certificate locations. Commit `config.example.json` instead. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f71907d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +`php-dev-router` is intended for local development machines. Do not use it as a +public production vhost manager. + +The tool writes nginx, hosts, and systemd files only when explicitly run with +sufficient permissions. Review generated config with: + +```bash +./bin/php-dev-router apply --dry-run +``` + +Please report security issues privately to the repository maintainer instead of +opening a public issue with exploit details. diff --git a/bin/php-dev-router b/bin/php-dev-router new file mode 100755 index 0000000..dbccdd3 --- /dev/null +++ b/bin/php-dev-router @@ -0,0 +1,537 @@ +#!/usr/bin/env php + commandScan($config, $options), + 'apply' => commandApply($config, $options), + 'watch' => commandWatch($config, $options), + 'doctor', 'status' => commandDoctor($config), + 'install' => commandInstall($config, $options), + 'help', '-h', '--help' => commandHelp(), + default => fail("Unknown command: {$command}\nRun: php-dev-router help"), + }; + } catch (RuntimeException $exception) { + fwrite(STDERR, "php-dev-router: {$exception->getMessage()}\n"); + exit(1); + } +} + +function parseOptions(array $args): array +{ + $options = []; + foreach ($args as $arg) { + if (!str_starts_with($arg, '--')) { + $options['_'][] = $arg; + continue; + } + + $arg = substr($arg, 2); + if (str_contains($arg, '=')) { + [$key, $value] = explode('=', $arg, 2); + $options[$key] = $value; + } else { + $options[$arg] = true; + } + } + + return $options; +} + +function loadConfig(?string $path): array +{ + $projectRoot = dirname(__DIR__); + $configPath = $path ? expandPath($path) : defaultConfigPath($projectRoot); + if (!is_file($configPath)) { + fail("Config file not found: {$configPath}"); + } + + $config = json_decode((string) file_get_contents($configPath), true); + if (!is_array($config)) { + fail("Config file is not valid JSON: {$configPath}"); + } + + $defaults = [ + 'projects_root' => getenv('HOME') . '/PhpstormProjects', + 'domain' => 'php.test', + 'ssl_certificate' => getenv('HOME') . '/.ssl/php-dev-router/fullchain.pem', + 'ssl_certificate_key' => getenv('HOME') . '/.ssl/php-dev-router/privkey.pem', + 'php_fpm_socket' => '/run/php-fpm/php-fpm.sock', + 'nginx_conf' => '/etc/nginx/conf.d/php-dev-router.conf', + 'hosts_file' => '/etc/hosts', + 'systemd_service' => '/etc/systemd/system/php-dev-router.service', + 'skip_dirs' => ['.git', '.idea', '.vscode', 'bootstrap/cache', 'node_modules', 'storage', 'vendor'], + 'scan_max_depth' => 5, + 'watch_interval_seconds' => 3, + 'config_path' => $configPath, + 'project_root' => $projectRoot, + ]; + + $config = array_replace($defaults, $config); + foreach (['projects_root', 'ssl_certificate', 'ssl_certificate_key', 'nginx_conf', 'hosts_file', 'systemd_service'] as $key) { + $config[$key] = expandPath((string) $config[$key]); + } + $config['domain'] = trim((string) $config['domain'], ". \t\n\r\0\x0B"); + $config['scan_max_depth'] = max(1, (int) $config['scan_max_depth']); + $config['watch_interval_seconds'] = max(1, (int) $config['watch_interval_seconds']); + + return $config; +} + +function commandHelp(): void +{ + $version = VERSION; + echo << {$app['root']} ({$app['type']})\n"; + } +} + +function commandApply(array $config, array $options): void +{ + $apps = discoverApps($config); + $nginx = renderNginx($config, $apps); + $hosts = renderHostsBlock($apps); + + if (isset($options['dry-run'])) { + echo "Would write nginx config: {$config['nginx_conf']}\n"; + echo "Would update hosts file: {$config['hosts_file']}\n\n"; + echo $nginx; + echo "\n"; + echo $hosts; + return; + } + + ensureWritableTargets([$config['nginx_conf'], $config['hosts_file']]); + atomicWrite($config['nginx_conf'], $nginx); + updateHostsFile($config['hosts_file'], $hosts); + + if (empty($options['no-nginx-test'])) { + runOrFail(['nginx', '-t']); + } + if (empty($options['no-reload'])) { + reloadService('nginx'); + } + + echo "Applied " . count($apps) . " app route(s).\n"; +} + +function commandWatch(array $config, array $options): void +{ + $interval = isset($options['interval']) ? max(1, (int) $options['interval']) : $config['watch_interval_seconds']; + echo "Watching {$config['projects_root']} every {$interval}s\n"; + + $lastHash = ''; + while (true) { + $apps = discoverApps($config); + $hash = hash('sha256', json_encode($apps, JSON_UNESCAPED_SLASHES)); + if ($hash !== $lastHash) { + $lastHash = $hash; + try { + commandApply($config, ['no-reload' => false]); + } catch (RuntimeException $exception) { + fwrite(STDERR, date('c') . " apply failed: {$exception->getMessage()}\n"); + } + } + sleep($interval); + } +} + +function commandDoctor(array $config): void +{ + $apps = discoverApps($config); + $checks = [ + ['Projects root', is_dir($config['projects_root']), $config['projects_root']], + ['SSL cert', is_file($config['ssl_certificate']), $config['ssl_certificate']], + ['SSL key', is_file($config['ssl_certificate_key']), $config['ssl_certificate_key']], + ['PHP-FPM socket', file_exists($config['php_fpm_socket']), $config['php_fpm_socket']], + ['nginx config dir', is_dir(dirname($config['nginx_conf'])), dirname($config['nginx_conf'])], + ['hosts file', is_file($config['hosts_file']), $config['hosts_file']], + ]; + + foreach ($checks as [$name, $ok, $detail]) { + echo sprintf("%-18s %s %s\n", $name, $ok ? 'OK ' : 'MISS', $detail); + } + + echo "\nDetected apps: " . count($apps) . "\n"; + foreach ($apps as $app) { + $resolved = trim((string) shell_exec('getent hosts ' . escapeshellarg($app['host']) . ' 2>/dev/null')); + $dnsStatus = str_contains($resolved, '127.0.0.1') ? 'resolves' : 'not-local'; + echo " {$app['host']} -> {$app['root']} ({$dnsStatus})\n"; + } +} + +function commandInstall(array $config, array $options): void +{ + $service = renderSystemdService($config); + + if (isset($options['dry-run'])) { + echo "Would write systemd service: {$config['systemd_service']}\n\n"; + echo $service; + echo "\n"; + commandApply($config, ['dry-run' => true]); + return; + } + + ensureWritableTargets([$config['systemd_service'], $config['nginx_conf'], $config['hosts_file']]); + atomicWrite($config['systemd_service'], $service); + commandApply($config, ['no-reload' => false]); + + runOrFail(['systemctl', 'daemon-reload']); + enableServiceIfAvailable('php-fpm'); + enableServiceIfAvailable('nginx'); + runOrFail(['systemctl', 'enable', '--now', 'php-dev-router.service']); + + echo "Installed php-dev-router watcher service.\n"; +} + +function discoverApps(array $config): array +{ + $root = rtrim($config['projects_root'], '/'); + if (!is_dir($root)) { + return []; + } + + $candidates = []; + scanDirectory($root, $root, 0, $config, $candidates); + + $usedHosts = []; + $apps = []; + foreach ($candidates as $candidate) { + $relative = trim(substr($candidate['app_dir'], strlen($root)), '/'); + $slug = slugify($relative === '' ? basename($candidate['app_dir']) : $relative); + $host = "{$slug}.{$config['domain']}"; + if (isset($usedHosts[$host])) { + $hash = substr(hash('sha1', $candidate['app_dir']), 0, 7); + $host = "{$slug}-{$hash}.{$config['domain']}"; + } + $usedHosts[$host] = true; + $apps[] = [ + 'host' => $host, + 'name' => $slug, + 'type' => $candidate['type'], + 'app_dir' => $candidate['app_dir'], + 'root' => $candidate['root'], + 'index' => $candidate['root'] . '/index.php', + ]; + } + + usort($apps, fn (array $a, array $b): int => strcmp($a['host'], $b['host'])); + return $apps; +} + +function scanDirectory(string $dir, string $base, int $depth, array $config, array &$candidates): void +{ + if ($depth > $config['scan_max_depth']) { + return; + } + + $dir = rtrim($dir, '/'); + if (is_file("{$dir}/public/index.php")) { + $type = is_file("{$dir}/artisan") ? 'laravel' : 'public-front-controller'; + $candidates[] = ['app_dir' => $dir, 'root' => "{$dir}/public", 'type' => $type]; + } elseif (is_file("{$dir}/index.php")) { + $candidates[] = ['app_dir' => $dir, 'root' => $dir, 'type' => 'generic-php']; + } + + $children = @scandir($dir); + if ($children === false) { + return; + } + + foreach ($children as $child) { + if ($child === '.' || $child === '..') { + continue; + } + + $path = "{$dir}/{$child}"; + if ($child === 'public' || str_starts_with($child, '.')) { + continue; + } + if (!is_dir($path) || shouldSkip($path, $base, $config['skip_dirs'])) { + continue; + } + scanDirectory($path, $base, $depth + 1, $config, $candidates); + } +} + +function shouldSkip(string $path, string $base, array $skipDirs): bool +{ + $relative = trim(substr($path, strlen(rtrim($base, '/'))), '/'); + $name = basename($path); + foreach ($skipDirs as $skip) { + $skip = trim((string) $skip, '/'); + if ($skip === $name || $skip === $relative || str_ends_with($relative, "/{$skip}")) { + return true; + } + } + + return false; +} + +function renderNginx(array $config, array $apps): string +{ + $lines = [ + '# Managed by php-dev-router. Re-running apply replaces this file.', + '# Source: ' . $config['config_path'], + '', + ]; + + foreach ($apps as $app) { + $host = nginxEscape($app['host']); + $root = nginxEscape($app['root']); + $cert = nginxEscape($config['ssl_certificate']); + $key = nginxEscape($config['ssl_certificate_key']); + $socket = nginxEscape($config['php_fpm_socket']); + + $lines[] = "server {"; + $lines[] = " listen 80;"; + $lines[] = " server_name {$host};"; + $lines[] = " return 301 https://{$host}\$request_uri;"; + $lines[] = "}"; + $lines[] = ""; + $lines[] = "server {"; + $lines[] = " listen 443 ssl;"; + $lines[] = " server_name {$host};"; + $lines[] = ""; + $lines[] = " ssl_certificate \"{$cert}\";"; + $lines[] = " ssl_certificate_key \"{$key}\";"; + $lines[] = ""; + $lines[] = " root \"{$root}\";"; + $lines[] = " index index.php index.html;"; + $lines[] = ""; + $lines[] = " client_max_body_size 128m;"; + $lines[] = ""; + $lines[] = " location / {"; + $lines[] = " try_files \$uri \$uri/ /index.php?\$query_string;"; + $lines[] = " }"; + $lines[] = ""; + $lines[] = " location ~ \\.php$ {"; + $lines[] = " try_files \$uri =404;"; + $lines[] = " fastcgi_pass unix:{$socket};"; + $lines[] = " fastcgi_index index.php;"; + $lines[] = " include fastcgi.conf;"; + $lines[] = " }"; + $lines[] = ""; + $lines[] = " location ~ /\\.(?!well-known).* {"; + $lines[] = " deny all;"; + $lines[] = " }"; + $lines[] = "}"; + $lines[] = ""; + } + + return implode("\n", $lines); +} + +function renderHostsBlock(array $apps): string +{ + $lines = [HOSTS_BEGIN]; + foreach ($apps as $app) { + $lines[] = "127.0.0.1 {$app['host']}"; + } + $lines[] = HOSTS_END; + $lines[] = ''; + return implode("\n", $lines); +} + +function updateHostsFile(string $path, string $block): void +{ + $current = is_file($path) ? (string) file_get_contents($path) : ''; + $pattern = '/' . preg_quote(HOSTS_BEGIN, '/') . '.*?' . preg_quote(HOSTS_END, '/') . "\\n?/s"; + if (preg_match($pattern, $current)) { + $new = preg_replace($pattern, $block, $current); + } else { + $new = rtrim($current) . "\n\n" . $block; + } + + atomicWrite($path, (string) $new); +} + +function renderSystemdService(array $config): string +{ + $php = trim((string) shell_exec('command -v php 2>/dev/null')) ?: '/usr/bin/php'; + $script = __FILE__; + $configPath = $config['config_path']; + $interval = $config['watch_interval_seconds']; + + return <<&1'; + exec($cmd, $output, $code); + if ($code !== 0) { + fail("Command failed ({$code}): " . implode(' ', $command) . "\n" . implode("\n", $output)); + } +} + +function reloadService(string $service): void +{ + exec('systemctl reload ' . escapeshellarg($service) . ' 2>&1', $output, $code); + if ($code === 0) { + return; + } + + exec('systemctl restart ' . escapeshellarg($service) . ' 2>&1', $restartOutput, $restartCode); + if ($restartCode !== 0) { + fail("Could not reload or restart {$service}\n" . implode("\n", array_merge($output, $restartOutput))); + } +} + +function enableServiceIfAvailable(string $service): void +{ + exec('systemctl list-unit-files ' . escapeshellarg($service . '.service') . ' --no-legend 2>/dev/null', $output, $code); + if ($code === 0 && trim(implode("\n", $output)) !== '') { + runOrFail(['systemctl', 'enable', '--now', $service . '.service']); + } +} + +function ensureWritableTargets(array $paths): void +{ + $blocked = []; + foreach ($paths as $path) { + $parent = dirname($path); + if ((is_file($path) && is_writable($path)) || (!file_exists($path) && is_dir($parent) && is_writable($parent))) { + continue; + } + $blocked[] = $path; + } + + if ($blocked !== []) { + fail("This command cannot write required target(s). Re-run with sudo.\nTargets: " . implode(', ', $blocked)); + } +} + +function expandPath(string $path): string +{ + if ($path === '~') { + return (string) getenv('HOME'); + } + if (str_starts_with($path, '~/')) { + return getenv('HOME') . substr($path, 1); + } + return $path; +} + +function slugify(string $value): string +{ + $value = strtolower($value); + $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? $value; + $value = trim($value, '-'); + return $value !== '' ? $value : 'app'; +} + +function nginxEscape(string $value): string +{ + return str_replace(['\\', '"'], ['\\\\', '\\"'], $value); +} + +function fail(string $message): never +{ + throw new RuntimeException($message); +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d2199f4 --- /dev/null +++ b/composer.json @@ -0,0 +1,24 @@ +{ + "name": "php-dev-router/php-dev-router", + "description": "Local HTTPS nginx router for PHP and Laravel development projects.", + "type": "project", + "license": "MIT", + "require": { + "php": ">=8.2" + }, + "bin": [ + "bin/php-dev-router" + ], + "scripts": { + "lint": "php -l bin/php-dev-router", + "scan": "bin/php-dev-router scan" + }, + "keywords": [ + "php", + "laravel", + "nginx", + "php-fpm", + "development", + "https" + ] +} diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..cebf816 --- /dev/null +++ b/config.example.json @@ -0,0 +1,22 @@ +{ + "projects_root": "~/PhpstormProjects", + "domain": "php.test", + "ssl_certificate": "~/.ssl/php-dev-router/fullchain.pem", + "ssl_certificate_key": "~/.ssl/php-dev-router/privkey.pem", + "php_fpm_socket": "/run/php-fpm/php-fpm.sock", + "nginx_conf": "/etc/nginx/conf.d/php-dev-router.conf", + "hosts_file": "/etc/hosts", + "systemd_service": "/etc/systemd/system/php-dev-router.service", + "skip_dirs": [ + ".cd", + ".git", + ".idea", + ".vscode", + "bootstrap/cache", + "node_modules", + "storage", + "vendor" + ], + "scan_max_depth": 5, + "watch_interval_seconds": 3 +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..c38a15c --- /dev/null +++ b/install.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" +if [[ ! -f config.json ]]; then + cp config.example.json config.json + echo "Created config.json from config.example.json. Edit it if this machine needs different paths." +fi +sudo -v +sudo ./bin/php-dev-router install +./bin/php-dev-router doctor diff --git a/systemd/php-dev-router.service.example b/systemd/php-dev-router.service.example new file mode 100644 index 0000000..3c31a84 --- /dev/null +++ b/systemd/php-dev-router.service.example @@ -0,0 +1,13 @@ +[Unit] +Description=PHP development router +After=network.target nginx.service php-fpm.service +Wants=nginx.service php-fpm.service + +[Service] +Type=simple +ExecStart=/usr/bin/php /opt/php-dev-router/bin/php-dev-router watch --config=/etc/php-dev-router/config.json --interval=3 +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target