# 文档中心 —— 全文合并版 生成时间:2026-08-07 03:33 说明:本文件由 6 份文档合并而成,供 AI 一次性读取。所有敏感值均为占位符。 ============================================================================== 文档:machinaix-docs 文档站维护手册 来源:src/machinaix-docs.md ============================================================================== ## 一、概览 {#overview} ### 这是什么 一套**静态文档流水线**:你写 Markdown,它产出一整个带导航、搜索、代码高亮、深浅色主题的文档站,并自动部署到 Cloudflare 边缘节点。 ```diagram src/*.md ┐ theme.html ├──► build.py ──► dist/ ──► Cloudflare Workers ──► docs.machinaix.com site.json ┘ │ └──► llms.txt / llms-full.txt ──► {hl:供 AI 读取} ``` ### 它解决的三个问题 ::: cards #### 样式统一 *consistency* 6 份文档共用一套模板。改一次配色或交互,全站生效,不用逐个文件改。 --- #### 凭证安全 *safety* 源文件只写占位符,真值单独存放且从不提交。公开产物出现真值时构建直接失败。 --- #### AI 可读 *machine-readable* 产出 `llms.txt` 与 `llms-full.txt`,AI 拿一个 URL 就能读完整个知识库。 ::: ### 核心特征 | 特征 | 说明 | |------|------| | 零第三方依赖 | 只用 Python 标准库,没有 `requirements.txt`、没有 `node_modules` | | 自包含单文件 | 每个 HTML 内嵌全部 CSS/JS,可离线打开、可单独发给别人 | | 双轨产出 | 公开版(脱敏)与完整版(含真值)由同一份源生成 | | 构建即体检 | 每次构建自动扫描凭证泄漏,不通过就中断 | | 推送即上线 | `git push` 后约 40 秒自动完成构建与部署 | > [!TIP] 谁该看这篇 > 未来的你自己。当你换了电脑、隔了半年、或者想加一份新文档时,从这里开始读,不用回忆任何细节。 ## 二、架构与工作原理 {#architecture} ### 目录结构 ```tree machinaix-docs/ ├── build.py # 生成器:Markdown 渲染 + 模板注入 + 锚点校验 + 泄漏扫描 ├── theme.html # 文档页模板(全部 CSS/JS/组件,6 套品牌配色) ├── theme-index.html # 文档中心首页模板 ├── site.json # 站点配置:文档顺序、卡片分组、扫描白名单、图标 ├── placeholders.json # 占位符标签表(可公开,只有标签没有真值) ├── wrangler.toml # Cloudflare 部署配置(指明 dist/ 为静态资源目录) ├── README.md # 仓库说明(GitHub 上展示) │ ├── src/ # ← 唯一的内容源,日常只改这里 │ ├── machinaix-docs.md # 本文 │ ├── andrewblog.md │ ├── yunchuan.md │ ├── proxy.md │ ├── rustdesk.md │ └── new-api.md │ ├── private/ │ ├── secrets.json # 真实凭证(.gitignore,绝不提交) │ └── secrets.example.json # 模板(会提交,只有占位值) │ ├── dist/ # 公开产物(.gitignore,由 Cloudflare 构建时生成) └── dist-private/ # 完整产物(.gitignore,仅本机) ``` ### 一次构建发生了什么 ::: steps #### 读取配置 读 `site.json`(站点级设置)和 `placeholders.json`(占位符标签),若存在 `private/secrets.json` 也一并读入。 --- #### 逐份解析 Markdown 每个 `src/*.md` 拆成两部分:顶部 `---` 包裹的 JSON front-matter(标题、配色、导航分组、首页卡片信息),以及下方正文。 --- #### 渲染正文 正文按 `##` 切分成 `
`,标题里的编号(`一、` `4.1`)提取成徽章,`{#id}` 作为稳定锚点。表格、代码块、提示框、卡片、清单等逐一转成组件 HTML。 --- #### 处理占位符 `%%KEY%%` 在公开构建里渲染为脱敏块,在 `--private` 构建里替换为真实值。 --- #### 注入模板 把标题、导语、侧栏、正文塞进 `theme.html` 的占位标记,输出自包含单文件。 --- #### 生成首页与 AI 入口 汇总所有 front-matter 里的 `card` 字段生成 `index.html`;同时产出 `llms.txt`(文档清单)和 `llms-full.txt` / `all.txt`(全文合并,同一份内容两个名字)。 --- #### 校验内部锚点 收集全部页面的 `id`,检查每一个 `#锚点` 和 `xxx.html#锚点` 是否真实存在。**只报警告不中断构建** —— 死链不影响其他内容,不值得挡住部署。 --- #### 泄漏扫描 扫描 `dist/` 里所有产物,命中真值或可疑模式则以非零码退出,构建失败。 ::: ### 关键设计:模板与内容分离 `theme.html` 里有一批 `{{...}}` 标记,`build.py` 用字符串替换填入内容: | 标记 | 填入内容 | |------|------| | `{{ACCENT}}` | 品牌色名(写进 ``,CSS 据此切换整套配色) | | `{{SIDEBAR}}` | 根据 front-matter 的 `nav` 生成的分组侧栏 | | `{{CONTENT}}` | 渲染后的正文(一串 `
`) | | `{{META_CHIPS}}` | 首屏那排信息胶囊 | | `{{BADGE}}` | 顶栏右侧的状态徽章 | | `{{RELATED}}` | 正文末尾的「相关文档」卡片,由 front-matter 的 `related` 生成 | 好处是**加一份文档不需要碰任何 CSS/JS**,改一次样式全站同步。 ## 三、在新机器上开始 {#start} 假设你换了一台电脑,或者半年后回来接手,从零开始的完整步骤。 ### 前置条件 | 需要 | 版本 | 检查命令 | |------|------|------| | Python | 3.7 或更高 | `python --version` | | Git | 任意 | `git --version` | | 浏览器 | 任意现代浏览器 | — | **不需要** Node.js、不需要 `pip install`、不需要 `npm install`。整个生成器只用 Python 标准库。 ### 步骤 ::: steps #### 克隆仓库 ```bash git clone https://github.com/ANDREW-SVIP/machinaix-docs.git cd machinaix-docs ``` 仓库是**私有**的,克隆时需要 `ANDREW-SVIP` 账号的 Git 凭证。首次在新机器上操作会弹出浏览器登录(Git Credential Manager)或要求输入 Personal Access Token。 --- #### 构建公开版 ```bash python build.py ``` macOS / Linux 上如果 `python` 指向 Python 2,改用 `python3 build.py`。 正常输出: ```text [doc] machinaix-docs 11 sections machinaix-docs 文档站维护手册 [doc] andrewblog 15 sections AndrewBlog 开发者文档 [doc] yunchuan 12 sections 云传 · 开发者文档 [doc] proxy 16 sections 代理服务器部署文档 [doc] rustdesk 14 sections RustDesk 自建服务器 · 运维与诊断文档 [doc] new-api 11 sections New API 运维手册 [gen] index.html / llms.txt / all.txt / llms-full.txt [scan] 内部锚点校验通过 [scan] 泄漏扫描通过(6 份文档) 完成 -> .../machinaix-docs/dist ``` --- #### 本地预览 直接双击 `dist/index.html`,或者: ```bash # Windows start dist\index.html # macOS open dist/index.html ``` 产物是自包含单文件,**不需要起本地服务器**,`file://` 协议直接就能看。 --- #### 恢复真实凭证(可选,仅本机需要完整版时) `private/secrets.json` 不在仓库里(这是设计使然)。新机器上有三种拿法: 1. 从旧机器复制这个文件过来 2. 从密码管理器里逐项恢复 3. 照着模板重建: ```bash cp private/secrets.example.json private/secrets.json # 然后编辑 secrets.json 填入真实值 ``` 填好后生成完整版: ```bash python build.py --private ``` 产物在 `dist-private/`,同样是 `.gitignore` 的,只在本机存在。 --- #### 只做体检不写文件 ```bash python build.py --check ``` 只跑解析和泄漏扫描,不产出文件。适合提交前快速验证。 --- #### 新建一份文档 ```bash python build.py --new proxy-v2 ``` 生成带完整 front-matter 骨架的 `src/proxy-v2.md`,并自动把 `"proxy-v2"` 追加进 `site.json` 的 `order`。已存在则拒绝覆盖。 ::: > [!WARNING] 没有 secrets.json 也能正常工作 > 缺少 `private/secrets.json` 时,公开构建完全正常(占位符渲染成脱敏块);只有 `--private` 会因为拿不到真值而退化成脱敏输出。所以在任何一台机器上 clone 下来就能直接构建和预览。 ## 四、写作语法 {#syntax} 标准 Markdown 的一个子集,加上几个专用扩展。 ### Front-matter(JSON) 每份 `.md` 顶部用 `---` 包一段 JSON。这是唯一必须的结构: ```json { "title": "文档标题(首屏 H1 与浏览器标签)", "brand": "顶栏品牌名", "brandSub": "顶栏副标题", "accent": "teal", "eyebrow": "运维文档", "subtitle": "副标题(等宽字体,可省略)", "lede": "首屏导语,一两句话说清这份文档是什么", "badge": { "text": "🔒 脱敏版", "tone": "ok" }, "meta": [["框架", "Flask 3.1"], ["许可证", "MIT"]], "footer": ["页脚左侧", "页脚右侧"], "related": ["proxy", "new-api"], "nav": [ { "group": "开始", "items": ["s1", "s2"] }, { "group": "进阶", "items": ["s3"] } ], "card": { "group": "基础设施文档", "initial": "R", "sub": "首页卡片的等宽副标题", "desc": "首页卡片正文描述", "role": "配色表里的定位说明", "tags": [["🔒 脱敏版", "ok"], ["14 章节", ""]], "meta": "卡片底部的小字" } } ``` | 字段 | 取值 | |------|------| | `accent` | `blue` / `indigo` / `teal` / `violet` / `fuchsia` / `slate` | | `tone`、标签颜色 | `ok`(绿)/ `warn`(黄)/ `danger`(红)/ `accent`(品牌色)/ 空(灰) | | `nav.items` | 章节 id 数组;写成 `{"id":"s31","label":"3.1 节点","sub":true}` 可自定义标签并缩进为子项 | | `card.group` | 决定首页归到哪一组,组的顺序在 `site.json` 的 `cardGroups` 里 | | `related` | 其他文档的 slug 数组(可省略)。正文末尾自动渲染「相关文档」卡片,标题和描述从对方的 front-matter 取,不用手写。slug 写错会在构建时报 `[warn] related 指向不存在的文档` | ### 章节与锚点 ```text ## 一、架构总览 {#s1} ### 4.1 UFW 防火墙 ``` - `##` 自动切分成 `
`,是侧栏导航的单位 - 标题开头的编号(`一、` `1.` `4.1`)会被提取成左侧的方块徽章 - `{#s1}` 是稳定锚点,交叉引用写 `[见第七节](#s7)` - `###` 进入右侧「本节内容」目录,跟随滚动高亮 没写 `{#id}` 时,`##` 用出现顺序编号(`s1`、`s2`……),`###` 用标题内容的哈希(`h-a1b2c3d4`)。两者都**只在你不改动文档结构时才稳定**: | 不写 `{#id}` 时 | 什么情况下锚点会变 | |------|------| | `##` 二级标题 | 增删任何一个二级标题 —— 后面所有编号整体移位 | | `###` 三级标题 | 改动这个标题的文字(哪怕只改一个字) | 所以**凡是要被交叉引用或对外分享的章节,都写显式 `{#id}`**。构建时会校验所有内部锚点,指向不存在的 id 会在输出里报 `[warn] 内部锚点死链`。 ### 提示框 ```text > [!WARNING] 可选标题 > 正文,可以有多行、列表、代码块。 ``` | 类型 | 呈现 | |------|------| | `NOTE` | 品牌色,💡 | | `TIP` / `INFO` | 灰底,💡 / ℹ️ | | `WARNING` / `CAUTION` | 黄色,⚠️ | | `DANGER` / `IMPORTANT` | 红色,⛔ | | `SUCCESS` | 绿色,✅ | | `KEY` / `LOCK` | 灰底,🔑 / 🔒 | ### 代码块 围栏后面跟语言名,支持这些: | 语言 | 效果 | |------|------| | `bash` `powershell` `json` `yaml` `nginx` `go` `env` `python` | 语法高亮 + 语言标签 + 一键复制 | | `text` | 纯文本,无高亮 | | `text wrap` | 长链接自动换行(连接串这类超长内容用它) | | `tree` | 目录树:目录名加粗,`#` 和 `←` 后的注释淡化 | | `diagram` | ASCII 图,支持 `{good:文字}`、`{bad:文字}`、`{hl:文字}` 上色 | | `=html` | 原样输出 HTML,用于流程图之类模板没覆盖的复杂组件 | ### 勾选清单 ```text - [x] **① 已完成** - [ ] **② 待办** - [!] **③ 阻塞** - [~] **④ 已并入别处** ``` 四种状态分别渲染成绿勾、空框、琥珀感叹号、灰虚线(带删除线效果)。 ### 卡片 / 步骤 / 折叠块 用 `:::` 包裹,内部用 `---` 分隔条目: ```text ::: cards #### 卡片标题 *等宽副标题* 卡片正文 --- #### 第二张卡片 正文 ::: ::: steps #### 第一步的标题 正文、代码块、表格都可以放 --- #### [2.5] 自定义编号的步骤 方括号里的内容会替换默认序号 ::: ::: expandable 展开完整日志 内容超过 600px 时自动折叠,底部出现展开按钮 ::: ``` ### 行内元素 支持 `**粗体**`、`*斜体*`、`` `代码` ``、`[链接](url)`,以及原始 HTML 标签。 常用行内组件: | 写法 | 效果 | |------|------| | `已开启` | 绿色胶囊(还有 red / yellow / blue / gray / accent) | | `GET` | 方法徽章(get / post / delete) | | `★★★★☆` | 星级 | | `补充说明` | 弱化小字 | | `
` | 表格单元格内换行 | ## 五、脱敏与占位符 {#secrets} 整套系统里最重要的一条规则:**源文件里永远不写真实凭证。** ### 工作方式 在 Markdown 里写占位符: ```text | 密码 | %%HY2_PASSWORD%% | | 服务器 | %%PROXY_HOST%% | ``` 两种构建产出不同结果: | 构建 | `%%HY2_PASSWORD%%` 渲染为 | 产物目录 | 用途 | |------|------|------|------| | `python build.py` | 脱敏块「HY2 密码」 | `dist/` | 上云、给 AI 读、对外分享 | | `python build.py --private` | 真实值 | `dist-private/` | 本机查阅、实际运维 | 代码块里的占位符会渲染成 `` 这种尖括号形式,保持代码可读。 ### 新增一个占位符 ::: steps #### 在 placeholders.json 加标签 这个文件**会提交**,所以只放标签,不放真值: ```json "NEW_SERVER_IP": { "label": "新服务器 IP", "note": "用途备注" } ``` --- #### 在 private/secrets.json 加真值 这个文件**不会提交**: ```json "NEW_SERVER_IP": "这里填真实 IP" ``` --- #### 在 Markdown 里引用 ```text 服务器地址:%%NEW_SERVER_IP%% ``` ::: > [!TIP] 想在文档里展示占位符语法本身 > 在前面加反斜杠:写 `%%KEY%%` 会原样输出 `%%KEY%%` 而不被替换。本文的所有语法示例用的都是这个转义。 > [!DANGER] 顺序不能反 > 先加标签、后加真值、最后引用。如果直接在 md 里写真值再想着"回头替换",很可能忘记,而泄漏扫描只拦公开产物里的真值 —— 一旦真值进了 Git 历史,就得改密码而不只是改文件。 ### 泄漏扫描 每次构建都会扫描 `dist/` 里的所有产物: | 触发条件 | 处理 | |------|------| | 出现 `private/secrets.json` 里的任何真值(≥6 字符) | 构建失败 | | 疑似 IPv4 | 构建失败(除非在白名单里) | | 疑似 IPv6(5 段以上或含 `::`) | 构建失败 | | UUID 格式 | 构建失败 | | `sk-` / `cfut_` / `ghp_` 开头的密钥 | 构建失败 | 确认可以公开的字面量加进 `site.json` 的 `allowlist`: ```json "allowlist": ["127.0.0.1", "0.0.0.0", "66.249.68.32"] ``` > [!TIP] 白名单里现在有什么 > `127.0.0.1`、`0.0.0.0`(回环与通配,无意义)、两个 Googlebot 的 IP(博客文档里的日志示例,是 Google 的公开地址,不是你的服务器)。加任何新条目前先确认它不是你自己的资产。 ## 六、新增与维护文档 {#maintain} ### 加一份新文档 ::: steps #### 建 Markdown 文件 ```bash python build.py --new xxx ``` 生成 `src/xxx.md` 骨架并自动登记进 `site.json`。也可以手工新建,照抄现有文件的 front-matter 改。 --- #### 登记到 site.json `--new` 已经自动做完这步。手工新建时,在 `order` 数组里加上文件名(不含扩展名),位置决定它在首页和 `llms.txt` 里的排序: ```json "order": ["machinaix-docs", "andrewblog", "yunchuan", "xxx"] ``` 如果用了新的卡片分组,还要在 `cardGroups` 里加上组名来控制分组顺序。 --- #### 构建验证 ```bash python build.py ``` 看输出里有没有你的新文档,以及锚点校验和泄漏扫描是否通过。 --- #### 推送上线 ```bash git add . git commit -m "docs: 新增 xxx 文档" git push ``` Cloudflare 检测到 push 后自动构建部署,约 40 秒后生效。 ::: 首页卡片、侧栏分组、`llms.txt`、`llms-full.txt` **全部自动生成**,不需要手工维护任何索引。 ### 日常修改流程 ```bash # 1. 改内容 # 编辑 src/xxx.md # 2. 本地看效果 python build.py start dist\xxx.html # 3. 满意后推送 git add . git commit -m "docs: 更新 xxx 的某某章节" git push # 4. 等约 40 秒,线上生效 ``` ### 验证线上是否已更新 ```bash curl -s -A "Mozilla/5.0" https://docs.machinaix.com/llms.txt | head -5 ``` 或者直接浏览器强制刷新(Ctrl + Shift + R)。 > [!INFO] 为什么要带 User-Agent > Cloudflare 的机器人防护会拦掉不带 User-Agent 的请求,返回 403。用 curl 测试时记得加 `-A`。 ### 改样式或交互 改 `theme.html`(文档页)或 `theme-index.html`(首页),重新构建即可,**全站同步生效**。这是模板化最大的收益 —— 不用逐个文件改。 ## 七、给 AI 的入口 {#ai} 这是整套系统的核心目的之一:让 AI 用最低成本读懂你的全部技术上下文。 ### 两个入口 | 地址 | 内容 | 什么时候用 | |------|------|------| | `https://docs.machinaix.com/llms.txt` | 全部文档的 URL 清单 + 一句话描述 | 让 AI 先看清单,再决定读哪一篇 | | `https://docs.machinaix.com/llms-full.txt` | 全部文档合并成一份纯文本 | 让 AI 一次性拿到完整上下文 | `https://docs.machinaix.com/all.txt` 是全文合并版的**别名**,内容与 `llms-full.txt` 逐字节相同。`llms-full.txt` 是社区正在形成的约定名,AI 更可能主动去猜这个地址;`all.txt` 是本站原有的名字,保留是为了不让已经发出去的链接失效。 ### 怎么用 对话开始时直接把地址给 AI: ```text 先读 https://docs.machinaix.com/llms.txt, 然后根据我的问题去读对应的文档,再回答。 ``` 或者需要全局上下文时: ```text 读 https://docs.machinaix.com/llms-full.txt,这是我全部项目和服务器的技术文档。 ``` ### 注意事项 > [!WARNING] 线上全是脱敏版 > AI 读到的服务器 IP、密码、UUID 全是占位符。这是刻意设计 —— 让 AI 理解架构和流程,但拿不到能实际访问你系统的凭证。需要 AI 帮你处理真实值时,用本机 `dist-private/` 的内容,不要把线上地址当作真值来源。 > [!INFO] 机器人防护 > Cloudflare 会拦掉不带 User-Agent 的请求。绝大多数 AI 抓取器都会带 UA,所以正常可用。万一某个 AI 读不到,去 Cloudflare 的 **安全性 → WAF / 机器人管理** 给 `docs.machinaix.com` 放行。 ### AI 策略设置 在 Cloudflare 域名设置里配置的三项: | 项目 | 当前设置 | 含义 | |------|------|------| | 搜索 | 阻止 | 搜索引擎爬虫拿不到内容,站点不会出现在搜索结果里 | | **代理** | **允许** | **AI 回答问题时抓取网页走这条,必须开** | | 训练 | 阻止 | AI 厂商的训练爬虫拿不到内容 | > [!DANGER] 代理这项绝对不能关 > 关掉「代理」等于 AI 读不到你的文档,整套系统的核心价值就没了。 ## 八、部署到 Cloudflare Workers {#deploy} 从零复现整套部署的完整步骤。 ### 整体架构 ```diagram GitHub 私有仓库 │ push 触发 ▼ Cloudflare Workers 构建环境 │ ① python build.py -> 生成 dist/(含泄漏扫描) │ ② npx wrangler deploy -> 读 wrangler.toml,上传 dist/ ▼ Cloudflare 边缘节点 ──► {hl:docs.machinaix.com} ``` ### 步骤 ::: steps #### 准备 GitHub 私有仓库 在**你本机 Git 凭证所属的那个账号**下创建仓库,设为 **Private**,然后: ```bash git remote add origin https://github.com/<账号>/machinaix-docs.git git branch -M main git push -u origin main ``` --- #### 确认仓库里有 wrangler.toml 这是 Workers 静态资源部署的必需文件: ```text name = "machinaix-docs" compatibility_date = "2026-08-06" [assets] directory = "./dist" ``` `[assets]` 段告诉 wrangler 把 `dist/` 作为静态资源上传。没有这个文件,`npx wrangler deploy` 会失败。 --- #### 创建 Cloudflare 应用 Cloudflare Dashboard → **Workers 和 Pages** → **创建应用程序** → 找到「导入 Git 存储库」或「Pages」标签页。 新版界面会走「创建 Worker」流程,这是正常的 —— Cloudflare 正在用 Workers 静态资源取代 Pages 新项目。 --- #### 授权 GitHub 选择账号 → **Only select repositories** → 勾选 `machinaix-docs` → Install & Authorize。 **必须勾选具体仓库**,否则回到 Cloudflare 后在列表里找不到它(私有仓库尤其)。 选 `Only select repositories` 而不是 `All repositories`,是最小权限原则 —— Cloudflare 只需要这一个仓库。 --- #### 填写构建配置 | 字段 | 值 | 什么时候执行 | |------|------|------| | 项目名称 | `machinaix-docs` | —— | | 生产分支 | `main` | —— | | **构建命令** | `python build.py` | 每次构建 | | 部署命令 | `npx wrangler deploy` | 只在生产分支 | | **版本命令** | `npx wrangler versions upload` | 只在非生产分支 | | 根目录 | 留空 / `/` | —— | > [!DANGER] 构建命令最容易漏 > 这一栏标着「可选」,很容易跳过。但**漏了它整个部署就是错的** —— `dist/` 不会生成,`wrangler deploy` 找不到资源目录直接失败。详见踩坑第 2 条。 > [!WARNING] 版本命令别填成 deploy > 「版本命令」管的是**非生产分支**。填成 `npx wrangler deploy` 的话,推任何一个分支都会直接覆盖生产版本,而不是生成预览。必须是 `npx wrangler versions upload`。详见踩坑第 9 条。 --- #### 部署并验证 点「部署」,等约 40 秒。成功后会得到一个 `<项目名>.<你的子域>.workers.dev` 地址,打开能看到文档中心首页就说明通了。 --- #### 绑定自定义域 Worker 页面 → **域** 标签 → **添加域名**,填 `docs.machinaix.com`。 **如果报「已有外部管理的 DNS 记录」**,说明 DNS 里有一条同名的 A/CNAME 记录挡着,删掉它再加。**如果提示「没有区域匹配」**,不要点「查找相似项」(那是卖域名的页面),可以先用「添加路由」顶着。完整过程见踩坑第 4、5 条。 加完回列表核对名称 —— 输入框可能自动补后缀,拼出 `docs.machinaix.com.machinaix.com` 这种东西。 --- #### 回填 baseUrl 域名生效后,把 `site.json` 的 `baseUrl` 填成正式地址: ```json "baseUrl": "https://docs.machinaix.com" ``` 推送后 `llms.txt` 里的链接会从相对路径变成完整 URL,AI 抓取时才能正确跳转。 ::: ### 构建环境说明 | 项 | 情况 | |------|------| | Python | 构建镜像自带,直接 `python build.py` 即可 | | 依赖安装 | 无 —— 没有 `requirements.txt`,日志里会显示 `No dependencies detected to cache` | | wrangler | `npx` 首次执行时自动下载(日志里会看到 `will be installed: wrangler@4.x`) | | 构建耗时 | 约 40 秒,其中大半是初始化环境和下载 wrangler | 如果遇到 Python 版本问题,在 **设置 → 变量和机密** 加一条 `PYTHON_VERSION` = `3.11`。 ### 自定义域 vs 路由 两种把域名接到 Worker 的方式,理解差异能少走弯路: | | 自定义域(Custom Domain) | 路由(Route) | |------|------|------| | DNS 记录 | Cloudflare 自动创建一条 `Worker` 类型记录并管理 | 需要该主机名已能通过 Cloudflare 解析(泛解析也算) | | 配置形式 | 填主机名 `docs.machinaix.com` | 填 URL 模式 `docs.machinaix.com/*` | | 前置条件 | 该主机名**不能**已有你自己建的 A/CNAME 记录 | 无 | | 适用场景 | 该主机名专属于这个 Worker | 想按路径分流,或不方便动 DNS 记录 | | 本站采用 | ✅ 现用方案(2026-08-07 起) | 建站初期用它顶过一段时间 | > [!TIP] 路由模式末尾的 `/*` 不能漏 > 写成 `docs.machinaix.com` 只匹配根路径,`/llms.txt`、`/proxy.html` 这些子路径全会 404。必须写 `docs.machinaix.com/*`。 > [!SUCCESS] 本站的迁移过程 > 建站时自定义域加不上(见踩坑第 4 条),先用路由顶着。后来查明真正的拦路石是一条**手工建的 `docs` A 记录**,删掉它之后自定义域正常添加,路由随即删除。 > > 迁移顺序是关键:**先加自定义域、确认站点正常,再删路由**。反过来的话,中间那段时间 `docs.machinaix.com` 会落回泛解析,显示成博客首页。 ## 九、部署踩坑实录 {#pitfalls} 真实踩过的坑,按遇到顺序排列。每条都记了现象、原因、诊断方法和解决办法。最后一条是**事前发现、还没踩上**的,一并记在这里。 ### 坑 1:git push 报 "Repository not found" **现象** ```text remote: Repository not found. fatal: repository 'https://github.com/xxx/machinaix-docs.git/' not found ``` 仓库明明在网页上看得见,push 却说找不到。 **原因** 仓库建在了 A 账号下,而本机 Git 凭证是 B 账号的。**GitHub 对无权访问的私有仓库统一返回 404 而不是 403** —— 这是防止通过错误码探测私有仓库是否存在的安全设计,但也让报错具有误导性。 **诊断** ```powershell # 看本机存的是哪个 GitHub 账号 cmdkey /list | Select-String "github" -Context 0,2 # 确认目标账号类型(User 还是 Organization) Invoke-RestMethod "https://api.github.com/users/<账号名>" ``` **解决** 三选一:把仓库转移到凭证所属账号 / 把凭证账号加为协作者 / 用带用户名的 remote URL 让 Git 凭证管理器单独登录。本站采用的是第一种(在正确账号下重建)。 > [!TIP] 注意显示名和登录名的区别 > GitHub 的授权页面可能显示**显示名**(Name 字段)而不是**登录名**(login)。看到一个陌生名字先别慌,用 `https://api.github.com/users/<登录名>` 查一下 `name` 字段就能确认是不是同一个账号。 ### 坑 2:首次构建失败,日志里没有构建步骤 **现象** 构建日志: ```text Cloning repository... No build output detected to cache. Skipping. Executing user deploy command: npx wrangler deploy ``` 克隆完直接跳到部署,中间没有任何构建动作,然后部署失败。 **原因** 创建项目时「构建命令」那一栏是空的(它标着「可选」)。于是 `dist/` 从未生成,`wrangler deploy` 按 `wrangler.toml` 去找 `./dist` 却找不到。 **诊断** 在构建详情页展开「构建设置」,看 **构建命令** 是不是显示「无」。 **解决** 项目 → **设置** → **构建** → 构建配置 → 填 `python build.py` → 保存 → 回到「部署」标签点「重试构建」。 ### 坑 3:wrangler deploy 需要配置文件 **现象** `npx wrangler deploy` 报错找不到入口或资源目录。 **原因** Workers 部署需要 `wrangler.toml`(或 `wrangler.json`)声明要部署什么。纯静态站点属于「仅静态资源 Worker」,没有 `main` 入口脚本,必须用 `[assets]` 段指明目录。 **解决** 仓库根目录放 `wrangler.toml`: ```text name = "machinaix-docs" compatibility_date = "2026-08-06" [assets] directory = "./dist" ``` ### 坑 4:添加自定义域报「没有区域匹配」 **现象** Worker → 域 → 添加域名 → 填 `docs.machinaix.com`,弹窗提示: ```text 没有区域匹配 docs.machinaix.com。 如果您拥有此域名并希望将其连接到您的 Worker,请先将该域名添加到 Cloudflare。 ``` 但域名明明就在 Cloudflare 上。 **排查过程** ::: steps #### 确认域名确实托管在 Cloudflare ```bash # 查 NS 记录 dig NS machinaix.com +short ``` 返回 `xxx.ns.cloudflare.com` 就说明在 Cloudflare 上。 --- #### 确认域名和 Worker 在同一个账号 Cloudflare 账号主页会同时列出 **Domains** 和 **Workers** 两栏。如果域名和 Worker 都在里面,就排除了账号不一致的可能。 > Worker 只能绑定**同账号**下的域名,这是最常见的原因,但本例不是。 --- #### 判定为界面异常 域名在、账号对,弹窗却说没有区域匹配 —— 是这个弹窗自身的问题。 ::: **解决** 改用 **添加路由**。路由走的是另一套接口,在它的域名选择列表里 `machinaix.com` 正常出现: 1. Worker → **域** → **+ 添加路由** 2. 选择区域 `machinaix.com` 3. 路由模式填 `docs.machinaix.com/*` > [!DANGER] 千万别点「查找相似项」和「接入域名」 > 「查找相似项」会跳到 **Cloudflare 域名销售页**,向你推销 `docsmachinaix.com` 这类新域名 —— 你根本不需要买。 > 「接入域名」是把一个**新域名迁入** Cloudflare 的流程(要改 NS),你的域名早就在 Cloudflare 里了,点了只会把事情搞乱。 **后续:2026-08-07 查明真正原因** 再试一次时,弹窗不再说「没有区域匹配」,而是给出了真正有用的报错: ```text Hostname 'docs.machinaix.com' already has externally managed DNS records (A, CNAME, etc). Delete them first or try a different hostname. ``` 拦路石是一条**手工建的 `docs` A 记录**(建站早期留下的)。自定义域要求由 Cloudflare 自己创建并管理那条 `Worker` 类型记录,所以不接受同名的既有记录。 删掉那条 A 记录之后,自定义域一次就加上了,路由随即删除。**建站时那个「没有区域匹配」的提示是误导** —— 它把「主机名已被占用」说成了「找不到区域」,白白多花了一小时。 > [!WARNING] 添加域名的输入框会自动补后缀 > 这次还顺手踩了个小坑:在输入框里填完整的 `docs.machinaix.com`,实际建出来的是 `docs.machinaix.com.machinaix.com`。加完一定回列表看一眼名称对不对,建错了就删掉重来。 ### 坑 5:域名绑好了,打开却是另一个站点 **现象** `docs.machinaix.com` 能打开、HTTPS 正常,但显示的是**博客首页**而不是文档站;`/llms.txt` 返回 404。 **原因** 主域名有一条 `*.machinaix.com` **泛解析**记录指向博客服务器。在没有为 `docs` 建专属记录、也没有路由拦截时,`docs.machinaix.com` 命中泛解析 → 打到博客服务器的 Nginx → 返回默认站点。 **诊断技巧** 拿一个**根本不存在的子域**去解析: ```bash dig zzz-does-not-exist-9421.machinaix.com +short ``` 如果它也能解析出 IP,就证明存在泛解析记录。这个方法很好用,能立刻定位这类"看起来像成功但内容是错的"问题。 **解决** 加上 Worker 路由 `docs.machinaix.com/*` 之后自动解决 —— **路由在 Cloudflare 边缘节点就拦截请求,根本不会回源到博客服务器**,优先级天然高于泛解析。 现在改用自定义域后同样安全:Cloudflare 为 `docs` 建了一条专属的 `Worker` 类型记录,专属记录的优先级高于泛解析。 > [!WARNING] 泛解析仍然在,别删 > `*.machinaix.com` 是博客其他子域在用的,删掉会连带搞垮它们。它跟文档站现在已经没有关系 —— `docs` 有了自己的专属记录,不再走泛解析。 ### 坑 6:.txt 文件中文可能乱码 **现象** `llms.txt` / `all.txt` 的响应头是 `Content-Type: text/plain`,**不带 charset**。浏览器可能按本地编码解析,中文变乱码。 HTML 不受影响,因为文件头部有 ``。 **解决** `build.py` 构建时生成 `_headers` 文件(Cloudflare 静态资源支持这个约定): ```text /* Cache-Control: public, max-age=300, must-revalidate /*.txt Content-Type: text/plain; charset=utf-8 ``` 修复后响应头变成 `text/plain; charset=utf-8`。 顺带声明了 5 分钟的浏览器缓存:改完推上去很快能看到新内容,同时挡住反复刷新时的回源。想让改动立刻可见就强制刷新(Ctrl + Shift + R)。 ### 坑 7:不带 User-Agent 的请求被 403 **现象** 用脚本测试站点时返回 `HTTP Error 403: Forbidden`,浏览器里却完全正常。 **原因** Cloudflare 默认的机器人防护会拦掉没有 User-Agent 的请求。 **解决** 测试时带上 UA: ```bash curl -s -A "Mozilla/5.0" https://docs.machinaix.com/llms.txt ``` 绝大多数 AI 抓取器都会带 UA,正常使用不受影响。万一某个 AI 读不到,去 Cloudflare **安全性 → WAF** 给这个主机名加放行规则。 ### 坑 8:PowerShell 取网页内容中文乱码(误报) **现象** 用 `Invoke-WebRequest` 检查线上内容,中文关键词全都"找不到",ASCII 关键词却能找到。 **原因** 响应头没有 charset 时,PowerShell 的 `.Content` 会按 ISO-8859-1 解码,中文全乱。**这是检查工具的问题,不是站点的问题。** **解决** 验证脚本改用 Python 显式按 UTF-8 解码: ```python import urllib.request as u req = u.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) text = u.urlopen(req, timeout=25).read().decode('utf-8') ``` > [!TIP] 这条坑的教训 > 排查问题时先怀疑自己的检查工具。当"部分检查失败、部分成功"且失败的都是非 ASCII 内容时,几乎一定是编码问题而不是业务问题。 ### 坑 9:非生产分支会直接覆盖生产 **现象** 这条是**事前发现的**,还没踩上:建项目时「版本命令」被填成了 `npx wrangler deploy`,而「非生产分支构建」是开启的。 **原因** Cloudflare Workers Builds 有两个部署命令: | 字段 | 触发分支 | 应该是 | |------|------|------| | 部署命令 | 生产分支(`main`) | `npx wrangler deploy` | | 版本命令 | 其他所有分支 | `npx wrangler versions upload` | 两个都填 `deploy` 的话,随便开个分支改点东西一推,线上立刻被这个半成品分支覆盖 —— 而你以为自己只是在做预览。 **解决** 项目 → **设置** → **构建** → 构建配置 → **版本命令** 改成 `npx wrangler versions upload`。改完非生产分支只生成带独立预览地址的版本,生产纹丝不动。 不需要分支预览的话,也可以直接在 **分支控制** 里关掉「非生产分支构建」。 > [!TIP] 怎么验证改对了 > 建个临时分支推一次,看构建日志里执行的是 `versions upload` 还是 `deploy`,同时确认 `docs.machinaix.com` 的内容没变。验完删分支。 ## 十、故障排查 {#trouble} | 现象 | 可能原因 | 处理 | |------|------|------| | 构建失败:`front-matter JSON 解析失败` | md 顶部的 JSON 语法错(多逗号、缺引号) | 按报错里的位置改;JSON 不支持注释和尾逗号 | | 构建失败:泄漏扫描不通过 | 源文件里写了真值,或出现新的 IP/UUID | 改成 `%%占位符%%`,或把确认安全的值加进 `site.json` 的 `allowlist` | | 构建警告:`placeholders.json 中缺少标签` | md 里用了未登记的占位符 | 在 `placeholders.json` 补一条标签 | | 侧栏少了某个章节 | front-matter 的 `nav.items` 里没写这个 id | 补上 id;未列出的章节不会出现在侧栏 | | 章节锚点失效 | 没写 `{#id}`:`##` 的 `s1`/`s2` 会随增删移位,`###` 的 `h-xxxxxxxx` 会随标题改字而变 | 给需要交叉引用的章节写显式 `{#id}` | | 构建警告:`内部锚点死链` | 文档里的 `[见第七节](#s7)` 指向了不存在的 id | 按提示改链接或补 `{#id}`。只是警告,不会挡住构建 | | 首页没有某份文档的卡片 | front-matter 缺 `card` 字段,或 `site.json` 的 `order` 里没登记 | 补 `card` 并加进 `order` | | 线上没更新 | 构建失败,或浏览器缓存 | 看 Cloudflare 构建日志;Ctrl+Shift+R 强刷 | | 线上 404 | 路由模式漏了 `/*` | 改成 `docs.machinaix.com/*` | | 线上显示别的站点 | 泛解析截胡 | 见踩坑第 5 条 | | curl 返回 403 | 没带 User-Agent | 加 `-A "Mozilla/5.0"` | ### 本地快速自检 ```bash # 只跑解析和泄漏扫描,不写文件 python build.py --check # 完整构建后核对产物 python build.py ls dist ``` ### 线上快速自检 ```bash curl -s -o /dev/null -w "%{http_code}\n" -A "Mozilla/5.0" https://docs.machinaix.com/ curl -s -A "Mozilla/5.0" https://docs.machinaix.com/llms.txt | head -8 ``` ## 十一、设计决策 {#decisions} 记录为什么这样设计,避免以后"优化"掉关键约束。 ### 为什么产物是自包含单文件 每个 HTML 内嵌全部 CSS 和 JS,不引用任何外部资源。 **代价**:每份文档重复约 20 KB 的模板代码。 **收益**:可以离线打开、可以单独发给别人、可以塞进邮件附件、十年后没有 CDN 失效问题。脱敏版文档本来就是"要能单独发出去"的东西,这条约束不能破。 > [!WARNING] 不要改成共享 CSS 文件 > 抽出 `docs.css` 看起来更"专业",但会让单个 HTML 失去独立性。真正的去重方案是模板化(已经做了),而不是拆分产物。 ### 为什么源是 Markdown 而不是 HTML 最初的 6 份文档是手写 HTML,每份都内嵌一整套 CSS/JS。改一次配色要改 6 个文件,到 10 份就彻底失控。 改成 Markdown + 模板后:写作成本降低、AI 改 md 的可靠性远高于改上千行 HTML、样式改一次全站生效。 ### 为什么零第三方依赖 `build.py` 只用 Python 标准库,手写了一个 Markdown 子集渲染器。 **代价**:不支持完整 Markdown 语法(够用即可)。 **收益**:Cloudflare 构建环境不用装任何东西、构建快、没有供应链风险、几年后依然能跑。对一个文档站来说,这个权衡明显划算。 ### 为什么用占位符而不是"两份文件" 最早的做法是维护「完整版」和「脱敏版」两份独立文档。问题是改一次密码要改两处,迟早不同步。 占位符方案让真值只存在于 `private/secrets.json` 一处,两个版本由同一份源生成,结构永远一致。 ### 为什么泄漏扫描要让构建失败而不是只警告 警告会被忽略,尤其在 CI 日志里。让构建**硬失败**意味着含真值的产物永远不可能上线 —— 这条防线的价值就在于它不给人"下次再说"的机会。 ### 为什么线上只放脱敏版 因为目标是让 AI 能读,而 AI 抓取需要**公开无鉴权的 URL**。公开 + 含真值 = 泄密,两者不可兼得。 所以选择:线上公开脱敏版供 AI 阅读,真值只在本机 `dist-private/`。这也是为什么「代理」这项 AI 策略必须保持允许 —— 它是整个设计的目的所在。 ============================================================================== 文档:AndrewBlog 开发者文档 来源:src/andrewblog.md ============================================================================== ## 技术栈 {#tech} | 层次 | 技术 | |------|------| | Web 框架 | Flask 3.1 | | 数据库 ORM | Flask-SQLAlchemy 3.1 + SQLite | | 数据库迁移 | Flask-Migrate 4.0(Alembic) | | 用户认证 | Flask-Login 0.6 | | 表单与 CSRF | Flask-WTF 1.2 | | Markdown 渲染 | Python-Markdown 3.7 | | Markdown 编辑器 | EasyMDE(CDN) | | 前端 UI | Pico.css v2(CDN) | | 密码安全 | Werkzeug `generate_password_hash` | | 频率限制 | Flask-Limiter 3.11 | ## 项目结构 {#structure} ```tree AndrewBlog/ ├── app/ │ ├── __init__.py # 应用工厂 create_app() │ ├── config.py # 开发/生产配置 │ ├── extensions.py # db / login_manager / migrate / csrf 实例 │ ├── models.py # User / Post / Comment 数据模型 │ ├── forms.py # WTForms 表单定义 │ ├── utils.py # 文件上传 / 删除工具函数 │ └── routes/ │ ├── auth.py # 注册 / 登录 / 忘记密码 / 退出 │ ├── blog.py # 前台博客(首页 / 文章详情 / 评论) │ └── admin.py # 后台管理(文章列表 / 新建 / 编辑 / 删除) │ ├── app/templates/ │ ├── base.html # 公共布局(导航 / Flash 消息) │ ├── auth/ │ │ ├── login.html │ │ ├── register.html │ │ └── forgot_password.html │ ├── blog/ │ │ ├── index.html # 文章列表(分页) │ │ └── post_detail.html # 文章详情 + 评论 │ └── admin/ │ ├── posts.html # 文章管理列表 │ └── editor.html # 文章编辑器(EasyMDE) │ ├── scripts/ │ ├── init_db.py # 首次部署初始化(建表 + 管理员账号) │ └── db_manage.py # 数据库管理工具(查看 / 删文章 / 删用户) ├── migrations/ # Alembic 数据库迁移文件 ├── instance/ # SQLite 数据库(不提交到 Git) ├── uploads/ # 用户上传的封面图片(不提交到 Git) ├── .env # 环境变量(不提交到 Git) ├── .env.example # 环境变量示例 ├── requirements.txt └── run.py # 入口文件 ``` ## 路由总览 {#routes} ### 前台(博客) | 方法 | URL | 功能 | |------|-----|------| | GET | `/` | 文章列表首页(分页,每页 10 篇) | | GET | `/post/` | 文章详情页(含评论,管理员可预览草稿) | | POST | `/post//comment` | 提交评论(需登录) | | GET | `/uploads/` | 访问上传的封面图片 | ### 认证 | 方法 | URL | 功能 | |------|-----|------| | GET/POST | `/auth/register` | 注册新账号 | | GET/POST | `/auth/login` | 登录(支持用户名或邮箱) | | GET/POST | `/auth/forgot-password` | 忘记密码(用户名 + 邮箱验证后重置) | | GET | `/auth/logout` | 退出登录 | ### 后台管理(需管理员权限) | 方法 | URL | 功能 | |------|-----|------| | GET | `/admin/` | 重定向到文章列表 | | GET | `/admin/posts` | 文章管理列表 | | GET/POST | `/admin/post/new` | 新建文章 | | GET/POST | `/admin/post//edit` | 编辑文章 | | POST | `/admin/post//delete` | 删除文章 | ## 数据模型 {#models} ### User | 字段 | 类型 | 说明 | |------|------|------| | id | Integer | 主键 | | username | String(64) | 唯一,用户名 | | email | String(120) | 唯一,邮箱 | | password_hash | String(256) | 哈希后的密码 | | is_admin | Boolean | 是否管理员 | | created_at | DateTime | 注册时间 | ### Post | 字段 | 类型 | 说明 | |------|------|------| | id | Integer | 主键 | | title | String(200) | 文章标题 | | slug | String(200) | URL 标识,唯一 | | summary | String(500) | 摘要(可选) | | body | Text | 正文(Markdown 原文) | | tags | String(200) | 标签(逗号分隔) | | cover_image | String(200) | 封面图片文件名 | | published | Boolean | 是否发布 | | created_at / updated_at | DateTime | 时间戳 | ### Comment | 字段 | 类型 | 说明 | |------|------|------| | id | Integer | 主键 | | body | Text | 评论内容 | | post_id | Integer | 外键 → Post | | author_id | Integer | 外键 → User | | created_at | DateTime | 评论时间 | ## 本地运行 {#quickstart} ### 1. 克隆仓库 ```bash git clone https://github.com/ANDREW-SVIP/AndrewBlog.git cd AndrewBlog ``` ### 2. 创建虚拟环境并安装依赖 ```bash #如果没有权限 先解锁脚本执行权限(PowerShell 默认限制直接运行激活脚本) Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process #接着执行 python -m venv venv # Windows venv\Scripts\activate # 初始化数据库 python scripts/init_db.py # macOS / Linux source venv/bin/activate pip install -r requirements.txt ``` ### 3. 配置环境变量 ```bash cp .env.example .env ``` 编辑 `.env`,至少修改 `SECRET_KEY`: ```env FLASK_APP=run.py FLASK_ENV=development SECRET_KEY=your-long-random-secret-key-here DATABASE_URL=sqlite:///blog.db UPLOAD_FOLDER=uploads MAX_CONTENT_LENGTH=5242880 ``` ### 4. 初始化数据库 ```bash flask db upgrade ``` ### 5. 创建管理员账号 ```bash python - <<'EOF' from app import create_app from app.extensions import db from app.models import User app = create_app() with app.app_context(): u = User(username='admin', email='admin@example.com', is_admin=True) u.set_password('your-password') db.session.add(u) db.session.commit() print('管理员账号创建成功') EOF ``` ### 6. 启动开发服务器 ```bash flask run ``` 或者直接运行: ```bash python run.py ``` 访问 http://127.0.0.1:5000 ## 主要功能 {#features} - **Markdown 编辑器**:集成 EasyMDE,支持实时预览、分屏模式、自动保存(localStorage) - **封面图片**:上传后即时预览,支持替换和删除,限制 5MB / jpg png gif webp - **草稿系统**:文章可保存为草稿,管理员登录后可预览草稿,普通访客只能看已发布文章 - **标签**:逗号分隔,在文章列表和详情页展示 - **评论**:登录用户可评论,按时间升序排列 - **忘记密码**:通过用户名 + 注册邮箱双重验证后直接设置新密码,无需邮件 - **CSRF 保护**:所有 POST 表单均受 Flask-WTF CSRF 保护 ## 生产部署建议 {#deploy} 1. 将 `FLASK_ENV` 改为 `production` 2. 使用强随机 `SECRET_KEY`(至少 32 位随机字符串) 3. 将 SQLite 替换为 PostgreSQL 或 MySQL(修改 `DATABASE_URL`) 4. 使用 Gunicorn + Nginx 作为 WSGI 服务器 5. 将 `uploads/` 目录托管到对象存储(如 OSS / S3) ```bash pip install gunicorn gunicorn -w 4 -b 0.0.0.0:8000 "app:create_app('production')" ``` ## 生产服务器信息 {#server} | 项目 | 内容 | |------|------| | 服务器 | <博客服务器 IP>(SSH 端口 <博客 SSH 端口>) | | 项目目录 | `/www/wwwroot/andrewblog` | | 进程管理 | systemd(服务名 `andrewblog`) | | WSGI 服务器 | Gunicorn,监听 `127.0.0.1:8000` | | Web 服务器 | Nginx(宝塔面板管理) | | 数据库 | SQLite,路径 `instance/blog.db` | | 日志 | `/var/log/andrewblog/` | ## 修改代码后的发布流程 {#release} ### 本地开发完成后 ```bash # 1. 提交代码 git add . git commit -m "描述本次修改内容" git push ``` ### 服务器拉取更新 ```bash # SSH 登录服务器 ssh -p <博客 SSH 端口> root@<博客服务器 IP> # 进入项目目录 cd /www/wwwroot/andrewblog # 拉取最新代码 git pull # 如果新增了依赖包,重新安装 venv/bin/pip install -r requirements.txt # 只有修改了 models.py(新增/删除字段或表)时才需要执行,日常发布不需要 # FLASK_ENV=production venv/bin/flask db upgrade # 重启服务(优雅重启,不中断用户请求) systemctl reload andrewblog ``` ### 验证部署成功 ```bash # 查看服务状态 systemctl status andrewblog # 查看最新日志 tail -f /var/log/andrewblog/error.log ``` ### 常用运维命令 ```bash # 完全重启服务 systemctl restart andrewblog # 停止服务 systemctl stop andrewblog # 备份数据库 cp /www/wwwroot/andrewblog/instance/blog.db ~/backup/blog_$(date +%Y%m%d).db # 查看实时访问日志 tail -f /var/log/andrewblog/access.log ``` ### 如果改动了静态文件(CSS/JS) Nginx 缓存了静态文件,改动后需要让浏览器重新加载: ```bash # 方法一:在文件名后加版本号(推荐) # 例如 style.css?v=2 # 方法二:重载 Nginx nginx -s reload ``` ## 访问日志解读(爬虫 & 收录验证) {#logs} 日志路径:`/var/log/andrewblog/access.log` ### 查看最近 20 条日志 ```bash tail -20 /var/log/andrewblog/access.log ``` ### 实时监控(持续滚动) ```bash tail -f /var/log/andrewblog/access.log ``` ### 日志格式说明 每一行格式如下: ``` IP地址 - - [时间 +0800] "请求方法 路径 协议" 状态码 响应字节数 "来源页" "User-Agent" ``` 示例: ``` 66.249.68.32 - - [22/May/2026:14:21:21 +0800] "GET /post/markdown-cheatsheet HTTP/1.1" 200 5091 "-" "Mozilla/5.0 (compatible; Googlebot/2.1)" ``` ### 如何识别 Google 爬虫 **特征:** IP 段 `66.249.x.x`,User-Agent 含 `Googlebot` ```bash grep -i "googlebot" /var/log/andrewblog/access.log | tail -20 ``` **看到以下内容说明 Google 正在抓取:** ``` 66.249.68.32 - "GET /post/文章slug" 200 ... "Googlebot" 66.249.68.33 - "GET /sitemap.xml" 200 ... "Googlebot" ``` **收录验证:** 在 Google 搜索 `site:machinaix.com`,有结果说明已收录。 ### 如何识别百度爬虫 **特征:** User-Agent 含 `Baiduspider` ```bash grep -i "baiduspider" /var/log/andrewblog/access.log | tail -20 ``` **看到以下内容说明百度正在抓取:** ``` x.x.x.x - "GET /post/文章slug" 200 ... "Baiduspider" x.x.x.x - "GET /robots.txt" 200 ... "Baiduspider" ``` **收录验证:** 在百度搜索 `site:machinaix.com`,有结果说明已收录。 ### 如何确认百度主动推送成功 发布文章后,查看日志里有没有 `POST /admin/post/new` 或 `POST /admin/post/ID/edit` 的 302 响应: ```bash grep "POST /admin" /var/log/andrewblog/access.log | tail -10 ``` 看到 `302` 状态码说明文章发布成功,百度推送函数已自动执行。 去百度搜索资源平台「**普通收录 → 数据反馈**」可查看历史推送数量(数据有延迟,次日更新)。 ### 常见爬虫 IP / UA 速查 | 爬虫 | IP 特征 | User-Agent 关键词 | |------|---------|-----------------| | Google | `66.249.x.x` | `Googlebot` | | 百度 | `180.76.x.x` / `220.181.x.x` | `Baiduspider` | | UptimeRobot | `216.144.x.x` / `69.162.x.x` | `UptimeRobot` | | Bing | `157.55.x.x` | `bingbot` | ## 数据库管理工具(db_manage.py) {#dbtool} 所有操作在服务器上执行: ### 查看数据库状态(只读,随时可用) ```bash venv/bin/python scripts/db_manage.py status ``` 显示所有用户、文章、评论数量,不修改任何数据。 ### 删除指定文章(按 slug) ```bash # 预览(不会真正删除) venv/bin/python scripts/db_manage.py delete-posts --slugs "slug1,slug2" # 确认无误后加 --execute 执行 venv/bin/python scripts/db_manage.py delete-posts --slugs "slug1,slug2" --execute ``` ### 删除指定用户(按 ID) ```bash # 预览 venv/bin/python scripts/db_manage.py delete-users --ids 2,6 # 执行(管理员账号自动跳过,不会误删) venv/bin/python scripts/db_manage.py delete-users --ids 2,6 --execute ``` > slug 是文章 URL 中的标识符,在 `status` 输出里每篇文章都能看到。 ## 垃圾注册账号处理 {#spam} ### 如何发现 定期运行 `db_manage.py status` 查看用户列表,重点关注: - 用户名含俄语、阿拉伯语、随机数字 - 用户名含 `http://` 或 `https://` 链接 - 邮箱域名可疑 ### 如何清理 ```bash venv/bin/python scripts/db_manage.py status # 确认可疑用户 ID venv/bin/python scripts/db_manage.py delete-users --ids 可疑ID # 预览 venv/bin/python scripts/db_manage.py delete-users --ids 可疑ID --execute # 执行 ``` ### 已有防护措施 | 防护 | 说明 | |------|------| | 频率限制 | 同一 IP 每小时最多注册 5 次,登录每分钟最多 20 次 | | 蜜罐字段 | 注册页隐藏字段,机器人自动填写后静默拦截 | | 用户名格式 | 只允许中文、英文、数字、下划线、连字符 | ## UptimeRobot 监控 {#uptime} UptimeRobot 监控博客可用性,宕机时自动推送告警。 ### 查看监控状态 1. 访问 [uptimerobot.com](https://uptimerobot.com) 并登录 2. 首页查看所有监控项:绿色 = 正常,红色 = 宕机 ### 添加新监控 1. 点击「+ Add New Monitor」 2. Monitor Type 选「HTTP(s)」 3. URL 填 `https://machinaix.com` 4. Monitoring Interval 选「5 minutes」 ### 宕机后处理流程 ```bash ssh -p <博客 SSH 端口> root@<博客服务器 IP> cd /www/wwwroot/andrewblog systemctl status andrewblog # 查看状态 journalctl -u andrewblog -n 100 --no-pager # 查看错误日志 systemctl restart andrewblog # 重启服务 ``` ## Google 收录(Search Console) {#seo} ### 查看收录情况 1. 访问 [search.google.com/search-console](https://search.google.com/search-console) 2. 选择 machinaix.com 资源 → 左侧「覆盖率」查看已收录页面 ### 提交 Sitemap(只需做一次) 1. Search Console → 左侧「站点地图」 2. 输入 `sitemap.xml` 点击提交 ### 新文章发布后请求收录 1. Search Console 顶部搜索框输入文章完整 URL 2. 点击「请求编入索引」,1-3 天内生效 ### 验证文章是否被收录 在 Google 搜索: ``` site:machinaix.com/post/文章slug ``` 有结果 = 已收录,无结果 = 尚未收录(等待或手动请求)。 ## License {#license} MIT ============================================================================== 文档:云传 · 开发者文档 来源:src/yunchuan.md ============================================================================== ## 功能特性 {#features} | 功能 | 说明 | |------|------| | 多格式文件上传 | 支持 APK、图片(JPG/PNG/GIF/WebP)、视频(MP4/MOV/AVI/MKV)、压缩包(ZIP/RAR/7z)、文档(PDF/Word/Excel/PPT/TXT),最大 **5 GB** | | 文件类型安全检测 | 扩展名白名单 + 魔数(magic byte)双重校验,防止文件类型伪造 | | 文件分类展示 | 仪表盘与管理后台按类型分 Tab(全部 / APK / 图片 / 视频 / 压缩包 / 文档),APK 按包名分组,其他文件平铺列表 | | 多用户注册登录 | 账号各自管理自己上传的文件,注册入口默认关闭(内测阶段) | | 邮箱验证 | 注册后生成 24 小时验证链接展示在页面,点击完成验证后才能登录;无需 SMTP 服务器,适合自托管 | | 忘记密码 | 生成 15 分钟有效的重置链接,无需邮件服务器 | | 拖拽上传 | 支持拖拽 / 点击选择文件,显示上传进度条,最大 **5 GB** | | APK 自动解析 | 自动提取应用名、包名、版本名、版本号、启动图标(失败时降级处理)| | 短链接 | 生成 `/d/{12位随机码}` 格式的公开下载链接 | | 二维码 | 上传成功页和下载页均展示二维码,支持 PNG 下载 | | 版本历史 | 同一包名的所有 APK 版本分组管理,下载页显示版本列表(默认 5 条,可展开)| | 更新说明 | 每个版本可填写描述,下载页展示 | | 密码保护 | 为每个文件单独设置下载密码(可选),验证通过后 Cookie 记录 10 分钟 | | 防暴力破解 | 登录/管理员登录/下载密码均有 IP 限速,超限返回 429 | | 管理后台 | 管理员可查看所有用户的文件,支持编辑、删除、查看下载次数 | | 流式上传/下载 | 非 APK 大文件直接将 multipart.File 流式写入 R2,不在服务器落盘;下载走 R2 预签名 URL 302 跳转,绕过服务器直达 CDN | | Cloudflare R2 存储 | 文件存储于 R2 对象存储,下载走 Cloudflare CDN;未配置 R2 时自动降级到本地磁盘 | | 响应式 UI | 登录/注册页左右分屏布局,列表/详情页自适应桌面和移动端 | | 品牌动画 | 登录/注册/忘记密码页左侧纯 CSS 动画(blob + 浮动文件卡片 + 云上传图标),不依赖 JS | | 自定义 Favicon | 云朵+上传箭头 SVG 图标,与品牌配色一致 | ## 技术栈 {#tech} ``` 后端: Go 1.26 + Gin v1.12 + GORM v1.25 数据库:SQLite(glebarez/sqlite,纯 Go,无需 CGO) APK 解析:shogo82148/androidbinary 二维码:skip2/go-qrcode 密码哈希:golang.org/x/crypto/bcrypt 对象存储:aws/aws-sdk-go-v2(兼容 S3 API,接入 Cloudflare R2) 前端: HTML + Tailwind CSS(CDN)+ HTMX 1.9(局部更新) 部署: Docker + Nginx ``` ## 数据库模型 {#models} ### User(用户) | 字段 | 类型 | 说明 | |------|------|------| | ID | uint | 主键,自增 | | Username | string | 用户名,唯一,最长 32 字符 | | Email | string | 邮箱,注册时必填 | | EmailVerified | bool | 邮箱是否已验证,未验证不可登录 | | PasswordHash | string | bcrypt 哈希,不存明文 | | CreatedAt / UpdatedAt | time.Time | 自动维护 | ### App(文件记录) | 字段 | 类型 | 说明 | |------|------|------| | ID | uint | 主键,自增 | | FileType | string | 文件类型:`apk` / `image` / `video` / `zip` / `document`(默认 `apk`)| | Name | string | 文件/应用名称 | | PackageName | string | 包名(APK 专用,如 com.example.app;其他格式为空)| | VersionName | string | 版本名(APK 专用)| | VersionCode | int32 | 版本号(APK 专用)| | Description | string | 更新说明 / 文件描述 | | ShortCode | string | 下载短码(唯一,12 位 hex)| | FileName | string | 原始文件名 | | FilePath | string | 磁盘存储路径(R2 模式下为对象 Key)| | FileSize | int64 | 文件大小(字节)| | HasIcon | bool | 是否已提取到图标(APK 专用)| | UserID | uint | 所属用户 ID(0 = 历史遗留/管理员创建)| | Password | string | 下载密码 bcrypt 哈希,空 = 无密码 | | DownloadCount | int | 累计下载次数 | | CreatedAt / UpdatedAt | time.Time | 自动维护 | ## 本地开发启动 {#localdev} ### 前提 - Go 已安装(本项目安装在 `D:\InstallSoft\Go`) - 已克隆/下载本项目到本地 ### 方式一:直接运行编译好的 exe(推荐) ```powershell cd D:\ANDREW\Work\Personal\GoWork\apk-dist # 可选:设置环境变量(以下为默认值) $env:PORT = "8080" $env:BASE_URL = "http://localhost:8080" $env:ADMIN_PASSWORD = "admin123" .\apk-dist.exe ``` ### 方式二:源码运行 ```powershell cd D:\ANDREW\Work\Personal\GoWork\apk-dist $env:GOROOT = "D:\InstallSoft\Go" $env:PATH = "D:\InstallSoft\Go\bin;" + $env:PATH go run . ``` ### 方式三:重新编译后运行 ```powershell cd D:\ANDREW\Work\Personal\GoWork\apk-dist $env:GOROOT = "D:\InstallSoft\Go" $env:PATH = "D:\InstallSoft\Go\bin;" + $env:PATH go build -o apk-dist.exe . .\apk-dist.exe ``` ### 访问地址 | 页面 | 地址 | |------|------| | 登录 | http://localhost:8080/login | | 上传页(需登录) | http://localhost:8080/ | | 我的文件 | http://localhost:8080/my | | 管理后台 | http://localhost:8080/admin | > 管理员密码默认 `admin123`,通过 `ADMIN_PASSWORD` 环境变量修改。 > 启动日志只打印后台 URL,**不会**打印密码。 ## 完整配置项 {#config} 所有配置通过**环境变量**传入: | 变量名 | 默认值 | 说明 | |--------|--------|------| | `PORT` | `8080` | 监听端口 | | `APP_ENV` | `development` | 环境,`production` 时关闭 Gin 调试日志 | | `BASE_URL` | `http://localhost:8080` | 对外访问域名(用于生成二维码/重置链接)| | `ADMIN_PASSWORD` | `admin123` | 管理后台登录密码,**生产必须修改** | | `SESSION_SECRET` | `please-change-this...` | Cookie 签名密钥,**生产必须修改** | | `UPLOAD_DIR` | `./uploads` | 本地存储路径(R2 未配置时使用)| | `DATA_DIR` | `./data` | SQLite 数据库存储路径 | | `MAX_UPLOAD_MB` | `5120` | 单个文件最大上传大小(MB),默认 5 GB | | `R2_ACCOUNT_ID` | `` | Cloudflare Account ID | | `R2_ACCESS_KEY_ID` | `` | R2 API 令牌 Access Key ID | | `R2_SECRET_ACCESS_KEY` | `` | R2 API 令牌 Secret Access Key | | `R2_BUCKET` | `` | R2 Bucket 名称 | > R2 四个变量全部填写时自动启用 R2 存储;任一为空则降级到本地磁盘。 ## 云端部署(Cloudflare + aaPanel + Docker) {#deploy} 当前生产环境:**send.machinaix.com**,架构为 Cloudflare CDN → aaPanel Nginx(宝塔)→ Docker 容器(127.0.0.1:8080)。 文件存储在 **Cloudflare R2**(Bucket:`yunchuan`),SQLite 数据库存储在服务器本地磁盘。 ```diagram 用户 → Cloudflare(HTTPS/CDN) → 服务器 aaPanel Nginx(:80/:443) → Docker app(:8080) 上传:app → Cloudflare R2(流式写入,服务器不落盘) 下载:app 生成预签名 URL → 302 跳转 → 用户直连 R2 CDN ``` ### 首次部署 #### 1. 服务器准备 ```bash # 安装 Docker curl -fsSL https://get.docker.com | sh apt install docker-compose-plugin -y # 在 aaPanel 防火墙开放 80、443 端口(不暴露 8080) ``` #### 2. 克隆项目到服务器 ```bash cd /www/wwwroot # aaPanel 默认网站目录 git clone https://github.com/你的用户名/apk-dist.git cd apk-dist ``` #### 3. 修改 docker-compose.yml 中的配置 ```bash vim docker-compose.yml ``` ```yaml environment: BASE_URL: "https://send.machinaix.com" ADMIN_PASSWORD: "your-strong-password" # ← 必须修改 SESSION_SECRET: "your-random-32-char-secret" # ← 必须修改 MAX_UPLOAD_MB: "5120" R2_ACCOUNT_ID: "your-account-id" # ← Cloudflare Dashboard 右侧 Account ID R2_ACCESS_KEY_ID: "your-access-key-id" # ← R2 API 令牌的 Access Key ID R2_SECRET_ACCESS_KEY: "your-secret-access-key" # ← R2 API 令牌的 Secret Access Key R2_BUCKET: "yunchuan" # ← 你创建的 Bucket 名称 ``` > **获取 R2 凭证**:Cloudflare Dashboard → R2 → 创建 Bucket(如 `yunchuan`,建议选 APAC 区域)→ > 右上角「管理 R2 API 令牌」→ 创建令牌(权限选「对象读和写」,范围选「所有 Bucket」)→ > 复制 Access Key ID 和 Secret Access Key(**只显示一次,立即保存**)。 #### 4. 首次启动容器 ```bash docker compose up -d --build ``` 容器启动后监听 `127.0.0.1:8080`,不对外暴露。 #### 5. 配置 aaPanel 反向代理 在 aaPanel → 网站 → send.machinaix.com → 反向代理,添加: | 字段 | 值 | |------|----| | 代理名称 | apk-dist | | 目标 URL | http://127.0.0.1:8080 | | 发送域名 | $host | #### 6. 配置 Cloudflare SSL(Full Strict 模式) 1. 在 Cloudflare → SSL/TLS → Origin Server 生成 Origin Certificate(覆盖 `*.machinaix.com`,15 年有效期) 2. 将证书和私钥粘贴到 aaPanel → 网站 → send.machinaix.com → SSL → 其他证书 3. 在 aaPanel 防火墙开放 443 端口 4. 在 Cloudflare SSL/TLS 加密模式选择 **完整(严格)**(Full Strict) #### 7. 在 Cloudflare 添加子域名 DNS 记录 Cloudflare → machinaix.com → DNS → 添加记录: | 类型 | 名称 | IPv4 | 代理状态 | |------|------|------|----------| | A | send | 服务器 IP | 橙云(已代理)| ### 更新代码(日常部署流程) 本地修改代码 → 推送到 GitHub → 服务器拉取 → 重新构建容器: **步骤一:本地提交并推送** ```bash git add . git commit -m "描述本次修改" git push ``` **步骤二:SSH 登录服务器,拉取并重建** ```bash cd /www/wwwroot/apk-dist # docker-compose.yml 含有本地密码/密钥,需先暂存再拉取再还原 git stash git pull git stash pop # 重新构建并启动(--build 会重新编译 Go 代码) docker compose up -d --build ``` > **注意**:`git stash pop` 有时会产生冲突(docker-compose.yml 两侧都有修改), > 若出现 `CONFLICT` 提示,执行 `git checkout HEAD -- docker-compose.yml` > 恢复干净版本,再用 `vim` 重新填入密码和 R2 凭证,参考首次部署第 3 步。 **步骤三:确认服务正常** ```bash docker compose ps # 状态应为 Up docker compose logs -f app # 查看启动日志,Ctrl+C 退出 ``` ### 运维命令速查 | 场景 | 命令 | |------|------| | 改了 Go 代码 / 模板 / nginx.conf | `docker compose up -d --build` | | 只改了 docker-compose.yml 环境变量 | `docker compose up -d` | | 只是重启服务(不重新编译)| `docker compose restart app` | | 实时查看日志 | `docker compose logs -f app` | | 查看容器状态 | `docker compose ps` | | 进入容器调试 | `docker compose exec app sh` | ```bash # 备份数据库(SQLite) docker compose exec app tar czf /tmp/backup.tar.gz /data docker cp $(docker compose ps -q app):/tmp/backup.tar.gz ./backup.tar.gz ``` ## 项目结构 {#structure} ```tree apk-dist/ ├── main.go # 入口:路由注册、模板加载、服务启动 ├── go.mod / go.sum │ ├── internal/ │ ├── config/config.go # 配置加载(环境变量),含 R2 配置 │ ├── db/db.go # GORM + SQLite 初始化、自动迁移(User + App) │ │ │ ├── models/ │ │ ├── user.go # User 模型 │ │ └── app.go # App 模型(含 FileType、UserID 等字段) │ │ │ ├── handlers/ │ │ ├── helpers.go # AppGroup 结构体 + buildAppGroups() + buildTypeCounts() │ │ ├── auth.go # 注册/登录/退出/忘记密码/重置密码(含登录限速) │ │ ├── user.go # 用户文件管理(Dashboard 按类型过滤、版本列表、编辑、删除) │ │ ├── upload.go # POST /upload — 多格式上传、APK 解析、流式写入 R2 │ │ ├── download.go # GET /d/:code — 下载页、密码验证(含限速)、R2 预签名跳转 │ │ └── admin.go # /admin/* — 管理后台(含登录限速) │ │ │ ├── middleware/ │ │ ├── auth.go # 管理员会话(内存 session + SameSite Cookie) │ │ ├── user_auth.go # 用户会话(随机 token + SameSite Cookie)+ 验证/重置 token │ │ └── ratelimit.go # 内存固定窗口限速器 │ │ │ ├── storage/ │ │ └── r2.go # Cloudflare R2 客户端(UploadFile io.Reader 接口、GetPresignedURL、DeleteFile) │ │ │ └── utils/ │ ├── apk.go # APK 元数据解析 + 图标提取(多策略降级) │ ├── filetype.go # 文件类型检测(扩展名白名单 + 魔数校验)+ FileTypeInfo │ ├── qr.go # 二维码 PNG 生成(Base64 / 文件) │ └── short.go # 短码生成(crypto/rand,12 位 hex) │ ├── templates/ │ ├── layout.html # 基础 HTML 框架(Tailwind CDN + HTMX + favicon) │ │ │ ├── register.html # 注册页(分屏布局) │ ├── user_login.html # 用户登录页(分屏布局,含忘记密码入口) │ ├── forgot_password.html # 忘记密码页(输入用户名 → 生成重置链接) │ ├── reset_password.html # 重置密码页(输入新密码 + 确认) │ │ │ ├── index.html # 上传页(登录后可见,含用户 NavBar) │ ├── dashboard.html # 我的文件列表(APK 分组 + 其他格式平铺,类型 Tab 过滤) │ ├── user_app_versions.html # 我的 APK 版本列表 │ ├── user_app_edit.html # 编辑我的文件(名称/描述/密码) │ │ │ ├── download.html # 公开下载页(文件信息 + 二维码 + 下载按钮) │ │ │ ├── admin_login.html # 管理员登录页(深色主题分屏) │ ├── admin_apps.html # 管理后台文件列表(全用户,类型 Tab 过滤) │ ├── admin_app_versions.html # 管理后台 APK 版本列表 │ ├── admin_app_edit.html # 管理后台编辑文件 │ │ │ └── partials/ │ ├── auth_brand_panel.html # 登录/注册/忘记密码页左侧品牌动画面板(纯 CSS) │ ├── upload_result.html # HTMX 局部:上传成功结果 │ ├── password_form.html # HTMX 局部:下载密码表单 │ └── download_button.html # HTMX 局部:下载按钮(密码验证后替换) │ ├── static/ │ └── favicon.svg # 品牌图标:云朵+上传箭头,indigo 底色 │ ├── uploads/ # 运行时生成(R2 未配置时):images/ videos/ zips/ docs/ apks/ icons/ ├── data/ # 运行时生成:apk.db(SQLite) │ ├── Dockerfile # 多阶段构建(alpine) ├── docker-compose.yml # 一键编排:app 容器 └── nginx/nginx.conf # Cloudflare 真实 IP 还原 + 限速 + 大文件超时(5G / 3600s) ``` ## API 路由一览 {#api} ### 公开路由(无需登录) ``` GET /register 注册页 POST /register 提交注册(自动登录后跳转首页) GET /login 用户登录页 POST /login 提交登录(IP 限速:10次/5min) GET /logout 退出登录 GET /forgot-password 忘记密码页(输入用户名) POST /forgot-password 生成重置链接(显示在页面上,有效期 15 分钟) GET /reset-password?token=xxx 重置密码页(验证 token) POST /reset-password 提交新密码(token 一次性,消费后失效) GET /verify-email?token=xxx 消费验证 token,标记邮箱已验证并自动登录 GET /resend-verify 重新发送验证链接页(输入用户名) POST /resend-verify 生成新验证链接(显示在页面上,有效期 24 小时) GET /d/:code 下载页(文件信息 + 版本历史(APK)+ 二维码) POST /d/:code/unlock 密码验证(HTMX 局部响应,IP+短码限速:15次/10min) GET /d/:code/dl 文件下载(R2: 302 预签名 URL;本地: 流式传输) GET /icons/:file 应用图标(R2 / 本地磁盘) GET /static/* 静态资源(favicon 等) ``` ### 用户路由(需登录,Cookie: apk_session) ``` GET / 上传页(含用户 NavBar) POST /upload 上传文件(multipart,HTMX 响应,绑定 UserID) GET /my 我的文件列表(?type=apk/image/video/zip/document 过滤) GET /my/versions?pkg=com.xxx 某 APK 应用的版本历史列表 GET /my/apps/:id/edit 编辑页(仅限自己的文件) POST /my/apps/:id/edit 保存编辑 DELETE /my/apps/:id 删除(HTMX 行内移除,仅限自己的文件) ``` ### 管理员路由(需登录,Cookie: apk_admin) ``` GET /admin/login 管理员登录页 POST /admin/login 提交登录(IP 限速:10次/5min) GET /admin/logout 退出 GET /admin 所有用户文件列表(?type= 过滤) GET /admin/versions?pkg=com.xxx 某 APK 应用所有版本(跨用户) GET /admin/apps/:id/edit 编辑任意文件 POST /admin/apps/:id/edit 保存编辑 DELETE /admin/apps/:id 删除任意文件(HTMX 行内移除) GET /admin/apps/:id/qr.png 下载二维码 PNG ``` ## 会话与安全机制 {#security} ### 用户会话(user_auth.go) - **登录态**:生成 64 位随机 hex token,存入服务器内存(`sync.RWMutex` 保护的 map) - **Cookie**:`apk_session`,HttpOnly + SameSite=Lax,7 天有效 - **中间件**: - `LoadUserContext(db)`:全局运行,从 Cookie 读取 session,有效则将 `*models.User` 写入 `gin.Context`,无效则静默跳过 - `RequireUser`:保护需登录的路由,无有效 session 则重定向 `/login` ### 邮箱验证(user_auth.go) - 注册时生成 48 位随机 hex token,TTL 24 小时,存于内存 map - `ConsumeVerifyToken`:验证 + 删除(一次性),防重放;成功后更新 `EmailVerified = true` 并自动登录 - 验证链接直接显示在注册成功页(无邮件依赖),适合自托管场景 ### 密码重置(user_auth.go) - 生成 48 位随机 hex token,TTL 15 分钟,存于内存 map - `ConsumeResetToken`:验证 + 删除(一次性),防重放 - `PeekResetToken`:仅校验有效性(展示 reset 表单时使用,不消费) ### 管理员会话(auth.go) - 独立内存 session map,与用户会话互不干扰 - Cookie:`apk_admin`,HttpOnly + SameSite=Lax,24 小时有效 - 登录凭证:`subtle.ConstantTimeCompare` 对比 `ADMIN_PASSWORD` 环境变量(防时序攻击) ### 文件类型安全(utils/filetype.go) - **扩展名白名单**:仅接受预定义扩展名,其他一律拒绝 - **魔数校验**:读取文件头字节验证实际格式,防止将 `.exe` 改名为 `.jpg` 上传 - APK:`PK\x03\x04`(ZIP 结构) - 图片:JPEG / PNG / GIF / WebP(RIFF) - 压缩包:ZIP / RAR / 7z / GZip - 文档:PDF / OLE2(Word/Excel 旧格式)/ OOXML(ZIP 结构) - 视频格式头部多样(MP4/MOV/AVI/MKV),仅做扩展名白名单校验 ### 下载密码与防盗链(download.go) - 每次访问下载页强制清除旧 cookie,禁用缓存,确保每次都需要重新输入密码 - 验证通过后签发 `dl_{shortCode}` Cookie: - HMAC-SHA256 签名(`shortCode:timestamp` + `SESSION_SECRET`) - Cookie 有效期 **10 分钟**,服务端验证也限制在 **660 秒**内(防止 token 被截获后长期有效) - R2 模式下每次生成 **15 分钟**预签名 URL,不暴露固定下载地址 - 短码为 12 位随机 hex(16¹² ≈ 281 万亿种),枚举不现实 ### 防暴力破解(middleware/ratelimit.go) 内存固定窗口限速器,无需外部依赖: | 端点 | 限制 | Key | |------|------|-----| | `POST /login` | 10 次 / 5 分钟 | 客户端 IP | | `POST /admin/login` | 10 次 / 5 分钟 | 客户端 IP | | `POST /d/:code/unlock` | 15 次 / 10 分钟 | 客户端 IP + 短码 | 超限返回 HTTP 429,前端显示友好提示。 ### Cookie 安全属性 所有 session / unlock cookie 均设置: - `HttpOnly`:JavaScript 无法读取,防 XSS 窃取 - `SameSite=Lax`:阻断跨站 POST 请求,有效防御 CSRF - `Secure`:BASE_URL 以 `https://` 开头时自动启用,生产环境必须开启 ## 关键业务逻辑 {#logic} ### 文件分类与存储路径(upload.go + utils/filetype.go) 上传时: 1. `DetectFileType(filename)` 检查扩展名白名单,返回 `FileTypeInfo`(类别、存储目录、MIME 类型) 2. APK:写入临时文件 → 解析元数据 + 提取图标 → 上传 R2 `apks/{shortCode}.apk` 3. 非 APK:直接将 `multipart.File`(`io.Reader`)流式写入 R2 `{type}/{shortCode}.{ext}`,服务器不落盘 4. 未配置 R2 时按原目录结构保存到本地磁盘 ### 应用分组(helpers.go) `buildAppGroups(apps []models.App) []AppGroup` — 将 APK 列表按 `PackageName` 聚合: - 第一条即为「最新版本」(代表该组的图标、版本号、上传时间) - 统计 `TotalVersions`、`TotalDownloads`、`HasPassword`(任意版本有密码则为 true) - `admin.go` 和 `user.go` 均复用此函数 `buildTypeCounts(apps []models.App) map[string]int64` — 统计各类型数量,用于 Tab 徽章。 ### 仪表盘类型过滤(user.go / admin.go) `?type=apk/image/video/zip/document` 查询参数过滤 App 列表: - `type=apk`:仅查 APK,传入 `buildAppGroups` 展示分组表格 - 其他类型:平铺列表,展示文件名、类型图标、大小、下载数 - `type=`(空):APK 分组表 + 其他文件列表同时展示 ### 版本历史(download.go) `DownloadPage` 额外查询同 `PackageName` 的所有版本传给模板: - 模板默认渲染前 5 条(`{{if lt $i 5}}`) - 超过 5 条显示「查看更多」按钮,点击用 JS 移除 `hidden` class ### 上传与 UserID 绑定(upload.go) `Upload` 从 `gin.Context` 读取 `currentUser`(由 `LoadUserContext` 写入),将 `user.ID` 赋给 `app.UserID`。历史数据 `UserID = 0`,管理员后台可统一查看。 ### 所有权校验(user.go) `findOwnedApp` 查询时加 `WHERE id = ? AND user_id = ?`,防止用户操作他人文件。管理员路由不加此条件。 ## 左侧品牌动画说明(auth_brand_panel.html) {#animation} 所有认证页(登录/注册/忘记密码/重置密码)共享同一个局部模板: ```go r.AddFromFilesFuncs("user_login", fm, layout, authPanel, "templates/user_login.html") ``` 动画元素: | 元素 | 动画 | 关键帧 | |------|------|--------| | 3 个背景 blob | 缓慢 scale + translate 脉动 | `blobPulse` 8~12 s | | 2 个同心环 | 正反向缓速旋转 | `spinSlow` 16/22 s | | 云上传 SVG 图标 | 上下浮动 + 发光 | `cloudBob` 4 s | | 4 张文件卡片(图片/视频/压缩包/文档)| 漂浮(错开延迟)| `cardFloat` 6 s | | 格式标签行(图片/视频/APK/压缩包/文档/最大5GB)| 静态展示 | — | | 功能列表条目 | 入场淡入左移(一次性)| `fadeInLeft` 0.5 s | **性能**:所有动画仅使用 `transform` 和 `opacity`,全部由 GPU 合成层处理,不触发重排(reflow)或重绘(repaint)。 ## 后续扩展方向 {#roadmap} ### 开放注册 注册入口已实现,仅注释了模板中的「立即注册」链接。开放时取消 `templates/user_login.html` 中的注释即可。 ### 支持 IPA(iOS) - IPA 是 ZIP 格式,读取 `Payload/*.app/Info.plist` 解析元数据 - 安装需要生成 `manifest.plist` + `itms-services://` 协议链接 ### 邮件发送(注册验证 + 找回密码) - 当前验证链接和重置链接均直接显示在页面,适合内网/自托管,无 SMTP 依赖 - 如需真正发送邮件:引入 `gopkg.in/gomail.v2`,在 `Register` / `ResendVerify` / `ForgotPassword` handler 中发送邮件而非渲染链接 - 相关配置:`SMTP_HOST`、`SMTP_PORT`、`SMTP_USER`、`SMTP_PASS`、`SMTP_FROM` ### 下载统计 / 访问日志 - 新增 `DownloadLog` 表(AppID、IP、UA、时间) - `ServeFile` 中异步写入 - 管理后台增加图表(Chart.js + `/admin/stats` API) ### 切换 PostgreSQL - `db.go`:替换 `glebarez/sqlite` 为 `gorm.io/driver/postgres` - 通过 `DATABASE_URL` 环境变量传入连接串 ============================================================================== 文档:代理服务器部署文档 来源:src/proxy.md ============================================================================== ## 0、脱敏说明与占位符对照 {#s0} > [!LOCK] 这是脱敏版,不能直接用来连接节点 > 所有可用于访问节点的信息(密码、UUID、传输路径、API Token、真实域名、完整 IP)均已替换为占位符。文档的架构说明、操作步骤、命令与排查思路完整保留,可用于交流、存档或交给 AI 阅读。 > 需要真实值时,在本机执行 `python build.py --private`,生成的 `dist-private/` 内即为注入真值的完整版。 ### 占位符对照表 | 文档中的占位符 | 对应真实项 | 出现位置 | |------|------|------| | <主域名> | 真实主域名 | 全文 | | <代理服务器 IP> | 服务器 IPv4 | 一、二、十一、九 | | <代理服务器 IPv6> | 服务器 IPv6 | 一、二 | | | Hysteria 2 认证密码 | 3.1、十、十二、十三 | | | salamander 混淆密码 | 3.1、十、十二、十三 | | | Xray 客户端 UUID | 3.2、3.3、十二 | | | WebSocket path | 3.2、十二、十三 | | | gRPC 服务名 | 3.3、十二、十三 | | | acme.sh 用 DNS Edit Token | 六、七 | | <维护人邮箱> | 维护人邮箱 | 文档头 | ## 一、服务器基本信息 {#s1} | 项目 | 值 | |------|------| | IPv4 | <代理服务器 IP> | | IPv6 | <代理服务器 IPv6> | | 操作系统 | Rocky Linux 9.5 x86_64 | | 面板 | 宝塔面板(BT Panel) | | 主域名 | <主域名>(托管于 Cloudflare) | ## 二、DNS 记录配置(Cloudflare) {#s2} | 子域名 | 类型 | 值 | 代理状态 | 用途 | |------|------|------|------|------| | `hy2.<主域名>` | A | <代理服务器 IP> | 灰色(仅 DNS) | Hysteria 2 IPv4 | | `hy2.<主域名>` | AAAA | <代理服务器 IPv6> | 灰色(仅 DNS) | Hysteria 2 IPv6 | | `ws.<主域名>` | A | <代理服务器 IP> | 橙色(已代理) | VLESS + WS / gRPC CDN | ### Cloudflare 设置 ::: cards #### SSL/TLS 模式 *SSL mode* 完全(严格) --- #### gRPC *gRPC* 已开启 --- #### WebSockets *WebSockets* 已开启 --- #### IPv6 兼容性 *IPv6 compatibility* 已开启 ::: ## 三、节点连接信息 {#s3} 三个节点共用一台服务器:Hysteria 2 走 UDP 直连,两个 VLESS 节点走 Cloudflare CDN。 ### 3.1 Hysteria 2 主力节点 {#s31} > [!WARNING] 手机流量下无法使用 > 此节点因服务器 IP 被运营商列入 UDP 黑名单,**手机流量下无法使用**。WiFi 宽带环境下可正常使用。商业节点可通是因其使用了未被标记的 IP 或中转,非协议问题。 | 项目 | 值 | |------|------| | 协议 | Hysteria 2 | | 服务器 | `hy2.<主域名>` | | 端口 | `8443`(UDP) | | 密码 | | | 混淆类型 | `salamander` | | 混淆密码 | | | SNI | `hy2.<主域名>` | | TLS 证书 | Let's Encrypt(acme.sh 自动续签) | **连接链接格式** ```text wrap hysteria2://@hy2.<主域名>:8443?sni=hy2.<主域名>&obfs=salamander&obfs-password=#HY2-主力 ``` ### 3.2 VLESS + WebSocket + CDN 备用节点 1 {#s32} > [!SUCCESS] 手机流量可用 > 走 Cloudflare CDN,源站 IP 不直接暴露。 | 项目 | 值 | |------|------| | 协议 | VLESS | | 服务器 | `ws.<主域名>` | | 端口 | `443`(TCP) | | UUID | | | 传输方式 | WebSocket | | 路径 | | | TLS | 开启 | | SNI | `ws.<主域名>` | **连接链接格式** ```text wrap vless://@ws.<主域名>:443?type=ws&path=&security=tls&sni=ws.<主域名>#VLESS-WS-CDN ``` ### 3.3 VLESS + gRPC + CDN 备用节点 2 · 推荐日常 {#s33} > [!SUCCESS] 手机流量可用,延迟比 WS 略低 > 推荐日常优先使用。 | 项目 | 值 | |------|------| | 协议 | VLESS | | 服务器 | `ws.<主域名>` | | 端口 | `443`(TCP) | | UUID | | | 传输方式 | gRPC | | serviceName | | | TLS | 开启 | | SNI | `ws.<主域名>` | **连接链接格式** ```text wrap vless://@ws.<主域名>:443?type=grpc&serviceName=&security=tls&sni=ws.<主域名>#VLESS-gRPC-CDN ``` ## 四、节点对比与使用策略 {#s4} | 节点 | 速度 | 稳定性 | 手机流量 | WiFi | 抗封锁 | |------|------|------|------|------|------| | Hysteria 2 | ★★★★★ | ★★★ | ✗ | ✓ | ★★★ | | VLESS + gRPC + CDN | ★★★★ | ★★★★★ | ✓ | ✓ | ★★★★★ | | VLESS + WS + CDN | ★★★ | ★★★★★ | ✓ | ✓ | ★★★★★ | ### 推荐策略 ::: cards #### 手机流量 · 日常 *cellular · primary* 优先 **VLESS + gRPC + CDN** --- #### 手机流量 · 备用 *cellular · fallback* 切换 **VLESS + WS + CDN** --- #### WiFi 环境 *wifi* 优先 **Hysteria 2**(速度最快) ::: ## 八、推荐客户端 {#s8} | 平台 | 客户端 | 支持协议 | |------|------|------| | Windows | v2rayN | VLESS + WS / gRPC | | Android | v2rayNG / Hiddify / sing-box | VLESS + WS / gRPC + Hysteria 2 | | iOS | Hiddify / Shadowrocket | 全部支持 | | macOS | Hiddify / V2RayXS | 全部支持 | > [!INFO] > Hysteria 2 需要客户端版本较新(v2rayNG 1.8.x+,Hiddify 最新版)。 ## 十、端口跳跃配置(Hysteria 2) {#s10} ### 服务器端 nftables 规则 将 20000-40000 UDP 全部重定向到 Hysteria 2 主端口 8443: - 规则文件:`/etc/nftables-hy2-porthopping.conf` - 开机服务:`hy2-porthopping.service`(已 enable) ```bash # 查看规则是否生效 nft list ruleset | grep "20000" # 重新加载规则 systemctl restart hy2-porthopping ``` ### 客户端配置(手动填写) | 字段 | 值 | |------|------| | 服务器地址 | `hy2.<主域名>` | | 服务器端口 | `8443` | | 跳跃端口 | `20000-40000` | | 密码 | | | 混淆密码 | | | SNI | `hy2.<主域名>` | > [!WARNING] > 端口跳跃链接格式(`8443,20000-40000`)部分客户端不支持,建议手动填写字段。 ## 十二、sing-box 配置文件 {#s12} 适用于 sing-box 客户端(Android / iOS / macOS / Windows),保存为 `config.json` 导入。**脱敏版需自行填入真实密码 / UUID / 路径 / 域名后方可使用。** ```json { "log": { "level": "info" }, "outbounds": [ { "type": "hysteria2", "tag": "HY2-主力(WiFi)", "server": "hy2.<主域名>", "server_port": 8443, "password": "", "obfs": { "type": "salamander", "password": "" }, "tls": { "enabled": true, "server_name": "hy2.<主域名>" } }, { "type": "vless", "tag": "VLESS-gRPC-CDN", "server": "ws.<主域名>", "server_port": 443, "uuid": "", "transport": { "type": "grpc", "service_name": "" }, "tls": { "enabled": true, "server_name": "ws.<主域名>" } }, { "type": "vless", "tag": "VLESS-WS-CDN", "server": "ws.<主域名>", "server_port": 443, "uuid": "", "transport": { "type": "ws", "path": "" }, "tls": { "enabled": true, "server_name": "ws.<主域名>" } }, { "type": "direct", "tag": "direct" }, { "type": "block", "tag": "block" } ], "route": { "rules": [ { "geoip": "cn", "outbound": "direct" }, { "geosite": "cn", "outbound": "direct" } ], "final": "VLESS-gRPC-CDN" } } ``` > [!TIP] > `route.final` 默认走 gRPC 节点,国内流量直连。如需切换主节点,修改 `final` 值为对应 `tag` 名称。 ## 五、服务器端配置文件路径 {#s5} ### Hysteria 2 | 项目 | 路径 | |------|------| | 配置文件 | `/etc/hysteria/config.yaml` | | 证书目录 | `/etc/hysteria/certs/` | | 服务管理 | `systemctl [start\|stop\|restart\|status] hysteria-server` | ### Xray | 项目 | 路径 | |------|------| | 配置文件 | `/usr/local/etc/xray/config.json` | | 日志目录 | `/var/log/xray/` | | 服务管理 | `systemctl [start\|stop\|restart\|status] xray` | | 本地监听 | `127.0.0.1:10000`(WS)、`127.0.0.1:10001`(gRPC) | ### Nginx(宝塔管理) | 项目 | 路径 | |------|------| | vhost 配置 | `/www/server/panel/vhost/nginx/ws.<主域名>.conf` | | 证书路径 | `/www/server/panel/vhost/cert/ws.<主域名>/` | | 服务管理 | `systemctl reload nginx` | ### SSL 证书(acme.sh 自动续签) | 项目 | 路径 | |------|------| | `hy2` 子域证书 | `/root/.acme.sh/hy2.<主域名>_ecc/` | | `ws` 子域证书 | `/root/.acme.sh/ws.<主域名>_ecc/` | | 续签方式 | Cloudflare DNS API 自动(无需手动操作) | ## 六、Cloudflare API 凭证 {#s6} | 项目 | 值 | |------|------| | API Token | | | 权限 | Zone → DNS → Edit(仅限本域名) | | 用途 | acme.sh DNS 验证自动续签证书 | > [!DANGER] > Token 须妥善保管,泄露后立即在 Cloudflare 撤销并重新生成;权限务必限定到单一 Zone 的 DNS Edit,不要用 Global API Key。 ## 七、常用维护命令 {#s7} ```bash # 查看所有代理服务状态 systemctl status hysteria-server xray nginx # 查看 Hysteria 2 实时日志 journalctl -u hysteria-server -f --no-pager # 查看 Xray 实时日志 journalctl -u xray -f --no-pager # 手动续签证书(通常自动,无需手动) export CF_Token="" ~/.acme.sh/acme.sh --renew -d hy2.<主域名> --server letsencrypt ~/.acme.sh/acme.sh --renew -d ws.<主域名> --server letsencrypt # 查看端口监听状态 ss -ulnp | grep '8443' # Hysteria 2 UDP ss -tlnp | grep -E '10000|10001' # Xray 本地端口 ss -tlnp | grep ':443' # Nginx HTTPS ``` ## 十三、更换凭证(废弃旧订阅 / 防止他人使用) {#s13} > [!WARNING] 适用场景 > 配置文件泄露、文档外传、怀疑他人使用你的节点时执行。执行后所有旧连接链接 / 配置立即失效。 ::: steps #### 生成新 UUID(用于替换 VLESS UUID) ```bash xray uuid # 或 cat /proc/sys/kernel/random/uuid ``` 记下输出的新 UUID,格式如 `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`。 --- #### 修改 Xray 配置(替换 UUID 和路径) ```bash nano /usr/local/etc/xray/config.json ``` 在文件中找到并替换以下内容: | 旧值(需替换) | 替换为 | |------|------| | 旧 UUID | 步骤 1 生成的新 UUID(两个 inbound 建议用同一个) | | 旧 WS 路径 | 新 WS 路径,如 `/ws_新随机字符串` | | 旧 gRPC serviceName | 新 gRPC 名称,如 `grpc_新随机字符串` | > [!TIP] > 路径建议用随机字符串,例如 `openssl rand -hex 8` 生成 8 位随机串。 > [!WARNING] > **WS 和 gRPC 建议使用同一个 UUID**,否则 gen-links 脚本需要分别读取,客户端配置也容易出错。 修改完毕后重启 Xray: ```bash systemctl restart xray systemctl status xray ``` --- #### [2.5] 同步更新 Nginx 路径 > [!DANGER] > **Xray 里改了 WS 路径和 gRPC serviceName 后,Nginx 配置必须同步修改**,否则新路径请求无法路由到 Xray。 ```bash nano /www/server/panel/vhost/nginx/ws.<主域名>.conf ``` 在 nano 中按 `Ctrl+W` 搜索旧路径快速定位,将: ```nginx location /旧WS路径 { location /旧gRPC名称 { ``` 改为: ```nginx location /新WS路径 { location /新gRPC名称 { ``` 保存后验证并重载: ```bash nginx -t && systemctl reload nginx ``` --- #### 修改 Hysteria 2 配置(替换密码) ```bash nano /etc/hysteria/config.yaml ``` 找到并替换旧的认证密码与 salamander 混淆密码,新密码建议含大小写 + 数字 + 符号。 修改完毕后重启 Hysteria 2: ```bash systemctl restart hysteria-server systemctl status hysteria-server ``` --- #### 更新文档中的新连接信息 替换完成后,用新 UUID、新密码、新路径重新生成连接链接(格式参考第三节),并更新 `private/secrets.json` 中的对应值,重新构建文档即可。 > [!LOCK] > 原文档此处列有「旧连接链接(已废弃)」清单,含完整密码与 UUID,**脱敏版已整段移除**。 ::: ## 十四、更新 Xray 核心到最新版本 {#s14} ### 查看当前 Xray 版本 ```bash xray version ``` ### 一键更新到最新版(官方脚本) ```bash bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install ``` > [!INFO] > 此命令会自动拉取最新版本并替换安装,**服务会短暂中断约几秒**,安装完成后服务自动恢复。 ### 验证更新结果 ```bash xray version systemctl status xray ``` ### 注意事项 - 更新 Xray 核心**不影响配置文件**,`/usr/local/etc/xray/config.json` 保持不变 - 建议在用量低峰期操作(如凌晨) - 如更新后服务异常,查看日志排查:`journalctl -u xray -f --no-pager` ## 十五、一键生成连接链接脚本 {#s15} 脚本本身不含密钥,它在运行时从服务器配置文件读取当前生效的 UUID / 路径 / 密码并拼出链接。**使用前把域名改成自己的。** ### 安装脚本 ```bash cat > /usr/local/bin/gen-links << 'EOF' #!/usr/bin/env python3 import json, re, sys def urlencode(s): safe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" return "".join(c if c in safe else f"%{ord(c):02X}" for c in s) try: with open("/usr/local/etc/xray/config.json") as f: xray = json.load(f) except Exception as e: print(f"读取 Xray 配置失败: {e}"); sys.exit(1) ws_uuid = ws_path = grpc_uuid = grpc_name = None for ib in xray.get("inbounds", []): clients = ib.get("settings", {}).get("clients", []) ss = ib.get("streamSettings", {}) if ss.get("network") == "ws" and not ws_path: ws_path = ss.get("wsSettings", {}).get("path", "") if clients: ws_uuid = clients[0].get("id") if ss.get("network") == "grpc" and not grpc_name: grpc_name = ss.get("grpcSettings", {}).get("serviceName", "") if clients: grpc_uuid = clients[0].get("id") try: with open("/etc/hysteria/config.yaml") as f: hy2 = f.read() except Exception as e: print(f"读取 Hysteria2 配置失败: {e}"); sys.exit(1) hy2_pass = obfs_pass = None for pat in [r'auth:\s*\n\s*(?:type:\s*\S+\s*\n\s*)?password:\s*["\']?([^"\'\n]+)', r'auth:\s*["\']?([^"\'\n]+)']: m = re.search(pat, hy2) if m: hy2_pass = m.group(1).strip().strip("\"'"); break m = re.search(r'salamander:\s*\n\s*password:\s*["\']?([^"\'\n]+)', hy2) if m: obfs_pass = m.group(1).strip().strip("\"'") DOMAIN = "<主域名>" print() print("=" * 60) print(" VLESS + WebSocket + CDN") print("=" * 60) print(f"vless://{ws_uuid}@ws.{DOMAIN}:443?type=ws&path={urlencode(ws_path)}&security=tls&sni=ws.{DOMAIN}#VLESS-WS-CDN") print() print("=" * 60) print(" VLESS + gRPC + CDN") print("=" * 60) print(f"vless://{grpc_uuid}@ws.{DOMAIN}:443?type=grpc&serviceName={grpc_name}&security=tls&sni=ws.{DOMAIN}#VLESS-gRPC-CDN") print() print("=" * 60) print(" Hysteria 2") print("=" * 60) print(f"hysteria2://{urlencode(hy2_pass)}@hy2.{DOMAIN}:8443?sni=hy2.{DOMAIN}&obfs=salamander&obfs-password={urlencode(obfs_pass)}#HY2-主力") print() EOF chmod +x /usr/local/bin/gen-links ``` ### 使用方法 ```bash gen-links ``` 每次更换凭证后运行即可获得最新连接链接。 > [!WARNING] > 该脚本的输出**包含完整明文凭证**,请勿把运行结果截图或粘贴到公开场合。 ## 十一、Hysteria 2 手机流量不通原因及解决方案 {#s11} > [!DANGER] 根本原因 > 服务器 IP 段被运营商列入 UDP 黑名单,所有 UDP 流量在运营商侧直接丢弃,与协议 / 端口 / 混淆无关(已验证:服务器日志从未收到任何连接记录)。 **商业节点能通的原因**:使用 Linode 等数据中心的未被标记 IP,非协议优势。 ### 已验证的中转方案 | 中转服务器 | IP | 结果 | |------|------|------| | 直连 | <代理服务器 IP> | UDP 封锁 ✗ | | Oracle 新加坡 | | UDP 封锁 ✗ | | 商业 Linode LAX | | 正常通过 ✓ | **结论**:该运营商对源站所在 IP 段和 Oracle Cloud 新加坡 IP 段均封锁 UDP,但 Linode LAX IP 段未被封锁。若要 Hysteria 2 在手机流量下可用,需租用 Linode 节点做中转。 > [!SUCCESS] > **当前最优方案:使用 VLESS + gRPC + CDN 作为主力(已验证稳定可用)。** ## 九、故障排查 {#s9} | 现象 | 可能原因 | 解决方法 | |------|------|------| | Hysteria 2 手机不通 | 服务器 IP 被运营商列入 UDP 黑名单,非协议问题 | WiFi 可用;手机流量需加 Linode 等干净 IP 做 UDP 中转 | | VLESS 443 不通 | Cloudflare 故障 | 等待恢复或检查 DNS | | 证书过期 | acme.sh 续签失败 | 手动运行续签命令(见第七节) | | Xray 启动失败 | 配置语法错误 | 检查 `/usr/local/etc/xray/config.json` | | Nginx 启动失败 | 配置语法错误 | 运行 `nginx -t` 检查 | ============================================================================== 文档:RustDesk 自建服务器 · 运维与诊断文档 来源:src/rustdesk.md ============================================================================== ## 0、文档维护说明(给未来的我 / AI) {#s0} - 每次改动配置或排查,请在文末 **「变更与迭代日志」** 追加一条,并更新顶部「最后更新」与「当前状态」。 - 状态图标约定:🟢 正常 🟡 部分可用 · 待优化 🔴 故障 - 命令、IP、端口等关键信息以代码块记录,方便复制和 AI 解析。 - 本文档为脱敏版:服务器 IP、IPv6 地址、设备 ID 均以占位符呈现,真实值在本机 `private/secrets.json`。 ## 1、架构概览 {#s1} | 项目 | 内容 | |------|------| | 软件 | RustDesk(开源远程桌面,自建中继 / 信令服务器) | | 服务器 | Oracle Cloud 免费实例,**地域:新加坡** | | 系统 | Ubuntu(实例名 ,登录用户 `ubuntu`) | | 服务进程 | `hbbs`(信令 / ID Server)、`hbbr`(中继 / Relay Server) | | 主控端 | 本人电脑,家庭宽带 **1000M**(中国,普通家宽) | | 被控端 | ⚠️ 待补充(在哪条网络尚未确认) | | 连接现状 | **加密中继连接 (TCP)** —— 即走 Relay,未实现 P2P 直连 | ### 数据路径示意 ```=html
理想(直连 / Direct)
主控本机
P2P 直连◀▶
被控远端机器

服务器仅做信令,画质好

现状(中继 / Relay)
主控本机
上行
新加坡服务器hbbr Relay
下行
被控远端机器

国际绕路,又卡又糊

``` ## 2、原始诉求(问题) {#s2} 1. **界面模糊、卡顿** —— 原因是什么,如何解决。 2. **用二级域名代替 IP** —— 是否更好、怎么配。 ## 3、诊断结论速览(TL;DR) {#s3} ::: cards #### 卡顿 / 模糊根因 *Root cause* 连接走了 **Relay 中继**,所有画面流量绕道新加坡服务器(免费实例带宽小 + 国内↔新加坡国际链路瓶颈)。 --- #### 服务器侧已 100% 排除 *Server-side · OK* 防火墙端口、服务进程、UDP 监听全部正常。 --- #### 真正卡点 *NAT traversal* **NAT 打洞失败**,两端大概率都是 **CGNAT 大内网**(中国家宽通病),UDP 打不通 → 被迫退回中继。 --- #### 1000M 宽带无用武之地 *Bandwidth* 走中继时瓶颈在服务器出口和国际链路,本地带宽再大也没用。 --- #### 域名问题 *DNS* 建议用域名,但只提升「运维便利性」,**不会改善画质 / 速度**。 ::: ## 4、已验证的服务器配置(均正常 ✅) {#s4} ### 4.1 UFW 防火墙(端口已全部放通) > [!INFO] > 实例用的是 **UFW**,不是 iptables。Oracle 云控制台安全列表已设为 all。 ```text 21115:21119/tcp ALLOW IN Anywhere ✅ 21116/udp ALLOW IN Anywhere ✅ 打洞关键端口 (v6 规则同样已放通) ``` - 另有 Fail2Ban 自动封禁大量恶意 SSH IP(正常防护,无需处理)。 - 已开放的其他端口:22 (SSH)、80、443、8633 (tcp/udp);2096 为 DENY。 ### 4.2 服务进程与监听 ```bash sudo ss -tulnp | grep 2111 udp *:21116 hbbs (pid 3954199) ✅ 打洞 UDP 在监听 tcp *:21115 hbbs tcp *:21116 hbbs tcp *:21117 hbbr tcp *:21118 hbbs tcp *:21119 hbbr ``` > [!SUCCESS] > **结论:服务端完全正常,无需再动。** ### 4.3 端口职责对照表 | 端口 | 协议 | 进程 | 作用 | |------|------|------|------| | `21115` | TCP | `hbbs` | NAT 类型测试 | | `21116` | TCP + **UDP** | `hbbs` | **21116/UDP = NAT 打洞核心**;TCP 为信令心跳 | | `21117` | TCP | `hbbr` | 中继 Relay | | `21118` | TCP | `hbbs` | Web 客户端支持 | | `21119` | TCP | `hbbr` | Web 客户端中继 | ## 5、已解决:IPv6 直连 ✅ 2026-06-13 {#s5} ### 根因确认 服务器侧无误,打洞失败根本原因是双端均为 CGNAT(IPv4 层),但**两端均有公网 IPv6**,IPv6 无 NAT,可直接 P2P。 ### 各端 IPv6 信息 | 端 | 公网 IPv6 | 运营商 | |------|------|------| | **A 机** |
2026-07-05 换电信后本机实测;`rust-a.<主域名>` DDNS 自动跟踪,原联通地址已失效 | 中国电信(与 D 同 /64 前缀,疑同一条宽带) | | **B 机** | | 中国电信 | | **C 机** | 待查
原联通地址已失效;换电信后未记录,待装 DDNS 时更新 | 中国电信 | | **D 机** | 与 A 同 /64 前缀
最新地址以 `rust-d.<主域名>` AAAA 记录为准,DDNS 自动更新 | 中国电信 | | **服务器** | | Oracle 新加坡 | ### 当前生效的连接方式(域名直连,双向互控) 两台机器均可互为主控 / 被控,连接时填域名即可,IP 变化自动跟踪。 | 方向 | 连接栏填写 | |------|------| | A 机控制 B 机 | `rust-b.<主域名>:21118` | | B 机控制 A 机 | `rust-a.<主域名>:21118` | | A 机控制 C 机 | ⚠️ 待重配 原联通临时 IP 已失效,待 C 装 DDNS 后改用 `rust-c.<主域名>:21118` | | C 机控制 D 机 | `rust-d.<主域名>:21118` | ### 各端配置详情 ::: cards #### A 机 电信 *与 D 同 /64 前缀* - 2026-07-05 由联通换为电信 - 域名:`rust-a.<主域名>`(AAAA 自动更新) - 设置 → 安全 → 「允许 IP 直接访问」 ✅ 端口 21118 - 防火墙入站 TCP 21118 已放行 - DDNS 脚本:`C:\Scripts\update-rustdesk-ipv6.ps1` - 计划任务:`RustDesk-DDNS-Boot`(开机)+ `RustDesk-DDNS-Repeat`(**每 1 小时**) --- #### B 机 电信 *独立线路* - 域名:`rust-b.<主域名>`(AAAA 自动更新) - 设置 → 安全 → 「允许 IP 直接访问」 ✅ 端口 21118 - 防火墙入站 TCP 21118 已放行 - DDNS 脚本:`C:\Scripts\update-rustdesk-ipv6.ps1` - 计划任务:Boot + Repeat(**每 1 小时**) - Cloudflare API Token 名称:(DNS Write,无过期) - RustDesk 服务:✅ 已安装(`RustDesk Service` Running) - **RustDesk ID**:(2026-06-14 重置后的新 ID) - ⚠️ **永久密码已重置**:`RustDesk.toml` 被删除,需重新在 B 的设置里设置永久密码 --- #### D 机 电信 *与 B 不同线路* - 域名:`rust-d.<主域名>`(AAAA 由脚本自动创建并更新) - 设置 → 安全 → 「允许 IP 直接访问」 ✅ 端口 21118 - 防火墙入站 TCP 21118 已放行 - DDNS 脚本:与 A/B 同款,仅 `$SUBDOMAIN` 改为 `rust-d.<主域名>` - 计划任务:Boot + Repeat(每 1 小时;改间隔命令见待办 ⑮) - 已验证:C→D 域名直连 ✅(2026-07-05) ::: ### DDNS 日志查看 ```powershell Get-Content C:\Scripts\ddns.log -Tail 10 ``` ## 5.1、已确认:B→A 直连单向失败 2026-06-14 · 历史 {#s51} > [!WARNING] 2026-07-05 更新:本节结论已过时 > 当时 A 为联通,失败根因是电信→联通跨运营商封锁;现 A 已换为电信宽带,该前提不复存在,B→A 需按电信→电信重测(见待办 ㉑)。下文保留作历史记录。 ### 现象 | 方向 | 结果 | |------|------| | A(联通)→ B(电信)直连 | ✅ 成功 P2P 直连 | | B(电信)→ A(联通)直连 | ❌ 失败 连接不通 | ### 结论 **疑为电信侧单向封锁**:电信 IPv6 出方向对联通 21118 端口有过滤或运营商 ACL 拦截,但联通→电信方向正常。此问题在用户侧无法修复。 ### 当前 B→A 的可用替代方案 1. **让 A 主动连 B**(A→B 直连正常)—— 需要在 A 端操作,不能从 B 发起。 2. **B→A 走中继**(Relay 模式)—— 可用,但有新 Bug,见 5.2。 ## 5.2、已定位:中继时画面套娃(Screen Inception)根因 ✅ 2026-06-14 {#s52} ### 复现路径 1. 用 **ToDesk** 远程连入 B(电信) 2. 在 B 上打开 RustDesk,用 **A 的 RustDesk ID** 中继连接 A(联通) 3. 连接成功后,画面出现多层叠加任务栏,无限嵌套闪烁 ### 根因(已截图确认) **屏幕捕获递归(Screen Inception)**,不是 Bug,是物理规律: ```diagram B 的 RustDesk 捕获 A 的屏幕 └── A 的屏幕上有 ToDesk 窗口,正在显示 B 的屏幕 └── B 的屏幕里有 RustDesk 显示 A 的屏幕 └── {bad:无限嵌套 → 多层任务栏叠加} ``` A 上的 ToDesk 是循环的关键节点。只要 A 有 ToDesk 会话开着,B 通过 RustDesk 看到的 A 就会包含 B 自己的内容。 ### 解决方案 > [!SUCCESS] > **连接前在 A 上关掉 ToDesk(退出,不是最小化)** 1. 通过 B 的 RustDesk 窗口点进 A 的桌面 2. 右键 A 任务栏托盘中的 ToDesk 图标 → **退出** 3. 或 Ctrl + Alt + Del → 任务管理器 → 结束 ToDesk 进程 4. ToDesk 关闭后循环立即断开,画面稳定 ### 正确工作流(B 控制 A,无套娃) 1. 物理到 B 旁边(或通过不涉及 A 屏幕的方式控制 B) 2. 确认 A 上 ToDesk 已退出(无活跃会话) 3. 在 B 的 RustDesk 里输入 A 的 ID(
)→ 连接 4. 画面正常,无套娃 > [!WARNING] > 待 B 旁边时验证,目前尚未实地测试(2026-06-14)。 ## 6、客户端画质优化(无论直连 / 中继都先做) {#s6} 参考 RustDesk 远程窗口顶部工具栏菜单: | 设置项 | 当前 | 建议 | |------|------|------| | 缩放 | 适应窗口 | 改为 **原始尺寸**(消除拉伸模糊) | | 画质 | 平衡(默认) | **高清** 或自定义拉高码率 | | 编解码 | — | 选 **硬件编码 H264 / H265**(两端需支持) | | 显示质量监控 | 未勾 | **勾选**,实时看码率 / 帧率 / 延迟 | ## 7、信令 / 中继服务器域名化 ✅ 2026-06-14 已完成 {#s7} > [!TIP] 结论:推荐用域名,但仅提升运维便利,不影响性能 > ✅ 好处:IP 变了只改一条 DNS,不用动所有客户端;好记好分发;便于后续上 TLS / 反代。 > ❌ 不会让画面变快或变清晰(DNS 解析开销可忽略)。 ### 已生效配置 · Cloudflare DNS 记录 | Type | Name | IPv4 | Proxy | TTL | |------|------|------|------|------| | A | `rd` | | 灰云 DNS only | Auto | > [!DANGER] > **必须灰云**,橙色代理会拦截 21116-21119 端口。 ### A 机 + B 机 RustDesk 配置文件(已批量替换) 配置文件路径:`%APPDATA%\RustDesk\config\RustDesk2.toml` ```powershell # 执行命令(A 机和 B 机各执行一次,管理员 PowerShell) $config = "$env:APPDATA\RustDesk\config\RustDesk2.toml" (Get-Content $config) ` -replace "rendezvous_server = '<旧IP>:21116'", "rendezvous_server = 'rd.<主域名>:21116'" ` -replace "relay-server = '<旧IP>'", "relay-server = 'rd.<主域名>'" ` -replace "custom-rendezvous-server = '<旧IP>'", "custom-rendezvous-server = 'rd.<主域名>'" | Set-Content $config Restart-Service RustDesk ``` 验证: ```powershell Get-Content "$env:APPDATA\RustDesk\config\RustDesk2.toml" | Select-String "server" # 三行均应显示 rd.<主域名>,RustDesk 界面底部状态显示「就绪」 ``` ## 7.1、服务器 Key 轮换标准流程 ✅ 2026-07-05 已验证 {#s71} > [!KEY] > RustDesk 的 Key = hbbs 自动生成的 ed25519 密钥对(`id_ed25519` 私钥 + `id_ed25519.pub` 公钥),客户端填的是公钥内容。 > 本服务器为 **Docker 部署**:容器 `hbbs` / `hbbr`(镜像 `rustdesk/rustdesk-server:latest`),数据卷 `/home/ubuntu/rustdesk/data → 容器内 /root`,密钥和 ID 数据库(`db_v2.sqlite3`)均持久化在宿主机该目录,重建容器不丢数据。 ### 服务器端(SSH 到实例) ```bash # ① 备份并删除旧密钥(在宿主机数据卷目录操作) cd /home/ubuntu/rustdesk/data sudo mv id_ed25519 id_ed25519.bak sudo mv id_ed25519.pub id_ed25519.pub.bak # ② 重启容器,hbbs 发现无密钥会自动生成新的一对 sudo docker restart hbbs hbbr # ③ 获取新公钥(这一串就是发给客户端的新 Key,44 字符左右,以 = 结尾) sudo cat /home/ubuntu/rustdesk/data/id_ed25519.pub # ④ 验证服务正常:应有 6 行监听(21115-21119 TCP + 21116 UDP) sudo ss -tulnp | grep 2111 ``` > [!TIP] 回滚方法 > 把 `.bak` 两个文件改回原名,再 `sudo docker restart hbbs hbbr`。 ### 客户端(A / B / C / D 四台都要更新) 否则通过 ID 的信令 / 中继连不上。 **方式一(图形界面)**:RustDesk → 设置 → 网络 → 解锁 → **Key** 栏粘贴新公钥(ID 服务器保持 `rd.<主域名>`)。 **方式二(管理员 PowerShell 批量替换)**: ```powershell $config = "$env:APPDATA\RustDesk\config\RustDesk2.toml" (Get-Content $config) -replace "key = '.*'", "key = '<新公钥>'" | Set-Content $config Restart-Service RustDesk ``` 验证:每台 RustDesk 主界面底部显示「就绪」。 ### 影响范围 | 是否受影响 | 路径 | |------|------| | ✅ 受影响 | 通过 **ID 连接**的信令 / 中继路径(如 B→A 中继)—— 客户端未更新 Key 前连不上 | | ❌ 不受影响 | **IPv6 域名直连**(`rust-x.<主域名>:21118`)不经过 hbbs / hbbr,Key 变更无感 | ## 8、关键信息速查 {#s8} > [!LOCK] > 本节所有敏感值均为占位符。需要真实值时在本机执行 `python build.py --private`。 | 项 | 值 | |------|------| | 服务器地域 | 新加坡(Oracle Cloud 免费实例) | | 服务器公网 IP | | | 服务器公网 IPv6 | | | 信令 / 中继域名 | `rd.<主域名>`(Cloudflare A 记录,灰云 DNS only) | | A 机直连域名 | `rust-a.<主域名>`(AAAA 记录,DDNS 自动更新) | | B 机直连域名 | `rust-b.<主域名>`(AAAA 记录,DDNS 自动更新) | | D 机直连域名 | `rust-d.<主域名>`(AAAA 记录,DDNS 自动更新) | | SSH 登录 | `ubuntu@` | | RustDesk Key(公钥) | 不入档 2026-07-05 已轮换,取值见服务器 `/home/ubuntu/rustdesk/data/id_ed25519.pub` | | A 机 RustDesk ID | | | B 机 RustDesk ID | | | 部署方式 | Docker(容器 `hbbs` / `hbbr`,镜像 `rustdesk/rustdesk-server:latest`,数据卷 `/home/ubuntu/rustdesk/data → /root`) | | hbbs PID(参考) | `2504031`(2026-07-05 换 Key 重启后) | | hbbr PID(参考) | `2504115`(2026-07-05 换 Key 重启后) | ## 9、变更与迭代日志 {#s9} 按时间升序记录,最新一条在最下方。 ::: expandable 展开完整日志 | 日期 | 变更内容 | 状态 | |------|------|------| | 2026-06-13 | 初始诊断:确认走中继;验证服务器防火墙 / 进程 / UDP 监听全部正常;定位为 NAT 打洞失败(CGNAT)。输出 IPv6 / 公网 IP / 中继三套方案。生成本文档。 | 🟡 待解决打洞 | | 2026-06-13 | 确认三端均有公网 IPv6(主控联通 / 被控电信 / 服务器 Oracle)。主控端发现运行 v2rayN 全局 TUN 代理(非元凶,用户确认)。采用直接 IPv6 IP 直连方案:主控填 `[B机IPv6]:21118`,被控端开启「允许 IP 直接访问」+ 放行防火墙端口 21118,连接成功,直连 P2P 实现。 | 🟢 直连成功 | | 2026-06-13 | 升级为双向互控 + IPv6 DDNS。A 机(联通)→ `rust-a.<主域名>`,B 机(电信)→ `rust-b.<主域名>`,均使用 Cloudflare API 自动更新 AAAA 记录,Windows 计划任务开机 + 每 10 分钟执行。连接方式改为域名,IP 变化自动跟踪。 | 🟢 稳定运行 | | 2026-06-14 | DDNS 计划任务间隔从 10 分钟改为 1 小时(IPv6 地址基本不变)。确认 B→A 单向直连失败(疑电信出向 ACL),A→B 直连正常。发现 B 通过 ToDesk 进入后用 RustDesk 中继连 A,出现多窗口死循环 Bug,临时方案:改为 A 主动连 B。 | 🟡 B→A 路径待修复 | | 2026-06-14 | 深入排查「多窗口死循环」:确认根因为 Screen Inception(非 Bug),A 上运行 ToDesk 会话 + B 的 RustDesk 显示 A 屏幕形成视觉递归。B→A 直连 100% 被 ISP 封锁(`Test-NetConnection` 超时)。`rust-a.<主域名>` 解析正确(非 Cloudflare 代理),但 B 网络层无法到达 A,域名连接同样失败。B 装了 RustDesk 服务(新 ID 已记录),误删 `RustDesk.toml` 致密码重置。修复方向:连接前在 A 上退出 ToDesk,待实地验证。 | 🟡 待实地验证 | | 2026-06-14 | 信令 / 中继服务器域名化完成:Cloudflare 添加 A 记录 `rd.<主域名>` → 服务器 IP(灰云 DNS only);A 机和 B 机 `RustDesk2.toml` 中三个字段(rendezvous_server / relay-server / custom-rendezvous-server)均从 IP 替换为域名;重启服务后两机均验证通过,状态显示「就绪」。 | 🟡 同上 | | 2026-06-14 | C 机(联通)接入:公网 IPv6 已记录,防火墙 TCP 21118 已放行,RustDesk 开启「允许 IP 直接访问」。A→C 直连测试 ✅ 成功(同联通网段)。待配 DDNS 和计划任务后替换为域名访问。D 机(电信)IPv6 直连测试进行中。 | 🟡 C 待 DDNS,D 待测试 | | 2026-06-14 | D→C 直连失败:D(电信)连接 C(联通)21118 报错「请稍后再试」。根因与 B→A 相同,电信出向 IPv6 → 联通 21118 端口被 ISP ACL 封锁,用户侧无法修复。同时发现 C 机 IPv6 已变更(无 DDNS 导致)。解法:改为 C(联通)主动连 D(电信)。 | 🟡 C→D 方向待验证 | | 2026-07-05 | **C→D IPv6 直连验证成功**(联通→电信)。此前失败原因为 D 端配置问题(按排查清单修复:确认端口为 21118 五位数、D 开启「允许 IP 直接访问」、防火墙放行、核实最新 IPv6),非 ISP 封锁 —— 与 A→B 同向,联通→电信方向再次确认可通。下一步:D 机配 DDNS。 | 🟡 D 待 DDNS | | 2026-07-05 | 服务器 Key(ed25519 密钥对)轮换:确认部署方式为 Docker。备份并删除旧 `id_ed25519(.pub)` → `docker restart hbbs hbbr` 自动生成新密钥对 → `ss` 验证 6 行监听恢复正常。待办:A/B/C/D 四台客户端更新新 Key,验证「就绪」。IPv6 直连路径不受影响。 | 🟡 客户端 Key 待更新 | | 2026-07-05 | **D 机 DDNS 配置完成**:`C:\Scripts\update-rustdesk-ipv6.ps1`(`$SUBDOMAIN = rust-d.<主域名>`,脚本自动在 Cloudflare 创建 AAAA 记录)+ 计划任务 Boot / Repeat(每 1 小时)。C 机用 `rust-d.<主域名>:21118` 域名直连验证成功。待办 ⑮ 补充了改任务间隔为一天 / 一周 / 一月的命令。 | 🟢 C→D 域名直连稳定 | | 2026-07-05 | **全网切换电信:A / C 由联通换为电信宽带(联通线路退网),四台机器现均为中国电信**。影响:① 「电信→联通 ISP 封锁」结论作废(5.1 已加过时标注),B→A / D→C / D→B 待按电信→电信重测(新增待办 ㉑,⑲ 并入);② A 机 IPv6 变更,由 DDNS 自动跟踪;③ C 机原联通 IP 及「A→C 已验证」失效,待装 DDNS(⑫)后重测;④ 待办 ⑳(删 A 的 DDNS)暂缓,视 B→A 重测结果而定。 | 🟡 待 ㉑ 重测 | ::: > [!TIP] 维护提示 > 每次改动复制下面这行追加到上表: > `| YYYY-MM-DD | 做了什么改动 / 排查了什么 | 🟢/🟡/🔴 |` ## 10、待办事项 {#s10} > [!INFO] > 完成一项就把复选框勾上(`- [ ]` 改成 `- [x]`),并到第 9 节日志追加一行。 > 标记约定:`[x]` 已完成 · `[ ]` 待办 · `[!]` 阻塞 · `[~]` 已并入其他条目 ### 🔴 当前阻塞:让连接从「中继」变「直连」 - [x] **① 确认被控端所在网络** —— 中国电信家宽,有公网 IPv6。 - [x] **② 两端各测 IPv6** —— 主控联通 / 被控电信 / 服务器 Oracle,三端均有公网 IPv6。 - [x] **③ IPv6 直连验证** —— 采用直接 IP 直连方式,连接成功,P2P 直连实现。 - [x] **⑤ 验收** —— 直连成功,不再走中继。 - [x] **⑨ IPv6 DDNS 稳定化** —— 双端均已配置 Cloudflare DDNS + Windows 计划任务,连接改用域名,IP 变化自动跟踪。 ### 🟡 优化项(直连成功后或并行做) - [ ] **⑥ 客户端画质** —— 缩放改「原始尺寸」+ 画质「高清」+ 开硬件编码 H264/H265 + 勾「显示质量监控」。 - [x] **⑦ 二级域名** —— `rd.<主域名>` A 记录已建(灰云),A/B 机 `RustDesk2.toml` 已全部替换,验证通过。 ### 🖥️ 扩展:C 机和 D 机接入 - [x] **⑩ 确认 C / D 机网络** —— C 机联通(IPv6 已记录);D 机电信(非 B 机)。 - [x] **⑪ 确认子域名命名** —— `rust-c.<主域名>` / `rust-d.<主域名>`。 - [ ] **⑫ C 机装 DDNS** —— ⚠️ C 已换电信,IPv6 已变,原联通地址和「A→C 已验证」均失效。装 DDNS(脚本同 A/B/D,`$SUBDOMAIN` 改 `rust-c.<主域名>`)+ 计划任务后,连接改用 `rust-c.<主域名>:21118` 并重测。 - [x] **⑬ D 机装 DDNS** —— 已完成(2026-07-05):DDNS 脚本 + 计划任务已装,C 机用 `rust-d.<主域名>:21118` 域名直连验证成功。 - [!] **㉑ 全电信后重测各方向直连** —— 四台已全为电信,旧跨运营商封锁结论作废,预期两两可通:B→A(填 `rust-a.<主域名>:21118`)、D→C(待 C 装 DDNS 后填 `rust-c.<主域名>:21118`)、D→B(填 `rust-b.<主域名>:21118`,即原 ⑲)。全部通过后 ⑭ 验收即可关闭。 - [ ] **⑭ 验收** —— 四台机器两两连接测试,确认均为直连 P2P。 - [~] **⑲ 测试电信→电信直连(D→B)** —— 已并入 ㉑(四台全电信后统一重测)。 ### 🗑️ 可以清理的任务 - [ ] **⑳ 删除 A 机的 DDNS 定时任务** —— A 机永远只作主控端,不需要被远程控制。可删除 `RustDesk-DDNS-Boot` 和 `RustDesk-DDNS-Repeat` 两个计划任务及 DDNS 脚本。B 机的同名任务**必须保留**(A 连 B 依赖它)。⚠️ 2026-07-05:建议**暂缓** —— A 换电信后 B→A 直连可能恢复(㉑ 待测),若测通且需要反向控制 A,则 A 的 DDNS 必须保留。 ### 🔧 可选优化 - [x] **⑮ 调整 DDNS 检测间隔为 1 小时**(IPv6 地址基本不变,1 小时完全够用,已执行)。 ```powershell # A 机和 B 机各执行一次(/ri 单位:分钟) schtasks /change /tn "RustDesk-DDNS-Repeat" /ri 60 ``` **改为更长间隔(一天 / 一周 / 一月)**:`schtasks /change` 改不了调度类型(`/sc`),需用 `/f` 覆盖重建任务。在目标机器管理员 PowerShell 执行下面**其中一条**即可: ```powershell # 每天一次(每天 09:00) schtasks /create /tn "RustDesk-DDNS-Repeat" /tr "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\update-rustdesk-ipv6.ps1" /sc daily /mo 1 /st 09:00 /ru SYSTEM /rl HIGHEST /f # 每周一次(每周一 09:00) schtasks /create /tn "RustDesk-DDNS-Repeat" /tr "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\update-rustdesk-ipv6.ps1" /sc weekly /d MON /st 09:00 /ru SYSTEM /rl HIGHEST /f # 每月一次(每月 1 号 09:00) schtasks /create /tn "RustDesk-DDNS-Repeat" /tr "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Scripts\update-rustdesk-ipv6.ps1" /sc monthly /mo 1 /d 1 /st 09:00 /ru SYSTEM /rl HIGHEST /f # 改完验证 schtasks /query /tn "RustDesk-DDNS-Repeat" /v /fo list ``` > [!WARNING] > 间隔越长,IPv6 变更后 DNS 失效的窗口越长(例如每月一次,IP 变了最坏要等一个月才自动修正)。好在 `RustDesk-DDNS-Boot` 开机必跑一次,重启机器即可立即修正;连不上时也可在被控端手动跑一次脚本。建议不低于**每天一次**。 ### 🔴 B→A 连接问题 - [x] **⑯ 排查 B→A 单向失败根因** —— 已确认:ISP 封锁(电信→联通 IPv6 路由不通),用户侧无法修复。 - [ ] **⑰ 验证 Screen Inception 修复** —— 待实地到 B 旁边测试:连接前在 A 上退出 ToDesk,再从 B 用 A 的 ID 中继连 A,确认画面正常。 - [ ] **⑱ 重设 B 的 RustDesk 永久密码** —— B 的 `RustDesk.toml` 被误删,密码已重置,需在 B 的 RustDesk → 设置 → 安全 → 重新设置永久密码。 ### 📝 文档待补全 - [ ] **⑧ 补全第 8 节速查表** —— 服务器公网 IP、域名、Key 等占位信息(Key 注意脱敏勿外泄)。 ============================================================================== 文档:New API 运维手册 来源:src/new-api.md ============================================================================== ## 一、架构总览 {#s1} ```diagram 用户浏览器 │ HTTPS (TLS 1.3) ▼ Cloudflare 边缘节点(橙色云,隐藏源站真实 IP ) │ HTTPS (Cloudflare Origin Certificate, Full Strict) ▼ Oracle Cloud 防火墙(仅开放 80/443) │ ▼ 宿主机 Nginx(进程管理,非 systemd) │ 内部转发 → 127.0.0.1:3010 ▼ new-api Docker 容器(127.0.0.1:3010:3000,公网不可达) │ ▼ /home/ubuntu/new-api-data/one-api.db(SQLite 持久化) ``` ## 二、关键信息速查 {#s2} | 项目 | 内容 | |------|------| | 服务器 IP | | | SSH 连接 | `ssh ubuntu@`(端口 22)| | 访问地址 | https://api.<主域名> | | 管理后台 | https://api.<主域名>(右上角登录)| | API 接入地址 | https://api.<主域名>/v1 | | 容器内部端口 | 127.0.0.1:3010(仅本机可达,公网不可达)| | 数据目录 | /home/ubuntu/new-api-data/ | | 项目目录 | /home/ubuntu/new-api/ | | nginx 反代配置 | /etc/nginx/sites-available/new-api | | nginx 代理参数 | /etc/nginx/snippets/proxy-params.conf | | SSL 证书 | /etc/nginx/certs/<主域名>/origin.pem | | SSL 私钥 | /etc/nginx/certs/<主域名>/origin-key.pem | ## 三、文件结构 {#s3} ```tree /home/ubuntu/new-api/ ├── docker-compose.yml ← Docker 编排文件 └── .env ← 环境变量(含 SESSION_SECRET,权限 600) /home/ubuntu/new-api-data/ └── one-api.db ← SQLite 数据库(所有数据在这里) /etc/nginx/ ├── sites-available/new-api ← nginx 反代配置(源文件) ├── sites-enabled/new-api ← 软链接(指向上面的文件) ├── snippets/proxy-params.conf ← 代理参数公共片段 └── certs/<主域名>/ ├── origin.pem ← Cloudflare Origin Certificate └── origin-key.pem ← 私钥(权限 600,严格保护) ``` ## 四、日常运维命令 {#s4} ### 4.1 查看服务状态 ```bash # 查看容器运行状态(STATUS 应为 healthy) cd ~/new-api && docker compose ps # 查看实时日志 cd ~/new-api && docker compose logs -f --tail=50 # 查看 nginx 访问日志 sudo tail -f /var/log/nginx/new-api.access.log # 查看 nginx 错误日志 sudo tail -f /var/log/nginx/new-api.error.log # 检查 nginx 进程 ps aux | grep nginx | grep -v grep ``` ### 4.2 启动 / 停止 / 重启 ```bash # 启动容器 cd ~/new-api && docker compose up -d # 停止容器(数据不丢失) cd ~/new-api && docker compose down # 重启容器 cd ~/new-api && docker compose restart # 重载 nginx(修改配置后执行,不中断现有连接) sudo nginx -s reload # 测试 nginx 配置语法(重载前先测试) sudo nginx -t ``` ### 4.3 更新 new-api 版本 ```bash cd ~/new-api # 拉取最新镜像 docker compose pull # 用新镜像重新启动 docker compose up -d # 确认新版本已运行 docker compose logs --tail=10 ``` ### 4.4 数据备份与恢复 ```bash # 手动备份数据库 cp ~/new-api-data/one-api.db ~/new-api-backup-$(date +%Y%m%d-%H%M).db # 查看所有备份 ls -lh ~/new-api-backup-*.db # 设置每日自动备份(执行一次永久生效,凌晨 2 点备份,保留最近 7 份) (crontab -l 2>/dev/null; echo "0 2 * * * cp ~/new-api-data/one-api.db ~/new-api-backup-\$(date +\%Y\%m\%d).db && find ~ -name 'new-api-backup-*.db' -mtime +7 -delete") | crontab - # 验证定时任务已添加 crontab -l # 恢复数据(替换日期为实际备份文件名) cd ~/new-api && docker compose down cp ~/new-api-backup-20260604.db ~/new-api-data/one-api.db docker compose up -d ``` ## 五、Cloudflare 配置说明 {#s5} ### 5.1 DNS 配置 | 类型 | 名称 | 内容 | 代理状态 | |------|------|------|----------| | A | api | | 已代理(橙色云)| > ⚠️ 代理状态必须是「已代理」(橙色云朵),选灰色云会直接暴露源站真实 IP。 ### 5.2 SSL/TLS 配置 - **加密模式**:完整(严格)/ Full (Strict) - **Origin Certificate**:已安装在服务器,有效期 15 年 - **证书覆盖范围**:`*.<主域名>` 和 `<主域名>`(通配符,api/panel 等子域均覆盖) ### 5.3 SSL 证书续签(约 15 年后) ``` 1. Cloudflare Dashboard → <主域名> → SSL/TLS → Origin Server 2. 撤销旧证书 → Create Certificate(重新生成) 3. 将新证书内容写入服务器: sudo nano /etc/nginx/certs/<主域名>/origin.pem sudo nano /etc/nginx/certs/<主域名>/origin-key.pem 4. 重载 nginx:sudo nginx -s reload ``` ## 六、安全配置说明 {#s6} ### 6.1 端口安全验证 ```bash # 验证 3010 只绑定到本地(每次服务器重启后建议检查一次) sudo ss -tlnp | grep 3010 # 正确输出(安全): # 127.0.0.1:3010 ← 只有本地可达 # # 危险输出(立即检查 docker-compose.yml): # 0.0.0.0:3010 ← 公网可达,不安全! ``` ### 6.2 nginx 安全特性(已配置) | 特性 | 说明 | |------|------| | 登录速率限制 | /api/user/login 每分钟最多 5 次,超出返回 429 | | X-Frame-Options | DENY,防止点击劫持 | | X-Content-Type-Options | nosniff,防 MIME 嗅探 | | Strict-Transport-Security | max-age=31536000,强制 HTTPS | | server_tokens | off,隐藏 nginx 版本号 | | SSL 协议 | 仅 TLSv1.2 + TLSv1.3 | ### 6.3 密码与 Token 安全建议 - 管理员密码:16 位以上,含大小写字母 + 数字 + 特殊符号 - 为每个应用/用户单独创建 Token,不共用同一个 - 后台设置中关闭「允许新用户注册」 ## 七、new-api 面板操作指南 {#s7} ### 7.1 添加 AI 供应商渠道 ``` 登录后台 → 渠道 → 添加渠道 ├── 类型:选择对应供应商(OpenAI / Anthropic / 智谱 / DeepSeek 等) ├── 名称:自定义(如 OpenAI-GPT4) ├── 密钥:填入对应供应商的 API Key └── 保存后点「测试」验证是否可用 ``` ### 7.2 创建用户 Token ``` 后台 → 令牌 → 添加令牌 ├── 名称:按用途命名(如 cursor-personal / app-prod) ├── 额度:按需设置(-1 为无限制) ├── 模型限制:可指定允许使用的模型 └── 复制生成的 sk-xxx Token 保存好 ``` ### 7.3 客户端(Cursor / ChatBox 等)接入配置 ``` API Base URL : https://api.<主域名>/v1 API Key : 填写上面创建的 Token(sk-xxx) 模型名称 : 与原始供应商相同(如 gpt-4o、claude-sonnet-4-5 等) ``` ### 7.4 关闭公开注册 ``` 后台 → 系统设置 → 通用设置 → 关闭「允许新用户注册」→ 保存 ``` ## 八、故障排查 {#s8} ### 问题:访问 https://api.<主域名> 打不开 ```bash # Step 1:检查容器是否在运行 cd ~/new-api && docker compose ps # Step 2:查看容器错误日志 docker compose logs --tail=30 # Step 3:检查 nginx 进程 ps aux | grep nginx | grep -v grep # Step 4:测试内部链路 curl -s http://127.0.0.1:3010/api/status # Step 5:如果容器挂了,重新启动 docker compose up -d # Step 6:如果 nginx 挂了 sudo nginx ``` ### 问题:修改 nginx 配置后不生效 ```bash sudo nginx -t # 先测试语法 sudo nginx -s reload # 语法正确后重载 ``` ### 问题:容器状态一直是 starting,不变 healthy ```bash docker compose logs # 查看启动报错原因 ls -la ~/new-api-data/ # 检查数据目录权限 chmod 755 ~/new-api-data/ docker compose restart ``` ### 问题:服务器重启后服务没恢复 ```bash # 检查 nginx ps aux | grep nginx | grep -v grep # 没有进程则启动: sudo nginx # 检查容器 cd ~/new-api && docker compose ps # 没有运行则启动: docker compose up -d # 让 nginx 开机自动启动(执行一次) sudo systemctl enable nginx ``` ## 九、服务器重启后恢复顺序 {#s9} ```bash # 1. 启动 nginx sudo nginx # 2. 启动容器 cd ~/new-api && docker compose up -d # 3. 验证内部链路 curl -s http://127.0.0.1:3010/api/status # 4. 验证外部访问 curl -s -o /dev/null -w "HTTP状态码: %{http_code}\n" https://api.<主域名>/api/status # 返回 200 则一切正常 ``` ## 十、定期维护清单 {#s10} ### 每月 ```bash # 更新 new-api cd ~/new-api && docker compose pull && docker compose up -d # 清理旧镜像(释放磁盘) docker image prune -f # 检查磁盘使用 df -h ~/ ``` ### 每季度 - 检查管理员密码,必要时轮换 - 检查各 AI 渠道 API Key 是否正常(后台 → 渠道 → 测试) - 回顾 nginx 访问日志排查异常:`sudo tail -200 /var/log/nginx/new-api.access.log` ## 十一、部署安全验证清单 {#s11} ``` 网络暴露 [✓] 3010 端口仅绑定 127.0.0.1(已验证) [✓] 公网无法直接访问 3010 端口(已验证) [✓] 80/443 由 nginx 统一管理 HTTPS 加密 [✓] 全站强制 HTTPS(HTTP 301 → HTTPS) [✓] Cloudflare Full (Strict) 端到端加密 [✓] Cloudflare Origin Certificate 已安装(*.<主域名> 通配符) [✓] TLS 1.2 / 1.3 only IP 保护 [✓] Cloudflare 橙色云代理已启用 [✓] 解析结果为 Cloudflare IP,非源站 数据持久化 [✓] SQLite 挂载到 /home/ubuntu/new-api-data/ [✓] 容器重启/删除不影响数据 应用安全(待手动完成) [ ] 关闭公开注册(后台 → 系统设置) [ ] 设置每日自动备份(见第四章 4.4) [ ] 为每个应用创建独立 Token ``` *文档版本:v1.0 | 生成日期:2026-06-04*