NAS + Tailscale + WebDAV:安全对接云服务器 Spring Boot

NAS + Tailscale + WebDAV:安全对接云服务器 Spring Boot

本文介绍如何利用 Tailscale 组建加密虚拟局域网,让云服务器上的 Spring Boot 应用通过 WebDAV 访问家庭 NAS。整个方案无需将 NAS 的 WebDAV 端口暴露到公网,并支持 UDP 打洞直连。

一、方案背景

常见需求是:Spring Boot 部署在云服务器,图片、视频、备份等大文件存储在家庭 NAS,希望应用可以像访问普通 HTTP 服务一样上传、下载和管理文件。

直接映射 NAS WebDAV 端口存在几个问题:

  • 家庭宽带可能没有固定公网 IP;
  • 端口暴露到公网会增加安全风险;
  • 路由器端口转发和 HTTPS 证书配置比较烦琐;
  • 两地网络环境变化后容易失联。

Tailscale 基于 WireGuard,可将 NAS 和云服务器加入同一个加密网络。Spring Boot 只需访问 NAS 的 Tailscale IP,无需关心家庭公网 IP变化。

二、整体架构

#mermaid-svg-YSD43zoZd2grpwJb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YSD43zoZd2grpwJb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YSD43zoZd2grpwJb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YSD43zoZd2grpwJb .error-icon{fill:#552222;}#mermaid-svg-YSD43zoZd2grpwJb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YSD43zoZd2grpwJb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YSD43zoZd2grpwJb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YSD43zoZd2grpwJb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YSD43zoZd2grpwJb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YSD43zoZd2grpwJb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YSD43zoZd2grpwJb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YSD43zoZd2grpwJb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YSD43zoZd2grpwJb .marker.cross{stroke:#333333;}#mermaid-svg-YSD43zoZd2grpwJb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YSD43zoZd2grpwJb p{margin:0;}#mermaid-svg-YSD43zoZd2grpwJb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-YSD43zoZd2grpwJb .cluster-label text{fill:#333;}#mermaid-svg-YSD43zoZd2grpwJb .cluster-label span{color:#333;}#mermaid-svg-YSD43zoZd2grpwJb .cluster-label span p{background-color:transparent;}#mermaid-svg-YSD43zoZd2grpwJb .label text,#mermaid-svg-YSD43zoZd2grpwJb span{fill:#333;color:#333;}#mermaid-svg-YSD43zoZd2grpwJb .node rect,#mermaid-svg-YSD43zoZd2grpwJb .node circle,#mermaid-svg-YSD43zoZd2grpwJb .node ellipse,#mermaid-svg-YSD43zoZd2grpwJb .node polygon,#mermaid-svg-YSD43zoZd2grpwJb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-YSD43zoZd2grpwJb .rough-node .label text,#mermaid-svg-YSD43zoZd2grpwJb .node .label text,#mermaid-svg-YSD43zoZd2grpwJb .image-shape .label,#mermaid-svg-YSD43zoZd2grpwJb .icon-shape .label{text-anchor:middle;}#mermaid-svg-YSD43zoZd2grpwJb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-YSD43zoZd2grpwJb .rough-node .label,#mermaid-svg-YSD43zoZd2grpwJb .node .label,#mermaid-svg-YSD43zoZd2grpwJb .image-shape .label,#mermaid-svg-YSD43zoZd2grpwJb .icon-shape .label{text-align:center;}#mermaid-svg-YSD43zoZd2grpwJb .node.clickable{cursor:pointer;}#mermaid-svg-YSD43zoZd2grpwJb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-YSD43zoZd2grpwJb .arrowheadPath{fill:#333333;}#mermaid-svg-YSD43zoZd2grpwJb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-YSD43zoZd2grpwJb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-YSD43zoZd2grpwJb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YSD43zoZd2grpwJb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-YSD43zoZd2grpwJb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YSD43zoZd2grpwJb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-YSD43zoZd2grpwJb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-YSD43zoZd2grpwJb .cluster text{fill:#333;}#mermaid-svg-YSD43zoZd2grpwJb .cluster span{color:#333;}#mermaid-svg-YSD43zoZd2grpwJb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-YSD43zoZd2grpwJb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-YSD43zoZd2grpwJb rect.text{fill:none;stroke-width:0;}#mermaid-svg-YSD43zoZd2grpwJb .icon-shape,#mermaid-svg-YSD43zoZd2grpwJb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YSD43zoZd2grpwJb .icon-shape p,#mermaid-svg-YSD43zoZd2grpwJb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-YSD43zoZd2grpwJb .icon-shape .label rect,#mermaid-svg-YSD43zoZd2grpwJb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YSD43zoZd2grpwJb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-YSD43zoZd2grpwJb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-YSD43zoZd2grpwJb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} WireGuard 加密隧道

优先 UDP 直连
Spring Boot 应用
云服务器 Tailscale
NAS Tailscale
NAS WebDAV 服务
NAS 存储目录

示例环境:

节点 作用 Tailscale 设备名 示例 Tailnet IP
家庭 NAS 提供 WebDAV 文件存储 nas-node 100.x.x.10
云服务器 运行 Spring Boot cloud-node 100.x.x.20

本文中的 IP、用户名和密码均为示例,请替换为自己的配置。

三、NAS 部署 Tailscale

建议使用 Docker 的 host 网络模式,让 Tailscale 直接使用 NAS 的网络命名空间,减少 Docker bridge 带来的额外 NAT。

bash 复制代码
docker volume create tailscale-nas-state

docker run -d \
  --name tailscale-nas \
  --hostname nas-node \
  --network host \
  --restart unless-stopped \
  --cap-add NET_ADMIN \
  --cap-add NET_RAW \
  --device /dev/net/tun:/dev/net/tun \
  -e TS_STATE_DIR=/var/lib/tailscale \
  -e TS_USERSPACE=false \
  -e TS_HOSTNAME=nas-node \
  -e TS_AUTH_ONCE=true \
  -e TS_TAILSCALED_EXTRA_ARGS="--port=41641" \
  -v tailscale-nas-state:/var/lib/tailscale \
  tailscale/tailscale:latest

首次启动时,如果未提供 Auth Key,可从日志中取得登录地址:

bash 复制代码
docker logs tailscale-nas

为什么要持久化状态目录

/var/lib/tailscale 保存设备身份和登录状态。正确挂载状态卷后,删除并重建容器不会生成新的 Tailscale 设备,也通常不需要重新获取 Auth Key。

是否必须开启特权模式

一般不需要 privileged: true。本文只授予:

  • /dev/net/tun:创建 Tailscale 虚拟网卡;
  • NET_ADMIN:配置路由和防火墙;
  • NET_RAW:处理底层网络数据包。

只有 NAS 的容器管理系统无法精细授予这些权限时,才考虑开启特权模式。特权容器权限接近宿主机 root,应仅运行可信的官方镜像。

四、云服务器部署 Tailscale

云端同样使用 host 网络并固定 UDP 41641:

bash 复制代码
docker volume create tailscale-cloud-state

docker run -d \
  --name tailscale-cloud \
  --hostname cloud-node \
  --network host \
  --restart unless-stopped \
  --cap-add NET_ADMIN \
  --cap-add NET_RAW \
  --device /dev/net/tun:/dev/net/tun \
  -e TS_STATE_DIR=/var/lib/tailscale \
  -e TS_USERSPACE=false \
  -e TS_HOSTNAME=cloud-node \
  -e TS_ACCEPT_DNS=false \
  -e TS_AUTH_ONCE=true \
  -e TS_TAILSCALED_EXTRA_ARGS="--port=41641" \
  -v tailscale-cloud-state:/var/lib/tailscale \
  tailscale/tailscale:latest

如果设备以前启用过 --accept-routes,重建时还应加入:

bash 复制代码
-e TS_EXTRA_ARGS="--accept-routes"

否则 containerboot 可能提示:

text 复制代码
changing settings via 'tailscale up' requires mentioning all non-default flags

这是因为状态卷保存了非默认参数,而新容器启动时没有完整声明。按照日志提示补齐参数即可,无需删除状态卷。

云安全组配置

建议在云平台安全组中允许:

text 复制代码
协议:UDP
端口:41641
来源:0.0.0.0/0

如果云服务器使用 NAT 公网地址,还需确认公网 UDP 41641 能映射到服务器内网 IP 的 UDP 41641。

五、验证是否 UDP 直连

先查看两台设备:

bash 复制代码
docker exec tailscale-cloud tailscale status

从云服务器测试 NAS:

bash 复制代码
docker exec tailscale-cloud \
  tailscale ping --c 10 --timeout 5s 100.x.x.10

如果输出公网 IP 和端口,代表已经直连:

text 复制代码
pong from nas-node (100.x.x.10) via 116.x.x.x:41641 in 25ms

如果显示以下内容,则仍通过 DERP 中继:

text 复制代码
pong from nas-node (100.x.x.10) via DERP(lax) in 360ms

连接刚建立时,前几次请求可能暂时经过 DERP,随后完成 UDP 打洞并切换为直连,这是正常现象。

查看当前网络条件:

bash 复制代码
docker exec tailscale-cloud tailscale netcheck

重点关注:

text 复制代码
UDP: true
MappingVariesByDestIP: false

若想提高冷启动直连速度,可在家庭路由器设置端口转发:

text 复制代码
公网 UDP 41641 → NAS局域网IP:41641

六、在 NAS 开启 WebDAV

不同品牌 NAS 的入口名称不同,一般位于"文件服务"或"应用中心"中。建议:

  1. 新建一个专用 WebDAV 用户,例如 springboot-dav
  2. 只授予业务目录的读写权限;
  3. 不使用 NAS 管理员账号;
  4. 不在路由器上公开映射 WebDAV 端口;
  5. 确保 WebDAV 监听 NAS 主机网络,而非仅监听回环地址。

假设 WebDAV 地址为:

text 复制代码
http://100.x.x.10:5005/dav/

在云服务器上先进行连通性测试:

bash 复制代码
curl -u 'springboot-dav:你的密码' \
  -X PROPFIND \
  -H 'Depth: 1' \
  'http://100.x.x.10:5005/dav/'

返回 HTTP 207 Multi-Status 通常表示 WebDAV 工作正常。

虽然 Tailscale 隧道本身已经端到端加密,但条件允许时仍建议为 WebDAV 配置 HTTPS。

七、Spring Boot 对接 WebDAV

1. Maven 依赖

下面使用 Apache HttpClient 5 发送 WebDAV 请求:

xml 复制代码
<dependency>
    <groupId>org.apache.httpcomponents.client5</groupId>
    <artifactId>httpclient5</artifactId>
</dependency>

2. 配置文件

application.yml

yaml 复制代码
storage:
  webdav:
    base-url: http://100.x.x.10:5005/dav/
    username: ${WEBDAV_USERNAME}
    password: ${WEBDAV_PASSWORD}

不要将真实密码提交到 Git,生产环境通过环境变量或密钥管理服务注入:

bash 复制代码
export WEBDAV_USERNAME='springboot-dav'
export WEBDAV_PASSWORD='请替换为真实密码'

3. WebDAV 配置类

java 复制代码
package com.example.storage;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "storage.webdav")
public record WebDavProperties(
        String baseUrl,
        String username,
        String password
) {
}
java 复制代码
package com.example.storage;

import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.net.URI;

@Configuration
@EnableConfigurationProperties(WebDavProperties.class)
public class WebDavConfiguration {

    @Bean
    CloseableHttpClient webDavHttpClient(WebDavProperties properties) {
        URI uri = URI.create(properties.baseUrl());

        BasicCredentialsProvider credentials = new BasicCredentialsProvider();
        credentials.setCredentials(
                new AuthScope(uri.getHost(), uri.getPort()),
                new UsernamePasswordCredentials(
                        properties.username(),
                        properties.password().toCharArray()
                )
        );

        return HttpClients.custom()
                .setDefaultCredentialsProvider(credentials)
                .build();
    }
}

4. 上传和下载文件

java 复制代码
package com.example.storage;

import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.classic.methods.HttpPut;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.InputStreamEntity;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;

@Service
public class WebDavStorageService {

    private final CloseableHttpClient httpClient;
    private final WebDavProperties properties;

    public WebDavStorageService(
            CloseableHttpClient httpClient,
            WebDavProperties properties
    ) {
        this.httpClient = httpClient;
        this.properties = properties;
    }

    public void upload(
            String remotePath,
            InputStream inputStream,
            long contentLength
    ) throws IOException {
        URI target = resolve(remotePath);
        HttpPut request = new HttpPut(target);
        request.setEntity(new InputStreamEntity(
                inputStream,
                contentLength,
                ContentType.APPLICATION_OCTET_STREAM
        ));

        httpClient.execute(request, response -> {
            int status = response.getCode();
            EntityUtils.consume(response.getEntity());
            if (status < 200 || status >= 300) {
                throw new IOException("WebDAV 上传失败,HTTP " + status);
            }
            return null;
        });
    }

    public byte[] download(String remotePath) throws IOException {
        HttpGet request = new HttpGet(resolve(remotePath));

        return httpClient.execute(request, response -> {
            int status = response.getCode();
            if (status != 200) {
                EntityUtils.consume(response.getEntity());
                throw new IOException("WebDAV 下载失败,HTTP " + status);
            }
            return EntityUtils.toByteArray(response.getEntity());
        });
    }

    private URI resolve(String remotePath) {
        String normalized = remotePath.startsWith("/")
                ? remotePath.substring(1)
                : remotePath;
        return URI.create(properties.baseUrl()).resolve(normalized);
    }
}

上述下载示例适合小文件。视频、压缩包等大文件应将响应流直接写入 HTTP Response 或目标文件,避免一次性载入内存。

5. 上传接口示例

java 复制代码
package com.example.storage;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

@RestController
@RequestMapping("/files")
public class FileController {

    private final WebDavStorageService storageService;

    public FileController(WebDavStorageService storageService) {
        this.storageService = storageService;
    }

    @PostMapping
    public ResponseEntity<Void> upload(
            @RequestParam("path") String path,
            @RequestParam("file") MultipartFile file
    ) throws IOException {
        storageService.upload(path, file.getInputStream(), file.getSize());
        return ResponseEntity.noContent().build();
    }
}

实际项目中必须校验 path,禁止 ../ 等目录穿越输入,并限制文件大小和允许的文件类型。

八、Spring Boot 运行在 Docker 中的注意事项

如果 Spring Boot 直接运行在云服务器宿主机,可直接访问 NAS 的 100.x.x.x 地址。

如果 Spring Boot 也运行在 Docker bridge 网络中,先进入容器测试:

bash 复制代码
docker exec springboot-app \
  curl -I http://100.x.x.10:5005/dav/

若 bridge 容器无法访问 Tailnet,可选择:

  • 将 Spring Boot 容器改为 network_mode: host
  • 使用 Tailscale sidecar,让应用容器共享 Tailscale 网络;
  • 在宿主机正确配置 Docker 到 tailscale0 的转发规则。

最简单的云服务器部署通常是 Spring Boot 与 Tailscale 都使用 host 网络,但要提前检查端口冲突。

九、安全建议

  1. WebDAV 不暴露到公网,只允许通过 Tailnet 访问;
  2. 使用专用低权限 WebDAV 用户;
  3. 密码使用环境变量或密钥管理工具,不写入代码;
  4. 通过 Tailscale Access Controls 仅允许云服务器访问 NAS WebDAV 端口;
  5. 持久化 Tailscale 状态目录,但不要把状态文件打包进镜像;
  6. 定期更新 Tailscale 和 NAS 系统;
  7. 对上传路径做目录穿越、文件类型和容量校验;
  8. 大文件采用流式传输,并设置合理的连接、读取超时和重试策略。

十、常见故障排查

1. 一直显示 DERP

检查:

bash 复制代码
docker exec tailscale-cloud tailscale netcheck
docker exec tailscale-cloud tailscale ping nas-node
ss -lunp | grep 41641

重点确认两端 UDP: true、容器使用 host 网络、UDP 41641 未被防火墙阻止。

2. 容器不断重启

查看日志:

bash 复制代码
docker logs --tail 200 tailscale-cloud

如果提示必须声明全部非默认参数,需要根据提示补齐 TS_ACCEPT_DNSTS_EXTRA_ARGSTS_HOSTNAME

3. Tailscale 能 ping,WebDAV 访问失败

说明隧道正常,问题通常位于 WebDAV:

  • WebDAV 服务未启动;
  • 端口填写错误;
  • 服务只监听 127.0.0.1
  • NAS 防火墙拒绝来自 tailscale0 的流量;
  • WebDAV 用户没有目标目录权限。

4. 中文文件名或空格路径失败

不要直接拼接原始字符串,应按 URL path segment 进行 UTF-8 百分号编码,同时避免对整个 URL 重复编码。

十一、总结

这套方案的核心链路是:

text 复制代码
Spring Boot → 云服务器 Tailscale → WireGuard 隧道 → NAS Tailscale → WebDAV

它兼顾了安全性与维护成本:WebDAV 无需暴露公网,NAS 公网 IP变化不影响应用配置,UDP 打洞成功后还能获得接近点对点网络的传输性能。

相关资料:

相关推荐
橘子海全栈攻城狮7 小时前
【最新源码】基于SpringBoot + Vue的超市管理系统的设计与实现D002
java·开发语言·vue.js·spring boot·后端·spring
拾贰_C9 小时前
【Java | SpringBoot | GlobalExceptionHandler】全局异常拦截器实现思路
java·开发语言·spring boot
Lyra_Infra9 小时前
故障排查报告:Spring boot连接 Nacos 异常及启动失败
java·spring boot
wuqingshun31415911 小时前
如何理解Spring Boot中的starter?
java·spring boot·后端
wuqingshun31415911 小时前
SpringBoot是如何实现自动配置的
java·spring boot·后端
zhouhui00111 小时前
AI帮我写了个Spring Boot校验,线上漏掉了这组边界条件
java·spring boot·redis·ai编程
hdsoft_huge11 小时前
SpringBoot系列17:日志体系Logback配置,日志分割、脱敏、异常堆栈收集规范(生产级落地)
java·spring boot·logback
吃饱了得干活11 小时前
扫码登录如何实现?从原理到实战
java·spring boot·redis
凤山老林12 小时前
数据库连接池深度调优:Spring Boot + HikariCP 泄漏检测、参数调优与慢 SQL 拦截体系
数据库·spring boot·sql