收起左侧

#萌新折腾笔记# 利用Cloudflare或服务器实现设备离线邮件通知

6
回复
148
查看
[ 复制链接 ]

14

主题

110

回帖

195

牛值

共建版主

社区上线纪念勋章社区共建团荣誉勋章

背景及心路历程

近期,UPS和邮件通知似乎成了nas圈的一个讨论热点。这引起了我的兴趣,既然已经没电了,那应该也没网,还如何能够实现邮件的及时发送呢?

经过多方了解后主要得到以下的结论:

1、部份UPS自带网络环境可以实现联网;

2、利用UPS给家里的光猫、路由器供电以保证网络连通;

3、白群晖自带邮箱通知服务

同时,我也看到了来自
Shaw0828的文章UPS状态切换邮件通知功能分享

显然,这类方案并不适合我这种没有UPS短时间内也不打算购入UPS的用户。转念一想,事情反而变得简单又清晰了起来。既然没有UPS无法在Nas本地进行状态判断,那就像白群那样整一台永远都不会断 电的服务器,在服务器上进行判断不就好了!

那么,多少价格可以拿下一台永久在线的服务器呢?这让我想到一位大善人!

image.png

实现逻辑

客户端:

  • Docker
  • MP 插件等

设定设备名称和Token密钥,采用心跳机制,每间隔一段时间像服务端发送一次请求;

服务端:

  • Cloudflare Workers + KV空间

设定需要监控的设备名称、Token密钥、邮件相关参数、执行间隔等内容,并记录该设备的“离线后第一次恢复在线”和“在线后第一次离线”的信息,按设定要求将邮件发送到用户邮箱。由于Workers本身无法发送邮件,只能调用API去实现该功能,因此,我们需要一个支持API发送邮件的平台。

邮件发送平台(免费):

以上平台的注册没有难度,故本文不进行展开,需要注意的是Luckycola的appkey申请可能需要两天,建议提前准备。

配置服务端

第一步:创建KV存储空间

登录cloudflare后,可以在左侧找到“存储和数据库-KV”,如图所示点击Create Instance,并为你的KV创建一个英文名称,点击创建

image1.png
image2.png

KV空间创建完成!

第二步:部署Cloudflare Worker作为服务端

Cloudflare worker的免费额度如下:

Workers 使用量:每天10万次请求,最大 10ms CPU 时间/每个请求

Workers KV 使用量:每日 100,000 次读取操作,每日 1,000 次写入、删除、列举

当每日每项使用量超过50%时,Cloudflare会发送邮件提醒。在了解以上限制后,我们可以制作相应的部署!

创建一个Cloudflare Worker

在cloudflare左侧侧边栏中找到“计算(Workers)”进入,按图操作即可,worker名称可行自定义。

image3.png
image4.png
image5.png

image.png
完成创建后,点击继续处理项目,来到绑定界面

为新Worker绑定KV空间(数据库)

  1. 现在,将这个 KV 绑定到您的 Worker:
    • 回到您的 Worker (device-monitor) 的管理页面。 image7.png
      image8.png
    • 变量名称为:DEVICE_STATUS KV命名空间选择刚刚创建的,这里我是同名。
      image9.png
      image10.png

点击添加绑定后,会如上图所示↑

部署Cloud flare Worker执行代码

点击此处进入代码编辑页面
image11.png

参数说明:
代码中HEARTBEAT_TIMEOUT参数用于判定设备是否掉线,当设备超过这个时间没有汇报在线,视为离线并发送邮件通知,建议按网络环境修改,如果心跳发送是两分钟一次,建议改为4分钟以上,以避免由于单次的cloudflare连接不稳定导致误判。

单设备监控

// 心跳超时时间调整为 300 秒 (5分钟)
const HEARTBEAT_TIMEOUT = 300 * 1000;
const COLA_KEY_STORE_KEY = 'global:cola_key_data';

export default {

	async fetch(request, env, ctx) {
		const url = new URL(request.url);
		if (url.pathname !== '/heartbeat' || request.method !== 'POST') {
			return new Response('Not Found', { status: 404 });
		}

		try {
			const { token } = await request.json();
			if (!token) {
				return new Response('Missing token', { status: 400 });
			}

			const devicename = env.DEVICE_NAME;
			const expectedToken = env.DEVICE_TOKEN;

			if (!devicename || !expectedToken) {
				console.error('Error: DEVICE_NAME or DEVICE_TOKEN environment variable is not set.');
				return new Response('Worker is not configured correctly', { status: 500 });
			}

			if (token !== expectedToken) {
				return new Response('Invalid token', { status: 403 });
			}

			const kvKey = `device:${devicename}`;
			const currentStatus = await env.DEVICE_STATUS.get(kvKey, { type: 'json' });

			if (currentStatus && currentStatus.status === 'offline') {
				const subject = `✅ 设备恢复在线`;
				const beijingTime = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
				const body = `设备 "${devicename}" 已于 ${beijingTime} 恢复在线。`;
				
				ctx.waitUntil(sendEmail(env, subject, body));
			}

			const newStatus = { lastSeen: Date.now(), status: 'online' };
			await env.DEVICE_STATUS.put(kvKey, JSON.stringify(newStatus));
			
			return new Response('Heartbeat received', { status: 200 });

		} catch (error) {
			console.error('Error processing heartbeat:', error);
			return new Response('Internal Server Error', { status: 500 });
		}
	},
	async scheduled(controller, env, ctx) {
		console.log('Running scheduled check for the single device...');
		
		const devicename = env.DEVICE_NAME;
		if (!devicename) {
			console.error('Error: DEVICE_NAME environment variable is not set. Scheduled task cannot run.');
			return;
		}

		const kvKey = `device:${devicename}`;
		const now = Date.now();
		const status = await env.DEVICE_STATUS.get(kvKey, { type: 'json' });

		if (!status || status.status === 'offline') {
			return;
		}

		if (now - status.lastSeen > HEARTBEAT_TIMEOUT) {
			console.log(`Device ${devicename} is offline.`);
			const newStatus = { ...status, status: 'offline' };
			await env.DEVICE_STATUS.put(kvKey, JSON.stringify(newStatus));
			const subject = `❌ 设备异常离线`;
			const beijingTime = new Date(status.lastSeen).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
			const body = `设备 "${devicename}" 已离线。<br>最后一次心跳时间:${beijingTime}.`;
			
			ctx.waitUntil(sendEmail(env, subject, body));
		}
	},
};

async function getValidColaKey(env) {
	const storedData = await env.DEVICE_STATUS.get(COLA_KEY_STORE_KEY, { type: 'json' });
	if (storedData && storedData.key && Date.now() < storedData.endTime) {
		console.log('Using valid ColaKey from KV cache.');
		return storedData.key;
	}

	console.log('ColaKey is missing or expired. Fetching a new one...');
	try {
		const response = await fetch('https://luckycola.com.cn/ai/getColaKey', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ uid: env.UID, appKey: env.APP_KEY }),
		});

		if (!response.ok) {
			throw new Error(`API request failed with status ${response.status}`);
		}
		const responseData = await response.json();
		
		if (responseData.code === 0 && responseData.data) {
			const newData = { key: responseData.data.cola_key, endTime: responseData.data.end_time };
			await env.DEVICE_STATUS.put(COLA_KEY_STORE_KEY, JSON.stringify(newData));
			console.log('Successfully fetched and stored a new ColaKey.');
			return newData.key;
		} else {
			throw new Error(`API returned an error: ${JSON.stringify(responseData)}`);
		}
	} catch (error) {
		console.error('Failed to fetch a new ColaKey:', error);
		return null;
	}
}


async function sendEmail(env, subject, body) {
	const colaKey = await getValidColaKey(env);
	if (!colaKey) {
		console.error('Could not obtain a valid ColaKey. Email sending aborted.');
		return;
	}
	
	const requestBody = {
		ColaKey: colaKey,
		tomail: env.EMAIL_TO,
		fromTitle: env.FROM_TITLE,
		subject: subject,
		smtpCode: env.SMTP_CODE,
		smtpEmail: env.SMTP_EMAIL,
		smtpCodeType: env.SMTP_CODE_TYPE,
		isTextContent: false,
		content: `<div style="font-family: sans-serif;">${body}</div>`,
	};

	try {
		const response = await fetch('https://luckycola.com.cn/tools/customMail', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify(requestBody),
		});
		if (response.ok) {
			console.log(`Email sent successfully: "${subject}"`);
		} else {
			const errorBody = await response.text();
			console.error(`Failed to send email. Status: ${response.status}, Body: ${errorBody}`);
		}
	} catch (error) {
		console.error('Error sending email:', error);
	}
}

所需创建的环境变量如下:

类型均为纯文本

变量名称:DEVICE_NAME
值:填写客户端名称,自定义项,务必确保与客户端一致,例如“ewedlnt”

变量名称:DEVICE_TOKEN
值:填写客户端密钥,自定义项,务必确保与客户端一致,例如“123123”

变量名称:EMAIL_TO
值:填写目的邮箱(想发到哪个邮箱就写哪个)

变量名称:FROM_TITLE
值:邮件发送标题,自定义项,API发送必要参数

变量名称:UID
值:填写从Luckycola获取到的用户ID

变量名称:APP_KEY
值:填写从Luckycola获取到的APPKEY

变量名称:SMTP_CODE_TYPE
值:填写邮箱类型,当前API仅支持“163/126/qq”三个值,任选其一

变量名称:SMTP_EMAIL
值:填写开启了SMTP服务的邮箱地址

变量名称:SMTP_CODE
值:填写邮件系统授权码

cron表达式:* * * * *
意为每分钟都执行。正常情况下相当于每天执行1440次读取操作,免费套餐完全满足使用

多设备监控

//心跳超时时间调整为 300 秒 (5分钟)
const HEARTBEAT_TIMEOUT = 300 * 1000;
const COLA_KEY_STORE_KEY = 'global:cola_key_data';

export default {
	async fetch(request, env, ctx) {
		const url = new URL(request.url);
		if (url.pathname !== '/heartbeat' || request.method !== 'POST') {
			return new Response('Not Found', { status: 404 });
		}

		try {
			const { devicename, token } = await request.json();
			if (!devicename || !token) {
				return new Response('Missing devicename or token', { status: 400 });
			}

			const deviceTokens = JSON.parse(env.DEVICE_TOKENS || '{}');
			if (deviceTokens[devicename] !== token) {
				return new Response('Invalid token', { status: 403 });
			}

			const kvKey = `device:${devicename}`;
			const currentStatus = await env.DEVICE_STATUS.get(kvKey, { type: 'json' });

			if (currentStatus && currentStatus.status === 'offline') {
				const subject = `✅ 设备恢复在线`;
				const beijingTime = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
				const body = `设备 "${devicename}" 已于 ${beijingTime} 恢复在线。`;

				ctx.waitUntil(sendEmail(env, subject, body));
			}

			const newStatus = { lastSeen: Date.now(), status: 'online' };
			await env.DEVICE_STATUS.put(kvKey, JSON.stringify(newStatus));
			
			return new Response('Heartbeat received', { status: 200 });

		} catch (error) {
			console.error('Error processing heartbeat:', error);
			return new Response('Internal Server Error', { status: 500 });
		}
	},

	async scheduled(controller, env, ctx) {
		console.log('Running scheduled check for offline devices...');
		const list = await env.DEVICE_STATUS.list({ prefix: 'device:' });
		const now = Date.now();

		for (const key of list.keys) {
			const status = await env.DEVICE_STATUS.get(key.name, { type: 'json' });
			if (!status || status.status === 'offline') {
				continue;
			}

			if (now - status.lastSeen > HEARTBEAT_TIMEOUT) {
				const devicename = key.name.replace('device:', '');
				console.log(`Device ${devicename} is offline.`);
				const newStatus = { ...status, status: 'offline' };
				await env.DEVICE_STATUS.put(key.name, JSON.stringify(newStatus));
				const subject = `❌ 设备异常离线`;
				const beijingTime = new Date(status.lastSeen).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
				const body = `设备 "${devicename}" 已离线。<br>最后一次心跳时间:${beijingTime}.`;
				ctx.waitUntil(sendEmail(env, subject, body));
			}
		}
	},
};

async function getValidColaKey(env) {
	const storedData = await env.DEVICE_STATUS.get(COLA_KEY_STORE_KEY, { type: 'json' });
	if (storedData && storedData.key && Date.now() < storedData.endTime) {
		console.log('Using valid ColaKey from KV cache.');
		return storedData.key;
	}

	console.log('ColaKey is missing or expired. Fetching a new one...');
	try {
		const response = await fetch('https://luckycola.com.cn/ai/getColaKey', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ uid: env.UID, appKey: env.APP_KEY }),
		});

		if (!response.ok) {
			throw new Error(`API request failed with status ${response.status}`);
		}
		const responseData = await response.json();
		
		if (responseData.code === 0 && responseData.data) {
			const newData = { key: responseData.data.cola_key, endTime: responseData.data.end_time };
			await env.DEVICE_STATUS.put(COLA_KEY_STORE_KEY, JSON.stringify(newData));
			console.log('Successfully fetched and stored a new ColaKey.');
			return newData.key;
		} else {
			throw new Error(`API returned an error: ${JSON.stringify(responseData)}`);
		}
	} catch (error) {
		console.error('Failed to fetch a new ColaKey:', error);
		return null;
	}
}

async function sendEmail(env, subject, body) {
	const colaKey = await getValidColaKey(env);
	if (!colaKey) {
		console.error('Could not obtain a valid ColaKey. Email sending aborted.');
		return;
	}
	
	const requestBody = {
		ColaKey: colaKey,
		tomail: env.EMAIL_TO,
		fromTitle: env.FROM_TITLE,
		subject: subject,
		smtpCode: env.SMTP_CODE,
		smtpEmail: env.SMTP_EMAIL,
		smtpCodeType: env.SMTP_CODE_TYPE,
		isTextContent: false,
		content: `<div style="font-family: sans-serif;">${body}</div>`,
	};

	try {
		const response = await fetch('https://luckycola.com.cn/tools/customMail', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify(requestBody),
		});
		if (response.ok) {
			console.log(`Email sent successfully: "${subject}"`);
		} else {
			const errorBody = await response.text();
			console.error(`Failed to send email. Status: ${response.status}, Body: ${errorBody}`);
		}
	} catch (error) {
		console.error('Error sending email:', error);
	}
}

所需创建的环境变量如下:

类型均为纯文本

变量名称:DEVICE_TOKENS
值:{"设备名1":"密码1", "设备名2":"密码2"}
例如:{"MyNAS":"nas", "MyPC":"pc"}

变量名称:EMAIL_TO
值:填写目的邮箱(想发到哪个邮箱就写哪个)

变量名称:FROM_TITLE
值:邮件发送标题,自定义项,API发送必要参数

变量名称:UID
值:填写从Luckycola获取到的用户ID

变量名称:APP_KEY
值:填写从Luckycola获取到的APPKEY

变量名称:SMTP_CODE_TYPE
值:填写邮箱类型,当前API仅支持“163/126/qq”三个值,任选其一

变量名称:SMTP_EMAIL
值:填写开启了SMTP服务的邮箱地址

变量名称:SMTP_CODE
值:填写邮件系统授权码

cron表达式:*/3 * * * *
意为每3分钟执行。正常情况下相当于每天执行480次list操作,低于免费列出额度的50%。

根据我们自己的需要,选择代码、环境变量、cron表达式分别填入并点击部署:

image12.png

image13.png

image14.png

关于Cron表达式的填写

至此,位于Cloudflare Worker上的服务端部署完成

配置客户端

Docker

upload 附件:cfheartbeat.zip
(压缩包内仅四个文件,无毒无后门,放心食用)
将以上压缩包文件选定一个位置解压并导入到docker-compose中

image.png
在环境变量中按需填写相关内容,构建即可;

MP插件

MP插件已同步上传至github,仅需要在MP项目中插件市场添加如下仓库:
https://github.com/EWEDLCM/MoviePilot-Plugins/
随后找到以下插件,进行安装配置并启用即可

image.png

image.png

总结

  • 本项目仅提供Docker和MP插件两个客户端方案,Docker压缩包中Python脚本实际亦可以运行于青龙面板(在环境变量''处填写响相应内容即可)
  • Cloudflare Worker服户端也可以用自有服务器进行部署,有需要可自行利用ai进行制作,客户端无需修改。
  • 由于近几年国内的网络环境对Cloudflare的直连一直都不太友好,各地区情况有所不同,在配置时,需要根据实际情况做相应的时间间隔冗余。如果是在无法进行链接或不够稳定,可能需要采取更加科学的上网。
  • 文章结束,完结撒花!!
收藏
送赞
分享

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x

10

主题

34

回帖

0

牛值

fnOS系统内测组

社区上线纪念勋章

略略略略略略

1

主题

2

回帖

0

牛值

江湖小虾

不错,就是太复杂了,看的头晕。

14

主题

110

回帖

195

牛值

共建版主

社区上线纪念勋章社区共建团荣誉勋章

昨天 17:22 楼主 显示全部楼层

哆哒哒哒哒~,哆哒哒哒哒~

14

主题

110

回帖

195

牛值

共建版主

社区上线纪念勋章社区共建团荣誉勋章

昨天 17:24 楼主 显示全部楼层
musichub 发表于 2025-9-1 17:19
不错,就是太复杂了,看的头晕。

啊这!除去申请appkey,其他内容跟着走一遍5分钟内就能搞定的

3

主题

299

回帖

0

牛值

社区共建团

围观神贴titter

1

主题

72

回帖

155

牛值

社区共建团

社区上线纪念勋章社区共建团荣誉勋章飞牛百度网盘玩家

技术贴

科学技术这一仗,一定要打,而且必须打好。

                                                -------**
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则