mirror of
https://github.com/laravel/valet.git
synced 2026-02-04 16:10:08 +01:00
1191 lines
37 KiB
PHP
1191 lines
37 KiB
PHP
<?php
|
|
|
|
namespace Valet;
|
|
|
|
use DomainException;
|
|
use Illuminate\Support\Collection;
|
|
use PhpFpm;
|
|
|
|
class Site
|
|
{
|
|
/**
|
|
* Create a new Site instance.
|
|
*
|
|
* @param Brew $brew
|
|
* @param Configuration $config
|
|
* @param CommandLine $cli
|
|
* @param Filesystem $files
|
|
*/
|
|
public function __construct(public Brew $brew, public Configuration $config, public CommandLine $cli, public Filesystem $files)
|
|
{
|
|
}
|
|
|
|
/**
|
|
* Get the name of the site.
|
|
*
|
|
* @param string|null $name
|
|
* @return string
|
|
*/
|
|
private function getRealSiteName(?string $name): string
|
|
{
|
|
if (! is_null($name)) {
|
|
return $name;
|
|
}
|
|
|
|
if (is_string($link = $this->getLinkNameByCurrentDir())) {
|
|
return $link;
|
|
}
|
|
|
|
return basename(getcwd());
|
|
}
|
|
|
|
/**
|
|
* Get link name based on the current directory.
|
|
*
|
|
* @return null|string
|
|
*/
|
|
private function getLinkNameByCurrentDir(): ?string
|
|
{
|
|
$count = count($links = $this->links()->where('path', getcwd()));
|
|
|
|
if ($count == 1) {
|
|
return $links->shift()['site'];
|
|
}
|
|
|
|
if ($count > 1) {
|
|
throw new DomainException("There are {$count} links related to the current directory, please specify the name: valet unlink <name>.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the real hostname for the given path, checking links.
|
|
*
|
|
* @param string $path
|
|
* @return string|null
|
|
*/
|
|
public function host(string $path): ?string
|
|
{
|
|
foreach ($this->files->scandir($this->sitesPath()) as $link) {
|
|
if (realpath($this->sitesPath($link)) === $path) {
|
|
return $link;
|
|
}
|
|
}
|
|
|
|
return basename($path);
|
|
}
|
|
|
|
/**
|
|
* Link the current working directory with the given name.
|
|
*
|
|
* @param string $target
|
|
* @param string $link
|
|
* @return string
|
|
*/
|
|
public function link(string $target, string $link): string
|
|
{
|
|
$this->files->ensureDirExists(
|
|
$linkPath = $this->sitesPath(), user()
|
|
);
|
|
|
|
$this->config->prependPath($linkPath);
|
|
|
|
$this->files->symlinkAsUser($target, $linkPath.'/'.$link);
|
|
|
|
return $linkPath.'/'.$link;
|
|
}
|
|
|
|
/**
|
|
* Pretty print out all links in Valet.
|
|
*
|
|
* @return Collection
|
|
*/
|
|
public function links(): Collection
|
|
{
|
|
$certsPath = $this->certificatesPath();
|
|
|
|
$this->files->ensureDirExists($certsPath, user());
|
|
|
|
$certs = $this->getCertificates($certsPath);
|
|
|
|
return $this->getSites($this->sitesPath(), $certs);
|
|
}
|
|
|
|
/**
|
|
* Pretty print out all parked links in Valet.
|
|
*
|
|
* @return Collection
|
|
*/
|
|
public function parked(): Collection
|
|
{
|
|
$certs = $this->getCertificates();
|
|
|
|
$links = $this->getSites($this->sitesPath(), $certs);
|
|
|
|
$config = $this->config->read();
|
|
$parkedLinks = collect();
|
|
foreach (array_reverse($config['paths']) as $path) {
|
|
if ($path === $this->sitesPath()) {
|
|
continue;
|
|
}
|
|
|
|
// Only merge on the parked sites that don't interfere with the linked sites
|
|
$sites = $this->getSites($path, $certs)->filter(function ($site, $key) use ($links) {
|
|
return ! $links->has($key);
|
|
});
|
|
|
|
$parkedLinks = $parkedLinks->merge($sites);
|
|
}
|
|
|
|
return $parkedLinks;
|
|
}
|
|
|
|
/**
|
|
* Get all sites which are proxies (not Links, and contain proxy_pass directive).
|
|
*
|
|
* @return Collection
|
|
*/
|
|
public function proxies(): Collection
|
|
{
|
|
$dir = $this->nginxPath();
|
|
$tld = $this->config->read()['tld'];
|
|
$links = $this->links();
|
|
$certs = $this->getCertificates();
|
|
|
|
if (! $this->files->exists($dir)) {
|
|
return collect();
|
|
}
|
|
|
|
$proxies = collect($this->files->scandir($dir))
|
|
->filter(function ($site, $key) use ($tld) {
|
|
// keep sites that match our TLD
|
|
return ends_with($site, '.'.$tld);
|
|
})->map(function ($site, $key) use ($tld) {
|
|
// remove the TLD suffix for consistency
|
|
return str_replace('.'.$tld, '', $site);
|
|
})->reject(function ($site, $key) use ($links) {
|
|
return $links->has($site);
|
|
})->mapWithKeys(function ($site) {
|
|
$host = $this->getProxyHostForSite($site) ?: '(other)';
|
|
|
|
return [$site => $host];
|
|
})->reject(function ($host, $site) {
|
|
// If proxy host is null, it may be just a normal SSL stub, or something else; either way we exclude it from the list
|
|
return $host === '(other)';
|
|
})->map(function ($host, $site) use ($certs, $tld) {
|
|
$secured = $certs->has($site);
|
|
$url = ($secured ? 'https' : 'http').'://'.$site.'.'.$tld;
|
|
|
|
return [
|
|
'site' => $site,
|
|
'secured' => $secured ? ' X' : '',
|
|
'url' => $url,
|
|
'path' => $host,
|
|
];
|
|
});
|
|
|
|
return $proxies;
|
|
}
|
|
|
|
/**
|
|
* Get the site URL from a directory if it's a valid Valet site.
|
|
*
|
|
* @param string $directory
|
|
* @return string
|
|
*/
|
|
public function getSiteUrl(string $directory): string
|
|
{
|
|
$tld = $this->config->read()['tld'];
|
|
|
|
if ($directory == '.' || $directory == './') { // Allow user to use dot as current dir's site `--site=.`
|
|
$directory = $this->host(getcwd());
|
|
}
|
|
|
|
// Remove .tld from the end of sitename if it was provided
|
|
if (ends_with($directory, '.'.$tld)) {
|
|
$directory = substr($directory, 0, -(strlen('.'.$tld)));
|
|
}
|
|
|
|
if (! $this->parked()->merge($this->links())->where('site', $directory)->count() > 0) {
|
|
throw new DomainException("The [{$directory}] site could not be found in Valet's site list.");
|
|
}
|
|
|
|
return $directory.'.'.$tld;
|
|
}
|
|
|
|
/**
|
|
* Identify whether a site is for a proxy by reading the host name from its config file.
|
|
*
|
|
* @param string $site Site name without TLD
|
|
* @param string $configContents Config file contents
|
|
* @return string|null
|
|
*/
|
|
public function getProxyHostForSite(string $site, string $configContents = null): ?string
|
|
{
|
|
$siteConf = $configContents ?: $this->getSiteConfigFileContents($site);
|
|
|
|
if (empty($siteConf)) {
|
|
return null;
|
|
}
|
|
|
|
$host = null;
|
|
if (preg_match('~proxy_pass\s+(?<host>https?://.*)\s*;~', $siteConf, $patterns)) {
|
|
$host = trim($patterns['host']);
|
|
}
|
|
|
|
return $host;
|
|
}
|
|
|
|
/**
|
|
* Get the contents of the configuration for the given site.
|
|
*
|
|
* @param string $site
|
|
* @param string $suffix
|
|
* @return string|null
|
|
*/
|
|
public function getSiteConfigFileContents(string $site, ?string $suffix = null): ?string
|
|
{
|
|
$config = $this->config->read();
|
|
$suffix = $suffix ?: '.'.$config['tld'];
|
|
$file = str_replace($suffix, '', $site).$suffix;
|
|
|
|
return $this->files->exists($this->nginxPath($file)) ? $this->files->get($this->nginxPath($file)) : null;
|
|
}
|
|
|
|
/**
|
|
* Get all certificates from config folder.
|
|
*
|
|
* @param string|null $path
|
|
* @return Collection
|
|
*/
|
|
public function getCertificates(?string $path = null): Collection
|
|
{
|
|
$path = $path ?: $this->certificatesPath();
|
|
|
|
$this->files->ensureDirExists($path, user());
|
|
|
|
$config = $this->config->read();
|
|
|
|
return collect($this->files->scandir($path))->filter(function ($value, $key) {
|
|
return ends_with($value, '.crt');
|
|
})->map(function ($cert) use ($config) {
|
|
$certWithoutSuffix = substr($cert, 0, -4);
|
|
$trimToString = '.';
|
|
|
|
// If we have the cert ending in our tld strip that tld specifically
|
|
// if not then just strip the last segment for backwards compatibility.
|
|
if (ends_with($certWithoutSuffix, $config['tld'])) {
|
|
$trimToString .= $config['tld'];
|
|
}
|
|
|
|
return substr($certWithoutSuffix, 0, strrpos($certWithoutSuffix, $trimToString));
|
|
})->flip();
|
|
}
|
|
|
|
/**
|
|
* Get list of sites and return them formatted
|
|
* Will work for symlink and normal site paths.
|
|
*
|
|
* @param string $path
|
|
* @param Collection $certs
|
|
* @return Collection
|
|
*/
|
|
public function getSites(string $path, Collection $certs): Collection
|
|
{
|
|
$config = $this->config->read();
|
|
|
|
$this->files->ensureDirExists($path, user());
|
|
|
|
return collect($this->files->scandir($path))->mapWithKeys(function ($site) use ($path) {
|
|
$sitePath = $path.'/'.$site;
|
|
|
|
if ($this->files->isLink($sitePath)) {
|
|
$realPath = $this->files->readLink($sitePath);
|
|
} else {
|
|
$realPath = $this->files->realpath($sitePath);
|
|
}
|
|
|
|
return [$site => $realPath];
|
|
})->filter(function ($path) {
|
|
return $this->files->isDir($path);
|
|
})->map(function ($path, $site) use ($certs, $config) {
|
|
$secured = $certs->has($site);
|
|
$url = ($secured ? 'https' : 'http').'://'.$site.'.'.$config['tld'];
|
|
$phpVersion = $this->getPhpVersion($site.'.'.$config['tld']);
|
|
|
|
return [
|
|
'site' => $site,
|
|
'secured' => $secured ? ' X' : '',
|
|
'url' => $url,
|
|
'path' => $path,
|
|
'phpVersion' => $phpVersion,
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Unlink the given symbolic link.
|
|
*
|
|
* @param string $name
|
|
* @return string
|
|
*/
|
|
public function unlink(string $name): string
|
|
{
|
|
$name = $this->getRealSiteName($name);
|
|
|
|
if ($this->files->exists($path = $this->sitesPath($name))) {
|
|
$this->files->unlink($path);
|
|
}
|
|
|
|
return $name;
|
|
}
|
|
|
|
/**
|
|
* Remove all broken symbolic links.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function pruneLinks(): void
|
|
{
|
|
$this->files->ensureDirExists($this->sitesPath(), user());
|
|
|
|
$this->files->removeBrokenLinksAt($this->sitesPath());
|
|
}
|
|
|
|
/**
|
|
* Get the PHP version for the given site.
|
|
*
|
|
* @param string $url Site URL including the TLD
|
|
* @return string
|
|
*/
|
|
public function getPhpVersion(string $url): string
|
|
{
|
|
$defaultPhpVersion = $this->brew->linkedPhp();
|
|
$phpVersion = PhpFpm::normalizePhpVersion($this->customPhpVersion($url));
|
|
if (empty($phpVersion)) {
|
|
$phpVersion = PhpFpm::normalizePhpVersion($defaultPhpVersion);
|
|
}
|
|
|
|
return $phpVersion;
|
|
}
|
|
|
|
/**
|
|
* Resecure all currently secured sites with a fresh configuration.
|
|
*
|
|
* There are only two supported values: tld and loopback
|
|
* And those must be submitted in pairs else unexpected results may occur.
|
|
* eg: both $old and $new should contain the same indexes.
|
|
*
|
|
* @param array $old
|
|
* @param array $new
|
|
* @return void
|
|
*/
|
|
public function resecureForNewConfiguration(array $old, array $new): void
|
|
{
|
|
if (! $this->files->exists($this->certificatesPath())) {
|
|
return;
|
|
}
|
|
|
|
$secured = $this->secured();
|
|
|
|
$defaultTld = $this->config->read()['tld'];
|
|
$oldTld = ! empty($old['tld']) ? $old['tld'] : $defaultTld;
|
|
$tld = ! empty($new['tld']) ? $new['tld'] : $defaultTld;
|
|
|
|
$defaultLoopback = $this->config->read()['loopback'];
|
|
$oldLoopback = ! empty($old['loopback']) ? $old['loopback'] : $defaultLoopback;
|
|
$loopback = ! empty($new['loopback']) ? $new['loopback'] : $defaultLoopback;
|
|
|
|
foreach ($secured as $url) {
|
|
$newUrl = str_replace('.'.$oldTld, '.'.$tld, $url);
|
|
$siteConf = $this->getSiteConfigFileContents($url, '.'.$oldTld);
|
|
|
|
if (! empty($siteConf) && strpos($siteConf, '# valet stub: secure.proxy.valet.conf') === 0) {
|
|
// proxy config
|
|
$this->unsecure($url);
|
|
$this->secure(
|
|
$newUrl,
|
|
$this->replaceOldLoopbackWithNew(
|
|
$this->replaceOldDomainWithNew($siteConf, $url, $newUrl),
|
|
$oldLoopback,
|
|
$loopback
|
|
)
|
|
);
|
|
} else {
|
|
// normal config
|
|
$this->unsecure($url);
|
|
$this->secure($newUrl);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse Nginx site config file contents to swap old domain to new.
|
|
*
|
|
* @param string $siteConf Nginx site config content
|
|
* @param string $old Old domain
|
|
* @param string $new New domain
|
|
* @return string
|
|
*/
|
|
public function replaceOldDomainWithNew(string $siteConf, string $old, string $new): string
|
|
{
|
|
$lookups = [];
|
|
$lookups[] = '~server_name .*;~';
|
|
$lookups[] = '~error_log .*;~';
|
|
$lookups[] = '~ssl_certificate_key .*;~';
|
|
$lookups[] = '~ssl_certificate .*;~';
|
|
|
|
foreach ($lookups as $lookup) {
|
|
preg_match($lookup, $siteConf, $matches);
|
|
foreach ($matches as $match) {
|
|
$replaced = str_replace($old, $new, $match);
|
|
$siteConf = str_replace($match, $replaced, $siteConf);
|
|
}
|
|
}
|
|
|
|
return $siteConf;
|
|
}
|
|
|
|
/**
|
|
* Parse Nginx site config file contents to swap old loopback address to new.
|
|
*
|
|
* @param string $siteConf Nginx site config content
|
|
* @param string $old Old loopback address
|
|
* @param string $new New loopback address
|
|
* @return string
|
|
*/
|
|
public function replaceOldLoopbackWithNew(string $siteConf, string $old, string $new): string
|
|
{
|
|
$shouldComment = $new === VALET_LOOPBACK;
|
|
|
|
$lookups = [];
|
|
$lookups[] = '~#?listen .*:80; # valet loopback~';
|
|
$lookups[] = '~#?listen .*:443 ssl http2; # valet loopback~';
|
|
$lookups[] = '~#?listen .*:60; # valet loopback~';
|
|
|
|
foreach ($lookups as $lookup) {
|
|
preg_match($lookup, $siteConf, $matches);
|
|
foreach ($matches as $match) {
|
|
$replaced = str_replace($old, $new, $match);
|
|
|
|
if ($shouldComment && strpos($replaced, '#') !== 0) {
|
|
$replaced = '#'.$replaced;
|
|
}
|
|
|
|
if (! $shouldComment) {
|
|
$replaced = ltrim($replaced, '#');
|
|
}
|
|
|
|
$siteConf = str_replace($match, $replaced, $siteConf);
|
|
}
|
|
}
|
|
|
|
return $siteConf;
|
|
}
|
|
|
|
/**
|
|
* Get all of the URLs that are currently secured.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function secured(): array
|
|
{
|
|
return collect($this->files->scandir($this->certificatesPath()))
|
|
->filter(function ($file) {
|
|
return ends_with($file, ['.key', '.csr', '.crt', '.conf']);
|
|
})->map(function ($file) {
|
|
return str_replace(['.key', '.csr', '.crt', '.conf'], '', $file);
|
|
})->unique()->values()->all();
|
|
}
|
|
|
|
/**
|
|
* Secure the given host with TLS.
|
|
*
|
|
* @param string $url
|
|
* @param string|null $siteConf pregenerated Nginx config file contents
|
|
* @param int $certificateExpireInDays The number of days the self signed certificate is valid.
|
|
* Certificates SHOULD NOT have a validity period greater than 397 days.
|
|
* @param int $caExpireInYears The number of years the self signed certificate authority is valid.
|
|
*
|
|
* @see https://github.com/cabforum/servercert/blob/main/docs/BR.md
|
|
*
|
|
* @return void
|
|
*/
|
|
public function secure(string $url, ?string $siteConf = null, int $certificateExpireInDays = 396, int $caExpireInYears = 20): void
|
|
{
|
|
// Extract in order to later preserve custom PHP version config when securing
|
|
$phpVersion = $this->customPhpVersion($url);
|
|
|
|
$this->unsecure($url);
|
|
|
|
$this->files->ensureDirExists($this->caPath(), user());
|
|
|
|
$this->files->ensureDirExists($this->certificatesPath(), user());
|
|
|
|
$this->files->ensureDirExists($this->nginxPath(), user());
|
|
|
|
$caExpireInDate = (new \DateTime())->diff(new \DateTime("+{$caExpireInYears} years"));
|
|
|
|
$this->createCa($caExpireInDate->format('%a'));
|
|
$this->createCertificate($url, $certificateExpireInDays);
|
|
|
|
$siteConf = $this->buildSecureNginxServer($url, $siteConf);
|
|
|
|
// If the user had isolated the PHP version for this site, swap out .sock file
|
|
if ($phpVersion) {
|
|
$siteConf = $this->replaceSockFile($siteConf, $phpVersion);
|
|
}
|
|
|
|
$this->files->putAsUser($this->nginxPath($url), $siteConf);
|
|
}
|
|
|
|
/**
|
|
* If CA and root certificates are nonexistent, create them and trust the root cert.
|
|
*
|
|
* @param int $caExpireInDays The number of days the self signed certificate authority is valid.
|
|
* @return void
|
|
*/
|
|
public function createCa(int $caExpireInDays): void
|
|
{
|
|
$caPemPath = $this->caPath('LaravelValetCASelfSigned.pem');
|
|
$caKeyPath = $this->caPath('LaravelValetCASelfSigned.key');
|
|
|
|
if ($this->files->exists($caKeyPath) && $this->files->exists($caPemPath)) {
|
|
return;
|
|
}
|
|
|
|
$oName = 'Laravel Valet CA Self Signed Organization';
|
|
$cName = 'Laravel Valet CA Self Signed CN';
|
|
|
|
if ($this->files->exists($caKeyPath)) {
|
|
$this->files->unlink($caKeyPath);
|
|
}
|
|
if ($this->files->exists($caPemPath)) {
|
|
$this->files->unlink($caPemPath);
|
|
}
|
|
|
|
$this->cli->run(sprintf(
|
|
'sudo security delete-certificate -c "%s" /Library/Keychains/System.keychain',
|
|
$cName
|
|
));
|
|
|
|
$this->cli->runAsUser(sprintf(
|
|
'openssl req -new -newkey rsa:2048 -days %s -nodes -x509 -subj "/C=/ST=/O=%s/localityName=/commonName=%s/organizationalUnitName=Developers/emailAddress=%s/" -keyout "%s" -out "%s"',
|
|
$caExpireInDays, $oName, $cName, 'rootcertificate@laravel.valet', $caKeyPath, $caPemPath
|
|
));
|
|
$this->trustCa($caPemPath);
|
|
}
|
|
|
|
/**
|
|
* Create and trust a certificate for the given URL.
|
|
*
|
|
* @param string $url
|
|
* @param int $caExpireInDays The number of days the self signed certificate is valid.
|
|
* @return void
|
|
*/
|
|
public function createCertificate(string $url, int $caExpireInDays): void
|
|
{
|
|
$caPemPath = $this->caPath('LaravelValetCASelfSigned.pem');
|
|
$caKeyPath = $this->caPath('LaravelValetCASelfSigned.key');
|
|
$caSrlPath = $this->caPath('LaravelValetCASelfSigned.srl');
|
|
$keyPath = $this->certificatesPath($url, 'key');
|
|
$csrPath = $this->certificatesPath($url, 'csr');
|
|
$crtPath = $this->certificatesPath($url, 'crt');
|
|
$confPath = $this->certificatesPath($url, 'conf');
|
|
|
|
$this->buildCertificateConf($confPath, $url);
|
|
$this->createPrivateKey($keyPath);
|
|
$this->createSigningRequest($url, $keyPath, $csrPath, $confPath);
|
|
|
|
$caSrlParam = '-CAserial "'.$caSrlPath.'"';
|
|
if (! $this->files->exists($caSrlPath)) {
|
|
$caSrlParam .= ' -CAcreateserial';
|
|
}
|
|
|
|
$result = $this->cli->runAsUser(sprintf(
|
|
'openssl x509 -req -sha256 -days %s -CA "%s" -CAkey "%s" %s -in "%s" -out "%s" -extensions v3_req -extfile "%s"',
|
|
$caExpireInDays, $caPemPath, $caKeyPath, $caSrlParam, $csrPath, $crtPath, $confPath
|
|
));
|
|
|
|
// If cert could not be created using runAsUser(), use run().
|
|
if (strpos($result, 'Permission denied')) {
|
|
$this->cli->run(sprintf(
|
|
'openssl x509 -req -sha256 -days %s -CA "%s" -CAkey "%s" %s -in "%s" -out "%s" -extensions v3_req -extfile "%s"',
|
|
$caExpireInDays, $caPemPath, $caKeyPath, $caSrlParam, $csrPath, $crtPath, $confPath
|
|
));
|
|
}
|
|
|
|
$this->trustCertificate($crtPath);
|
|
}
|
|
|
|
/**
|
|
* Create the private key for the TLS certificate.
|
|
*
|
|
* @param string $keyPath
|
|
* @return void
|
|
*/
|
|
public function createPrivateKey(string $keyPath): void
|
|
{
|
|
$this->cli->runAsUser(sprintf('openssl genrsa -out "%s" 2048', $keyPath));
|
|
}
|
|
|
|
/**
|
|
* Create the signing request for the TLS certificate.
|
|
*
|
|
* @param string $url
|
|
* @param string $keyPath
|
|
* @param string $csrPath
|
|
* @param string $confPath
|
|
* @return void
|
|
*/
|
|
public function createSigningRequest(string $url, string $keyPath, string $csrPath, string $confPath): void
|
|
{
|
|
$this->cli->runAsUser(sprintf(
|
|
'openssl req -new -key "%s" -out "%s" -subj "/C=/ST=/O=/localityName=/commonName=%s/organizationalUnitName=/emailAddress=%s%s/" -config "%s"',
|
|
$keyPath, $csrPath, $url, $url, '@laravel.valet', $confPath
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Trust the given root certificate file in the Mac Keychain.
|
|
*
|
|
* @param string $pemPath
|
|
* @return void
|
|
*/
|
|
public function trustCa(string $caPemPath): void
|
|
{
|
|
$this->cli->run(sprintf(
|
|
'sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "%s"', $caPemPath
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Trust the given certificate file in the Mac Keychain.
|
|
*
|
|
* @param string $crtPath
|
|
* @return void
|
|
*/
|
|
public function trustCertificate(string $crtPath): void
|
|
{
|
|
$this->cli->run(sprintf(
|
|
'sudo security add-trusted-cert -d -r trustAsRoot -k /Library/Keychains/System.keychain "%s"', $crtPath
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Build the SSL config for the given URL.
|
|
*
|
|
* @param string $path
|
|
* @param string $url
|
|
* @return void
|
|
*/
|
|
public function buildCertificateConf(string $path, string $url): void
|
|
{
|
|
$config = str_replace('VALET_DOMAIN', $url, $this->files->getStub('openssl.conf'));
|
|
$this->files->putAsUser($path, $config);
|
|
}
|
|
|
|
/**
|
|
* Build the TLS secured Nginx server for the given URL.
|
|
*
|
|
* @param string $url
|
|
* @param string|null $siteConf (optional) Nginx site config file content
|
|
* @return string
|
|
*/
|
|
public function buildSecureNginxServer(string $url, ?string $siteConf = null): string
|
|
{
|
|
if ($siteConf === null) {
|
|
$siteConf = $this->replaceOldLoopbackWithNew(
|
|
$this->files->getStub('secure.valet.conf'),
|
|
'VALET_LOOPBACK',
|
|
$this->valetLoopback()
|
|
);
|
|
}
|
|
|
|
return str_replace(
|
|
['VALET_HOME_PATH', 'VALET_SERVER_PATH', 'VALET_STATIC_PREFIX', 'VALET_SITE', 'VALET_CERT', 'VALET_KEY'],
|
|
[
|
|
$this->valetHomePath(),
|
|
VALET_SERVER_PATH,
|
|
VALET_STATIC_PREFIX,
|
|
$url,
|
|
$this->certificatesPath($url, 'crt'),
|
|
$this->certificatesPath($url, 'key'),
|
|
],
|
|
$siteConf
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create new nginx config or modify existing nginx config to isolate this site
|
|
* to a custom version of PHP.
|
|
*
|
|
* @param string $valetSite
|
|
* @param string $phpVersion
|
|
* @return void
|
|
*/
|
|
public function isolate(string $valetSite, string $phpVersion): void
|
|
{
|
|
if ($this->files->exists($this->nginxPath($valetSite))) {
|
|
// Modify the existing config if it exists (likely because it's secured)
|
|
$siteConf = $this->files->get($this->nginxPath($valetSite));
|
|
$siteConf = $this->replaceSockFile($siteConf, $phpVersion);
|
|
} else {
|
|
$siteConf = str_replace(
|
|
['VALET_HOME_PATH', 'VALET_SERVER_PATH', 'VALET_STATIC_PREFIX', 'VALET_SITE', 'VALET_PHP_FPM_SOCKET', 'VALET_ISOLATED_PHP_VERSION'],
|
|
[VALET_HOME_PATH, VALET_SERVER_PATH, VALET_STATIC_PREFIX, $valetSite, PhpFpm::fpmSockName($phpVersion), $phpVersion],
|
|
$this->replaceLoopback($this->files->getStub('site.valet.conf'))
|
|
);
|
|
}
|
|
|
|
$this->files->putAsUser($this->nginxPath($valetSite), $siteConf);
|
|
}
|
|
|
|
/**
|
|
* Remove PHP Version isolation from a specific site.
|
|
*
|
|
* @param string $valetSite
|
|
* @return void
|
|
*/
|
|
public function removeIsolation(string $valetSite): void
|
|
{
|
|
// If a site has an SSL certificate, we need to keep its custom config file, but we can
|
|
// just re-generate it without defining a custom `valet.sock` file
|
|
if ($this->files->exists($this->certificatesPath($valetSite, 'crt'))) {
|
|
$siteConf = $this->buildSecureNginxServer($valetSite);
|
|
$this->files->putAsUser($this->nginxPath($valetSite), $siteConf);
|
|
} else {
|
|
// When site doesn't have SSL, we can remove the custom nginx config file to remove isolation
|
|
$this->files->unlink($this->nginxPath($valetSite));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unsecure the given URL so that it will use HTTP again.
|
|
*
|
|
* @param string $url
|
|
* @return void
|
|
*/
|
|
public function unsecure(string $url): void
|
|
{
|
|
// Extract in order to later preserve custom PHP version config when unsecuring. Example output: "74"
|
|
$phpVersion = $this->customPhpVersion($url);
|
|
|
|
if ($this->files->exists($this->certificatesPath($url, 'crt'))) {
|
|
$this->files->unlink($this->nginxPath($url));
|
|
|
|
$this->files->unlink($this->certificatesPath($url, 'conf'));
|
|
$this->files->unlink($this->certificatesPath($url, 'key'));
|
|
$this->files->unlink($this->certificatesPath($url, 'csr'));
|
|
$this->files->unlink($this->certificatesPath($url, 'crt'));
|
|
}
|
|
|
|
$this->cli->run(sprintf('sudo security delete-certificate -c "%s" /Library/Keychains/System.keychain', $url));
|
|
$this->cli->run(sprintf('sudo security delete-certificate -c "*.%s" /Library/Keychains/System.keychain', $url));
|
|
$this->cli->run(sprintf(
|
|
'sudo security find-certificate -e "%s%s" -a -Z | grep SHA-1 | sudo awk \'{system("security delete-certificate -Z \'$NF\' /Library/Keychains/System.keychain")}\'',
|
|
$url, '@laravel.valet'
|
|
));
|
|
|
|
// If the user had isolated the PHP version for this site, swap out .sock file
|
|
if ($phpVersion) {
|
|
$this->isolate($url, $phpVersion);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Un-secure all sites.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function unsecureAll(): void
|
|
{
|
|
$tld = $this->config->read()['tld'];
|
|
|
|
$secured = $this->parked()
|
|
->merge($this->links())
|
|
->sort()
|
|
->where('secured', ' X');
|
|
|
|
if ($secured->count() === 0) {
|
|
info('No sites to unsecure. You may list all servable sites or links by running <comment>valet parked</comment> or <comment>valet links</comment>.');
|
|
|
|
return;
|
|
}
|
|
|
|
info('Attempting to unsecure the following sites:');
|
|
table(['Site', 'SSL', 'URL', 'Path'], $secured->toArray());
|
|
|
|
foreach ($secured->pluck('site') as $url) {
|
|
$this->unsecure($url.'.'.$tld);
|
|
}
|
|
|
|
$remaining = $this->parked()
|
|
->merge($this->links())
|
|
->sort()
|
|
->where('secured', ' X');
|
|
if ($remaining->count() > 0) {
|
|
warning('We were not succesful in unsecuring the following sites:');
|
|
table(['Site', 'SSL', 'URL', 'Path'], $remaining->toArray());
|
|
}
|
|
info('unsecure --all was successful.');
|
|
}
|
|
|
|
/**
|
|
* Build the Nginx proxy config for the specified domain.
|
|
*
|
|
* @param string $url The domain name to serve
|
|
* @param string $host The URL to proxy to, eg: http://127.0.0.1:8080
|
|
* @param bool $secure
|
|
* @return void
|
|
*/
|
|
public function proxyCreate(string $url, string $host, bool $secure = false): void
|
|
{
|
|
if (! preg_match('~^https?://.*$~', $host)) {
|
|
throw new \InvalidArgumentException(sprintf('"%s" is not a valid URL', $host));
|
|
}
|
|
|
|
$tld = $this->config->read()['tld'];
|
|
if (! ends_with($url, '.'.$tld)) {
|
|
$url .= '.'.$tld;
|
|
}
|
|
|
|
$siteConf = $this->replaceOldLoopbackWithNew(
|
|
$this->files->getStub($secure ? 'secure.proxy.valet.conf' : 'proxy.valet.conf'),
|
|
'VALET_LOOPBACK',
|
|
$this->valetLoopback()
|
|
);
|
|
|
|
$siteConf = str_replace(
|
|
['VALET_HOME_PATH', 'VALET_SERVER_PATH', 'VALET_STATIC_PREFIX', 'VALET_SITE', 'VALET_PROXY_HOST'],
|
|
[$this->valetHomePath(), VALET_SERVER_PATH, VALET_STATIC_PREFIX, $url, $host],
|
|
$siteConf
|
|
);
|
|
|
|
if ($secure) {
|
|
$this->secure($url, $siteConf);
|
|
} else {
|
|
$this->put($url, $siteConf);
|
|
}
|
|
|
|
$protocol = $secure ? 'https' : 'http';
|
|
|
|
info('Valet will now proxy ['.$protocol.'://'.$url.'] traffic to ['.$host.'].');
|
|
}
|
|
|
|
/**
|
|
* Unsecure the given URL so that it will use HTTP again.
|
|
*
|
|
* @param string $url
|
|
* @return void
|
|
*/
|
|
public function proxyDelete(string $url): void
|
|
{
|
|
$tld = $this->config->read()['tld'];
|
|
if (! ends_with($url, '.'.$tld)) {
|
|
$url .= '.'.$tld;
|
|
}
|
|
|
|
$this->unsecure($url);
|
|
$this->files->unlink($this->nginxPath($url));
|
|
|
|
info('Valet will no longer proxy [https://'.$url.'].');
|
|
}
|
|
|
|
/**
|
|
* Create the given nginx host.
|
|
*
|
|
* @param string $url
|
|
* @param string $siteConf pregenerated Nginx config file contents
|
|
* @return void
|
|
*/
|
|
public function put(string $url, string $siteConf): void
|
|
{
|
|
$this->unsecure($url);
|
|
|
|
$this->files->ensureDirExists($this->nginxPath(), user());
|
|
|
|
$this->files->putAsUser(
|
|
$this->nginxPath($url), $siteConf
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Remove old loopback interface alias and add a new one if necessary.
|
|
*
|
|
* @param string $oldLoopback
|
|
* @param string $loopback
|
|
* @return void
|
|
*/
|
|
public function aliasLoopback(string $oldLoopback, string $loopback): void
|
|
{
|
|
if ($oldLoopback !== VALET_LOOPBACK) {
|
|
$this->removeLoopbackAlias($oldLoopback);
|
|
}
|
|
|
|
if ($loopback !== VALET_LOOPBACK) {
|
|
$this->addLoopbackAlias($loopback);
|
|
}
|
|
|
|
$this->updateLoopbackPlist($loopback);
|
|
}
|
|
|
|
/**
|
|
* Remove loopback interface alias.
|
|
*
|
|
* @param string $loopback
|
|
* @return void
|
|
*/
|
|
public function removeLoopbackAlias(string $loopback): void
|
|
{
|
|
$this->cli->run(sprintf(
|
|
'sudo ifconfig lo0 -alias %s', $loopback
|
|
));
|
|
|
|
info('['.$loopback.'] loopback interface alias removed.');
|
|
}
|
|
|
|
/**
|
|
* Add loopback interface alias.
|
|
*
|
|
* @param string $loopback
|
|
* @return void
|
|
*/
|
|
public function addLoopbackAlias(string $loopback): void
|
|
{
|
|
$this->cli->run(sprintf(
|
|
'sudo ifconfig lo0 alias %s', $loopback
|
|
));
|
|
|
|
info('['.$loopback.'] loopback interface alias added.');
|
|
}
|
|
|
|
/**
|
|
* Remove old LaunchDaemon and create a new one if necessary.
|
|
*
|
|
* @param string $loopback
|
|
* @return void
|
|
*/
|
|
public function updateLoopbackPlist(string $loopback): void
|
|
{
|
|
$this->removeLoopbackPlist();
|
|
|
|
if ($loopback !== VALET_LOOPBACK) {
|
|
$this->files->put(
|
|
$this->plistPath(),
|
|
str_replace(
|
|
'VALET_LOOPBACK',
|
|
$loopback,
|
|
$this->files->getStub('loopback.plist')
|
|
)
|
|
);
|
|
|
|
info('['.$this->plistPath().'] persistent loopback interface alias launch daemon added.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove loopback interface alias launch daemon plist file.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function removeLoopbackPlist(): void
|
|
{
|
|
if ($this->files->exists($this->plistPath())) {
|
|
$this->files->unlink($this->plistPath());
|
|
|
|
info('['.$this->plistPath().'] persistent loopback interface alias launch daemon removed.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove loopback interface alias and launch daemon plist file for uninstall purpose.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function uninstallLoopback(): void
|
|
{
|
|
if (($loopback = $this->valetLoopback()) !== VALET_LOOPBACK) {
|
|
$this->removeLoopbackAlias($loopback);
|
|
}
|
|
|
|
$this->removeLoopbackPlist();
|
|
}
|
|
|
|
/**
|
|
* Return Valet home path constant.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function valetHomePath(): string
|
|
{
|
|
return VALET_HOME_PATH;
|
|
}
|
|
|
|
/**
|
|
* Return Valet loopback configuration.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function valetLoopback(): string
|
|
{
|
|
return $this->config->read()['loopback'];
|
|
}
|
|
|
|
/**
|
|
* Get the path to loopback LaunchDaemon.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function plistPath(): string
|
|
{
|
|
return '/Library/LaunchDaemons/com.laravel.valet.loopback.plist';
|
|
}
|
|
|
|
/**
|
|
* Get the path to Nginx site configuration files.
|
|
*
|
|
* @param string|null $additionalPath
|
|
* @return string
|
|
*/
|
|
public function nginxPath(?string $additionalPath = null): string
|
|
{
|
|
return $this->valetHomePath().'/Nginx'.($additionalPath ? '/'.$additionalPath : '');
|
|
}
|
|
|
|
/**
|
|
* Get the path to the linked Valet sites.
|
|
*
|
|
* @param string|null $link
|
|
* @return string
|
|
*/
|
|
public function sitesPath(?string $link = null): string
|
|
{
|
|
return $this->valetHomePath().'/Sites'.($link ? '/'.$link : '');
|
|
}
|
|
|
|
/**
|
|
* Get the path to the Valet CA certificates.
|
|
*
|
|
* @param string|null $caFile
|
|
* @return string
|
|
*/
|
|
public function caPath(?string $caFile = null): string
|
|
{
|
|
return $this->valetHomePath().'/CA'.($caFile ? '/'.$caFile : '');
|
|
}
|
|
|
|
/**
|
|
* Get the path to the Valet TLS certificates.
|
|
*
|
|
* @param string $url
|
|
* @param string $extension
|
|
* @return string
|
|
*/
|
|
public function certificatesPath(?string $url = null, ?string $extension = null): string
|
|
{
|
|
$url = $url ? '/'.$url : '';
|
|
$extension = $extension ? '.'.$extension : '';
|
|
|
|
return $this->valetHomePath().'/Certificates'.$url.$extension;
|
|
}
|
|
|
|
/**
|
|
* Make the domain name based on parked domains or the internal TLD.
|
|
*
|
|
* @param string|null $domain
|
|
* @return string
|
|
*/
|
|
public function domain(?string $domain): string
|
|
{
|
|
// if ($this->parked()->pluck('site')->contains($domain)) {
|
|
// return $domain;
|
|
// }
|
|
|
|
// if ($parked = $this->parked()->where('path', getcwd())->first()) {
|
|
// return $parked['site'];
|
|
// }
|
|
|
|
return ($domain ?: $this->host(getcwd())).'.'.$this->config->read()['tld'];
|
|
}
|
|
|
|
/**
|
|
* Replace Loopback configuration line in Valet site configuration file contents.
|
|
*
|
|
* @param string $siteConf
|
|
* @return string
|
|
*/
|
|
public function replaceLoopback(string $siteConf): string
|
|
{
|
|
$loopback = $this->config->read()['loopback'];
|
|
|
|
if ($loopback === VALET_LOOPBACK) {
|
|
return $siteConf;
|
|
}
|
|
|
|
$str = '#listen VALET_LOOPBACK:80; # valet loopback';
|
|
|
|
return str_replace(
|
|
$str,
|
|
substr(str_replace('VALET_LOOPBACK', $loopback, $str), 1),
|
|
$siteConf
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extract PHP version of exising nginx conifg.
|
|
*
|
|
* @param string $url
|
|
* @return string|null
|
|
*/
|
|
public function customPhpVersion(string $url): ?string
|
|
{
|
|
if ($this->files->exists($this->nginxPath($url))) {
|
|
$siteConf = $this->files->get($this->nginxPath($url));
|
|
|
|
if (starts_with($siteConf, '# '.ISOLATED_PHP_VERSION)) {
|
|
$firstLine = explode(PHP_EOL, $siteConf)[0];
|
|
|
|
return preg_replace("/[^\d]*/", '', $firstLine); // Example output: "74" or "81"
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Replace .sock file in an Nginx site configuration file contents.
|
|
*
|
|
* @param string $siteConf
|
|
* @param string $phpVersion
|
|
* @return string
|
|
*/
|
|
public function replaceSockFile(string $siteConf, string $phpVersion): string
|
|
{
|
|
$sockFile = PhpFpm::fpmSockName($phpVersion);
|
|
|
|
$siteConf = preg_replace('/valet[0-9]*.sock/', $sockFile, $siteConf);
|
|
$siteConf = preg_replace('/# '.ISOLATED_PHP_VERSION.'.*\n/', '', $siteConf); // Remove ISOLATED_PHP_VERSION line from config
|
|
|
|
return '# '.ISOLATED_PHP_VERSION.'='.$phpVersion.PHP_EOL.$siteConf;
|
|
}
|
|
|
|
/**
|
|
* Get PHP version from .valetphprc for a site.
|
|
*
|
|
* @param string $site
|
|
* @return string|null
|
|
*/
|
|
public function phpRcVersion(string $site): ?string
|
|
{
|
|
if ($site = $this->parked()->merge($this->links())->where('site', $site)->first()) {
|
|
$path = data_get($site, 'path').'/.valetphprc';
|
|
|
|
if ($this->files->exists($path)) {
|
|
return PhpFpm::normalizePhpVersion(trim($this->files->get($path)));
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|