🎁 Sync 2026-10-28 19:35:15

This commit is contained in:
github-actions[bot] committed 2026-10-28 19:35:15 +08:00
1 parent 85209f310d
commit 390801c844
90 files changed
+5821 -1967

No files matched your search

+91 -50
View File
@@ -4,9 +4,30 @@ ACME_BIN="${ACME_HOME}/acme.sh"
CERT_DIR="${MINIGATE_CERT_DIR:-/etc/minigate/certs}"
LOGFILE="${MINIGATE_ACME_LOG:-/var/log/minigate-acme.log}"
PROXY_SCRIPT="${MINIGATE_PROXY_SCRIPT:-/usr/lib/minigate/proxy.sh}"
# 最近一次签发结果只放内存;证书本身的有效期由页面直接读取证书文件
STATE_FILE="${MINIGATE_ACME_STATE:-/var/run/minigate/acme.state}"
# 固定 acme.sh 版本并校验 GitHub 标签归档的 SHA-256,不再以 root 执行未校验的 master 分支
ACME_VERSION="3.1.6"
ACME_SHA256="0d3f9000ac44a6331314742a88c475f79134e24fc991997883652adc59efc486"
ACME_URL="https://github.com/acmesh-official/acme.sh/archive/refs/tags/${ACME_VERSION}.tar.gz"
# 证书剩余天数少于此值才强制重签,避免反复点击触发 Let's Encrypt 频率限制
RENEW_DAYS=30
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [ACME] $*" >> "$LOGFILE"; }
# write_state 结果 域名 说明
write_state() {
local tmp
mkdir -p "$(dirname "$STATE_FILE")" 2>/dev/null || return 0
tmp="${STATE_FILE}.$$"
{
printf 'result=%s\n' "$1"
printf 'domain=%s\n' "$2"
printf 'message=%s\n' "$3"
printf 'time=%s\n' "$(date '+%Y-%m-%d %H:%M:%S')"
} > "$tmp" && mv -f "$tmp" "$STATE_FILE"
}
safe_dir() { echo "$1" | sed 's/\*/_wildcard_/g'; }
cert_path_for_domain() {
@@ -22,23 +43,24 @@ cert_is_valid() {
[ -f "$cert" ] && openssl x509 -checkend 0 -noout -in "$cert" >/dev/null 2>&1
}
cert_valid_for_days() {
local cert="$1" days="$2"
[ -f "$cert" ] && openssl x509 -checkend $((days * 86400)) -noout -in "$cert" >/dev/null 2>&1
}
keep_existing_cert_ok() {
local domain="$1" reason="$2"
local cert="$(cert_path_for_domain "$domain")"
if cert_is_valid "$cert"; then
local expiry=$(cert_expiry "$cert")
log "失败: $reason;现有证书仍有效: $domain (过期: $expiry)"
uci -q set minigate.acme.status="ok"
uci -q set minigate.acme.last_domain="$domain"
uci -q set minigate.acme.cert_expiry="$expiry"
uci -q set minigate.acme.last_error="$reason"
uci commit minigate
log "失败: $reason;现有证书仍有效: $domain (过期: $(cert_expiry "$cert"))"
write_state partial "$domain" "$reason;现有证书仍有效,已继续使用"
[ -f "$PROXY_SCRIPT" ] && /bin/sh "$PROXY_SCRIPT" reload 2>/dev/null
return 0
fi
return 1
}
# 只使用同一域名或其上级域名所在 DDNS 记录的令牌,不拿其他区域的令牌碰运气
find_token_for_domain() {
local target="$1"
local sections=$(uci -q show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1)
@@ -49,43 +71,50 @@ find_token_for_domain() {
return 0
fi
done
local parent=$(echo "$target" | sed 's/^[^.]*\.//')
local parent=$(echo "$target" | sed 's/^\*\.//; s/^[^.]*\.//')
local base=$(echo "$target" | sed 's/^\*\.//')
for sec in $sections; do
local d=$(uci -q get minigate.${sec}.domain)
local db=$(echo "$d" | sed 's/^\*\.//')
if [ "$db" = "$parent" ] || [ "$d" = "*.$parent" ]; then
if [ "$db" = "$parent" ] || [ "$db" = "$base" ] || [ "$d" = "*.$parent" ]; then
echo "$(uci -q get minigate.${sec}.cf_api_token)|$(uci -q get minigate.${sec}.cf_zone_id)"
return 0
fi
done
for sec in $sections; do
local t=$(uci -q get minigate.${sec}.cf_api_token)
local z=$(uci -q get minigate.${sec}.cf_zone_id)
[ -n "$t" ] && echo "${t}|${z}" && return 0
done
return 1
}
download_acme() {
local output="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 120 -o "$output" "$ACME_URL" 2>>"$LOGFILE" && return 0
fi
command -v wget >/dev/null 2>&1 && wget -qO "$output" "$ACME_URL" 2>>"$LOGFILE"
}
do_install() {
if [ -x "$ACME_BIN" ] && [ -d "${ACME_HOME}/dnsapi" ]; then
log "acme.sh 已安装"; return 0
fi
log "下载 acme.sh ..."
log "下载 acme.sh ${ACME_VERSION} ..."
mkdir -p "$ACME_HOME" "$CERT_DIR"
cd /tmp; rm -rf acme.sh-master acme-master.tar.gz
local ok=0
for url in "https://github.com/acmesh-official/acme.sh/archive/master.tar.gz"; do
wget -qO /tmp/acme-master.tar.gz "$url" 2>/dev/null && ok=1 && break
curl -sLo /tmp/acme-master.tar.gz "$url" 2>/dev/null && ok=1 && break
done
[ "$ok" != "1" ] && { log "下载失败"; return 1; }
tar xzf /tmp/acme-master.tar.gz -C /tmp/ 2>/dev/null
cd /tmp/acme.sh-master
./acme.sh --install --home "$ACME_HOME" --no-cron --no-profile 2>>"$LOGFILE"
cd /tmp; rm -rf /tmp/acme-master.tar.gz /tmp/acme.sh-master
local work archive actual
work=$(mktemp -d /tmp/minigate-acme.XXXXXX) || { log "无法创建临时目录"; return 1; }
archive="${work}/acme.tar.gz"
if ! download_acme "$archive"; then
log "下载失败"; rm -rf "$work"; return 1
fi
actual=$(sha256sum "$archive" | awk '{print $1}')
if [ "$actual" != "$ACME_SHA256" ]; then
log "acme.sh 校验失败(SHA-256 不一致),已拒绝安装"
rm -rf "$work"; return 1
fi
tar xzf "$archive" -C "$work" 2>>"$LOGFILE" || { log "解压失败"; rm -rf "$work"; return 1; }
( cd "${work}/acme.sh-${ACME_VERSION}" && ./acme.sh --install --home "$ACME_HOME" --no-cron --no-profile ) >> "$LOGFILE" 2>&1
rm -rf "$work"
if [ -x "$ACME_BIN" ] && [ -d "${ACME_HOME}/dnsapi" ]; then
"$ACME_BIN" --home "$ACME_HOME" --set-default-ca --server letsencrypt 2>>"$LOGFILE"
log "安装成功"; uci -q set minigate.acme.status="installed"; uci commit minigate; return 0
"$ACME_BIN" --home "$ACME_HOME" --set-default-ca --server letsencrypt >> "$LOGFILE" 2>&1
log "安装成功"; return 0
else
log "安装失败"; return 1
fi
@@ -101,20 +130,30 @@ do_issue() {
local fs=$(uci -q show minigate | grep '=ddns$' | head -1 | cut -d. -f2 | cut -d= -f1)
[ -n "$fs" ] && domain=$(uci -q get minigate.${fs}.domain)
fi
[ -z "$domain" ] && { log "未指定域名"; uci -q set minigate.acme.status="error"; uci commit minigate; return 1; }
[ -z "$domain" ] && { log "未指定域名"; write_state error "" "未指定域名"; return 1; }
local ti=$(find_token_for_domain "$domain")
local token=$(echo "$ti" | cut -d'|' -f1)
local zone_id=$(echo "$ti" | cut -d'|' -f2)
[ -z "$token" ] && { log "无API令牌"; uci -q set minigate.acme.status="error"; uci commit minigate; return 1; }
[ -z "$token" ] && { log "无API令牌"; write_state error "$domain" "没有找到该域名对应的 Cloudflare 令牌"; return 1; }
local safe=$(safe_dir "$domain")
local cert="${CERT_DIR}/${safe}/fullchain.pem"
mkdir -p "${CERT_DIR}/${safe}"
if ! do_install; then
keep_existing_cert_ok "$domain" "acme.sh 安装失败" && return 0
uci -q set minigate.acme.status="error"; uci commit minigate
write_state error "$domain" "acme.sh 安装失败"
return 1
fi
local force=""
if cert_valid_for_days "$cert" "$RENEW_DAYS" && [ "$staging" != "1" ] && ! cert_is_staging "$cert"; then
log "跳过: $domain 证书剩余超过 ${RENEW_DAYS} 天 (过期: $(cert_expiry "$cert"))"
write_state ok "$domain" "证书剩余超过 ${RENEW_DAYS} 天,未重复签发"
return 0
fi
[ -f "$cert" ] && force="--force"
export CF_Token="$token"
[ -n "$zone_id" ] && export CF_Zone_ID="$zone_id"
@@ -124,23 +163,21 @@ do_issue() {
[ "$staging" = "1" ] && sf="--staging --server letsencrypt"
log "签发: $domain (staging=$staging)"
uci -q set minigate.acme.status="issuing"; uci commit minigate
write_state issuing "$domain" "正在签发"
"$ACME_BIN" --home "$ACME_HOME" --issue --dns dns_cf $sf -d "$domain" --keylength "$kl" \
${email:+--accountemail "$email"} \
--cert-file "${CERT_DIR}/${safe}/cert.pem" --key-file "${CERT_DIR}/${safe}/key.pem" \
--fullchain-file "${CERT_DIR}/${safe}/fullchain.pem" --force >> "$LOGFILE" 2>&1
--fullchain-file "$cert" $force >> "$LOGFILE" 2>&1
local ret=$?
if [ $ret -eq 0 ] && [ -f "${CERT_DIR}/${safe}/fullchain.pem" ]; then
local expiry=$(cert_expiry "${CERT_DIR}/${safe}/fullchain.pem")
log "成功: $domain (过期: $expiry)"
uci -q set minigate.acme.status="ok"
uci -q set minigate.acme.last_domain="$domain"
uci -q set minigate.acme.last_issue="$(date '+%Y-%m-%d %H:%M:%S')"
uci -q set minigate.acme.cert_expiry="$expiry"
uci -q delete minigate.acme.last_error
uci commit minigate
if [ $ret -eq 0 ] && [ -f "$cert" ]; then
log "成功: $domain (过期: $(cert_expiry "$cert"))"
if [ "$staging" = "1" ]; then
write_state ok "$domain" "已签发测试证书(浏览器不信任),正式使用请关闭测试模式后重新签发"
else
write_state ok "$domain" "签发成功"
fi
if echo "$domain" | grep -q '^\*\.'; then
local base=$(echo "$domain" | sed 's/^\*\.//')
[ ! -e "${CERT_DIR}/${base}" ] && ln -sf "${safe}" "${CERT_DIR}/${base}"
@@ -149,16 +186,22 @@ do_issue() {
return 0
else
keep_existing_cert_ok "$domain" "签发失败 (exit=$ret)" && return 0
log "失败 (exit=$ret)"; uci -q set minigate.acme.status="error"; uci -q set minigate.acme.last_error="签发失败 (exit=$ret)"; uci commit minigate; return 1
log "失败 (exit=$ret)"
write_state error "$domain" "签发失败 (exit=$ret),请查看 ACME 日志"
return 1
fi
}
cert_is_staging() {
openssl x509 -in "$1" -noout -issuer 2>/dev/null | grep -qi 'staging\|fake'
}
certs_fingerprint() {
cat "$CERT_DIR"/*/fullchain.pem 2>/dev/null | md5sum | cut -d' ' -f1
}
do_renew() {
local before domain cert
local before
do_install || return 1
before=$(certs_fingerprint)
log "续期..."
@@ -167,14 +210,12 @@ do_renew() {
# acme.sh 只替换证书文件,运行中的 Nginx 必须重载才会使用新证书。
[ "$(certs_fingerprint)" = "$before" ] && return 0
log "证书已更新,重载反向代理"
domain=$(uci -q get minigate.acme.last_domain)
cert="$(cert_path_for_domain "$domain")"
if [ -n "$domain" ] && [ -f "$cert" ]; then
uci -q set minigate.acme.cert_expiry="$(cert_expiry "$cert")"
uci commit minigate
fi
[ -f "$PROXY_SCRIPT" ] && /bin/sh "$PROXY_SCRIPT" reload 2>/dev/null
return 0
}
if [ "${MINIGATE_ACME_LIBRARY_ONLY:-0}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
case "${1:-issue}" in install) do_install;; issue) do_issue "$2";; renew) do_renew;; *) echo "用法: $0 {install|issue [域名]|renew}";; esac
@@ -1,33 +0,0 @@
#!/bin/sh
# LuCI 的 on_after_commit 在浏览器提交配置之前执行。这里先记录当前配置摘要,
# 后台等配置文件变化(最多 WAIT 秒)后再应用,避免后台脚本读到旧配置。
CONFIG="${MINIGATE_CONFIG_FILE:-/etc/config/minigate}"
WAIT="${MINIGATE_COMMIT_WAIT:-15}"
INIT="${MINIGATE_INIT:-/etc/init.d/minigate}"
LIB_DIR="${MINIGATE_LIB_DIR:-/usr/lib/minigate}"
config_digest() {
md5sum "$CONFIG" 2>/dev/null | cut -d' ' -f1
}
case "$1" in
restart|stop|reload|ddns|proxy) ;;
*)
echo "用法: $0 {restart|stop|reload|ddns|proxy}" >&2
exit 2
;;
esac
before=$(config_digest)
(
i=0
while [ "$i" -lt "$WAIT" ] && [ "$(config_digest)" = "$before" ]; do
sleep 1
i=$((i + 1))
done
case "$1" in
restart|stop|reload) exec "$INIT" "$1" ;;
ddns) exec /bin/sh "$LIB_DIR/ddns.sh" force ;;
proxy) exec /bin/sh "$LIB_DIR/proxy.sh" reload ;;
esac
) </dev/null >/dev/null 2>&1 &
+125 -44
View File
@@ -6,6 +6,8 @@ CACHE_DIR="${MINIGATE_CACHE_DIR:-/tmp}"
SINGBOX_CONFIG="${MINIGATE_SINGBOX_CONFIG:-/opt/open-box/etc/config.json}"
CLASH_API_URL="${MINIGATE_CLASH_API_URL:-http://127.0.0.1:9095}"
LOCK_FILE="${MINIGATE_DDNS_LOCK:-/var/lock/minigate-ddns.lock}"
# 运行状态只放内存,不写 /etc/config:避免每次检查都提交配置、备份里混入运行数据
STATE_DIR="${MINIGATE_DDNS_STATE_DIR:-/var/run/minigate/ddns}"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [DDNS] $*" >> "$LOGFILE"; }
trim_log() { [ -f "$LOGFILE" ] && [ "$(wc -l < "$LOGFILE")" -gt 300 ] && { tail -n 150 "$LOGFILE" > "${LOGFILE}.tmp"; mv "${LOGFILE}.tmp" "$LOGFILE"; }; }
@@ -24,6 +26,71 @@ lock_ddns() {
fi
}
# 状态文件与 IP 历史一样按域名命名;LuCI 侧用相同规则读取
state_key() {
printf '%s' "$1" | tr -c 'A-Za-z0-9_-' '_'
}
state_value() {
sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1
}
# write_state 域名 状态 说明 IPv4 IPv6 更新时间 下次检查;IP 为空时保留上次结果
write_state() {
local key file tmp ip ip6
key=$(state_key "$1")
[ -n "$key" ] || return 0
mkdir -p "$STATE_DIR" 2>/dev/null || return 0
file="${STATE_DIR}/${key}.state"
tmp="${file}.$$"
ip="$4"; ip6="$5"
[ -n "$ip" ] || ip=$(state_value "$file" ip)
[ -n "$ip6" ] || ip6=$(state_value "$file" ip6)
{
printf 'status=%s\n' "$2"
printf 'message=%s\n' "$3"
printf 'ip=%s\n' "$ip"
printf 'ip6=%s\n' "$ip6"
printf 'updated=%s\n' "$6"
printf 'next=%s\n' "$7"
} > "$tmp" && mv -f "$tmp" "$file"
}
mark_stopped() {
local sec domain file
for sec in $(uci -q -X show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1); do
domain=$(uci -q get minigate.${sec}.domain)
[ -n "$domain" ] || continue
file="${STATE_DIR}/$(state_key "$domain").state"
write_state "$domain" stopped "已停止" "" "" "$(state_value "$file" updated)" ""
done
}
# 只接受公网地址:软路由在上级路由 NAT 之后时,网卡上是内网地址,不能推到 Cloudflare
is_public_ipv4() {
printf '%s\n' "$1" | awk -F. '
NF != 4 { exit 1 }
{
for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i + 0 > 255) exit 1
a = $1 + 0; b = $2 + 0
if (a == 0 || a == 10 || a == 127 || a >= 224) exit 1
if (a == 100 && b >= 64 && b <= 127) exit 1
if (a == 169 && b == 254) exit 1
if (a == 172 && b >= 16 && b <= 31) exit 1
if (a == 192 && b == 168) exit 1
if (a == 198 && (b == 18 || b == 19)) exit 1
}'
}
# 全球单播 2000::/3;排除 ULA、链路本地等
is_public_ipv6() {
case "$1" in
*[!0-9a-fA-F:]*) return 1 ;;
[23][0-9a-fA-F]*:*) return 0 ;;
esac
return 1
}
history_now() {
if [ -n "${MINIGATE_HISTORY_NOW:-}" ]; then
printf '%s\n' "$MINIGATE_HISTORY_NOW"
@@ -158,49 +225,60 @@ get_ip4_iface() {
echo "$ip" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'
}
# 依次请求查询地址,取第一个公网 IPv4;HTTPS 优先,HTTP 仅作兜底
IP4_FALLBACK_URLS="https://ddns.oray.com/checkip http://ip.3322.net http://members.3322.org/dyndns/getip http://ns1.dnspod.net:6666"
get_ip4_url() {
local ip=""
# 备用 URL 列表(主选 + 自动回退)
local fallback_urls="http://ip.3322.net http://members.3322.org/dyndns/getip http://ns1.dnspod.net:6666 http://ip.tool.chinaz.com/getip"
local urls="$1 $fallback_urls"
for url in $urls; do
local host=$(echo "$url" | sed -E 's|https?://||;s|/.*||;s|:.*||')
local port=$(echo "$url" | grep -oE ':[0-9]+' | head -1 | tr -d ':')
local path=$(echo "$url" | sed -E 's|https?://[^/]*||')
[ -z "$path" ] && path="/"
[ -z "$port" ] && port="80"
# 方法1: 直连
ip=$(curl -4 -s --connect-timeout 3 --max-time 8 "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
[ -n "$ip" ] && { echo "$ip"; return; }
# 方法2: DNS 解析后用 Host 头直连(绕过代理)
local resolved=$(nslookup "$host" 223.5.5.5 2>/dev/null | grep -A1 'Name:' | grep 'Address:' | head -1 | awk '{print $2}')
if [ -n "$resolved" ]; then
ip=$(curl -4 -s --connect-timeout 3 --max-time 8 -H "Host: ${host}" "http://${resolved}:${port}${path}" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)
[ -n "$ip" ] && { echo "$ip"; return; }
local ip="" url
for url in $1 $IP4_FALLBACK_URLS; do
ip=$(fetch_ip4 "$url")
if [ -n "$ip" ]; then
echo "$ip"
return 0
fi
done
echo ""
}
fetch_ip4() {
local url="$1" ip candidate host port scheme resolved
host=$(echo "$url" | sed -E 's|https?://||;s|/.*||;s|:.*||')
port=$(echo "$url" | sed -E 's|https?://[^/:]*||' | grep -oE '^:[0-9]+' | tr -d ':')
scheme="http"
echo "$url" | grep -q '^https' && scheme="https"
[ -z "$port" ] && { [ "$scheme" = "https" ] && port="443" || port="80"; }
# 方法1: 直连
for candidate in $(curl -4 -s --connect-timeout 3 --max-time 8 "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -3); do
is_public_ipv4 "$candidate" && { echo "$candidate"; return 0; }
done
# 方法2: 用公共 DNS 解析后直连(绕过透明代理的 fake-ip 解析)
resolved=$(nslookup "$host" 223.5.5.5 2>/dev/null | grep -A1 'Name:' | grep 'Address:' | head -1 | awk '{print $2}')
is_public_ipv4 "$resolved" || return 0
for candidate in $(curl -4 -s --connect-timeout 3 --max-time 8 --resolve "${host}:${port}:${resolved}" "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -3); do
is_public_ipv4 "$candidate" && { echo "$candidate"; return 0; }
done
return 0
}
# ====== IPv6 获取 ======
get_ip6_iface() {
local ip="" iface="${1:-wan6}"
# 优先取全局 IPv6 地址(排除 link-local fe80::)
ip=$(ubus call network.interface.${iface} status 2>/dev/null | jsonfilter -e '@["ipv6-address"][0].address' 2>/dev/null)
[ -z "$ip" ] && ip=$(ifstatus "${iface}" 2>/dev/null | jsonfilter -e '@["ipv6-address"][0].address' 2>/dev/null)
# 也尝试从 ipv6-prefix-assignment 获取
[ -z "$ip" ] && ip=$(ubus call network.interface.${iface} status 2>/dev/null | jsonfilter -e '@["ipv6-prefix-assignment"][0]["local-address"].address' 2>/dev/null)
# 验证是有效的 IPv6(排除 link-local)
echo "$ip" | grep -v '^fe80' | grep -oE '^[0-9a-fA-F:]+$' | head -1
local status ip iface="${1:-wan6}"
status=$(ubus call network.interface.${iface} status 2>/dev/null)
[ -n "$status" ] || status=$(ifstatus "${iface}" 2>/dev/null)
# 取第一个全球单播地址(跳过 ULA、链路本地),没有时再看前缀分配给本机的地址
for ip in $(printf '%s' "$status" | jsonfilter -e '@["ipv6-address"][*].address' 2>/dev/null) \
$(printf '%s' "$status" | jsonfilter -e '@["ipv6-prefix-assignment"][*]["local-address"].address' 2>/dev/null); do
is_public_ipv6 "$ip" && { echo "$ip"; return 0; }
done
echo ""
}
get_ip6_url() {
local url="$1" ip=""
local host=$(echo "$url" | sed -E 's|https?://||;s|/.*||;s|:.*||')
local port=$(echo "$url" | grep -oE ':[0-9]+' | head -1 | tr -d ':')
local port=$(echo "$url" | sed -E 's|https?://[^/:]*||' | grep -oE '^:[0-9]+' | tr -d ':')
# 先尝试直连
ip=$(curl -6 -s --connect-timeout 5 --max-time 10 "$url" 2>/dev/null | grep -oE '([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}' | head -1)
@@ -215,7 +293,7 @@ get_ip6_url() {
ip=$(curl -6 -s --connect-timeout 5 --max-time 10 --resolve "${host}:${port}:${resolved}" "$url" 2>/dev/null | grep -oE '([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}' | head -1)
fi
fi
echo "$ip"
is_public_ipv6 "$ip" && echo "$ip"
}
# Token 经标准输入交给 curl,不出现在进程参数中。
@@ -315,7 +393,7 @@ update_one() {
[ -z "$domain" ] || [ -z "$zone_id" ] || [ -z "$token" ] && {
log "$domain: 配置不完整"
uci -q set minigate.${sec}.status="error"; uci -q set minigate.${sec}.status_msg="配置不完整"
write_state "$domain" error "配置不完整" "" "" "$(date '+%Y-%m-%d %H:%M:%S')" ""
return 1
}
@@ -333,7 +411,12 @@ update_one() {
local ip4=""
case "$ip_source" in url) ip4=$(get_ip4_url "$ip_url");; *) ip4=$(get_ip4_iface "$iface");; esac
if [ -z "$ip4" ]; then
if [ -n "$ip4" ] && ! is_public_ipv4 "$ip4"; then
log "$domain: 网卡地址 $ip4 不是公网地址(可能在上级路由 NAT 之后),请改用「从外部URL获取」"
msgs="${msgs}IPv4 ${ip4} 不是公网地址; "
any_fail=1
ip4=""
elif [ -z "$ip4" ]; then
log "$domain: 获取IPv4失败"
msgs="${msgs}IPv4获取失败; "
any_fail=1
@@ -406,18 +489,13 @@ update_one() {
fi
# ====== 更新状态 ======
local status="error"
if [ "$any_ok" = "1" ] && [ "$any_fail" = "0" ]; then
uci -q set minigate.${sec}.status="ok"
status="ok"
elif [ "$any_ok" = "1" ] && [ "$any_fail" = "1" ]; then
uci -q set minigate.${sec}.status="partial"
else
uci -q set minigate.${sec}.status="error"
status="partial"
fi
uci -q set minigate.${sec}.status_msg="$msgs"
[ -n "$result_ip" ] && uci -q set minigate.${sec}.last_ip="$result_ip"
[ -n "$result_ip6" ] && uci -q set minigate.${sec}.last_ip6="$result_ip6"
uci -q set minigate.${sec}.last_update="$(date '+%Y-%m-%d %H:%M:%S')"
uci -q set minigate.${sec}.next_sync="$next_time"
write_state "$domain" "$status" "$msgs" "$result_ip" "$result_ip6" "$(date '+%Y-%m-%d %H:%M:%S')" "$next_time"
[ "$refresh_dns" = "1" ] && refresh_local_dns_caches "$domain" || true
}
@@ -448,7 +526,6 @@ main() {
local c=0
for sec in $due; do update_one "$sec" "$force"; c=$((c + 1)); done
[ "$c" = "0" ] && log "无 DDNS 记录"
uci commit minigate
log "--- 同步完成 ($c 条) ---"
trim_log
}
@@ -462,9 +539,13 @@ if [ "$1" = "single" ] && [ -n "$2" ]; then
lock_ddns wait
log "手动同步: $2"
update_one "$2" "1"
uci commit minigate
trim_log
exit 0
fi
if [ "$1" = "mark-stopped" ]; then
mark_stopped
exit 0
fi
main "$@"
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
# 用随包的离线地址库查询省级归属地,命中时输出地区名;未命中输出空。
GEO_DIR="${MINIGATE_GEO_DIR:-/usr/lib/minigate/geo}"
ip="$1"
case "$ip" in
''|*[!0-9a-fA-F:.]*) exit 1 ;;
esac
code=$(awk -v ip="$ip" '
function v4(a, p, n, i, v) {
n = split(a, p, ".")
if (n != 4) return -1
v = 0
for (i = 1; i <= 4; i++) {
if (p[i] !~ /^[0-9]+$/ || p[i] + 0 > 255) return -1
v = v * 256 + p[i]
}
return v
}
function pad(g) { while (length(g) < 4) g = "0" g; return g }
function v6(a, halves, left, right, l, r, i, out, missing) {
a = tolower(a)
if (a !~ /^[0-9a-f:]+$/) return ""
if (split(a, halves, "::") > 2) return ""
l = (halves[1] == "") ? 0 : split(halves[1], left, ":")
r = (index(a, "::") && halves[2] != "") ? split(halves[2], right, ":") : 0
if (!index(a, "::") && l != 8) return ""
missing = 8 - l - r
if (missing < 0) return ""
out = ""
for (i = 1; i <= l; i++) { if (length(left[i]) > 4 || left[i] == "") return ""; out = out pad(left[i]) }
for (i = 0; i < missing; i++) out = out "0000"
for (i = 1; i <= r; i++) { if (length(right[i]) > 4 || right[i] == "") return ""; out = out pad(right[i]) }
return out
}
function hexval(c) { return index("0123456789abcdef", c) - 1 }
BEGIN {
family = index(ip, ":") ? 6 : 4
if (family == 4) { want4 = v4(ip); if (want4 < 0) exit }
else { want6 = v6(ip); if (want6 == "") exit }
}
/^[0-9a-fA-F.:]+\/[0-9]+ 1;$/ {
net = $1; sub(/ .*/, "", net)
addr = net; sub(/\/.*/, "", addr)
bits = net; sub(/^[^\/]*\//, "", bits); bits = bits + 0
if (family == 4) {
if (index(addr, ":")) next
d = 2 ^ (32 - bits)
if (int(want4 / d) == int(v4(addr) / d)) { found = FILENAME; exit }
} else {
if (!index(addr, ":")) next
b = v6(addr)
full = int(bits / 4)
if (substr(want6, 1, full) != substr(b, 1, full)) next
rest = bits - full * 4
if (rest == 0) { found = FILENAME; exit }
d = 2 ^ (4 - rest)
if (int(hexval(substr(want6, full + 1, 1)) / d) == int(hexval(substr(b, full + 1, 1)) / d)) { found = FILENAME; exit }
}
}
END {
if (found != "") { sub(/^.*\//, "", found); sub(/\.conf$/, "", found); print found }
}
' "$GEO_DIR"/[0-9]*.conf 2>/dev/null)
[ -n "$code" ] || exit 0
awk -v code="$code" '$1 == code { $1 = ""; sub(/^ /, ""); print; exit }' "$GEO_DIR/regions.txt"
-198
View File
@@ -1,198 +0,0 @@
#!/bin/sh
# MiniGate GeoFence - 地区访问控制
# 检查反代访问日志,非允许地区的 IP 用防火墙封禁
LOGFILE="${MINIGATE_GEOFENCE_LOG:-/var/log/minigate-geofence.log}"
CACHE_DIR="${MINIGATE_GEO_CACHE_DIR:-/tmp/minigate_geo}"
BLOCK_SET="minigate_blocked"
ACCESS_LOG="${MINIGATE_ACCESS_LOG:-/var/log/minigate-access.log}"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [GEOFENCE] $*" >> "$LOGFILE"; }
# 查询 IP 归属地,返回省份名
lookup_geo() {
local ip="$1"
local cache="$CACHE_DIR/${ip}"
# 缓存命中
[ -f "$cache" ] && cat "$cache" && return 0
local geo=""
# API 1: pconline
if [ -z "$geo" ]; then
local raw=$(curl -s --connect-timeout 3 --max-time 5 "https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true" 2>/dev/null)
geo=$(echo "$raw" | jsonfilter -e '$.pro' 2>/dev/null)
fi
# API 2: ip9.com.cn
if [ -z "$geo" ]; then
local raw=$(curl -s --connect-timeout 3 --max-time 5 "https://ip9.com.cn/get?ip=${ip}" 2>/dev/null)
geo=$(echo "$raw" | jsonfilter -e '$.data.prov' 2>/dev/null)
fi
# API 3: ip-api.com
if [ -z "$geo" ]; then
local raw=$(curl -s --connect-timeout 3 --max-time 5 "http://ip-api.com/json/${ip}?fields=regionName" 2>/dev/null)
geo=$(echo "$raw" | jsonfilter -e '$.regionName' 2>/dev/null)
fi
[ -z "$geo" ] && geo="unknown"
# 写入缓存
mkdir -p "$CACHE_DIR"
echo "$geo" > "$cache"
echo "$geo"
}
# 检查 IP 是否在允许的地区列表中
is_allowed() {
local geo="$1"
local allowed=$(uci -q get minigate.global.geo_allowed)
[ -z "$allowed" ] && return 0 # 未配置地区限制,全部放行
# allowed 是逗号分隔的省份列表,如 "四川,重庆,贵州"
echo "$allowed" | tr ',' '\n' | while read prov; do
[ -z "$prov" ] && continue
echo "$geo" | grep -q "$prov" && echo "yes" && return 0
done | grep -q "yes"
}
# 初始化 ipset(nftables 或 iptables)
init_block_set() {
if command -v nft >/dev/null 2>&1; then
# nftables (OpenWrt 22+)
nft list set inet fw4 "$BLOCK_SET" >/dev/null 2>&1 || {
nft add set inet fw4 "$BLOCK_SET" '{ type ipv4_addr; flags timeout; }'
nft add rule inet fw4 input ip saddr @"$BLOCK_SET" drop 2>/dev/null
log "nftables 封禁集合已创建"
}
else
# iptables fallback
ipset list "$BLOCK_SET" >/dev/null 2>&1 || {
ipset create "$BLOCK_SET" hash:ip timeout 43200 2>/dev/null # 12小时超时
iptables -I INPUT -m set --match-set "$BLOCK_SET" src -j DROP 2>/dev/null
log "ipset 封禁集合已创建"
}
fi
}
# 封禁 IP
block_ip() {
local ip="$1" reason="$2"
if command -v nft >/dev/null 2>&1; then
nft add element inet fw4 "$BLOCK_SET" "{ $ip timeout 12h }" 2>/dev/null
else
ipset add "$BLOCK_SET" "$ip" timeout 43200 2>/dev/null
fi
log "封禁: $ip ($reason) 12小时"
# 记录到 UCI
local ts=$(date '+%Y-%m-%d %H:%M:%S')
uci -q add_list minigate.global.blocked_ips="${ip}|${reason}|${ts}"
uci commit minigate 2>/dev/null
}
# 清理过期缓存(超过24小时的)
clean_cache() {
[ -d "$CACHE_DIR" ] && find "$CACHE_DIR" -type f -mmin +1440 -delete 2>/dev/null
}
# 局域网、回环、链路本地和运营商 NAT 地址没有归属地,局域网直连时访客就是内网 IP,不参与地区判断
is_private_ip() {
case "$1" in
0.*|10.*|127.*|169.254.*|192.168.*) return 0 ;;
172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 0 ;;
100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) return 0 ;;
esac
return 1
}
# BusyBox grep 不支持 -P,用 sed 取 IPv4 访客地址
extract_public_ips() {
local ip
sed -n 's/.*"client":"\([0-9]\{1,3\}\(\.[0-9]\{1,3\}\)\{3\}\)".*/\1/p' "$ACCESS_LOG" 2>/dev/null | \
sort -u | while read -r ip; do
is_private_ip "$ip" || echo "$ip"
done
}
# 主逻辑:扫描 access log 中的新 IP
scan_and_block() {
# 请求前准入已覆盖公网反代,避免旧扫描器再把来源全局封禁到其他服务。
[ "$(uci -q get minigate.global.access_enabled)" = "1" ] && return 0
local enabled=$(uci -q get minigate.global.geo_enabled)
[ "$enabled" != "1" ] && return 0
local allowed=$(uci -q get minigate.global.geo_allowed)
[ -z "$allowed" ] && { log "未配置允许地区,跳过"; return 0; }
init_block_set
local ips=$(extract_public_ips)
local checked=0 blocked=0
for ip in $ips; do
# 已经封禁的跳过
if command -v nft >/dev/null 2>&1; then
nft list set inet fw4 "$BLOCK_SET" 2>/dev/null | grep -q "$ip" && continue
else
ipset test "$BLOCK_SET" "$ip" 2>/dev/null && continue
fi
local geo=$(lookup_geo "$ip")
checked=$((checked + 1))
if [ "$geo" = "unknown" ]; then
log "警告: $ip 归属地未知,暂时放行"
continue
fi
if ! is_allowed "$geo"; then
block_ip "$ip" "$geo"
blocked=$((blocked + 1))
else
log "放行: $ip ($geo)"
fi
# 限速,避免 API 过载
sleep 1
done
[ "$checked" -gt 0 ] && log "扫描完成: 检查 $checked 个IP,封禁 $blocked 个"
clean_cache
}
# 手动解封
unblock_ip() {
local ip="$1"
if command -v nft >/dev/null 2>&1; then
nft delete element inet fw4 "$BLOCK_SET" "{ $ip }" 2>/dev/null
else
ipset del "$BLOCK_SET" "$ip" 2>/dev/null
fi
log "解封: $ip"
}
# 清除所有封禁
flush_blocks() {
if command -v nft >/dev/null 2>&1; then
nft flush set inet fw4 "$BLOCK_SET" 2>/dev/null
else
ipset flush "$BLOCK_SET" 2>/dev/null
fi
uci -q delete minigate.global.blocked_ips
uci commit minigate 2>/dev/null
log "已清除所有封禁"
}
if [ "${MINIGATE_GEOFENCE_LIBRARY_ONLY:-0}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
case "${1:-scan}" in
scan) scan_and_block ;;
unblock) unblock_ip "$2" ;;
flush) flush_blocks ;;
*) echo "用法: $0 {scan|unblock <ip>|flush}" ;;
esac
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# 后台执行耗时操作(签发证书、手动同步 DDNS),LuCI 轮询状态文件,不再卡住网页请求。
# 用法: job.sh <任务名> <命令...>
JOB_DIR="${MINIGATE_JOB_DIR:-/var/run/minigate/jobs}"
name="$1"
shift
case "$name" in ''|*[!A-Za-z0-9_-]*) echo "无效任务名" >&2; exit 2 ;; esac
[ "$#" -gt 0 ] || { echo "用法: $0 <任务名> <命令...>" >&2; exit 2; }
mkdir -p "$JOB_DIR" || exit 1
file="${JOB_DIR}/${name}.state"
write_job() {
printf 'status=%s\nstarted=%s\nfinished=%s\ncode=%s\n' "$1" "$2" "$3" "$4" > "${file}.$$" &&
mv -f "${file}.$$" "$file"
}
started=$(date +%s)
write_job running "$started" "" ""
"$@" </dev/null >/dev/null 2>&1
code=$?
write_job done "$started" "$(date +%s)" "$code"
exit "$code"
+302 -110
View File
@@ -1,9 +1,9 @@
#!/bin/sh
# MiniGate · 登录防护(Login Guard)
# 监控 SSH (dropbear) 和 LuCI 网页登录失败,达到阈值自动封禁源 IP
# 监控 SSH (dropbear) 和 LuCI 网页登录失败,达到阈值自动封禁源 IP(IPv4 / IPv6)
#
# 用法:
# login_guard.sh run 后台监控(init.d 调用)
# login_guard.sh run 后台监控(由 procd 启动)
# login_guard.sh list 列出当前已封禁的 IP
# login_guard.sh status 状态总览
# login_guard.sh ban <IP> 手动封禁
@@ -16,15 +16,21 @@ ENABLED=$(uci -q get minigate.login_guard.enabled || echo "0")
THRESHOLD=$(uci -q get minigate.login_guard.threshold || echo "3")
BANTIME=$(uci -q get minigate.login_guard.bantime || echo "43200")
WINDOW=$(uci -q get minigate.login_guard.window || echo "600")
case "$THRESHOLD" in ''|*[!0-9]*|0) THRESHOLD=3 ;; esac
case "$BANTIME" in ''|*[!0-9]*|0) BANTIME=43200 ;; esac
case "$WINDOW" in ''|*[!0-9]*|0) WINDOW=600 ;; esac
DATA_DIR="${MINIGATE_LG_DATA_DIR:-/etc/minigate/login-guard}"
RUN_DIR="${MINIGATE_LG_RUN_DIR:-/var/run/minigate/login-guard}"
BANS_FILE="$DATA_DIR/bans.txt"
COUNTER_DIR="$RUN_DIR/counters"
LOG_FILE="${MINIGATE_LG_LOG:-/var/log/minigate-login-guard.log}"
ACCESS_LOG=/var/log/minigate-access.log
SET="login_banned_v4"
ACCESS_LOG="${MINIGATE_ACCESS_LOG:-/var/log/minigate-access.log}"
SET4="login_banned_v4"
SET6="login_banned_v6"
TABLE="inet fw4"
WATCHDOG_INTERVAL="${MINIGATE_LG_WATCHDOG:-30}"
LAN6_NETS=""
mkdir -p "$DATA_DIR" "$COUNTER_DIR"
touch "$BANS_FILE"
@@ -34,62 +40,212 @@ log() {
logger -t minigate-lg "$*"
}
# === 白名单:从 uci 读 list whitelist + 默认私有段 ===
fmt_duration() {
local s=$1
if [ "$s" -ge 86400 ] && [ $((s % 86400)) -eq 0 ]; then
printf "%d天" $((s / 86400))
elif [ "$s" -ge 3600 ]; then
if [ $((s % 3600)) -eq 0 ]; then
printf "%d小时" $((s / 3600))
else
printf "%d小时%d分" $((s / 3600)) $(((s % 3600) / 60))
fi
elif [ "$s" -ge 60 ]; then
printf "%d分%d秒" $((s / 60)) $((s % 60))
else
printf "%d秒" "$s"
fi
}
# 输出 4 / 6,无效地址输出空。IPv6 只接受纯十六进制写法,地址会原样进入 nft 命令。
ip_family() {
case "$1" in
*[!0-9.]*) ;;
*.*.*.*)
printf '%s\n' "$1" | awk -F. 'NF == 4 { for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i > 255) exit 1; ok = 1 } END { exit !ok }' && echo 4
return 0
;;
esac
case "$1" in
*:*:*)
case "$1" in *[!0-9a-fA-F:]*) return 0 ;; esac
[ "${#1}" -le 39 ] && echo 6
;;
esac
return 0
}
set_for_ip() {
case "$(ip_family "$1")" in
4) echo "$SET4" ;;
6) echo "$SET6" ;;
esac
}
# 判断 IP 是否属于 CIDR(IPv4 任意前缀;IPv6 展开后逐位比较)
cidr_match() {
awk -v ip="$1" -v net="$2" '
function v4(a, p, n, i, v) {
n = split(a, p, ".")
if (n != 4) return -1
v = 0
for (i = 1; i <= 4; i++) {
if (p[i] !~ /^[0-9]+$/ || p[i] + 0 > 255) return -1
v = v * 256 + p[i]
}
return v
}
function pad(g) { while (length(g) < 4) g = "0" g; return g }
function v6(a, halves, left, right, l, r, i, out, missing, g) {
a = tolower(a)
if (a !~ /^[0-9a-f:]+$/) return ""
if (split(a, halves, "::") > 2) return ""
l = (halves[1] == "") ? 0 : split(halves[1], left, ":")
r = (index(a, "::") && halves[2] != "") ? split(halves[2], right, ":") : 0
if (!index(a, "::") && l != 8) return ""
missing = 8 - l - r
if (missing < 0) return ""
out = ""
for (i = 1; i <= l; i++) { if (length(left[i]) > 4 || left[i] == "") return ""; out = out pad(left[i]) }
for (i = 0; i < missing; i++) out = out "0000"
for (i = 1; i <= r; i++) { if (length(right[i]) > 4 || right[i] == "") return ""; out = out pad(right[i]) }
return out
}
function hexval(c) { return index("0123456789abcdef", c) - 1 }
BEGIN {
addr = net; bits = -1
if (index(net, "/")) { addr = substr(net, 1, index(net, "/") - 1); bits = substr(net, index(net, "/") + 1) }
if (bits != -1 && bits !~ /^[0-9]+$/) exit 1
bits = bits + 0
if (index(ip, ":")) {
a = v6(ip); b = v6(addr)
if (a == "" || b == "") exit 1
if (bits == -1) bits = 128
if (bits > 128) exit 1
full = int(bits / 4)
if (substr(a, 1, full) != substr(b, 1, full)) exit 1
rest = bits - full * 4
if (rest == 0) exit 0
d = 2 ^ (4 - rest)
exit !(int(hexval(substr(a, full + 1, 1)) / d) == int(hexval(substr(b, full + 1, 1)) / d))
}
a = v4(ip); b = v4(addr)
if (a < 0 || b < 0) exit 1
if (bits == -1) bits = 32
if (bits > 32) exit 1
d = 2 ^ (32 - bits)
exit !(int(a / d) == int(b / d))
}'
}
# LAN 接口实际分配到的全球 IPv6 子网也属于内网
load_lan6_nets() {
local status address mask i=0 nets=""
status=$(ubus call network.interface.lan status 2>/dev/null)
if [ -n "$status" ]; then
while [ "$i" -lt 32 ]; do
address=$(printf '%s' "$status" | jsonfilter -e "@[\"ipv6-prefix-assignment\"][${i}].address" 2>/dev/null)
mask=$(printf '%s' "$status" | jsonfilter -e "@[\"ipv6-prefix-assignment\"][${i}].mask" 2>/dev/null)
[ -n "$address" ] || break
i=$((i + 1))
case "$mask" in ''|*[!0-9]*) continue ;; esac
[ "$mask" -ge 48 ] && [ "$mask" -le 128 ] || continue
nets="$nets ${address}/${mask}"
done
fi
LAN6_NETS="$nets"
}
# === 白名单:默认私有段 + LAN 的 IPv6 子网 + uci list whitelist ===
is_whitelisted() {
local ip="$1"
local ip="$1" net
case "$ip" in
192.168.*|10.*|127.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 0 ;;
::1|[fF][cCdD]*:*|[fF][eE][89aAbB]*:*) return 0 ;;
esac
local wl
wl=$(uci -q get minigate.login_guard.whitelist 2>/dev/null)
[ -z "$wl" ] && return 1
for net in $wl; do
case "$net" in
"$ip") return 0 ;;
*/[0-9]*)
# 简单 CIDR 匹配(前缀 byte 比较)
local prefix="${net%/*}"
local mask="${net#*/}"
# 只比 /8 /16 /24 /32 这种整字节边界(够用)
case "$mask" in
8) [ "${ip%%.*}" = "${prefix%%.*}" ] && return 0 ;;
16) [ "${ip%.*.*}" = "${prefix%.*.*}" ] && return 0 ;;
24) [ "${ip%.*}" = "${prefix%.*}" ] && return 0 ;;
32) [ "$ip" = "$prefix" ] && return 0 ;;
esac
;;
esac
for net in $LAN6_NETS $(uci -q get minigate.login_guard.whitelist 2>/dev/null); do
cidr_match "$ip" "$net" && return 0
done
return 1
}
# === 确保 nft set + drop rule 存在(fw4 reload 后自动重建) ===
# 返回 0 表示刚重建过,调用方需要从持久化文件恢复封禁。
nft_set_spec() {
case "$1" in
"$SET6") echo '{ type ipv6_addr; flags timeout; }' ;;
*) echo '{ type ipv4_addr; flags timeout; }' ;;
esac
}
nft_rule_match() {
case "$1" in
"$SET6") echo "ip6 saddr" ;;
*) echo "ip saddr" ;;
esac
}
# === 确保 nft set + drop rule 存在 ===
# 规则和集合随包放在 /usr/share/nftables.d,防火墙每次重载都会带上;这里兜底处理
# 安装后防火墙尚未重载的情况。返回 0 表示刚重建过,调用方需要从持久化文件恢复封禁。
ensure_nft() {
local recreated=0
if ! nft list set $TABLE $SET >/dev/null 2>&1; then
nft "add set $TABLE $SET { type ipv4_addr; flags timeout; timeout ${BANTIME}s; }" 2>/dev/null
recreated=1
fi
if ! nft list chain $TABLE input 2>/dev/null | grep -q "@$SET"; then
nft "insert rule $TABLE input ip saddr @$SET counter drop comment \"minigate-login-guard\"" 2>/dev/null
recreated=1
fi
local recreated=0 set chain
for set in "$SET4" "$SET6"; do
if ! nft list set $TABLE $set >/dev/null 2>&1; then
nft "add set $TABLE $set $(nft_set_spec "$set")" 2>/dev/null
recreated=1
fi
done
chain=$(nft list chain $TABLE input 2>/dev/null)
for set in "$SET4" "$SET6"; do
if ! printf '%s\n' "$chain" | grep -q "@$set"; then
nft "insert rule $TABLE input $(nft_rule_match "$set") @$set counter drop comment \"minigate-login-guard\"" 2>/dev/null
recreated=1
fi
done
[ $recreated -eq 1 ] || return 1
log "已创建 nft set + drop 规则"
return 0
}
# 防火墙重载后,随包的集合会重新出现但内容为空;持久化文件里仍有未过期封禁时恢复
sets_lost_bans() {
local now set
now=$(date +%s)
for set in "$SET4" "$SET6"; do
nft list set $TABLE $set 2>/dev/null | grep -q 'elements' && continue
while read -r ip exp; do
[ -n "$ip" ] || continue
case "$exp" in ''|*[!0-9]*) continue ;; esac
[ "$exp" -gt "$now" ] || continue
[ "$(set_for_ip "$ip")" = "$set" ] && return 0
done < "$BANS_FILE"
done
return 1
}
add_ban_element() {
local ip="$1" seconds="$2" set
set=$(set_for_ip "$ip")
[ -n "$set" ] || return 1
nft add element $TABLE $set "{ $ip timeout ${seconds}s }" 2>/dev/null
}
persist_ban() {
local ip="$1" exp="$2"
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
echo "$ip $exp" >> "${BANS_FILE}.tmp"
mv "${BANS_FILE}.tmp" "$BANS_FILE"
}
# === 把 IP 加入封禁集合 + 持久化 ===
ban_ip() {
local ip="$1"
local exp=$(($(date +%s) + BANTIME))
nft add element $TABLE $SET "{ $ip timeout ${BANTIME}s }" 2>/dev/null
# 去重写入持久化文件
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
echo "$ip $exp" >> "${BANS_FILE}.tmp"
mv "${BANS_FILE}.tmp" "$BANS_FILE"
log "BAN $ip 12小时(达到 $THRESHOLD 次失败)"
if ! add_ban_element "$ip" "$BANTIME"; then
log "封禁 $ip 失败:防火墙未接受该地址"
return 1
fi
persist_ban "$ip" "$exp"
log "BAN $ip $(fmt_duration "$BANTIME")(达到 $THRESHOLD 次失败)"
rm -f "$COUNTER_DIR/$ip"
}
@@ -102,9 +258,9 @@ restore_bans() {
local count=0
while read -r ip exp; do
[ -z "$ip" ] && continue
if [ "$exp" -gt "$now" ]; then
local remaining=$((exp - now))
nft add element $TABLE $SET "{ $ip timeout ${remaining}s }" 2>/dev/null
case "$exp" in ''|*[!0-9]*) continue ;; esac
if [ "$exp" -gt "$now" ] && [ -n "$(set_for_ip "$ip")" ]; then
add_ban_element "$ip" $((exp - now))
echo "$ip $exp" >> "$tmp"
count=$((count + 1))
fi
@@ -117,6 +273,7 @@ restore_bans() {
handle_failure() {
local ip="$1"
[ -z "$ip" ] && return
[ -n "$(ip_family "$ip")" ] || return
is_whitelisted "$ip" && return
# 滑动窗口:超过 window 秒的旧失败丢弃
@@ -139,14 +296,24 @@ handle_failure() {
if [ "$count" -ge "$THRESHOLD" ]; then
# 加 ban 前再确认 nft 还在(fw4 可能重载)
local ban_target="$ip"
repair_nft
ban_ip "$ban_target"
ban_ip "$ip"
fi
}
# 取日志行里最后一个 " from " 后的地址:用户名由访客提供,可能伪造前面的 from。
# dropbear 以 "地址:端口" 记录(IPv6 不加方括号),LuCI 只记录地址。
extract_ip() {
echo "$1" | grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" | head -1
local line="$1" with_port="$2" addr
addr=${line##* from }
addr=${addr%% *}
case "$addr" in
\[*\]:*) addr=${addr#\[}; addr=${addr%%\]*} ;;
*)
[ "$with_port" = "1" ] && case "$addr" in *:[0-9]*) addr=${addr%:*} ;; esac
;;
esac
[ -n "$(ip_family "$addr")" ] && echo "$addr"
}
extract_json_field() {
@@ -171,8 +338,24 @@ access_log_loop() {
done
}
syslog_loop() {
logread -f 2>/dev/null | while read -r line; do
case "$line" in
*"Bad password attempt"*"from"*|\
*"Login attempt for nonexistent user"*"from"*)
handle_failure "$(extract_ip "$line" 1)"
;;
*"luci: failed login"*"from"*)
handle_failure "$(extract_ip "$line" 0)"
;;
esac
done
}
repair_nft() {
ensure_nft && restore_bans
if ensure_nft || sets_lost_bans; then
restore_bans
fi
return 0
}
@@ -183,15 +366,42 @@ trim_log() {
tail -n 500 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv -f "${LOG_FILE}.tmp" "$LOG_FILE"
}
# === 后台 watchdog:每 5 分钟检查 nft 资源 ===
# === 后台 watchdog:防火墙重载后尽快恢复封禁 ===
watchdog_loop() {
local tick=0
while true; do
sleep 300
sleep "$WATCHDOG_INTERVAL"
repair_nft
trim_log
tick=$((tick + 1))
if [ $((tick % 10)) -eq 0 ]; then
load_lan6_nets
trim_log
fi
done
}
stop_process_tree() {
local child
for child in $(pgrep -P "$1" 2>/dev/null); do
stop_process_tree "$child"
done
kill "$1" 2>/dev/null
}
# procd 停止时只向本进程发 TERM;logread -f / tail -f 忽略 SIGPIPE 不会自行退出,需逐个结束。
# 停止后不再拦截(与旧版移除规则一致),持久化文件保留,下次启动恢复。
stop_watcher() {
local child
trap - TERM INT
for child in $(pgrep -P $$ 2>/dev/null); do
stop_process_tree "$child"
done
nft flush set $TABLE $SET4 2>/dev/null
nft flush set $TABLE $SET6 2>/dev/null
log "登录防护已停止"
exit 0
}
# === 主监控循环 ===
run_watcher() {
[ "$ENABLED" = "1" ] || {
@@ -199,28 +409,19 @@ run_watcher() {
exit 0
}
load_lan6_nets
ensure_nft
restore_bans
log "登录防护启动 阈值=$THRESHOLD 窗口=${WINDOW}s 封禁=${BANTIME}s"
log "登录防护启动 阈值=$THRESHOLD 窗口=${WINDOW}s 封禁=$(fmt_duration "$BANTIME")"
# 后台 watchdog
trap stop_watcher TERM INT
watchdog_loop &
# MiniGate 反代访问 LuCI 时,LuCI 日志只会看到反代内网 IP。
# 这里补充监听 nginx access log,用 JSON client 字段记录真实公网 IP。
access_log_loop &
# 主循环:监控系统日志
logread -f 2>/dev/null | while read -r line; do
case "$line" in
*"Bad password attempt"*"from"*|\
*"Login attempt for nonexistent user"*"from"*|\
*"luci: failed login"*"from"*)
ip=$(extract_ip "$line")
handle_failure "$ip"
;;
esac
done
syslog_loop &
# 在 wait 中才能及时响应 TERM;任一循环全部退出后进程结束,由 procd 重新拉起
wait
}
# === CLI 命令 ===
@@ -228,37 +429,26 @@ run_watcher() {
# 用 nft -j 拿封禁列表,输出每行: <IP> <剩余秒数>
# 注意 nft -j 输出可能有空格、字段顺序也不固定(有的 elem 多个 timeout 字段)
parse_set_json() {
nft -j list set $TABLE $SET 2>/dev/null | awk '
{
# 在每条 elem 块({...})内匹配 val + expires
# [^{}]* 限制不跨越 brace 边界
while (match($0, /"val":[ ]*"[0-9.]+"[^{}]*"expires":[ ]*[0-9]+/)) {
block = substr($0, RSTART, RLENGTH)
$0 = substr($0, RSTART + RLENGTH)
# 提取 IP
if (match(block, /[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/)) {
ip = substr(block, RSTART, RLENGTH)
local set
for set in "$SET4" "$SET6"; do
nft -j list set $TABLE $set 2>/dev/null | awk '
{
# 在每条 elem 块({...})内匹配 val + expires;[^{}]* 限制不跨越 brace 边界
while (match($0, /"val":[ ]*"[0-9a-fA-F:.]+"[^{}]*"expires":[ ]*[0-9]+/)) {
block = substr($0, RSTART, RLENGTH)
$0 = substr($0, RSTART + RLENGTH)
if (match(block, /"val":[ ]*"[0-9a-fA-F:.]+"/)) {
ip = substr(block, RSTART, RLENGTH)
sub(/^"val":[ ]*"/, "", ip); sub(/"$/, "", ip)
}
if (match(block, /"expires":[ ]*[0-9]+/)) {
exp_str = substr(block, RSTART, RLENGTH)
gsub(/[^0-9]/, "", exp_str)
print ip, exp_str
}
}
# 提取 expires
if (match(block, /"expires":[ ]*[0-9]+/)) {
exp_str = substr(block, RSTART, RLENGTH)
gsub(/[^0-9]/, "", exp_str)
print ip, exp_str
}
}
}'
}
# 把秒数转人类可读
fmt_duration() {
local s=$1
if [ "$s" -ge 3600 ]; then
printf "%dh%dm" $((s/3600)) $(((s%3600)/60))
elif [ "$s" -ge 60 ]; then
printf "%dm%ds" $((s/60)) $((s%60))
else
printf "%ds" "$s"
fi
}'
done
}
cmd_list() {
@@ -270,7 +460,7 @@ cmd_list() {
return
fi
echo "$out" | while read -r ip secs; do
printf " %-18s 剩余 %s\n" "$ip" "$(fmt_duration "$secs")"
printf " %-40s 剩余 %s\n" "$ip" "$(fmt_duration "$secs")"
done
}
@@ -293,15 +483,16 @@ cmd_status() {
read -r first count < "$f"
now=$(date +%s)
age=$((now - first))
printf " %-18s %d/%d (距首次失败 %ds)\n" "$ip" "$count" "$THRESHOLD" "$age"
printf " %-40s %d/%d (距首次失败 %ds)\n" "$ip" "$count" "$THRESHOLD" "$age"
done
fi
}
cmd_unban() {
local ip="$1"
[ -z "$ip" ] && { echo "用法: $0 unban <IP>"; exit 1; }
nft delete element $TABLE $SET "{ $ip }" 2>&1
local ip="$1" set
set=$(set_for_ip "$ip")
[ -n "$set" ] || { echo "用法: $0 unban <IP>"; exit 1; }
nft delete element $TABLE $set "{ $ip }" 2>&1
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
mv "${BANS_FILE}.tmp" "$BANS_FILE"
rm -f "$COUNTER_DIR/$ip"
@@ -311,19 +502,20 @@ cmd_unban() {
cmd_ban() {
local ip="$1"
[ -z "$ip" ] && { echo "用法: $0 ban <IP>"; exit 1; }
[ -n "$(ip_family "$ip")" ] || { echo "用法: $0 ban <IP>"; exit 1; }
ensure_nft
local exp=$(($(date +%s) + BANTIME))
nft add element $TABLE $SET "{ $ip timeout ${BANTIME}s }" 2>&1
grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true
echo "$ip $exp" >> "${BANS_FILE}.tmp"
mv "${BANS_FILE}.tmp" "$BANS_FILE"
if ! add_ban_element "$ip" "$BANTIME"; then
echo "✗ 封禁 $ip 失败"
exit 1
fi
persist_ban "$ip" $(($(date +%s) + BANTIME))
echo "✓ 已封禁 $ip"
log "MANUAL ban $ip"
}
cmd_flush() {
nft flush set $TABLE $SET 2>&1 || true
nft flush set $TABLE $SET4 2>&1 || true
nft flush set $TABLE $SET6 2>&1 || true
> "$BANS_FILE"
rm -rf "$COUNTER_DIR"/*
echo "✓ 已清空所有封禁"
@@ -356,7 +548,7 @@ case "${1:-help}" in
cat <<EOH
MiniGate 登录防护
$0 run 后台监控(init.d 用)
$0 run 后台监控(procd 启动)
$0 list 列出已封禁 IP
$0 status 状态总览
$0 ban <IP> 手动封禁
+225 -92
View File
@@ -15,9 +15,40 @@ ACCESS_LOG_MAX="${MINIGATE_ACCESS_LOG_MAX:-1048576}"
LAN_HOSTS_FILE="${MINIGATE_LAN_HOSTS_FILE:-/tmp/hosts/minigate}"
GEO_DIR="${MINIGATE_GEO_DIR:-/usr/lib/minigate/geo}"
MIME_TYPES="${MINIGATE_MIME_TYPES:-/etc/nginx/mime.types}"
LOCK_FILE="${MINIGATE_PROXY_LOCK:-/var/lock/minigate-proxy.lock}"
STATUS_FILE="${MINIGATE_PROXY_STATUS:-/var/run/minigate/proxy.status}"
INIT_SCRIPT="${MINIGATE_INIT:-/etc/init.d/minigate}"
# 每次生成使用独立临时目录记录端口、默认站点和通配符设置,结束后删除
GEN_TMP=""
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [PROXY] $*" >> "$LOGFILE"; }
# 生成配置时的提示同时写日志,并在总览页显示
warn() {
log "$*"
[ -n "$GEN_TMP" ] && printf '%s\n' "$*" >> "$GEN_TMP/warnings"
}
# LuCI 保存、证书续期和更新可能同时触发生成;串行化,避免互相删除刚生成的文件。
lock_proxy() {
command -v flock >/dev/null 2>&1 || return 0
mkdir -p "$(dirname "$LOCK_FILE")" 2>/dev/null
exec 6>"$LOCK_FILE" || return 0
flock -x 6
}
write_status() {
local result="$1" message="$2" tmp
mkdir -p "$(dirname "$STATUS_FILE")" 2>/dev/null || return 0
tmp="${STATUS_FILE}.$$"
{
printf 'result=%s\n' "$result"
printf 'time=%s\n' "$(date '+%Y-%m-%d %H:%M:%S')"
printf 'message=%s\n' "$message"
[ -n "$GEN_TMP" ] && [ -f "$GEN_TMP/warnings" ] && sed 's/^/warning=/' "$GEN_TMP/warnings"
} > "$tmp" && mv -f "$tmp" "$STATUS_FILE"
}
generate_main_conf() {
local http_redirect=$(get_http_redirect)
local use_stream=0
@@ -27,7 +58,7 @@ generate_main_conf() {
use_stream=1
fi
if [ "$use_stream" = "1" ] && [ ! -f "$STREAM_MODULE" ]; then
log "同端口HTTP跳转需要 nginx-mod-stream"
warn "同端口HTTP跳转需要 nginx-mod-stream,请先安装"
return 1
fi
@@ -62,6 +93,11 @@ EOF
default $proxy_protocol_addr;
"" $remote_addr;
}
# 所有站点都支持 WebSocket;普通请求仍按 nginx 默认关闭上游连接
map $http_upgrade $minigate_connection_upgrade {
default upgrade;
"" close;
}
log_format minigate_json escape=json
'{"time":"$time_iso8601",'
'"domain":"$server_name",'
@@ -97,9 +133,19 @@ find_cert() {
return 1
}
# nginx 1.25 起用独立的 http2 指令;同时兼容以后的主版本号
check_h2() {
local m=$(nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1 | cut -d. -f2)
[ "${m:-0}" -ge 25 ] && echo "new" || echo "old"
local v major minor
v=$(nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1)
major=${v%%.*}
minor=${v#*.}
case "$major" in ''|*[!0-9]*) major=0 ;; esac
case "$minor" in ''|*[!0-9]*) minor=0 ;; esac
if [ "$major" -gt 1 ] || { [ "$major" = "1" ] && [ "$minor" -ge 25 ]; }; then
echo "new"
else
echo "old"
fi
}
# 读取全局 IPv6 监听设置
@@ -229,7 +275,7 @@ SEOF
ensure_default_server() {
local lport="$1" ssl="$2" h2s="$3" ipv6_listen="$4" mux="$5"
local key=$(echo "${lport}_${ssl}_${mux}" | tr -c 'A-Za-z0-9_' '_')
local mark="/tmp/minigate_default_${key}.tmp"
local mark="${GEN_TMP}/default_${key}"
[ -f "$mark" ] && return 0
: > "$mark"
write_default_server "${SITES_DIR}/000_default_${key}.conf" "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
@@ -254,7 +300,7 @@ SEOF
ensure_stream_server() {
local lport="$1" ipv6_listen="$2"
local mark="/tmp/minigate_stream_${lport}.tmp"
local mark="${GEN_TMP}/stream_${lport}"
local conf="${STREAMS_DIR}/stream_${lport}.conf"
[ -f "$mark" ] && return 0
: > "$mark"
@@ -320,11 +366,15 @@ SEOF
write_geo_guard "$conf"
if [ "$ssl" = "1" ]; then
local cp=$(find_cert "$domain")
[ -n "$cp" ] && cat >> "$conf" <<SEOF
if [ -n "$cp" ]; then
cat >> "$conf" <<SEOF
ssl_certificate ${cp}/fullchain.pem;
ssl_certificate_key ${cp}/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
SEOF
else
warn "${domain} 没有可用证书,HTTPS 访问会失败;请先在「SSL 证书」页签发"
fi
fi
# 判断目标地址是否为 IPv6
@@ -345,22 +395,77 @@ SEOF
proxy_http_version 1.1;
proxy_read_timeout 300s;
proxy_buffering off;
SEOF
[ "$ws" = "1" ] && cat >> "$conf" <<SEOF
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Connection \$minigate_connection_upgrade;
}
}
SEOF
echo " }" >> "$conf"; echo "}" >> "$conf"
}
# 同一端口只能用于一种协议:明文 HTTP、HTTPS,或 HTTPS + HTTP 跳转(stream 分流)。
# 先占用的规则生效,冲突的规则跳过并提示,避免生成 nginx 无法加载的配置。
claim_port() {
local lport="$1" mode="$2" domain="$3" file used
file="${GEN_TMP}/port_${lport}"
if [ -f "$file" ]; then
used=$(cat "$file")
[ "$used" = "$mode" ] && return 0
warn "跳过 ${domain}:端口 ${lport} 已用于$(port_mode_label "$used"),不能同时用于$(port_mode_label "$mode")"
return 1
fi
echo "$mode" > "$file"
}
port_mode_label() {
case "$1" in
http) echo "HTTP" ;;
https) echo "HTTPS" ;;
*) echo "HTTPS(含 HTTP 跳转)" ;;
esac
}
# 生成一条站点规则;返回非 0 表示因端口冲突被跳过
add_site() {
local domain="$1" lport="$2" taddr="$3" tport="$4" ssl="$5" h2s="$6" ipv6_listen="$7" http_redirect="$8"
local mux=0 mode=http conf
if [ "$ssl" = "1" ]; then
mode=https
if [ "$http_redirect" = "1" ]; then
mux=1
mode=mux
fi
fi
claim_port "$lport" "$mode" "$domain" || return 1
if [ "$mux" = "1" ]; then
ensure_stream_server "$lport" "$ipv6_listen"
ensure_default_server "$lport" "0" "$h2s" "$ipv6_listen" "1"
fi
ensure_default_server "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
SITE_INDEX=$((SITE_INDEX + 1))
conf="${SITES_DIR}/site_${SITE_INDEX}.conf"
: > "$conf"
write_server "$conf" "$domain" "$lport" "$taddr" "$tport" "$ssl" "1" "1" "$h2s" "$ipv6_listen" "$mux"
[ "$mux" = "1" ] && write_redirect_server "$conf" "$domain" "$lport"
return 0
}
wildcard_key() {
printf '%s' "$1" | tr -c 'A-Za-z0-9.-' '_'
}
generate_sites() {
local own_tmp=0
if [ -z "$GEN_TMP" ]; then
GEN_TMP=$(mktemp -d "${TMPDIR:-/tmp}/minigate-gen.XXXXXX") || return 1
own_tmp=1
fi
mkdir -p "$SITES_DIR" "$STREAMS_DIR" "$SOCKET_DIR"
rm -f "$SITES_DIR"/*.conf
rm -f "$STREAMS_DIR"/*.conf
local h2s=$(check_h2)
local idx=0
local ipv6_listen=$(get_ipv6_listen)
local http_redirect=$(get_http_redirect)
SITE_INDEX=0
# === 通配符域名设置 (proxy_wildcard sections) ===
local wc_sections=$(uci -q show minigate | grep '=proxy_wildcard$' | cut -d. -f2 | cut -d= -f1)
@@ -372,7 +477,7 @@ generate_sites() {
local lport=$(uci -q get minigate.${sec}.listen_port); lport=${lport:-443}
local ssl=$(uci -q get minigate.${sec}.ssl); ssl=${ssl:-0}
local base=$(echo "$domain" | sed 's/^\*\.//')
echo "${lport}|${ssl}" > "/tmp/minigate_proxy_${base}.tmp"
echo "${lport}|${ssl}" > "${GEN_TMP}/wildcard_$(wildcard_key "$base")"
log "通配符: $domain (端口:$lport https:$ssl ipv6:$ipv6_listen)"
done
@@ -386,58 +491,38 @@ generate_sites() {
local taddr=$(uci -q get minigate.${sec}.target_addr)
local tport=$(uci -q get minigate.${sec}.target_port); tport=${tport:-80}
local ssl=$(uci -q get minigate.${sec}.ssl); ssl=${ssl:-1}
local h2=$(uci -q get minigate.${sec}.http2); h2=${h2:-1}
local ws=$(uci -q get minigate.${sec}.websocket); ws=${ws:-0}
[ -z "$domain" ] || [ -z "$taddr" ] && continue
local mux=0
if [ "$http_redirect" = "1" ] && [ "$ssl" = "1" ]; then
mux=1
ensure_stream_server "$lport" "$ipv6_listen"
ensure_default_server "$lport" "0" "$h2s" "$ipv6_listen" "1"
fi
ensure_default_server "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
idx=$((idx + 1))
local conf="${SITES_DIR}/site_${idx}.conf"; > "$conf"
write_server "$conf" "$domain" "$lport" "$taddr" "$tport" "$ssl" "$h2" "$ws" "$h2s" "$ipv6_listen" "$mux"
[ "$mux" = "1" ] && write_redirect_server "$conf" "$domain" "$lport"
add_site "$domain" "$lport" "$taddr" "$tport" "$ssl" "$h2s" "$ipv6_listen" "$http_redirect"
done
# === 子域名规则:继承通配符主域名设置 ===
# === 子域名规则:继承通配符主域名设置;主域名未启用时整组不生效 ===
local subs=$(uci -q show minigate | grep '=subproxy$' | cut -d. -f2 | cut -d= -f1)
for sec in $subs; do
local enabled=$(uci -q get minigate.${sec}.enabled)
[ "${enabled:-1}" = "1" ] || continue
local parent=$(uci -q get minigate.${sec}.parent_domain)
local prefix=$(uci -q get minigate.${sec}.prefix)
local taddr=$(uci -q get minigate.${sec}.target_addr)
local tport=$(uci -q get minigate.${sec}.target_port); tport=${tport:-80}
local ws=$(uci -q get minigate.${sec}.websocket); ws=${ws:-0}
[ -z "$parent" ] || [ -z "$prefix" ] || [ -z "$taddr" ] && continue
local domain="${prefix}.${parent}"
local parent_conf="/tmp/minigate_proxy_${parent}.tmp"
local lport="443" ssl="0"
if [ -f "$parent_conf" ]; then
local settings=$(cat "$parent_conf")
lport=$(echo "$settings" | cut -d'|' -f1)
ssl=$(echo "$settings" | cut -d'|' -f2)
local parent_conf="${GEN_TMP}/wildcard_$(wildcard_key "$parent")"
if [ ! -f "$parent_conf" ]; then
warn "跳过 ${domain}:通配符主域名 *.${parent} 未启用或不存在"
continue
fi
idx=$((idx + 1))
local mux=0
if [ "$http_redirect" = "1" ] && [ "$ssl" = "1" ]; then
mux=1
ensure_stream_server "$lport" "$ipv6_listen"
ensure_default_server "$lport" "0" "$h2s" "$ipv6_listen" "1"
fi
ensure_default_server "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux"
local conf="${SITES_DIR}/site_${idx}.conf"; > "$conf"
write_server "$conf" "$domain" "$lport" "$taddr" "$tport" "$ssl" "1" "$ws" "$h2s" "$ipv6_listen" "$mux"
[ "$mux" = "1" ] && write_redirect_server "$conf" "$domain" "$lport"
local settings=$(cat "$parent_conf")
local lport=${settings%%|*}
local ssl=${settings#*|}
add_site "$domain" "$lport" "$taddr" "$tport" "$ssl" "$h2s" "$ipv6_listen" "$http_redirect"
done
rm -f /tmp/minigate_proxy_*.tmp
rm -f /tmp/minigate_default_*.tmp
rm -f /tmp/minigate_stream_*.tmp
if [ "$own_tmp" = "1" ]; then
rm -rf "$GEN_TMP"
GEN_TMP=""
fi
return 0
}
# === 局域网直连解析 ===
@@ -523,62 +608,109 @@ ${ip} ${domain}"
log "局域网直连解析 -> ${ip}: $(echo $domains)"
}
do_stop() {
if [ -f "$PID_FILE" ]; then
local pid=$(cat "$PID_FILE" 2>/dev/null)
if [ -n "$pid" ]; then
kill "$pid" 2>/dev/null
local i=0
while [ $i -lt 10 ] && kill -0 "$pid" 2>/dev/null; do
sleep 1; i=$((i + 1))
done
kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null
fi
rm -f "$PID_FILE"
rm -f "$SOCKET_DIR"/*.sock
log "已停止"
# 保存当前配置,生成或检查失败时恢复,磁盘上始终保留最后一份可用配置
snapshot_config() {
local dir="$1"
mkdir -p "$dir/sites" "$dir/streams" || return 1
cp -p "$SITES_DIR"/*.conf "$dir/sites/" 2>/dev/null
cp -p "$STREAMS_DIR"/*.conf "$dir/streams/" 2>/dev/null
[ -f "$NGINX_CONF" ] && cp -p "$NGINX_CONF" "$dir/main.conf"
return 0
}
restore_config() {
local dir="$1"
rm -f "$SITES_DIR"/*.conf "$STREAMS_DIR"/*.conf
cp -p "$dir/sites/"*.conf "$SITES_DIR/" 2>/dev/null
cp -p "$dir/streams/"*.conf "$STREAMS_DIR/" 2>/dev/null
if [ -f "$dir/main.conf" ]; then
cp -p "$dir/main.conf" "$NGINX_CONF"
else
rm -f "$NGINX_CONF"
fi
}
# 生成并检查配置;失败时恢复上一份配置,不影响正在运行的 nginx
prepare_config() {
generate_sites || return 1
generate_main_conf || return 1
nginx -t -c "$NGINX_CONF" >> "$LOGFILE" 2>&1 || { log "配置错误"; return 1; }
local backup output rc=0 message=""
GEN_TMP=$(mktemp -d "${TMPDIR:-/tmp}/minigate-gen.XXXXXX") || { log "无法创建临时目录"; return 1; }
backup="${GEN_TMP}/backup"
output="${GEN_TMP}/nginx-test.log"
snapshot_config "$backup"
if ! generate_sites || ! generate_main_conf; then
rc=1
message="生成配置失败,已保留上一份配置"
elif ! nginx -t -c "$NGINX_CONF" > "$output" 2>&1; then
rc=1
cat "$output" >> "$LOGFILE"
message="配置检查失败,已保留上一份配置:$(grep -m1 -E 'emerg|error' "$output" | sed 's/^nginx: //')"
log "配置错误,已恢复上一份配置"
fi
if [ "$rc" = "0" ]; then
write_status ok "配置检查通过"
else
restore_config "$backup"
write_status error "$message"
fi
rm -rf "$GEN_TMP"
GEN_TMP=""
return "$rc"
}
start_prepared() {
do_stop 2>/dev/null
sleep 1
nginx_pid() {
local pid
pid=$(cat "$PID_FILE" 2>/dev/null)
case "$pid" in ''|*[!0-9]*) return 1 ;; esac
kill -0 "$pid" 2>/dev/null || return 1
echo "$pid"
}
# 由 procd 启动(前台运行,崩溃后自动拉起)。启动前清掉上次异常退出留下的 socket。
do_run() {
mkdir -p "$SOCKET_DIR"
rm -f "$SOCKET_DIR"/*.sock
nginx -c "$NGINX_CONF" >> "$LOGFILE" 2>&1 && log "已启动" || { log "启动失败"; return 1; }
exec nginx -c "$NGINX_CONF" -g 'daemon off;'
}
do_start() {
[ -z "$(which nginx 2>/dev/null)" ] && { log "nginx未安装"; return 1; }
prepare_config || return 1
start_prepared || return 1
sync_lan_hosts
# 让运行中的 nginx 加载新配置。stream 模块增减需要重启进程,交给 procd 处理。
apply_config() {
local pid old_stream="$1" new_stream=0
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && new_stream=1
pid=$(nginx_pid)
if [ -n "$pid" ] && [ "$old_stream" = "$new_stream" ]; then
kill -HUP "$pid" || return 1
log "已重载"
else
MINIGATE_SKIP_PREPARE=1 "$INIT_SCRIPT" start 6>&- >/dev/null 2>&1 || return 1
log "已交由 procd 启动"
fi
}
do_reload() {
local old_mux=0 new_mux=0
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && old_mux=1
local old_stream=0
[ "$(uci -q get minigate.global.enabled)" = "1" ] || return 0
lock_proxy
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && old_stream=1
prepare_config || return 1
grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && new_mux=1
if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE" 2>/dev/null) 2>/dev/null; then
if [ "$old_mux" != "$new_mux" ]; then
start_prepared || return 1
else
kill -HUP $(cat "$PID_FILE") || return 1
log "已重载"
fi
else
start_prepared || return 1
fi
apply_config "$old_stream" || return 1
sync_lan_hosts
}
# 仅向已加载新配置的 nginx 发送重载信号(init 已完成生成和检查)
do_hup() {
local pid
pid=$(nginx_pid) || return 0
kill -HUP "$pid" && log "已重载"
}
do_stop() {
remove_lan_hosts
rm -f "$SOCKET_DIR"/*.sock 2>/dev/null
write_status stopped "反向代理已停止"
}
# /var/log 在内存中且 nginx 不会自行轮转。访问日志保留最近记录到 .1 后原地清空:
# nginx 以追加方式继续写入,登录防护的 tail -f 从头读取新内容,不会重放旧记录。
trim_logs() {
@@ -599,11 +731,12 @@ if [ "${MINIGATE_PROXY_LIBRARY_ONLY:-0}" = "1" ]; then
fi
case "$1" in
start) do_start ;;
stop) do_stop; remove_lan_hosts ;;
prepare|test) lock_proxy; prepare_config ;;
run) do_run ;;
reload) do_reload ;;
test) prepare_config ;;
hup) do_hup ;;
stop) do_stop ;;
trim-logs) trim_logs ;;
lan-dns) sync_lan_hosts ;;
*) echo "用法: $0 {start|stop|reload|test|trim-logs|lan-dns}" ;;
*) echo "用法: $0 {prepare|run|reload|hup|stop|trim-logs|lan-dns}" ;;
esac
+6 -2
View File
@@ -158,6 +158,7 @@ remove_firewall_state() {
[ -z "$ROOT" ] || return 0
remove_nft_rule_for_set login_banned_v4
remove_nft_rule_for_set login_banned_v6
remove_nft_rule_for_set minigate_blocked
if command -v iptables >/dev/null 2>&1; then
while iptables -D INPUT -m set --match-set minigate_blocked src -j DROP >/dev/null 2>&1; do :; done
@@ -170,7 +171,8 @@ remove_cron() {
cron_file="$(root_path /etc/crontabs/root)"
if [ -f "$cron_file" ]; then
cron_tmp="${cron_file}.minigate.$$"
if sed '/minigate/d' "$cron_file" > "$cron_tmp"; then
# 只删除本插件脚本的任务,保留用户自己写的其他任务
if sed '\#/usr/lib/minigate/#d' "$cron_file" > "$cron_tmp"; then
cat "$cron_tmp" > "$cron_file"
fi
rm -f "$cron_tmp"
@@ -205,7 +207,9 @@ remove_program_files() {
rm -f \
"$(root_path /etc/init.d/minigate)" \
"$(root_path /usr/lib/lua/luci/controller/minigate.lua)" \
"$(root_path /usr/lib/lua/luci/i18n/minigate.zh-cn.lmo)"
"$(root_path /usr/lib/lua/luci/i18n/minigate.zh-cn.lmo)" \
"$(root_path /usr/share/nftables.d/table-pre/20-minigate-login-guard.nft)" \
"$(root_path /usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft)"
rm -rf \
"$(root_path /usr/lib/minigate)" \
"$(root_path /usr/lib/lua/luci/model/cbi/minigate)" \
+4 -2
View File
@@ -3,7 +3,7 @@
PATH=/usr/sbin:/usr/bin:/sbin:/bin
export PATH
CURRENT_VERSION="2026.10.26"
CURRENT_VERSION="2026.10.28"
REPOSITORY="tpxcer/minigate"
API_URL="https://api.github.com/repos/${REPOSITORY}/releases/latest"
DOWNLOAD_ROOT="https://github.com/${REPOSITORY}/releases/download"
@@ -159,7 +159,9 @@ backup_program() {
usr/lib/minigate \
usr/lib/lua/luci/controller/minigate.lua \
usr/lib/lua/luci/model/cbi/minigate \
usr/lib/lua/luci/view/minigate; do
usr/lib/lua/luci/view/minigate \
usr/share/nftables.d/table-pre/20-minigate-login-guard.nft \
usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft; do
[ -e "/$path" ] && paths="$paths $path"
done
@@ -0,0 +1,3 @@
# MiniGate 登录防护:放在 input 链最前,已建立的连接也会被断开
ip saddr @login_banned_v4 counter drop comment "minigate-login-guard"
ip6 saddr @login_banned_v6 counter drop comment "minigate-login-guard"
@@ -0,0 +1,10 @@
# MiniGate 登录防护:封禁集合随防火墙每次重载重建,封禁记录由 login_guard.sh 恢复
set login_banned_v4 {
type ipv4_addr
flags timeout
}
set login_banned_v6 {
type ipv6_addr
flags timeout
}