Debian家庭NAS解决方案(内网穿透/DDNS)

内网穿透 - 云端 #

笔者采用 Debian bookworm 镜像

apt update && apt install -y ca-certificates curl tree tmux python-is-python3

fastfetch(替代 neofetch 和 screenfetch)

wget -q https://github.com/fastfetch-cli/fastfetch/releases/download/2.46.0/fastfetch-linux-amd64.deb -O /tmp/fastfetch.deb && apt install -y /tmp/fastfetch.deb

Debian 13 更新: fastfetch 已被加入官方源

docker 和 lazydocker(视情况安装) #

curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null && apt update
apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
wget -qO- https://github.com/jesseduffield/lazydocker/releases/download/v0.24.1/lazydocker_0.24.1_Linux_x86_64.tar.gz | tar -zxO lazydocker > /usr/local/bin/lazydocker && chmod a+x /usr/local/bin/lazydocker

境内云需要在 /etc/docker/daemon.json 配置加速源

配置以 阿里云提供的个人加速源 为例

frps #

wget -qO- /tmp/frp.tar.gz https://github.com/fatedier/frp/releases/download/v0.62.1/frp_0.62.1_linux_amd64.tar.gz | tar -zxC /tmp && \
install /tmp/frp*/frps /usr/local/bin
useradd -r -d /var/frp -s /usr/sbin/nologin frp
mkdir -p /etc/frp && mkdir -p /var/log/frp && chown frp: /var/log/frp
tee /etc/frp/frps.toml <<-EOF
auth.token = "$(openssl rand -hex 16)"
bindPort = 7000
log.to = "/var/log/frp/frps.log"
log.level = "info"
log.maxDays = 5
EOF

/etc/systemd/system/frps.service

[Unit]
Description=Server of frp: A fast reverse proxy to help you expose a local server behind a NAT or firewall to the internet.
After = network.target syslog.target
Wants = network.target

[Service]
ExecStart=/usr/local/bin/frps -c /etc/frp/frps.toml
Restart=on-failure
User=frp
Group=frp

[Install]
WantedBy=multi-user.target

测试连通性(需要 nmap) sudo nping --tcp -p 7000 1.2.3.4

fail2ban SSH #

apt update && apt install -y rsyslog fail2ban

/etc/fail2ban/jail.local

[sshd]
port     = ssh
logpath  = %(sshd_log)s
backend  = %(sshd_backend)s
enabled  = true
maxretry = 3
findtime = 10m
bantime  = 24h

fail2ban frp #

先添加过滤日志的 filter

/etc/fail2ban/filter.d/frps-app.conf

[Definition]
failregex = ^.+get a user connection \[<HOST>:[0-9]+\]$
ignoreregex =

测试 filter fail2ban-regex /var/log/frp/frps.log frps-app --print-all-matched

jail 直接封禁短期内大量的请求防止爆破,在 jail.local 里加上

[frps-app]
enabled  = true
filter   = frps-app
logpath  = /var/log/frp/frps.log
maxretry = 100
findtime = 10m
bantime  = 24h

测试 jail fail2ban-server -t

爆破来测试是否被封 nmap -sV -vvv -p[port] [ip]

查看封禁情况 fail2ban-client status frps-app

解封 fail2ban-client unban [ip]

备注:如果要给 frps 自身上保护可以加上这个 filter,并给 7000 上 jail

^.+client login info: ip \[<HOST>:[0-9]+\].+$

但问题在于 frps 的日志本身不是很详细,如果不是合法的 frp 连接,日志中将不会显示(就算开了最高级别日志,也不会记录 IP),因此给 frps 自身上 jail 作用不大,只能防止别人爆破 token,不能防止别人扫描

NAS #

将用户加入 sudo 组 gpasswd -a user sudo

锁定 root 的密码 sudo passwd -l root

安装软件包

sudo apt update && sudo apt install -y ssh git rsyslog vim net-tools tmux curl tree python-is-python3 nginx nmap zip

NetworkManager #

在没有显示器的情况下无干扰自动配置 WIFI

sudo apt install -y network-manager wpasupplicant wireless-tools

接管 ifupdown

/etc/NetworkManager/NetworkManager.conf

[main]
plugins=ifupdown,keyfile

[ifupdown]
managed=true

接管 networking service

sudo systemctl disable --now networking.service

NetworkManager 不会管理在 /etc/network/interfaces 中配置的网卡,需要只留一个本地环回

/etc/network/interfaces

source /etc/network/interfaces.d/*

auto lo
iface lo inet loopback

解决 GitHub 下载问题 #

/etc/hosts 加入 CNAME 解析到镜像即可,例如

raw.githubusercontent.com       raw.gitmirror.com

https://www.7ed.net/gitmirror/hub.html

frpc(SSH 内网穿透为例) #

wget -qO- /tmp/frp.tar.gz https://github.com/fatedier/frp/releases/download/v0.62.1/frp_0.62.1_linux_amd64.tar.gz | tar -zxC /tmp && \
cd /tmp/frp* && sudo install frpc /usr/local/bin

/etc/frp/frpc.toml

auth.token = "x"
serverAddr = "example.com"
serverPort = 7000
log.to = "/var/log/frp/frpc.log"
log.level = "info"
log.maxDays = 5

[[proxies]]
name = "nas-sshd"
type = "tcp"
localIP = "127.0.0.1"
localPort = 22
remotePort = 10000
transport.useCompression = true

创建服务,使 NAS 来电自启后能够自动建立穿透

useradd -r -d /var/frp -s /usr/sbin/nologin frp
mkdir -p /etc/frp && mkdir -p /var/log/frp && chown frp: /var/log/frp

/usr/local/lib/systemd/system/frpc.service

需要 nss-lookup.target 而不只是 network.target

[Unit]
Description=Client of frp: A fast reverse proxy to help you expose a local server behind a NAT or firewall to the internet.
After = network.target nss-lookup.target

[Service]
User=frp
Group=frp
ExecStart=/usr/local/bin/frpc -c /etc/frp/frpc.toml
Restart=on-failure

[Install]
WantedBy=multi-user.target

可能 会需要让 SSH 只运作在 v4 上 (ListenAddress 0.0.0.0) 来防止暴露在 v6 公网

一些权限问题

  • 家目录 700
  • sftp 目录 770,然后将想要访问 sftp 的用户加支援组 gpasswd -a foobar sftp_user

文件传输 / 同步 #

rsync / sftp 可以走 ssh 内网穿透

sudo apt install rsync

useradd -s /bin/false -m -d /home/sftp_user sftp_user

ssh-keygen -t ed25519 -C "foo@example.com"

配置 /etc/ssh/sshd_config 的全局 PasswordAuthentication 为 no,让管理用户通过私钥连接,sftp_user 可以单独配置密码认证,方便移动端连接

Match User sftp_user
    AuthorizedKeysFile .ssh/authorized_keys
    PasswordAuthentication yes
    ForceCommand internal-sftp
    ChrootDirectory /mnt
    PermitTunnel no
    AllowAgentForwarding no
    AllowTcpForwarding no
    X11Forwarding no

添加 ftp 文件夹

chown root:root /mnt
chmod 755 /mnt
mkdir -p /mnt/ftp
chown sftp_user:sftp_user /mnt/ftp
chmod 755 /mnt/ftp

添加公钥,然后修改权限

mkdir -p /home/sftp_user/.ssh
chown sftp_user:sftp_user /home/sftp_user/.ssh
chmod 700 /home/sftp_user/.ssh
chown sftp_user:sftp_user /home/sftp_user/.ssh/authorized_keys
chmod 600 /home/sftp_user/.ssh/authorized_keys

设备温度检测 #

sudo apt install lm-sensors

sudo sensors-detect --auto 来生成配置文件,重启即可

硬盘信息检测 #

sudo apt install smartmontools

sudo smartctl --all 即可检测硬盘信息,如启动时长、启停次数和硬盘温度

家庭影院 #

sudo apt install minidlna

编辑 /etc/minidlna.confmedia_dir 改为存放影视的路径即可

除了专用的投屏软件如贝当、乐播外,现在常见视频软件如爱优腾都自带 DLNA 支持(在笔者的电视上这些软件会定期广播 SSDP NOTIFY,频率堪称疯狂)

电视推流还需在手机上安装 BubbleUPnP 将手机作为 DLNA 控制器,然后选择电视上的视频软件作为 DLNA 渲染器

贴一个 ffmpeg 提取内嵌字幕的办法,可用于电视投屏

先查看所有的字幕 (subtitle) 流 ffprobe -select_streams s -show_streams input.mkv

选定流编号来导出,srt 模式为例 ffmpeg -i input.mkv -vn -an -codec:s:0 srt output.srt

主机可以直接用 VLC,选择 View - Playlist - UPnP 即可

另外贴一个笔者遇到的 bug:

VLC 的 UPnP 主动扫描模块只会扫描多个网卡中的一个,有时会被 docker0 干扰

https://code.videolan.org/videolan/vlc/-/issues/24992

目前还没有看到官方的修复

除了主动扫描外,其实也可以等待 miniDLNA 的周期 NOTIFY(通过配置文件的 notify_interval 配置)

然而 Linux 默认不会将网卡加到 UPnP 多播组,只能手动加入 sudo ip addr add 239.255.255.250 dev xxxxxx autojoin

https://gist.github.com/PhilipSchmid/2597c23c68f21d938779aa92683d30b2

防火墙的配置:被动扫描是多播出站,然后服务器单播入站回来,等待 NOTIFY 则需要监听多播入站

UPS 断电通知 #

WIP

等买了 UPS 再更吧,基本不停电,也不是很急

更新:IPv6 动态 DNS 免穿透 #

现在 IPv6 普及,可以走 v6 来避免内网穿透

一般家宽的 v6 地址不是固定的,所以需要动态 DNS (DDNS)

DDNS 有三种解决方案:一是买 DDNS 服务商的服务然后本地装 agent 定时通信,如 DynDNS;二是利用现有域名服务商的 API 手搓一个,配合 crontab 自动更新;三是在域名服务商处注册一个 ns 记录到 VPS,然后在 VPS 上搭 DNS 服务,本地和 VPS 定时通信

笔者这里就用 API 手搓的办法

先贴一个提取公网 IPv6 主机地址的正则

ip -6 addr show | sed -En 's/\s+inet6 ([0-9a-fA-F:]+)\/128 scope global.+/\1/p'

基本流程都大差不差:先提取本地 v6 地址,然后查询服务商的 AAAA 记录,如果不同则添加一个新的 AAAA 记录,再删掉旧的记录(添加和删除的顺序不建议调换,为什么可以自己想一下);如果服务商没有存储 AAAA 记录,直接添加一个新的记录就好

ddns.py 脚本如下,笔者使用的服务商是 spaceship

#!/usr/bin/env python3

import requests
import json
import subprocess
import sys

DEBUG = len(sys.argv) > 1

CMD = r"ip -6 addr show | sed -En 's/\s+inet6 ([0-9a-fA-F:]+)\/128 scope global.+/\1/p'"

X_API_KEY = "{X_API_KEY}"
X_SECRET_KEY = "{X_SECRET_KEY}"

DOMAIN = "example.com"
RECORD = "foobar"

URL = f"https://spaceship.dev/api/v1/dns/records/{DOMAIN}"
QUERY_DNS_PARAMS = {"take": "100", "skip": "0", "orderBy": "name"}
HEADERS = {
    "X-API-Key": X_API_KEY,
    "X-API-Secret": X_SECRET_KEY,
    "Content-Type": "application/json",
}


def get_ipv6_address():
    addr = subprocess.check_output(CMD, shell=True, text=True)
    addr = addr.strip()

    if len(addr) == 0:
        raise Exception("Unable to get IPv6 address")

    return addr


def query_dns_record():
    response = requests.get(URL, params=QUERY_DNS_PARAMS, headers=HEADERS)

    if not (200 <= response.status_code < 300):
        print(response.status_code)
        print(response.text)
        raise Exception("DNS record query request failed")

    records = json.loads(response.text)
    for r in records["items"]:
        if r["name"] == RECORD:
            break
    else:
        print("Warning: Unable to find specified DNS record")
        return None

    addr = r["address"].strip()
    return addr


def delete_dns_record(addr):
    payload = [{"type": "AAAA", "address": addr, "name": RECORD}]

    response = requests.delete(URL, json=payload, headers=HEADERS)

    if not (200 <= response.status_code < 300):
        print(response.status_code)
        print(response.text)
        raise Exception("DNS record delete request failed")


def update_dns_record(addr):
    payload = {
        "force": True,
        "items": [{"type": "AAAA", "address": addr, "name": RECORD, "ttl": 1800}],
    }

    response = requests.put(URL, json=payload, headers=HEADERS)

    if not (200 <= response.status_code < 300):
        print(response.status_code)
        print(response.text)
        raise Exception("DNS record update request failed")


def main():
    addr = get_ipv6_address()
    print(addr)

    prev_addr = query_dns_record()
    if prev_addr is not None and prev_addr == addr:
        print("IPv6 address not updated")
        return 1
    else:
        print(prev_addr)

    if DEBUG is True:
        print("Debug: should update DNS record now")
    else:
        update_dns_record(addr)
        print("DNS record updated successfully")

    if prev_addr is None:
        print("No old DNS record found, so not deleting")
    else:
        if DEBUG is True:
            print("Debug: Should delete old DNS record now")
        else:
            # 一定要确保 update 成功了再 delete
            delete_dns_record(prev_addr)
            print("Old DNS record deleted successfully")

    return 0


if __name__ == "__main__":
    main()

crontab 每小时过 5 分的时候更新

5 * * * * /path/to/ddns.py

PT #

编译 transmission-daemon 4.1.0

sudo apt install git build-essential cmake libcurl4-openssl-dev libssl-dev python3 libsystemd-dev

mkdir build && cd build && cmake .. -DCMAKE_BUILD_TYPE=Release -DINSTALL_DOC=OFF -DENABLE_UTILS=OFF -DWITH_SYSTEMD=ON

更新:因为 tr 功能和性能都逊色于 qbt,现迁移至 qbittorrent-nox

sudo apt install qbittorrent-nox

useradd -r -s /usr/sbin/nologin -d /var/bt -m bt_user

服务启动前需要以 bt_user 单跑一下 qbittorrent-nox 选择同意隐私声明

sudo -u bt_user qbittorrent-nox

/etc/systemd/system/qbittorrent-nox.service

[Unit]
Description=qBittorrent Command Line Client
After=network.target nss-lookup.target

[Service]
Type=forking
User=bt_user
Group=bt_user
ExecStart=/usr/bin/qbittorrent-nox -d --webui-port=8080
Restart=on-failure

[Install]
WantedBy=multi-user.target

最好在配置中设置 WebUI 监听在 0.0.0.0,防止 IPv6 公网访问

更新:笔者曾误以为 qbt 采用单 socket 监听全部 ip 地址,然而 qbt 其实会在每个 ip 地址上各开一个套接字,如果某个 IPv6 地址遭到运营商更改,实测下来会导致 qbt 直接摆烂

总之,笔者这里还是采用和 DDNS 一样的思想,监听单个地址,然后定时触发 qbt 的 API 来更新

update_bt_ip.py

#!/usr/bin/env python3

import requests
import json
import subprocess
import sys

DEBUG = len(sys.argv) > 1

CMD = r"ip -6 addr show | sed -En 's/\s+inet6 ([0-9a-fA-F:]+)\/128 scope global.+/\1/p'"

QUERY_URL = "http://127.0.0.1:8080/api/v2/app/preferences"
UPDATE_URL = "http://127.0.0.1:8080/api/v2/app/setPreferences"


def get_ipv6_address():
    addr = subprocess.check_output(CMD, shell=True, text=True)
    addr = addr.strip()

    if len(addr) == 0:
        raise Exception("Unable to get IPv6 address")

    return addr


def query_listening_address():
    response = requests.get(QUERY_URL, headers={"Accept": "application/json"})

    if response.status_code != 200:
        print(response.status_code)
        print(response.text)
        raise Exception("Unable to get preferences")

    preferences = json.loads(response.text)

    addr = preferences["current_interface_address"].strip()
    return addr


def update_listening_address(addr):
    json_param = '{"current_interface_address":"%s"}' % addr
    response = requests.post(
            UPDATE_URL,
            data={"json": json_param},
            headers={"Content-Type": "application/x-www-form-urlencoded"})

    if response.status_code != 200:
        print(response.status_code)
        print(response.text)
        raise Exception("Unable to update preferences")


def main():
    addr = get_ipv6_address()
    print(addr)

    prev_addr = query_listening_address()
    if prev_addr is not None and prev_addr == addr:
        print("IPv6 address not updated")
        return 1
    else:
        print(prev_addr)

    if DEBUG is True:
        print("Debug: should update interface address now")
    else:
        update_listening_address(addr)
        print("Interface address updated successfully")

    return 0


if __name__ == "__main__":
    main()

一点吐槽:怎么会有人用 form 参数传 json 的?

SSH 代理本地管理页面 #

想远程访问如 miniDLNA 8200 和 qbt 8080 等管理页面可以借助 SSH 动态代理

ssh -D 1080 -N -o ServerAliveInterval=60 foo@example.com

走 socks5 1080 即可