chore: baseline snapshot before moderation-strictness feature

Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
This commit is contained in:
tamakyi
2026-09-10 10:56:27 +08:00
commit e3e59e7c69
296 changed files with 67458 additions and 0 deletions
Binary file not shown.
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
# Insert form-validation i18n keys (form.*) into all four dictionaries.
import io, os
base = os.path.join(os.path.dirname(__file__), "..", "internal", "i18n")
# key -> {lang: text}
keys = {
"form.register.email": {
"zh_cn": "请填写有效的电子邮箱地址(不超过 100 个字符)",
"zh_tw": "請填寫有效的電子郵件地址(不超過 100 個字元)",
"en": "Please enter a valid email address (max 100 characters)",
"ja": "有効なメールアドレスを入力してください(100文字以内)",
},
"form.register.domain": {
"zh_cn": "个性域名只能包含字母、数字和下划线,长度为 3 至 20 位",
"zh_tw": "個性網域只能包含字母、數字與底線,長度為 3 至 20 位",
"en": "The custom domain may only contain letters, digits and underscores, and must be 3–20 characters long",
"ja": "カスタムドメインは半角英数字とアンダースコアのみ使用でき、3〜20文字である必要があります",
},
"form.register.name": {
"zh_cn": "昵称不能为空,且不超过 20 个字符",
"zh_tw": "暱稱不能為空,且不超過 20 個字元",
"en": "Nickname is required and must be at most 20 characters",
"ja": "ニックネームは必須で、20文字以内である必要があります",
},
"form.register.password": {
"zh_cn": "请设置 8 至 30 位的密码",
"zh_tw": "請設定 8 至 30 位的密碼",
"en": "Please set a password of 8–30 characters",
"ja": "8〜30文字のパスワードを設定してください",
},
"form.register.repeatPassword": {
"zh_cn": "两次输入的密码不一致",
"zh_tw": "兩次輸入的密碼不一致",
"en": "The two passwords do not match",
"ja": "入力した2つのパスワードが一致しません",
},
"form.register.recaptcha": {
"zh_cn": "无法加载人机验证,请刷新页面或切换网络后重试",
"zh_tw": "無法人機驗證,請重新整理頁面或更換網路後重試",
"en": "Unable to load the CAPTCHA. Please refresh the page or switch your network and try again",
"ja": "CAPTCHAを読み込めません。ページを再読み込みするか、ネットワークを変更してお試しください",
},
"form.email.required": {
"zh_cn": "请填写有效的电子邮箱地址",
"zh_tw": "請填寫有效的電子郵件地址",
"en": "Please enter a valid email address",
"ja": "有効なメールアドレスを入力してください",
},
"form.login.password": {
"zh_cn": "请输入密码",
"zh_tw": "請輸入密碼",
"en": "Please enter your password",
"ja": "パスワードを入力してください",
},
"form.recaptcha": {
"zh_cn": "请完成人机验证",
"zh_tw": "請完成人機驗證",
"en": "Please complete the CAPTCHA verification",
"ja": "CAPTCHA認証を完了してください",
},
"form.recover.newPassword": {
"zh_cn": "请设置 8 至 30 位的新密码",
"zh_tw": "請設定 8 至 30 位的新密碼",
"en": "Please set a new password of 8–30 characters",
"ja": "8〜30文字の新しいパスワードを設定してください",
},
"form.recover.repeatPassword": {
"zh_cn": "两次输入的新密码不一致",
"zh_tw": "兩次輸入的新密碼不一致",
"en": "The two new passwords do not match",
"ja": "入力した2つの新しいパスワードが一致しません",
},
"form.question.content": {
"zh_cn": "问题内容不能为空,且不超过 2000 个字符",
"zh_tw": "問題內容不能為空,且不超過 2000 個字元",
"en": "Question content is required and must be at most 2000 characters",
"ja": "質問内容は必須で、2000文字以内である必要があります",
},
"form.question.receiveReplyEmail": {
"zh_cn": "请填写有效的电子邮箱地址以接收回复",
"zh_tw": "請填寫有效的電子郵件地址以接收回覆",
"en": "Please enter a valid email address to receive replies",
"ja": "返信を受け取るための有効なメールアドレスを入力してください",
},
"form.answer.content": {
"zh_cn": "回答内容不能为空,且不超过 2000 个字符",
"zh_tw": "回答內容不能為空,且不超過 2000 個字元",
"en": "Answer content is required and must be at most 2000 characters",
"ja": "回答内容は必須で、2000文字以内である必要があります",
},
"form.followup.content": {
"zh_cn": "追问内容不能为空,且不超过 2000 个字符",
"zh_tw": "追問內容不能為空,且不超過 2000 個字元",
"en": "Follow-up content is required and must be at most 2000 characters",
"ja": "フォローアップ内容は必須で、2000文字以内である必要があります",
},
"form.followup.answer": {
"zh_cn": "追问回答不能为空,且不超过 2000 个字符",
"zh_tw": "追問回答不能為空,且不超過 2000 個字元",
"en": "Follow-up answer is required and must be at most 2000 characters",
"ja": "フォローアップの回答は必須で、2000文字以内である必要があります",
},
"form.profile.name": {
"zh_cn": "昵称不能为空,且不超过 20 个字符",
"zh_tw": "暱稱不能為空,且不超過 20 個字元",
"en": "Nickname is required and must be at most 20 characters",
"ja": "ニックネームは必須で、20文字以内である必要があります",
},
"form.profile.newPassword": {
"zh_cn": "新密码长度不能超过 30 个字符",
"zh_tw": "新密碼長度不能超過 30 個字元",
"en": "New password must be at most 30 characters",
"ja": "新しいパスワードは30文字以内である必要があります",
},
"form.profile.intro": {
"zh_cn": "个人介绍不能为空,且不超过 100 个字符",
"zh_tw": "個人介紹不能為空,且不超過 100 個字元",
"en": "Bio is required and must be at most 100 characters",
"ja": "自己紹介は必須で、100文字以内である必要があります",
},
"form.profile.colorHex": {
"zh_cn": "请填写 7 位十六进制颜色代码(含 #)",
"zh_tw": "請填寫 7 位十六進制顏色代碼(含 #)",
"en": "Please enter a 7-character hex color code (including #)",
"ja": "7文字の16進数カラーコード(#を含む)を入力してください",
},
"form.profile.dotscale": {
"zh_cn": "请填写二维码点大小",
"zh_tw": "請填寫 QR Code 點大小",
"en": "Please enter the QR code dot size",
"ja": "QRコードの点の大きさを入力してください",
},
"form.profile.backgroundimagealpha": {
"zh_cn": "请填写二维码背景透明度",
"zh_tw": "請填寫 QR Code 背景透明度",
"en": "Please enter the QR code background opacity",
"ja": "QRコードの背景の不透明度を入力してください",
},
"form.profile.linkMax": {
"zh_cn": "主页链接长度不能超过 30 个字符",
"zh_tw": "主頁連結長度不能超過 30 個字元",
"en": "Profile link must be at most 30 characters",
"ja": "プロフィールリンクは30文字以内である必要があります",
},
"form.profile.passwordMax": {
"zh_cn": "密码长度不能超过 30 个字符",
"zh_tw": "密碼長度不能超過 30 個字元",
"en": "Password must be at most 30 characters",
"ja": "パスワードは30文字以内である必要があります",
},
"form.profile.chatMaxMessages": {
"zh_cn": "私聊消息上限需为 1 至 50 之间的整数",
"zh_tw": "私聊訊息上限需為 1 至 50 之間的整數",
"en": "The chat message limit must be an integer between 1 and 50",
"ja": "チャットメッセージ上限は1〜50の整数である必要があります",
},
}
files = {
"zh_cn": "dict_zh_cn.go",
"zh_tw": "dict_zh_tw.go",
"en": "dict_en.go",
"ja": "dict_ja.go",
}
# Build inserted block (sorted by key for determinism).
ordered = sorted(keys.keys())
block_lines = ["\n\t// 表单校验", " // 表单校验 (form validation)"]
# dedupe comment for non-en langs not needed; use single comment line per lang appropriately.
for lang, fname in files.items():
path = os.path.join(base, fname)
with io.open(path, "r", encoding="utf-8") as fh:
text = fh.read()
# Insert before the final standalone "}" that closes the map.
idx = text.rfind("\n}")
if idx == -1:
raise SystemExit("cannot find closing brace in " + fname)
# Build entries.
lines = ["\n\t// 表单校验"]
for k in ordered:
v = keys[k][lang]
# Escape double quotes (none expected, but be safe).
v = v.replace('"', '\\"')
lines.append('\t"%s":\t"%s",' % (k, v))
insertion = "\n".join(lines) + "\n"
new_text = text[:idx] + insertion + text[idx:]
with io.open(path, "w", encoding="utf-8") as fh:
fh.write(new_text)
print("updated", fname, "with", len(ordered), "keys")
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
# ============================================================================
# bench-cdn.sh — UIkit CDN 测速脚本(国内 / 海外双视角)
#
# 用途:对比"自建 R2 自托管 uikit"与"公共 CDN (cdnjs.snrat.com)"的加载速度,
# 用于验证/复测 2026-09-05 的 CDN 迁移是否持续生效。
#
# 用法:
# bash dev/bench-cdn.sh cn # 国内本机:每资源 5 次全量下载取中位
# bash dev/bench-cdn.sh global # 海外:check-host.net 多区域节点 (p50/p90)
#
# 数据结论参考(2026-09-05 基线,国内中位):
# css R2 0.864s -> snrat 0.200s (4.3x)
# js R2 0.796s -> snrat 0.163s (4.9x)
# icons R2 0.824s -> snrat 0.177s (4.7x)
# 海外(check-host p50):css 打平;js R2 372ms vs snrat 647ms (R2 略快)
# 结论:站点访客以国内为主 -> snrat 净收益大;海外属轻微下降可接受。
# ============================================================================
OLD_CSS="https://r2.337845818.best/tamabox/cdn/expire-1-y/uikit/3.3.3/css/modify-black-font/uikit.min.css"
NEW_CSS="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/css/uikit.min.css"
OLD_JS="https://r2.337845818.best/tamabox/cdn/lib/uikit/3.3.3/js/uikit.min.js"
NEW_JS="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit.min.js"
OLD_ICONS="https://r2.337845818.best/tamabox/cdn/lib/uikit/3.3.3/js/uikit-icons.min.js"
NEW_ICONS="https://cdnjs.snrat.com/ajax/libs/uikit/3.3.3/js/uikit-icons.min.js"
# ---------- 国内:全量下载计时 ----------
bench_cn() {
echo "== 国内本机:每资源 5 次全量下载(秒),取中位 =="
local pairs=(
"OLD-css (R2 modify):$OLD_CSS"
"NEW-css (snrat) :$NEW_CSS"
"OLD-js (R2) :$OLD_JS"
"NEW-js (snrat) :$NEW_JS"
"OLD-icons (R2) :$OLD_ICONS"
"NEW-icons (snrat) :$NEW_ICONS"
)
for entry in "${pairs[@]}"; do
local name="${entry%%:*}"; local url="${entry#*:}"
local vals=()
for i in 1 2 3 4 5; do
vals+=("$(curl -sS -o /dev/null -w '%{time_total}' --max-time 20 "$url" 2>/dev/null)")
done
local sorted=($(printf '%s\n' "${vals[@]}" | sort -n))
local med="${sorted[2]}"
printf '%-20s : [%s] 中位=%ss\n' "$name" "${vals[*]}" "$med"
done
}
# ---------- 海外:check-host.net 多区域节点 ----------
probe_global() {
local name="$1" url="$2"
local enc
enc=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$url")
local rid
rid=$(curl -sS -H "Accept: application/json" \
"https://check-host.net/check-http?host=$enc&max_nodes=12" 2>/dev/null \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('request_id',''))")
[ -z "$rid" ] && { echo "$name: 请求失败"; return; }
sleep 8
curl -sS -H "Accept: application/json" "https://check-host.net/check-result/$rid" 2>/dev/null \
| python3 -c "
import sys,json,statistics
d=json.load(sys.stdin)
ms=[]
for node,arr in (d or {}).items():
if not arr or not arr[0]: continue
r=arr[0]
if len(r)>3 and str(r[3]).startswith('2') and isinstance(r[1],(int,float)):
ms.append(r[1]*1000)
if ms:
ms.sort()
print(f'$name | n={len(ms)} | min={ms[0]:.0f} | p50={statistics.median(ms):.0f} | p90={ms[int(len(ms)*0.9)-1]:.0f} | max={ms[-1]:.0f} ms')
else:
print(f'$name | 无有效节点')
"
}
bench_global() {
echo "== 海外:check-host.net 多区域节点探测(HTTP 200 的耗时统计)=="
probe_global "OLD css (R2 modify)" "$OLD_CSS"
probe_global "NEW css (snrat)" "$NEW_CSS"
probe_global "OLD js (R2)" "$OLD_JS"
probe_global "NEW js (snrat)" "$NEW_JS"
}
case "$1" in
cn) bench_cn ;;
global) bench_global ;;
*)
echo "用法: bash dev/bench-cdn.sh {cn|global}"
echo " cn = 国内本机全量下载测速"
echo " global = 海外 check-host.net 多区域探测"
exit 1
;;
esac
Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

+11
View File
@@ -0,0 +1,11 @@
## 2024-03-04 21:45:55
新增:用户私信功能上线,可在「我的」页查看匿名来信。
修复:移动端问题列表间距过窄的问题。
优化:首页加载速度提升约 30%。
## 2024-02-29 00:00:55
修复:部分旧浏览器下时间显示异常。
新增:修改记录页(本页),将完整更新历史从首页挪出,保持首页简洁。
## 2023-06-03 13:11:55
项目初始化:匿名提问 / 回答的基础流程跑通。
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"fmt"
"io/fs"
"os"
"strings"
texttmpl "text/template"
"github.com/tamakyi/TamaBox/internal/template"
"github.com/tamakyi/TamaBox/templates"
)
// mergedFuncMap 将项目实际使用的模板函数(含 t 等 i18n 函数)合并为 text/template.FuncMap。
// 不使用 FuncMap 直接解析会因 "t" 等函数未定义而误报模板错误。
func mergedFuncMap() texttmpl.FuncMap {
fm := texttmpl.FuncMap{}
for _, m := range template.FuncMap() {
for k, v := range m {
fm[k] = v
}
}
return fm
}
func main() {
hasErr := false
_ = fs.WalkDir(templates.FS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".html") {
return nil
}
if _, err := texttmpl.New(path).Funcs(mergedFuncMap()).ParseFS(templates.FS, path); err != nil {
fmt.Println("TEMPLATE ERROR:", path, "->", err)
hasErr = true
}
return nil
})
if hasErr {
os.Exit(1)
}
fmt.Println("all templates syntax OK")
}
+21
View File
File diff suppressed because one or more lines are too long
+219
View File
@@ -0,0 +1,219 @@
[
{
"ip": "43.174.150.2",
"tcp_ms": 176.9,
"handshake_ms": 599.1,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.4",
"tcp_ms": 609.9,
"handshake_ms": 794.4,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.9",
"tcp_ms": 791.7,
"handshake_ms": 973.8,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.10",
"tcp_ms": 973.8,
"handshake_ms": 1153.9,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.7",
"tcp_ms": 1156.6,
"handshake_ms": 1914.6,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.21",
"tcp_ms": 1906.3,
"handshake_ms": 2689.0,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.1",
"tcp_ms": 2703.3,
"handshake_ms": 3606.3,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.11",
"tcp_ms": 3600.5,
"handshake_ms": 3795.7,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.5",
"tcp_ms": 3800.5,
"handshake_ms": 3996.5,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.6",
"tcp_ms": 3996.7,
"handshake_ms": 4194.4,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.8",
"tcp_ms": 4194.1,
"handshake_ms": 5120.6,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.18",
"tcp_ms": 5114.5,
"handshake_ms": 5299.6,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.20",
"tcp_ms": 5298.5,
"handshake_ms": 6025.5,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.12",
"tcp_ms": 6031.7,
"handshake_ms": 6218.7,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.3",
"tcp_ms": 6225.4,
"handshake_ms": 7469.9,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.23",
"tcp_ms": 7457.2,
"handshake_ms": 7648.1,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.29",
"tcp_ms": 7644.9,
"handshake_ms": 7827.4,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.26",
"tcp_ms": 7829.8,
"handshake_ms": 8188.7,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.15",
"tcp_ms": 8197.2,
"handshake_ms": 8388.9,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.16",
"tcp_ms": 8388.6,
"handshake_ms": 8585.0,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.32",
"tcp_ms": 8574.8,
"handshake_ms": 9287.3,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.19",
"tcp_ms": 9297.3,
"handshake_ms": 9490.1,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.13",
"tcp_ms": 9494.5,
"handshake_ms": 9692.0,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.22",
"tcp_ms": 9686.6,
"handshake_ms": 10704.7,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.24",
"tcp_ms": 10704.7,
"handshake_ms": 11717.3,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.25",
"tcp_ms": 11717.6,
"handshake_ms": 12484.2,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.34",
"tcp_ms": 12479.1,
"handshake_ms": 12667.2,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.14",
"tcp_ms": 12681.3,
"handshake_ms": 13862.1,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.42",
"tcp_ms": 13843.7,
"handshake_ms": 14038.8,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.37",
"tcp_ms": 14043.4,
"handshake_ms": 14412.6,
"tls": "TLSv1.3",
"alpn": "http/1.1"
},
{
"ip": "43.174.150.35",
"tcp_ms": 14414.9,
"handshake_ms": 15780.7,
"tls": "TLSv1.3",
"alpn": "http/1.1"
}
]
+114
View File
@@ -0,0 +1,114 @@
[
{
"ip": "43.175.107.217",
"http": "400",
"total_s": 1.010907,
"elapsed_ms": 3525.3,
"note": ""
},
{
"ip": "43.175.118.111",
"http": "400",
"total_s": 1.113784,
"elapsed_ms": 2782.9,
"note": ""
},
{
"ip": "43.175.18.50",
"http": "200",
"total_s": 1.504578,
"elapsed_ms": 1629.3,
"note": ""
},
{
"ip": "43.175.17.109",
"http": "200",
"total_s": 1.675108,
"elapsed_ms": 2895.5,
"note": ""
},
{
"ip": "43.175.113.139",
"http": "200",
"total_s": 1.79808,
"elapsed_ms": 3461.2,
"note": ""
},
{
"ip": "43.175.119.214",
"http": "200",
"total_s": 1.836886,
"elapsed_ms": 4706.9,
"note": ""
},
{
"ip": "43.175.115.202",
"http": "200",
"total_s": 1.977182,
"elapsed_ms": 3401.5,
"note": ""
},
{
"ip": "43.175.115.123",
"http": "200",
"total_s": 1.987886,
"elapsed_ms": 3563.1,
"note": ""
},
{
"ip": "43.175.115.149",
"http": "200",
"total_s": 2.029702,
"elapsed_ms": 3139.6,
"note": ""
},
{
"ip": "43.175.119.174",
"http": "200",
"total_s": 2.116343,
"elapsed_ms": 3937.1,
"note": ""
},
{
"ip": "43.175.119.230",
"http": "200",
"total_s": 2.65339,
"elapsed_ms": 4696.5,
"note": ""
},
{
"ip": "43.174.126.220",
"http": "200",
"total_s": 3.056871,
"elapsed_ms": 4404.8,
"note": ""
},
{
"ip": "43.175.115.41",
"http": "200",
"total_s": 3.309262,
"elapsed_ms": 4261.4,
"note": ""
},
{
"ip": "43.174.126.152",
"http": "200",
"total_s": 3.333779,
"elapsed_ms": 4557.8,
"note": ""
},
{
"ip": "43.175.17.185",
"http": "200",
"total_s": 3.525846,
"elapsed_ms": 5194.5,
"note": ""
},
{
"ip": "43.175.16.233",
"http": "200",
"total_s": 4.754878,
"elapsed_ms": 6255.5,
"note": ""
}
]
+3579
View File
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
EO 优选 IP 免费探测脚本 (方案 B 免费替代 wetest.vip)
原理: EdgeOne 边缘节点(腾讯云香港 43.174.x/43.175.x 段)对任意 EO 接入域名做 SNI 路由。
若某 IP:443 能对 box.shiroko.one 完成 TLS 握手, 则该 IP 可作为境内 A 记录直连目标。
用法:
python eo-probe-ips.py # 扫默认段列表(已知优选 IP 所在 /24)
python eo-probe-ips.py --host box.shiroko.one --out eo-hits.json
判定: TCP 连通 + TLS 握手成功(SNI=目标host) -> hit; 记录握手 RTT。
"""
import argparse
import asyncio
import json
import socket
import ssl
import sys
import time
# 已知/疑似 EO 香港优质 IP 所在段(来自社区 itdog 截图统计 + 教程)
DEFAULT_CIDRS = [
"43.174.150.0/24", "43.174.78.0/24", "43.174.79.0/24", "43.174.57.0/24",
"43.175.130.0/24", "43.175.132.0/24", "43.175.151.0/24", "43.175.165.0/24",
"43.175.185.0/24", "43.175.150.0/24", "43.174.152.0/24", "43.174.153.0/24",
"43.175.131.0/24", "43.175.133.0/24",
]
def cidr_hosts(cidr: str):
net, bits = cidr.split("/")
bits = int(bits)
a, b, c, d = map(int, net.split("."))
base = (a << 24) + (b << 16) + (c << 8) + d
count = 1 << (32 - bits)
for i in range(1, count - 1): # skip network & broadcast
v = base + i
yield f"{(v >> 24) & 255}.{(v >> 16) & 255}.{(v >> 8) & 255}.{v & 255}"
async def probe(ip: str, host: str, timeout: float, sem: asyncio.Semaphore):
"""TCP connect + TLS handshake (SNI=host). Return dict or None."""
async with sem:
loop = asyncio.get_running_loop()
t0 = time.perf_counter()
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
# async connect
await loop.sock_connect(sock, (ip, 443))
tcp_ms = (time.perf_counter() - t0) * 1000
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_alpn_protocols(["http/1.1"])
tls_sock = ctx.wrap_socket(sock, server_hostname=host)
tls_sock.settimeout(timeout)
tls_ms = (time.perf_counter() - t0) * 1000
ver = tls_sock.version()
alpn = tls_sock.selected_alpn_protocol()
tls_sock.close()
sock.close()
return {"ip": ip, "tcp_ms": round(tcp_ms, 1),
"handshake_ms": round(tls_ms, 1), "tls": ver, "alpn": alpn}
except Exception as e:
try:
sock.close()
except Exception:
pass
return None
async def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="box.shiroko.one")
ap.add_argument("--cidrs", nargs="*", default=None)
ap.add_argument("--concurrency", type=int, default=256)
ap.add_argument("--timeout", type=float, default=2.5)
ap.add_argument("--out", default="eo-hits.json")
args = ap.parse_args()
cidrs = args.cidrs or DEFAULT_CIDRS
ips = []
for c in cidrs:
ips.extend(cidr_hosts(c))
print(f"[*] 目标 host: {args.host} 段数: {len(cidrs)} 待测 IP: {len(ips)} 并发: {args.concurrency}")
sem = asyncio.Semaphore(args.concurrency)
hits = []
done = 0
t_start = time.perf_counter()
# chunk to avoid building all tasks at once
CH = args.concurrency * 4
for i in range(0, len(ips), CH):
chunk = ips[i:i + CH]
tasks = [asyncio.create_task(probe(ip, args.host, args.timeout, sem)) for ip in chunk]
results = await asyncio.gather(*tasks)
for r in results:
if r:
hits.append(r)
done += len(chunk)
if done % (len(ips) // 10 or 1) == 0 or done == len(ips):
el = time.perf_counter() - t_start
print(f" [{done}/{len(ips)}] hits={len(hits)} elapsed={el:.1f}s")
hits.sort(key=lambda x: x["handshake_ms"])
print(f"\n[*] 完成: {done} IP 用时 {time.perf_counter()-t_start:.1f}s, 命中 {len(hits)}")
for h in hits[:40]:
print(f" {h['ip']:16s} tcp={h['tcp_ms']:6.1f}ms hs={h['handshake_ms']:6.1f}ms tls={h['tls']} alpn={h['alpn']}")
if hits and args.out:
with open(args.out, "w", encoding="utf-8") as f:
json.dump(hits, f, ensure_ascii=False, indent=2)
print(f"[*] 结果已存: {args.out}")
if __name__ == "__main__":
asyncio.run(main())
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
EO 优选 IP 扫描 v2 (schannel 证书校验版)
判据更严格: 用 curl (Windows schannel, 系统证书链) + --resolve 强制 SNI,
只有当 TLS 证书链校验通过(浏览器视角可访问)且返回 HTTP 响应才算 hit。
这能区分 "CDN 通用池 IP (证书 *.cdn.myqcloud.com 不匹配)" 与 "真正承载目标站点的 EO 边缘"。
用法: python eo-probe-v2.py --cidrs 43.174.246.0/24 43.174.247.0/24 ...
"""
import argparse
import concurrent.futures
import json
import subprocess
import time
DEFAULT_CIDRS = [
"43.174.150.0/24", "43.174.151.0/24", "43.174.152.0/24", "43.174.153.0/24",
"43.174.78.0/24", "43.174.79.0/24", "43.174.57.0/24",
"43.175.130.0/24", "43.175.131.0/24", "43.175.132.0/24", "43.175.133.0/24",
"43.175.150.0/24", "43.175.151.0/24", "43.175.152.0/24",
"43.175.165.0/24", "43.175.166.0/24",
"43.175.185.0/24", "43.175.186.0/24",
"43.174.246.0/24", "43.174.247.0/24", # EO 官方当前分配段(新加坡)
]
def cidr_hosts(cidr):
net, bits = cidr.split("/")
bits = int(bits)
a, b, c, d = map(int, net.split("."))
base = (a << 24) + (b << 16) + (c << 8) + d
count = 1 << (32 - bits)
for i in range(1, count - 1):
v = base + i
yield f"{(v >> 24) & 255}.{(v >> 16) & 255}.{(v >> 8) & 255}.{v & 255}"
def probe(ip, host, timeout):
t0 = time.perf_counter()
# schannel on Windows: 默认走系统 CA, 证书不匹配会直接失败 => http_code 000 + 错误
cmd = ["curl", "-sS", "--resolve", f"{host}:443:{ip}",
"--connect-timeout", str(timeout), "--max-time", str(timeout * 2),
"-o", "NUL", "-w", "%{http_code}|%{time_total}|%{time_starttransfer}",
f"https://{host}/"]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout * 3,
errors="replace")
out = r.stdout.strip()
parts = out.split("|")
code = parts[0] if parts else "000"
el = (time.perf_counter() - t0) * 1000
err = r.stderr.strip() if r.stderr else ""
if code not in ("000",):
return {"ip": ip, "http": code, "total_s": float(parts[1]) if len(parts) > 1 else None,
"elapsed_ms": round(el, 1), "note": err.splitlines()[-1] if err else ""}
# 000: 看看是不是证书错误
note = ""
if "SEC_E_WRONG_PRINCIPAL" in err or "certificate" in err.lower():
note = "cert-mismatch"
elif err:
note = err.splitlines()[-1][:80]
return None
except Exception as e:
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="box.shiroko.one")
ap.add_argument("--cidrs", nargs="*", default=None)
ap.add_argument("--workers", type=int, default=64)
ap.add_argument("--timeout", type=float, default=3)
ap.add_argument("--out", default="eo-hits-v2.json")
args = ap.parse_args()
cidrs = args.cidrs or DEFAULT_CIDRS
ips = []
for c in cidrs:
ips.extend(cidr_hosts(c))
print(f"[*] host={args.host} 段={len(cidrs)} IP={len(ips)} workers={args.workers}")
hits = []
t_start = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as ex:
futs = {ex.submit(probe, ip, args.host, args.timeout): ip for ip in ips}
done = 0
for fut in concurrent.futures.as_completed(futs):
done += 1
r = fut.result()
if r:
hits.append(r)
print(f" HIT {r['ip']:16s} http={r['http']} total={r['total_s']}s {r['note']}")
if done % 500 == 0:
print(f" [{done}/{len(ips)}] hits={len(hits)} elapsed={time.perf_counter()-t_start:.0f}s")
print(f"\n[*] done in {time.perf_counter()-t_start:.0f}s, hits={len(hits)}")
if hits:
hits.sort(key=lambda x: x["total_s"] or 99)
with open(args.out, "w", encoding="utf-8") as f:
json.dump(hits, f, ensure_ascii=False, indent=2)
print(f"[*] saved: {args.out}")
if __name__ == "__main__":
main()
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
EO 全段扫描 v3 (方案 B: 找所有能服务 box.shiroko.one 的 EO 边缘 IP)
两段式: 1) asyncio TCP:443 预筛全段 2) openssl 对 TCP 通的 IP 做 SNI 证书核验 (CN 含 host)
输出: eo-all-hits.json [{ip, cn, tcp_ms}]
用法: python eo-scan-all.py --host box.shiroko.one --nets 43.174.0.0/16 43.175.0.0/16 --workers 400
"""
import argparse
import asyncio
import json
import socket
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
def iter_hosts(net: str):
net, bits = net.split("/")
bits = int(bits)
a, b, c, d = map(int, net.split("."))
base = (a << 24) + (b << 16) + (c << 8) + d
count = 1 << (32 - bits)
for i in range(1, count - 1):
v = base + i
yield f"{(v >> 24) & 255}.{(v >> 16) & 255}.{(v >> 8) & 255}.{v & 255}"
async def tcp_probe(ip: str, port: int, timeout: float, sem: asyncio.Semaphore):
async with sem:
t0 = time.perf_counter()
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setblocking(False)
loop = asyncio.get_running_loop()
await asyncio.wait_for(loop.sock_connect(s, (ip, port)), timeout)
ms = (time.perf_counter() - t0) * 1000
s.close()
return ip, ms
except Exception:
try:
s.close()
except Exception:
pass
return None
def tls_cn(ip: str, host: str, timeout: float):
try:
r = subprocess.run(
["openssl", "s_client", "-connect", f"{ip}:443", "-servername", host,
"-tlsextdebug", "-no_ign_eof"],
capture_output=True, text=True, timeout=timeout, errors="replace",
input="Q")
out = r.stdout + r.stderr
cn = ""
for line in out.splitlines():
ls = line.strip()
if ls.startswith("subject="):
cn = ls
break
return {"ip": ip, "cn": cn or "(no-subject)", "ok": host in cn}
except Exception as e:
return {"ip": ip, "cn": f"ERR:{type(e).__name__}", "ok": False}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="box.shiroko.one")
ap.add_argument("--nets", nargs="*",
default=["43.174.0.0/16", "43.175.0.0/16"])
ap.add_argument("--workers", type=int, default=300)
ap.add_argument("--timeout", type=float, default=2.0)
ap.add_argument("--out", default="eo-all-hits.json")
args = ap.parse_args()
all_ips = []
for n in args.nets:
all_ips.extend(iter_hosts(n))
print(f"[*] host={args.host} 网段={args.nets} 总IP={len(all_ips)}")
# Stage 1: TCP 预筛
sem = asyncio.Semaphore(args.workers)
open_ips = []
async def run_tcp():
CH = args.workers * 6
for i in range(0, len(all_ips), CH):
chunk = all_ips[i:i + CH]
tasks = [asyncio.create_task(tcp_probe(ip, 443, args.timeout, sem)) for ip in chunk]
res = await asyncio.gather(*tasks)
for r in res:
if r:
open_ips.append(r)
if (i + CH) % 20000 < CH or i + CH >= len(all_ips):
print(f" tcp预筛 [{i+CH}/{len(all_ips)}] open={len(open_ips)}", flush=True)
t0 = time.perf_counter()
asyncio.run(run_tcp())
print(f"[*] TCP 预筛完成: {len(all_ips)} -> {len(open_ips)} open, {time.perf_counter()-t0:.0f}s")
if not open_ips:
print("[!] 无开放 IP")
return
# Stage 2: openssl 证书核验
hits = []
with ThreadPoolExecutor(max_workers=64) as ex:
futs = [ex.submit(tls_cn, ip, args.host, args.timeout * 2) for ip, _ in open_ips]
done = 0
for fut in futures_as_completed(futs):
done += 1
r = fut.result()
if r and r["ok"]:
hits.append(r)
print(f" HIT {r['ip']:16s} {r['cn']}", flush=True)
if done % 200 == 0:
print(f" 证书核验 [{done}/{len(open_ips)}] hits={len(hits)}", flush=True)
print(f"\n[*] 全部完成: hits={len(hits)}")
if hits:
with open(args.out, "w", encoding="utf-8") as f:
json.dump(hits, f, ensure_ascii=False, indent=2)
print(f"[*] saved: {args.out}")
def futures_as_completed(futs):
import concurrent.futures
for fut in concurrent.futures.as_completed(futs):
yield fut
if __name__ == "__main__":
main()
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
gen_changelog.py — 生成 PocketBase 风格的 changelogs JSON(无需 PocketBase 服务)
用途:
你在本地维护一个 Markdown 源文件(按 `## <时间戳>` 分段写 changelog),
本脚本把它导出成前端 change-logs.html 所 fetch 的 tamabox.changelogs.json。
输出格式完全兼容 PocketBase list API 的返回结构,但无需部署任何后端服务,
因此不会在你的 1C1G 服务器上多占哪怕 1 MB 常驻内存。
输出位置:static/tamabox.changelogs.json —— 该文件被 static 包以 //go:embed 打入
二进制,由 flamego.Static 在 /static 路径同源提供,前端直接 fetch 同源路径,
不再依赖任何外部域名(去掉 api-direct.337845818.best 等外链,降低可用性/安全风险)。
源文件格式(dev/changelog-source.md 示例):
## 2024-03-04 21:45:55
新增:用户私信功能上线。
修复:移动端问题列表间距过窄。
## 2024-02-29 00:00:55
优化:首页加载速度提升约 30%。
注意:
- 时间戳支持 `YYYY-MM-DD HH:MM:SS` / `YYYY-MM-DD` / 带毫秒的 `...fffZ` / ISO 格式,
脚本会统一归一化为 PocketBase 风格的 `YYYY-MM-DD HH:MM:SS.fffZ`(UTC)。
- 正文里的换行默认会被转成 `<br>`,因为前端用 x-html 渲染;传 --no-br 可关闭。
- 导出时按时间倒序排列,最新一条排在最前(前端读 items[0] 即取最新)。
用法:
python dev/gen_changelog.py
python dev/gen_changelog.py 源文件.md 输出.json
python dev/gen_changelog.py 源文件.md 输出.json --no-br
"""
import json
import re
import sys
from datetime import datetime, timezone
COLLECTION_ID = "tamabox"
COLLECTION_NAME = "tamabox-changelogs"
DEFAULT_SRC = "dev/changelog-source.md"
DEFAULT_OUT = "static/tamabox.changelogs.json"
TS_LINE = re.compile(r"^##\s+(.+?)\s*$")
FULL_TS = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}Z$")
def normalize_ts(raw: str) -> str:
"""把用户输入的时间归一化为 PocketBase 风格时间戳:YYYY-MM-DD HH:MM:SS.fffZ(UTC)。"""
raw = raw.strip()
if FULL_TS.match(raw):
return raw
for fmt in ("%Y-%m-%d %H:%M:%S.%fZ", "%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
dt = datetime.strptime(raw, fmt)
return dt.strftime("%Y-%m-%d %H:%M:%S") + ".000Z"
except ValueError:
continue
try:
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + ".000Z"
except ValueError:
sys.exit(f"[错误] 无法解析时间戳: {raw!r}")
def parse_source(text: str):
"""按 `## <时间戳>` 分段,返回 [(ts, content), ...],顺序与源文件一致。"""
entries, cur_ts, buf = [], None, []
for line in text.splitlines():
m = TS_LINE.match(line)
if m:
if cur_ts is not None:
entries.append((cur_ts, "\n".join(buf).strip()))
cur_ts, buf = m.group(1).strip(), []
elif cur_ts is not None:
buf.append(line)
if cur_ts is not None:
entries.append((cur_ts, "\n".join(buf).strip()))
if not entries:
sys.exit("[错误] 源文件未找到任何 '## <时间戳>' 分段")
return entries
def build(entries, br=True):
"""把条目导出为 PocketBase list API 风格 JSON(最新在前)。"""
parsed = [(normalize_ts(ts), c) for ts, c in entries]
parsed.sort(key=lambda x: x[0], reverse=True) # 倒序:最新 published_at 在前
items = []
for ts, content in parsed:
items.append({
"collectionId": COLLECTION_ID,
"collectionName": COLLECTION_NAME,
"content": content.replace("\n", "<br>\n") if br else content,
"created": ts,
"id": "none",
"published_at": ts,
"updated": ts,
})
return {
"page": 1,
"perPage": len(items) or 1,
"totalItems": len(items),
"totalPages": 1,
"items": items,
}
def main():
args = sys.argv[1:]
br = "--no-br" not in args
paths = [a for a in args if not a.startswith("--")]
src = paths[0] if len(paths) > 0 else DEFAULT_SRC
out = paths[1] if len(paths) > 1 else DEFAULT_OUT
try:
with open(src, encoding="utf-8") as f:
text = f.read()
except FileNotFoundError:
sys.exit(f"[错误] 找不到源文件: {src}")
data = build(parse_source(text), br=br)
with open(out, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
latest = data["items"][0]["published_at"] if data["items"] else "N/A"
print(f"[完成] 已生成 {out}({data['totalItems']} 条记录,最新发布于 {latest})")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"Replace": {"C:/OtherFiles/Github/TamaBox-v4-mainline/internal/storage/s3.go": "C:/Users/TAMAKY~1/AppData/Local/Temp/tamabox_s3_stub.go"}}
+31
View File
@@ -0,0 +1,31 @@
// s3_stub.go 是 internal/storage/s3.go 的本地编译桩:s3.go 依赖 cgo 库
// chai2010/webp(需要 libwebp + C 工具链),Windows 沙箱无此环境。
// 通过 `go build -overlay=dev/overlay.json` 用本文件替换 s3.go,做全模块类型检查。
// 符号覆盖 = s3.go 中被包内其他文件(oss.go 引用 isSupportedImage)与
// 包外调用方(route/user、route/question、route/chat 等)引用的全部标识符。
// 桩仅在类型检查时生效,不会被真正调用;生产构建在 Linux(cgo + libwebp)下不受影响。
package storage
import (
"io"
"mime/multipart"
)
// MaxUploadBytes 与真实实现保持一致(8MB 单图上限,bodylimit 中间件依赖此值语义)。
const MaxUploadBytes = 8 * 1024 * 1024
func UploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
return "", nil
}
func UploadAvatarToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
return "", nil
}
func UploadBackgroundToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
return "", nil
}
func ConvertStaticImageToWebP(r io.Reader) ([]byte, error) { return nil, nil }
func isSupportedImage(data []byte) bool { return true }
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# TamaBox Cloudflare Tunnel 部署安全自检脚本
#
# 架构前提:
# 访客 → Cloudflare 边缘(a.b.com 自定义主机名证书 / a.a.com 主域)
# → cloudflared Tunnel(源站仅出站连接)→ 127.0.0.1:5877 (TamaBox)
#
# 用法:
# 第一部分(在源站服务器上执行): ./security-selfcheck.sh local
# 第二部分(在你自己的电脑上执行):SERVER_IP=源站公网IP CF_DOMAIN=a.a.com CUSTOM_DOMAIN=a.b.com ./security-selfcheck.sh remote
# 两部分都跑: ./security-selfcheck.sh all
#
set -u
MODE="${1:-all}"
SERVER_IP="${SERVER_IP:-}"
CF_DOMAIN="${CF_DOMAIN:-a.a.com}"
CUSTOM_DOMAIN="${CUSTOM_DOMAIN:-a.b.com}"
APP_PORT="${APP_PORT:-5877}"
section() { printf "\n========== %s ==========\n" "$1"; }
ok() { echo "[✓] $1"; }
warn() { echo "[!] $1"; }
bad() { echo "[✗] $1"; }
# 用 bash /dev/tcp 探测 TCP 端口是否可达(3 秒超时)
port_open() {
(timeout 3 bash -c "echo > /dev/tcp/$1/$2") 2>/dev/null
}
# ----------------------------------------------------------------
# 第一部分:源站本地检查
# ----------------------------------------------------------------
local_checks() {
section "1. 应用与数据库端口绑定检查(核心)"
BIND=$(ss -tlnp 2>/dev/null | grep ":${APP_PORT} " || true)
echo "$BIND"
if echo "$BIND" | grep -qE "127\.0\.0\.1:${APP_PORT}|\[::1\]:${APP_PORT}"; then
ok "TamaBox 只绑定在 127.0.0.1:${APP_PORT},外部无法直连(正确)"
elif [ -z "$BIND" ]; then
warn "端口 ${APP_PORT} 未监听——确认 TamaBox 是否在运行"
else
bad "TamaBox 绑定在 0.0.0.0/*:${APP_PORT}(监听所有网卡)!"
echo " 修复:conf/app.ini [server] 段设置 listen = 127.0.0.1:${APP_PORT} 并重启"
fi
section "2. PostgreSQL / Redis 是否暴露公网"
for p in 5432 6379; do
BIND=$(ss -tlnp 2>/dev/null | grep ":${p} " || true)
if echo "$BIND" | grep -qE "0\.0\.0\.0:${p}|\*:${p}"; then
bad "端口 ${p} 绑定在所有网卡!必须改为 127.0.0.1 或加防火墙(云数据库直接暴露=高危)"
else
ok "端口 ${p} 未对外监听(${BIND:-未运行})"
fi
done
section "3. cloudflared 状态与 ingress 配置"
if systemctl is-active --quiet cloudflared 2>/dev/null || pgrep -x cloudflared >/dev/null 2>&1; then
ok "cloudflared 正在运行"
else
bad "cloudflared 未运行——Tunnel 断开,所有域名不可达"
fi
CFG=""
for f in /etc/cloudflared/config.yml /root/.cloudflared/config.yml /home/*/.cloudflared/config.yml; do
[ -f "$f" ] && CFG="$f" && break
done
if [ -n "$CFG" ]; then
echo "配置文件:$CFG"
grep -nE "hostname|service|originRequest" "$CFG" || true
if grep -qE "service:\s*http://127\.0\.0\.1:${APP_PORT}|localhost:${APP_PORT}" "$CFG"; then
ok "ingress service 指向 127.0.0.1:${APP_PORT}(正确)"
else
warn "未在 config.yml 中找到指向 127.0.0.1:${APP_PORT} 的 service,请人工核对 ingress 规则"
fi
else
warn "未找到 config.yml(若用 token 模式 + 远端 ingress,请到 Cloudflare 面板核对 Public Hostname 的 service 地址)"
fi
section "4. 本地请求验证(模拟 cloudflared 的视角)"
RESP=$(curl -s -m 5 -o /dev/null -w "%{http_code}" "http://127.0.0.1:${APP_PORT}/" || true)
if [ "$RESP" = "200" ] || [ "$RESP" = "302" ] || [ "$RESP" = "303" ]; then
ok "本地 HTTP 请求返回 $RESP,应用正常"
else
warn "本地请求返回 ${RESP:-无响应},请人工确认"
fi
section "5. 防火墙状态(仅供参考)"
if command -v ufw >/dev/null 2>&1; then
ufw status verbose 2>/dev/null | head -15 || true
elif command -v firewall-cmd >/dev/null 2>&1; then
firewall-cmd --list-all 2>/dev/null || true
else
iptables -L INPUT -n --line-numbers 2>/dev/null | head -15 || true
fi
warn "请人工确认:除 SSH(22) 外,80/443/5877 等端口不应有公网入站放行规则"
}
# ----------------------------------------------------------------
# 第二部分:外部探测(在自己的电脑上执行)
# ----------------------------------------------------------------
remote_checks() {
if [ -z "$SERVER_IP" ]; then
bad "未设置 SERVER_IP 环境变量,跳过直连探测(用法见文件头注释)"
else
section "6. 直连源站 IP 探测(核心:防 IP+端口直连)"
for p in "$APP_PORT" 80 443 5432 6379; do
if port_open "$SERVER_IP" "$p"; then
case "$p" in
22) warn "端口 22 开放(SSH,正常,但建议改密钥登录/限来源 IP)" ;;
*) bad "端口 ${p} 从公网可达!存在被直连/攻击面,去服务器防火墙封掉它" ;;
esac
else
ok "端口 ${p} 从公网不可达"
fi
done
fi
section "7. 经域名的正常访问验证"
for d in "$CF_DOMAIN" "$CUSTOM_DOMAIN"; do
CODE=$(curl -s -m 10 -o /dev/null -w "%{http_code}" "https://${d}/" || true)
RAY=$(curl -sI -m 10 "https://${d}/" | grep -i "^cf-ray:" || true)
if [ -n "$RAY" ]; then
ok "https://${d}/ 返回 ${CODE:-?},且带 ${RAY%%$'\r'} → 确认流量经过 Cloudflare 边缘"
elif [ "$CODE" = "200" ] || [ "$CODE" = "302" ] || [ "$CODE" = "303" ]; then
warn "https://${d}/ 返回 ${CODE} 但没有 cf-ray 头 → 流量可能没走 Cloudflare(自定义主机名未生效?)"
else
bad "https://${d}/ 不可达(${CODE:-无响应})"
fi
done
section "8. 伪造头穿透测试(验证 Cloudflare 会重写伪造头)"
# 若伪造头被 CF 覆盖,应用侧记录的仍是真实 IP;此测试主要用于演示,无法从响应直接观察,
# 需配合 TamaBox 侧日志/封禁记录核对(见第 9 项说明)。
curl -s -m 10 -o /dev/null \
-H "CF-Connecting-IP: 203.0.113.66" \
-H "X-Forwarded-For: 203.0.113.66" \
"https://${CUSTOM_DOMAIN}/" || true
ok "已发送带伪造 CF-Connecting-IP 的请求(CF 边缘会将其重写为你的真实 IP)"
section "9. HTTP 明文与 HSTS 检查"
LOC=$(curl -sI -m 10 "http://${CUSTOM_DOMAIN}/" | grep -iE "^(HTTP|location):" | tr -d '\r')
echo "$LOC"
HSTS=$(curl -sI -m 10 "https://${CUSTOM_DOMAIN}/" | grep -i "strict-transport-security" | tr -d '\r')
if [ -n "$HSTS" ]; then ok "$HSTS"; else warn "未返回 HSTS 头(若浏览器直访 http 可能被中间人,建议确认 securityHeaders 已生效)"; fi
}
case "$MODE" in
local) local_checks ;;
remote) remote_checks ;;
all) local_checks; remote_checks ;;
*) echo "用法: $0 {local|remote|all}"; exit 1 ;;
esac
section "自检完成"
echo "逐项核对上面的 [✓]/[!]/[✗],所有 [✗] 都需要修复后再跑一遍。"
+44
View File
@@ -0,0 +1,44 @@
function secondToDate(second) {
if (!second) {
return 0;
}
var time = new Array(0, 0, 0, 0, 0);
if (second >= 365 * 24 * 3600) {
time[0] = parseInt(second / (365 * 24 * 3600));
second %= 365 * 24 * 3600;
}
if (second >= 24 * 3600) {
time[1] = parseInt(second / (24 * 3600));
second %= 24 * 3600;
}
if (second >= 3600) {
time[2] = parseInt(second / 3600);
second %= 3600;
}
if (second >= 60) {
time[3] = parseInt(second / 60);
second %= 60;
}
if (second > 0) {
time[4] = second;
}
return time;
}
function setTime() {
var create_time = Math.round(new Date(Date.UTC(2023, 06, 01, 18, 15, 43)).getTime() / 1000);
var timestamp = Math.round((new Date().getTime() + 8 * 60 * 60 * 1000) / 1000);
currentTime = secondToDate((timestamp - create_time));
currentTimeHtml = currentTime[0] + '年 ' + currentTime[1] + '天 ' + currentTime[2] + '时 ' + currentTime[3] + '分 ' + currentTime[4] + '秒 ';
document.getElementById("box_runtime").innerHTML = currentTimeHtml;
var boxRuntime = document.getElementById("box_runtime");
if (boxRuntime) {
boxRuntime.innerHTML = currentTimeHtml;
} else {
console.error("Element with id 'box_runtime' not found!");
}
}
// 确保 DOM 加载完成后执行
document.addEventListener('DOMContentLoaded', function() {
setInterval(setTime, 1000);
});
+20
View File
@@ -0,0 +1,20 @@
// webpstub(package webp)是 chai2010/webp 的本地编译桩:仅用于在缺少 libwebp 的
// Windows 沙箱中通过 `go build -overlay` 对依赖 internal/storage 的包做完整
// 类型检查。API 只覆盖项目实际用到的 Encode/Options。
package webp
import (
"image"
"io"
)
// Options 与 github.com/chai2010/webp 的 Options 字段保持兼容(项目仅用 Quality)。
type Options struct {
Lossless bool
Quality float32
}
// Encode 是 webp.Encode 的签名兼容桩(类型检查期间不会被真正调用)。
func Encode(w io.Writer, m image.Image, o *Options) error {
return nil
}