Claude Code

自我检查是成功的最高杠杆

  • 提供验证标准
    实现有一个邮箱验证函数,测试示例: user@example.com 为真,user@.com 为假

  • 直观验证UI
    实现 xx 文件夹下截图的设计,并截图和源文件比较差异,列出差异点并修复

  • 构建失败,出现了以下错误:[粘贴错误]。修复后确认构建成功。解决根本原因,不要掩盖错误。

探索/计划/实现/提交 四步走

  • 切换到计划模式,让 Claude 阅读指定文件,了解现在的实现方式,让他问问题并告诉他想知道的内容,这期间不做任何修改
    模糊的探索有时候很有用,例如:你想改进这个文件中的什么功能。
  • 说明想要实现的功能,询问可能修改的文件,需要添加的测试,以及实现流程, 让 Claude 列出详细的计划, 通过 CTRL + G 可以打开编辑
  • 切回普通模式,让 Claude 根据计划一步步实现功能,并生成测试文件,然后执行验证并解决错误。
  • 让 Claude 生成提交信息并创建 PR。

提供明确的上下文

  • 明确修改的场景,文件。 例如: 为 login.ts 添加测试, 需要覆盖第三方登录的场景。
  • 引导 Claude 能找出文件的源头,例如: 查看 login.ts 的 commit 历史,总结一下登录功能的变化过程。
  • 参考你希望的模式,例如: 查看首页表格组件, 仿照那个模式实现,产品列表,从零构建,不使用第三方库,需要实现双击单元格可修改的功能。
  • 错误描述带有引导性,例如: login.ts 存在以下错误,检查 auth 下的相关文件,在修改前写一个期待失败的错误来复现后在修改。

提供丰富的内容

  • 使用@引用这个文件
  • 直接复制粘贴图片,或让 Cladue 读取文件夹中的图片
  • 提供URL,使用 /permissions 来允许常用域名。
  • 通过运行 cat error.log | claude 直接发送文件内容,导入数据 。
  • 明确让 Claude 用指定 bash 或 MPC 去获取必要的上下文。

环境配置

CLAUDE.md

运行 /init 生成基于当前项目结构的起始 CLAUDE.md 文件,然后逐步细化。CLAUDE.md 在每一次对话都会被使用,逐渐丰富它的 Bash 命令、代码样式和工作流程规则,这是一个无法用代码推断出的持久上下文。

写 CLAUDE.md 时问自己 Claude 能从代码分析出某个问题么,如果能就删掉。

包括 删除
bash 命令,Claude 从代码中推断不出来的内容 删除 Claude 可以通过代码理解的内容
与默认规则不同的代码风格 删除 Cladue 已经知道的标准约定
测试说明和首选测试工具 删除 Api 文档,直接用官方的链接
协作习惯,分支命名, PR命名 删除 经常变化的信息
针对当前项目的具体架构 删除 长篇的解释或教程
环境相关的配置 删除 代码目录/文件 功能描述
独特的个人习惯 删除 ‘保持代码简洁’ 这种默认遵循的习惯

保持 Cladue.md 文件简洁,定期修改来修正 Claude 的方向。

使用 IMPORTANT YOU MUST 来提高信息的权重。

可以使用 - Git workflow: @docs/git-instructions.md 类似的语句来导入文件

权限配置

使用 /permissions 来允许安全的命令。

CLI

可以直接说明让 Claude 使用 gh 等 CLI 工具,也可以说:使用 gh -help 学习命令使用,并解决 x 问题。

MCP
Hooks

Hooks 可以确保在某个操作后执行动作,比如在每次修改文件后都运行测试。

使用 /hooks 交互式添加 hook, 或直接修改 .claude/settings.json

Wordpress 全流程

主题安装

轻量/性能

  • GeneratePress
    特点: 全球开发者公认的“最干净”主题。核心文件只有几十 KB。
    它不会产生冗余代码。可以使用它的 Elements 功能(类似于 Hook),在不改主题源码的情况下,在任何地方插入你的自定义代码。

  • Astra
    特点: 市场占有率第一。它有非常多的“一键导入”模板(Starter Templates)。
    它对 LearnDash(课程插件)有原生优化,能自动把课程页面美化得非常专业,省去写 CSS 的时间。

  • Kadence
    特点: 近两年崛起的黑马,自定义程度比 Astra 更高,且不依赖任何页面构建器。
    为什么选它: 它的 Header/Footer Builder 非常直观,适合快速搭建独立站的品牌感。

课程/社区

  • BuddyBoss Theme
    特点: 目前做课程+社交的最强主题。它把 WordPress 变成了一个像 Facebook 一样的社区。
    适用场景: 课程不仅是卖视频,还想要学生之间互动、发动态、做小组,这是唯一选择。

  • Eduma (Education WP)
    特点: ThemeForest 上销量第一的教育主题。
    适用场景: 一个“开箱即用”的学校网站感觉。它内置了 LearnPress(一个免费课程插件),对新手非常友好。

  • 配合插件
    LearnDash 老牌、功能最稳、扩展极多。闭源但生态极其丰富,适合严肃教育。
    TutorLMS UI 非常现代、中文支持好。后台操作非常丝滑,更有“互联网产品”的感觉。

FSE 主题 : Frost 或 Twenty Twenty-Five
特点: 完全基于区块(Blocks)。没有传统的 PHP 模板概念,全部通过 JSON 和 HTML 预定义。这种模式更接近于“组件化”开发的逻辑,你可以完全控制页面的 DOM 结构。

产品类目的三种方式

图片排列

OpenFaas 部署/应用

安装

OpenFaaS 依赖 Kubernetes 集群, 参考 kubernetes 安装文档, 首先关闭 swap

1
2
3
4
5
# 删除swap所在行
sudo vi /etc/fstab
sudo swapoff -a
# 验证
free -h

安装一个 container runtime 官方推荐 containerd,安装成功后,初始化控制节点

1
2
3
4
5
6
7
8
sudo kubeadm init --pod-network-cidr=10.244.0.0/16

# 如果报错 [ERROR FileContent--proc-sys-net-ipv4-ip_forward]: /proc/sys/net/ipv4/ip_forward contents are not set to 1
# Kubernetes 节点需要开启 IPv4 转发(IP forwarding),这样 Node 可以把 Pod 的网络流量转发到其他节点或外部网络。

sudo nano /etc/sysctl.conf
net.ipv4.ip_forward=1 # 添加或打开注释
sudo sysctl -p # 保存

初始化成功后参照控制台输出执行 配置命令

安装一个 网络插件 以便于容器间通信,或访问外部网络

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kubectl apply -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml

# 如果报错Failed to check br_netfilter: stat /proc/sys/net/bridge/bridge-nf-call-iptables: no such file or directory
# 说明 内核缺少 br_netfilter 模块或相关配置没有启用,这是 Flannel 启动 VXLAN 网络所必须的。

sudo modprobe br_netfilter # 加载 br_netfilter 模块
lsmod | grep br_netfilter # 确认模块加载

# 开启 bridge-nf-call-iptables
echo "br_netfilter" | sudo tee /etc/modules-load.d/k8s.conf
echo "net.bridge.bridge-nf-call-iptables = 1" | sudo tee /etc/sysctl.d/k8s.conf
echo "net.bridge.bridge-nf-call-ip6tables = 1" | sudo tee -a /etc/sysctl.d/k8s.conf
sudo sysctl --system

# 重新部署
kubectl delete -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml
kubectl apply -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml

OpenFaaS 安装参考 官方文档, 安装 openfaas-ce 后,命令行会返回一段信息, 依次执行这些命令

1
2
3
4
5
6
7
8
# 获取 faas-cli(OpenFaaS 的命令行工具),用来操作 OpenFaaS
arkade get faas-cli

# 安装后查看版本
faas-cli version

# 等待 OpenFaaS Gateway 部署完成,gateway 是 OpenFaaS 的 核心入口 / UI / API Server。
kubectl rollout status -n openfaas deploy/gateway

如果命令 kubectl rollout status -n openfaas deploy/gateway 不成功, 检查 coredns 的 forward 配置

1
2
3
4
5
kubectl edit configmap coredns -n kube-system

forward . 192.168.48.1 # 修改为宿主机ip

kubectl rollout restart deployment coredns -n kube-system # 重启服务

UI 界面访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 查看端口 
kubectl get svc -n openfaas
# gateway-external NodePort 10.103.137.90 <none> 8080:31112/TCP 39m
# 此时就可以通过 31112 端口访问 ui 界面了


# 登录
echo -n $PASSWORD | faas-cli login --gateway http://192.168.48.133:31112 --username admin --password-stdin
# Calling the OpenFaaS server to validate the credentials...
# WARNING! You are not using an encrypted connection to the gateway, consider using HTTPS.
# credentials saved for admin http://192.168.48.133:31112

echo $PASSWORD

# 修改密码
echo -n "NewPassword123" | base64

kubectl get secret basic-auth -n openfaas -o yaml
# 替换 basic-auth-password

# 重启服务
kubectl rollout restart deployment gateway -n openfaas

系统重启准备

1
2
3
4
5
6
7
8
9
10
kubectl drain openfass --ignore-daemonsets --force --delete-emptydir-data

# --ignore-daemonsets → 不驱逐 DaemonSet Pod
# --force → 强制在单节点上驱逐 Pod
# --delete-emptydir-data → 删除使用 emptyDir 的 Pod
sudo reboot

# 把节点状态改回 可调度
kubectl uncordon openfass
kubectl get nodes

Puppeteer + 火山 OCR 实现自动登录和爬虫操作

火山 OCR Api 接入

需要在用户中心获取 AK,SK 密钥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
// request.js

"use strict";

const crypto = require("crypto");
const util = require("util");
const url = require("url");
const qs = require("querystring");
const fs = require("fs");

/**
* 不参与加签过程的 header key
*/
const HEADER_KEYS_TO_IGNORE = new Set([
"authorization",
"content-type",
"content-length",
"user-agent",
"presigned-expires",
"expect",
]);

// do request example
function request(signParams) {
signParams.headers = {
...signParams.headers,
["X-Date"]: getDateTimeNow(),
};
signParams.bodySha = getBodySha(signParams.body);

// 正规化 query object, 防止串化后出现 query 值为 undefined 情况
for (const [key, val] of Object.entries(signParams.query)) {
if (val === undefined || val === null) {
signParams.query[key] = "";
}
}
const authorization = sign(signParams);
return fetch(
`https://iam.volcengineapi.com/?${qs.stringify(signParams.query)}`,
{
headers: {
...signParams.headers,
Authorization: authorization,
},
method: signParams.method,
body: signParams.body,
}
);
}

function sign(params) {
const {
headers = {},
query = {},
region = "",
serviceName = "",
method = "",
pathName = "/",
accessKeyId = "",
secretAccessKey = "",
needSignHeaderKeys = [],
bodySha,
} = params;

const datetime = headers["X-Date"];
const date = datetime.substring(0, 8); // YYYYMMDD
// 创建正规化请求
const [signedHeaders, canonicalHeaders] = getSignHeaders(
headers,
needSignHeaderKeys
);
const canonicalRequest = [
method.toUpperCase(),
pathName,
queryParamsToString(query) || "",
`${canonicalHeaders}\n`,
signedHeaders,
bodySha || hash(""),
].join("\n");

const credentialScope = [date, region, serviceName, "request"].join("/");
// 创建签名字符串
const stringToSign = [
"HMAC-SHA256",
datetime,
credentialScope,
hash(canonicalRequest),
].join("\n");
// 计算签名
const kDate = hmac(secretAccessKey, date);
const kRegion = hmac(kDate, region);
const kService = hmac(kRegion, serviceName);
const kSigning = hmac(kService, "request");
const signature = hmac(kSigning, stringToSign).toString("hex");

return [
"HMAC-SHA256",
`Credential=${accessKeyId}/${credentialScope},`,
`SignedHeaders=${signedHeaders},`,
`Signature=${signature}`,
].join(" ");
}

function hmac(secret, s) {
return crypto.createHmac("sha256", secret).update(s, "utf8").digest();
}

function hash(s) {
return crypto.createHash("sha256").update(s, "utf8").digest("hex");
}

function queryParamsToString(params) {
return Object.keys(params)
.sort()
.map((key) => {
const val = params[key];
if (typeof val === "undefined" || val === null) {
return undefined;
}
const escapedKey = uriEscape(key);
if (!escapedKey) {
return undefined;
}
if (Array.isArray(val)) {
return `${escapedKey}=${val
.map(uriEscape)
.sort()
.join(`&${escapedKey}=`)}`;
}
return `${escapedKey}=${uriEscape(val)}`;
})
.filter((v) => v)
.join("&");
}

function getSignHeaders(originHeaders, needSignHeaders) {
function trimHeaderValue(header) {
return header.toString?.().trim().replace(/\s+/g, " ") ?? "";
}

let h = Object.keys(originHeaders);
// 根据 needSignHeaders 过滤
if (Array.isArray(needSignHeaders)) {
const needSignSet = new Set(
[...needSignHeaders, "x-date", "host"].map((k) => k.toLowerCase())
);
h = h.filter((k) => needSignSet.has(k.toLowerCase()));
}
// 根据 ignore headers 过滤
h = h.filter((k) => !HEADER_KEYS_TO_IGNORE.has(k.toLowerCase()));
const signedHeaderKeys = h
.slice()
.map((k) => k.toLowerCase())
.sort()
.join(";");
const canonicalHeaders = h
.sort((a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : 1))
.map((k) => `${k.toLowerCase()}:${trimHeaderValue(originHeaders[k])}`)
.join("\n");
return [signedHeaderKeys, canonicalHeaders];
}

function uriEscape(str) {
try {
return encodeURIComponent(str)
.replace(/[^A-Za-z0-9_.~\-%]+/g, escape)
.replace(
/[*]/g,
(ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`
);
} catch (e) {
return "";
}
}

function getDateTimeNow() {
const now = new Date();
return now.toISOString().replace(/[:-]|\.\d{3}/g, "");
}

// 获取 body sha256
function getBodySha(body) {
const hash = crypto.createHash("sha256");
if (typeof body === "string") {
hash.update(body);
} else if (body instanceof url.URLSearchParams) {
hash.update(body.toString());
} else if (util.isBuffer(body)) {
hash.update(body);
}
return hash.digest("hex");
}

module.exports = request;

puppeteer 爬虫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
const puppeteer = require("puppeteer");
const fs = require("fs");

const request = require("./request");

// const { createWorker } = require("tesseract.js");

// const worker = await createWorker("eng");
// const ret = await worker.recognize(fs.readFileSync("./authCode.jpg"));
// console.log(ret.data.text);

(async () => {
const browser = await puppeteer.launch({
headless: true,
defaultViewport: { width: 1400, height: 900 },
});
const page = await browser.newPage();

// 设置导航的默认超时时间为永不超时
page.setDefaultNavigationTimeout(0);

// 如果添加请求拦截器,需要添加下面一句代码
// 如果使用响应拦截器,则不需要添加,添加会导致代码不向下执行
// await page.setRequestInterception(true);

page.on("response", async (response) => {
const url = response.url();

// 拦截验证码图片,保存到本地
if (url.includes("image.php")) {
const img = await response.buffer();
fs.writeFileSync(`./authCode.jpg`, img);
}
});

await Promise.all([
page.waitForNavigation(),
page.goto("https://hdsky.me/login.php"),
]);

await page.type("input[name='username']", "username");
await page.type("input[name='password']", "password");

// 调用 OCR api 识别验证码
const AccessKeyId = "___YOUR_ACCESS_KEY___";
const SecretKey = "___YOUR_SECRET_KEY___";

const img = fs.readFileSync("./authCode.jpg");
const imgBase64 = img.toString("base64");

const body = new URLSearchParams();
body.append("image_base64", imgBase64);

const {
data: {
line_texts: [authCode],
},
} = await request({
headers: {
// 火山文档提示必传,但是 sdk 会过滤掉
"content-type": "application/x-www-form-urlencoded",
},
method: "POST",
// api 文档必填参数
query: {
Action: "OCRNormal",
Version: "2020-08-26",
},
accessKeyId: AccessKeyId,
secretAccessKey: SecretKey,
serviceName: "cv",
region: "cn-north-1",
body,
}).then((res) => res.json());

console.log(authCode);

await page.type("input[name='imagestring']", authCode);

// 点击事件导致页面导航,需要等待导航结束
await Promise.all([
page.waitForNavigation(),
page.click("input[type='submit']"),
]);

await Promise.all([
page.waitForNavigation(),
page.click("a[href='torrents.php']"),
]);

const loop = async (startPage) => {
const list = await page.$$("table tr[class*=progresstr]");

for (let i = 0; i < list.length; i++) {
const item = list[i];

// 监听新页面创建事件,并获取详情页的 page 对象
const detailPromise = new Promise((resolve) => {
browser.once("targetcreated", async (target) => {
if (target.type() === "page") {
const newPage = await target.page();
resolve(newPage);
}
});
});

const a = await item.$(
"td:nth-child(2) table > tbody > tr > td:nth-child(1) > a"
);
await a.click();

const detailPage = await detailPromise;

await detailPage.waitForSelector("#saythanks", {
timeout: 0,
});

await detailPage.click("#saythanks");

console.log(`第 ${startPage} 页,第 ${i + 1} 条已点赞`);

await new Promise((resolve) => setTimeout(resolve, 4000));

await detailPage.close();
}
};

const loop2 = async () => {
const startPage = fs.readFileSync("./skypage.txt", "utf8");

const search = await page.$("input[type=number]:nth-child(2)");
await search.click({ clickCount: 3 });
await search.type(startPage);

await Promise.all([
page.waitForNavigation(),
page.click("input[type=submit]:nth-child(3)"),
]);

await loop(startPage);

if (startPage > 0) {
fs.writeFileSync("./skypage.txt", (startPage - 1).toString(), "utf8");
loop2();
}
};

loop2();

// await browser.close();
})();

debian 执行任务报错

Failed to launch the browser process: error while loading shared libraries: libnss3.so: cannot open shared object file

可能是没想相关的执行环境安装以下依赖

1
2
3
sudo apt-get install libpangocairo-1.0-0 libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 libxi6 libxtst6 libnss3 libcups2 libxss1 libxrandr2 libasound2 libatk1.0-0 libgtk-3-0

sudo apt-get install -y libgbm-dev

Dify 工作流

下载 Dify 源码

1
git clone https://github.com/langgenius/dify.git

启动 Dify

进入 Dify 源代码的 docker 目录,执行一键启动命令:

1
2
cd dify/docker
docker compose up -d

如果您的系统安装了 Docker Compose V2 而不是 V1,请使用 docker compose 而不是 docker-compose。通过$ docker compose version 检查版本号:Docker

1
2
3
4
5
6
7
8
[+] Running 7/7
✔ Container docker-web-1 Started 1.0s
✔ Container docker-redis-1 Started 1.1s
✔ Container docker-weaviate-1 Started 0.9s
✔ Container docker-db-1 Started 0.0s
✔ Container docker-worker-1 Started 0.7s
✔ Container docker-api-1 Started 0.8s
✔ Container docker-nginx-1 Started

检查容器是否正常运行,包括 3 个业务服务 api / worker / web,以及 4 个基础组件 weaviate / db / redis / nginx,都要处于启动状态。注意下面每一个服务的 status 都要是 Up 状态才算真正启动成功。

1
2
3
4
5
6
7
8
9
10
docker compose ps

NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
docker-api-1 langgenius/dify-api:0.3.2 "/entrypoint.sh" api 4 seconds ago Up 2 seconds 80/tcp, 5001/tcp
docker-db-1 postgres:15-alpine "docker-entrypoint.s…" db 4 seconds ago Up 2 seconds 0.0.0.0:5432->5432/tcp
docker-nginx-1 nginx:latest "/docker-entrypoint.…" nginx 4 seconds ago Up 2 seconds 0.0.0.0:80->80/tcp
docker-redis-1 redis:6-alpine "docker-entrypoint.s…" redis 4 seconds ago Up 3 seconds 6379/tcp
docker-weaviate-1 semitechnologies/weaviate:1.18.4 "/bin/weaviate --hos…" weaviate 4 seconds ago Up 3 seconds
docker-web-1 langgenius/dify-web:0.3.2 "/entrypoint.sh" web 4 seconds ago Up 3 seconds 80/tcp, 3000/tcp
docker-worker-1 langgenius/dify-api:0.3.2 "/entrypoint.sh" worker 4 seconds ago Up 2 seconds 80/tcp, 5001/tcp

升级 Dify

进入 Dify 源代码的 docker 目录,按顺序执行以下命令:

1
2
3
4
5
cd dify/docker
git pull origin main
docker compose down
docker compose pull
docker compose up -d

FAQ

  • 首页正常显示,当点击设置管理员用户页面一直在加载中,浏览器控制台报错,
1
/console/api/setup 502

检查服务的状态 status 栏并是不是 Up 状态

1
docker compose ps

查看日志

1
docker logs docker-api-1

这是由于 Docker 运行权限不够,需要被赋予更高的权限。

在 docker-compose.yaml 文件中,给 services 下每一个 service 最后一行都加上 privileged: true,然后重新启动 Dify 即可。

  • 由于在第一个问题中,Ubuntu 系统中,本地修改了 docker-compose.yaml 文件,在使用 git pull origin main 会提示本地有文件没有提交:
1
2
3
4
5
6
7
8
9
10
error: Your local changes to the following files would be overwritten by merge:
docker/docker-compose.yaml
Please, commit your changes or stash them before you can merge.

# 需要提交修改
git config --global user.email "xxx"
git config --global user.name "xxx"

git add docker-compose.yaml
git commit -m "local change"
  • Docker 无法拉取镜像
1
2
3
4
5
6
7
8
9
10
// daemon.json可能会不存在,直接 vi /etc/docker/daemon.json可以创建
sudo vi /etc/docker/daemon.json

{
"registry-mirrors": ["新的镜像源地址"]
}

systemctl restart docker //重启docker

docker info //查看镜像源有没有配置成功

React Router v7 概览

v7 可以让 React Router 作为框架来使用,利用提供的 cli 工具创建项目,提供了相关的构建包,以 React Router 的视角开发项目。以下是官方提供的特性说明:

  • Vite 捆绑器和开发服务器集成
  • 热模块替换
  • 代码分割
  • 带类型安全的路由约定
  • 文件系统或基于配置的路由
  • 带类型安全的数据加载
  • 带类型安全的操作
  • 操作后页面数据的自动重新验证
  • SSR、SPA 和静态呈现策略
  • 待定状态和乐观 UI 的 api
  • 部署适配器

客户端路由相关知识

Location API (获取与操作 URL)

window.location:

  • assign(url): 导航到新地址(产生历史记录)。

  • replace(url): 导航到新地址(不产生历史记录,直接替换当前页)。

  • reload(): 刷新当前页。

History API

  • history.state 只读属性,返回当前历史条目关联的状态对象。

    当页面刷新后,或者通过代码逻辑回到某个历史点时,你可以直接通过 history.state 拿到之前存进去的那个 JSON 对象,而不需要等待 popstate 事件。

  • history.pushState(state, title, url) 增加一个历史记录条目,但是不会触发 popstate 事件, 也不会刷新页面。

    state 参数:它可以是任何可以被序列化的对象(即可以用 JSON.stringify 处理的对象)。

    存储位置:这个数据是存储在浏览器进程中的。即使你刷新页面,只要这个历史条目还在,这个 state 就不会丢失。

    位置,状态,类型,行为
    1,http://localhost:3000?tag=m,Document 1,初始加载
    2,http://localhost:3000?tag=m1,Document 1,pushState (无刷新)
    3,http://localhost:3000?tag=m2,Document 1,pushState (无刷新)
    4,http://localhost:3000?tag=d,Document 2,手动输入 (全页面刷新)
    5,http://localhost:3000?tag=d1,Document 2,pushState (无刷新)
    6,http://localhost:3000?tag=d2,Document 2,pushState (无刷新)

    通过 前进,后退 按钮,或者 history.back(), history.forward(), history.go() 方法导航时,会触发 popstate 事件。

    特殊逻辑

    • 浏览器的“无交互记录”跳过机制(User Activation)

      如果一个历史记录是通过 pushState 创建的,但用户在那个状态下没有进行任何实质性的交互(比如点击、滚动、按键),浏览器在点击后退时,可能会认为这些状态是“无意义”的。

      当你手动输入 ?tag=d 后,如果紧接着连续运行了两次 pushState 而没有点击页面任何地方,浏览器在后退时可能会直接把 D1 和 D2 视为 中间过程 直接跳过,一步退回到手动输入的那个 “有来源” 的页面起点。

    • 只有 URL 变化,没有“历史条目”

      在某些特殊的本地测试环境下(比如某些版本的 Live Server 或 Vite HMR 期间),如果你连续快速执行 pushState,而浏览器认为页面内容完全没有变化(DOM 没动),它有时会合并这些连续的 URL 变更。

    • “手动输入”触发了替换(Replace)而不是推送(Push)

      当你从 M2 手动输入 ?tag=d 时,浏览器会认为这是一个全新的导航起点。 如果你在 D 页面加载后,脚本立即执行了 pushState(例如在 onload 或是组件挂载时自动执行),浏览器有时会为了防止死循环,将这种“刚打开就改 URL”的行为处理为 replaceState 效果,导致 D 被 D1 覆盖,D1 被 D2 覆盖。

    • history.replaceState(state, title, url) 修改当前历史记录条目,不会触发 popstate 事件,也不会刷新页面。

    • history.popstate 事件 当活动历史记录条目发生变化时触发(例如用户点击浏览器的后退按钮,或者调用 history.back() 方法)。

特殊文件

react-router.config.ts

可选,全局的配置文件

root.ts

必须,唯一的必须路由,是 routes 目录有所有路由的父路由,也用于描述 HTML 文档。

可以把 React Router 提供的全局组件写在这里,只会渲染一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import type { LinksFunction } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";

import "./global-styles.css";

export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

{/* 所有路由上的所有meta导出都会在这里渲染 */}
<Meta />

{/* 所有路由上的所有link导出都会在这里渲染 */}
<Links />
</head>
<body>
{/* 子路由*/}
<Outlet />

{/* 管理客户端渲染时候的滚动条位置 */}
{/* If you use a nonce-based content security policy for scripts, you must provide the `nonce` prop. Otherwise, omit the nonce prop as shown here. */}

<ScrollRestoration />

<Scripts />
</body>
</html>
);
}

Content Security Policy (CSP)
CSP 是一种浏览器安全机制,用于防止跨站脚本攻击(XSS)等安全问题。通过配置 CSP,开发者可以限制页面加载的资源(如脚本、样式等)的来源和执行方式。

Nonce
nonce 是 CSP 中的一个概念,表示一个随机生成的字符串(一次性值)。通过在 CSP 中配置 nonce,可以允许特定的内联脚本或动态加载的脚本执行,而不会违反 CSP 规则。

如果你使用了基于 nonce 的 CSP 策略:
如果你的 CSP 配置中使用了 nonce 来允许脚本执行,那么你需要在 React Router 的相关组件中传递 nonce 属性。这是因为 React Router 可能会动态加载或执行一些脚本,这些脚本需要符合你的 CSP 策略。

如果你没有使用基于 nonce 的 CSP 策略:
如果你的 CSP 配置不涉及 nonce,或者你不需要对脚本进行特殊限制,那么你可以忽略 nonce 属性(如示例中所示).

假设你的 CSP 配置如下:

1
Content-Security-Policy: script-src 'nonce-abc123';

这意味着只有带有 nonce=”abc123” 的脚本才能执行。在这种情况下,你需要在 React Router 中传递 nonce 属性:

1
<Router nonce="abc123">{/* Your routes here */}</Router>

layout-export

一个单独导出的组件,可以避免在 Root Component,HydrateFallback, ErrorBoundary 重复声明 app 的框架元素。

routes.ts

必须, 统一的路由配置文件,它将自动对每个路由进行代码拆分,为参数和数据提供类型安全,并在用户导航到数据时自动加载具有挂起状态访问权的数据。

1
2
3
4
5
6
import type { RouteConfig } from "@react-router/dev/routes";
import { route } from "@react-router/dev/routes";

export default [
route("contacts/:contactId", "routes/contact.tsx"),
] satisfies RouteConfig;
index route

当路由没有匹配任何路径的时候,他会在 Outlet 中显示空白,index 可以当做是默认路由的视图。

1
2
3
4
5
6
7
8
// app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { index, route } from "@react-router/dev/routes";

export default [
index("routes/home.tsx"),
route("contacts/:contactId", "routes/contact.tsx"),
] satisfies RouteConfig;
layout route

可以在路由的配置文件中,描述组件的嵌套关系.

1
2
3
4
5
6
7
8
9
10
11
12
import type { RouteConfig } from "@react-router/dev/routes";
import { index, layout, route } from "@react-router/dev/routes";

export default [
layout("layouts/sidebar.tsx", [
index("routes/home.tsx"),

// 会渲染到 layouts/sidebar.tsx 这个 layout 下的 Outlet 里面。
route("contacts/:contactId", "routes/contact.tsx"),
]),
route("about", "routes/about.tsx"),
] satisfies RouteConfig;

特殊组件

用于展示路由

1
2
3
4
5
6
7
8
9
10
11
// app/root.tx
import { Outlet } from "react-router";
export default function App() {
return (
<>
<div id="detail">
<Outlet />
</div>
</>
);
}

核心方法

clientLoader

只在浏览器中调用,为路由组件提供数据

1
2
3
4
5
6
7
8
9
10
11
// app/root.tsx

export async function clientLoader() {
const contacts = await getContacts();
return { contacts };
}

export default function App({ loaderData }: any) {
const { contacts } = loaderData;
// ...
}
HydrateFallback

如果通过 react-router.config.ts 配置为客户端渲染,那么在 app 根文件执行之前是没有任何内容。

提供一个 HydrateFallback 方法,在 app 被渲染前提供能内容。他会被直接添加到 index.html 文件中。

1
2
3
4
5
6
7
8
// app/root.tsx
export function HydrateFallback() {
return (
<div id="loading-splash">
<p>Loading, please wait...</p>
</div>
);
}
URL Params Loader

在 loader 方法中获取

1
2
3
4
5
6
7
8
9
10
11
12
13
// existing imports
import type { Route } from "./+types/contact";

export async function loader({ params }: Route.LoaderArgs) {
const contact = await getContact(params.contactId);
return { contact };
}

export default function Contact({ loaderData }: Route.ComponentProps) {
const { contact } = loaderData;

// existing code
}

在组件中获取

1
2
3
4
export default function SidebarLayout({ params }: Route.ComponentProps) {
console.log(params);
return; //...
}
useNavigation

返回当前导航信息,包括导航状态。 导航需要等到 loader 结束时才会渲染页面,因此可以使用导航状态展示 loading 信息。

1
2
3
4
5
6
7
8
9
10
11
12
import { useNavigation } from "react-router";

export default function SidebarLayout() {
const navigation = useNavigation();

return (
<div
id="detail"
className={navigation.state === "loading" ? "loading" : ""}
></div>
);
}

类型安全

React Router 自动为每个路由生成类型文件存放在 +types/<route file>.d.ts.

1
2
3
4
5
6
7
// app/root.tsx
import type { Route } from "./+types/root";

export default function App({ loaderData }: Route.ComponentProps) {
const { contacts } = loaderData;
// ...
}

预渲染静态路由

对于没有内容的页面,希望在加载的时候不展示 loading,而是直接展示内容。

指定哪些页面需要预渲染,他们会在打包阶段,打包为静态文件。

1
2
3
4
5
6
7
//react-router.config.ts
import { type Config } from "@react-router/dev/config";

export default {
ssr: false,
prerender: ["/about"],
} satisfies Config;

抛出错误

在 loader 中抛出错误, 会被 root.tsx 的 ErrorBoundary 捕获

1
2
3
4
5
6
7
export async function clientLoader({ params }: Route.LoaderArgs) {
const contact = await getContact(params.contactId);
if (!contact) {
throw new Response("Not Found", { status: 404 });
}
return { contact };
}

表单提交

默认情况下,表单的提交会触发 history 的修改,因此使用 Form 组件,它会就近拦截表单的提交,并将请求通过 fetch 发送到最近的 action 中处理。

Form 组件上的 action 会作为请求提交的地址,用于匹配路由。

action 的规则是如果路由匹配那么对应的路由文件中必须存在 action 处理函数否则报错,如果没有对应的路由文件(例如写在 layout 文件中),会使用 root 中的 action 处理。

需要注意的是使用 action 不能将 react-router.config.ts 中的 ssr 设置为 false,因为这是服务端的功能。

clientLoader 也需要修改为 loader 从服务端获取数据。

1
2
3
4
5
6
7
// root.tsx

// 如果在路由上没有其他的文件写 action 就会到root的action中处理
export async function action() {
const contact = await createEmptyContact();
return { contact };
}
1
2
3
4
5
6
7
8
9
import { Form } from "react-router";

export default function SidebarLayout() {
return (
<Form method="post">
<button type="submit">New</button>
</Form>
);
}

删除信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 当前文件 app/routes/contact.tsx 对应的路由是 contacts/:contactId
// 因此表但会提交到 contacts/:contactId/destroy
// 需要为此路由创建对应的文件

<Form
action="destroy"
method="post"
onSubmit={(event) => {
const response = confirm("Please confirm you want to delete this record.");
if (!response) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
1
2
3
4
5
6
7
8
9
10
// app/routes/destroy-contact.tsx
import { redirect } from "react-router";
import type { Route } from "./+types/destroy-contact";

import { deleteContact } from "../data";

export async function action({ params }: Route.ActionArgs) {
await deleteContact(params.contactId);
return redirect("/");
}
1
2
3
4
5
// app/routes.ts

export default [
route("contacts/:contactId/destroy", "routes/destroy-contact.tsx"),
] satisfies RouteConfig;

Server Components 基础

SPA 应用渲染流程

简单服务器组件渲染流程

想要使用 RSC 必须要添加 condition 环境变量

1
node --conditions=react-server --watch server/app.js

在 RSC 架构中,组件被分为两类:

  • 服务端组件 (RSC):在服务器运行,直接读数据库,禁止使用 useState 或 useEffect。

  • 客户端组件 (CC):在浏览器运行,可以使用所有 Hook。

在编写服务端组件时,由于 Node.js 环境里依然能引用到完整的 react 包,可能会不小心写下 useState。这时程序不会报错,但会导致逻辑混乱。React 团队通过 exports 条件导出 解决了这个问题: 当 Node.js 开启了 react-server 条件时, import React from ‘react’ 拿到的其实是一个 阉割版的 React,它根本没有 useState 这些导出。一旦你误用,代码在服务器编译阶段就会直接报错,从而保证了 RSC 的纯净性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// React package.json 条件导出定义
{
"exports": {
".": {
"react-server": "./react.react-server.js",
"default": "./index.js"
},
"./package.json": "./package.json",
"./jsx-runtime": {
"react-server": "./jsx-runtime.react-server.js",
"default": "./jsx-runtime.js"
},
"./jsx-dev-runtime": "./jsx-dev-runtime.js"
}
}

创建一个 Server Component

当服务器拦截到客户端页面请求之后, 并不是像 SSR 渲染一样,直接返回渲染后的HTML字符串。而是会通过一个特定的API以流的方式返回给浏览器。

1
2
3
4
5
6
7
8
import { renderToPipeableStream } from 'react-server-dom-esm/server'

app.get('/rsc/:id', async (req, res) => {
const { pipe } = renderToPipeableStream(<App/>);
pipe(res);
})

// transfer-encoding chunked

在服务端 (Server Side) 任务是 序列化 (Serialization)。 它将 React 组件树(JSX)转化成一种特殊的、可流式传输的文本格式,最终返回的流的格式如下:

1
2
3
4
5
6
7
8
9
2:"$Sreact.suspense"
1:{"name":"App","env":"Server","owner":null}
0:D"$1"
4:{"name":"SearchResultsFallback","env":"Server","owner":"$1"}
3:D"$4"
3:[["$","li","0",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","1",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","2",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","3",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","4",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","5",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","6",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","7",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","8",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","9",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","10",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"],["$","li","11",{"children":["$","a",null,{"href":"#","children":[["$","img",null,{"src":"/img/fallback-ship.png","alt":"loading"},"$4"],"... loading"]},"$4"]},"$4"]]
6:{"name":"SearchResults","env":"Server","owner":"$1"}
5:D"$6"
8:{"name":"ShipFallback","env":"Server","owner":"$1"}

通过 RSC Parser 查看被解析后的格式

在客户端 (Client Side),任务是 反序列化与重建 (Reconstruction)。 它通过 createFromFetch 方法接收服务端传来的流,并将其重新“缝合”到浏览器当前的 React 树中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Suspense, createElement as h, startTransition, use } from 'react'
import { createRoot } from 'react-dom/client'
import { createFromFetch } from 'react-server-dom-esm/client'

const initialContentFetchPromise = fetch(`/rsc/something`)
const initialContentPromise = createFromFetch(initialContentFetchPromise)

function Root() {
const content = use(initialContentPromise)
return content
}

startTransition(() => {
createRoot(document.getElementById('root')).render(<Root/>)
})

数据获取

RSC的组件只会在服务端执行,所以它甚至可以直接在组件内部访问数据库,所有的数据获取直接写在组件中,就像是正常的后端代码一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ShipList.tsx (这是一个服务端组件)
import { db } from './db';

// 1. 注意:这是一个异步函数组件
export async function ShipList({ search }: { search: string }) {

// 2. 直接访问数据库!没有 fetch,没有 API 路由,没有加载状态管理
// 这段代码永远不会发送到浏览器,数据库凭证也不会泄露
const ships = await db.ship.findMany({});

return (
<SomeComponent/>
);
}

使用 Suspense

享用 RSC 组件带来的优点,必须要配额使用 Suspense 组件,下面这个代码示例中只有根组件使用了 Suspense

1
2
3
4
5
6
7
8
9
10
11

// 客户端代码,请求服务器接口,获取入口组件
import { createFromFetch } from 'react-server-dom-esm/client'
const initialContentFetchPromise = fetch(`/rsc/${initialLocation}`)
const initialContentPromise = createFromFetch(initialContentFetchPromise)

function Root() {
const content = use(initialContentPromise)
return content
}
createRoot(document.getElementById('root')).render(<Suspense fallback={"loading..."}><Root/></Suspense>)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 入口服务器组件 app.js
export function App({ shipId, search }){
return <div>
<List/>
<Detail/>
<div>
}

export function List(){
const data = await fetch("/data")
return <ul></ul>
}

export function Detail(){
const data = await fetch("/detail")
return <p></p>
}

它的渲染流程是:

  • root.render 触发,React 进入 Root 组件。

  • use(initialContentPromise) 被调用。此时 fetch 刚刚开始,连第一个 RSC 数据块(Chunk)可能还没解析完。

  • initialContentPromise 处于 Pending 状态。use 钩子会直接“抛出(throw)”这个 Promise。

  • React 捕获到异常,立刻中断 Root 的渲染,转而渲染 loading…

  • 服务器传回了第一批数据(比如 App 的外壳)。

  • createFromFetch 解析了这部分数据,initialContentPromise 的状态被更新。React 被“唤醒”,重新尝试渲染 Root。

  • 这次 use 返回了内容(即包含了两个异步服务端组件占位符的 content)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {
    "type": "ul",
    "key": null,
    "props": {
    "children": {
    "_payload": {
    "status": "pending",
    },
    }
    },
    }

    但很快发现:内容里那两个服务端组件因为没用 Suspense 包裹,它们共用了根部的挂起逻辑。如果这两个组件的数据还没到,React 会再次因为它们而挂起,所以继续显示根组件的 loading…,导致 App 外壳必须等到所有子组件加载成功之后才能显示。

所以对于异步组件尽可能的细粒度的控制加载,通过 Suspense 及时渲染已经加载的组件

1
2
3
4
5
6
7
export function App({ shipId, search }){
return <div>
<Suspense fallback={"loading..."}><List/></Suspense>
<Suspense fallback={"loading..."}><Detail/></Suspense>
<Detail/>
<div>
}

组件共享数据

因为 createContext API只能在客户端执行,所以在服务器组件中想要组件共享数据需要使用 nodeJS 中的一个API

1
2
3
4
5
6
7
8
9
10
//list-storage.js
import { AsyncLocalStorage } from 'node:async_hooks'

export const listData = new AsyncLocalStorage()

// 回调函数执行作用域中可以直接访问到保存的storage
listData.run(data, () => {
const { pipe } = renderToPipeableStream(<App/>)
pipe(res)
})
1
2
3
4
5
6
7
8
// List.js 
// 在其他组件中,消费数据
import { listData } from 'list-storage.js'

export default List() {
const data = listData.getStore();
return <ul></ul>
}

Client Component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { EditableText } from './edit-text.js'
export async function ShipDetails() {
const { shipId } = shipDataStorage.getStore()
const ship = await getShip({ shipId })
const shipImgSrc = getImageUrlForShip(ship.id, { size: 200 })
return (
<div>
<EditableText text={ship.name} />
<img src={shipImgSrc} alt={ship.name} />
<h2>{ship.name}</h2>
<p>{ship.description}</p>
</div>
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
'use client'
export function EditableText({ id, shipId, initialValue = '' }) {
const [edit, setEdit] = useState(false)
const [value, setValue] = useState(initialValue)
const inputRef = useRef(null)
const buttonRef = useRef(null)
return (
<div>
{edit ? (
<input ref={inputRef} value={value} onChange={e => setValue(e.target.value)} />
) : (
<span>{value}</span>
)}
<button ref={buttonRef} onClick={() => setEdit(!edit)}>
{edit ? 'Save' : 'Edit'}
</button>
</div>
)
}

当一个服务器组件需要交互的时候,必然会产生一些状态或是函数,如果想要实时验证这些状态,最好的办法是让这些操作发生在客户端而不是服务端,虽然客户端通可以过某种通讯机制与服务端通信,但是服务器是无状态的,这会非常浪费资源。

虽然状态与函数可以序列换,但是 RSC 的设计原则是:服务器组件不应该暴露任何状态或函数给客户端组件。 这就要求我们必须把这些状态和函数放在客户端组件中,可以通过 'use client' 标识一个组件是客户端组件, React 会在将这个组件发送给客户端前,把他处理为可以序列化的代码,并在 RSC Payload 中引用。

b:I[“/edit-text.js”, “EditableText”]
那如何把一个有 use client 标识的组件标记为我们能知道加载路径的状态呢

使用 react-server-dom-esm/node-loader, 需要配合 import { register } from 'node:module register是node中的一个特殊的api,可以在引用组件的文件的时候,和代码被执行之前,提供钩子做一些处理

获取所有的 export 并删除所有的import , 用 import {registerClientReference} from 'react-server-dom-es/server 包装,第一个参数是一个函数, 直接抛出错误,说明不能在服务器调用,因为这个一个 use client 组件,第二个参数是一个字符串,是这个组件在服务器的绝对路径, 第三个参数是组件的名称

registerClientReference 函数会返回一个react 组件说明

1
2
3
4
5
{
"$$type": "Symbol(react.client.reference)",
// 用#标识 组件名称
"id": "/absolute/path/to/edit-text.js#EditableText",
}

在客户端使用的时候,不可以在 客户端组件内直接 import 服务端组件,因为服务端组件可以访问数据库等敏感资源

1
2
3
4
5
6
// 错误写法
'use client'
import { ShipDetails } from './ShipDetails.js'
export function Detail() {
return <ShipDetails/>
}

正确的写法是使用组合的模式

1
2
3
4
'use client'
export function Detail({ShipDetails}) {
return ShipDetails
}

TrueNas 应用配置

MinIO

参考安装文档, 挂载点配置需要开启 ACL, 文件默认保存 export 目录下。

Nginx 配置参考MinIO 配置文档, 需要添加chunked_transfer_encoding off;

NextCloud

  • dataSet 创建
    父文件夹需要有以下权限 user:www-data,group:www-data,user:netdata,group:docker,user:root,group:root,user:apps
    user 文件夹需要有以下权限 user:www-data,group:www-data,user:apps
    data 文件夹需要有以下权限 user:www-data,group:www-data,user:apps
    db 文件夹需要有以下权限 user:netdata,group:docker,user:root,group:root,user:apps

  • APT Packages 需要添加 ffmpeg smbclient

  • host 需要填写真实访问的域名

  • Database Password 注意不要有 #@?& 等特殊符号

  • Certificate ID 选择默认证书,避免 nextCloud 提示警告,如果前端有 nginx 代理,需要使用 https

  • 在 config/config.php 中添加 maintenance_window_start 消除调度任务的警告

  • nginx配置

虚拟机配置

Nas 服务器开机虚拟化功能,配置以下字段,其余字段可以保持默认

Boot Method : UEFI

CPU Mode : Host Passthrough

Select Disk Type : VirtIO
virtIO 是一个优化的虚拟磁盘驱动程序,专为虚拟化环境设计,提供更高的性能和较低的开销。它支持更高的数据传输速率,特别适用于 I/O 密集型的应用场景。

Adapter Type:VirtIO

如果安装后提示 CD-ROM 加载错误,可以删除 CD-ROM 设备后重试

Jellyfin

nginx 代理按照官方文档操作

Babel 源码与插件

调式源码

1
2
3
4
5
6
7
8
git clone https://github.com/<your-github-username>/babel
cd babel
make bootstrap

# 在想要调试文件的入口处添加断点
# -i 指定测试package
# -t 指定测试用例 fixtures
yarn run jest -i packages/babel-parser -t 'es2016/simple parameter list/arrow function'

插件开发最小化环境

相关依赖包

1
2
3
4
5
6
7
{
"babel-plugin-tester": "^11.0.4",
"jest": "^29.7.0",
"ts-jest": "^29.2.6",
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
}

测试文件入口与 fixtures 用例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//__test__/index.ts

import { pluginTester } from "babel-plugin-tester";
import insertLogPlugin from "../plugins/babel-plugin-insert-log";
import path from "path";

pluginTester({
plugin: insertLogPlugin,

babelOptions: {
plugins: ["@babel/plugin-syntax-jsx"],
},
fixtures: path.join(__dirname, "fixtures"),
});
1
2
3
4
5
6
7
// __tests__/fixtures/in-arrow-function/code.ts
const a = () => console.log(1);

// __tests__/fixtures/in-arrow-function/output.ts
const a = () => {
console.log(1);
};

jest 配置文件, 排除 fixtures 目录下的测试用例,避免多次执行

1
2
3
4
5
6
7
8
/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
testEnvironment: "node",
transform: {
"^.+.tsx?$": ["ts-jest", {}],
},
testMatch: ["**/__tests__/**/*", "!**/fixtures/**/*"],
};

babel-parser

使用多个类继承,完善 parser 功能

1
2
3
4
5
6
7
8
export default class Parser extends StatementParser{}
abstract class StatementParser extends ExpressionParser{}
abstract class ExpressionParser extends LValParser{}
abstract class LValParser extends NodeUtils{}
abstract class NodeUtils extends UtilParser{}
abstract class UtilParser extends Tokenizer{}
abstract class Tokenizer extends CommentsParser{}
class CommentsParser extends BaseParser{}

解析 le\\u0074 x = 5 流程:

  • 实例化 Parser,调用 parse 方法开始解析

  • 初始化 file, grogram 节点

  • 尝试解析 token

    • 跳过空白符,注释等

    • 根据第一个字符判断要如何解析, 比如 l 为一个小写字母, 会被当作 Identifier 解析

    • 尝试读取完成的标识符

      this.state.pos += str <= 0xffff ? 1 : 2; 如果字符 charCode 大于 0xffff 例如 , 则向后移动两个字符

      如果匹配到 \, 则判断是否是一个 Unicode 转义序列,后面三位必须是 \\u 开头,如果不是则标记错误

      如果是一个转移字符,尝试读取这个转义字符并返回, 因此第一个 token 会是 let

    • 解析 Program 节点,let 作为 ExpressionStatement 加入 Program 节点,保存在 body 数组中

      继续尝试解析 x = 5 这个表达式作为 AssignmentExpression,x 和 5 作为 Identifier 和 NumericLiteral 加入 AssignmentExpression 分别作为 left 和 right

最终形成的树结构就是 ast.

OpenWrt 安装与设置

下载固件

OpenWrt 官方版本,所有的第三方版本都是基于官方的自定义编译,
immortalwrt 是针对国内用户编译的第三方版本,推荐优先使用。[GitHub]

点击 All Downloads

选择适配的 x86 版本

SquashFS 和 Ext4 是两种不同的文件系统,各自有不同的特点和应用场景:

  • SquashFS 只读的文件系统,经过压缩后可以节省存储空间。默认情况下不支持直接写入,因此需要将可写数据放在单独的 Overlay 文件系统上。
  • Ext4 一种常用的可读写文件系统,支持动态修改。

由于 OpenWrt 并不会将所有硬盘剩余空间作为根路径,因此不同的文件格式对应不同的磁盘组成,在安装后需要对根分区扩容。

  • Ext4: /boot + /根分区(读写) + 剩余未分区磁盘
  • SquashFS: /boot + /rom(只读) + /根分区(读写) + 剩余未分区磁盘 (优先选择此版本,方便迁移和系统恢复)

安装

参考官方安装文档,采用写入镜像文件方式,由于 OpenWrt 没有提供安装引导,所以需要用 微 PE 或 老毛桃[https://www.laomaotao.net/] 等工具制作一个 WinPE 启动盘, 将用到的固件,和固件写入工具保存到启动盘中,当进入 WinPE 系统后是用写入工具写入镜像。

Windows Preinstallation Environment(Windows PE),Windows 预安装环境,是带有有限服务的最小 Win32 子系统,基于以完整 Windows 环境或者保护模式运行的 Windows 3.x 及以上内核。这类系统一般很小。它包括了运行 Windows 安装程序及脚本、连接网络共享、自动化基本过程以及执行硬件验证所需的最小功能。

另外需要下载一个镜像写入工具 Win32 Disk ImagerbalenaEtcher

将镜像写入工具, 解压出的 OpenWrt 镜像(.iso)文件, 复制到制作好的 WinPE 启动 U 盘中。

将 U 盘插入到软路由中(软路由需要接上鼠标,键盘,显示器),开机时入到软路由的 BIOS,将默认的固态硬盘启动改为 U 盘启动。

保存重启后,进入 WinPE 系统,首先使用 WinPE 自带的 DiskGenius 工具,删除软路由硬盘的所有分区并保存。打开写入工具,选择磁盘和镜像并写入。

写入成功后,重启系统再次进入 BIOS,将 U 盘启动修改回硬盘启动,保存并再次重启。

启动成功后,会看见有命令行信息,如果卡住不动,尝试按一下回车键,这样会进入 OpenWrt 系统。

首次登录提示修改密码,执行 passwd 命令,修改 root 账户密码,此密码也是网页登录的密码。

修改网络配置信息,执行命令 vi /etc/config/network,

将 config device 的 list_ports 修改为 eth1
将 interface lan 的 option ipaddr 修改为内网不会冲突的 IP 地址例如 192.168.100.110.10.0.1
将 interface wan 和 interface wan6 的 option device 修改为 eth0

wan 口表示外网,也就是网络运营商的网线,通常更习惯将第一个网口作为插入外网网线,后面插入的都是内网设备,这样更有条理性

保存后重启设备,访问前需要重新连接一下设备:

  • 软路由插入电源并开机
  • 运营商光猫的网线,链接到软路由的 eth0 网口。
  • wifi 路由器需要在管理后台,设置为 AP 有线中继模式,用一个网线将软路由的 eth1 网口,链接到路由器的第一个网口。
  • 其他内网设备可以通过网线连接软路由或路由器,也可以直接通过 wifi 网络链接.

这是虽然不能上网,单已经可以通过内网中其他设备访问 /etc/config/network 中设置的 lan 口的 ipaddr 访问到 OpenWrt 的 UI 界面。

pve 虚拟机安装

上传镜像文件到 pve, 创建虚拟机, 删除虚拟机的默认磁盘

1
2
3
qm importdisk [虚拟机ID] [镜像路径] [磁盘]

qm importdisk 4444 /var/lib/vz/template/iso/immortalwrt-24.10.3-x86-64-generic-squashfs-combined-efi__1_.img storage

虚拟机 => 硬件 添加未启用的磁盘
虚拟机 => 选线 修改刚刚添加的磁盘为第一启动项

wan 口配置

最先的配置一定是软路由可以上网,输入账号密码进入到软路由的 UI 界面,点击菜单 网络 => 接口 点击 wan 口的编辑按钮,协议切换为 PPPoE ,输入账号密码,即可上网。

此方式是通过 PPPoE 拨号上网,需要致电运营商,将网络改为桥接模式,并提供账号密码。

lan 口配置

网络 => 接口 => 设备 选择 br_lan, 在网桥端口中将其他的端口都添加到 br_lan 这个网桥中,方便让局域网中的设备互相访问

磁盘扩容

在菜单 状态 => 概览 中可以看到,磁盘空间只有几百 M, 这是因为 OpenWrt 默认不会将剩余空间作为根节点。

官方 openwrt 参考 官方的扩容操作

immortalwrt 在安装前可以使用以下命令修改镜像的分区大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 将镜像文件扩容
dd if=/dev/zero bs=1M count=98304 >> immortalwrt-24.10.3-x86-64-generic-squashfs-combined-efi__1_.img

# 使用parted扩容文件系统

parted immortalwrt-24.10.3-x86-64-generic-squashfs-combined-efi__1_.img
GNU Parted 3.5
Using /var/lib/vz/template/iso/immortalwrt-24.10.3-x86-64-generic-squashfs-combined-efi__1_.img
Welcome to GNU Parted! Type 'help' to view a list of commands.
(parted) p
Error: The backup GPT table is corrupt, but the primary appears OK, so that will be used.
OK/Cancel? OK
Warning: Not all of the space available to
/var/lib/vz/template/iso/immortalwrt-24.10.3-x86-64-generic-squashfs-combined-efi__1_.img appears to be used, you can fix the GPT
to use all of the space (an extra 30 blocks) or continue with the current setting?
Fix/Ignore? Fix
Model: (file)
Disk /var/lib/vz/template/iso/immortalwrt-24.10.3-x86-64-generic-squashfs-combined-efi__1_.img: 348MB
Sector size (logical/physical): 512B/512B
Partition Table: gpt
Disk Flags:

Number Start End Size File system Name Flags
128 17.4kB 262kB 245kB bios_grub
1 262kB 33.8MB 33.6MB fat16 legacy_boot
2 33.8MB 348MB 315MB

(parted) resizepart 2 100%
(parted) q

immortalwrt 也可以安装后扩容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
opkg update

opkg install fdisk lsblk losetup f2fs-toolsopkg install lsblk fdisk resize2fs losetup blkid f2fs-tools tree

# 查看磁盘信息
# df -h
# fdisk -l

# 替换为自己的磁盘
fdisk /dev/sda

# 查看磁盘
Command (m for help): p

Device Boot Start End Sectors Size Id Type
/dev/sda1 65536 98303 32768 16M 83 Linux
/dev/sda2 131072 745471 614400 300M 83 Linux

# 删除
Command (m for help): d
Partition number (1,2, default 2): 2

Partition 2 has been deleted.

# 新建磁盘 需要注意 sector 起始位置一定要与原有磁盘信息中 start 大小相同
Command (m for help): n
Partition type
p primary (1 primary, 0 extended, 3 free)
e extended (container for logical partitions)
Select (default p): p
Partition number (2-4, default 2): 2
First sector (2048-31268863, default 2048): 131072
Last sector, +/-sectors or +/-size{K,M,G,T,P} (131072-31268863, default 31268863):

Created a new partition 2 of type 'Linux' and of size 14.8 GiB.
Partition #2 contains a squashfs signature.


# 一定要选 n ,不要修改标识符
Do you want to remove the signature? [Y]es/[N]o: n

# 保存
Command (m for help): w
The partition table has been altered.

对文件系统扩容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
losetup
# 记录 offset 的值
# NAME SIZELIMIT OFFSET AUTOCLEAR RO BACK-FILE DIO LOG-SEC
# /dev/loop0 0 6291456 1 0 /mmcblk0p2 0 512

# 使用相同的 offset 创建一个临时的 循环设备
losetup -f -o 6291456 /dev/sda2

# 挂在这个设备以便于记录日志, 如果不挂在会导致不能访问
mount /dev/loop1 /mnt

# 卸载设备后可以执行扩容
umount /dev/loop1

resize.f2fs /dev/loop1

修改分区大小后会导致磁盘 UUID 改变,需要更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

blkid
# /dev/loop0: LABEL="rootfs_data" UUID="b32ce334-3193-428b-9ae3-2a156379ecc8" BLOCK_SIZE="4096" TYPE="f2fs"
# 记录 sda2 的新的 PARTUUID
# /dev/sda2: BLOCK_SIZE="262144" TYPE="squashfs" PARTUUID="ff2d03b0-3d06-4001-bb97-367a0265a03f"

vi /boot/grub/grub.cfg

# 修改对应的 PARTUUID 为最新值
# menuentry "ImmortalWrt" {
# linux /boot/vmlinuz root=PARTUUID=484c7fc2-ae74-a97d-23da-c3b75cf79602 rootwait console=tty1 console=ttyS0,115200n8 noinit
# }
# menuentry "ImmortalWrt (failsafe)" {
# linux /boot/vmlinuz failsafe=true root=PARTUUID=484c7fc2-ae74-a97d-23da-c3b75cf79602 rootwait console=tty1 console=ttyS0,1
# }


# 或者使用以下脚本更新
ROOT_BLK="$(readlink -f /sys/dev/block/"$(awk -e \
'$9=="/dev/root"{print $3}' /proc/self/mountinfo)")"
ROOT_DISK="/dev/$(basename "${ROOT_BLK%/*}")"
ROOT_DEV="/dev/${ROOT_BLK##*/}"
ROOT_UUID="$(partx -g -o UUID "${ROOT_DEV}" "${ROOT_DISK}")"
sed -i -r -e "s|(PARTUUID=)\S+|\1${ROOT_UUID}|g" /boot/grub/grub.cfg

# 最后重启
reboot

软件安装

docker

安装后菜单不显示,尝试退出系统重新登陆

1
2
opkg update
opkg install docker dockerd luci-app-dockerman

将 docker 区域设置为 lan 区域的目标区域,以便于可以在局域网中访问 docker 应用

ttyd

ttyd 是一个在线的命令行工具,安装 luci-i18n-ttyd-zh-cn 中文版本,会显示在系统菜单中,如果不显示重启设备。

passwall

安装 passwall 插件, 如果安装时提示 无法执行 opkg install 命令:SyntaxError: Unexpected end of JSON input 尝试更换其他的软件源再次尝试

导入 x2ray 分享链接后,一定要把节点配置中 [域名] 和 [WebSocket Host] 都设置为分享链接中的域名,并在基本设置中开启主开关,选择节点后才能使用。如果配置正确还是不能使用,重新启动后再次使用。

DDns

由于家庭网络等外界因素,导致 IP 会不定时的修改,如果想要无论何时都能让 绑定的域名解析到当前 IP 上就需要 DDns 服务。

DDns 服务会定时在后台检测当前 IP 是否修改,如果 IP 已经发生变动,DDns 服务会发送请求通知域名服务商,重新绑定域名解析的 IP 地址,从而实现动态域名解析。

在 OpenWrt 软件库中安装 ddns-go 和 luci-i18n-ddns-go-zh-cn 软件包, 可以方便的选择常用的运营商进行配置。

启用 ddns-go 服务并打开 web 界面, 跳转到 腾讯云 DNDSPOD 创建密钥。

IPv4 中选择通过网卡获取 IP, 并添加域名。

在运营商的域名解析页面,添加一条记录,先默认填入软路由的内网地址。

点击保存可以看到域名已经被正确的解析

外网访问内网应用

本设置的目的是使用不同的二级域名访问内网的各个应用,由于 OpenWrt 的防火墙只提供网络层的接口转发,而域名访问属于应用层,需要使用 nginx 作为反向代理工具代理域名请求并指向内网地址.[说明文档]

完整的请求过程是,外网的 https 请求进入防火墙,防火墙放行 => 软路由配置 nginx 监听指定域名和端口的请求 => 转发请求到内网的其他服务器

  • 系统 => 软件包 中安装 luci-nginx , 由于 luci-nginx 默认配置中包括强制将 http 重定向为 https 所以在软件安装后 OpenWrt 需要重新刷新登录。

    如果使用 ssh 工具登录时提示以下错误

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    [user@hostname ~]$ ssh root@pong
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
    @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
    Someone could be eavesdropping on you right now (man-in-the-middle attack)!
    It is also possible that a host key has just been changed.
    The fingerprint for the RSA key sent by the remote host is
    6e:45:f9:a8:af:38:3d:a1:a5:c7:76:1d:02:f8:77:00.
    Please contact your system administrator.
    Add correct host key in /home/hostname /.ssh/known_hosts to get rid of this message.
    Offending RSA key in /var/lib/sss/pubconf/known_hosts:4
    RSA host key for pong has changed and you have requested strict checking.
    Host key verification failed.

    可以使用以下命令

    1
    2
    3
    4
    # 移除 known_hosts 此 ip 的所有记录值
    ssh-keygen -R 192.168.3.10
    # 也可以使用
    rm ~/.ssh/known_hosts

    如果使用的是 ttyd 插件,可以在 ttyd 配置中开启 ssl, 证书的默认路径是

    1
    2
    /etc/nginx/conf.d/_lan.crt
    /etc/nginx/conf.d/_lan.key

    luci-nginx 使用 cui 统一了系统的配置,会通过 /etc/config/nginx 配置文件和模板文件(/etc/nginx/uci.conf.template) 生成 /etc/nginx/uci.conf 文件, uci.conf 文件最终会被 nginx 使用,并且 nginx 每次重启前都会重新生成此文件,因此不要手动更改生成的 /etc/nginx/uci.conf 文件中的内容。

    如果想要保持 http 访问可以修改 /etc/config/nginx 配置文件中的内容,注释掉 _lan 和 _redirect2ssl 添加一个新的配置 http_lan

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    config main global
    option uci_enable 'true'

    #config server '_lan'
    # list listen '443 ssl default_server'
    # list listen '[::]:443 ssl default_server'
    # option server_name '_lan'
    # list include 'restrict_locally'
    # list include 'conf.d/*.locations'
    # option uci_manage_ssl 'self-signed'
    # option ssl_certificate '/etc/nginx/conf.d/_lan.crt'
    # option ssl_certificate_key '/etc/nginx/conf.d/_lan.key'
    # option ssl_session_cache 'shared:SSL:32k'
    # option ssl_session_timeout '64m'
    # option access_log 'off; # logd openwrt'

    #config server '_redirect2ssl'
    # list listen '80'
    # list listen '[::]:80'
    # option server_name '_redirect2ssl'
    # option return '302 https://$host$request_uri'

    config server 'http_lan'
    list listen '80'
    list listen '[::]:80'
    #注意不要添加这个配置规则
    #因为限制了对nginx的访问地址为内网设备,会导致外网访问失效。
    #list include 'restrict_locally'
    list include 'conf.d/*.locations'

    使用命令 service nginx reload 重启 nginx, 这样就恢复了 http 访问

    重启过程中会提示错误, 可以按照提示使用 nginx -T -c ‘/etc/nginx/uci.conf’ 命令测试配置文件

    1
    2
    3
    root@ImmortalWrt:~# service nginx reload
    nginx_init: NOT using conf file!
    show config to be used by: nginx -T -c '/etc/nginx/uci.conf'

    如果文件报错会提示具体错误,并指明所在位置

  • 外网使用域名访问

    目前的网络环境是,外网使用 _.iftrue.me 访问, 内网使用 _.iftrue.me 访问, 因此配置好外网的访问端口后,可以直接把内网的请求转发到外网的监听端口

    首先配置外网域名解析, 在 cloudflare 选中域名 =》 DNS 添加一条 AAAA 解析, Name 填入 * 表示泛域名, Content 地址临时填入一个 192.168.48.1 本地地址

    添加 DDNS GO 解析将域名同步到 cloudflare , 需要添加一个 cloudflare Api token , Overview =》Get your Api token =》create token =》 Edit zone DNS =》选择对应的域名, 在 DDNS GO 中添加 token, 并解析域名

    添加 cloudflare 规则, Rules =》 Overview =》 添加 Origin Rules =》 可以将对域名的请求转发到特定的端口

    创建一个 Origin Certificates 用于 Origin server 和 cloudflare 之间的验证, SSL/TSL => Origin Certificates, 保存 PEM 和 KEY

    修改 uci 模板 /etc/nginx/uci.conf.template,在 http 模块中添加 ssl 通用配置

    1
    2
    3
    4
    5
    6
    7
    ssl_certificate /etc/nginx/conf.d/_lan.crt;
    ssl_certificate_key /etc/nginx/conf.d/_lan.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    将证书传到服务器的执行目录下

    如果使用 scp 命令包错 ash: /usr/libexec/sftp-server: not found, 需要在软件包中安装 sftp-server

    配置一个反向代理的案例,/etc/nginx/conf.d/openwrt.conf

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    server {
    listen 9348 ssl;
    listen [::]:9348 ssl;

    # qb.iftrue.me 用于内网域名的请求
    server_name qb.iftrue.me qb.iftrue.me;

    location / {
    proxy_pass http://192.168.48.189:10095;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    }
    }

    最后需要防火墙放行外网请求,Incoming Ipv6 from Wan to this device ,port 9348

  • 内网使用域名访问

    给 openwrt lan 配置一个额外的 ip 用于转发,将 内网域名全部解析到此 ip, nginx 监听 443 端口,并将所有请求转发到外网的反向代理配置中

    Network =》 DHCP DNS =》 添加地址 /iftrue.me/192.168.48.3 把 *.iftrue.me 解析到 192.168.48.3

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    # 添加一个默认规则,如果所有的都没有匹配走404
    server {
    listen 9348 default_server;
    listen [::]:9348 default_server;
    server_name _;

    return 404;
    }

    server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name *.iftrue.me;

    ssl_certificate /etc/nginx/conf.d/_club.crt;
    ssl_certificate_key /etc/nginx/conf.d/_club.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    location / {
    proxy_pass https://192.168.48.1:9348;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    }
    }

OpenVPN

软件包中安装 luci-app-openvpn-server

  • 协议 udp ipv4
  • 端口 1194
  • 客户端网段: 自动
  • 客户端推送配置
    comp-lzo adaptive
    redirect-gateway def1 bypass-dhcp
    dhcp-option DNS 192.168.48.1 (填写 openWrt 的 IP)
    route 192.168.48.0 255.255.255.0 (填写 openWrt 的 网段,最后一位需要是 0)
  • 下载客户端配置文件,客户端安装 openvpn, 配置存放在 user/openvpn/client.opvn

可以重新生成证书文件,时间比较长,需要等待浏览器加载结束

点击网络接口,重新加载会多出一个 vpn0 的接口

编辑配置文件,允许多个客户端链接

1
2
3
4
vi /etc/config/openvpn

# 添加配置
option duplicate_cn "1"

配置 NAT 转发规则,允许访问内网设备,网络 => 防火墙 => NAT 规则

openWrt 23.05.4 以及以后版本不需要配置

1
2
# 使用 POSTROUTING 进行规则转发
iptables -t nat -A POSTROUTING -o br-lan -j MASQUERADE

HomeProxy 自定义路由

  • 创建路由节点,可以定义流量使用哪个 vpn 代理,一定要先设置,因为默认出站会用到这些节点。
    节点的出站选择直接出站

  • 创建 DNS 服务

    标签: Google

    类型: HTTPS

    地址: dns.google

    路径: /dns-query

    端口: 443

    出站: 选择路由节点中最稳定的节点(BWG)

  • DNS 设置

    默认策略: 仅 IPV4

    默认 DNS 服务器: Google

    缓存: 关闭

    EDNS 客户端子网: (202.101.172.36 浙江) 选择一个距离当前宽带运营商最近的一个 IP 地址,这可以让 Google DNS 查询的时候选择一个离运营商最近的服务器

  • 路由设置

    路由模式: 自定义路由

    绕过国内流量: 开启,可以在防火墙就直接转发国内流量,性能更好

    复写目标地址: 一般用于视频网站解锁,Google DNS 获取到了目标 IP,sing-box 通过嗅探获取到了 netflix 域名,发送的目标是代理服务器,发送数据中的内容为请求 netflix 的 IP 以及 netflix 网站的域名,当代理受到这个请求后发现当前服务器不支持解锁,导致访问失败
    开启复写后,发送数据中的目标地址变为 netflix 域名,当发送到代理服务器后会在代理服务器的环境中做 DNS 解析, 它获取到的不是真实 IP, 而是专门的反代服务器 IP 用于解锁。

    默认出站: 选择路由节点中最稳定的节点(BWG)

    默认 DNS 出站: Google

  • 规则集

    https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo-lite/geoip/cn.srs 国内 ip
    https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo-lite/geosite/cn.srs 国内网址
    https://raw.githubusercontent.com/xmdhs/sing-box-ruleset/rule-set/AdGuardSDNSFilterSingBox.srs 广告网址

    出站选择默认,这会使用路由设置中的默认出站用于访问这些资源。

  • 路由规则

    直连,选择国内的两个规则集
    内网设备,可以通过 IP 来指定一个路由规则,以便于在出站时使用其他的代理 vpn

  • DNS 规则

    Block,选择广告的资源集,在 DNS 解析的时候就禁止访问
    直连,选择国内的域名规则集,DNS 服务器选择 WAN 自动下发的 DNS 服务,这样当请求一个国内的域名时,可以直接用运营商的 DNS 解析

WireGuard

安装 luci-proto-wireguard qrencode

Netword =》 Interfaces =》新建协议 Wireguard Vpn

通用设置 =》 生成新的密钥对 =》 添加监听端口 =》Ip 地址选择一个与内网不冲突的网段 10.0.6.0

防火墙设置 =》 加入 lan 区域

对端设置 =》 创建新的密钥对 =》ip 地址选择一个网段内的 ip =》生成二维码 =》修改链接地址为配置了 DDNS 的域名 =》扫描二维码或复制配置添加

  • Copyrights © 2015-2026 SunZhiqi

此时无声胜有声!

支付宝
微信