🤞 Sync 2026-09-27 22:16:29

This commit is contained in:
github-actions[bot] committed 2026-09-27 22:16:29 +08:00
1 parent 8ede4a8320
commit 618f51e9dc
459 files changed
+44817 -2191

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
# 保证脚本 / lua / shell 配置以 LF 入库, 避免 Windows 编辑后 CRLF 上传杀死 busybox ash
* text=eol=lf
# 二进制例外
*.png binary
*.jpg binary
*.ico binary
*.bin binary
*.zip binary
*.ipk binary
+2
View File
@@ -0,0 +1,2 @@
/docs/
*.md
+7 -5
View File
@@ -1,4 +1,5 @@
# Copyright (C) 2016 Openwrt.org
# luci-app-caddy - LuCI 管理 Caddy (反向代理 / ACME DNS-01 / Aliyun DDNS)
# Copyright (C) 2026 andangel - https://github.com/andangel/luci-app-caddy
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
@@ -9,17 +10,18 @@ PKG_NAME:=luci-app-caddy
LUCI_TITLE:=LuCI support for caddy
LUCI_DEPENDS:=+caddy
PKG_VERSION:=1.0
PKG_RELEASE:=2
PKG_VERSION:=2.0
PKG_RELEASE:=3
define Package/$(PKG_NAME)/conffiles
/etc/caddy
/etc/config/caddy
/etc/caddy/Caddyfile
endef
define Package/$(PKG_NAME)/postrm
#!/bin/sh
[ -f /etc/caddy/Caddyfile ] && rm -rf /etc/caddy/Caddyfile >/dev/null 2>&1
return 0
return 0
endef
define Package/$(PKG_NAME)/postinst
+187 -22
View File
@@ -1,30 +1,38 @@
module("luci.controller.caddy", package.seeall)
local DL_DEFAULT = "https://github.com/andangel/luci-app-caddy/releases/latest/download/caddy_linux_arm64"
local DL_API = "https://api.github.com/repos/andangel/luci-app-caddy/releases/latest"
function index()
entry({"admin", "nas"}, firstchild(), _("NAS") , 45).dependent = false
if not nixio.fs.access("/etc/config/caddy") then
return
end
local page = entry({"admin", "nas", "caddy"}, alias("admin", "nas", "caddy", "basic"), _("Caddy"), 20)
page.dependent = true
page.acl_depends = { "luci-app-caddy" }
entry({"admin", "nas"}, firstchild(), "NAS", 44).dependent = false
entry({"admin", "nas", "caddy", "basic"}, cbi("caddy/caddy"), _("基本设置"), 1).leaf = true
entry({"admin", "nas", "caddy", "log"}, cbi("caddy/caddy_log"), _("日志"), 2).leaf = true
entry({"admin", "nas", "caddy", "caddy_status"}, call("caddy_status")).leaf = true
entry({"admin", "nas", "caddy", "get_log"}, call("get_log")).leaf = true
entry({"admin", "nas", "caddy", "clear_log"}, call("clear_log")).leaf = true
entry({"admin", "nas", "caddy", "admin_info"}, call("admin_info")).leaf = true
entry({"admin", "network", "caddy"}, alias("admin", "network", "caddy", "basic"), _("Caddy"), 30)
entry({"admin", "network", "caddy", "basic"}, cbi("caddy/caddy"), _("状态"), 1).leaf = true
entry({"admin", "network", "caddy", "domain"}, cbi("caddy/domain"), _("域名"), 2).leaf = true
entry({"admin", "network", "caddy", "host"}, cbi("caddy/host"), _("主机"), 3).leaf = true
entry({"admin", "network", "caddy", "caddyfile"}, cbi("caddy/caddyfile"), _("配置"), 4).leaf = true
entry({"admin", "network", "caddy", "log"}, cbi("caddy/caddy_log"), _("日志"), 5).leaf = true
entry({"admin", "network", "caddy", "caddy_status"}, call("caddy_status")).leaf = true
entry({"admin", "network", "caddy", "get_log"}, call("get_log")).leaf = true
entry({"admin", "network", "caddy", "clear_log"}, call("clear_log")).leaf = true
entry({"admin", "network", "caddy", "admin_info"}, call("admin_info")).leaf = true
entry({"admin", "network", "caddy", "download"}, call("download")).leaf = true
entry({"admin", "network", "caddy", "download_status"}, call("download_status")).leaf = true
entry({"admin", "network", "caddy", "download_ctl"}, call("download_ctl")).leaf = true
end
function caddy_status()
local e={}
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local port = tonumber(uci:get_first("caddy", "caddy", "port"))
e.port = (port or 12311)
-- Web 界面按钮地址: 取第一个 proxy host 的 域名:端口, 没有则留空
local weburl = ""
local pname = uci:get_first("caddy", "proxy")
if pname then
local pdom = uci:get("caddy", pname, "domain")
local pprt = uci:get("caddy", pname, "port")
if pdom then
weburl = "http://" .. pdom .. (pprt and (":" .. pprt) or "")
end
end
e.weburl = weburl
e.running=luci.sys.call("pidof caddy >/dev/null")==0
local tagfile = io.open("/tmp/caddy_time", "r")
if tagfile then
@@ -57,7 +65,8 @@ function caddy_status()
e.caddyram = "unknown"
end
local command4 = io.popen("([ -s /tmp/caddy.tag ] && cat /tmp/caddy.tag ) || (echo `$(uci -q get caddy.@caddy[0].bin_dir) -v |awk '{print $1}' | sed 's/[^0-9.]*//g'` > /tmp/caddy.tag && cat /tmp/caddy.tag)")
-- 官方 CLI: caddy version (输出形如 v2.11.4 h1:xxx, 取版本号段)
local command4 = io.popen("$(uci -q get caddy.@caddy[0].bin_dir) version 2>/dev/null | awk '{print $1}'")
e.caddytag = command4:read("*all")
command4:close()
if e.caddytag == "" then
@@ -83,9 +92,165 @@ function clear_log()
end
function admin_info()
local validate = luci.sys.exec("$(uci -q get caddy.@caddy[0].bin_dir) validate --config /etc/caddy/Caddyfile --adapter caddyfile 2>&1")
-- HOME=/root 兜底: LuCI CGI (uhttpd) 环境没 $HOME/$XDG_CONFIG_HOME,
-- caddy validate 会 warn "unable to determine directory for user configuration; falling back to current directory".
-- 显式给个家目录就没这条 warn; --config 已显式传, 结果不受 HOME 影响.
local validate = luci.sys.exec("env HOME=/root $(uci -q get caddy.@caddy[0].bin_dir) validate --config /etc/caddy/Caddyfile --adapter caddyfile 2>&1")
luci.http.prepare_content("application/json")
luci.http.write_json({ validate = validate })
end
function download_status()
local st = { state = "idle", size = 0, total = 0, speed = 0, msg = "" }
local f = io.open("/tmp/caddy_dl.status", "r")
if f then
for line in f:lines() do
local k, v = line:match("^([%a_]+)=(.*)$")
if k then st[k] = v end
end
f:close()
end
st.size = tonumber(st.size) or 0
st.total = tonumber(st.total) or 0
st.speed = tonumber(st.speed) or 0
-- 看门狗: 活动状态下 status 由下载进程每秒刷新一次并带 ts 时间戳 (该 lua 桥无 os.stat, 不靠文件 mtime),
-- 若 >60s 没刷新说明进程已丢失 (被 OOM/清理 SIGKILL 不执行 trap), 状态文件残留会导致 UI 永远下载中
local actives = { downloading = 1, paused = 1, verifying = 1 }
local ts = tonumber(st.ts) or 0
if actives[st.state] and ts > 0 and os.time() - ts > 60 then
st.state = "lost"
st.speed = 0
st.msg = (st.size > 0) and "下载进程已丢失 (保留进度, 点[下载并重启]续传)" or "状态异常 (点[下载并重启]重新开始)"
end
luci.http.prepare_content("application/json")
luci.http.write_json(st)
end
-- 暂停/继续/停止: 写一个控制命令到 /tmp/caddy_dl.ctl, 下载进程每秒轮询处理.
-- 暂停=SIGSTOP 冻结 curl (连接仍挂着, 不额外撞 GitHub); 继续=SIGCONT; 停止=杀 curl 留已下部分.
function download_ctl()
-- 本 runtime (Luci openwrt-25.12 / ucode 桥) 的 luci.http 不提供 readrequest, 用 formvalue 读 POST 字段
local act = tostring(luci.http.formvalue("act") or "")
local ok, msg = false, ""
if act == "pause" or act == "resume" or act == "stop" then
local f = io.open("/tmp/caddy_dl.ctl", "w")
if f then f:write(act .. "\n"); f:close(); ok = true; msg = "ok" end
else
msg = "unknown act: " .. act
end
luci.http.prepare_content("application/json")
luci.http.write_json({ ok = ok, msg = msg })
end
function download()
local uci = require "luci.model.uci".cursor()
local bin = uci:get("caddy", "caddy", "bin_dir") or "/usr/sbin/caddy"
local u = uci:get("caddy", "caddy", "download_url")
local url = (u and u ~= "") and u or DL_DEFAULT
local use_sha = (url == DL_DEFAULT) and "1" or "0"
local sq = function(s) return s:gsub("'", "'\\''") end
local L = {
"#!/bin/sh",
"URL='" .. sq(url) .. "'",
"TMP='/tmp/caddy.dl'",
"STATUS='/tmp/caddy_dl.status'",
"CTL='/tmp/caddy_dl.ctl'",
"BIN='" .. sq(bin) .. "'",
"USE_SHA=" .. use_sha,
"API='" .. DL_API .. "'",
"RECEIPT='/tmp/caddy_dl.done'",
"CURLPID=0; PAUSED=0; STOPTED=0",
"rm -f \"$CTL\"",
"",
"# 写临时文件再原子 mv, 防状态端点读到半截文件而把 UI 误渲染成 idle",
"ws(){ printf 'state=%s\\nsize=%s\\ntotal=%s\\nspeed=%s\\nmsg=%s\\nts=%s\\n' \"$1\" \"$2\" \"$3\" \"$4\" \"$5\" $(date +%s) > \"$STATUS.tmp\"; mv -f \"$STATUS.tmp\" \"$STATUS\"; }",
"",
"PIDFILE='/tmp/caddy_dl.pid'",
"# 去重锁: 已有存活实例则直接退出 (防清理漏杀导致多实例竞争写同一文件 / 互相覆盖 status)",
"if [ -f \"$PIDFILE\" ]; then OLD=$(cat \"$PIDFILE\" 2>/dev/null); if [ -n \"$OLD\" ] && kill -0 \"$OLD\" 2>/dev/null; then exit 0; fi; fi",
"echo $$ > \"$PIDFILE\"",
"trap 'rm -f \"$PIDFILE\"' EXIT",
"",
"# 取总大小: 发 Range bytes=0-0 只下 1 字节, 从 Content-Range: bytes 0-0/TOTAL 抽分母 (比完整 HEAD 稳); GitHub 抖则重试 3 次",
"TOT=0",
"i=0",
'while [ "$i" -lt 3 ]; do',
"TOT=$(curl -s -L -r 0-0 --connect-timeout 10 -D - -o /dev/null \"$URL\" 2>/dev/null | tr -d '\\r' | grep -i 'Content-Range:' | grep -oE '[0-9]+/[0-9]+' | awk -F/ '{print $2}' | head -1)",
"if [ -n \"$TOT\" ] && [ \"$TOT\" -gt 0 ] 2>/dev/null; then break; fi",
"i=$((i+1)); sleep 2",
"done",
"ws downloading $(wc -c < \"$TMP\" 2>/dev/null || echo 0) \"$TOT\" 0 \"\"",
"",
"# 已下载收据: 上次下载且 SHA 通过时, 记下当时的期望 sha (写入在成功替换后). 收据在 /tmp, 重启即清空 → 重启后再点会重新下载(拉到新版).",
"# 本次点击若 API 最新 sha == 收据 == 当前二进制 sha, 说明盘上的就是刚下载的版本 → 不重复下载, 直接重启",
"SKIP=0",
"BINSIZE=$(wc -c < \"$BIN\" 2>/dev/null || echo 0)",
"if [ \"$USE_SHA\" -eq 1 ] && [ -f \"$RECEIPT\" ]; then",
"ws verifying \"$BINSIZE\" \"$BINSIZE\" 0 '核对已下载版本...'",
"API_NOW=$(curl -L -sS --connect-timeout 10 --max-time 20 \"$API\" | grep -oE 'sha256:[0-9a-f]{64}' | head -1 | sed 's/sha256://')",
"BIN_SHA=$(sha256sum \"$BIN\" 2>/dev/null | awk '{print $1}')",
"if [ \"${#API_NOW}\" -ge 64 ] && [ \"${#BIN_SHA}\" -ge 64 ] && [ \"$(cat \"$RECEIPT\" 2>/dev/null)\" = \"$API_NOW\" ] && [ \"$BIN_SHA\" = \"$API_NOW\" ]; then SKIP=1; fi",
"fi",
"if [ $SKIP -eq 1 ]; then /etc/init.d/caddy restart >/dev/null 2>&1; ws done \"$BINSIZE\" \"$BINSIZE\" 0 '已是上次下载的最新版, 无需重复下载, 已直接重启 Caddy'; exit 0; fi",
"",
"# 直接后台运行 curl (不经函数包一层), 使 $! 即真实 curl pid, 暂停/停止(信号)才能直接作用于 curl",
"# GitHub 直连不稳: 除网络类错误外, HTTP/2 reset / -C 续传冲突等瞬时错误也按 --retry 自动再来",
"# speed-limit/speed-time: 低于 ~100B/s 持续 45s 视为假死, 触发重试 (比干等 5 分钟超时快)",
"curl -L -C - -sS --retry 4 --retry-delay 8 --retry-all-errors --connect-timeout 20 --speed-limit 100 --speed-time 45 -o \"$TMP\" \"$URL\" >> /tmp/caddy_dl.log 2>&1 &",
"CURLPID=$!; RC=",
"LAST=$(wc -c < \"$TMP\" 2>/dev/null || echo 0); LTIME=$(date +%s)",
"# 主循环: 每秒刷新进度 + 处理暂停/继续/停止控制命令 (与下载解耦, 控制端点只写 $CTL 文件)",
'while kill -0 "$CURLPID" 2>/dev/null; do',
'if [ -f "$CTL" ]; then',
' CMD=$(cat "$CTL" 2>/dev/null); rm -f "$CTL"',
' if [ "$CMD" = "pause" ] && [ $PAUSED -eq 0 ]; then kill -STOP $CURLPID 2>/dev/null; PAUSED=1',
' elif [ "$CMD" = "resume" ] && [ $PAUSED -eq 1 ]; then kill -CONT $CURLPID 2>/dev/null; PAUSED=0',
' elif [ "$CMD" = "stop" ]; then kill $CURLPID 2>/dev/null; STOPTED=1',
' fi',
'fi',
'if [ $PAUSED -eq 1 ]; then ws paused $(wc -c < "$TMP" 2>/dev/null || echo 0) "$TOT" 0 ""; sleep 1; continue; fi',
'SZ=$(wc -c < "$TMP" 2>/dev/null || echo 0)',
'NOW=$(date +%s); DT=$((NOW - LTIME)); SP=0',
'if [ "$DT" -gt 0 ]; then SP=$(( (SZ - LAST) / DT )); fi',
'LAST=$SZ; LTIME=$NOW',
'ws downloading "$SZ" "$TOT" "$SP" ""',
"# 僵尸检测: curl 死掉但未 wait 收尸时 kill -0 仍成功会卡死主循环, /proc 状态 Z 即已死; 已下满按成功, 否则按失败",
'CS=$(awk "{print $3}" /proc/$CURLPID/stat 2>/dev/null)',
'if [ "$CS" = "Z" ]; then if [ "$TOT" -gt 0 ] 2>/dev/null && [ "$SZ" -ge "$TOT" ] 2>/dev/null; then RC=0; else RC=128; fi; break; fi',
"sleep 1",
"done",
"# 主循环已判定 (z 检测设了 RC) 则不覆盖; 否则 wait 收尸取真实退出码",
'if [ -z "$RC" ]; then wait $CURLPID 2>/dev/null; RC=$?; fi',
'SZ=$(wc -c < "$TMP" 2>/dev/null || echo 0)',
"if [ $STOPTED -eq 1 ]; then ws stopped \"$SZ\" \"$TOT\" 0 '已停止 (保留已下载部分, 点[下载并重启]可续传)'; exit 0; fi",
"if [ \"$RC\" -ne 0 ]; then ws error \"$SZ\" \"$TOT\" 0 '下载失败 (GitHub 波动/网络), 点[下载并重启]可自动续传重试'; exit 0; fi",
"if [ \"$USE_SHA\" -eq 1 ]; then",
"API_SHA=$(curl -L -sS --connect-timeout 10 --max-time 20 \"$API\" | grep -oE 'sha256:[0-9a-f]{64}' | head -1 | sed 's/sha256://')",
"LOC_SHA=$(sha256sum \"$TMP\" 2>/dev/null | awk '{print $1}')",
'if [ "${#API_SHA}" -ge 64 ] && [ "${#LOC_SHA}" -ge 64 ] && [ "$API_SHA" != "$LOC_SHA" ]; then',
'rm -f "$TMP"; ws error "$SZ" "$TOT" 0 "SHA256 不匹配, 文件可能损坏, 已丢弃, 请重试"; exit 0',
"fi",
"fi",
"# curl -o 落盘不带可执行位, -h 前先 chmod, 否则任何架构 (包括正确 arm64) 都会 Permission denied",
"chmod +x \"$TMP\" 2>/dev/null",
"if ! \"$TMP\" -h >/dev/null 2>&1; then rm -f \"$TMP\"; ws error \"$SZ\" \"$TOT\" 0 '文件无法执行, 可能架构不匹配 (应为 linux/arm64 静态二进制)'; exit 0; fi",
"if ! mv -f \"$TMP\" \"$BIN\"; then ws error \"$SZ\" \"$TOT\" 0 '替换 $BIN 失败'; exit 0; fi",
"# 写收据: 本版本已在盘上, 下次点击命中 SKIP 不再重复下载 (仅默认 URL 有 API_SHA)",
"[ \"$USE_SHA\" -eq 1 ] && echo \"$API_SHA\" > \"$RECEIPT\" 2>/dev/null",
"if /etc/init.d/caddy restart >/dev/null 2>&1; then ws done \"$SZ\" \"$TOT\" 0 '下载完成, 已替换并重启 Caddy'; else ws done \"$SZ\" \"$TOT\" 0 '下载完成并已替换, 但重启 Caddy 失败, 请手动 /etc/init.d/caddy restart'; fi",
}
local f = io.open("/tmp/caddy_dl.sh", "w")
f:write(table.concat(L, "\n") .. "\n")
f:close()
os.execute("chmod +x /tmp/caddy_dl.sh")
-- 先清掉旧的下载进程 (脚本本体 + 它遗留的写 caddy.dl 的 curl 子进程, 否则多实例互相覆盖 status)
-- ps 会截断命令行导致漏杀 curl, 故直接扫 /proc/*/cmdline; 用 [.] 自转义 glob 避免匹配到本命令自身
os.execute("for p in /proc/[0-9]*; do c=$(tr '\\0' ' ' < $p/cmdline 2>/dev/null); case \"$c\" in *caddy_dl[.]sh*|*caddy[.]dl*) kill ${p##*/} 2>/dev/null;; esac; done; sleep 1; rm -f /tmp/caddy_dl.status")
-- nohup 脱离 CGI, 页面刷新也不中断
os.execute("nohup sh /tmp/caddy_dl.sh >/dev/null 2>&1 &")
luci.http.prepare_content("application/json")
-- 注意: call 端点内 _ 未注入 (只在 index 阶段有), 这里用字面量; 该消息仅作启动占位, 随即被进度轮询覆盖
luci.http.write_json({ ok = true, msg = "已开始后台下载 (支持断点续传), 自动刷新进度..." })
end
+39 -110
View File
@@ -1,127 +1,56 @@
local m, s
m = Map("caddy", translate("Caddy"), translate("Caddy 是一个可扩展的服务器平台,具有自动 HTTPS 功能的快速且可扩展的多平台 HTTP/1-2-3 Web 服务器") .. "<br/>" .. "项目地址:" .. [[<a href="https://github.com/caddyserver/caddy" target="_blank">]] .. translate("github.com/caddyserver/caddy") .. "</a>&nbsp; &nbsp;&nbsp;" .. " caddy文档:" .. [[<a href="https://caddyserver.com/docs/" target="_blank">]] .. translate("caddyserver.com/docs/") .. [[</a>]])
local m, s, o
m:section(SimpleSection).template = "caddy/caddy_status"
m = Map("caddy", translate("Caddy 状态"),
translate("全局配置: 启用 Caddy / 公共开关 / 程序与日志路径. 反向代理 Host 在 [Host] 标签页增删改查") .. "<br/>" ..
[[<a href="https://github.com/andangel/luci-app-caddy" target="_blank">]] ..
translate("github.com/andangel/luci-app-caddy") .. "</a>&nbsp;&nbsp;" ..
[[<a href="https://caddyserver.com/docs/" target="_blank">]] ..
translate("caddyserver.com/docs/ (Caddy 官方文档)") .. [[</a>]])
m:section(SimpleSection).template = "caddy/caddy_status"
----------------------------------------------------------------------
-- 全局设置 (单例) - JSON 里的 global 对象, 只有一条, 只有"改", 没有增删
----------------------------------------------------------------------
s = m:section(TypedSection, "caddy")
s.addremove = false
s.anonymous = true
o = s:option(Flag, "enabled", translate("Enabled"))
o = s:option(Flag, "enabled", translate("启用 Caddy"))
o.rmempty = false
o.default = 0
o.default = 1
o = s:option(Button, "btnrm", translate("重启"))
o.inputtitle = translate("重启")
o.description = translate("在没有修改参数的情况下快速重新启动一次")
o = s:option(Button, "restart", translate("重启 Caddy"))
o.inputtitle = translate("重启 Caddy")
o.description = translate("按全局 + Host 配置重新生成 Caddyfile 并重启 Caddy")
o.inputstyle = "apply"
o:depends("enabled", "1")
o.write = function()
os.execute("/etc/init.d/caddy restart")
os.execute("/etc/init.d/caddy restart")
end
e=s:option(ListValue,"cmd",translate("启动方式"),
translate("自定义配置文件启动,若不懂参数请勿选择自定义"))
e:value("默认")
e:value("自定义")
o = s:option(TextValue, "caddyfile", translate("Caddyfile配置文件"),
translate("这是caddy的启动配置文件Caddyfile,路径在/etc/caddy/Caddyfile<br>使用的命令是:caddy run --config /etc/caddy/Caddyfile --adapter caddyfile<br>如需设置密码,这里就不能使用明文密码,需要转换<br>转换命令:$(uci -q get caddy.@caddy[0].bin_dir) hash-password --plaintext 新密码"))
o.rows = 3
o.wrap = "off"
o:depends("cmd", "自定义")
o = s:option(Value, "port", translate("端口"))
o.datatype = "and(port,min(1))"
o.default = "12311"
o:depends("cmd", "默认")
o = s:option(Flag,"file_pass", translate("启用 认证"))
o.datatype = "string"
o.default = "0"
o:depends("cmd", "默认")
o = s:option(Value,"file_username", translate("用户名"))
o.datatype = "string"
o.default = "admin"
o:depends("file_pass", "1")
o = s:option(Value,"file_password", translate("密码"))
o.datatype = "string"
o.password = true
o.default = "123456"
o:depends("file_pass", "1")
o = s:option(Flag, "filezip", translate("启用 压缩"))
o.default = 0
o:depends("cmd", "默认")
o = s:option(Flag, "log", translate("启用日志"))
o.default = 1
o:depends("cmd", "默认")
o = s:option(Value, "log_dir", translate("日志路径"),
translate("日志的存放路径,填写完整的路径及日志文件名<br>建议/tmp里,例如:/tmp/caddy/requests.log"))
o.datatype = "string"
o.default = "/tmp/caddy/requests.log"
o:depends("log", "1")
o = s:option(Value, "bin_dir", translate("程序路径"),
translate("caddy二进制的存放路径,填写完整的路径及caddy名称<br>例如:/usr/bin/caddy &nbsp;&nbsp;&nbsp; 例如:/tmp/caddy<br>可自行编译对应架构的二进制程序:<a href='https://www.right.com.cn/forum/forum.php?mod=viewthread&tid=6006472&highlight=caddy' target='_blank'>编译教程</a>"))
o.datatype = "string"
o.default = "/usr/bin/caddy"
o = s:option(Value, "data_dir", translate("指向路径"),
translate("指向一个路径,可在web界面访问你的文件,默认为 /mnt"))
o.datatype = "string"
o.default = "/mnt"
o:depends("cmd", "默认")
o = s:option(Flag, "webdav", translate("启用 webdav"))
o.default = "0"
o:depends("cmd", "默认")
o = s:option(Flag,"webdav_pass", translate("启用 认证"))
o.datatype = "string"
o:depends("webdav", "1")
o.default = "0"
o = s:option(Value,"webdav_username", translate("用户名"))
o.datatype = "string"
o.default = "admin"
o:depends("webdav_pass", "1")
o = s:option(Value,"webdav_password", translate("密码"))
o.datatype = "string"
o.password = true
o.default = "123456"
o:depends("webdav_pass", "1")
o = s:option(Value,"webdav_port", translate("webdav端口"))
o.datatype = "and(port,min(1))"
o.default = "12322"
o:depends("webdav", "1")
o = s:option(Flag, "webzip", translate("启用 压缩"))
o.default = 0
o:depends("webdav", "1")
o = s:option(Value, "webdav_data_dir", translate("webdav指向路径"),
translate("指向一个路径,使用webdav访问你的文件,默认为 /mnt<br>连接地址须加/dav后缀&nbsp;&nbsp;如: 192.168.1.1:12311/dav"))
o.datatype = "string"
o:depends("webdav", "1")
o.default = "/mnt"
o = s:option(Flag, "allow_wan", translate("允许从外网访问"))
o.rmempty = false
o = s:option(Flag, "api", translate("启用 API接口"))
o:depends("cmd", "默认")
o = s:option(Button, "admin_info", translate("检测配置文件"),
translate("验证Caddyfile配置文件是否正确,它会模拟启动caddy<br>但是并不会真的启动,会列出详细信息,以便修正配置文件"))
o = s:option(Button, "validate", translate("检测配置文件"))
o.rawhtml = true
o.template = "caddy/admin_info"
--- 主程序 ---
o = s:option(Value, "bin_dir", translate("主程序"),
translate("caddy 二进制完整路径"))
o.default = "/usr/sbin/caddy"
o.placeholder = "/usr/sbin/caddy"
o = s:option(Value, "download_url", translate("下载地址(可选)"),
translate("留空用默认 GitHub latest Release. 填了则用该 URL (可换镜像/固定版本). 下载带重试+SHA256 校验"))
o.placeholder = "https://github.com/andangel/luci-app-caddy/releases/latest/download/caddy_linux_arm64"
o = s:option(Button, "download", translate("下载并重启"))
o.rawhtml = true
o.template = "caddy/download"
--- 日志 ---
o = s:option(Value, "log_dir", translate("日志"),
translate("各 Host 的访问日志写入此文件, 建议放 /tmp 以免占用闪存"))
o.default = "/tmp/caddy/requests.log"
o.placeholder = "/tmp/caddy/requests.log"
return m
@@ -0,0 +1,45 @@
local m, s, o
-- 手动 Caddyfile 编辑: 路径从 UCI caddy_file 取 (默认 /etc/caddy/Caddyfile),
-- 与 init.d 里 G_FILE 一致, 避免用户在 [状态] 页改 bin_dir/log_dir 后此处仍写死路径.
local uci = require("luci.model.uci").cursor()
-- get_first 返回的是 section name 而非字段值, 必须用 get(cfg, section, key) 取字段
local cf_file = uci:get("caddy", "caddy", "caddy_file") or "/etc/caddy/Caddyfile"
local cur_caddyfile
local cfh = io.open(cf_file, "r")
if cfh then
cur_caddyfile = cfh:read("*a")
cfh:close()
end
m = Map("caddy", translate("Caddy 配置 (手动)"),
translate("直接编辑 当前 Caddyfile (" .. cf_file .. "). 默认载入当前文件. 注意: 在 [主机] 页重启 Caddy 时会按表单重新生成并覆盖此页内容"))
s = m:section(TypedSection, "caddy")
s.addremove = false
s.anonymous = true
o = s:option(TextValue, "caddyfile", translate("Caddyfile"),
translate("完整 Caddyfile. 要 Caddy 的任何功能 (forward_proxy / encode / basicauth / reverse_proxy ...) 都写在这里<br/>"
.. "如需设置密码, 用命令生成 hash: caddy hash-password --plaintext 新密码"))
o.default = cur_caddyfile
o.rows = 25
o.wrap = "off"
o = s:option(Button, "apply_caddyfile", translate("写入并重启 Caddy"))
o.inputtitle = translate("写入并重启 Caddy")
o.description = translate("把上方内容写入当前 Caddyfile 并重启 Caddy (下次 [主机] 页重启 Caddy 会覆盖)")
o.inputstyle = "apply"
o.write = function(_, value)
-- 经 /tmp 中转, 避免写一半坏掉 Caddyfile; 写完 mv 原子替换
local tmp = "/tmp/caddyfile_manual.tmp"
local f = io.open(tmp, "w")
if f then
f:write(value or "")
f:close()
os.execute(string.format("cp '%s' '%s' && rm -f '%s' && /etc/init.d/caddy restart", tmp, cf_file, tmp))
end
end
return m
@@ -0,0 +1,65 @@
local m, s, o
local uci = require("luci.model.uci").cursor()
m = Map("caddy", translate("Caddy Domain"),
translate("域名列表 (增删改查). 每条域名对应一个阿里云账号 (AK), 该域名的 TLS 证书和 DDNS 都用这份 AK"))
--- Domain (多条) - 增删改查, 每条 = 一个域名 + 对应的阿里云 AK + DDNS 参数 ---
--- anonymous: 用户点"添加"即可, 无需填 UCI 标识符 (域名含 . 不合 UCI 命名规范) ---
s = m:section(TypedSection, "domain")
s.addremove = true
s.anonymous = true
s.nodeps = true
s.cfgtitle = "domain_name"
o = s:option(Value, "domain_name", translate("域名"),
translate("例如 example.com. Host 页的监听域名下拉从这里取"))
o.rmempty = false
o = s:option(Value, "key_id", translate("阿里云 Access Key ID"),
translate("该域名的 AliDNS 写权限 AK. 需要 AliyunDNS 的 Read/Write 权限"))
o.rmempty = false
o = s:option(Value, "key_secret", translate("阿里云 Access Key Secret"))
o.password = true
o.rmempty = false
--- DDNS 子块: 勾选 ddns_on 才显示以下字段 ---
o = s:option(Flag, "ddns_on", translate("启用 DDNS (更新 A 记录)"),
translate("勾选 = 生成的 Caddyfile 包含该域名的 aliyun_ddns 块, 定期把公网 IP 写入 A 记录"))
o.default = 0
o = s:option(TextValue, "ddns_records", translate("额外记录名 (每行一条)"),
translate("每行一个子域名前缀, 用换行分隔, 不要带主域名. 例如填 mail 会更新 mail.example.com, 填 www 会更新 www.example.com. 留空 = 只更新 @ 主域 A 记录"))
o.rows = 3
o.wrap = "off"
o:depends("ddns_on", "1")
o = s:option(ListValue, "ddns_wan_iface", translate("IP 获取方式"),
translate("public = 公网查询 (api.ipify.org 等取出口 IP); 选接口 = 直读该网卡 IPv4. 一般填 pppoe-wan, 下拉自动列出路由器实际接口"))
o:value("public")
local netf = io.popen("ls /sys/class/net 2>/dev/null")
if netf then
for line in netf:lines() do
local name = line:match("%S+")
-- 只保留 WAN 口 (名字含 wan, 如 pppoe-wan / eth0-wan), 过滤掉 LAN/WiFi/桥等无关设备
if name and name:lower():find("wan", 1, true) then
o:value(name)
end
end
netf:close()
end
o.default = "pppoe-wan"
o:depends("ddns_on", "1")
o = s:option(Value, "ddns_check_interval", translate("检查间隔"),
translate("多久检查一次 A 记录是否需要更新"))
o.default = "5m"
o:depends("ddns_on", "1")
o = s:option(Value, "ddns_ttl", translate("TTL"),
translate("A 记录 TTL, 影响公网 DNS 生效速度"))
o.default = "60s"
o:depends("ddns_on", "1")
return m
@@ -0,0 +1,80 @@
local m, s, gs, o
m = Map("caddy", translate("Caddy Host"),
translate("反向代理 Host 列表 (增删改查). 每条 Host 对应 Caddyfile 里一段 域名:端口 { ... }"))
----------------------------------------------------------------------
-- 通用开关 (全局, 单例 @caddy[0]). 放在 Host 列表上方: 跨所有 Host 生效,
-- 不是某一条 Host 的属性. 后续全局项 (如日志开关) 也加在这个 section 里
----------------------------------------------------------------------
gs = m:section(TypedSection, "caddy")
gs.addremove = false
gs.anonymous = true
gs.nodeps = true
o = gs:option(Flag, "disable_redirect", translate("保留 HTTP (禁用自动跳转 HTTPS)"),
translate("勾选 = Caddyfile 写 auto_https disable_redirects; 不勾 = 不写, Caddy 默认 HTTP 自动 301 到 HTTPS"))
o.default = 1
o = gs:option(Flag, "log_on", translate("请求日志"),
translate("勾选 = 各 Host 写访问日志到 [状态] 页的 日志 路径; 不勾 = Caddyfile 不生成 log 块, 不写日志, 省资源"))
o.default = 1
----------------------------------------------------------------------
-- Host (多条) - 增删改查, NPM 的 Proxy Hosts
----------------------------------------------------------------------
s = m:section(TypedSection, "proxy")
s.addremove = true
s.anonymous = true
s.nodeps = true
s.cfgtitle = "name"
o = s:option(Value, "name", translate("名称"),
translate("给这条 Host 起个名, 便于区分"))
o.rmempty = false
o = s:option(Flag, "enabled", translate("启用"))
o.default = 1
o = s:option(ListValue, "domain", translate("监听域名"),
translate("从 [Domain] 页的域名列表中选择. 每个域名列两个选项: 裸域 (签 example.com 一张证书, 覆盖裸域) 和 *. 通配符 (签 裸域+通配符 各一张, 覆盖裸域+全部子域, 等价 acme.sh 两条 -d). 只要 Domain 页填了 AK 就一律走 DNS-01, 不依赖 80/443 入站. 需先在 Domain 页创建域名"))
o.rmempty = false
-- 从 UCI 文件读 domain_name: 直接读 /etc/config/caddy, 匹配有引号和没引号两种风格.
-- 比 grep + sed 更稳 (不靠 shell pipeline 转义), 比 uci cursor 更简单 (不依赖 cursor API 版本差异).
local f = io.open("/etc/config/caddy", "r")
if f then
for line in f:lines() do
local dn = line:match("option%s+domain_name%s+'([^']+)'")
or line:match("option%s+domain_name%s+([%w%.%-]+)")
if dn and #dn > 0 then
o:value(dn)
o:value("*." .. dn)
end
end
f:close()
end
o = s:option(Value, "port", translate("监听端口"),
translate("非标端口可防指纹扫描, 例如 443 / 8443"))
o.datatype = "and(port,min(1))"
o.default = "443"
o = s:option(Value, "target", translate("转发目标"),
translate("内网后端地址, 形如 192.168.1.x:8080"))
o.rmempty = false
o = s:option(ListValue, "tls_cert", translate("TLS 证书"),
translate("auto = Caddy 自动签发+自动续签 (Domain 页填了 AK 走 DNS-01, 不依赖 80/443 入站); off = 纯 HTTP 不加密 (内网调试用)"))
o.default = "auto"
o:value("auto")
o:value("off")
o = s:option(Value, "api_key", translate("API Key (可选)"),
translate("留空 = Caddy 不做 key 校验 (交给后端); 填值 = Caddy 层校验 Authorization: Bearer <key>"))
o = s:option(TextValue, "extra", translate("自定义指令 (可选)"),
translate("Caddyfile 语法, 原样插入此 Host 块末尾. 用于 path 路由 / rate_limit / encode / basicauth / handle 等表单没覆盖的指令"))
o.rows = 6
o.wrap = "off"
return m
@@ -4,7 +4,7 @@
{
btn.disabled = true;
btn.value = '<%:Collecting data...%>';
XHR.get('<%=luci.dispatcher.build_url("admin", "nas", "caddy", "admin_info")%>',
XHR.get('<%=luci.dispatcher.build_url("admin", "network", "caddy", "admin_info")%>',
null,
function(x,rv)
{
+154 -111
View File
@@ -1,124 +1,167 @@
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script type="text/javascript">//<![CDATA[
/* caddy_log: 纯 LuCI 风格, XHR + DOM 拼接, 无外部框架 (与 caddy_status.htm 一致) */
var LOG_URL = '<%=url("admin/network/caddy/get_log")%>';
var CLEAR_URL = '<%=url("admin/network/caddy/clear_log")%>';
var logPaused = false;
var logFilter = 'all';
function logStatusClass(c) {
if (c >= 500) return 'err';
if (c >= 400) return 'warn';
return 'ok';
}
function logEscape(s) {
var d = document.createElement('div');
d.appendChild(document.createTextNode(String(s)));
return d.innerHTML;
}
function logRender() {
var tb = document.getElementById('caddy_log_list');
var cnt = document.getElementById('caddy_log_count');
if (!tb) return;
XHR.get(LOG_URL, null, function(x) {
if (!x || x.status !== 200) return;
var logs = [];
x.responseText.split('\n').filter(Boolean).forEach(function(l, i) {
try {
var o = JSON.parse(l);
o.__id = i;
logs.push(o);
} catch (e) { }
});
logs.reverse();
if (logFilter !== 'all')
logs = logs.filter(function(l) { return logStatusClass(l.status) === logFilter; });
if (cnt) cnt.innerHTML = logs.length + ' <em><%:条日志%></em>';
if (logs.length === 0) {
tb.innerHTML = '<em><%:暂无日志%></em>';
return;
}
tb.innerHTML = logs.map(function(l) {
var h = (l.request && l.request.headers) || {};
var rows = [];
if (l.ts)
rows.push(['时间', new Date(l.ts * 1000).toLocaleString()]);
if (l.request)
rows.push(['访问', l.request.host + l.request.uri]);
if (l.request && l.request.method)
rows.push(['方法', l.request.method]);
if (l.status)
rows.push(['状态', l.status]);
var ip = (h['Cf-Connecting-Ip'] && h['Cf-Connecting-Ip'][0]) || (l.request && l.request.client_ip);
if (ip)
rows.push(['IP', ip]);
var ua = h['User-Agent'] ? h['User-Agent'][0] : null;
if (ua)
rows.push(['标识', ua]);
if (l.duration)
rows.push(['耗时', (l.duration * 1000).toFixed(2) + ' ms']);
return '<div class="log-card ' + logStatusClass(l.status) + '">' +
rows.map(function(r) {
return '<div class="log-row"><span class="log-label">' + logEscape(r[0]) + ':</span>' + logEscape(r[1]) + '</div>';
}).join('') +
'<div class="log-row"><input type="button" class="btn cbi-button view-json" data-idx="' + l.__id + '" value="<%:查看原始 JSON%>" /></div>' +
'</div>';
}).join('');
var list = document.getElementById('caddy_log_list');
list.querySelectorAll('.view-json').forEach(function(btn) {
btn.onclick = function() {
logShowJson(logs[+btn.getAttribute('data-idx')]);
};
});
});
}
function logShowJson(obj) {
if (!obj) return;
var copy = {};
for (var k in obj) { if (k !== '__id') copy[k] = obj[k]; }
document.getElementById('log_modal_body').textContent = JSON.stringify(copy, null, 2);
document.getElementById('log_modal').style.display = 'flex';
}
function logCloseModal() {
document.getElementById('log_modal').style.display = 'none';
}
setInterval(function() {
if (!logPaused) logRender();
}, 3000);
document.addEventListener('DOMContentLoaded', function() {
logRender();
document.getElementById('log_toggle').onclick = function() {
logPaused = !logPaused;
this.value = logPaused ? '<%:继续刷新%>' : '<%:暂停刷新%>';
};
document.getElementById('log_clear').onclick = function() {
XHR.get(CLEAR_URL, null, function() {
var tb = document.getElementById('caddy_log_list');
var cnt = document.getElementById('caddy_log_count');
if (tb) tb.innerHTML = '<em><%:暂无日志%></em>';
if (cnt) cnt.innerHTML = '0 <em><%:条日志%></em>';
});
};
document.getElementById('log_filter').onchange = function() {
logFilter = this.value;
logRender();
};
});
//]]></script>
<style>
:root { --bg:#ffffff; --card:#f6f8fa; --text:#222; --border:#ddd; --muted:#666; }
@media (prefers-color-scheme: dark) { :root { --bg:#0f1115; --card:#1b1e24; --text:#e5e7eb; --border:#333; --muted:#9ca3af; } }
body { background:var(--bg); color:var(--text); margin:0; }
#app { all: unset; display:block; width:100%; padding:12px; font-family:system-ui,-apple-system; text-align:left; }
.toolbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
.toolbar select { margin-left:4px; }
button { border-radius:6px; padding:6px 10px; border:1px solid var(--border); background:transparent; color:var(--text); cursor:pointer; transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.1s ease; }
button:active { transform: scale(0.95); box-shadow: inset 0 2px 4px rgba(0,0,0,0.2); background: rgba(0,0,0,0.05); }
button.toggle-btn { background:#d0a026; color:#fff; border:none; }
button.clear-log { background:#e74c3c; color:#fff; border:none; }
button.view-json { background:#60a5fa; color:#fff; border:none; }
.log-card { all: unset; display:block; width:100%; padding:12px; margin-bottom:12px; background:var(--card); border-radius:10px; text-align:left; box-sizing:border-box; overflow:hidden; border-width:2px; border-style:solid; border-left-color:#888; }
.log-card.ok { border-color:#2ecc71; }
.log-card.warn { border-color:#f39c12; }
.log-card.err { border-color:#e74c3c; }
.log-inner { all: unset; display:block; width:100%; text-align:left; overflow-wrap:break-word; word-break:break-word; }
.log-row { all: unset; display:flex; width:100%; font-size:13px; line-height:1.8; word-break:break-all; margin-bottom:2px; text-align:left; align-items:flex-start; }
.log-row .label { all: unset; flex-shrink:0; color:var(--muted); margin-right:8px; }
.log-row a { color:#2563eb; text-decoration:underline; font-size:13px; }
.modal { display:none; position:fixed; z-index:9999; left:0; top:0; width:100%; height:100%; background-color: rgba(0,0,0,0.5); justify-content:center; align-items:center; overflow:auto; }
.modal.show { display:flex; }
.modal-content { background:#fff; border-radius:6px; max-width:700px; width:80%; max-height:70vh; overflow:auto; white-space: pre; position:relative; display:flex; flex-direction:column; }
.modal-header { display:flex; justify-content:space-between; align-items:center; padding:10px 16px; border-bottom:1px solid #ccc; background:#f3f3f3; border-top-left-radius:6px; border-top-right-radius:6px; flex-shrink:0; }
.modal-header h3 { margin:0; font-size:16px; }
.modal-close { padding:4px 8px; border:none; border-radius:6px; background:#393fec; color:#fff; cursor:pointer; }
.modal-body { padding:12px; overflow:auto; flex:1; }
#toTop { position:fixed; right:18px; bottom:120px; padding:8px 14px; border-radius:20px; background:#8644f1; color:#fff; display:none; z-index:9999; cursor:pointer; transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.1s ease; }
#toTop.show { display:block; }
#toTop:active { transform: scale(0.95); box-shadow: inset 0 2px 4px rgba(0,0,0,0.2); background: rgba(0,0,0,0.05); }
@media (max-width:600px){ .log-row{font-size:12px;} }
@media (prefers-color-scheme: dark){
body { background:#0f1115; color:#e5e7eb; }
.log-card { background:#1b1e24; border-color:#333; color:#e5e7eb; }
.modal-content { background:#1b1e24; color:#e5e7eb; }
.modal-header { background:#2b2e36; border-bottom-color:#444; }
button.toggle-btn { background:#d0a026; color:#fff; }
button.clear-log { background:#b91c1c; color:#fff; }
button.view-json { background:#2563eb; color:#fff; }
.modal-close { background:#393fec; color:#fff; }
.log-card {
background:rgba(0,0,0,0.04);
border-left:3px solid #2ecc71;
border-radius:6px;
padding:8px 10px;
margin-bottom:10px;
}
.log-card.warn { border-left-color:#f39c12; }
.log-card.err { border-left-color:#e74c3c; }
.log-row { font-size:13px; line-height:1.8; word-break:break-all; }
.log-label { opacity:0.6; margin-right:6px; }
#log_modal { display:none; position:fixed; z-index:9999; left:0; top:0; width:100%; height:100%; background:rgba(0,0,0,0.5); }
/* 弹窗固定暗色终端风: LuCI bootstrap-dark 主题走自己的 cascade.css,
不触发 prefers-color-scheme, 媒体查询不可靠; 且 JSON 适合暗底亮字 */
#log_modal .modal-box { background:#1e222a; color:#d8dee6; max-width:700px; width:85%; max-height:70vh; overflow:auto; border-radius:6px; border:1px solid rgba(255,255,255,0.15); margin:8vh auto 0; }
#log_modal .modal-box b { color:#fff; }
#log_modal_body { margin:8px 12px; white-space:pre-wrap; word-break:break-all; font-size:12px; color:#d8dee6; }
</style>
<div id="app">
<div class="toolbar">
<fieldset class="cbi-section">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:10px;">
<div>
<button type="button" class="toggle-btn" @click="toggle">{{ paused ? '继续刷新' : '暂停刷新' }}</button>
<button type="button" class="clear-log" @click="clear">清空日志</button>
<input type="button" class="btn cbi-button cbi-button-apply" id="log_toggle" value="<%:暂停刷新%>" />
<input type="button" class="btn cbi-button" id="log_clear" value="<%:清空日志%>" />
</div>
<div style="display:flex;align-items:center;gap:12px;">
<label>
<select v-model="filterType">
<option value="all">全部</option>
<option value="warn">警告</option>
<option value="err">错误</option>
</select>
</label>
<span>共 {{ filteredLogs.length }} 条日志</span>
<div>
<select id="log_filter">
<option value="all"><%:全部%></option>
<option value="warn"><%:警告%></option>
<option value="err"><%:错误%></option>
</select>&nbsp;
<span id="caddy_log_count">0 <em><%:条日志%></em></span>
</div>
</div>
<p id="caddy_log_list"><em><%:加载中...%></em></p>
</fieldset>
<div v-for="log in filteredLogs" :key="log.id" class="log-card" :class="statusClass(log.status)">
<div class="log-inner">
<div class="log-row" v-if="log.ts"><span class="label">时间:</span>{{ time(log.ts) }}</div>
<div class="log-row" v-if="log.request"><span class="label">访问:</span>{{ fullUrl(log) }}</div>
<div class="log-row" v-if="log.request && log.request.method"><span class="label">方法:</span>{{ log.request.method }}</div>
<div class="log-row" v-if="log.status"><span class="label">状态:</span>{{ log.status }}</div>
<div class="log-row" v-if="realIp(log)"><span class="label">IP:</span>{{ realIp(log) }}</div>
<div class="log-row" v-if="userAgent(log)"><span class="label">标识:</span>{{ userAgent(log) }}</div>
<div class="log-row" v-if="log.duration"><span class="label">耗时:</span>{{ (log.duration*1000).toFixed(2) }} ms</div>
<div class="log-row">
<button type="button" class="view-json" @click.prevent="showModal(log)">查看原始 JSON 日志</button>
</div>
</div>
</div>
<div class="modal" :class="{show: modalLog}" @click.self="closeModal">
<div class="modal-content">
<div class="modal-header">
<h3>完整日志</h3>
<button class="modal-close" @click.prevent="closeModal">关闭</button>
</div>
<div class="modal-body">
<pre>{{ modalLog ? JSON.stringify(modalLog,null,2) : '' }}</pre>
</div>
<div id="log_modal" onclick="if(event.target===this)logCloseModal()">
<div class="modal-box">
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #ccc;">
<b><%:完整日志%></b>
<input type="button" class="btn cbi-button" value="<%:关闭%>" onclick="logCloseModal()" />
</div>
<pre id="log_modal_body"></pre>
</div>
</div>
<div id="toTop">返回顶部</div>
<script>
const { createApp } = Vue;
createApp({
data(){ return { logs: [], paused:false, filterType:'all', modalLog:null }; },
computed:{ filteredLogs(){ if(this.filterType==='all') return this.logs; return this.logs.filter(l=>this.statusClass(l.status)===this.filterType); }},
methods:{
fetch(){ if(this.paused) return; XHR.get('<%=url([[admin]], [[nas]], [[caddy]], [[get_log]])%>', null, x=>{ if(x && x.status===200){ this.logs = x.responseText.split('\n').filter(Boolean).map((l,i)=>{ try{ let obj=JSON.parse(l); obj.id=i; return obj; }catch{return null;} }).filter(Boolean).reverse(); } }); },
toggle(){ this.paused = !this.paused; },
clear(){ XHR.get('<%=url([[admin]], [[nas]], [[caddy]], [[clear_log]])%>'); this.logs=[]; },
time(ts){ return new Date(ts*1000).toLocaleString(); },
fullUrl(l){ const h=l.request.headers||{}; const p=h['X-Forwarded-Proto']?h['X-Forwarded-Proto'][0]:'http'; return p+'://'+l.request.host+l.request.uri; },
realIp(l){ const h=l.request.headers||{}; return h['Cf-Connecting-Ip']?h['Cf-Connecting-Ip'][0]:l.request.client_ip; },
userAgent(l){ const h=l.request.headers||{}; return h['User-Agent']?h['User-Agent'][0]:'未知'; },
statusClass(c){ if(c>=500) return 'err'; if(c>=400) return 'warn'; return 'ok'; },
showModal(log){ this.modalLog = log; document.body.style.overflow = 'hidden'; },
closeModal(){ this.modalLog = null; document.body.style.overflow = ''; }
},
mounted(){ this.fetch(); setInterval(this.fetch,2000); const btn=document.getElementById('toTop'); btn.onclick=()=>window.scrollTo({top:0,behavior:'smooth'}); window.addEventListener('scroll',()=>btn.classList.toggle('show',window.scrollY>400)); }
}).mount('#app');
</script>
@@ -1,26 +1,19 @@
<%
local uci = require 'luci.model.uci'.cursor()
ssl = uci:get_first('caddy', 'caddy', 'ssl')
if ssl == '1' then
protocol="http://"
else
protocol="http://"
end
%>
%>
<script type="text/javascript">//<![CDATA[
XHR.poll(5, '<%=url("admin/nas/caddy/caddy_status")%>', null,
XHR.poll(5, '<%=url("admin/network/caddy/caddy_status")%>', null,
function(x, st) {
var tb = document.getElementById('caddy_status');
if (st && tb) {
var caddyStatus = st.running ? "<span style='color:green;'> 运行中</span><img src='https://www.right.com.cn/forum/data/attachment/album/202401/30/073058is17bcmu7z3nmn2b.gif' width='30px' alt=''>" : "<span style='color:red;'> 未运行</span>";
var caddyStatus = st.running ? "<span style='color:green;'> 运行中</span>" : "<span style='color:red;'> 未运行</span>";
var caddystaContent = st.running ? "已运行:" + "<span style='color:#DA70D6;'>" + st.caddysta + "</span>" : "";
var caddycpuContent = st.running ? "<br>" + "CPU占用:" + "<span style='color:#6A5ACD;'>" + st.caddycpu + "</span>" : "";
var caddyramContent = st.running ? "&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;" + "&nbsp;内存占用:" + "<span style='color:#0000CD;'>" + st.caddyram + "</span>" : "";
var caddytagContent = st.running ? "<br>" + "当前版本:" + "<span style='color:#CD853F;'>" + st.caddytag + "</span>" : "";
var caddynewtagContent = st.running ? "&nbsp;&nbsp;&nbsp;" + "最新版本:" + "<span style='color:#FFA500;'>" + st.caddynewtag + "</span>" : "";
var caddywebContent = st.running ? "<input class=\"cbi-button cbi-button-reload mar-10\" type=\"button\" value=\" <%:Web 界面%> \" onclick=\"window.open('<%=protocol%>" + window.location.hostname + ":" + st.port + "/')\"/>" : "";
var caddywebContent = (st.running && st.weburl) ? "<input class=\"cbi-button cbi-button-reload mar-10\" type=\"button\" value=\" <%:Web 界面%> \" onclick=\"window.open('" + st.weburl + "/')\"/>" : "";
tb.innerHTML = "<b><em>Caddy " + caddyStatus + caddystaContent + caddycpuContent + caddyramContent + caddytagContent + caddynewtagContent + caddywebContent + "</b></em>";
}
}
@@ -0,0 +1,102 @@
<%+cbi/valueheader%>
<span style="color:#808080"><%:后台下载 (断点续传). 点击后进度自动刷新: 已下载 / 总大小 / 速度 / 百分比. 中途刷新页面不会中断, 可暂停/继续/停止, 再点[下载并重启]会从断点继续%></span>
<div style="margin:6px 0">
<input type="button" id="<%=self.option%>-btn" class="btn cbi-button cbi-button-apply" value="<%:下载并重启%>" style="width:auto" onclick="return caddy_dl_start(this)" />
<input type="button" id="<%=self.option%>-btn-pause" class="btn cbi-button cbi-button-edit" value="<%:暂停%>" style="width:auto;display:none" onclick="return cdl_ctl('pause')" />
<input type="button" id="<%=self.option%>-btn-resume" class="btn cbi-button cbi-button-edit" value="<%:继续%>" style="width:auto;display:none" onclick="return cdl_ctl('resume')" />
<input type="button" id="<%=self.option%>-btn-stop" class="btn cbi-button cbi-button-remove" value="<%:停止%>" style="width:auto;display:none" onclick="return cdl_ctl('stop')" />
<span id="<%=self.option%>-pct" style="margin-left:10px;font-family:monospace"></span>
</div>
<div id="<%=self.option%>-barbox" style="display:none;width:100%;max-width:520px;background:#2b2b2b;border:1px solid #555;border-radius:4px;height:18px;overflow:hidden;margin:4px 0">
<div id="<%=self.option%>-bar" style="height:100%;width:0%;background:#008000;transition:width 1s linear"></div>
</div>
<div id="<%=self.option%>-stat" style="font-family:monospace;color:#bbb;font-size:12px"></div>
<div id="<%=self.option%>-msg"></div>
<script type="text/javascript">//<![CDATA[
var cdl_tid = null;
// 点击后的宽限窗口(ms 时间戳): 控制器先删旧 status 再启脚本, 新脚本要过 TOT 探测(几秒~几十秒)才写第一个 status,
// 这段空窗轮询回来是 idle, 若按普通 idle 停轮询就再也没人刷新了 → 窗口内的 idle 保持轮询, 终态(done/error/stopped)照常停
var cdl_until = 0;
function cdl_mb(n){ return (n/1048576).toFixed(2) + ' MB'; }
function cdl_esc(s){ return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function cdl_render(rv)
{
var bar = document.getElementById('<%=self.option%>-bar');
var box = document.getElementById('<%=self.option%>-barbox');
var pct = document.getElementById('<%=self.option%>-pct');
var stat = document.getElementById('<%=self.option%>-stat');
var msg = document.getElementById('<%=self.option%>-msg');
var btn = document.getElementById('<%=self.option%>-btn');
var bP = document.getElementById('<%=self.option%>-btn-pause');
var bR = document.getElementById('<%=self.option%>-btn-resume');
var bS = document.getElementById('<%=self.option%>-btn-stop');
var size = rv.size || 0;
var total = rv.total || 0;
var speed = rv.speed || 0;
var text = cdl_mb(size) + (total ? ' / ' + cdl_mb(total) : '');
if (total > 0) {
var p = Math.floor(size * 100 / total);
text += ' · ' + p + '%';
}
if (speed > 0) text += ' · ' + cdl_mb(speed) + '/s';
pct.innerHTML = text;
var st = rv.state || 'idle';
// 定时器由这里统一管理: 下载/暂停/校验中保持轮询, 结束(done/error/stopped)则停
// 窗口内的 idle(启动空窗)也算活动, 否则首次轮询落在空窗里就把轮询停死, UI 永远 idle
var active = (st === 'downloading' || st === 'paused' || st === 'verifying' || (st === 'idle' && Date.now() < cdl_until));
if (active && !cdl_tid) cdl_tid = setInterval(cdl_poll, 1500);
else if (!active && cdl_tid) { clearInterval(cdl_tid); cdl_tid = null; }
// 进度条
if (bar && box) {
if (st === 'downloading' || st === 'paused' || st === 'done' || st === 'verifying') box.style.display = 'block';
var w = total ? Math.min(100, Math.floor(size*100/total)) : 0;
bar.style.width = w + '%';
}
// 主按钮: 有已下载部分(error/stopped/有进度)时点它就是续传
if (btn) {
if (st === 'downloading') { btn.disabled = true; btn.value = '<%:下载中...%>'; } else if (st === 'lost') { btn.disabled = false; btn.value = '<%:重新下载 (自动续传)%>'; } else if (st === 'paused') { btn.disabled = true; btn.value = '<%:已暂停 (点[继续])%>'; }
else if (st === 'verifying') { btn.disabled = true; btn.value = '<%:校验/替换中...%>'; }
else if (st === 'stopped' || st === 'lost') { btn.disabled = false; btn.value = '<%:继续下载 (自动续传)%>'; }
else if (st === 'error') { btn.disabled = false; btn.value = '<%:重试 (自动续传)%>'; }
else { btn.disabled = false; btn.value = '<%:下载并重启%>'; }
}
// 暂停/继续/停止 控制按钮的显示与可用性
if (bP && bR && bS) {
var showCtl = (st === 'downloading' || st === 'paused');
bP.style.display = showCtl ? '' : 'none'; bP.disabled = (st !== 'downloading');
bR.style.display = showCtl ? '' : 'none'; bR.disabled = (st !== 'paused');
bS.style.display = showCtl ? '' : 'none'; bS.disabled = false;
}
if (stat) {
var stmap = { idle:'', downloading:'<%:下载中...%>', paused:'<%:已暂停%>', verifying:'<%:校验中...%>', done:'<%:完成%>', stopped:'<%:已停止%>', lost:'<%:进程丢失%>', error:'<%:出错%>' };
stat.innerHTML = (stmap[st] || st) + ' — ' + text;
}
if (msg) {
if (st === 'done') msg.innerHTML = "<font color='#008000'>" + cdl_esc(rv.msg) + "</font>";
else if (st === 'error' || st === 'lost') msg.innerHTML = "<font color='#DB7093'>" + cdl_esc(rv.msg || '') + "</font>";
else if (st === 'stopped' || st === 'paused') msg.innerHTML = "<font color='#aaaaaa'>" + cdl_esc(rv.msg || '') + "</font>";
else msg.innerHTML = "";
}
}
function cdl_poll()
{
XHR.get('<%=luci.dispatcher.build_url("admin","network","caddy","download_status")%>', null, function(x, rv){ cdl_render(rv); });
}
function cdl_ctl(act)
{
XHR.post('<%=luci.dispatcher.build_url("admin","network","caddy","download_ctl")%>', { act: act }, function(){ cdl_poll(); });
return false;
}
function caddy_dl_start(btn)
{
btn.disabled = true; btn.value = '<%:启动中...%>';
cdl_until = Date.now() + 60000; // 60s 宽限: 覆盖 控制器删旧 status → 新脚本 TOT 探测 → 第一个进度 的空窗
XHR.get('<%=luci.dispatcher.build_url("admin","network","caddy","download")%>', null, function(x, rv){
cdl_poll();
});
return false;
}
// 页面加载时若上次在后台下载, 恢复进度显示
window.addEventListener('load', function(){ cdl_poll(); });
//]]></script>
<%+cbi/valuefooter%>
+54 -44
View File
@@ -1,49 +1,59 @@
# 此配置文件路径:/etc/caddy/Caddyfile
{ # 全局配置
# 启动 cgi 模块
order cgi before respond
# 启动 webdav 模块
order webdav before file_server
# 关闭 API 端口
admin off
}
# 此配置文件路径: /etc/caddy/Caddyfile
# 下面是 proxy 模式生成 Caddyfile 的样例 (通用值).
# 手动模式 (Caddyfile) 下, 本文件内容即你实际使用的配置, 可任意写 Caddy 支持的语法.
:12311 {
#启用压缩
encode gzip
#认证,账号admin 密码123456
basicauth {
admin $2a$14$RdbOHzJhf5BaapSdlYTCbe.yWY9cEZjyDpfgwStY28K/qsM1tX8tu
}
#路径 /mnt
root * /mnt
#文件服务器 端口12311
file_server browse
log {
#日志 路径/tmp/caddy/requests.log
output file /tmp/caddy/requests.log {
roll_size 1MiB
roll_local_time
roll_keep 5
roll_keep_for 120h
}
}
{
auto_https disable_redirects
aliyun_ddns {
access_key_id <ACCESS_KEY_ID>
access_key_secret <ACCESS_KEY_SECRET>
region_id cn-hangzhou
domains {
example.com @
}
versions ipv4
ip_source interface pppoe-wan
check_interval 5m
ttl 60s
}
}
:12322 {
#启用压缩
encode gzip
#认证,账号admin 密码123456
basicauth {
admin $2a$14$RdbOHzJhf5BaapSdlYTCbe.yWY9cEZjyDpfgwStY28K/qsM1tX8tu
}
example.com:8443 {
tls {
dns alidns {
access_key_id <ACCESS_KEY_ID>
access_key_secret <ACCESS_KEY_SECRET>
}
}
# webdav 路径根目录 端口12322
webdav /dav/* {
prefix /dav
root /
}
header -Server
request_body max_size 0
@health path /health
@apikey header Authorization "Bearer <API_KEY>"
handle @health {
reverse_proxy 192.168.1.x:8080 { flush_interval -1 }
}
handle @apikey {
reverse_proxy 192.168.1.x:8080 { flush_interval -1 }
}
handle {
respond 404 "Not Found" { close }
}
log {
output file /tmp/caddy/requests.log {
roll_size 1MiB
roll_local_time
roll_keep 5
roll_keep_for 120h
}
}
}
+27 -13
View File
@@ -1,14 +1,28 @@
# luci-app-caddy 默认配置
#
# 数据模型:
# config caddy -> 全局 (单条): 启用 / 路径 / 重定向开关
# config domain -> 域名 (多条, 增删): 每条 = 一个域名 + AK + DDNS 参数
# config proxy -> Host (多条, 增删): 每条 = 一段 Caddyfile 的 域名:端口 { ... }
#
# domain 示例 (在 LuCI [Domain] 页点 添加 创建):
# config domain 'd1'
# option domain_name 'example.com'
# option key_id '<AK_ID>'
# option key_secret '<AK_SECRET>'
# option ddns_on '0'
#
# proxy 示例 (在 LuCI [Host] 页点 添加 创建):
# config proxy 'host1'
# option name 'my-host'
# option enabled '1'
# option domain 'example.com'
# option port '8443'
# option tls '1'
# option target '192.168.1.x:8080'
config caddy
option 'enabled' '0'
option cmd '默认'
option 'port' '12311'
option 'webdav' '0'
option 'filezip' '0'
option 'webzip' '0'
option 'cgi' '0'
option 'api' '0'
option 'data_dir' '/mnt'
option 'file_pass' '0'
option 'log' '1'
option 'log_dir' '/tmp/caddy/requests.log'
option allow_wan '0'
option enabled '0'
option disable_redirect '1'
option bin_dir '/usr/sbin/caddy'
option log_dir '/tmp/caddy/requests.log'
+310 -161
View File
@@ -2,178 +2,315 @@
START=99
USE_PROCD=1
PROG="$(uci -q get caddy.@caddy[0].bin_dir)"
get_config() {
config_get_bool enabled $1 enabled 0
config_get cmd $1 cmd "默认"
config_get_bool webdav $1 webdav 0
config_get port $1 port 12311
config_get webdav_port $1 webdav_port 12322
config_get log $1 log 1
config_get data_dir $1 data_dir "/mnt"
config_get webdav_data_dir $1 webdav_data_dir "/mnt"
config_get log_dir $1 log_dir "/tmp/caddy/requests.log"
config_get caddy_file $1 caddy_file "/etc/caddy/Caddyfile"
config_get caddyfile $1 caddyfile
config_get file_pass $1 file_pass 0
config_get file_username $1 file_username "admin"
config_get file_password $1 file_password "123456"
config_get webdav_pass $1 webdav_pass 0
config_get webdav_username $1 webdav_username "admin"
config_get webdav_password $1 webdav_password "123456"
config_get api $1 api 0
config_get filezip $1 filezip 0
config_get webzip $1 webzip 0
config_get allow_wan $1 allow_wan 0
# ---- 全局值 (来自 @caddy[0]) ----
G_BIN="$(uci -q get caddy.@caddy[0].bin_dir)"
[ -z "$G_BIN" ] && G_BIN="/usr/sbin/caddy"
G_FILE="$(uci -q get caddy.@caddy[0].caddy_file)"
[ -z "$G_FILE" ] && G_FILE="/etc/caddy/Caddyfile"
G_LOG="$(uci -q get caddy.@caddy[0].log_dir)"
[ -z "$G_LOG" ] && G_LOG="/tmp/caddy/requests.log"
TMP="$G_FILE.new"
# 列出所有 domain section 的 UCI 路径 (支持命名+匿名)
# 返回完整路径如 caddy.d1 或 caddy.@domain[0]
list_all_domains() {
uci show caddy 2>/dev/null | grep '\.domain_name' | sed 's/\.domain_name=.*//'
}
set_firewall() {
if [ "$external_access" = "allow" ]; then
uci -q delete firewall.caddy
uci set firewall.caddy=rule
uci set firewall.caddy.name="caddy"
uci set firewall.caddy.target="ACCEPT"
uci set firewall.caddy.src="wan"
uci set firewall.caddy.proto="tcp"
uci set firewall.caddy.dest_port="$port"
[ ! -z "$webdav_port" ] && uci set firewall.caddy.dest_port="$webdav_port"
uci set firewall.caddy.enabled="1"
uci commit firewall
/etc/init.d/firewall reload >/dev/null 2>&1
elif [ "$external_access" = "deny" ]; then
uci -q delete firewall.caddy
uci commit firewall
/etc/init.d/firewall reload >/dev/null 2>&1
# 按 domain_name 查找匹配的 section UCI 路径
find_domain_by_name() {
local target="$1" ref dn
for ref in $(list_all_domains); do
dn="$(uci -q get "$ref".domain_name)"
[ "$dn" = "$target" ] && echo "$ref" && return 0
done
return 1
}
# 列出所有 proxy section (匿名+命名都认, 按 target 字段识别)
# 匿名 section 在 uci show 里形如 caddy.@proxy[N]
list_all_proxies() {
uci show caddy 2>/dev/null | grep '\.target=' | sed 's/\.target=.*//'
}
# ---- 生成 Caddyfile ----
# 逻辑: 全局块 (auto_https + 每个 ddns_on 的 domain 生成 aliyun_ddns) + 每个启用的 host
generate_caddyfile() {
local cf="$TMP"
local disr
local p dom prt tgt key tls_cert extra
disr="$(uci -q get caddy.@caddy[0].disable_redirect)"
{
echo "# /etc/caddy/Caddyfile"
echo "# generated by luci-app-caddy, edit via LuCI -> Network -> Caddy"
echo
echo "{"
# auto_https: 勾选才写
if [ "$disr" = "1" ]; then
echo " auto_https disable_redirects"
fi
# CA 固定 Let's Encrypt, 避开 Caddy 默认可能跳 ZeroSSL
echo " acme_ca https://acme-v02.api.letsencrypt.org/directory"
# --- DDNS: 遍历 domain, ddns_on=1 的生成 aliyun_ddns 块 ---
for d in $(list_all_domains); do
local don dkid dksec dname drec diface dint dttl
don="$(uci -q get "$d".ddns_on)"
[ "$don" != "1" ] && continue
dname="$(uci -q get "$d".domain_name)"
dkid="$(uci -q get "$d".key_id)"
dksec="$(uci -q get "$d".key_secret)"
drec="$(uci -q get "$d".ddns_records)"
diface="$(uci -q get "$d".ddns_wan_iface)"
dint="$(uci -q get "$d".ddns_check_interval)"
dttl="$(uci -q get "$d".ddns_ttl)"
[ -z "$dname" ] && continue
[ -z "$dkid" ] && continue
[ -z "$dksec" ] && continue
[ -z "$dint" ] && dint="5m"
[ -z "$dttl" ] && dttl="60s"
echo
echo " # DDNS: $dname (@ = 主域 A 记录, * = 泛解析 *.domain 独立记录, 二者互不串用)"
echo " aliyun_ddns {"
echo " access_key_id $dkid"
echo " access_key_secret $dksec"
echo
echo " domains {"
echo " $dname @"
printf '%s\n' "$drec" | while IFS= read -r r; do
[ -z "$r" ] && continue
echo " $dname $r"
done
echo " }"
echo
echo " versions ipv4"
case "$diface" in
public|"")
: # 走插件默认 simple_http (api.ipify.org / icanhazip.com / ifconfig.me / ident.me)
;;
*)
echo " ip_source interface $diface"
;;
esac
echo " check_interval $dint"
echo " ttl $dttl"
echo " }"
done
echo "}"
# ---- Host: 每个启用的 proxy (匿名/命名都认) ----
for p in $(list_all_proxies); do
local pen
pen="$(uci -q get "$p".enabled)"
[ "$pen" = "0" ] && continue
# 一次性迁移: 下拉现在只有 auto/off, 任何旧值 (tls Flag / 静态证书路径) 归到这两个
local tlsval
tlsval="$(uci -q get "$p".tls_cert)"
case "$tlsval" in
auto|off|"")
if [ -z "$tlsval" ]; then
# 从未设过: 按旧版 tls Flag 定 (1=auto, 其余 off)
if [ "$(uci -q get "$p".tls)" = "1" ]; then
tlsval=auto
else
tlsval=off
fi
uci -q set "$p".tls_cert="$tlsval"
uci commit caddy
fi
;;
*) # 旧值 (静态证书路径等) 一律归 auto, 写回 UCI 一次性归一
uci -q set "$p".tls_cert=auto
uci commit caddy
tlsval=auto
;;
esac
dom="$(uci -q get "$p".domain)"
prt="$(uci -q get "$p".port)"
tgt="$(uci -q get "$p".target)"
key="$(uci -q get "$p".api_key)"
extra="$(uci -q get "$p".extra)"
[ -z "$prt" ] && prt="443"
[ -z "$dom" ] && continue
[ -z "$tgt" ] && continue
# 取对应 Domain 的 AK (Host 域名剥掉 *. 前缀再查 Domain 页)
# 注意: 必须用 \*.*) 转义前导星号, 否则 example.net 这种含点的裸域会误中 *.*
local dmatch dkid dksec dlookup
dlookup="$dom"
case "$dlookup" in
\*.*) dlookup="${dlookup#\*.}" ;;
esac
dmatch="$(find_domain_by_name "$dlookup")"
dkid=""
dksec=""
if [ -n "$dmatch" ]; then
dkid="$(uci -q get "$dmatch".key_id)"
dksec="$(uci -q get "$dmatch".key_secret)"
fi
# 监听地址: 完全由表单的 domain+port 决定, 代码不替用户加地址
# 选了 *.x 时, 同一行带上裸域 x (官方"一行多名字"模式):
# certmagic 逐名字签发 → 裸域、通配符各一张证书(各一条 order)
# ≡ acme.sh -d x --dns + -d '*.x' --dns, 效果等价: 裸域+全部子域都覆盖
local addr="$dom:$prt"
case "$dom" in
\*.*) addr="$dom:$prt ${dom#\*.}:$prt" ;;
esac
echo
echo "$addr {"
# TLS: 只有 auto / off 两个值 (下拉已砍掉静态证书选项)
# auto + Domain 页有 AK: 一律 DNS-01 (裸域/通配符都不依赖 80/443 入站, 适合 NAT 家庭宽带)
# auto + 无 AK: 回退官方 HTTP-01 (一行 tls 都不写, Caddy 自动处理)
# 用上面迁移后的 $tlsval, 不重读 UCI (迁移刚写回时本轮即生效)
case "$tlsval" in
auto|"")
if [ -n "$dkid" ] && [ -n "$dksec" ]; then
echo " tls {"
echo " dns alidns {"
echo " access_key_id $dkid"
echo " access_key_secret $dksec"
echo " }"
echo " }"
echo
fi
;;
off)
: # 纯 HTTP, 不写 tls 块
;;
esac
echo " header -Server"
echo
echo " request_body max_size 0"
if [ -n "$key" ]; then
echo
echo " # Caddy 层 API key 校验: 除 @apikey 外一律 404 (/health 也需带 key)"
echo " @apikey header Authorization \"Bearer $key\""
echo
echo " handle @apikey {"
echo " reverse_proxy $tgt { flush_interval -1 }"
echo " }"
echo
echo " handle {"
# 无 key 的兜底: 空 body 的 404 (不回 "Not Found" 文本, 不泄露身份)
# 注意: respond 带 body 文本 + 尾部 { close } 子块时 Caddyfile 参数错位
# (404→body, 文本→status), 运行时 Atoi 炸 500. 故 body 留空.
echo " respond 404 { close }"
echo " }"
else
echo
echo " reverse_proxy $tgt {"
echo " flush_interval -1"
echo " }"
fi
# 自定义指令 (原样插入)
if [ -n "$extra" ]; then
echo
printf '%s\n' "$extra" | while IFS= read -r line; do
echo " $line"
done
fi
# 请求日志: 勾选 log_on 才生成, 关 = 不写日志, 省资源
local lon
lon="$(uci -q get caddy.@caddy[0].log_on)"
if [ "$lon" != "0" ]; then
echo
echo " # 请求日志"
echo " log {"
echo " output file $G_LOG {"
echo " roll_size 1MiB"
echo " roll_local_time"
echo " roll_keep 5"
echo " roll_keep_for 120h"
echo " }"
echo " }"
fi
echo "}"
done
} > "$cf"
mv -f "$cf" "$G_FILE"
# DDNS/AK secret 明文在文件里, 收紧权限只让 root 读写 (LuCI CGI 同以 root 运行, 不受影响)
chmod 600 "$G_FILE"
# 一次性迁移: wildcard 旧字段. 通配符选择已挪到 Host 页下拉, Domain 页不再显示该字段,
# 而 CBI 保存只写页面已定义字段、不删未定义键 → 旧 wildcard 键会永久残留 UCI. 有就删.
local wcwipe
wcwipe=0
for d in $(list_all_domains); do
case "$(uci -q get "$d".wildcard)" in
""|auto|off) : ;;
*) uci -q delete "$d".wildcard; wcwipe=1 ;;
esac
done
[ "$wcwipe" = "1" ] && uci commit caddy
# 清 UCI 冻结值: [配置] 页 TextValue 点过「保存」后会把当时内容冻进 UCI,
# 之后页面优先显示 UCI 而不再读磁盘 (表现: 这里改了但配置页看到的还是旧的).
# 每次重新生成后把键删掉, 页面即回落 o.default(读磁盘真值).
# 只在键存在时才 commit (commit 会动 /etc/config/caddy 触发 procd reload,
# 避免无谓的额外重载: 键存在→删→commit→本轮 reload 重生成时键已不在, 不会死循环)
if uci -q get caddy.@caddy[0].caddyfile >/dev/null 2>&1; then
uci -q delete caddy.@caddy[0].caddyfile
uci commit caddy
fi
}
check_prog() {
if [ ! -f "$G_BIN" ]; then
uci -q set caddy.@caddy[0].enabled=0
uci commit caddy
echo "caddy: $G_BIN 未找到" >>"$G_LOG" 2>&1
exit 1
else
chmod +x "$G_BIN"
if [ "$($G_BIN -h 2>&1 | wc -l)" -lt 3 ]; then
uci -q set caddy.@caddy[0].enabled=0
uci commit caddy
echo "caddy: 程序不完整或架构不匹配" >>"$G_LOG" 2>&1
exit 1
fi
fi
}
start_service() {
config_load caddy
config_foreach get_config caddy
[ $enabled -ne 1 ] && return 1
log_path="$(dirname $log_dir)"
mkdir -p $log_path
if [ ! -f "$PROG" ] ;then
uci -q set caddy.@caddy[0].enabled=0
uci commit caddy
echo "$(TZ=UTC-8 date -R +%Y年%m月%d日\ %X) : $PROG 未找到,无法启动!请确认已上传程序" >>$log_dir 2>&1
exit 1
else
chmod +x $PROG
if [ $(($($PROG -h | wc -l))) -lt 3 ] ;then
uci -q set caddy.@caddy[0].enabled=0
uci commit caddy
echo "$(TZ=UTC-8 date -R +%Y年%m月%d日\ %X) : $PROG 程序不完整或CPU架构不支持的程序,无法启动!" >>$log_dir 2>&1
exit 1
fi
fi
if [ "$cmd" = "默认" ] ;then
mkdir -p $data_dir
if [ "$log" -eq 1 ] ;then
logs="log {
output file $log_dir {
roll_size 1MiB
roll_local_time
roll_keep 5
roll_keep_for 120h
}
}"
fi
if [ "$file_pass" -eq 1 ] ;then
filepassword="$($PROG hash-password --plaintext $file_password)"
filebasicauth="basicauth {
$file_username $filepassword
}"
fi
if [ "$webdav" -eq 1 ] ;then
mkdir -p $webdav_data_dir
if [ "$webdav_pass" -eq 1 ] ;then
password="$($PROG hash-password --plaintext $webdav_password)"
basicauth="basicauth {
$webdav_username $password
}"
fi
webdavs=":$webdav_port {
$basicauth
webdav * {
prefix /dav
root $webdav_data_dir
}
}"
orderweb="order webdav before file_server # 启动 webdav 模块"
fi
if [ "$filezip" -eq 1 ] ;then
filezips="encode gzip"
fi
if [ "$webzip" -eq 1 ] ;then
webzips="encode gzip"
fi
if [ "$api" != "1" ] ;then
apis="admin off # 关闭 API 端口"
fi
if [ "$allow_wan" -eq "1" ]; then
external_access="allow"
else
external_access="deny"
fi
if [ "$webdav" -eq 1 ] || [ "$api" != "1" ] ;then
order="{ # 全局配置
$orderweb
$apis
} "
fi
set_firewall
cat > $caddy_file <<EOF
# 此配置文件路径:/etc/caddy/Caddyfile
local enabled
enabled="$(uci -q get caddy.@caddy[0].enabled)"
[ -z "$enabled" ] && enabled=0
[ "$enabled" != "1" ] && return 1
$order
mkdir -p "$(dirname "$G_LOG")"
check_prog
:$port {
$filezips
$filebasicauth
root * $data_dir
file_server browse
$logs
}
generate_caddyfile
$webdavs
EOF
"$G_BIN" fmt --overwrite "$G_FILE" 2>/dev/null || true
uci -q set caddy.@caddy[0].caddyfile="$(cat $caddy_file)"
uci commit caddy
else
caddyf="$(uci -q get caddy.@caddy[0].caddyfile)"
logpath="$(echo $caddyf | grep "output file" | awk -F 'output file' '{print $2}' | tr -d '{ ')"
[ ! -z "$logpath" ] && uci -q set caddy.@caddy[0].log_dir=$logpath && uci commit caddy
if [ ! -z "$caddyf" ] ;then
echo "$caddyf" >$caddy_file 2>&1
else
uci -q set caddy.@caddy[0].enabled=0
uci commit caddy
echo "$(TZ=UTC-8 date -R +%Y年%m月%d日\ %X) : $caddy_file 配置文件Caddyfile为空,无法启动!" >>$log_dir 2>&1
exit 1
fi
fi
$PROG fmt --overwrite $caddy_file
procd_open_instance caddy
procd_set_param command $PROG
procd_append_param command run
procd_append_param command --config $caddy_file
procd_append_param command --adapter caddyfile
procd_set_param stdout 0
procd_set_param stderr 0
procd_set_param command "$G_BIN" "run" "--config" "$G_FILE" "--adapter" caddyfile
procd_set_param pidfile /var/run/caddy.pid
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance caddy
echo `date +%s` > /tmp/caddy_time
date +%s > /tmp/caddy_time
}
service_triggers() {
@@ -181,14 +318,26 @@ service_triggers() {
}
stop_service() {
external_access="deny"
set_firewall
rm -rf /tmp/caddy.tag /tmp/caddynew.tag
rm -rf $(uci -q get caddy.@caddy[0].log_dir)
procd_kill caddy 2>/dev/null
rm -f /tmp/caddynew.tag
rm -f "$G_LOG" 2>/dev/null
rm -f /var/run/caddy.pid
return 0
}
reload_service() {
# 覆盖 rc.common 的 restart(): 官方零停机热重载 (caddy reload, 等价 POST /load)
# 有运行实例 -> 热重载, 旧连接不断; 无实例 -> 完整 stop/start
restart() {
generate_caddyfile
"$G_BIN" fmt --overwrite "$G_FILE" 2>/dev/null || true
if pidof caddy >/dev/null 2>&1; then
"$G_BIN" reload --config "$G_FILE" --adapter caddyfile >/dev/null 2>&1 && return 0
fi
stop
sleep 1
start
}
# reload: procd 模式 rc.common 调 reload_service, 委托给热重载
reload_service() {
restart
}