immortalwrt自带的uhttpd功能过于简单,无法胜任反代、证书管理等功能,所以参考了前人经验用caddy干掉了uhttpd,运行效果令人满意。
配置防火墙
新增一条防火墙规则,对外开放80、443端口。我这里移动宽带没有限制IPv6端口,443端口用于对外提供网页服务,80端口用于Let's Encrypt的http-challenge。设置好后看起来大致如下:
firewall.@rule[14]=rule
firewall.@rule[14].src='wan'
firewall.@rule[14].name='Allow-LUCI-WAN'
firewall.@rule[14].dest_port='80 443'
firewall.@rule[14].target='ACCEPT'禁用uhttpd
/etc/init.d/uhttpd disable
/etc/init.d/uhttpd stop下载caddy
caddy本身只是一个二进制文件,而且第三方插件众多,所以immortalwrt软件源中没有提供,要去官网按需下载,地址为 https://caddyserver.com/download 。我选中了这些第三方模块:
aksdb/caddy-cgi/v2 :没它无法访问LUCI
mholt/caddy-webdav:提供了webdav功能
选中这些模块后点击下载,注意架构不要错,丢到 /usr/bin 中改名为caddy,权限设置为 751 。
创建Caddyfile配置文件
在/etc/目录中新建 caddy 目录,进入其中创建名为 Caddyfile 的配置文件,内容如下:
{
# 全局设置
# Caddy 的指令顺序非常重要。
# 确保 cgi 和 file_server 在 respond 之前处理,这是你现有配置的逻辑。
order cgi before respond
order file_server last
# 建议在这里设置全局邮箱,Caddy 会用它注册 ACME 账户
# Caddy 默认使用 Let's Encrypt 作为 ACME CA,通常不需要显式设置 acme_ca。
email [email protected]
# Caddy 存储 ACME 证书和内部状态的目录。
# 确保这个目录存在并且 Caddy 有写入权限。
# 默认通常是 /var/lib/caddy 或 ~/.local/share/caddy
# 如果在 OpenWrt 上运行,可能需要指定一个可持久化的路径,例如 /etc/caddy/data
# storage /etc/caddy/data
# 如果你的Caddy运行在反向代理(如Cloudflare、OpenWrt上的homeproxy等)之后,
# 并且需要基于真实客户端IP进行访问控制,请在这里配置 trusted_proxies。
# 例如,如果你的homeproxy的IPv6地址是 2408:832e:8443:e000::1,且它转发了X-Forwarded-For头:
# trusted_proxies 2408:832e:8443:e000::/64
# 如果是Cloudflare:
# trusted_proxies cloudflare
}
# 定义一个名为 'luci' 的片段,用于 OpenWrt LuCI 界面
(luci) {
root * /www # LuCI 静态文件根目录
route /cgi-bin* {
@exists {
file cgi-bin/{path.1} =404 # 检查 cgi-bin 目录下的文件是否存在
}
handle @exists {
uri strip_prefix {file_match.relative} # 剥离 URI 前缀
cgi * /www/{file_match.relative} { # 执行 CGI 脚本
script_name {file_match.relative}
}
}
}
cgi /ubus* ubus.sh { # UBUS 接口的 CGI
script_name /ubus
}
file_server # 提供静态文件服务
redir / /cgi-bin/luci # 根路径重定向到 LuCI 登录页
}
# 定义一个名为 'security_headers' 的片段,用于添加安全 HTTP 头
(security_headers) {
header {
X-Frame-Options DENY
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
}
# 定义一个名为 'internal_access_control' 的片段,用于限制内网访问
# 这样可以复用IP访问限制逻辑,避免重复代码
(internal_access_control) {
# 定义一个匹配器,匹配所有不允许的IP地址
# 即,不在这些允许的IPv4/IPv6范围内的所有IP
@not_allowed_ips {
not remote_ip 10.89.2.0/24 192.168.20.0/24 2408:832e:8443:e000::/60
}
# 如果IP不在允许列表中,则拒绝访问
handle @not_allowed_ips {
respond "Access Denied from your IP address. Your IP: {http.request.remote}" 403
}
}
# Luci 界面 - lab.tccmu.com
lab.tccmu.com {
import security_headers
import internal_access_control # 导入访问控制片段
# 如果IP在允许列表中,则继续处理LuCI
import luci
# 访问日志
log {
output file /var/log/caddy/lab.tccmu.com.access.log
format json
}
}
# Docker Portainer 界面 - docker.tccmu.com
docker.tccmu.com {
import security_headers
import internal_access_control # 导入访问控制片段
# 编码设置
encode gzip
# 反向代理到 Portainer
reverse_proxy 10.89.2.1:9000 {
# Portainer 可能需要一些特定的代理头,这里是通用推荐
header_up Host {host}
header_up X-Real-IP {remote_ip}
header_up X-Forwarded-For {remote_ip}
header_up X-Forwarded-Proto {scheme}
}
log {
output file /var/log/caddy/docker.tccmu.com.access.log
format json
}
}
# 增强版 WebDAV 配置
webdav.tccmu.com {
import security_headers
# 基本认证 (应用于整个站点)
basic_auth {
admin $2a$14$..................................
}
# WebDAV 根目录
# 所有WebDAV操作都将在这个目录下进行
root * /root/webdav
# 定义一个命名匹配器,用于匹配 WebDAV 方法
@webdav_methods {
# *** 关键修改:添加 GET 方法 ***
method GET PROPFIND MKCOL PUT DELETE MOVE COPY LOCK UNLOCK
}
# WebDAV 配置
# 当请求匹配 @webdav_methods 时,直接启用 WebDAV
route @webdav_methods {
webdav
}
# 访问日志
log {
output file /var/log/caddy/webdav.tccmu.com.access.log
format json
}
# 错误处理
handle_errors {
respond "Authentication required" 401
respond "Access forbidden" 403
}
}
blog.tccmu.com {
#import security_headers # 如果需要,可以取消注释
# 编码设置
encode gzip
# 反向代理配置
reverse_proxy 10.89.2.1:8001 {
# Halo 需要的代理头
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote}
header_up X-Real-IP {remote}
# 确保 Host 头正确传递
header_up Host {host}
# 健康检查
health_uri /actuator/health/readiness
health_interval 30s
health_timeout 5s
}
log {
output file /var/log/caddy/blog.tccmu.com.access.log
format json
}
}
# 默认响应 - 处理未配置的域名 (HTTPS)
# 如果有请求到达 443 端口,但没有匹配到任何域名,则返回 404。
:443 {
respond "Domain not configured" 404
}
# HTTP 监听 - 优先处理 ACME 挑战,然后重定向到 HTTPS
# 这个块是关键,它确保了 Let's Encrypt 的 http-01 挑战能够成功。
:80 {
# 优先处理 Let's Encrypt 的 http-01 挑战
# Caddy 自身在进行 ACME 挑战时,会使用这个路径来放置临时文件。
# 这样 Caddy 就能通过 http-01 挑战来获取和续订证书。
handle /.well-known/acme-challenge/* {
root * /var/www/caddy-acme-challenges # Caddy 默认用于 ACME 挑战的目录
file_server
}
# 对于其他所有 HTTP 请求,重定向到 HTTPS
# Caddy 会自动将 HTTP 请求重定向到对应的 HTTPS 域名。
# 例如,http://blog.tccmu.com 会重定向到 https://1.tccmu.com
redir https://{host}{uri} permanent
}
这个配置是用gpt生成的,功能还算齐全,证书自动管理、反代、重定向、webdav、访问IP限制等功能都有了,不过漏洞肯定不少,欢迎网友提出意见。
创建/etc/init.d/caddy
这个脚本用于管理caddy服务,设置权限751:
#!/bin/sh /etc/rc.common
USE_PROCD=1
START=99
STOP=10
SERVICE_USE_PID=1
SERVICE_WRITE_PID=1
SERVICE_DAEMONIZE=1
SERVICE_PID_FILE=/var/run/caddy.pid
SERVICE_NAME=caddy
SERVICE_BIN=/usr/bin/caddy
CONFIG_FILE=/etc/caddy/Caddyfile
start_service() {
procd_open_instance
procd_set_param command "$SERVICE_BIN" run --config "$CONFIG_FILE"
procd_set_param respawn
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param pidfile "$SERVICE_PID_FILE"
procd_close_instance
}
stop_service() {
service_stop "$SERVICE_BIN"
}
reload_service() {
# 检查caddy是否支持reload命令
if "$SERVICE_BIN" reload --help >/dev/null 2>&1; then
"$SERVICE_BIN" reload --config "$CONFIG_FILE"
else
# 如果不支持reload,则重启服务
echo "Caddy does not support reload, restarting instead"
restart
fi
}
restart() {
stop
start
}
service_running() {
service_started "$SERVICE_BIN"
}
service_enabled() {
/etc/init.d/caddy enabled
}
service_status() {
if service_running; then
echo "Service is running"
return 0
else
echo "Service is not running"
return 1
fi
}
service_info() {
echo "Service name: $SERVICE_NAME"
echo "Binary: $SERVICE_BIN"
echo "Config file: $CONFIG_FILE"
echo "PID file: $SERVICE_PID_FILE"
if service_running; then
pid=$(cat "$SERVICE_PID_FILE" 2>/dev/null)
echo "PID: $pid"
echo "Process info:"
ps -p "$pid" -o pid,ppid,user,comm,args 2>/dev/null || echo "Process not found"
fi
}
extra_commands="reload enabled running status info trace"创建ubus.sh文件
该脚本文件来自 https://github.com/yurt-page/cgi-ubus 。在 /www/cgi-bin/ 目录中创建 ubus.sh ,权限751,内容如下:
#!/bin/sh
[ "$REQUEST_METHOD" != "POST" ] && printf 'Status: 405\r\n\r\n' && exit
access() {
local sid=$1
local obj=$2
local fun=$3
local req=$(printf '{ "ubus_rpc_session": "%s", "scope": "ubus", "object": "%s", "function": "%s" }' "$sid" "$obj" "$fun")
local res=$(ubus call session access "$req" | jsonfilter -e '@.access')
[ "$res" = "true" ]
}
error() {
local code=$1
local mesg=$2
printf '{ "jsonrpc": "2.0", "id": "%s", "error": { "code": %d, "message": "%s" } }' \
"${RPC_ID:-null}" "$code" "$mesg"
exit 1
}
process() {
local request=$1
# - use `VAR=expr` notation to let it create shell compatible export statements
# - eval result to import variables
eval $(jsonfilter -s "$request" \
-e '[email protected]' \
-e '[email protected]' \
-e '[email protected]' \
-e '[email protected][3].ubus_rpc_session' \
-e '[email protected][0]' \
-e '[email protected][1]' \
-e '[email protected][2]')
# verify JSON-RPC framing
if [ -z "$RPC_ID" ] || [ "$RPC_VERSION" != "2.0" ]; then
error -32600 "Invalid request"
fi
# reject invalid values to prevent shell injection
case "$RPC_ID$UBUS_SID$UBUS_SERVICE$UBUS_CMD" in
*[^a-zA-Z0-9_.-]*) error -32600 "Invalid request" ;;
esac
case "$RPC_METHOD" in
call)
UBUS_PAYLOAD=$(jsonfilter -s "$request" -e '@.params[3]')
# ensure that payload is a dictionary or empty
case "$UBUS_PAYLOAD" in
""|{*}) : ;;
*) error -32602 "Invalid parameters" ;;
esac
# merge ubus_rpc_session parameter
if [ -z "$UBUS_PAYLOAD" ] || [ "$UBUS_PAYLOAD" = "{ }" ]; then
UBUS_PAYLOAD=$(printf '{ "ubus_rpc_session": "%s" }' "$UBUS_SID")
else
UBUS_PAYLOAD=$(printf '{ "ubus_rpc_session": "%s", %s' "$UBUS_SID" "${UBUS_PAYLOAD#\{ }")
fi
# reject requests with embedded ubus_rpc_session
if [ -n "$RPC_SESSION_ARG" ]; then
error -32602 "Invalid parameters"
fi
# check access
if ! access "$UBUS_SID" "$UBUS_SERVICE" "$UBUS_CMD"; then
error -32002 "Access denied"
fi
ubus_reply=$(ubus call "$UBUS_SERVICE" "$UBUS_CMD" "$UBUS_PAYLOAD")
ubus_status=$?
printf '{ "jsonrpc": "2.0", "id": "%s", "result": [ %d, %s ] }' \
"$RPC_ID" "$ubus_status" "${ubus_reply:-null}"
;;
list)
RPC_PARAMS=$(jsonfilter -s "$request" -e '@.params')
# ensure that payload is an array or empty
case "${RPC_PARAMS:-[ ]}" in
\[*\]) : ;;
*) error -32602 "Invalid parameters" ;;
esac
# empty payload should result in list of services
if [ "${RPC_PARAMS:-[ ]}" = "[ ]" ]; then
services=''
for service in $(ubus list); do
services="${services:+$services, }\"$service\""
done
printf '{ "jsonrpc": "2.0", "id": "%s", "result": [ %s ] }' \
"$RPC_ID" "$services"
# list of services should result in { service => { method => signature } } replies
else
signatures=''
eval $(jsonfilter -s "$RPC_PARAMS" -e 'indexes=@')
for i in $indexes; do
service=$(jsonfilter -s "$RPC_PARAMS" -e "@[$i]")
signature=''
IFS=$'\n\t'
for line in $(ubus -v list "$service" | tail -n +2); do
signature="${signature:+$signature, }$line"
done
IFS=$' \n\t'
signatures="${signatures:+$signatures, }\"$service\": { $signature }"
done
printf '{ "jsonrpc": "2.0", "id": "%s", "result": { %s } }' \
"$RPC_ID" "$signatures"
fi
;;
*)
error -32601 "Method not found"
;;
esac
}
# - read body from stdin (either an object or array)
# - process each item if it is an array
body=$(cat)
type=$(jsonfilter -s "$body" -t '@')
printf 'Content-Type: application/json\r\n\r\n'
if [ "$type" = "array" ]; then
first=true
printf '['
jsonfilter -s "$body" -e '@.*' | while read request ; do
# join response with ',' and the first should be omitted
if ! $first; then
printf ','
else
first=false
fi
# process each request
process "$request"
done
printf ']'
else
process "$body"
fi
然后再进行一些额外设置:
chmod +x /www/cgi-bin/ubus.sh
uci set luci.main.ubuspath='/cgi-bin/ubus.sh'
uci commit
service caddy enable
servcie caddy start可以用这个命令检查配置文件是否存在语法错误:
caddy validate --config /etc/caddy/Caddyfile配置dnsmasq
目前caddy服务已经正常运行,但是还存在一个问题:用于访问服务的域名只做了AAAA记录或者指向AAAA记录的CNAME,在使用wireguard连回家的场景中,由于客户端只有IPv4地址,很显然会访问失败。此时我们希望这些域名解析到路由器的内网IPv4地址,本例中,路由器内网IPv4地址 为 10.89.2.1,通过immortalwrt自带的dnsmasq即可实现。
编辑 /etc/config/dhcp,在dsnmasq一节追加解析条目,修改后大致如下:
config dnsmasq
option domainneeded '1'
option localise_queries '1'
option rebind_protection '1'
option rebind_localhost '1'
option local '/lan/'
option domain 'lan'
option expandhosts '1'
option min_cache_ttl '3600'
option use_stale_cache '3600'
option nonegcache '1'
option authoritative '1'
option readethers '1'
option leasefile '/tmp/dhcp.leases'
option localservice '1'
option ednspacket_max '1232'
option port '53'
option cachesize '150'
option dns_redirect '1'
option nonwildcard '0'
option allservers '1'
list address '/lab.tccmu.com/10.89.2.1'
list address '/blog.tccmu.com/10.89.2.1'最后两行为新增内容,作用是将这两个域名解析到路由器地址10.89.2.1。这两个域名一个对应路由器LUCI,一个对应路由器上通过docker搭建的网站,所以地址当然要解析到路由器自身。如果服务搭建在局域网中其他设备,修改为对应IP即可。
最后重启dnsmasq服务,修改就生效了。wireguard客户端配置文件中记得把DNS服务器设置为10.89.2.1。
service dnsmasq restart