---
url: /article/0be86xd9/index.md
---
## 前言

闲言少叙，总而言之就是又折腾了 R2S，把之前用的原本 ImmortalWrt 换成了 YAOF 的 OpenWrt 系统，原因是集成的插件多，好用省心。按照惯例，记录下本次折腾过程中碰到的麻烦事。

## 1. 固件刷入

这里选择的固件版本是[23.05.5-sfs](https://github.com/QiuSimons/YAOF/releases/tag/23.05.5)，因为这是最后一个支持 docker 的版本。为什么选择这个，自己动手安装不香吗？不香，有点麻烦，因为这个固件用的源中的软件和 ImmortalWrt 用的相比有挺多没有的。当然可以替换源，或者动手去网上找各种`luci-*-all.ipk`的插件上传安装，但是说了麻烦，做减法比做加法容易，既然有集成好的用就是了，一味的追求系统版本没意义，只要用起来稳定，让人省心，那就是好的。

固件刷写注意事项：

* 刷入前：进行扩容，保证给软件安装预留充足空间，不用太多，增加 1G 足以。[扩容方式](/article/tgd9dxlo/#_2-1-安装前扩容-推荐)；
* 刷入中：跟以前一样；
* 刷入后：启动进入系统后，不要启动 docker，磁盘分个区给 docker，这里我分了6G 空间足够用了。然后挂载分区到`/opt/docker`；

未提及的操作照旧即可。

顺便提一下这个固件内置的软件源：

```text :no-line-numbers
src/gz openwrt_core https://mirrors.pku.edu.cn/openwrt/releases/23.05.5/targets/rockchip/armv8/packages
src/gz openwrt_base https://mirrors.pku.edu.cn/openwrt/releases/23.05.5/packages/aarch64_generic/base
src/gz openwrt_luci https://mirrors.pku.edu.cn/openwrt/releases/23.05.5/packages/aarch64_generic/luci
src/gz openwrt_packages https://mirrors.pku.edu.cn/openwrt/releases/23.05.5/packages/aarch64_generic/packages
src/gz openwrt_routing https://mirrors.pku.edu.cn/openwrt/releases/23.05.5/packages/aarch64_generic/routing
src/gz openwrt_telephony https://mirrors.pku.edu.cn/openwrt/releases/23.05.5/packages/aarch64_generic/telephony
```

如果要替换为 ImmortalWrt 的软件源，只需要按照上述格式，把地址换成`https://downloads.immortalwrt.org/`。但是换源之后可能会有意想不到的问题，比如原固件的插件使用的依赖在原来的源中找得到，但是在新源可能找不到或者版本不可用。操作有风险，换源需谨慎。

**Tip**：sfs 的固件能够重置，当系统无法正常启动时，不要立马刷机，先尝试断电后插拔 TF 卡，然后重新上电长按`reset`键，之后观察`sys`指示灯的变化。当红灯快速闪烁表示正在重置中，慢闪表示系统正常启动中，长亮表示系统正常运行中。重置固件后虽然需要重新设置系统，但是 TF 卡上存储的文件不会丢失。

## 2. 安装插件

### 2.1 Tailscale

`Tailscale`的作用就不必多说了，内网穿透神器。与之齐名的还有`Zerotier`，为啥不用这个？因为这个设置起来费老鼻子劲了，很折腾，而且免费计划只支持最多3个网络➕10个设备，按说差不多够用了，但是感觉捉襟见肘。但`Tailscale`的安装就非常方便，并且免费计划支持3个用户➕100个设备，大气多了，用不完，根本用不完😄。

在安装插件前，先到软件包中安装`iptables-nft`，这个是必要的依赖。之后上[luci-app-tailscale](https://github.com/asvow/luci-app-tailscale/releases)下载最新版就完事，记得下载的是`luci-app-tailscale_*_all.ipk`，然后上传安装即可，启动后会自动设置好上网接口以及防火墙。入口在 OpenWrt 系统的`服务`下面。

`Tailscale`的使用也很简单，登录了相同账号的设备就能互相访问。这里有个关键的地方，当安装了`Tailscale`的软路由为主路由模式时，在 OpenWrt 的 Tailscale 的`高级设置`下把**暴露子网**配置好，这样内网中的所有设备即使没有安装`Tailscale`也可以被访问到，在外网的设备只需要打开`Tailscale`就能像在内网中一样访问。

**注意**：每个设备登录后是有时效的，默认有效期 3 个月，可以在[控制台](https://login.tailscale.com/admin/settings/device-management)调整。如果你想不失效，就在[控制台](https://login.tailscale.com/admin/machines)选中设备后点击`Disable key expiry`。

再就是，当你想要他人加入你的`tailnet`，有三种选择：

1. 登录到 Tailscale 的[控制台](https://login.tailscale.com/admin/settings/keys)配置一个`Auth keys`，他人使用你这个 Auth keys 登录，即可将他的设备加入到你的`tailnet`中。优点是，不用泄露你的密码；缺点是，Auth keys 最大有效期为 90 天。
2. 他人正常登录其账号，你[邀请](https://login.tailscale.com/admin/users)对方的账号到你的`tailnet`中。邀请成功后，需要对方重新登录其账号，此时就能看到有若干选项，要求其选择一个`tailnet`加入，那么选择你的即可。优点是，无有有效期限制；缺点是，占用一个用户的名额。
3. 他人正常登录其账号，你在 Tailscale 的[控制台](https://login.tailscale.com/admin/machines)选择一个设备分享给对方，对方接受要求后，你的设备就出现在了对方的`tailnet`中。

上面三种方式的不同点：

* 第 1 种，是他的设备加入到你的网络中，会占用你的设备限额，默认可以访问你账号下的所有设备，包括暴露出的子网，你也可以访问他的设备，可以编写`ACL`控制权限。用的是你的账号；
* 第 2 种，是他的设备加入到你的网络中，会占用你的设备限额，并且会占用你的用户限额，默认可以访问你账号下的所有设备，包括暴露出的子网，你也可以访问他的设备，可以编写`ACL`控制权限。用的是他的账号；
* 第 3 种，是你的设备加入他的网络中，会占用他的设备限额，他只能访问你分享的这个设备，即使此设备下有子网他也不能访问，但你分享的这个设备并不能访问他的网络，由于他并不在你的网络中，所以你无需也不能编写`ACL`控制权限。用的是他账号；

熟悉以上区别，选择合适的分享策略。

**注意**：Tailscale 通过 IPv6 直连速度就比较快，能保证正常使用，但如果以 IPv4 连接，若当前没有公网且网络 NAT 层数过多，则无法直连，就会走中转服务器，你懂的，那就非常慢了，慢到无法使用。\
所以，一种方式就是家庭内网拨号时获取 IPv6 地址，运营商一般会下发，如果没有那就没得玩，而手机通过蜂窝数据上网一般也都是有 IPv6 的，两端都是 IPv6 就可以直连；另一种方式就是自建 Derp 中继服务器，但那就需要有一台具有公网 IP 的服务器，一般是买云服务器，要钱的方案不在白嫖党的选择范围。

### 2.2 Navidrome

[官网](https://www.navidrome.org/)，这是一个可以提供音乐分享的服务，我们可以在软路由上运行它来搭建自己的音乐私服，配合内网穿透后就可以无限制听音乐了。\
**注意**：这仅仅是一个可以提供音乐分享的服务，它本身是没有任何音乐的，所有的音乐内容还是得自己去搞定，一劳永逸。

下载插件[luci-app-navidrome](https://github.com/tty228/luci-app-navidrome/releases)并上传安装，入口在 OpenWrt 系统的`服务`下面。\
安装完成后先不要急着启动，去`磁盘管理`分一个区用来存放音乐。分多大看个人情况，我没有其他需求，把剩余所有空间都分出来了。分区完后进行挂载，挂载路径随意，我这里是`/opt/navidrome`。\
在启动插件之前，设置好插件的各种目录，并确保这些目录都存在，否则启动会失败，之后正常启动浏览器访问操作即可。

### 2.3 Samba

Samba 协议可以用来在设备之间共享文件，Windows 和 Linux 系统都支持，不过需要做一些设置才能启用。

:::: card title="Linux" icon="simple-icons:linux"
::: steps

1. 安装 Samba

   在软件包中搜索下载`shadow-useradd` \[+useradd]、`luci-app-samba4` \[+samba]和`luci-i18n-samba4-zh-cn` \[+i18n-samba]，若已安装则忽略。安装完成后，入口在`服务`->`网络共享`。
   \[+useradd]: Linux下创建用户的工具
   \[+samba]: 基于Luci的samba服务器
   \[+i18n-samba]: luci-app-samba4的简体中文包

2. 创建 Samba 用户

   ```bash :no-line-numbers
   useradd samba # 添加名为 samba 的用户
   smbpasswd -a samba # 为用户 samba 创建 smb 服务的密码
   mkdir -p /opt/navidrome/music # 创建一个目录用于共享（已存在则忽略）
   # 使用户 samba 获得目录权限。这一步很重要，否则当使用 Samba 客户端连接时会提示：没有权限。
   chown -R samba:samba /opt/navidrome/music # 或者执行 chmod -R 777 /opt/navidrome/music

   # 如果创建的 samba 用户不想用了
   smbpasswd -x samba # 删除用户 samba 的密码
   userdel samba # 删除用户

   # 相关命令
   service samba4 start # 启动服务
   service samba4 stop # 停止服务
   service samba4 restart # 重启服务
   service samba4 status # 服务状态
   testparm -v # 配置文件检查
   ```

3. 设置共享

   在`服务`->`网络共享`的`常规设置`下，**接口**选择`lan`；**允许旧协议**可以选择性勾选，默认不用。\
   在`共享目录`下需要设置的选项：

   |名称|路径|可浏览|允许用户|创建权限掩码|目录权限掩码|
   |--|--|--|--|--|--|
   |music|/opt/navidrome/music|✔|samba|0666|0777|

   `保存并应用`，然后用命令重启 Samba 服务。

   **注意**：当使用 ios 或 macos 进行 samba 交互时，会导致上传使用，此时需要勾选`启用 macOS 兼容共享`就能正常上传了，但又会引发其他可能的问题，如在快捷指令中列出 samba 服务上的目录，就无数据返回，对此，我的解决方法是用下面 Alist 提供的接口请求即可。

:::
::::

:::: card title="Windows" icon="simple-icons:windows"

打开`控制面板`，在`程序和功能`->`启用或关闭 Windows 功能`中，勾选`SMB 1.0/CIFS 文件共享支持`和`SMB 直通`。\
启用完成后，按`Win+E`打开文件资源管理器，在地址栏输入`\\192.168.0.1`双击`music`目录后输入前面创建的账户/密码即可。

::::

如此一来，就可能很方便的上传音乐了，搭配内网穿透，可以随时随地的操作。

### 2.4 AList

可以使用 AList 将 smb 转接为 webdav 服务，这是最简单、方便且功能强大的方式。在软件包下搜索`luci-i18n-alist-zh-cn`安装即可。之后的使用方式这里不多说，参考[文档](https://alistgo.com/zh/guide/)即可。\
值得注意的是，AList 完全可以用过 api 的方式来操作，通过 http 的方式来请求，但默认情况下`guest`用户是禁用的，这就导致我们所有的 api 操作都需要鉴权。有的时候仅仅是查看目录，此时可以将`guest`用户启用即可，即使不给于任何权限，也是可以查看的。

### 2.5 SVN

有时候需要一个文件版本控制系统，即方便在多台主机上管理文件，也方便文件回溯。Git 协议的服务对于 R2S 来说可能比较占资源，不过 SVN 是非常轻量的。\
在`系统`->`软件包`中搜索`subversion`，可以看到三个包：`subversion-client`、`subversion-libs`、`subversion-server`。其中 libs 包是其他两个的依赖包，如果只是作为服务端使用，安装`subversion-server`即可，会自动安装`subversion-libs`依赖包。安装完成后安装如下步骤来初始化：

:::: steps

1. 创建 SVN 仓库

   ```bash :no-line-numbers
   mkdir -p /srv/svn
   cd /srv/svn
   # 创建一个名为 repos 的仓库
   svnadmin create repos
   ```

   执行完后，目录结构类似：

   ::: file-tree icon="colored"

   * repos
     * conf # 配置文件(如 passwd、authz)
       * authz # 用户权限
       * passwd # 账号密码
       * svnserve.conf # 服务器配置
     * db/ # 版本历史、文件内容、元数据都在这里！
     * format/
     * hooks/ # 钩子脚本(如 post-commit)
     * locks/ # 锁文件
     * README.txt
       :::

2. 配置 SVN 用户和权限

   修改`repos/conf/svnserve.conf`启用身份验证：

   ```ini title="svnserve.conf" :no-line-numbers
   [general]
   anon-access = none
   auth-access = write
   password-db = passwd
   authz-db = authz
   realm = R2S Repos
   ```

   修改`repos/conf/passwd`，添加用户名密码：

   ```ini title="passwd" :no-line-numbers
   [users]
   admin = admin
   b560m = 123456
   winmi = 123456
   ```

   修改`repos/conf/authz`，配置权限：

   ```ini title="authz" :no-line-numbers
   [groups]
   # 定义了两个组(名称随意)，为组赋予了成员
   admin = admin
   dev = b560m,winmi

   [/]
   # * 表示匿名，* = r 表示匿名可以读(r)到 / 下的仓库
   # 如果不想匿名看到所有就 * =  ，不给任何权限
   * = r
   @admin = rw

   # 因为仓库为 repos，所以一定要是 repos:<子路径>
   [repos:/sub-store]
   @dev = rw

   [repos:/proxy-server]
   @dev = rw

   [repos:/assist-tool]
   @dev = rw
   ```

   通过这种配置，可以在启动一个 svn 服务的情况下，管理多个子目录，每个子目录即表示一个代码库。

3. 重启 SVN 服务

   修改`/etc/config/subversion`的内容：

   ```ini title="subversion" :no-line-numbers
   config subversion
        option path     '/srv/svn'
        option port     '3690'
   ```

   注意，服务启动的路径在`/srv/svn`，实际创建的仓库应该在其子目录下，然后重启 SVN 服务`service subversion restart`。

4. 客户端连接

   注意，直接在服务器上的仓库下创建子目录是无效的，那不会被 svn 管理，正确的做法是由管理员先拉取整个仓库到本地，然后在本地创建好目录结构：

   ::: file-tree icon="colored"

   * svn-repos
     * assist-tool
       * trunk/ # 主干
       * braches/ # 分支
       * tags/ # 版本快照
     * proxy-server/
       * trunk/ # 主干
       * braches/ # 分支
       * tags/ # 版本快照
     * sub-store/
       * trunk/ # 主干
       * braches/ # 分支
       * tags/ # 版本快照
         :::

   ```bash :no-line-numbers
   # 管理员拉取整个仓库
   svn checkout svn://192.168.0.1/repos --username admin -- password admin
   # 开发者拉取子目录
   svn checkout svn://192.168.0.1/repos/sub-store --username winmi -- password 123456
   ```

   之后可以`add/commit/update/log`等常规操作了。

**注意**：上传到 SVN 的文件都在`db`目录中，但不是原样存储，而是被压缩、编码、分块成数据库形式。如你想导出完整版本库的内容为一个普通文件夹，可以用：

```bash :no-line-numbers
svn export svn://192.168.0.1/repos
# 或者使用 dump，可以导出整个仓库，包含提交记录。用于崩溃恢复或服务器迁移
svnadmin dump /srv/svn/repos > ~/svn-repos.dump
# 然后在恢复的服务器上创建仓库
svnadmin create repos
# 接着恢复 dump
svnadmin load /srv/svn/repos < ~/svn-repos.dump
```

SVN 与 Git 不同点：

* svn 不像 git 那样通过一个`.gitignore`文件就可以忽略多个、多级的文件/目录，svn 只能手动设置忽略单个文件/目录，每次忽略其实是设置目录的属性，因此设置忽略的操作也是要提交的。
* svn 不像 git 在本地删除了跟踪的文件后提交即可同步删除远程文件，svn 必须要使用`svn delete`命令删除文件然后提交才可以。

当使用 svn 管理时，删除了多个文件如何批量提交呢？有两种方式：

**方法一**：使用如下命令：

```bash
svn status | grep '^!' | sed 's/^! *//' | while IFS= read -r file; do svn delete "$file"; done
svn commit -m "批量删除被本地手动删除的文件"
```

**方法二**：创建脚本`sync-svn-delete.sh`以及 VSCode 的 task 文件`.vscode/tasks.json`实现点击运行。

```bash title="sync-svn-delete.sh" :no-line-numbers
#!/bin/bash
# 自动同步本地被手动删除的文件到 SVN 仓库

cd trunk

echo "📁 正在扫描本地被删除的文件..."

deleted_files=$(svn status | grep '^!' | sed 's/^! *//')

if [ -z "$deleted_files" ]; then
    echo "✅ 没有检测到需要同步删除的文件。"
    exit 0
fi

echo "$deleted_files" | while IFS= read -r file; do
    echo "➖ 删除：$file"
    svn delete "$file"
done

echo "🚀 提交到 SVN..."
svn commit -m "同步删除本地手动删除的文件"
```

```json title="tasjs.json" :no-line-numbers
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "同步 SVN 删除文件",
            "type": "shell",
            "command": "${workspaceFolder}/.vscode/sync-svn-delete.sh",
            "problemMatcher": [],
            "options": {
                "shell": {
                    "executable": "C:\\Program Files\\Git\\bin\\bash.exe"
                }
            },
            "presentation": {
                "reveal": "always"
            },
            "group": {
                "kind": "build",
                "isDefault": true
            }
        }
    ]
}
```

::::

当使用图形化客户端，比如 TortoiseSVN 检出时，默认会缓存首次输入的用户和密码，如果想要切换用户则需要删除`C:\Users\<用户名>\AppData\Roaming\Subversion\auth`(Windows)或`~/.subversion/auth`(Linux/MacOS)，在下次操作 SVN 时就会要求输入用户/密码。

如果是用命令行操作可以：

```bash :no-line-numbers
svn checkout svn://192.168.0.1/repos --username admin --password admin --no-auth-cache --non-interactive --quiet
```

`-no-auth-cache`表示不缓存密码，`--non-interactive`避免交互式输入密码，适合脚本，`--quiet`表示静默的不输出详情。

为了维护仓库代码的安全，应该有定时任务每天自动导出整个仓库代码，脚本如下：

::: details 点击查看代码

```py
# -*- coding: utf-8 -*-
import sys
from pathlib import Path
import shutil
import subprocess
from datetime import datetime
import os
import re
import zipfile

TMP = '/tmp/svn-bak' # 临时文件目录
BAK = '/srv/share/代码库/svn' # 备份文件目录
LIMIT = 5  # 最大备份数量

def init():
    # 删除临时目录(避免 checkout 失败)
    if Path(TMP).exists():
        shutil.rmtree(TMP)

    # 创建目录，包括中间目录
    Path(TMP).mkdir(parents=True, exist_ok=True)
    Path(BAK).mkdir(parents=True, exist_ok=True)


def zip(source_dir, zip_path):
    source_dir = os.path.abspath(source_dir)  # 确保是绝对路径
    with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
        for root, _, files in os.walk(source_dir):
            for file in files:
                abs_file_path = os.path.join(root, file)
                rel_path = os.path.relpath(abs_file_path, os.path.dirname(source_dir))
                zipf.write(abs_file_path, arcname=rel_path)

def main():
    print(f"时间：{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}|svn repos 仓库备份", file=sys.stderr)
    cmd = [
        "svn", "export", "svn://127.0.0.1/repos", f"{TMP}/repos",
        "--username", "admin",
        "--password", "admin",
        "--non-interactive",
        "--no-auth-cache",
        "--quiet"
    ]
    result = subprocess.run(cmd,capture_output=True,text=True)
    if result.returncode != 0:
        print('checkout failed: ' + result.stderr, file=sys.stderr)
        sys.exit(-1)
    # 压缩备份
    zip(f"{TMP}/repos",f"{BAK}/reposbak-{datetime.now().strftime('%Y%m%d%H%M%S')}.zip")
    pattern = re.compile(r"reposbak-\d{14}\.zip")
    files = [f for f in os.listdir(BAK) if pattern.fullmatch(f)]
    # 如果压缩包数量超过限制
    if len(files) > LIMIT:
        # 保留最近的时间
        files.sort(reverse=True)
        # 其余全部删除
        for f in files[LIMIT:]:
            os.remove(os.path.join(BAK, f))
    # 删掉临时目录(因为不需要了)
    shutil.rmtree(TMP)

# 0 2 * * * /usr/bin/python /usr/local/bin/svnrepo-backup.py >> /var/log/svnrepo-backup.log 2>&1
if __name__ == '__main__':
    init()
    main()
```

:::

***

::: note 本文参考

* [安装Samba](https://doc.embedfire.com/openwrt/user_manal/zh/latest/User_Manual/openwrt/samba.html#samba)
* [设置OpenWRT的samba共享](https://skyao.io/learning-openwrt/docs/share/samba/)
* [OpenWrt局域网文件共享软件之samba4](https://iyzm.net/openwrt/105.html)

:::
