Compare commits

...

21 Commits

Author SHA1 Message Date
mango
cb65230932 refine renewal action entry 2026-04-26 11:06:58 +08:00
ba84fd3811 add scripts/hkt.sh (默认出站源地址管理器, from vpsbuy/rfchost) 2026-04-20 20:28:14 +08:00
7de15a73e8 feat: v3.0 解锁机自动禁IPv6+dnsmasq filter-AAAA+self-test 2026-04-17 06:39:14 +00:00
bbc07051b6 fix: 默认禁用 IPv6 bind 避免冲突 + chattr -i 2026-04-17 06:30:56 +00:00
fd9cb824b9 fix: resolv.conf 写入前 chattr -i 解锁 2026-04-17 06:27:52 +00:00
09ec13d0b3 fix: banner 版本号引用 SCRIPT_VERSION 变量 2026-04-17 06:24:12 +00:00
2f5e5b7a59 fix: v2.3 防火墙工具检测(ufw/firewalld/iptables)+无防火墙优雅跳过 2026-04-17 06:18:43 +00:00
a800541c36 fix: v2.2 check_ipv4 多源+8s超时+http fallback 2026-04-17 06:15:44 +00:00
3d99fc13ba fix: v2.1 ERR trap 忽略 SIGPIPE(141) + sshd -T 管道改 grep 2026-04-17 06:10:45 +00:00
575332e78d fix: sniproxy systemd unit 去掉 NoNewPrivileges + PID 路径统一 /run/ + 注释 user daemon 2026-04-17 06:05:14 +00:00
0ff27faa45 refactor: stream-unlock v2.0 完整重写
- 修复源码编译 sniproxy 路径检测
- 修复 grep 误判 idempotency
- 防火墙 SSH 优先放行
- smartdns 动态下载 + 架构检测
- 去掉 chattr, 正确处理 systemd-resolved/NM
- 加 status/test/uninstall/rollback 子命令
- 加备份/回滚/日志/状态文件
- 卸载适配源码安装
- set -Eeuo pipefail + ERR trap
2026-04-17 06:00:28 +00:00
mango
d919c0f93d fix: 防火墙配置时放行 SSH 22 端口,防止锁死 2026-04-17 06:49:44 +08:00
mango
45399fc8c6 feat: 调整服务分类 - Netflix+Disney/YouTube+Google/AI全家桶/TikTok独立 2026-04-17 00:48:44 +08:00
mango
c020494cb5 feat: 完善被解锁机卸载功能,恢复原状 2026-04-17 00:46:12 +08:00
mango
a51ddcbd42 fix: 兼容 apt 版 smartdns 配置格式 2026-04-17 00:44:26 +08:00
mango
1476c1a52c fix: 修复 select_services 返回值问题 2026-04-17 00:37:42 +08:00
mango
ef31ad3409 fix: 优化服务选择提示 2026-04-17 00:34:29 +08:00
mango
1e32102f89 fix: smartdns 安装改用动态获取最新版本 2026-04-17 00:32:20 +08:00
mango
add7e4f1a3 fix: 解锁机不需要选服务,直接完成 2026-04-17 00:27:48 +08:00
mango
d4f3d96837 fix: Debian sniproxy 从源码编译安装 2026-04-17 00:25:30 +08:00
mango
7d16d2112f fix: 增加系统检测日志和 Arch Linux 支持 2026-04-17 00:24:08 +08:00
3 changed files with 1454 additions and 458 deletions

55
app.py
View File

@@ -608,6 +608,12 @@ def format_price(node: Node) -> str:
return f"{amount:.2f} {currency}/{cycle_label(node.price_cycle)}"
def next_renewal_date_text(node: Node) -> str:
if not int(node.expires_at or 0):
return "未设置"
return format_expiry(node.expires_at)
def annual_cost_total():
return round(monthly_cost_total() * 12, 2)
@@ -657,7 +663,7 @@ async def monitor_once(app: Application):
set_node_state(node.id, fail_count, is_online, str(e))
if int(node.expires_at or 0) > 0 and expire_days > 0:
days_left = math.floor((int(node.expires_at) - now) / 86400)
days_left = math.ceil((int(node.expires_at) - now) / 86400)
exp_state = get_expiry_state(node.id)
remind_points = sorted({expire_days, 7, 3, 0}, reverse=True)
should_notify = False
@@ -1169,6 +1175,7 @@ async def show_billing_menu(query, node_id: int):
[InlineKeyboardButton("改备注", callback_data=f"node:editfield:{node_id}:remark"), InlineKeyboardButton("改金额", callback_data=f"node:editfield:{node_id}:monthly_price")],
[InlineKeyboardButton("周期", callback_data=f"node:cycle:{node_id}"), InlineKeyboardButton("货币", callback_data=f"node:currency:{node_id}")],
[InlineKeyboardButton("改到期日", callback_data=f"node:editfield:{node_id}:expires_at")],
[InlineKeyboardButton("💳 标记已续费", callback_data=f"node:renew:{node_id}")],
[InlineKeyboardButton("⬅️ 返回节点", callback_data=f"node:view:{node_id}")],
]),
)
@@ -1208,6 +1215,50 @@ async def show_currency_menu(query, node_id: int):
)
async def renew_node(query, node_id: int):
node = load_node(node_id)
if not node:
await query.edit_message_text("节点不存在", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("🏠 首页", callback_data="home")]]))
return
if not int(node.expires_at or 0):
await query.edit_message_text(
f"<b>{esc(node.name)}</b> 还没设置到期日,没法直接续费。",
parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回账单", callback_data=f"node:billing:{node_id}")]]),
)
return
cycle = (node.price_cycle or "month").lower()
months_map = {"month": 1, "quarter": 3, "year": 12}
if cycle not in months_map:
await query.edit_message_text(
f"<b>{esc(node.name)}</b> 的账单周期 <code>{esc(cycle)}</code> 暂不支持自动续费。",
parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ 返回账单", callback_data=f"node:billing:{node_id}")]]),
)
return
current = datetime.fromtimestamp(int(node.expires_at))
import calendar
month_index = current.month - 1 + months_map[cycle]
year = current.year + month_index // 12
month = month_index % 12 + 1
day = min(current.day, calendar.monthrange(year, month)[1])
new_dt = current.replace(year=year, month=month, day=day)
update_node_field(node_id, "expires_at", int(new_dt.timestamp()))
conn = db()
conn.execute("INSERT INTO expiry_state(node_id,last_days_left,last_notified_at) VALUES(?,?,?) ON CONFLICT(node_id) DO UPDATE SET last_days_left=excluded.last_days_left,last_notified_at=excluded.last_notified_at", (node_id, 999999, 0))
conn.commit()
conn.close()
node = load_node(node_id)
await query.edit_message_text(
f"✅ <b>{esc(node.name)}</b> 已续费\n\n账单:<code>{esc(format_price(node))}</code>\n新到期:<code>{esc(format_expiry(node.expires_at))}</code>\n剩余:<code>{esc(days_left_text(node.expires_at))}</code>",
parse_mode=ParseMode.HTML,
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("💰 返回账单", callback_data=f"node:billing:{node_id}")],
[InlineKeyboardButton("📄 返回节点", callback_data=f"node:view:{node_id}")],
]),
)
async def show_edit_menu(query, node_id: int):
node = load_node(node_id)
if not node:
@@ -1550,6 +1601,8 @@ async def on_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
_, _, node_id, currency = data.split(":", 3)
update_node_field(int(node_id), "price_currency", currency.upper())
await show_billing_menu(q, int(node_id))
elif data.startswith("node:renew:"):
await renew_node(q, int(data.split(":")[-1]))
elif data.startswith("node:edit:"):
await show_edit_menu(q, int(data.split(":")[-1]))
elif data.startswith("node:editfield:"):

480
scripts/hkt.sh Executable file
View File

@@ -0,0 +1,480 @@
#!/usr/bin/env bash
set -u
# =========================================================
# 默认出站源地址管理器(精简美化版)
# 目标:
# - 默认新建连接优先使用内网IP作为源地址
# - 实际下一跳仍走当前公网网关
# - 保留公网IP独立策略避免SSH/公网入站回包异常
# =========================================================
[[ $EUID -eq 0 ]] || { echo "请使用 root 运行"; exit 1; }
STATE_DIR="/var/lib/default-src-ip"
STATE_FILE="$STATE_DIR/state.env"
APPLY_BIN="/usr/local/sbin/default-src-ip-apply"
SERVICE_NAME="default-src-ip.service"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}"
SYSCTL_FILE="/etc/sysctl.d/90-default-src-ip.conf"
mkdir -p "$STATE_DIR"
# ---------- 颜色(不使用红色) ----------
C_RESET='\033[0m'
C_BOLD='\033[1m'
C_DIM='\033[2m'
C_WHITE='\033[1;37m'
C_CYAN='\033[1;36m'
C_BLUE='\033[1;34m'
C_GREEN='\033[1;32m'
C_YELLOW='\033[1;33m'
C_GRAY='\033[0;37m'
# ---------- UI ----------
line() {
printf "%b%s%b\n" "$C_GRAY" "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" "$C_RESET"
}
subline() {
printf "%b%s%b\n" "$C_GRAY" "────────────────────────────────────────────────────────────" "$C_RESET"
}
header() {
clear 2>/dev/null || true
echo
}
section() {
echo
printf " %b%s%b\n" "$C_CYAN$C_BOLD" "$1" "$C_RESET"
subline
}
ok() { printf "%b[OK]%b %s\n" "$C_GREEN" "$C_RESET" "$*"; }
info() { printf "%b[INFO]%b %s\n" "$C_CYAN" "$C_RESET" "$*"; }
warn() { printf "%b[WARN]%b %s\n" "$C_YELLOW" "$C_RESET" "$*"; }
kv() {
printf " %b%-10s%b %s\n" "$C_GRAY" "$1" "$C_RESET" "$2"
}
menu_item() {
local num="$1"
local title="$2"
printf " %b[%s]%b %s\n" "$C_BLUE$C_BOLD" "$num" "$C_RESET" "$title"
}
pause() {
echo
read -r -p "按回车继续..." _
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "缺少命令: $1"
exit 1
}
}
for c in ip awk grep cut sed head tr sysctl systemctl curl ping; do
need_cmd "$c"
done
# ---------- IP 类型判断 ----------
is_private_ipv4() {
local ip="$1"
[[ "$ip" =~ ^10\. ]] && return 0
[[ "$ip" =~ ^192\.168\. ]] && return 0
[[ "$ip" =~ ^172\.(1[6-9]|2[0-9]|3[0-1])\. ]] && return 0
return 1
}
# ---------- 自动检测 ----------
detect_env() {
IFACE="$(ip -4 route show default | awk 'NR==1{print $5}')"
PUBLIC_GW="$(ip -4 route show default | awk 'NR==1{print $3}')"
PUBLIC_IP=""
PUBLIC_CIDR=""
PRIVATE_IP=""
PRIVATE_CIDR=""
while read -r linebuf; do
local cidr ip prefix
cidr="$(awk '{print $4}' <<<"$linebuf")"
ip="${cidr%/*}"
prefix="${cidr#*/}"
if is_private_ipv4 "$ip"; then
if [[ -z "$PRIVATE_IP" ]]; then
PRIVATE_IP="$ip"
PRIVATE_CIDR="$prefix"
fi
else
if [[ -z "$PUBLIC_IP" ]]; then
PUBLIC_IP="$ip"
PUBLIC_CIDR="$prefix"
fi
fi
done < <(ip -o -4 addr show dev "$IFACE" scope global 2>/dev/null)
[[ -n "${IFACE:-}" ]] || return 1
[[ -n "${PUBLIC_GW:-}" ]] || return 1
[[ -n "${PUBLIC_IP:-}" ]] || return 1
[[ -n "${PRIVATE_IP:-}" ]] || return 1
return 0
}
save_state() {
cat > "$STATE_FILE" <<EOF_STATE
IFACE="$IFACE"
PUBLIC_GW="$PUBLIC_GW"
PUBLIC_IP="$PUBLIC_IP"
PUBLIC_CIDR="$PUBLIC_CIDR"
PRIVATE_IP="$PRIVATE_IP"
PRIVATE_CIDR="$PRIVATE_CIDR"
EOF_STATE
}
load_state_or_detect() {
if [[ -f "$STATE_FILE" ]]; then
# shellcheck disable=SC1090
source "$STATE_FILE"
if [[ -n "${IFACE:-}" && -n "${PUBLIC_GW:-}" && -n "${PUBLIC_IP:-}" && -n "${PRIVATE_IP:-}" ]]; then
return 0
fi
fi
detect_env || return 1
save_state
return 0
}
refresh_state() {
detect_env || return 1
save_state
return 0
}
# ---------- 当前模式 ----------
current_mode() {
local def
def="$(ip route show default 2>/dev/null | head -n1)"
if [[ -n "${PRIVATE_IP:-}" ]] && grep -q "src ${PRIVATE_IP}" <<<"$def"; then
echo "默认源地址 = 内网IP172 优先)"
elif [[ -n "${PUBLIC_IP:-}" ]] && grep -q "src ${PUBLIC_IP}" <<<"$def"; then
echo "默认源地址 = 公网IP"
else
echo "默认源地址 = 未识别"
fi
}
# ---------- 显示摘要 ----------
show_summary() {
load_state_or_detect || {
warn "自动识别失败,请检查默认路由、网卡与 IP 配置。"
return 1
}
local mode
mode="$(current_mode)"
section "当前环境"
kv "网卡" "${IFACE}"
kv "公网IP" "${PUBLIC_IP}"
kv "内网IP" "${PRIVATE_IP}"
kv "公网网关" "${PUBLIC_GW}"
kv "当前模式" "${mode}"
}
# ---------- 清理策略 ----------
clean_policy_only() {
ip rule del pref 100 2>/dev/null || true
ip rule del pref 110 2>/dev/null || true
ip route flush table 100 2>/dev/null || true
ip route flush table 200 2>/dev/null || true
ip route flush cache 2>/dev/null || true
}
# ---------- 应用策略 ----------
apply_private_as_default_src() {
refresh_state || {
warn "自动识别失败,无法应用。"
return 1
}
info "正在应用:默认新连接优先使用 ${PRIVATE_IP} 出站"
info "下一跳保持:${PUBLIC_GW}"
clean_policy_only
# 主路由表默认新连接使用内网IP为源
ip route replace default via "${PUBLIC_GW}" dev "${IFACE}" src "${PRIVATE_IP}"
# 表100源地址=内网IP明确走公网网关源保持内网IP
ip route replace default via "${PUBLIC_GW}" dev "${IFACE}" src "${PRIVATE_IP}" table 100
# 表200源地址=公网IP明确走公网网关源保持公网IP
ip route replace default via "${PUBLIC_GW}" dev "${IFACE}" src "${PUBLIC_IP}" table 200
# 策略规则
ip rule add pref 100 from "${PRIVATE_IP}/32" table 100
ip rule add pref 110 from "${PUBLIC_IP}/32" table 200
ip route flush cache 2>/dev/null || true
ok "应用完成"
echo
show_route_status
}
# ---------- 回滚 ----------
rollback_public_as_default_src() {
refresh_state || {
warn "自动识别失败,无法回滚。"
return 1
}
info "正在恢复:默认新连接优先使用 ${PUBLIC_IP} 出站"
clean_policy_only
ip route replace default via "${PUBLIC_GW}" dev "${IFACE}" src "${PUBLIC_IP}"
ip route flush cache 2>/dev/null || true
ok "已恢复为公网IP默认出站"
echo
show_route_status
}
# ---------- 状态 ----------
show_route_status() {
load_state_or_detect || {
warn "自动识别失败"
return 1
}
section "当前详细状态"
kv "网卡" "${IFACE}"
kv "公网IP" "${PUBLIC_IP}/${PUBLIC_CIDR}"
kv "内网IP" "${PRIVATE_IP}/${PRIVATE_CIDR}"
kv "公网网关" "${PUBLIC_GW}"
kv "当前模式" "$(current_mode)"
echo
printf "%b主默认路由%b\n" "$C_CYAN" "$C_RESET"
ip route show default | sed 's/^/ /'
echo
printf "%b策略规则%b\n" "$C_CYAN" "$C_RESET"
ip rule | sed 's/^/ /'
echo
printf "%b表100内网IP源%b\n" "$C_CYAN" "$C_RESET"
ip route show table 100 2>/dev/null | sed 's/^/ /'
echo
printf "%b表200公网IP源%b\n" "$C_CYAN" "$C_RESET"
ip route show table 200 2>/dev/null | sed 's/^/ /'
echo
}
# ---------- 连通性测试 ----------
test_now() {
load_state_or_detect || {
warn "自动识别失败"
return 1
}
section "测试当前出站效果"
printf "%b默认新连接选路%b\n" "$C_CYAN" "$C_RESET"
ip route get 1.1.1.1 | sed 's/^/ /'
echo
printf "%b从内网IP出站选路%b\n" "$C_CYAN" "$C_RESET"
ip route get 1.1.1.1 from "${PRIVATE_IP}" | sed 's/^/ /'
echo
printf "%b从公网IP出站选路%b\n" "$C_CYAN" "$C_RESET"
ip route get 1.1.1.1 from "${PUBLIC_IP}" | sed 's/^/ /'
echo
printf "%bPing绑定内网IP%b\n" "$C_CYAN" "$C_RESET"
ping -I "${PRIVATE_IP}" -c 3 1.1.1.1 || true
echo
printf "%b公网IP查询绑定内网IP%b\n" "$C_CYAN" "$C_RESET"
curl -4 --interface "${PRIVATE_IP}" --connect-timeout 5 --max-time 10 https://api.ipify.org ; echo
echo
printf "%b公网IP查询默认新连接%b\n" "$C_CYAN" "$C_RESET"
curl -4 --connect-timeout 5 --max-time 10 https://api.ipify.org ; echo
echo
}
# ---------- 启动脚本 ----------
install_apply_bin() {
cat > "$APPLY_BIN" <<'EOF_APPLY'
#!/usr/bin/env bash
set -u
STATE_FILE="/var/lib/default-src-ip/state.env"
[[ -f "$STATE_FILE" ]] || exit 1
# shellcheck disable=SC1090
source "$STATE_FILE"
ip rule del pref 100 2>/dev/null || true
ip rule del pref 110 2>/dev/null || true
ip route flush table 100 2>/dev/null || true
ip route flush table 200 2>/dev/null || true
ip route replace default via "${PUBLIC_GW}" dev "${IFACE}" src "${PRIVATE_IP}"
ip route replace default via "${PUBLIC_GW}" dev "${IFACE}" src "${PRIVATE_IP}" table 100
ip route replace default via "${PUBLIC_GW}" dev "${IFACE}" src "${PUBLIC_IP}" table 200
ip rule add pref 100 from "${PRIVATE_IP}/32" table 100
ip rule add pref 110 from "${PUBLIC_IP}/32" table 200
ip route flush cache 2>/dev/null || true
EOF_APPLY
chmod +x "$APPLY_BIN"
}
install_sysctl() {
cat > "$SYSCTL_FILE" <<'EOF_SYSCTL'
net.ipv4.conf.all.rp_filter = 2
net.ipv4.conf.default.rp_filter = 2
EOF_SYSCTL
sysctl --system >/dev/null 2>&1 || true
}
install_service() {
refresh_state || {
warn "自动识别失败,无法安装开机自启。"
return 1
}
install_apply_bin
install_sysctl
cat > "$SERVICE_FILE" <<EOF_SERVICE
[Unit]
Description=Use private IP as default source for outbound traffic
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=${APPLY_BIN}
[Install]
WantedBy=multi-user.target
EOF_SERVICE
systemctl daemon-reload
systemctl enable --now "${SERVICE_NAME}"
ok "已安装开机自动应用"
echo
systemctl status "${SERVICE_NAME}" --no-pager || true
}
remove_service() {
systemctl disable --now "${SERVICE_NAME}" 2>/dev/null || true
rm -f "$SERVICE_FILE"
rm -f "$APPLY_BIN"
rm -f "$SYSCTL_FILE"
systemctl daemon-reload
sysctl --system >/dev/null 2>&1 || true
ok "已移除开机自动应用"
}
# ---------- 卸载 ----------
full_uninstall() {
warn "开始卸载并恢复为公网IP默认出站"
remove_service
rollback_public_as_default_src || true
rm -f "$STATE_FILE"
ok "卸载完成"
}
# ---------- 菜单 ----------
menu() {
while true; do
header
show_summary
section "功能菜单"
menu_item 1 "重新自动识别环境"
menu_item 2 "应用内网IP默认出站"
menu_item 3 "测试当前出站效果"
menu_item 4 "查看当前详细状态"
menu_item 5 "回滚为公网IP默认出站"
menu_item 6 "安装开机自动应用"
menu_item 7 "移除开机自动应用"
menu_item 8 "仅清理策略规则"
menu_item 9 "卸载并恢复默认"
menu_item 0 "退出"
echo
read -r -p "请输入编号 [0-9]: " choice
echo
case "$choice" in
1)
if refresh_state; then
ok "自动识别完成"
show_summary
else
warn "自动识别失败"
fi
pause
;;
2)
apply_private_as_default_src
pause
;;
3)
test_now
pause
;;
4)
show_route_status
pause
;;
5)
rollback_public_as_default_src
pause
;;
6)
install_service
pause
;;
7)
remove_service
pause
;;
8)
clean_policy_only
ok "策略规则已清理"
pause
;;
9)
full_uninstall
pause
;;
0)
exit 0
;;
*)
warn "无效选项,请输入 0-9"
pause
;;
esac
done
}
menu

File diff suppressed because it is too large Load Diff