从部署应用程序的那一刻起,开发人员和IT人员就要花费时间来将其封锁起来:配置ACL,轮换IP地址,使用像GRE隧道这样的笨拙解决方案。有一种更简单、更安全的方法可以保护您的应用程序和web服务器免受直接攻击:Cloudflare Tunnel。可以借助良心企业Cloudflare完全免费的Tunnel服务,快速安全地加密应用程序到任何类型基础设施的流量,让您能够隐藏你的web服务器IP地址,阻止直接攻击。Tunnel后台程序在源web服务器和Cloudflare最近的数据中心之间创建一条加密隧道,同时无需打开任何公共入站端口。使用防火墙锁定所有源服务器端口和协议后,HTTP/S端口上的任何请求都会被丢弃,包括容量耗尽DDoS攻击。数据泄露尝试被完全阻止,例如传输中数据窥探或暴力登录攻击。同时Tunnel支持gRPC的流量转发,用来配置哪吒探针也没有问题。

配置Cloudflare Tunnel

Cloudflare Tunnel支持多种部署方式,并且平台的适配也很完善,具体项目信息可以从Github上查看。下面以Debian为例,简单介绍一下安装方法。

在Cloudflare Dashboard中新建一个隧道,之后你可以获得一串密钥,之后下载合适的deb安装包

curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64.deb

然后可以直接使用一键安装

sudo dpkg -i cloudflared.deb && sudo cloudflared service install eyJhI...

注意:这样通过官网service install一键安装的方式需要在/etc/systemd/system/cloudflared.service中增加--protocol http2参数

ExecStart=/usr/bin/cloudflared --no-autoupdate tunnel run --protocol http2 --token eyJhI...

更推荐使用Docker安装,部署方式如下:

docker run -d \
    --name cloudflared \
    --restart always \
    --network host \
    cloudflare/cloudflared:latest \
    tunnel --no-autoupdate run --protocol http2 --token eyJhI...

配置隧道时如果你直接穿透web服务,你可以设置到目标端口,如http://127.0.0.1:12345。假使需要采用Tunnel+Nginx这样的组合,你的服务必须使用https协议,设置Service为https://127.0.0.1:443,同时需要打开No TLS VerifyHTTP2 connection Origin,并配置好Server NameHTTP Host Header

部署Nginx转发

这块比较简单,可以通过监控/proto.NezhaService,区分不同流量,直接贴出我的配置供参考

server {
	listen 443 ssl http2;
	listen [::]:443 ssl http2;
    proxy_ssl_server_name on;
    server_tokens off;
    ssl_certificate    /opt/cert/web.crt;
    ssl_certificate_key    /opt/cert/web.key;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS';
    ssl_prefer_server_ciphers on; 
    add_header Strict-Transport-Security max-age=15768000;
    server_name status.canghai.org;
    client_max_body_size 128M;

    underscores_in_headers on;

    location / {
    proxy_pass http://127.0.0.1:5015;
    proxy_set_header Host $http_host;
    proxy_set_header Upgrade $http_upgrade;
    }

    location ~ ^/(ws|terminal/.+)$  {
    proxy_pass http://127.0.0.1:5015;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_set_header Host $http_host;
    }

    location /proto.NezhaService {
        grpc_read_timeout 300s;
        grpc_send_timeout 300s;
        grpc_socket_keepalive on;
        grpc_pass grpc://grpcservers;
    }
}

upstream grpcservers {
    server localhost:5016;
    keepalive 512;
}

部署哪吒探针

哪吒探针的部署官网很详细,你可以轻松的使用Docker部署

docker run -d \
    --name nezha \
    --restart always \
    -v "/opt/nezha/dashboard/data:/dashboard/data" \
    -v "/opt/nezha/dashboard/resource:/dashboard/resource" \
    -p 5015:80 \
    -p 5016:5555 \
    ghcr.io/naiba/nezha-dashboard

探针前端自定义

  • 使用自定义主题的话本地目录结构如下:
resource
├── static
│   └── custom
│       ├── darkmode.css
│       ├── main.css
│       └── mixin.js
└── template
    └── theme-custom
        ├── footer.html
        ├── header.html
        ├── home.html
        ├── menu.html
        ├── network.html
        ├── service.html
        └── viewpassword.html
  • 其中main.css中修改内容如下:
/* 适配不同宽度窗口 */
@media only screen and (min-width:1200px) {
  .ui.container {
    width:95% !important;
    font-size: 90% !important;
    max-width: 1300px !important;
  }
}

@media only screen and (min-width:767px) {
  .status.cards {
    display: grid !important;
    justify-content: center !important;
    grid-template-columns: repeat(auto-fill, 305px);
    grid-gap: 15px;
  }
  .status.cards .card {
    width: 305px !important;
    margin: 0 !important;
  }
}

/* 隐藏页脚 */
.footer {
  display: none !important;
}

/* 修改字体 */
*:not(.icon) {
  font-family: 'LXGW WenKai Screen' !important;
}

/* 修改字体大小 */
.ui.card>.content>.header:not(.ui),
.ui.cards>.card>.content>.header:not(.ui) {
  line-height: 1em;
  font-size: 1.2em !important;
}

.status.cards .three.wide.column {
  font-weight: bold;
  text-align: center;
  width: 22% !important;
}

.status.cards .thirteen.wide.column {
  font-weight: bold;
  width: 78% !important;
  padding-left: 0;
}

.status.cards .ui.content.popup {
  min-width: calc(100%) !important;
  line-height: 1.72rem !important;
  font-size: 0.9rem !important;
  border-radius: 5px !important;
  border: 1px solid transparent !important;
}
  • header.html中的cdn可换成国内cdn,这里用的是阿里云和BootCDN的镜像
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <meta content="telephone=no" name="format-detection">
    <title>{{.Title}}</title>
    <link rel="shortcut icon" type="image/png" href="/static/logo.svg" />
    <link rel="stylesheet" href="https://registry.npmmirror.com/lxgw-wenkai-screen-web/latest/files/style.css" />
    <link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/semantic-ui/2.5.0/semantic.min.css">
    <link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-logos/1.2.0/font-logos.css">
    <link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/bootstrap-icons/1.11.3/font/bootstrap-icons.min.css">
    <link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/flag-icons/7.2.3/css/flag-icons.min.css">
    <link rel="stylesheet" type="text/css" href="/static/semantic-ui-alerts.min.css">
    <link rel="stylesheet" type="text/css" href="/static-custom/main.css?v20240226">
    <link rel="stylesheet" type="text/css" href="/static-custom/darkmode.css?v20240816">
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/semantic-ui/2.5.0/semantic.min.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.14/vue.min.js"></script>
    <script src="https://cdn.bootcdn.net/ajax/libs/echarts/5.5.0/echarts.min.js"></script>
    <script src="/static/semantic-ui-alerts.min.js"></script>
    <script src="/static-custom/mixin.js?v20240302"></script>
    <script>
        document.documentElement.setAttribute('nz-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    </script>
</head>
  • 可通过修改home.html来对主页部分显示信息进行自定义
<!-- 未开启SWAP分区显示 -->
<div v-if="server.Host.SwapTotal != 0" class="thirteen wide column">
  <div :class="formatPercent(server.live,server.State.SwapUsed, server.Host.SwapTotal).class">
    <div class="bar" :style="formatPercent(server.live,server.State.SwapUsed, server.Host.SwapTotal).style">
      <small>@#parseInt(server.State.SwapUsed/server.Host.SwapTotal*100)#@%</small>
    </div>
  </div>
</div>
<div v-else class="thirteen wide column">
  未开启SWAP分区<br />
</div>

<script>
// 增加单位TB
getK2Gb(bs){
    bs = bs / 1024 / 1024 / 1024 / 1024;
    if (bs >= 1) {
        return Math.ceil(bs.toFixed(2)) + 'TB';
    } else {
        bs = bs * 1024;
        if (bs >= 1) {
            return Math.ceil(bs.toFixed(2)) + 'GB';
        } else {
            bs = bs * 1024;
            return Math.ceil(bs.toFixed(2)) + 'MB';
        }
    }
},
// 温度显示四舍五入
getTemperature(temperatureList, sensorList) {
    const lowerCaseSensorList = sensorList.map(sensor => sensor.toLowerCase());
    const filtered = temperatureList.filter(item => item.Temperature !== 0 && lowerCaseSensorList.includes(item.Name.toLowerCase()));
    if (filtered.length > 0) {
        return filtered.reduce((max, current) => {
            return current.Temperature > max ? current.Temperature : max;
        }, filtered[0].Temperature);
    }
    if (filtered.length > 0) {
        const maxTemperature = filtered.reduce((max, current) => {
            return current.Temperature > max ? current.Temperature : max;
        }, filtered[0].Temperature);
        return Math.round(maxTemperature);
    }
    const nonZeroTemps = temperatureList.filter(item => item.Temperature !== 0);
    if (nonZeroTemps.length > 0) {
        const maxTemperature = nonZeroTemps.reduce((max, current) => {
            return current.Temperature > max ? current.Temperature : max;
        }, nonZeroTemps[0].Temperature);
        return Math.round(maxTemperature);
    }
    return 0;
}
</script>