如何安全地面向公网提供本地 NAS 上的 Web 服务

This entry is part 8 of 8 in the series homelab 历险记

最近飞牛 NAS 爆出了一个 0Day 漏洞,很多开启了公网访问(包括但不限于 DDNS、公网 IP、FN Connect)的飞牛用户都中招了,不得不格盘重装系统。

由于我一开始就不太信任飞牛团队以及我自己,所以除了 FN Connect 之外,飞牛的漏洞被披露后,我马上升级了最新版本,并关闭了 FN Connect,我没有提供任何直接的公网访问飞牛控制面板的方式 —— 想要从家庭局域网以外连接回去,必须使用我的 Singbox 服务。详情见2024 家庭网络设备大升级 网络篇

不过,我确实有一些其他的网络服务出于一些原因需要暴露在公网。这些服务中,有一些是我手搓的,天生免疫大部分的针对 Web 应用的网络攻击,另外一些则是有一定用户基础的开源项目的自托管实例。这类服务就像飞牛一样,容易成为黑客广撒网的攻击目标。

这些使用 Cloudflare Tunnel 暴露在公网的服务都有 Cloudflare 提供最基础的 Web 安全防护功能,但是防患于未然,我决定给这些比较脆弱的服务再多加上一层保护,这里我选择的是个人免费使用的雷池 WAF (Web Application Firewall)。

设置好雷池后的示意图

安装雷池

为了充分享受 DIY 的好处,我建议手动安装雷池。为了让雷池适配我的家庭内网配置,我修改后的 Docker Compose 文件如下:

networks:
  apps: #我手动创建的 Docker 网络,所有对外服务都需要加入这个网络
    external: true
  safeline-ce:
    name: safeline-ce
    driver: bridge
    ipam:
      driver: default
      config:
        - gateway: ${SUBNET_PREFIX:?SUBNET_PREFIX required}.1
          subnet: ${SUBNET_PREFIX}.0/24
    driver_opts:
      com.docker.network.bridge.name: safeline-ce

services:
  postgres:
    container_name: safeline-pg
    restart: always
    image: ${IMAGE_PREFIX}/safeline-postgres${ARCH_SUFFIX}:15.2
    volumes:
      - ${SAFELINE_DIR}/resources/postgres/data:/var/lib/postgresql/data
      - /etc/localtime:/etc/localtime:ro
    environment:
      - POSTGRES_USER=safeline-ce
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?postgres password required}
    networks:
      safeline-ce:
        ipv4_address: ${SUBNET_PREFIX}.2
    command: [postgres, -c, max_connections=600]
    healthcheck:
      test: pg_isready -U safeline-ce -d safeline-ce
  mgt:
    container_name: safeline-mgt
    restart: always
    image: ${IMAGE_PREFIX}/safeline-mgt${REGION}${ARCH_SUFFIX}:${IMAGE_TAG:?image tag required}
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - ${SAFELINE_DIR}/resources/mgt:/app/data
      - ${SAFELINE_DIR}/logs/nginx:/app/log/nginx:z
      - ${SAFELINE_DIR}/resources/sock:/app/sock
      - /var/run:/app/run
    ports:
      - ${MGT_PORT:-9443}:1443
    healthcheck:
      test: curl -k -f https://localhost:1443/api/open/health
    environment:
      - MGT_PG=postgres://safeline-ce:${POSTGRES_PASSWORD}@safeline-pg/safeline-ce?sslmode=disable
      - MGT_PROXY=${MGT_PROXY}
    depends_on:
      - postgres
      - fvm
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "5"
    networks:
      safeline-ce:
        ipv4_address: ${SUBNET_PREFIX}.4
  detect:
    container_name: safeline-detector
    restart: always
    image: ${IMAGE_PREFIX}/safeline-detector${REGION}${ARCH_SUFFIX}:${IMAGE_TAG}
    volumes:
      - ${SAFELINE_DIR}/resources/detector:/resources/detector
      - ${SAFELINE_DIR}/logs/detector:/logs/detector
      - /etc/localtime:/etc/localtime:ro
    environment:
      - LOG_DIR=/logs/detector
    networks:
      safeline-ce:
        ipv4_address: ${SUBNET_PREFIX}.5
  tengine:
    container_name: safeline-tengine
    restart: always
    image: ${IMAGE_PREFIX}/safeline-tengine${REGION}${ARCH_SUFFIX}:${IMAGE_TAG}
    volumes:
      - /etc/localtime:/etc/localtime:ro
      # 注意!!!我删除了这里的 /etc/resolv.conf 挂载
      # 因为 tengine 加入了 apps 网络,必须使用 Docker 内置的 DNS
      # - /etc/resolv.conf:/etc/resolv.conf:ro
      - ${SAFELINE_DIR}/resources/nginx:/etc/nginx
      - ${SAFELINE_DIR}/resources/detector:/resources/detector
      - ${SAFELINE_DIR}/resources/chaos:/resources/chaos
      - ${SAFELINE_DIR}/logs/nginx:/var/log/nginx:z
      - ${SAFELINE_DIR}/resources/cache:/usr/local/nginx/cache
      - ${SAFELINE_DIR}/resources/sock:/app/sock
    environment:
      - TCD_MGT_API=https://${SUBNET_PREFIX}.4:1443/api/open/publish/server
      - TCD_SNSERVER=${SUBNET_PREFIX}.5:8000
      # deprecated
      - SNSERVER_ADDR=${SUBNET_PREFIX}.5:8000
      - CHAOS_ADDR=${SUBNET_PREFIX}.10
    ulimits:
      nofile: 131072
    # 这里使用 apps 网络替代主机模式网络,避免占用主机 80/443 端口
    #network_mode: host
    networks:
      - apps
  luigi:
    container_name: safeline-luigi
    restart: always
    image: ${IMAGE_PREFIX}/safeline-luigi${REGION}${ARCH_SUFFIX}:${IMAGE_TAG}
    environment:
      - MGT_IP=${SUBNET_PREFIX}.4
      - LUIGI_PG=postgres://safeline-ce:${POSTGRES_PASSWORD}@safeline-pg/safeline-ce?sslmode=disable
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - ${SAFELINE_DIR}/resources/luigi:/app/data
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "5"
    depends_on:
      - detect
      - mgt
    networks:
      safeline-ce:
        ipv4_address: ${SUBNET_PREFIX}.7
  fvm:
    container_name: safeline-fvm
    restart: always
    image: ${IMAGE_PREFIX}/safeline-fvm${REGION}${ARCH_SUFFIX}:${IMAGE_TAG}
    volumes:
      - /etc/localtime:/etc/localtime:ro
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "5"
    networks:
      safeline-ce:
        ipv4_address: ${SUBNET_PREFIX}.8
  chaos:
    container_name: safeline-chaos
    restart: always
    image: ${IMAGE_PREFIX}/safeline-chaos${REGION}${ARCH_SUFFIX}:${IMAGE_TAG}
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "10"
    environment:
      - DB_ADDR=postgres://safeline-ce:${POSTGRES_PASSWORD}@safeline-pg/safeline-ce?sslmode=disable
    volumes:
      - ${SAFELINE_DIR}/resources/sock:/app/sock
      - ${SAFELINE_DIR}/resources/chaos:/app/chaos
    networks:
      safeline-ce:
        ipv4_address: ${SUBNET_PREFIX}.10

使用任何你喜欢的方式启动这个 docker-compose 文件,部署雷池的工作就完成了大半。

对接 Cloudflare Tunnel

Cloudflare Tunnel 是一个免费的内网穿透工具,可以将来自公网的请求通过 Cloudflare 网络导向没有公网地址的 Web 服务。要使用 Cloudflare Tunnel 可以参考这里的文档

我这里简单介绍一下在内网部署 Cloudflared 的 docker-compose 文件:

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: always
    pull_policy: always
    command: tunnel --no-autoupdate run --token $TUNNEL_TOKEN
    networks:
      - apps # 注意!!!需要让 cloudflared 也加入 apps 网络,否则无法将流量导向雷池
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN}
    volumes:
      - ./cloudflared-data:/home/nonroot/.cloudflared

networks:
  apps:
    external: true

完成 Cloudflare Tunnel 的部署后,还需要更新一下雷池的配置,让雷池可以读取 Cloudflare 传递过来的客户端 IP。

首先打开雷池「防护应用」页面:

点击右上角的「高级配置」,修改源 IP 获取方式为「从 HTTP Header 中获取」,并将 Header 设置为 「CF-Connection-IP」。

添加公网应用

假设我们已经通过飞牛的 Docker 管理工具部署了一个 openlist,并且该服务也加入了 apps 网络。我们可以先打开雷池的「防护应用」页面,在右上角点击「添加应用」:

这里有几点需要注意,域名须填写用于在公网访问的域名,但上游服务器要使用 apps 内网中的 docker service name。

这里不需要启用 HTTPS,因为 cloudflared 跟雷池的网关都运行在受控制的环境下,启用 HTTPS 没有太多好处,反而配置跟运维会变得复杂。

添加雷池的应用后,就需要去 Cloudflare 的面板添加一条从公网导向局域网的路由:

进入 Tunnel 的编辑页面,切换到 「Published application routes」页面,点击「Add a published appIication route」。

填写子域名后将服务类型设置为 HTTP,并设置 URL 为 tengine:80 (这就是雷池网关在 apps 网络中的地址)。这样,运行在内网的应用就可以比较安全的面向公网提供服务了。

最大的安全隐患

说实话,允许公网访问就是最大的安全隐患,要消除这个隐患,最简单有效的做法就是使用 VPN 并关闭任何通过公网直接访问的途径。

如果你跟我一样都有不得不对公网提供服务的需求,那么配置一些简单的防火墙安全规则,就可以有效防御来自公网的攻击。

homelab 历险记

解决 Qsirch 无法搜索文件夹的问题
Fediverse Reactions

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Index