从 app.test 到小锁:valet 本地 HTTPS 的完整链路
之前在用到valet的时候就觉得这个工具很厉害,因为本地部署很多时候都是比较费劲的,也比较简陋,就直接localhost启动下,但是有时候需要验一下回调的,就需要有域名跟https,当然之前也没想明白的是在本地其实用本地的地址也是可以的,意思就是如果回调地址没限制必须要域名,也可以用127.0.0.1或者localhost,当然如果需要域名和https那就正好是valet的用武之地,所以想把 Valet 这类工具背后的逻辑捋一下。
平时用 Valet 的时候体验是非常顺的:1
2valet park
valet secure demo
然后浏览器里访问:1
https://demo.test
就能看到本地项目,而且还有 HTTPS 的小锁。
这个体验乍一看很像魔法。一个本地域名,既不用手动写 hosts,也不用自己配 Nginx,还不用去公网 CA 申请证书,怎么就能跑起来了呢?
其实拆开之后,Valet 并没有发明什么新协议,它只是把本地开发里几件比较麻烦的事情串起来了:1
2
3
4
5
6本地域名解析
Nginx 入口
PHP-FPM 执行 PHP
项目目录映射
本地 CA 和站点证书
框架 driver 判断入口文件
先看完整链路
先放一个整体流程,后面再一段一段拆。
假设我们访问:1
https://demo.test/users/1
整个过程大概是:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28浏览器访问 https://demo.test/users/1
|
v
macOS 发现 .test 域名要走 /etc/resolver/test
|
v
本地 dnsmasq 把 demo.test 解析到 127.0.0.1
|
v
请求到达本机 443 端口
|
v
Nginx 接收请求,并使用 demo.test 的证书完成 HTTPS
|
v
Nginx 把请求交给 Valet 的 server.php
|
v
server.php 根据 Host 找到 demo 对应的本地目录
|
v
Valet driver 判断这是 Laravel 项目
|
v
静态文件直接返回,非静态请求交给 public/index.php
|
v
Laravel 应用开始处理路由
所以可以先得出一个粗略结论:1
2
3
4
5
6DNS 负责把域名带到本机
Nginx 负责接住 HTTP/HTTPS 请求
server.php 负责把请求分发到具体项目
driver 负责判断不同框架的入口规则
PHP-FPM 负责真正执行 PHP
本地 CA 负责让浏览器信任本地证书
这个结论很重要,因为很多时候我们排查 Valet 问题,就是在判断链路断在哪一层。
比如:1
2
3
4
5域名打不开,可能是 DNS/dnsmasq 问题
80/443 端口不通,可能是 Nginx 问题
项目找不到,可能是 park/link 映射问题
PHP 报错,可能是 PHP-FPM 或项目本身问题
HTTPS 不被信任,可能是证书或系统信任问题
Valet 的配置中心
源码里可以先看 Configuration.php。
Valet 会在用户目录下维护一个自己的配置目录:1
~/.config/valet
这个目录下面会有几类东西:1
2
3
4
5
6
7~/.config/valet/config.json
~/.config/valet/Sites
~/.config/valet/Drivers
~/.config/valet/Nginx
~/.config/valet/Log
~/.config/valet/Certificates
~/.config/valet/CA
从源码看,Configuration::install() 做的事情就是创建这些基础目录,并确保基础配置存在。
其中最核心的是:1
config.json
它大概长这样:1
2
3
4
5{
"tld": "test",
"loopback": "127.0.0.1",
"paths": []
}
这几个字段分别代表:1
2
3tld 本地域名后缀,默认是 test
loopback 本地回环地址,默认是 127.0.0.1
paths 被 park 的目录列表
所以 Valet 的状态不是凭空来的,它有一个非常明确的落盘位置。
比如 valet park 之后,当前目录会被写到 paths 里。
比如 valet link demo 之后,~/.config/valet/Sites/demo 里会出现一个指向真实项目目录的符号链接。
比如 valet secure demo 之后,证书会进入:1
~/.config/valet/Certificates
对应的 Nginx 站点配置会进入:1
~/.config/valet/Nginx
理解这个目录很有用,因为后面很多问题都可以直接去这里看。
valet install 大概做了什么
我们平时执行:1
valet install
它背后不是单纯装一个命令,而是在本机准备一整套本地 Web 开发环境。
从源码结构上看,大体可以分成几部分:1
2
3
4
5Configuration.php 创建 ~/.config/valet 相关目录和 config.json
DnsMasq.php 安装和配置 dnsmasq
Nginx.php 安装和配置 Nginx
PhpFpm.php 配置 PHP-FPM 和 valet.sock
Valet.php 处理 valet 命令本身的链接和 sudoers 等辅助事情
这里先不展开 PHP 版本管理和 sudoers 等细节,只看主链路。
Valet 安装完成后,本机基本会形成这么一个结构:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16macOS DNS
|
v
dnsmasq
|
v
127.0.0.1:80 / 127.0.0.1:443
|
v
Nginx
|
v
Valet server.php
|
v
具体项目
也就是说,Valet 的轻量不是说它什么服务都没有,而是它复用了本机的 Nginx、PHP-FPM、dnsmasq,不需要为每个项目单独开一个虚拟机或容器。
demo.test 为什么会到本机
先看第一个问题:1
demo.test 怎么就指向 127.0.0.1 了?
如果不用 Valet,我们最容易想到的方法是改 /etc/hosts:1
127.0.0.1 demo.test
但这样有个问题:每加一个项目,就要写一行。
Valet 不这么做。它用的是 dnsmasq 加 macOS resolver。
源码里对应的是 DnsMasq.php。
DnsMasq::install() 的流程大概是:1
2
3
4
5确保 dnsmasq 已安装
让 dnsmasq 加载额外配置目录
创建 .test 后缀对应的 dnsmasq 配置
创建 /etc/resolver/test
重启 dnsmasq
这里有两个关键文件。
第一个是类似这样的 dnsmasq 配置:1
2
3address=/.test/127.0.0.1
address=/.test/::1
listen-address=127.0.0.1
它的意思是:1
所有 .test 结尾的域名,都解析到本机
所以这些域名都可以成立:1
2
3
4demo.test
blog.test
api.test
anything.test
第二个是:1
/etc/resolver/test
内容大概是:1
nameserver 127.0.0.1
这个文件告诉 macOS:1
遇到 .test 这个后缀,不要走普通 DNS,交给 127.0.0.1 上的 DNS 服务处理
然后本地的 dnsmasq 再把它解析回 127.0.0.1。
这里要注意一件事:DNS 这一层只解决了“域名到 IP”的问题。
也就是说:1
2
3demo.test -> 127.0.0.1
blog.test -> 127.0.0.1
api.test -> 127.0.0.1
但是 DNS 并不知道:1
2demo.test 对应哪个项目目录
blog.test 对应哪个项目目录
这个问题要留给后面的 Nginx 和 Valet 自己处理。
请求到本机后谁来接
域名解析到 127.0.0.1 之后,浏览器会根据协议和端口发请求。
如果是 HTTP:1
http://demo.test -> 127.0.0.1:80
如果是 HTTPS:1
https://demo.test -> 127.0.0.1:443
这时接请求的是 Nginx。
源码里对应的是 Nginx.php。
Nginx::install() 大致做几件事:1
2
3
4确保 Nginx 已安装
写入 nginx.conf
写入 Valet 的 valet.conf
创建 ~/.config/valet/Nginx 目录
这里比较关键的是 Valet 的 Nginx 不是简单地给某一个项目写死 root。
它会准备一个统一入口,把请求转给 Valet 自己的 server.php。
可以粗略理解为:1
2
3
4
5
6
7Nginx 接住所有本地 .test 请求
|
v
统一转给 Valet server.php
|
v
server.php 再决定这个请求属于哪个项目
这一点是 Valet 设计里比较关键的地方。
如果完全靠 Nginx,每个项目都要生成一个完整的 server block:1
2demo.test -> /Users/me/code/demo/public
blog.test -> /Users/me/code/blog/public
Valet 当然也会为 HTTPS、proxy、PHP 隔离这类特殊场景生成站点配置,但普通场景下,它更核心的思路是:1
2Nginx 做入口
PHP 层的 server.php 做动态分发
这样它才能做到 valet park 之后,目录下面新增一个项目,不需要手工改 Nginx 配置也能访问。
park 和 link 到底有什么区别
接着看项目目录映射。
Valet 常用两个命令:1
2valet park
valet link
它们都能让本地域名对应到项目,但思路不一样。
park 是登记一个父目录
假设我们在这个目录执行:1
2cd ~/Sites
valet park
然后目录结构是:1
2
3~/Sites/demo
~/Sites/blog
~/Sites/shop
Valet 就可以让这些域名工作:1
2
3demo.test -> ~/Sites/demo
blog.test -> ~/Sites/blog
shop.test -> ~/Sites/shop
从配置角度看,park 主要就是把 ~/Sites 这个路径写入 config.json 的 paths。
之后 Valet 查找站点时,会扫描这些 parked path 下面的子目录。
所以 park 的心智模型是:1
2我把一个工作区交给 Valet
工作区下每个子目录都是一个站点
link 是创建一个明确映射
link 更像是给当前目录起一个名字。
比如:1
2cd ~/code/some-long-project-name
valet link demo
它会创建类似这样的符号链接:1
~/.config/valet/Sites/demo -> ~/code/some-long-project-name
之后:1
demo.test
就对应这个真实项目目录。
源码里 Site::link() 做的事情也很直接:1
2
3确保 ~/.config/valet/Sites 存在
把 Sites 目录加入配置路径
创建一个符号链接
所以可以这样理解:1
2park 是目录扫描
link 是显式别名
这也是排查时很好用的判断。
如果是 park 出来的站点,要看父目录有没有在 config.json 的 paths 里。
如果是 link 出来的站点,要看 ~/.config/valet/Sites 下的符号链接是否存在、是否指向正确目录。
每次请求真正进入的是 server.php
现在 DNS 和 Nginx 都说完了,请求已经进入 Valet。
真正处理请求分发的是仓库根目录下的:1
server.php
这段源码非常值得看,因为它基本把 Valet 的请求生命周期写清楚了。
它做的事情可以简化成这样:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15$config = read_valet_config();
$uri = parse_request_uri();
$siteName = site_name_from_http_host($_SERVER['HTTP_HOST']);
$sitePath = find_site_path($siteName);
$driver = ValetDriver::assign($sitePath, $siteName, $uri);
if ($driver->isStaticFile(...)) {
return $driver->serveStaticFile(...);
}
$frontController = $driver->frontControllerPath(...);
require $frontController;
这里面有几个关键点。
第一个,siteName 来自 HTTP Host。
访问:1
demo.test
Valet 会从 Host 里解析出:1
demo
第二个,sitePath 来自 Valet 的站点映射。
也就是前面说的:1
2parked paths
linked sites
第三个,Valet 不会一上来就假设这是 Laravel 项目,而是会通过 driver 选择。
这一层是 Valet 能支持很多框架的关键。
driver 是 Valet 的框架适配层
源码里 ValetDriver.php 定义了几个关键方法:1
2
3serves()
isStaticFile()
frontControllerPath()
这三个方法基本就回答了三个问题:1
2
3这个 driver 能不能处理当前项目?
当前请求是不是静态文件?
如果不是静态文件,入口文件在哪里?
比如 Laravel 项目的 driver,也就是 LaravelValetDriver.php,判断逻辑可以概括成:1
2
3项目目录下有 public/index.php
并且项目目录下有 artisan
那就认为这是 Laravel 项目
这就很符合 Laravel 项目的基本结构。
对于静态文件,它会优先找:1
2项目目录/public/当前 URI
项目目录/storage/app/public/当前 URI
如果请求的是:1
/css/app.css
并且真实文件存在:1
public/css/app.css
那就直接返回静态文件。
如果请求的是:1
/users/1
这显然不是一个真实静态文件,那就交给:1
public/index.php
然后 Laravel 自己的路由系统再接着处理。
这就是 Valet driver 的意义。
Nginx 并不知道 Laravel、WordPress、Statamic、普通 PHP 项目的入口规则有什么区别。Valet 通过 driver 把这些规则封装起来。
所以 Valet 支持多框架的本质不是 Nginx 有多聪明,而是:1
2server.php 统一接管请求
driver 负责识别项目类型和入口文件
静态文件为什么不是 PHP 直接读出来
Valet 处理静态文件还有一个有意思的细节。
在 driver 判断某个请求是静态文件后,它不是简单地在 PHP 里 readfile()。
Valet driver 会通过一个内部跳转机制,让 Nginx 去返回真实文件。
也就是类似:1
2
3
4
5
6
7PHP 判断这是静态文件
|
v
告诉 Nginx 这个文件在哪里
|
v
Nginx 直接返回文件
这么做的好处是:1
2PHP 只负责判断逻辑
真正传输文件这种事情交给 Nginx
这也符合 Web Server 和应用层的分工。
Nginx 擅长处理静态文件,PHP 擅长处理动态逻辑。
PHP-FPM 在链路里的位置
再说一下 PHP-FPM。
Nginx 自己不会执行 PHP。
当请求需要执行 server.php 或项目的 public/index.php 时,Nginx 会把请求交给 PHP-FPM。
Valet 里常见的通信方式是 Unix socket,比如:1
~/.config/valet/valet.sock
可以粗略理解成:1
2
3Nginx 是前台
PHP-FPM 是后厨
server.php 和 Laravel index.php 是菜单上的具体菜
Nginx 接到请求后,如果需要执行 PHP,就把请求交给 PHP-FPM。
PHP-FPM 执行完 PHP 脚本,再把响应交回给 Nginx,最后返回给浏览器。
这也解释了为什么有时候 Valet 域名能解析、Nginx 也启动了,但页面仍然打不开,可能是 PHP-FPM 没起来,或者 socket 路径不对。
本地 HTTPS 是怎么来的
现在再看 HTTPS。
问题是:1
为什么 https://demo.test 能有小锁?
公网 HTTPS 的常见逻辑是:1
2
3
4浏览器内置信任一批根 CA
网站证书由这些 CA 或它们的中间 CA 签发
浏览器验证证书链
验证通过后显示可信
本地 HTTPS 没法直接去公网 CA 申请 demo.test 的证书,因为它不是公网真实站点。
Valet 的思路是:1
2
3自己创建一个本地 CA
把这个本地 CA 加到 macOS 系统信任
再用这个本地 CA 给 demo.test 签发证书
也就是:1
2
3
4
5
6
7Laravel Valet 本地 CA
|
v
demo.test 站点证书
|
v
浏览器验证到系统信任的本地 CA,于是显示可信
这里最关键的一句话是:1
浏览器信任的不是 demo.test 这个域名本身,而是它的证书链能追溯到系统信任的 CA。
所以 Valet 本地 HTTPS 的本质是:1
2
3本地生成证书
本地信任证书颁发者
本地 Nginx 使用这张证书
这个信任只在你的机器上成立。
换一台电脑,如果没有信任你这台机器上的 Valet CA,demo.test 的证书就不会被认为可信。
valet secure 在源码里做了什么
源码里 HTTPS 的主线在 Site.php。
valet secure demo 最终会走到类似 Site::secure() 的逻辑。
它的主流程可以整理成:1
2
3
4
5
6保留当前站点可能存在的 PHP 版本隔离配置
确保本地 CA 存在
删除旧的站点证书和旧 Nginx 配置
创建 demo.test 的站点证书
生成 HTTPS Nginx server 配置
写入 ~/.config/valet/Nginx/demo.test
里面又可以拆成两个层次。
第一层:创建本地 CA
createCa() 负责创建本地 CA。
它会生成类似这些文件:1
2~/.config/valet/CA/LaravelValetCASelfSigned.pem
~/.config/valet/CA/LaravelValetCASelfSigned.key
然后通过 macOS 的 security 命令把这个 CA 加入系统钥匙串。
也就是告诉系统:1
这个本地 CA 我信任
这是小锁能出现的根本原因。
如果这一步失败,比如钥匙串没有信任成功,证书就算生成了,浏览器也可能不认。
第二层:给具体站点签证书
createCertificate() 负责给具体域名生成证书。
比如:1
2
3
4demo.test.key
demo.test.csr
demo.test.crt
demo.test.conf
这些文件放在:1
~/.config/valet/Certificates
流程大概是:1
2
3生成私钥
生成证书签名请求
用本地 CA 签出站点证书
所以证书关系是:1
2
3
4CA 私钥 + CA 证书
|
v
签发 demo.test 证书
这和公网证书的逻辑是类似的,只是 CA 从公网机构换成了你本机的本地 CA。
secure 之后 Nginx 多了什么
只生成证书还不够。
Nginx 还得知道:1
2
3
4
5demo.test 用哪张证书
demo.test 用哪把私钥
443 端口怎么监听
HTTP 要不要跳 HTTPS
请求最终仍然转给哪个入口
Site::buildSecureNginxServer() 会读取 Valet 的 HTTPS Nginx 模板,然后把占位符替换成真实值。
大概会替换这些东西:1
2
3
4
5VALET_SITE -> demo.test
VALET_CERT -> ~/.config/valet/Certificates/demo.test.crt
VALET_KEY -> ~/.config/valet/Certificates/demo.test.key
VALET_HOME_PATH
VALET_SERVER_PATH
所以 valet secure demo 之后,Nginx 站点配置就会知道:1
2
3server_name demo.test
ssl_certificate demo.test.crt
ssl_certificate_key demo.test.key
同时它还会保留 Valet 的基本请求分发逻辑:1
2
3
4
5
6
7
8
9
10HTTPS 请求进来
|
v
Nginx 完成 TLS
|
v
请求继续交给 Valet server.php
|
v
server.php 找项目和 driver
也就是说,HTTPS 只是入口层多了一次证书和 TLS 处理,后面的项目分发逻辑并没有变。
SNI 在这里扮演什么角色
这里稍微提一下 SNI,但不展开太远。
本机可能同时有很多 HTTPS 站点:1
2
3demo.test
blog.test
api.test
它们都走:1
127.0.0.1:443
那 Nginx 怎么知道应该拿哪张证书?
客户端在 TLS 握手时会带上要访问的域名,这就是 SNI。
Nginx 根据这个域名匹配对应的 server block,然后选择对应证书。
所以访问:1
https://demo.test
Nginx 会用 demo.test 的证书。
访问:1
https://blog.test
Nginx 会用 blog.test 的证书。
这也是为什么 server_name 和证书配置要对应起来。
proxy 又是怎么回事
Valet 还有一个很实用的功能:1
valet proxy api http://127.0.0.1:3000
这个功能不是 PHP 项目映射,而是 Nginx 反向代理。
它的意思是:1
2
3
4
5
6
7api.test
|
v
Nginx
|
v
http://127.0.0.1:3000
源码里也在 Site.php,对应 proxyCreate() 这类逻辑。
它会生成一份 Nginx proxy 配置,把 api.test 的流量转发到指定地址。
如果加上 secure,本质就是:1
2
3给 api.test 生成本地证书
Nginx 负责 HTTPS
后端仍然代理到 127.0.0.1:3000
所以 Valet 不只能服务 Laravel 或 PHP 项目。对于 Node、Go、Docker 暴露出来的本地端口,Valet 也可以提供一个统一的本地域名和本地 HTTPS 入口。
从源码角度再串一次
现在把源码文件和职责再汇总一下。1
Configuration.php
负责 Valet 自己的配置目录和 config.json:1
2
3tld
loopback
paths1
DnsMasq.php
负责 .test 这类本地域名后缀:1
2/etc/resolver/test
dnsmasq address=/.test/127.0.0.11
Nginx.php
负责安装和写入 Valet 的 Nginx 配置:1
2
3nginx.conf
valet.conf
~/.config/valet/Nginx1
Site.php
负责站点层面的事情:1
2
3
4
5
6link
secure
unsecure
proxy
证书路径
Nginx 站点配置生成1
server.php
是每次请求真正进入的 Valet 分发入口:1
2
3
4
5解析 Host
找到 site path
选择 driver
判断静态文件
加载 front controller1
ValetDriver.php
定义项目识别和入口判断的抽象:1
2
3serves()
isStaticFile()
frontControllerPath()1
LaravelValetDriver.php
是 Laravel 项目的具体规则:1
2
3
4有 artisan
有 public/index.php
静态文件从 public 或 storage/app/public 找
动态请求交给 public/index.php
这几个文件串起来,就能看到 Valet 的整体设计。
排查问题时可以顺着这条链路看
理解了链路之后,排查也会清楚很多。
1. 域名有没有解析到本机
可以看:1
cat /etc/resolver/test
以及:1
cat ~/.config/valet/dnsmasq.d/tld-test.conf
如果这里不对,问题通常还没到 Nginx。
2. Valet 配置里有没有这个路径
可以看:1
cat ~/.config/valet/config.json
如果是 park,看 paths 里有没有父目录。
如果是 link,看:1
ls -l ~/.config/valet/Sites
3. Nginx 配置有没有生成
可以看:1
ls ~/.config/valet/Nginx
如果是 secure 或 proxy 站点,这里一般会有对应配置。
4. 证书是否存在
可以看:1
ls ~/.config/valet/Certificates
也可以检查本地 CA:1
ls ~/.config/valet/CA
5. PHP-FPM 是否正常
如果 Nginx 能接请求,但 PHP 执行异常,就要看 PHP-FPM 和 socket。
Valet 的 Nginx 配置会把 PHP 请求交给类似:1
~/.config/valet/valet.sock
如果这个 socket 不存在,或者 PHP-FPM 没启动,就会出问题。
最后总结一下
Valet 的原理可以压缩成一句话:
Valet 用 dnsmasq 解决本地域名,用 Nginx 接住请求,用 server.php 和 driver 找到真正项目入口,用 PHP-FPM 执行 PHP,再用本地 CA 和 Nginx 配置补上 HTTPS。
它的好用之处不是某个单点技术特别神奇,而是把一堆本来要手工处理的事情自动化了:1
2
3
4
5不用每个项目手写 hosts
不用每个项目手写 Nginx 配置
不用自己维护 PHP-FPM socket
不用自己生成和信任本地证书
不用每个项目单独起一套容器或虚拟机
如果只看表面,Valet 好像只是让 demo.test 能访问。
但从源码和链路看,它其实是给本地开发搭了一套很轻量的“入口网关”:1
2
3
4
5
6所有本地域名都先进本机
所有请求都先进 Nginx
所有动态判断都进 Valet server.php
所有框架差异都交给 driver
所有 PHP 执行都交给 PHP-FPM
所有 HTTPS 信任都交给本地 CA
理解这一点之后,再看 valet park、valet link、valet secure、valet proxy,就不太像魔法了。
它们只是分别在这条链路上改了一小段配置。

