├── .gitignore ├── LICENSE ├── README.md ├── ali_oss_backup.py ├── backup_entry.py ├── config ├── heartbeat.eg ├── host.eg ├── monitor.eg └── sys.eg ├── download_nezha.sh ├── download_nezha_v1.sh ├── heart_beat_config_entry.py ├── heart_beat_entry.sh ├── heart_beat_logic.py ├── host_config_entry.py ├── logger_wrapper.py ├── main.py ├── notify_entry.py ├── paramiko_client.py ├── process_monitor.sh ├── pushplus_notify.py ├── qcloud_cos_backup.py ├── qiniu_backup.py ├── qywx_app_notify.py ├── qywx_notify.py ├── requirements.txt ├── sys_config_entry.py ├── tg_notify.py ├── utils.py └── utils.sh /.gitignore: -------------------------------------------------------------------------------- 1 | *.zip 2 | *.tar.gz 3 | *.log 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | 14 | 15 | 16 | # ide begin 17 | .idea 18 | /venv/ 19 | # ide end 20 | 21 | 22 | # sublime begin 23 | sftp-config.json 24 | # sublime end 25 | 26 | /tmp/ 27 | /log/ 28 | *.bak 29 | *.conf 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # serv00和ct8主机一键安装哪吒探针和多主机保活 2 | 3 | ![构建哪吒探针FreeBSD二进制安装包](https://github.com/nezhahq/nezha/raw/master/.github/brand.svg) 4 | 5 | 6 | ![Os][os-shield] 7 | [![Release][release-shield]][release-url] 8 | [![Forks][forks-shield]][forks-url] 9 | [![Stargazers][stars-shield]][stars-url] 10 | [![Issues][issues-shield]][issues-url] 11 | [![Contributors][contributors-shield]][contributors-url] 12 | [![License][license-shield]][license-url] 13 | 14 | 15 | ## 1 背景 16 | 基于`serv00`和`ct8`这种配置较低的主机,比较适合用来做探针。目前还没发现能自动安装哪吒探针面板和agent客户端的脚本,以及多主机相互保活、探针进程保活等,所以写了这个项目。 17 | 18 | 哪吒探针v0版本的效果体验:[https://monitor1.typecodes.us.kg](https://monitor1.typecodes.us.kg) 。 19 | 20 | 哪吒探针v1版本的效果体验:[https://monitor2.typecodes.us.kg](https://monitor2.typecodes.us.kg) 。 21 | 22 | 23 | ## 2 特点 24 | 25 | ``` 26 | 1、【安装简单】:支持一键安装最新v1/v0版本的哪吒探针dashboard或者agent客户端; 27 | 2、【自动保活】:弃用PM2,通过自动生成crontab,实现了探针进程监控保活以及主机间相互保活; 28 | 3、【自动保活】:当某个主机探针进程掉线时,本机或者其它保活的主机都能自动重新拉起本机探针进程; 29 | 4、【外部保活】:对于单台serv00/ct8主机,也可以通过 青龙面板 或者其它云主机对它进行探针进程监控和保活; 30 | 5、【扩展性强】:支持保活自定义的进程,只需把任该进程追加到monitor.conf配置文件即可; 31 | 6、【通信安全】:多主机之间使用ssh公私钥进行通信保活,不会暴露主机密码; 32 | 7、【监控通知】:支持企业微信机器人、企业微信app应用、Telegram、pushPlus等重要域名进行监控和通知; 33 | 8、【数据备份】:支持七牛、腾讯云cos、阿里云oss云存储备份哪吒面板数据库。 34 | ``` 35 | 36 | 37 | ## 2 使用步骤 38 | 39 | ``` 40 | 1、下载项目: git clone https://github.com/vfhky/serv00_ct8_nezha.git 41 | 2、进入项目: cd serv00_ct8_nezha 42 | 3、追加其它保活主机(非必须的操作): vim config/host.eg 43 | 4、开始安装: python3 main.py 。 44 | ``` 45 | 46 | 47 | ## 3 配置文件说明 48 | 49 | 在`config`配置目录下面有4个模板文件,其中`host.eg`和`sys.eg`这两个配置文件是需要`【手工配置】`,其它两个文件都不需要修改(系统会自动根据相关逻辑生成对应的`xxx.conf`配置文件)。 50 | 51 | #### 3.1 主机配置模板 host.eg 52 | 53 | 用于填写需要相互保活的主机信息。 54 | 55 | 1、当你有多台serv00/ct8机器时,通过这个配置实现主机间的相互保活。例如用当前serv00/ct8主机和另外一个s9的serv00机器(用户名是vhub)做相互保活,那么在文件中追加s9的配置即可: 56 | 57 | ``` 58 | # hostname|port|username|password 59 | s9.serv00.com|22|vhub|password 60 | ``` 61 | 62 | 2、假如你只有一台serv00/ct8机器,则不需要修改(可以借助青龙面板等外部定时任务来保活)。 63 | 64 | #### 3.2 系统配置模板 sys.eg 65 | 66 | 这个是系统配置文件,可以控制开启企业微信机器人、企业微信app应用、TG、pushPlus、七牛云备份等功能。 67 | 68 | #### 3.3 进程监控模板 monitor.eg 69 | 70 | 用于监控需要保活的进程。当进程(如探针dashboard面板)掉线时,会通过本机或者其它相互保活的主机的crontab自动重新拉起本机的这个进程。 71 | 72 | 在安装完哪吒dashboard或agent后,系统会自动生成类似以下的配置。当然也可以手工追加任意一个其它进程来实现该进程的监控保活。 73 | 74 | ``` 75 | /home/vfhky/nezha_app/agent|nezha-agent|sh nezha-agent.sh|foreground 76 | /home/vfhky/nezha_app/dashboard|nezha-dashboard|./nezha-dashboard|background 77 | ``` 78 | 79 | #### 3.4 多主机心跳保活模板 heartbeat.eg 80 | 81 | 用于对其它serv00/ct8机器保活(也包括进程保活等)。当在`host.eg`配置文件中新增了要相互保活的主机,系统会自动生成多主机间相互保活的配置数据(示例如下): 82 | 83 | `s9.serv00.com|22|vhub` 84 | 85 | 86 | ## 4 相关手册 87 | 88 | 以下是`安装哪吒探针`、`探针进程监控保活`、`多主机保活原理`、`面板sqlite.db备份`等功能的文档,方便大家参考查阅: 89 | 90 | #### 4.1 详细安装过程 91 | 92 | 1、常规手工安装哪吒探针V0版本: 包括如何server00开启应用、TCP端口、申请github的token等等,[《在serv00主机上安装哪吒探针》](https://typecodes.com/linux/server00installnezha.html) 93 | 94 | 2、一键安装哪吒探针V0版本: [《serv00和ct8主机一键安装哪吒探针和多主机保活》](https://typecodes.com/python/serv00ct8nezha.html) 95 | 96 | 3、一键安装哪吒探针V1版本: [serv00和ct8主机一键安装哪吒探针V1版本和多主机保活](https://typecodes.com/python/serv00ct8nezhav1.html) 97 | 98 | 4、升级哪吒探针V1版本开通Github、Gitee的OAuth2登录: [serv00和ct8上的哪吒探针V1开启Github和Gitee登录](https://typecodes.com/python/serv00ct8nezhav1githubgiteelogin.html) 99 | 100 | #### 4.2 其它功能 101 | 102 | 1、使用七牛、腾讯云cos、阿里云oss云存储备份哪吒面板数据库: [《serv00和ct8主机一键安装哪吒探针和多主机保活(五)》](https://typecodes.com/python/serv00ct8nezha5.html) 103 | 104 | 2、使用青龙面板对单台serv00保活: [《serv00和ct8主机一键安装哪吒探针和多主机保活(三)》](https://typecodes.com/python/serv00ct8nezha3.html) 105 | 106 | 3、utils.sh 强大的serv00脚本工具: [《serv00和ct8主机一键安装哪吒探针和多主机保活(四)》](https://typecodes.com/python/serv00ct8nezha4.html) 107 | 108 | 109 | #### 4.3 架构原理 110 | 111 | 1、哪吒探针dashboard面板FreeBSD二进制安装包构建:[https://github.com/vfhky/nezha-build](https://github.com/vfhky/nezha-build) 112 | 113 | 2、探针面板安装包:[《github workflow构建哪吒探针FreeBSD安装包》](https://typecodes.com/linux/githubworkflownezhafreebsdserv00.html) 114 | 115 | 3、架构说明(含保活原理等): [《serv00和ct8主机一键安装哪吒探针和多主机保活(二)》](https://typecodes.com/python/serv00ct8nezha2.html) 116 | 117 | 4、修复项目中哪吒面板不显示主机区域的问题: [《serv00和ct8主机一键安装哪吒探针和多主机保活(六)》](https://typecodes.com/python/serv00ct8nezha6.html) 118 | 119 | 120 | ## 5 Stars 趋势 121 | 122 | [![Star History Chart](https://api.star-history.com/svg?repos=vfhky/serv00_ct8_nezha&type=Date)](https://star-history.com/#vfhky/serv00_ct8_nezha&Date) 123 | 124 | 125 | 126 | [os-shield]: https://img.shields.io/badge/FreeBSD-blue 127 | [release-shield]: https://img.shields.io/github/v/release/vfhky/serv00_ct8_nezha 128 | [release-url]: https://github.com/vfhky/serv00_ct8_nezha/releases 129 | [contributors-shield]: https://img.shields.io/github/contributors/vfhky/serv00_ct8_nezha 130 | [contributors-url]: https://github.com/vfhky/serv00_ct8_nezha/graphs/contributors 131 | [forks-shield]: https://img.shields.io/github/forks/vfhky/serv00_ct8_nezha?style=flat 132 | [forks-url]: https://github.com/vfhky/serv00_ct8_nezha/network/members 133 | [stars-shield]: https://img.shields.io/github/stars/vfhky/serv00_ct8_nezha?style=flat 134 | [stars-url]: https://github.com/vfhky/serv00_ct8_nezha/stargazers 135 | [issues-shield]: https://img.shields.io/github/issues/vfhky/serv00_ct8_nezha 136 | [issues-url]: https://github.com/vfhky/serv00_ct8_nezha/issues 137 | [license-shield]: https://img.shields.io/github/license/vfhky/serv00_ct8_nezha 138 | [license-url]: https://github.com/vfhky/serv00_ct8_nezha/blob/master/LICENSE?color=blue 139 | -------------------------------------------------------------------------------- /ali_oss_backup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from typing import Dict, Optional 4 | from logger_wrapper import LoggerWrapper 5 | from sys_config_entry import SysConfigEntry 6 | import oss2 7 | 8 | class AliOssBackup: 9 | _instance = None 10 | DATE_FORMAT = '%d' 11 | MONTH_FORMAT = '%Y%m' 12 | 13 | def __new__(cls, sys_config_entry: SysConfigEntry): 14 | if cls._instance is None: 15 | cls._instance = super().__new__(cls) 16 | cls._instance._initialized = False 17 | return cls._instance 18 | 19 | def __init__(self, sys_config_entry: SysConfigEntry): 20 | if getattr(self, '_initialized', False): 21 | return 22 | self._initialized = True 23 | self.sys_config_entry = sys_config_entry 24 | self.logger = LoggerWrapper() 25 | self.access_key_id = self.sys_config_entry.get("ALI_OSS_ACCESS_KEY_ID") 26 | self.access_key_secret = self.sys_config_entry.get("ALI_OSS_ACCESS_KEY_SECRET") 27 | self.endpoint = self.sys_config_entry.get("ALI_OSS_ENDPOINT") 28 | self.bucket_name = self.sys_config_entry.get("ALI_OSS_BUCKET_NAME") 29 | self.dir_name = self.sys_config_entry.get("ALI_OSS_DIR_NAME") 30 | self.ttl = int(self.sys_config_entry.get("ALI_OSS_EXPIRE_DAYS", 7)) 31 | 32 | self.auth = oss2.Auth(self.access_key_id, self.access_key_secret) 33 | self.bucket = oss2.Bucket(self.auth, self.endpoint, self.bucket_name) 34 | self._ensure_bucket_exists() 35 | self._set_lifecycle_rule() 36 | 37 | def _ensure_bucket_exists(self): 38 | try: 39 | self.bucket.get_bucket_info() 40 | self.logger.info(f"====> 阿里云oss bucket: {self.bucket_name} 已经存在") 41 | except oss2.exceptions.NoSuchBucket: 42 | try: 43 | self.bucket.create_bucket() 44 | self.logger.info(f"====> 阿里云oss创建bucket: {self.bucket_name} 成功") 45 | except Exception as e: 46 | self.logger.error(f"====> 阿里云oss创建bucket: {self.bucket_name} 失败: {str(e)}") 47 | raise 48 | 49 | def _set_lifecycle_rule(self): 50 | rule = oss2.models.LifecycleRule( 51 | id='delete_expired_files', 52 | prefix=self.dir_name, 53 | status='Enabled', 54 | expiration=oss2.models.LifecycleExpiration(days=self.ttl) 55 | ) 56 | try: 57 | lifecycle = oss2.models.BucketLifecycle([rule]) 58 | result = self.bucket.put_bucket_lifecycle(lifecycle) 59 | self.logger.info(f"====> 设置阿里云oss {self.bucket_name} 的生命周期成功 result={result.status}") 60 | except Exception as e: 61 | self.logger.error(f"====> 设置阿里云oss {self.bucket_name} 的生命周期失败: {str(e)}") 62 | 63 | def backup_dashboard_db(self, db_file: str) -> Optional[str]: 64 | try: 65 | now = datetime.now() 66 | date_prefix = now.strftime(self.DATE_FORMAT) 67 | month_dir = now.strftime(self.MONTH_FORMAT) 68 | 69 | file_name = os.path.basename(db_file) 70 | new_file_name = f"{date_prefix}_{file_name}" 71 | key = f"{self.dir_name}/{month_dir}/{new_file_name}" 72 | 73 | with open(db_file, 'rb') as file_obj: 74 | result = self.bucket.put_object(key, file_obj) 75 | 76 | if result.status == 200: 77 | self.logger.info(f"====> 阿里oss: [{db_file}] 上传成功 bucket_name={self.bucket_name} {key}") 78 | return f"{self.bucket_name}/{key}" 79 | else: 80 | self.logger.error(f"====> 阿里oss: [{db_file}] 上传失败 bucket_name={self.bucket_name} {key} 详情: {result}") 81 | return None 82 | except Exception as e: 83 | self.logger.error(f"====> 阿里oss: [{db_file}] 上传失败 bucket_name={self.bucket_name} {key} 错误:{str(e)}") 84 | return None 85 | -------------------------------------------------------------------------------- /backup_entry.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from logger_wrapper import LoggerWrapper 3 | from sys_config_entry import SysConfigEntry 4 | from qiniu_backup import QiniuBackup 5 | from qcloud_cos_backup import QCloudCosBackup 6 | from ali_oss_backup import AliOssBackup 7 | 8 | class BackupEntry: 9 | _instance = None 10 | 11 | def __new__(cls, sys_config_entry: SysConfigEntry): 12 | if cls._instance is None: 13 | cls._instance = super().__new__(cls) 14 | cls._instance._initialized = False 15 | return cls._instance 16 | 17 | def __init__(self, sys_config_entry: SysConfigEntry): 18 | if getattr(self, '_initialized', False): 19 | return 20 | self._initialized = True 21 | self.logger = LoggerWrapper() 22 | self.sys_config_entry = sys_config_entry 23 | self.qiniu_backup = QiniuBackup(self.sys_config_entry) if self.sys_config_entry.get("ENABLE_QINIU_BACKUP") == "1" else None 24 | self.qcloud_cos_backup = QCloudCosBackup(self.sys_config_entry) if self.sys_config_entry.get("ENABLE_QCLOUD_COS_BACKUP") == "1" else None 25 | self.ali_oss_backup = AliOssBackup(self.sys_config_entry) if self.sys_config_entry.get("ENABLE_ALI_OSS_BACKUP") == "1" else None 26 | 27 | def backup_dashboard_db(self, db_file: str): 28 | self._backup_dashboard_db("backup_dashboard_db", db_file=db_file) 29 | 30 | def _backup_dashboard_db(self, method_name: str, **kwargs): 31 | if self.qiniu_backup: 32 | getattr(self.qiniu_backup, method_name)(**kwargs) 33 | if self.qcloud_cos_backup: 34 | getattr(self.qcloud_cos_backup, method_name)(**kwargs) 35 | if self.ali_oss_backup: 36 | getattr(self.ali_oss_backup, method_name)(**kwargs) 37 | -------------------------------------------------------------------------------- /config/heartbeat.eg: -------------------------------------------------------------------------------- 1 | # ============================= SYSTEM AUTO GENERATE ============================= 2 | # serv00_ct8_host|serv00_ct8_port|serv00_ct8_username 3 | -------------------------------------------------------------------------------- /config/host.eg: -------------------------------------------------------------------------------- 1 | # hostname|port|username|password 2 | -------------------------------------------------------------------------------- /config/monitor.eg: -------------------------------------------------------------------------------- 1 | # process_path|process_name|./process_run|process_run_mode(background or foreground) 2 | # /home/usernamexxxxxx/nezha_app/nezha-dashboard|nezha-dashboard|./nezha-dashboard|background 3 | -------------------------------------------------------------------------------- /config/sys.eg: -------------------------------------------------------------------------------- 1 | ########################################## 系统常量配置 ########################################## 2 | # 心跳的时候是否要监控域名并发送通知. 0-否 1-是 3 | CHECK_MONITOR_URL_DNS=0 4 | # 监控域名访问成功时的通知时间小时点。如果不填写则默认24小时都通知。每个小时只通知一次。 5 | OK_NOTIFY_HOURS=0, 8, 9, 10, 12, 14, 18, 19, 20, 21, 22, 23 6 | # 定时心跳任务脚本时间, */5 * * * * 7 | HEAT_BEAT_CRON_TABLE_TIME=*/5 * * * * 8 | # 要监控的域名 9 | MONITOR_URL=https://monitor.typecodes.com 10 | # 企业微信机器人 11 | ENABLE_QYWX_NOTIFY=1 12 | QYWX_ROBOT_KEY=xxxxxx 13 | # telegram 14 | ENABLE_TG_NOTIFY=1 15 | TG_ROBOT_KEY=xxxxxx 16 | TG_CHAT_ID=xxxxxx 17 | # PushPlus 18 | ENABLE_PUSHPLUS_NOTIFY=0 19 | PUSHPLUS_KEY=xxxxxx 20 | # 企业微信app推送 21 | ENABLE_QYWX_APP_NOTIFY=0 22 | QYWX_APP_CROP_ID=xxxxxx 23 | QYWX_APP_SECRET=xxxxxx 24 | QYWX_APP_AGENT_ID=xxxxxx 25 | QYWX_APP_NOTIFY_USER=@all 26 | # 开启七牛备份. 0-否 1-是 27 | ENABLE_QINIU_BACKUP=0 28 | QINIU_ACCESS_KEY=xxxxxx 29 | QINIU_SECRET_KEY=xxxxxx 30 | # https://developer.qiniu.com/kodo/1671/region-endpoint-fq 31 | QINIU_REGION=z2 32 | QINIU_BUCKET_NAME=serv00-ct8-nezha 33 | QINIU_DIR_NAME=serv00_ct_nezha 34 | # 文件保存多少天 35 | QINIU_EXPIRE_DAYS=30 36 | # 开启腾讯云cos备份. 0-否 1-是 37 | ENABLE_QCLOUD_COS_BACKUP=0 38 | QCLOUD_COS_APP_ID=xxxxxx 39 | QCLOUD_COS_SECRET_ID=xxxxxx 40 | QCLOUD_COS_SECRET_KEY=xxxxxx 41 | # https://cloud.tencent.com/document/product/436/6224 42 | QCLOUD_COS_REGION=ap-guangzhou 43 | QCLOUD_COS_BUCKET_NAME=serv00-ct8-nezha 44 | QCLOUD_COS_DIR_NAME=serv00_ct_nezha 45 | QCLOUD_COS_EXPIRE_DAYS=30 46 | # 开启阿里云OSS备份. 0-否 1-是 47 | ENABLE_ALI_OSS_BACKUP=0 48 | ALI_OSS_ACCESS_KEY_ID=xxxxxx 49 | ALI_OSS_ACCESS_KEY_SECRET=xxxxxx 50 | # https://help.aliyun.com/zh/oss/user-guide/regions-and-endpoints 51 | ALI_OSS_ENDPOINT=oss-cn-guangzhou.aliyuncs.com 52 | ALI_OSS_BUCKET_NAME=serv00-ct8-nezha 53 | ALI_OSS_DIR_NAME=serv00_ct_nezha 54 | ALI_OSS_EXPIRE_DAYS=30 55 | -------------------------------------------------------------------------------- /download_nezha.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | err() { 5 | printf "${red}$*${plain}\n" >&2 6 | } 7 | 8 | install_base() { 9 | (command -v curl >/dev/null 2>&1 && command -v wget >/dev/null 2>&1 && command -v unzip >/dev/null 2>&1) || 10 | (install_soft curl wget unzip) 11 | } 12 | 13 | install_soft() { 14 | (command -v yum >/dev/null 2>&1 && yum makecache && yum install $* selinux-policy -y) || 15 | (command -v apt >/dev/null 2>&1 && apt update && apt install $* selinux-utils -y) || 16 | (command -v pacman >/dev/null 2>&1 && pacman -Syu $* base-devel --noconfirm && install_arch) || 17 | (command -v apt-get >/dev/null 2>&1 && apt-get update && apt-get install $* selinux-utils -y) || 18 | (command -v apk >/dev/null 2>&1 && apk update && apk add $* -f) 19 | } 20 | 21 | geo_check() { 22 | api_list="https://blog.cloudflare.com/cdn-cgi/trace https://dash.cloudflare.com/cdn-cgi/trace https://cf-ns.com/cdn-cgi/trace" 23 | ua="Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/81.0" 24 | set -- $api_list 25 | for url in $api_list; do 26 | text="$(curl -A $ua -m 10 -s $url)" 27 | if echo $text | grep -qw 'CN'; then 28 | isCN=true 29 | break 30 | fi 31 | done 32 | } 33 | 34 | pre_check() { 35 | os_type=$(uname -s) 36 | os_arch=$(uname -m) 37 | 38 | case "$os_type" in 39 | FreeBSD|Linux) 40 | ;; 41 | *) 42 | echo "Unsupported operating system: $os_type" 43 | exit 1 44 | ;; 45 | esac 46 | 47 | case "$os_arch" in 48 | x86_64|amd64) 49 | os_arch="amd64" 50 | ;; 51 | i386|i686) 52 | os_arch="386" 53 | ;; 54 | aarch64|armv8b|armv8l) 55 | os_arch="arm64" 56 | ;; 57 | arm*) 58 | os_arch="arm" 59 | ;; 60 | s390x) 61 | os_arch="s390x" 62 | ;; 63 | riscv64) 64 | os_arch="riscv64" 65 | ;; 66 | *) 67 | echo "Unsupported architecture: $os_arch" 68 | exit 1 69 | ;; 70 | esac 71 | 72 | ## China_IP 73 | if [ -z "$CN" ]; then 74 | geo_check 75 | if [ ! -z "$isCN" ]; then 76 | echo "根据geoip api提供的信息,当前IP可能在中国" 77 | printf "是否选用中国镜像完成安装? [Y/n] (自定义镜像输入 3):" 78 | read -r input 79 | case $input in 80 | [yY][eE][sS] | [yY]) 81 | echo "使用中国镜像" 82 | CN=true 83 | ;; 84 | 85 | [nN][oO] | [nN]) 86 | echo "不使用中国镜像" 87 | ;; 88 | 89 | [3]) 90 | echo "使用自定义镜像" 91 | printf "请输入自定义镜像 (例如:dn-dao-github-mirror.daocloud.io),留空为不使用: " 92 | read -r input 93 | case $input in 94 | *) 95 | CUSTOM_MIRROR=$input 96 | ;; 97 | esac 98 | 99 | ;; 100 | *) 101 | echo "使用中国镜像" 102 | CN=true 103 | ;; 104 | esac 105 | fi 106 | fi 107 | 108 | if [ -n "$CUSTOM_MIRROR" ]; then 109 | GITHUB_RAW_URL="gitee.com/naibahq/nezha/raw/master" 110 | GITHUB_URL=$CUSTOM_MIRROR 111 | else 112 | if [ -z "$CN" ]; then 113 | GITHUB_RAW_URL="raw.githubusercontent.com/naiba/nezha/master" 114 | GITHUB_URL="github.com" 115 | else 116 | GITHUB_RAW_URL="gitee.com/naibahq/nezha/raw/master" 117 | GITHUB_URL="gitee.com" 118 | fi 119 | fi 120 | } 121 | 122 | # Function to prompt and read input with validation 123 | prompt_input() { 124 | local prompt_message=$1 125 | local default_value=$2 126 | local input_variable_name=$3 127 | 128 | while true; do 129 | printf "%s" "$prompt_message" 130 | read -r input_value 131 | if [ -z "$input_value" ] && [ -n "$default_value" ]; then 132 | eval "$input_variable_name='$default_value'" 133 | break 134 | elif [ -n "$input_value" ]; then 135 | eval "$input_variable_name='$input_value'" 136 | break 137 | else 138 | echo "输入不能为空,请重新输入。" 139 | fi 140 | done 141 | } 142 | 143 | modify_dashboard_config() { 144 | echo "> 修改面板配置" 145 | 146 | local config_file="${NZ_DASHBOARD_PATH}/nezha-config.yaml" 147 | if [ "$IS_DOCKER_NEZHA" = 1 ]; then 148 | echo "正在下载 Docker 脚本" 149 | wget -t 2 -T 60 -O /tmp/nezha-docker-compose.yaml https://${GITHUB_RAW_URL}/script/docker-compose.yaml >/dev/null 2>&1 150 | if [ $? != 0 ]; then 151 | err "下载脚本失败,请检查本机能否连接 ${GITHUB_RAW_URL}" 152 | return 0 153 | fi 154 | fi 155 | 156 | wget -t 2 -T 60 -O "${config_file}" https://${GITHUB_RAW_URL}/script/config.yaml >/dev/null 2>&1 157 | if [ $? != 0 ]; then 158 | err "下载脚本失败,请检查本机能否连接 https://${GITHUB_RAW_URL}/script/config.yaml" 159 | return 0 160 | fi 161 | 162 | echo "关于 GitHub Oauth2 应用:在 https://github.com/settings/developers 创建,无需审核,Callback 填 http(s)://域名或IP/oauth2/callback" 163 | 164 | prompt_input "===> 请输入 OAuth2 提供商(github/gitlab/jihulab/gitee): " "" nz_oauth2_type 165 | prompt_input "===> 请输入 Oauth2 应用的 Client ID: " "" nz_github_oauth_client_id 166 | prompt_input "===> 请输入 Oauth2 应用的 Client Secret: " "" nz_github_oauth_client_secret 167 | prompt_input "===> 请输入 GitHub/Gitee 登录名作为管理员,多个以逗号隔开: " "" nz_admin_logins 168 | prompt_input "===> 请输入站点标题(如 TypeCodes Monitor): " "TypeCodes Monitor" nz_site_title 169 | prompt_input "===> 请输入站点访问端口: " "" nz_site_port 170 | prompt_input "===> 请输入用于 Agent 接入的 RPC 端口: " "" nz_grpc_port 171 | 172 | # -i "s/nz_oauth2_type/${nz_oauth2_type}/" "${config_file}" 173 | sed -i '' "s/nz_oauth2_type/${nz_oauth2_type}/" "${config_file}" 174 | 175 | #sed -i "s/nz_admin_logins/${nz_admin_logins}/" "${config_file}" 176 | sed -i '' "s/nz_admin_logins/${nz_admin_logins}/" "${config_file}" 177 | 178 | #sed -i "s/nz_grpc_port/${nz_grpc_port}/" "${config_file}" 179 | sed -i '' "s/nz_grpc_port/${nz_grpc_port}/" "${config_file}" 180 | 181 | #sed -i "s/nz_github_oauth_client_id/${nz_github_oauth_client_id}/" "${config_file}" 182 | sed -i '' "s/nz_github_oauth_client_id/${nz_github_oauth_client_id}/" "${config_file}" 183 | 184 | #sed -i "s/nz_github_oauth_client_secret/${nz_github_oauth_client_secret}/" "${config_file}" 185 | sed -i '' "s/nz_github_oauth_client_secret/${nz_github_oauth_client_secret}/" "${config_file}" 186 | 187 | #sed -i "s/nz_language/zh-CN/" "${config_file}" 188 | sed -i '' "s/nz_language/zh-CN/" "${config_file}" 189 | 190 | #sed -i "s/nz_site_title/${nz_site_title}/" "${config_file}" 191 | sed -i '' "s/nz_site_title/${nz_site_title}/" "${config_file}" 192 | 193 | if [ "$IS_DOCKER_NEZHA" = 1 ]; then 194 | #sed -i "s/nz_site_port/${nz_site_port}/" /tmp/nezha-docker-compose.yaml 195 | sed -i '' "s/nz_site_port/${nz_site_port}/" /tmp/nezha-docker-compose.yaml 196 | 197 | #sed -i "s/nz_grpc_port/${nz_grpc_port}/g" /tmp/nezha-docker-compose.yaml 198 | sed -i '' "s/nz_grpc_port/${nz_grpc_port}/g" /tmp/nezha-docker-compose.yaml 199 | 200 | #sed -i "s/nz_image_url/${Docker_IMG}/" /tmp/nezha-docker-compose.yaml 201 | sed -i '' "s/nz_image_url/${Docker_IMG}/" /tmp/nezha-docker-compose.yaml 202 | else 203 | #sed -i "s/80/${nz_site_port}/" "${config_file}" 204 | sed -i '' "s/80/${nz_site_port}/" "${config_file}" 205 | fi 206 | 207 | mkdir -p $NZ_DASHBOARD_PATH/data 2>/dev/null 208 | \mv -f "${config_file}" ${NZ_DASHBOARD_PATH}/data/config.yaml 209 | if [ "$IS_DOCKER_NEZHA" = 1 ]; then 210 | mv -f /tmp/nezha-docker-compose.yaml ${NZ_DASHBOARD_PATH}/docker-compose.yaml 211 | fi 212 | 213 | printf "===> 面板配置 ${green}修改成功 ${plain}\n" 214 | } 215 | 216 | download_dashboard() { 217 | pre_check 218 | install_base 219 | 220 | NZ_DASHBOARD_PATH=$1 221 | mkdir -p "${NZ_DASHBOARD_PATH}" 222 | 223 | local version='v0.20.13' 224 | 225 | if [ ! -n "$version" ]; then 226 | err "获取版本号失败,请检查本机能否访问 https://api.github.com/repos/naiba/nezha/releases/latest" 227 | return 1 228 | else 229 | version_num=$(echo "$version" | sed 's/^v//') 230 | echo "当前最新版本为: v${version_num}" 231 | fi 232 | 233 | NZ_DASHBOARD_URL="https://github.com/vfhky/nezha-build/releases/download/${version}/nezha-dashboard.zip" 234 | #if [ -z "$CN" ]; then 235 | # NZ_DASHBOARD_URL="https://${GITHUB_URL}/naiba/nezha/archive/refs/tags/$version.zip" 236 | #else 237 | # NZ_DASHBOARD_URL="https://${GITHUB_URL}/naibahq/nezha/archive/refs/tags/$version.zip" 238 | #fi 239 | 240 | wget -qO "${NZ_DASHBOARD_PATH}"/app.zip $NZ_DASHBOARD_URL >/dev/null 2>&1 241 | if [ ! -f "${NZ_DASHBOARD_PATH}"/app.zip ]; then 242 | echo "===> [dashboard] ${NZ_DASHBOARD_URL} 下载失败,请检查是否能正常访问" 243 | exit 1 244 | fi 245 | 246 | echo "===> [dashboard] ${NZ_DASHBOARD_URL} 下载完成" 247 | 248 | local config_file="${NZ_DASHBOARD_PATH}/data/config.yaml" 249 | local config_file_bak='' 250 | if [[ -f "${config_file}" ]]; then 251 | config_file_bak="${NZ_DASHBOARD_PATH}/data/config.yaml_bak" 252 | \cp -f "${config_file}" "${config_file_bak}" 253 | echo "====> 已经备份dashboard的配置文件[${config_file}] 到 ${config_file_bak}" 254 | fi 255 | 256 | version_file="${NZ_DASHBOARD_PATH}/version.txt" 257 | if ! unzip -oqq "${NZ_DASHBOARD_PATH}"/app.zip -d "${NZ_DASHBOARD_PATH}"; then 258 | echo "====> [dashboard] ${NZ_DASHBOARD_PATH}/app.zip 解压失败" 259 | exit 1 260 | fi 261 | 262 | echo "v=${version_num}" > "${version_file}" 263 | \rm -rf "${NZ_DASHBOARD_PATH}"/app.zip 264 | 265 | if [[ -f "${config_file_bak}" ]]; then 266 | prompt_input "===> 是否继续使用旧的配置数据(Y/y 是,N/n 否): " "" modify 267 | if [[ "${modify}" =~ ^[Yy]$ ]]; then 268 | mv -f "${config_file_bak}" "${config_file}" 269 | echo "===> [dashboard] 已经成功使用旧的配置数据 ${config_file}" 270 | else 271 | echo "===> [dashboard] 准备输入新的配置数据" 272 | modify_dashboard_config 273 | fi 274 | else 275 | echo "===> [dashboard] 准备输入新的配置数据" 276 | modify_dashboard_config 277 | fi 278 | } 279 | 280 | download_agent() { 281 | pre_check 282 | install_base 283 | 284 | NZ_AGENT_PATH=$1 285 | 286 | local version='v0.20.5' 287 | 288 | if [ ! -n "$version" ]; then 289 | err "获取版本号失败,请检查本机能否链接 https://api.github.com/repos/nezhahq/agent/releases/latest" 290 | return 1 291 | else 292 | echo "当前最新版本为: ${version}" 293 | fi 294 | 295 | if [ -z "$CN" ]; then 296 | NZ_AGENT_URL="https://${GITHUB_URL}/nezhahq/agent/releases/download/${version}/nezha-agent_${os_type}_${os_arch}.zip" 297 | else 298 | NZ_AGENT_URL="https://${GITHUB_URL}/naibahq/agent/releases/download/${version}/nezha-agent_${os_type}_${os_arch}.zip" 299 | fi 300 | wget -t 2 -T 60 -O nezha-agent_linux_${os_arch}.zip $NZ_AGENT_URL >/dev/null 2>&1 301 | if [ $? != 0 ]; then 302 | err "Release 下载失败,请检查本机能否连接 ${GITHUB_URL}" 303 | return 1 304 | fi 305 | 306 | mkdir -p $NZ_AGENT_PATH 2>/dev/null 307 | unzip -qo nezha-agent_linux_${os_arch}.zip && 308 | \mv -f nezha-agent $NZ_AGENT_PATH && 309 | rm -rf nezha-agent_linux_${os_arch}.zip 310 | 311 | echo "===> Agent ${NZ_AGENT_URL} 下载完成" 312 | 313 | gen_agent_run_sh 314 | } 315 | 316 | gen_agent_run_sh() { 317 | agent_run_sh="${NZ_AGENT_PATH}/nezha-agent.sh" 318 | 319 | prompt_input "===> 请输入面板的域名: " "" nz_proxy_domain 320 | prompt_input "===> 面板的GRPCPort通信端口: " "" nz_proxy_port 321 | prompt_input "===> 面板的设置agent密钥,也可以后期再修改: " "password1234" nz_password 322 | prompt_input "===> 启用针对 gRPC 端口的 SSL/TLS加密,无特殊情况请选择N-否: " "N" nz_tls 323 | 324 | user_tmpdir="${NZ_AGENT_PATH}/tmp" 325 | if [ ! -d "${user_tmpdir}" ]; then 326 | mkdir -p "${user_tmpdir}" 327 | fi 328 | 329 | tls_flag="" 330 | if echo "$nz_tls" | grep -qiw 'Y'; then 331 | tls_flag="--tls" 332 | fi 333 | 334 | cat < "${agent_run_sh}" 335 | #!/bin/bash 336 | 337 | export TMPDIR=${user_tmpdir} 338 | 339 | nohup ${NZ_AGENT_PATH}/nezha-agent \\ 340 | -s ${nz_proxy_domain}:${nz_proxy_port} \\ 341 | -p ${nz_password} \\ 342 | $tls_flag \\ 343 | -d > /dev/null 2>&1 & 344 | EOF 345 | 346 | chmod +x "${agent_run_sh}" 347 | } 348 | 349 | modify_config() { 350 | echo "====> 开始准备修改,已知哪吒安装目录为[$1]" 351 | pre_check 352 | NZ_APP_PATH=$1 353 | 354 | prompt_input "===> 是否修改dashboard配置(Y/y 是,N/n 否): " "" modify 355 | if [[ "${modify}" =~ ^[Yy]$ ]]; then 356 | NZ_DASHBOARD_PATH="${NZ_APP_PATH}/dashboard" 357 | NZ_DASHBOARD_CONFIG_FILE="${NZ_DASHBOARD_PATH}/data/config.yaml" 358 | if [[ ! -f "${NZ_DASHBOARD_CONFIG_FILE}" ]]; then 359 | echo "dashboard的配置文件[${NZ_DASHBOARD_CONFIG_FILE}]不存在,请检查是否已经安装过了dashboard" 360 | exit 1 361 | fi 362 | 363 | echo "====> 准备开始修改dashboard配置文件[${NZ_DASHBOARD_CONFIG_FILE}]" 364 | modify_dashboard_config 365 | 366 | dashboard_pid=$(pgrep -f nezha-dashboard) 367 | if [[ -n "$dashboard_pid" ]]; then 368 | kill -9 "$dashboard_pid" 369 | echo "====> 关闭哪吒dashboard进程成功" 370 | fi 371 | fi 372 | 373 | prompt_input "===> 是否修改agent配置(Y/y 是,N/n 否): " "" modify 374 | if [[ "${modify}" =~ ^[Yy]$ ]]; then 375 | NZ_AGENT_PATH="${NZ_APP_PATH}/agent" 376 | agent_run_sh="${NZ_AGENT_PATH}/nezha-agent.sh" 377 | if [[ ! -f "${agent_run_sh}" ]]; then 378 | echo "agent的配置文件[${agent_run_sh}]不存在,请检查是否已经安装过了agent" 379 | exit 1 380 | fi 381 | 382 | echo "====> 准备开始修改agent配置文件[${NZ_DASHBOARD_CONFIG_FILE}]" 383 | gen_agent_run_sh 384 | 385 | agent_pid=$(pgrep -f nezha-agent) 386 | if [[ -n "$agent_pid" ]]; then 387 | kill -9 "$agent_pid" 388 | echo "====> 关闭哪吒agent进程成功" 389 | fi 390 | fi 391 | } 392 | 393 | 394 | if [ "$#" -lt 2 ]; then 395 | echo "Error: Not enough arguments provided." 396 | echo "Usage: $0 " 397 | echo "Commands:" 398 | echo " dashboard app 下载 dashboard" 399 | echo " agent 下载 agent" 400 | echo " config 修改dashboard或者agent的配置" 401 | exit 1 402 | fi 403 | 404 | command="$1" 405 | arg="$2" 406 | 407 | case "$command" in 408 | "dashboard") 409 | download_dashboard "$arg" 410 | ;; 411 | "agent") 412 | download_agent "$arg" 413 | ;; 414 | "config") 415 | modify_config "$arg" 416 | ;; 417 | *) 418 | echo "Error: Invalid command '$command'" 419 | echo "====== Usage ======" 420 | echo " $0 dashboard 下载 dashboard" 421 | echo " $0 agent 下载 agent" 422 | echo " $0 config 修改dashboard或者agent的配置" 423 | exit 1 424 | ;; 425 | esac 426 | 427 | -------------------------------------------------------------------------------- /download_nezha_v1.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | err() { 5 | printf "${red}$*${plain}\n" >&2 6 | } 7 | 8 | install_base() { 9 | (command -v curl >/dev/null 2>&1 && command -v wget >/dev/null 2>&1 && command -v unzip >/dev/null 2>&1) || 10 | (install_soft curl wget unzip) 11 | } 12 | 13 | install_soft() { 14 | (command -v yum >/dev/null 2>&1 && yum makecache && yum install $* selinux-policy -y) || 15 | (command -v apt >/dev/null 2>&1 && apt update && apt install $* selinux-utils -y) || 16 | (command -v pacman >/dev/null 2>&1 && pacman -Syu $* base-devel --noconfirm && install_arch) || 17 | (command -v apt-get >/dev/null 2>&1 && apt-get update && apt-get install $* selinux-utils -y) || 18 | (command -v apk >/dev/null 2>&1 && apk update && apk add $* -f) 19 | } 20 | 21 | geo_check() { 22 | api_list="https://blog.cloudflare.com/cdn-cgi/trace https://dash.cloudflare.com/cdn-cgi/trace https://cf-ns.com/cdn-cgi/trace" 23 | ua="Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/81.0" 24 | set -- $api_list 25 | for url in $api_list; do 26 | text="$(curl -A $ua -m 10 -s $url)" 27 | if echo $text | grep -qw 'CN'; then 28 | isCN=true 29 | break 30 | fi 31 | done 32 | } 33 | 34 | pre_check() { 35 | os_type=$(uname -s) 36 | os_arch=$(uname -m) 37 | 38 | case "$os_type" in 39 | FreeBSD|Linux) 40 | ;; 41 | *) 42 | echo "Unsupported operating system: $os_type" 43 | exit 1 44 | ;; 45 | esac 46 | 47 | case "$os_arch" in 48 | x86_64|amd64) 49 | os_arch="amd64" 50 | ;; 51 | i386|i686) 52 | os_arch="386" 53 | ;; 54 | aarch64|armv8b|armv8l) 55 | os_arch="arm64" 56 | ;; 57 | arm*) 58 | os_arch="arm" 59 | ;; 60 | s390x) 61 | os_arch="s390x" 62 | ;; 63 | riscv64) 64 | os_arch="riscv64" 65 | ;; 66 | *) 67 | echo "Unsupported architecture: $os_arch" 68 | exit 1 69 | ;; 70 | esac 71 | 72 | ## China_IP 73 | if [ -z "$CN" ]; then 74 | geo_check 75 | if [ ! -z "$isCN" ]; then 76 | echo "根据geoip api提供的信息,当前IP可能在中国" 77 | printf "是否选用中国镜像完成安装? [Y/n] (自定义镜像输入 3):" 78 | read -r input 79 | case $input in 80 | [yY][eE][sS] | [yY]) 81 | echo "使用中国镜像" 82 | CN=true 83 | ;; 84 | 85 | [nN][oO] | [nN]) 86 | echo "不使用中国镜像" 87 | ;; 88 | 89 | [3]) 90 | echo "使用自定义镜像" 91 | printf "请输入自定义镜像 (例如:dn-dao-github-mirror.daocloud.io),留空为不使用: " 92 | read -r input 93 | case $input in 94 | *) 95 | CUSTOM_MIRROR=$input 96 | ;; 97 | esac 98 | 99 | ;; 100 | *) 101 | echo "使用中国镜像" 102 | CN=true 103 | ;; 104 | esac 105 | fi 106 | fi 107 | 108 | if [ -n "$CUSTOM_MIRROR" ]; then 109 | GITHUB_RAW_URL="gitee.com/naibahq/scripts/raw/main" 110 | GITHUB_URL=$CUSTOM_MIRROR 111 | Docker_IMG="registry.cn-shanghai.aliyuncs.com\/naibahq\/nezha-dashboard" 112 | else 113 | if [ -z "$CN" ]; then 114 | GITHUB_RAW_URL="raw.githubusercontent.com/nezhahq/scripts/main" 115 | GITHUB_URL="github.com" 116 | Docker_IMG="ghcr.io\/nezhahq\/nezha" 117 | else 118 | GITHUB_RAW_URL="gitee.com/naibahq/scripts/raw/main" 119 | GITHUB_URL="gitee.com" 120 | Docker_IMG="registry.cn-shanghai.aliyuncs.com\/naibahq\/nezha-dashboard" 121 | fi 122 | fi 123 | } 124 | 125 | # Function to prompt and read input with validation 126 | prompt_input() { 127 | local prompt_message=$1 128 | local default_value=$2 129 | local input_variable_name=$3 130 | 131 | while true; do 132 | printf "%s" "$prompt_message" 133 | read -r input_value 134 | if [ -z "$input_value" ] && [ -n "$default_value" ]; then 135 | eval "$input_variable_name='$default_value'" 136 | break 137 | elif [ -n "$input_value" ]; then 138 | eval "$input_variable_name='$input_value'" 139 | break 140 | else 141 | echo "输入不能为空,请重新输入。" 142 | fi 143 | done 144 | } 145 | 146 | modify_dashboard_config() { 147 | # 1-先备份 0-不备份 148 | local need_backup=$1 149 | 150 | echo "> 修改面板配置" 151 | if [[ 1 == "${need_backup}" ]]; then 152 | local config_file="${NZ_DASHBOARD_PATH}/data/config.yaml" 153 | if [[ -f "${config_file}" ]]; then 154 | local config_file_bak="${config_file}.$(date +%Y_%m_%d_%H_%M)" 155 | \cp -f "${config_file}" "${config_file_bak}" 156 | echo "====> 已经备份dashboard的配置文件[${config_file}] 到 ${config_file_bak}" 157 | fi 158 | fi 159 | 160 | local config_file="${NZ_DASHBOARD_PATH}/nezha-config.yaml" 161 | 162 | # wget -t 2 -T 60 -O "${config_file}" https://${GITHUB_RAW_URL}/extras/config.yaml >/dev/null 2>&1 163 | # if [ $? != 0 ]; then 164 | # err "下载脚本失败,请检查本机能否连接 https://${GITHUB_RAW_URL}/extras/config.yaml" 165 | # return 0 166 | # fi 167 | 168 | cat > "$config_file" < 请输入面板标题(如 TypeCodes Monitor): " "TypeCodes Monitor" nz_site_title 197 | prompt_input "===> 请输入面板访问端口(如 80): " "" nz_port 198 | local user_name=$(whoami) 199 | local grpc_prompt="===> 请输入面板设置的 GRPC 通信地址(如 ${user_name}.serv00.net:${nz_port}): " 200 | prompt_input "${grpc_prompt}" "" nz_hostport 201 | prompt_input "===> 启用针对 gRPC 端口的 SSL/TLS加密,无特殊情况请选择false-否 true-是: " "false" nz_tls 202 | prompt_input "===> 是否开启 GitHub 登录(y-是 n-否): " "y" oauth2_github 203 | if [[ "${oauth2_github}" =~ ^[Yy]$ ]]; then 204 | prompt_input "===> 请输入 Github Client ID: " "" github_client_id 205 | prompt_input "===> 请输入 Github Client Secret: " "" github_client_secret 206 | fi 207 | prompt_input "===> 是否开启 Gitee 登录(y-是 n-否): " "y" oauth2_gitee 208 | if [[ "${oauth2_gitee}" =~ ^[Yy]$ ]]; then 209 | prompt_input "===> 请输入 Gitee Client ID: " "" gitee_client_id 210 | prompt_input "===> 请输入 Gitee Client Secret: " "" gitee_client_secret 211 | fi 212 | 213 | #sed -i "s/nz_site_title/${nz_site_title}/" "${config_file}" 214 | sed -i '' "s/nz_site_title/${nz_site_title}/" "${config_file}" 215 | #sed -i "s/nz_port/${nz_port}/" "${config_file}" 216 | sed -i '' "s/nz_port/${nz_port}/" "${config_file}" 217 | #sed -i "s/nz_hostport/${nz_hostport}/" "${config_file}" 218 | sed -i '' "s/nz_hostport/${nz_hostport}/" "${config_file}" 219 | #sed -i "s/nz_tls/${nz_tls}/" "${config_file}" 220 | sed -i '' "s/nz_tls/${nz_tls}/" "${config_file}" 221 | #sed -i "s/nz_language/zh_CN/" "${config_file}" 222 | sed -i '' "s/nz_language/zh_CN/" "${config_file}" 223 | 224 | if [[ "${oauth2_github}" =~ ^[Yy]$ ]]; then 225 | #sed -i "s/your_github_client_id/${github_client_id}/" "${config_file}" 226 | sed -i '' "s/your_github_client_id/${github_client_id}/" "${config_file}" 227 | #sed -i "s/your_github_client_secret/${github_client_secret}/" "${config_file}" 228 | sed -i '' "s/your_github_client_secret/${github_client_secret}/" "${config_file}" 229 | fi 230 | 231 | if [[ "${oauth2_gitee}" =~ ^[Yy]$ ]]; then 232 | #sed -i "s/your_gitee_client_id/${gitee_client_id}/" "${config_file}" 233 | sed -i '' "s/your_gitee_client_id/${gitee_client_id}/" "${config_file}" 234 | #sed -i "s/your_gitee_client_secret/${gitee_client_secret}/" "${config_file}" 235 | sed -i '' "s/your_gitee_client_secret/${gitee_client_secret}/" "${config_file}" 236 | fi 237 | 238 | mkdir -p $NZ_DASHBOARD_PATH/data 2>/dev/null 239 | \mv -f "${config_file}" ${NZ_DASHBOARD_PATH}/data/config.yaml 240 | 241 | printf "===> 面板配置修改成功\n" 242 | } 243 | 244 | download_dashboard() { 245 | pre_check 246 | install_base 247 | 248 | NZ_DASHBOARD_PATH=$1 249 | mkdir -p "${NZ_DASHBOARD_PATH}" 250 | 251 | local version=$(curl -m 3 -sL "https://api.github.com/repos/vfhky/nezha-build/releases/latest" | grep "tag_name" | head -n 1 | awk -F ":" '{print $2}' | sed 's/\"//g;s/,//g;s/ //g') 252 | if [ -z "${version}" ]; then 253 | version=$(curl -m 3 -sL "https://ghapi.1024.cloudns.org?pj=vfhky/nezha-build" | grep "tag_name" | head -n 1 | awk -F ":" '{print $2}' | sed 's/\"//g;s/,//g;s/ //g') 254 | fi 255 | if [ -z "${version}" ]; then 256 | version=$(curl -m 3 -sL "https://fastly.jsdelivr.net/gh/vfhky/nezha-build/" | grep "option\.value" | awk -F "'" '{print $2}' | sed 's/vfhky\/nezha-build@/v/g') 257 | fi 258 | if [ -z "${version}" ]; then 259 | version=$(curl -m 3 -sL "https://gcore.jsdelivr.net/gh/vfhky/nezha-build/" | grep "option\.value" | awk -F "'" '{print $2}' | sed 's/vfhky\/nezha-build@/v/g') 260 | fi 261 | 262 | if [ -z "$version" ]; then 263 | err "获取 Dashboard 版本号失败,请检查本机能否链接 https://api.github.com/repos/vfhky/nezha-build/releases/latest" 264 | return 1 265 | fi 266 | 267 | local version_num=$(echo "$version" | sed 's/^v//') 268 | echo "当前最新版本为: v${version_num}" 269 | 270 | NZ_DASHBOARD_URL="https://github.com/vfhky/nezha-build/releases/download/${version}/nezha-dashboard.zip" 271 | #if [ -z "$CN" ]; then 272 | # NZ_DASHBOARD_URL="https://${GITHUB_URL}/naiba/nezha/archive/refs/tags/$version.zip" 273 | #else 274 | # NZ_DASHBOARD_URL="https://${GITHUB_URL}/naibahq/nezha/archive/refs/tags/$version.zip" 275 | #fi 276 | 277 | wget -qO "${NZ_DASHBOARD_PATH}"/app.zip $NZ_DASHBOARD_URL >/dev/null 2>&1 278 | if [ ! -f "${NZ_DASHBOARD_PATH}"/app.zip ]; then 279 | echo "===> [dashboard] ${NZ_DASHBOARD_URL} 下载失败,请检查是否能正常访问" 280 | exit 1 281 | fi 282 | 283 | echo "===> [dashboard] ${NZ_DASHBOARD_URL} 下载完成" 284 | 285 | local config_file="${NZ_DASHBOARD_PATH}/data/config.yaml" 286 | local config_file_bak='' 287 | if [[ -f "${config_file}" ]]; then 288 | config_file_bak="${config_file}.$(date +%Y_%m_%d_%H_%M)" 289 | \cp -f "${config_file}" "${config_file_bak}" 290 | echo "====> 已经备份dashboard的配置文件[${config_file}] 到 ${config_file_bak}" 291 | fi 292 | 293 | version_file="${NZ_DASHBOARD_PATH}/version.txt" 294 | if ! unzip -oqq "${NZ_DASHBOARD_PATH}"/app.zip -d "${NZ_DASHBOARD_PATH}"; then 295 | echo "====> [dashboard] ${NZ_DASHBOARD_PATH}/app.zip 解压失败" 296 | exit 1 297 | fi 298 | 299 | echo "v=${version_num}" > "${version_file}" 300 | \rm -rf "${NZ_DASHBOARD_PATH}"/app.zip 301 | 302 | if [[ -f "${config_file_bak}" ]]; then 303 | prompt_input "===> 是否继续使用旧的配置数据(Y/y 是,N/n 否): " "" modify 304 | if [[ "${modify}" =~ ^[Yy]$ ]]; then 305 | mv -f "${config_file_bak}" "${config_file}" 306 | echo "===> [dashboard] 已经成功使用旧的配置数据 ${config_file}" 307 | else 308 | echo "===> [dashboard] 准备输入新的配置数据" 309 | modify_dashboard_config 0 310 | fi 311 | else 312 | echo "===> [dashboard] 准备输入新的配置数据" 313 | modify_dashboard_config 0 314 | fi 315 | } 316 | 317 | download_agent() { 318 | pre_check 319 | install_base 320 | 321 | NZ_AGENT_PATH=$1 322 | 323 | local version=$(curl -m 10 -sL "https://api.github.com/repos/nezhahq/agent/releases/latest" | grep "tag_name" | head -n 1 | awk -F ":" '{print $2}' | sed 's/\"//g;s/,//g;s/ //g') 324 | if [ ! -n "$version" ]; then 325 | version=$(curl -m 10 -sL "https://gitee.com/api/v5/repos/naibahq/agent/releases/latest" | awk -F '"' '{for(i=1;i<=NF;i++){if($i=="tag_name"){print $(i+2)}}}') 326 | fi 327 | if [ ! -n "$version" ]; then 328 | version=$(curl -m 10 -sL "https://fastly.jsdelivr.net/gh/nezhahq/agent/" | grep "option\.value" | awk -F "'" '{print $2}' | sed 's/nezhahq\/agent@/v/g') 329 | fi 330 | if [ ! -n "$version" ]; then 331 | version=$(curl -m 10 -sL "https://gcore.jsdelivr.net/gh/nezhahq/agent/" | grep "option\.value" | awk -F "'" '{print $2}' | sed 's/nezhahq\/agent@/v/g') 332 | fi 333 | 334 | if [ ! -n "$version" ]; then 335 | err "获取版本号失败,请检查本机能否链接 https://api.github.com/repos/nezhahq/agent/releases/latest" 336 | return 1 337 | else 338 | echo "当前最新版本为: ${version}" 339 | fi 340 | 341 | if [ -z "$CN" ]; then 342 | NZ_AGENT_URL="https://${GITHUB_URL}/nezhahq/agent/releases/download/${version}/nezha-agent_${os_type}_${os_arch}.zip" 343 | else 344 | NZ_AGENT_URL="https://${GITHUB_URL}/naibahq/agent/releases/download/${version}/nezha-agent_${os_type}_${os_arch}.zip" 345 | fi 346 | wget -t 2 -T 60 -O nezha-agent_linux_${os_arch}.zip $NZ_AGENT_URL >/dev/null 2>&1 347 | if [ $? != 0 ]; then 348 | err "Release 下载失败,请检查本机能否连接 ${GITHUB_URL}" 349 | return 1 350 | fi 351 | 352 | mkdir -p $NZ_AGENT_PATH 2>/dev/null 353 | unzip -qo nezha-agent_linux_${os_arch}.zip && 354 | \mv -f nezha-agent $NZ_AGENT_PATH && 355 | rm -rf nezha-agent_linux_${os_arch}.zip 356 | 357 | echo "===> Agent ${NZ_AGENT_URL} 下载完成" 358 | 359 | gen_agent_run_sh 360 | } 361 | 362 | gen_agent_config() { 363 | local agent_config_file="$1" 364 | # 1-需要备份 0-不需要备份 365 | local need_backup=$2 366 | 367 | if [ -z "${agent_config_file}" ]; then 368 | echo "File path is required." 369 | return 1 370 | fi 371 | 372 | if [[ 1 == "${need_backup}" ]]; then 373 | local agent_config_file_bak="${agent_config_file}.$(date +%Y_%m_%d_%H_%M)" 374 | \cp -f "${agent_config_file}" "${agent_config_file_bak}" 375 | echo "====> 已经备份agent的配置文件[${agent_config_file}] 到 ${agent_config_file_bak}" 376 | fi 377 | 378 | \rm -rf "${agent_config_file}" 379 | 380 | cat > "${agent_config_file}" < 请输入面板配置文件中的密钥agentsecretkey: " "" your_agent_secret 403 | prompt_input "===> 启用针对 gRPC 端口的 SSL/TLS加密,无特殊情况请选择false-否 true-是: " "false" your_tls 404 | prompt_input "===> 请输入面板设置的 GRPC 通信地址(例如 vfhky.serv00.net:8888): " "" your_dashboard_ip_port 405 | your_uuid=$(uuidgen) 406 | 407 | #sed -i "s/your_agent_secret/${your_agent_secret}/" "${agent_config_file}" 408 | sed -i '' "s/your_agent_secret/${your_agent_secret}/" "${agent_config_file}" 409 | 410 | #sed -i "s/your_tls/${your_tls}/g" "${agent_config_file}" 411 | sed -i '' "s/your_tls/${your_tls}/g" "${agent_config_file}" 412 | 413 | #sed -i "s/your_dashboard_ip_port/${your_dashboard_ip_port}/" "${agent_config_file}" 414 | sed -i '' "s/your_dashboard_ip_port/${your_dashboard_ip_port}/" "${agent_config_file}" 415 | 416 | #sed -i "s/your_uuid/${your_uuid}/" "${agent_config_file}" 417 | sed -i '' "s/your_uuid/${your_uuid}/" "${agent_config_file}" 418 | } 419 | 420 | gen_agent_run_sh() { 421 | local agent_run_sh="${NZ_AGENT_PATH}/nezha-agent.sh" 422 | local config_file="${NZ_AGENT_PATH}/config.yml" 423 | local config_file_bak="" 424 | 425 | if [[ -f "${config_file}" ]]; then 426 | config_file_bak="${config_file}.$(date +%Y_%m_%d_%H_%M)" 427 | prompt_input "===> 是否继续使用旧的配置数据(Y/y 是,N/n 否): " "" modify 428 | 429 | if [[ "${modify}" =~ ^[Yy]$ ]]; then 430 | echo "===> [agent] 您选择继续使用旧的配置文件[${config_file}]" 431 | exit 0 432 | else 433 | \cp -f "${config_file}" "${config_file_bak}" 434 | echo "===> [agent] 已经备份agent的配置文件到 ${config_file_bak}, 准备输入新的配置数据" 435 | fi 436 | else 437 | echo "===> [agent] 准备输入新的配置数据" 438 | fi 439 | 440 | gen_agent_config "${config_file}" 0 441 | 442 | cat < "${agent_run_sh}" 443 | #!/bin/bash 444 | 445 | nohup ${NZ_AGENT_PATH}/nezha-agent \\ 446 | -c ${config_file} \\ 447 | > /dev/null 2>&1 & 448 | EOF 449 | 450 | chmod +x "${agent_run_sh}" 451 | } 452 | 453 | modify_config() { 454 | echo "====> 开始准备修改,已知哪吒安装目录为[$1]" 455 | pre_check 456 | NZ_APP_PATH=$1 457 | 458 | prompt_input "===> 是否修改dashboard配置(Y/y 是,N/n 否): " "N" modify 459 | if [[ "${modify}" =~ ^[Yy]$ ]]; then 460 | NZ_DASHBOARD_PATH="${NZ_APP_PATH}/dashboard" 461 | NZ_DASHBOARD_CONFIG_FILE="${NZ_DASHBOARD_PATH}/data/config.yaml" 462 | if [[ ! -f "${NZ_DASHBOARD_CONFIG_FILE}" ]]; then 463 | echo "dashboard的配置文件[${NZ_DASHBOARD_CONFIG_FILE}]不存在,请检查是否已经安装过了dashboard" 464 | exit 1 465 | fi 466 | 467 | echo "====> 准备开始修改dashboard配置文件[${NZ_DASHBOARD_CONFIG_FILE}]" 468 | modify_dashboard_config 1 469 | 470 | dashboard_pid=$(pgrep -f nezha-dashboard) 471 | if [[ -n "$dashboard_pid" ]]; then 472 | kill -9 "$dashboard_pid" 473 | echo "====> 关闭哪吒dashboard进程成功" 474 | fi 475 | fi 476 | 477 | prompt_input "===> 是否修改agent配置(Y/y 是,N/n 否): " "N" modify 478 | if [[ "${modify}" =~ ^[Yy]$ ]]; then 479 | NZ_AGENT_PATH="${NZ_APP_PATH}/agent" 480 | agent_config_file="${NZ_AGENT_PATH}/config.yml" 481 | if [[ ! -f "${agent_config_file}" ]]; then 482 | echo "agent的配置文件[${agent_config_file}]不存在,请检查是否已经安装过了agent" 483 | exit 1 484 | fi 485 | 486 | echo "====> 准备开始修改agent配置文件[${agent_config_file}]" 487 | gen_agent_config "${agent_config_file}" 1 488 | 489 | agent_pid=$(pgrep -f nezha-agent) 490 | if [[ -n "$agent_pid" ]]; then 491 | kill -9 "$agent_pid" 492 | echo "====> 关闭哪吒agent进程成功" 493 | fi 494 | fi 495 | } 496 | 497 | 498 | if [ "$#" -lt 2 ]; then 499 | echo "Error: Not enough arguments provided." 500 | echo "Usage: $0 " 501 | echo "Commands:" 502 | echo " dashboard app 下载 dashboard" 503 | echo " agent 下载 agent" 504 | echo " config 修改dashboard或者agent的配置" 505 | exit 1 506 | fi 507 | 508 | command="$1" 509 | arg="$2" 510 | 511 | case "$command" in 512 | "dashboard") 513 | download_dashboard "$arg" 514 | ;; 515 | "agent") 516 | download_agent "$arg" 517 | ;; 518 | "config") 519 | modify_config "$arg" 520 | ;; 521 | *) 522 | echo "Error: Invalid command '$command'" 523 | echo "====== Usage ======" 524 | echo " $0 dashboard 下载 dashboard" 525 | echo " $0 agent 下载 agent" 526 | echo " $0 config 修改dashboard或者agent的配置" 527 | exit 1 528 | ;; 529 | esac 530 | 531 | -------------------------------------------------------------------------------- /heart_beat_config_entry.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List, Dict, Optional 3 | from paramiko_client import ParamikoClient 4 | from logger_wrapper import LoggerWrapper 5 | 6 | # 初始化日志记录器 7 | logger = LoggerWrapper() 8 | 9 | class HeartBeatConfigEntry: 10 | def __init__(self, file_path: str, private_key_file: Optional[str] = None): 11 | self.config_entries: List[Dict[str, any]] = self.parse_config_file(file_path) 12 | self.private_key_file: Optional[str] = private_key_file 13 | self.init_clients() 14 | 15 | def __repr__(self) -> str: 16 | return f"HeartBeatConfigEntry(config_entries={self.config_entries})" 17 | 18 | @staticmethod 19 | def parse_config_file(file_path: str) -> List[Dict[str, any]]: 20 | config_entries = [] 21 | try: 22 | with open(file_path, 'r') as file: 23 | for line_number, line in enumerate(file, 1): 24 | line = line.strip() 25 | if not line or line.startswith('#'): 26 | continue 27 | parts = line.split('|') 28 | if len(parts) != 3: 29 | logger.warning(f"Skipping invalid line {line_number}: {line}") 30 | continue 31 | hostname, port, username = parts 32 | try: 33 | config_entries.append({ 34 | "hostname": hostname, 35 | "port": int(port), 36 | "username": username 37 | }) 38 | except ValueError: 39 | logger.warning(f"Invalid port number on line {line_number}: {line}") 40 | except IOError as e: 41 | logger.error(f"Error reading config file: {e}") 42 | return config_entries 43 | 44 | def init_clients(self) -> None: 45 | if self.private_key_file and not os.path.exists(self.private_key_file): 46 | logger.warning(f"Private key file not found: {self.private_key_file}") 47 | for host_id, entry in enumerate(self.config_entries, 1): 48 | client = self.create_client(entry, host_id) 49 | if client: 50 | entry['client'] = client 51 | 52 | def create_client(self, entry: Dict[str, any], host_id: int, timeout: int = 2) -> Optional[ParamikoClient]: 53 | if self.private_key_file and os.path.exists(self.private_key_file): 54 | try: 55 | client = ParamikoClient( 56 | hostname=entry['hostname'], 57 | port=entry['port'], 58 | username=entry['username'], 59 | ed25519_pri_file=self.private_key_file, 60 | timeout=timeout 61 | ) 62 | ret_code, ret_msg = client.sshd_connect() 63 | if ret_code == 0: 64 | logger.info(f"====> [{host_id}] SSH key connect OK {entry['username']}@{entry['hostname']}:{entry['port']}") 65 | return client 66 | except Exception as e: 67 | logger.error(f"====> [{host_id}] Connection error: {str(e)}") 68 | 69 | logger.error(f"====> [{host_id}] Connect fail {entry['username']}@{entry['hostname']}:{entry['port']} SSH key={self.private_key_file}") 70 | return None 71 | 72 | def get_entries(self) -> List[Dict[str, any]]: 73 | return self.config_entries 74 | -------------------------------------------------------------------------------- /heart_beat_entry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if ! command -v pip &> /dev/null; then 4 | echo "pip could not be found. Please install pip and ensure it's in your PATH." >&2 5 | exit 1 6 | fi 7 | 8 | install_py_require() { 9 | local serv00_ct8_dir=$1 10 | 11 | local tmp_dir="${serv00_ct8_dir}/tmp" 12 | local requirements_file="${serv00_ct8_dir}/requirements.txt" 13 | local installed_modules_hash_file="${tmp_dir}/requirements_hash" 14 | 15 | mkdir -p "$tmp_dir" 16 | 17 | local current_hash=$(md5sum "$requirements_file" | awk '{print $1}') 18 | 19 | if [ ! -f "$installed_modules_hash_file" ] || [ "$(cat "$installed_modules_hash_file")" != "$current_hash" ]; then 20 | if pip install -r "$requirements_file"; then 21 | echo "$current_hash" > "$installed_modules_hash_file" 22 | else 23 | echo "Failed to install Python dependencies." >&2 24 | exit 1 25 | fi 26 | fi 27 | } 28 | 29 | count_processes() { 30 | pgrep -f "$1" | wc -l 31 | } 32 | 33 | # 设置环境变量 34 | script_path=$(readlink -f "$0") 35 | serv00_ct8_dir=$(dirname "$script_path") 36 | 37 | # TYPE|USER|HOSTNAME|PORT 38 | export HEART_BEAT_EXTRA_INFO="$1" 39 | 40 | # 主逻辑脚本文件 41 | heart_beat_logic_file="${serv00_ct8_dir}/heart_beat_logic.py" 42 | 43 | process_count=$(count_processes "heart_beat_") 44 | if [ "$process_count" -gt 1 ]; then 45 | exit 0 46 | fi 47 | 48 | install_py_require "${serv00_ct8_dir}" 49 | 50 | echo "即将执行 ${heart_beat_logic_file}" 51 | python3 "${heart_beat_logic_file}" 52 | -------------------------------------------------------------------------------- /heart_beat_logic.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import socket 4 | from datetime import datetime 5 | from typing import Dict, Optional, Set, List, Tuple 6 | 7 | import requests 8 | import pytz 9 | 10 | from heart_beat_config_entry import HeartBeatConfigEntry 11 | from sys_config_entry import SysConfigEntry 12 | from logger_wrapper import LoggerWrapper 13 | import utils 14 | from notify_entry import NotifyEntry 15 | from backup_entry import BackupEntry 16 | 17 | 18 | # 常量定义 19 | TIMEOUT = 3 20 | HTTP_OK = 200 21 | 22 | # 初始化日志记录器 23 | logger = LoggerWrapper() 24 | 25 | # Setup script directories and files 26 | SERV00_CT8_DIR = os.path.dirname(os.path.abspath(__file__)) 27 | SCRIPT_TMP_DIR = utils.get_serv00_dir_file(SERV00_CT8_DIR, "tmp") 28 | os.makedirs(SCRIPT_TMP_DIR, exist_ok=True) 29 | OK_NOTIFY_HOUR_FILE = os.path.join(SCRIPT_TMP_DIR, 'ok_notify_hour_file') 30 | 31 | def parse_ok_notify_hours(hours_str: str) -> Optional[Set[int]]: 32 | return {int(hour.strip()) for hour in hours_str.split(',')} if hours_str else None 33 | 34 | def check_and_write_notify_hour_file(file_path: str, ok_notify_hours: Optional[Set[int]]) -> bool: 35 | current_hour = datetime.now(pytz.timezone('Asia/Shanghai')).hour 36 | 37 | if ok_notify_hours is None or current_hour in ok_notify_hours: 38 | try: 39 | with open(file_path, "r") as file: 40 | if int(file.read().strip()) == current_hour: 41 | return False 42 | except (FileNotFoundError, ValueError): 43 | pass 44 | 45 | utils.overwrite_msg_to_file(str(current_hour), file_path) 46 | return True 47 | 48 | logger.info(f"当前时间{current_hour}不需要发起通知") 49 | return False 50 | 51 | def check_monitor_url_dns(url: str, notifier: NotifyEntry) -> bool: 52 | try: 53 | logger.info(f"==> 开始检测监控域名[{url}]的DNS解析情况") 54 | host = socket.gethostbyname(url.split('/')[2]) 55 | logger.info(f"==> 监控域名[{url}]解析成功,IP地址为: {host}") 56 | return True 57 | except socket.gaierror as e: 58 | notifier.check_monitor_url_dns_fail_notify(url, e) 59 | return False 60 | 61 | def check_monitor_url_visit(url: str, notifier: NotifyEntry, sys_config_entry: SysConfigEntry) -> bool: 62 | try: 63 | logger.info(f"==> 开始检测监控域名{url}的访问状态") 64 | with requests.get(url, timeout=TIMEOUT) as response: 65 | logger.info(f"监控域名{url}的访问状态为: {response.status_code}") 66 | 67 | if response.status_code != HTTP_OK: 68 | notifier.check_monitor_url_visit_fail_notify(url, response) 69 | else: 70 | ok_notify_hours = sys_config_entry.get("OK_NOTIFY_HOURS") 71 | if check_and_write_notify_hour_file(OK_NOTIFY_HOUR_FILE, parse_ok_notify_hours(ok_notify_hours)): 72 | notifier.check_monitor_url_visit_ok_notify(url, response) 73 | 74 | return response.status_code == HTTP_OK 75 | except requests.RequestException as e: 76 | logger.error(f"==> 异常: {e}") 77 | notifier.check_monitor_url_visit_fail_notify(url, str(e)) 78 | return False 79 | 80 | def check_monitor_url(url: str, notifier: NotifyEntry, sys_config_entry: SysConfigEntry) -> None: 81 | if check_monitor_url_dns(url, notifier): 82 | check_monitor_url_visit(url, notifier, sys_config_entry) 83 | 84 | def all_host_make_heart_beat(config_entries: List[Dict], heart_beat_entry_file: str, heart_beat_extra_info: Dict, local_host_name: str, local_user_name: str) -> None: 85 | for host_id, entry in enumerate(config_entries, 1): 86 | client = entry.get('client') 87 | hostname = entry.get('hostname') 88 | username = entry.get('username') 89 | 90 | if hostname == local_host_name and username == local_user_name: 91 | logger.info(f"==> [{host_id}]号主机[{username}@{hostname}]是当前主机,跳过不处理") 92 | continue 93 | 94 | if client: 95 | logger.info(f"==> 开始维护[{host_id}]号主机[{username}@{hostname}]的心跳...") 96 | remote_heart_beat_entry_file = heart_beat_entry_file.replace(local_user_name, username) 97 | param = utils.make_heart_beat_extra_info(heart_beat_extra_info, hostname, username) 98 | try: 99 | result = client.ssh_exec_script(remote_heart_beat_entry_file, param) 100 | if not result: 101 | logger.warning(f"==> 维护[{host_id}]号主机[{username}@{hostname}]的心跳失败") 102 | except Exception as e: 103 | logger.error(f"==> 维护[{host_id}]号主机[{username}@{hostname}]的心跳时发生异常: {str(e)}") 104 | else: 105 | logger.error(f"==> 维护远程主机[{host_id}]号主机[{username}@{hostname}]失败, 初始化配置的时候连接异常") 106 | 107 | def load_configurations(serv00_ct8_dir: str) -> Tuple[SysConfigEntry, str]: 108 | sys_config_file = utils.get_serv00_config_file(serv00_ct8_dir, 'sys.conf') 109 | heart_beat_config_file = utils.get_serv00_config_file(serv00_ct8_dir, 'heartbeat.conf') 110 | return SysConfigEntry(sys_config_file), heart_beat_config_file 111 | 112 | def main() -> None: 113 | try: 114 | logger.info(f"==============> 开始心跳模块 <==============") 115 | 116 | host_name, user_name = utils.get_hostname_and_username() 117 | private_key_file = utils.get_ssh_ed25519_pri(user_name) 118 | 119 | heat_beat_extra_info = utils.parse_heart_beat_extra_info(os.environ.get('HEART_BEAT_EXTRA_INFO')) 120 | msg = (f"==> 心跳来自主机[{heat_beat_extra_info['username']}@{heat_beat_extra_info['hostname']}:{heat_beat_extra_info['port']}] 类型:{heat_beat_extra_info['type']}" 121 | if heat_beat_extra_info else 122 | f"==> 心跳来自当前主机自身[{user_name}@{host_name}] heat_beat_extra_info={heat_beat_extra_info}") 123 | logger.info(msg) 124 | 125 | sys_config_entry, heart_beat_config_file = load_configurations(SERV00_CT8_DIR) 126 | notifier = NotifyEntry(sys_config_entry) 127 | 128 | process_monitor_file = utils.get_serv00_dir_file(SERV00_CT8_DIR, 'process_monitor.sh') 129 | monitor_config_file = utils.get_serv00_config_file(SERV00_CT8_DIR, 'monitor.conf') 130 | logger.info(f"==> 开始启动进程,[{process_monitor_file}] [{monitor_config_file}]") 131 | if not utils.run_shell_script_with_os(process_monitor_file, monitor_config_file): 132 | logger.error(f"====> 启动进程失败") 133 | 134 | heart_beat_entry_file = utils.get_serv00_dir_file(SERV00_CT8_DIR, 'heart_beat_entry.sh') 135 | utils_sh_file = utils.get_serv00_dir_file(SERV00_CT8_DIR, 'utils.sh') 136 | logger.info(f"==> 开始设置心跳的crontab,[{heart_beat_entry_file}]") 137 | if not utils.run_shell_script_with_os(utils_sh_file, "cron", sys_config_entry.get('HEAT_BEAT_CRON_TABLE_TIME'), heart_beat_entry_file): 138 | logger.error(f"====> 设置失败") 139 | 140 | if utils.need_check_and_heart_beat(heat_beat_extra_info): 141 | if sys_config_entry.get('CHECK_MONITOR_URL_DNS') == "1": 142 | check_monitor_url(sys_config_entry.get('MONITOR_URL'), notifier, sys_config_entry) 143 | 144 | logger.info(f"==> 开始读取心跳配置文件[{heart_beat_config_file}]...") 145 | heart_beat_config = HeartBeatConfigEntry(heart_beat_config_file, private_key_file) 146 | heart_config_entries = heart_beat_config.get_entries() 147 | all_host_make_heart_beat(heart_config_entries, heart_beat_entry_file, heat_beat_extra_info, host_name, user_name) 148 | 149 | backup_entry = BackupEntry(sys_config_entry) 150 | dashboard_db_file = utils.get_dashboard_db_file(user_name) 151 | backup_entry.backup_dashboard_db(dashboard_db_file) 152 | 153 | except Exception as e: 154 | logger.error(f"心跳模块运行时出现未预期的错误: {str(e)}") 155 | finally: 156 | logger.info(f"==============> 结束心跳模块 <==============") 157 | 158 | if __name__ == '__main__': 159 | main() 160 | -------------------------------------------------------------------------------- /host_config_entry.py: -------------------------------------------------------------------------------- 1 | from paramiko_client import ParamikoClient 2 | import os 3 | from typing import List, Dict, Optional 4 | from logger_wrapper import LoggerWrapper 5 | 6 | # 初始化日志记录器 7 | logger = LoggerWrapper() 8 | 9 | class HostConfigEntry: 10 | def __init__(self, file_path: str, private_key_file: Optional[str] = None, timeout: int = 3): 11 | self.config_entries = self.parse_config_file(file_path) 12 | self.private_key_file = private_key_file 13 | self.timeout = timeout 14 | self.init_clients() 15 | 16 | def __repr__(self) -> str: 17 | return f"HostConfigEntry(config_entries={self.config_entries})" 18 | 19 | @staticmethod 20 | def parse_config_file(file_path: str) -> List[Dict[str, str]]: 21 | config_entries = [] 22 | try: 23 | with open(file_path, 'r') as file: 24 | for line_number, line in enumerate(file, 1): 25 | line = line.strip() 26 | if not line or line.startswith('#'): 27 | continue 28 | parts = line.split('|') 29 | if len(parts) != 4: 30 | logger.warning(f"Skipping invalid line {line_number}: {line}") 31 | continue 32 | hostname, port, username, password = parts 33 | try: 34 | config_entries.append({ 35 | "hostname": hostname, 36 | "port": int(port), 37 | "username": username, 38 | "password": password 39 | }) 40 | except ValueError: 41 | logger.warning(f"Invalid port number on line {line_number}: {line}") 42 | except IOError as e: 43 | logger.error(f"Error reading config file: {e}") 44 | return config_entries 45 | 46 | def init_clients(self) -> None: 47 | for host_id, entry in enumerate(self.config_entries, 1): 48 | client = self.create_client(entry, host_id) 49 | entry['client'] = client 50 | 51 | def create_client(self, entry: Dict[str, str], host_id: int) -> Optional[ParamikoClient]: 52 | client = None 53 | if entry['password']: 54 | client = self.try_connection(entry, host_id, use_password=True) 55 | 56 | if not client and self.private_key_file and os.path.exists(self.private_key_file): 57 | client = self.try_connection(entry, host_id, use_password=False) 58 | 59 | if not client: 60 | logger.error(f"====> [{host_id}] Failed to connect to {entry['username']}@{entry['hostname']} with either password or SSH key.") 61 | 62 | return client 63 | 64 | def try_connection(self, entry: Dict[str, str], host_id: int, use_password: bool) -> Optional[ParamikoClient]: 65 | client_params = { 66 | "hostname": entry['hostname'], 67 | "port": entry['port'], 68 | "username": entry['username'], 69 | "timeout": self.timeout 70 | } 71 | 72 | if use_password: 73 | client_params["password"] = entry['password'] 74 | connection_method = "password_connect" 75 | connection_type = "Password-based" 76 | else: 77 | client_params["ed25519_pri_file"] = self.private_key_file 78 | connection_method = "sshd_connect" 79 | connection_type = "SSH key-based" 80 | 81 | try: 82 | client = ParamikoClient(**client_params) 83 | ret_code, _ = getattr(client, connection_method)() 84 | if ret_code == 0: 85 | logger.info(f"====> [{host_id}] {connection_type} connection successful for {entry['username']}@{entry['hostname']}") 86 | return client 87 | except Exception as e: 88 | logger.error(f"====> [{host_id}] {connection_type} connection failed for {entry['username']}@{entry['hostname']}: {str(e)}") 89 | 90 | return None 91 | 92 | def get_entries(self) -> List[Dict[str, str]]: 93 | return self.config_entries 94 | -------------------------------------------------------------------------------- /logger_wrapper.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from logging.handlers import RotatingFileHandler 4 | import pytz 5 | from datetime import datetime 6 | 7 | beijing_tz = pytz.timezone('Asia/Shanghai') 8 | 9 | class LoggerWrapper: 10 | _instance = None 11 | 12 | def __new__(cls, *args, **kwargs): 13 | if not cls._instance: 14 | cls._instance = super(LoggerWrapper, cls).__new__(cls) 15 | return cls._instance 16 | 17 | def __init__(self, log_file_name='main.log', max_bytes=1 * 1024 * 1024, backup_count=1): 18 | if hasattr(self, '_initialized') and self._initialized: 19 | return 20 | 21 | script_dir = os.path.dirname(os.path.abspath(__file__)) 22 | log_dir = os.path.join(script_dir, 'log') 23 | os.makedirs(log_dir, exist_ok=True) 24 | log_file_path = os.path.join(log_dir, log_file_name) 25 | 26 | self.logger = logging.getLogger('serv00_ct8_nezha_logger') 27 | self.logger.setLevel(logging.INFO) 28 | 29 | if not self.logger.handlers: 30 | handler = RotatingFileHandler(log_file_path, maxBytes=max_bytes, backupCount=backup_count, encoding='utf-8') 31 | formatter = logging.Formatter('%(asctime)s - %(message)s') 32 | handler.setFormatter(formatter) 33 | self.logger.addHandler(handler) 34 | 35 | self._initialized = True 36 | 37 | def _log(self, level, message): 38 | weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] 39 | current_weekday_name = weekdays[datetime.now(beijing_tz).weekday()] 40 | beijing_time = datetime.now(beijing_tz).strftime('%Y-%m-%d %H:%M:%S') 41 | log_entry = f"{beijing_time} - {current_weekday_name} - {message}" 42 | 43 | log_method = getattr(self.logger, level) 44 | log_method(log_entry) 45 | 46 | def info(self, message): 47 | self._log('info', message) 48 | 49 | def error(self, message): 50 | self._log('error', message) 51 | 52 | def warning(self, message): 53 | self._log('warning', message) 54 | 55 | def debug(self, message): 56 | self._log('debug', message) 57 | 58 | def critical(self, message): 59 | self._log('critical', message) 60 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | from time import sleep 5 | from typing import List, Dict 6 | from host_config_entry import HostConfigEntry 7 | from sys_config_entry import SysConfigEntry 8 | import utils 9 | 10 | 11 | def gen_ed25519(utils_sh_file: str, ssh_dir: str) -> None: 12 | if not utils.run_shell_script_with_os(utils_sh_file, 'init') or not utils.run_shell_script_with_os(utils_sh_file, 'key'): 13 | print("生成公私钥失败,请检查~/.ssh/目录") 14 | sys.exit(1) 15 | 16 | ed25519_files = { 17 | 'pub': f'{ssh_dir}/id_ed25519.pub', 18 | 'private': f'{ssh_dir}/id_ed25519', 19 | 'auth': f'{ssh_dir}/authorized_keys' 20 | } 21 | 22 | if not all(utils.check_file_exists(os.path.expanduser(file)) for file in ed25519_files.values()): 23 | print("公私钥缺失异常,请检查~/.ssh/目录") 24 | sys.exit(1) 25 | 26 | 27 | def transfer_ssh_dir_to_all_hosts(config_entries: List[Dict], host_name: str, user_name: str, local_dir: str) -> None: 28 | for host_id, entry in enumerate(config_entries, 1): 29 | client = entry.get('client') 30 | if not client: 31 | print(f"==> [{host_id}]号主机未连接成功 [{entry['username']}@{entry['hostname']}:{entry['port']}]") 32 | continue 33 | 34 | if entry['hostname'] == host_name and entry['username'] == user_name: 35 | print(f"==> [{host_id}]号主机为当前主机,不需要处理") 36 | continue 37 | 38 | print(f"==> 开始拷贝到[{host_id}]号主机 [{entry['username']}@{entry['hostname']}:{entry['port']}]...") 39 | remote_dir = utils.get_ssh_dir(entry['username']) 40 | client.transfer_files(local_dir, remote_dir) 41 | 42 | 43 | def gen_nezha_monitor_config(utils_sh_file: str, monitor_config_file: str, nezha_dir: str, process_name: str, 44 | process_run: str, process_run_mode: str) -> None: 45 | print(f"====> 开始把进程[{process_name}]写入到监控配置文件中{monitor_config_file}") 46 | utils.run_shell_script_with_os(utils_sh_file, 'monitor', monitor_config_file, nezha_dir, process_name, 47 | process_run, process_run_mode) 48 | 49 | 50 | def gen_all_hosts_heart_beat_config(utils_sh_file: str, heart_beat_config_file: str, config_entries: List[Dict], 51 | host_name: str, user_name: str) -> None: 52 | print(f"==> 开始把所有主机信息写入到心跳配置文件中{heart_beat_config_file}") 53 | for host_id, entry in enumerate(config_entries, 1): 54 | if host_name == entry["hostname"] and user_name == entry["username"]: 55 | print(f"====> [{host_id}]号主机[{user_name}@{host_name}]是当前主机,跳过不处理") 56 | continue 57 | print(f"====> 开始把[{host_id}]号主机[{entry['username']}@{entry['hostname']}]写入到心跳配置文件中{heart_beat_config_file}") 58 | try: 59 | result = utils.run_shell_script_with_os(utils_sh_file, 'heart', heart_beat_config_file, entry["hostname"], 60 | str(entry["port"]), entry["username"]) 61 | if not result: 62 | print(f"警告: 写入[{host_id}]号主机信息失败") 63 | except Exception as e: 64 | print(f"错误: 写入[{host_id}]号主机信息时发生异常: {str(e)}") 65 | 66 | 67 | def start_process(serv00_ct8_dir: str, host_name: str, user_name: str) -> None: 68 | # 通过进程监控配置文件,开启进程 69 | print("===> 开始通过进程监控配置文件,开启进程....") 70 | heart_beat_entry_file = utils.get_serv00_dir_file(serv00_ct8_dir, 'heart_beat_entry.sh') 71 | param = utils.make_heart_beat_extra_info(None, host_name, user_name) 72 | utils.run_shell_script_with_os(heart_beat_entry_file, param) 73 | 74 | @utils.time_count 75 | def main(): 76 | host_name, user_name = utils.get_hostname_and_username() 77 | 78 | # 定义环境 79 | ssh_dir = utils.get_ssh_dir(user_name) 80 | private_key_file = utils.get_ssh_ed25519_pri(user_name) 81 | 82 | # 应用安装目录 83 | dashboard_dir = utils.get_dashboard_dir(user_name) 84 | agent_dir = utils.get_agent_dir(user_name) 85 | 86 | # 当前脚本所在的目录 87 | serv00_ct8_dir = os.path.dirname(os.path.abspath(__file__)) 88 | utils_sh_file = utils.get_serv00_dir_file(serv00_ct8_dir, 'utils.sh') 89 | 90 | # 生成配置文件 91 | if not utils.run_shell_script_with_os(utils_sh_file, 'rename_config', utils.get_serv00_config_dir(serv00_ct8_dir)): 92 | print(f"===> 从[config]目录生成配置文件失败,请检查serv00是否开启允许应用....") 93 | sys.exit(1) 94 | 95 | print(f"===> 从[config]目录生成配置文件成功....") 96 | 97 | sys_config_file = utils.get_serv00_config_file(serv00_ct8_dir, 'sys.conf') 98 | host_config_file = utils.get_serv00_config_file(serv00_ct8_dir, 'host.conf') 99 | monitor_config_file = utils.get_serv00_config_file(serv00_ct8_dir, 'monitor.conf') 100 | heart_beat_config_file = utils.get_serv00_config_file(serv00_ct8_dir, 'heartbeat.conf') 101 | 102 | # 加载系统配置 103 | SysConfigEntry(sys_config_file) 104 | 105 | # 初始化 106 | utils.run_shell_script_with_os(utils_sh_file, "init") 107 | 108 | # 生成ed25519密钥对 109 | if utils.prompt_user_input("生成私钥(一般是安装面板需要生成,安装agent时不需要)"): 110 | gen_ed25519(utils_sh_file, ssh_dir) 111 | 112 | # 初始化配置并连接所有主机 113 | print("===> 开始连接host.conf中配置的相互保活的主机....") 114 | host_config = HostConfigEntry(host_config_file, private_key_file) 115 | config_entries = host_config.get_entries() 116 | 117 | # sshd公私钥文件拷贝 118 | if utils.prompt_user_input("拷贝公私钥到相互保活的主机(一般是首次安装面板才需要)"): 119 | transfer_ssh_dir_to_all_hosts(config_entries, host_name, user_name, ssh_dir) 120 | 121 | if utils.prompt_user_input("选择安装哪吒V1版本?(V1和V0完全不兼容,请确认)"): 122 | install_ver = "V1" 123 | download_nezha_sh = utils.get_serv00_dir_file(serv00_ct8_dir, 'download_nezha_v1.sh') 124 | else: 125 | install_ver = "V0" 126 | download_nezha_sh = utils.get_serv00_dir_file(serv00_ct8_dir, 'download_nezha.sh') 127 | 128 | if utils.prompt_user_input(f"安装【{install_ver}】版本的dashboard面板"): 129 | print(f"===> 开始安装dashboard....") 130 | if not utils.run_shell_script_with_os(download_nezha_sh, "dashboard", dashboard_dir): 131 | print("===> 安装失败,请稍后再重试....") 132 | sys.exit(1) 133 | 134 | gen_nezha_monitor_config(utils_sh_file, monitor_config_file, dashboard_dir, 135 | "nezha-dashboard", 136 | "./nezha-dashboard", "background") 137 | utils.run_shell_script_with_os(utils_sh_file, "check", "1", sys_config_file) 138 | 139 | start_process(serv00_ct8_dir, host_name, user_name) 140 | sleep(2) 141 | utils.run_shell_script_with_os(utils_sh_file, "show_agent_key", utils.get_dashboard_config_file(user_name)) 142 | 143 | if utils.prompt_user_input(f"安装【{install_ver}】版本的agent"): 144 | print(f"===> 开始安装agent....") 145 | if not utils.run_shell_script_with_os(download_nezha_sh, "agent", agent_dir): 146 | print("===> 安装失败,请稍后再重试....") 147 | sys.exit(1) 148 | 149 | gen_nezha_monitor_config(utils_sh_file, monitor_config_file, agent_dir, "nezha-agent", 150 | "sh nezha-agent.sh", "foreground") 151 | start_process(serv00_ct8_dir, host_name, user_name) 152 | 153 | # 生成所有主机的保活配置 154 | gen_all_hosts_heart_beat_config(utils_sh_file, heart_beat_config_file, config_entries, host_name, user_name) 155 | 156 | print("=======> 安装结束") 157 | 158 | 159 | if __name__ == '__main__': 160 | main() 161 | -------------------------------------------------------------------------------- /notify_entry.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from logger_wrapper import LoggerWrapper 3 | from sys_config_entry import SysConfigEntry 4 | from qywx_notify import QywxNotify 5 | from qywx_app_notify import QywxAppNotify 6 | from tg_notify import TgNotify 7 | from pushplus_notify import PushPlusNotify 8 | 9 | class NotifyEntry: 10 | _instance = None 11 | 12 | def __new__(cls, sys_config_entry: SysConfigEntry): 13 | if cls._instance is None: 14 | cls._instance = super().__new__(cls) 15 | cls._instance._initialized = False 16 | return cls._instance 17 | 18 | def __init__(self, sys_config_entry: SysConfigEntry): 19 | if getattr(self, '_initialized', False): 20 | return 21 | self._initialized = True 22 | self.logger = LoggerWrapper() 23 | self.sys_config_entry = sys_config_entry 24 | self.qywx_notify = QywxNotify(self.sys_config_entry) if self.sys_config_entry.get("ENABLE_QYWX_NOTIFY") == "1" else None 25 | self.qywx_app_notify = QywxAppNotify(self.sys_config_entry) if self.sys_config_entry.get("ENABLE_QYWX_APP_NOTIFY") == "1" else None 26 | self.tg_notify = TgNotify(self.sys_config_entry) if self.sys_config_entry.get("ENABLE_TG_NOTIFY") == "1" else None 27 | self.pushplus_notify = PushPlusNotify(self.sys_config_entry) if self.sys_config_entry.get("ENABLE_PUSHPLUS_NOTIFY") == "1" else None 28 | 29 | def check_monitor_url_dns_fail_notify(self, url: str, e: Exception): 30 | self._send_notify("check_monitor_url_dns_fail_notify", url=url, e=e) 31 | 32 | def check_monitor_url_visit_ok_notify(self, url: str, response): 33 | self._send_notify("check_monitor_url_visit_ok_notify", url=url, response=response) 34 | 35 | def check_monitor_url_visit_fail_notify(self, url: str, response): 36 | self._send_notify("check_monitor_url_visit_fail_notify", url=url, response=response) 37 | 38 | def _send_notify(self, method_name: str, **kwargs): 39 | if self.qywx_notify: 40 | getattr(self.qywx_notify, method_name)(**kwargs) 41 | if self.qywx_app_notify: 42 | getattr(self.qywx_app_notify, method_name)(**kwargs) 43 | if self.tg_notify: 44 | getattr(self.tg_notify, method_name)(**kwargs) 45 | if self.pushplus_notify: 46 | getattr(self.pushplus_notify, method_name)(**kwargs) 47 | -------------------------------------------------------------------------------- /paramiko_client.py: -------------------------------------------------------------------------------- 1 | import paramiko 2 | import os 3 | from typing import Tuple, Dict, Any 4 | from logger_wrapper import LoggerWrapper 5 | from utils import get_shell_run_cmd 6 | 7 | # 初始化日志记录器 8 | logger = LoggerWrapper() 9 | 10 | class ParamikoClient: 11 | def __init__(self, hostname: str, port: int = 22, username: str = None, password: str = None, 12 | ed25519_pri_file: str = None, timeout: int = 2, **kwargs): 13 | self.hostname = hostname 14 | self.port = port 15 | self.username = username 16 | self.password = password 17 | self.ed25519_pri_file = ed25519_pri_file 18 | self.timeout = timeout 19 | self.additional_options = kwargs 20 | self.client = None 21 | 22 | def __enter__(self): 23 | return self 24 | 25 | def __exit__(self, exc_type, exc_val, exc_tb): 26 | self.close() 27 | 28 | def __del__(self): 29 | self.close() 30 | 31 | def close(self): 32 | if self.client: 33 | self.client.close() 34 | logger.info(f"==> 关闭和 [{self.username}@{self.hostname}:{self.port}] 的SSH连接") 35 | self.client = None 36 | 37 | def _connect(self, connect_type: str, **kwargs) -> Tuple[int, str]: 38 | if not self.client: 39 | self.client = paramiko.SSHClient() 40 | self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 41 | 42 | connect_params = { 43 | 'hostname': self.hostname, 44 | 'port': self.port, 45 | 'username': self.username, 46 | 'timeout': self.timeout, 47 | **self.additional_options, 48 | **kwargs # 允许在调用时覆盖默认参数 49 | } 50 | 51 | try: 52 | if connect_type == 'password': 53 | connect_params['password'] = self.password 54 | else: 55 | pkey = paramiko.Ed25519Key(filename=self.ed25519_pri_file) 56 | connect_params['pkey'] = pkey 57 | 58 | self.client.connect(**connect_params) 59 | return 0, f"====> 连接成功 [{self.username}@{self.hostname}:{self.port}]" 60 | except paramiko.ssh_exception.AuthenticationException: 61 | return -1, f"====> 认证失败,请检查用户名和密码/密钥 [{self.username}@{self.hostname}:{self.port}]" 62 | except paramiko.ssh_exception.SSHException as ssh_error: 63 | return -2, f"====> SSH异常: {ssh_error} [{self.username}@{self.hostname}:{self.port}]" 64 | except FileNotFoundError as file_error: 65 | return -3, f"====> 密钥文件未找到: {file_error} [{self.username}@{self.hostname}:{self.port}]" 66 | except Exception as e: 67 | return -4, f"====> 连接失败,错误信息: {e} [{self.username}@{self.hostname}:{self.port}]" 68 | 69 | def password_connect(self, **kwargs) -> Tuple[int, str]: 70 | logger.info(f'==> 开始使用SSH密码连接主机 [{self.username}@{self.hostname}:{self.port}]') 71 | status, message = self._connect('password', **kwargs) 72 | if status == 0: 73 | logger.info(message) 74 | else: 75 | logger.error(message) 76 | return status, message 77 | 78 | def sshd_connect(self, **kwargs) -> Tuple[int, str]: 79 | logger.info(f'==> 开始使用SSH私钥连接主机 [{self.username}@{self.hostname}:{self.port}]') 80 | status, message = self._connect('key', **kwargs) 81 | if status == 0: 82 | logger.info(message) 83 | else: 84 | logger.error(message) 85 | return status, message 86 | 87 | def transfer_files(self, local_dir: str, remote_dir: str) -> None: 88 | if not self.client: 89 | logger.error(f"SSH client not connected [{self.username}@{self.hostname}:{self.port}]") 90 | return 91 | 92 | try: 93 | with self.client.open_sftp() as sftp: 94 | self.ensure_remote_dir_exists(sftp, remote_dir) 95 | logger.info(f"==> 开始拷贝[{local_dir}]目录到远程主机[{self.username}@{self.hostname}:{self.port}] [{remote_dir}]") 96 | 97 | for root, _, files in os.walk(local_dir): 98 | for file in files: 99 | local_file = os.path.join(root, file) 100 | relative_path = os.path.relpath(local_file, local_dir) 101 | remote_file = os.path.join(remote_dir, relative_path) 102 | 103 | self.ensure_remote_dir_exists(sftp, os.path.dirname(remote_file)) 104 | sftp.put(local_file, remote_file, callback=lambda transferred, total: 105 | logger.info(f"====> 传输进度[{self.username}@{self.hostname}:{self.port}] [{local_file}]: {transferred}/{total} bytes")) 106 | local_mode = os.stat(local_file).st_mode 107 | sftp.chmod(remote_file, local_mode) 108 | 109 | logger.info(f"====> 拷贝文件 [{local_file}] 到远程成功[{self.username}@{self.hostname}:{self.port}],权限设置为 {oct(local_mode)}") 110 | except Exception as e: 111 | logger.error(f"文件传输失败 {local_dir} ==> [{self.username}@{self.hostname}:{self.port}] : {e}") 112 | 113 | def ensure_remote_dir_exists(self, sftp, remote_dir: str) -> None: 114 | dirs = remote_dir.split('/') 115 | current_dir = '' 116 | for dir in dirs: 117 | if dir: 118 | current_dir = f"{current_dir}/{dir}" 119 | try: 120 | sftp.stat(current_dir) 121 | except FileNotFoundError: 122 | sftp.mkdir(current_dir) 123 | logger.info(f"====> 创建远程目录 [{self.username}@{self.hostname}:{self.port}]: {current_dir}") 124 | 125 | def ssh_exec_script(self, script_file: str, *args: str) -> Tuple[int, str]: 126 | if not self.client: 127 | return -1, f"SSH client not connected [{self.username}@{self.hostname}:{self.port}]" 128 | 129 | try: 130 | cmd = get_shell_run_cmd(script_file, *args) 131 | logger.info(f"==> 执行远程命令 [{self.username}@{self.hostname}:{self.port}]: {cmd}") 132 | stdin, stdout, stderr = self.client.exec_command(cmd, timeout=self.timeout) 133 | exit_status = stdout.channel.recv_exit_status() 134 | 135 | stdout_output = stdout.read().decode() 136 | stderr_output = stderr.read().decode() 137 | 138 | logger.info(f"STDOUT: {stdout_output}\nSTDERR: {stderr_output}") 139 | 140 | if exit_status == 0: 141 | ret_msg = f'通过SSH执行 {cmd} 命令成功 [{self.username}@{self.hostname}:{self.port}]' 142 | logger.info(ret_msg) 143 | return 0, ret_msg 144 | else: 145 | ret_msg = f'通过SSH执行 {cmd} 命令时出错,退出状态码: {exit_status} [{self.username}@{self.hostname}:{self.port}]' 146 | logger.error(ret_msg) 147 | return -1, ret_msg 148 | except Exception as e: 149 | error_message = f"执行脚本失败 [{self.username}@{self.hostname}:{self.port}]: {str(e)}" 150 | logger.error(error_message) 151 | return -2, error_message 152 | -------------------------------------------------------------------------------- /process_monitor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 用于管理进程 3 | 4 | if [ "$#" -ne 1 ]; then 5 | echo "Usage: $0 monitor.conf文件路径" 6 | exit 1 7 | fi 8 | 9 | config_file="$1" 10 | if [ ! -f "$config_file" ]; then 11 | echo "配置文件 $config_file 不存在" 12 | exit 1 13 | fi 14 | 15 | declare -a process_list 16 | 17 | while IFS='|' read -r app_path process_name script_command run_mode; do 18 | [[ "$app_path" =~ ^#.*$ ]] && continue 19 | process_list+=("$app_path|$process_name|$script_command|$run_mode") 20 | done < "$config_file" 21 | 22 | script_dir=$(dirname "$(realpath "$0")") 23 | 24 | kill_process() { 25 | local process_name=$1 26 | local pids=$(pgrep -f "${process_name}") 27 | if [[ -n "$pids" ]]; then 28 | for pid in $pids; do 29 | kill -15 "$pid" && sleep 2 30 | if kill -0 "$pid" 2>/dev/null; then 31 | kill -9 "$pid" 32 | fi 33 | done 34 | fi 35 | } 36 | 37 | check_and_restart_processes() { 38 | for entry in "${process_list[@]}"; do 39 | IFS='|' read -ra process_info <<< "$entry" 40 | local app_path="${process_info[0]}" 41 | local process_name="${process_info[1]}" 42 | local cmd="${process_info[2]}" 43 | local run_mode="${process_info[3]}" 44 | 45 | kill_process "$process_name" 46 | 47 | if ! pgrep -x "$process_name" > /dev/null; then 48 | cd "$app_path" || continue 49 | 50 | if [[ "$run_mode" == "background" ]]; then 51 | echo "run background command: $cmd" 52 | nohup "$cmd" > /dev/null 2>&1 & 53 | else 54 | echo "run foreground command: $cmd" 55 | $cmd 56 | fi 57 | 58 | echo "[$app_path] Restarted process=[${cmd}] at $(date)" >> "${script_dir}/restart.log" 59 | cd "${script_dir}" || continue 60 | 61 | sleep 1 62 | else 63 | echo "process [$process_name] is running." 64 | fi 65 | done 66 | } 67 | 68 | # 检查并重启进程 69 | check_and_restart_processes 70 | -------------------------------------------------------------------------------- /pushplus_notify.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from datetime import datetime 3 | import pytz 4 | from typing import Dict 5 | from logger_wrapper import LoggerWrapper 6 | from sys_config_entry import SysConfigEntry 7 | 8 | class PushPlusNotify: 9 | _instance = None 10 | PUSHPLUS_API_URL = 'http://www.pushplus.plus/send' 11 | 12 | def __new__(cls, sys_config_entry: SysConfigEntry): 13 | if cls._instance is None: 14 | cls._instance = super().__new__(cls) 15 | cls._instance._initialized = False 16 | return cls._instance 17 | 18 | def __init__(self, sys_config_entry: SysConfigEntry): 19 | if self._initialized: 20 | return 21 | self._initialized = True 22 | self.sys_config_entry = sys_config_entry 23 | self.logger = LoggerWrapper() 24 | self.api_token = self.sys_config_entry.get("PUSHPLUS_KEY") 25 | self.headers = {'Content-Type': 'application/json'} 26 | 27 | def check_monitor_url_dns_fail_notify(self, url: str, e: Exception): 28 | title = "💣解析失败提醒💣" 29 | content = f"域名: {url}\n错误: {e}\n请检查dns解析" 30 | self.logger.error(f"{title}\n{content}") 31 | self._send_notify(title, content) 32 | 33 | def check_monitor_url_visit_ok_notify(self, url: str, response): 34 | title = "🎉当前服务稳如泰山🎉" 35 | content = f"域名: {url}\n状态码: {response.status_code}\n继续加油!" 36 | self.logger.info(f"监控域名{url} {title}\n{content}") 37 | self._send_notify(title, content) 38 | 39 | def check_monitor_url_visit_fail_notify(self, url: str, response): 40 | title = "💥当前服务不可用💥" 41 | content = f"域名: {url}\n状态码: {response.status_code}\n心跳模块会拉起进程,请稍后检查" 42 | self.logger.info(f"监控域名{url} {title}\n{content}") 43 | self._send_notify(title, content) 44 | 45 | def _build_message(self, title: str, content: str) -> Dict[str, str]: 46 | system_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') 47 | beijing_time = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S') 48 | return { 49 | "token": self.api_token, 50 | "title": title, 51 | "content": f"----- {title} -----\n{content}\n系统时间: {system_time}\n北京时间: {beijing_time}" 52 | } 53 | 54 | def _send_notify(self, title: str, content: str) -> None: 55 | message = self._build_message(title, content) 56 | try: 57 | with requests.post(self.PUSHPLUS_API_URL, json=message, headers=self.headers, timeout=2) as response: 58 | response.raise_for_status() 59 | self.logger.info(f"PushPlus推送消息成功: {response.text}") 60 | except requests.RequestException as e: 61 | self.logger.error(f"PushPlus推送消息失败,错误: {str(e)}") 62 | -------------------------------------------------------------------------------- /qcloud_cos_backup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime, timedelta 3 | from typing import Dict, Optional 4 | from logger_wrapper import LoggerWrapper 5 | from sys_config_entry import SysConfigEntry 6 | from qcloud_cos import CosConfig, CosS3Client 7 | from qcloud_cos.cos_exception import CosServiceError, CosClientError 8 | 9 | class QCloudCosBackup: 10 | _instance = None 11 | DATE_FORMAT = '%d' 12 | MONTH_FORMAT = '%Y%m' 13 | 14 | def __new__(cls, sys_config_entry: SysConfigEntry): 15 | if cls._instance is None: 16 | cls._instance = super().__new__(cls) 17 | cls._instance._initialized = False 18 | return cls._instance 19 | 20 | def __init__(self, sys_config_entry: SysConfigEntry): 21 | if getattr(self, '_initialized', False): 22 | return 23 | self._initialized = True 24 | self.sys_config_entry = sys_config_entry 25 | self.logger = LoggerWrapper() 26 | self.app_id = self.sys_config_entry.get("QCLOUD_COS_APP_ID") 27 | self.secret_id = self.sys_config_entry.get("QCLOUD_COS_SECRET_ID") 28 | self.secret_key = self.sys_config_entry.get("QCLOUD_COS_SECRET_KEY") 29 | self.region = self.sys_config_entry.get("QCLOUD_COS_REGION") 30 | self.bucket_name = f"{self.sys_config_entry.get('QCLOUD_COS_BUCKET_NAME')}-{self.app_id}" 31 | self.dir_name = self.sys_config_entry.get("QCLOUD_COS_DIR_NAME") 32 | self.ttl = int(self.sys_config_entry.get("QCLOUD_COS_EXPIRE_DAYS", 7)) 33 | 34 | config = CosConfig(Region=self.region, SecretId=self.secret_id, SecretKey=self.secret_key) 35 | self.client = CosS3Client(config) 36 | 37 | def _ensure_bucket_exists(self): 38 | try: 39 | self.client.head_bucket(Bucket=self.bucket_name) 40 | self.logger.info(f"腾讯云cos Bucket 已存在: {self.bucket_name}") 41 | except CosServiceError as e: 42 | if e.get_status_code() == 404: 43 | try: 44 | self.client.create_bucket(Bucket=self.bucket_name) 45 | self.logger.info(f"腾讯云cos创建 Bucket 成功: {self.bucket_name}") 46 | except Exception as create_error: 47 | self.logger.error(f"腾讯云cos创建 Bucket 失败: {self.bucket_name}, 错误: {str(create_error)}") 48 | raise 49 | else: 50 | self.logger.error(f"腾讯云cos检查 Bucket 时出错: {self.bucket_name}, 错误: {str(e)}") 51 | raise 52 | 53 | def set_bucket_lifecycle(self): 54 | try: 55 | rule = { 56 | 'ID': 'DeleteAfterDays', 57 | 'Status': 'Enabled', 58 | 'Filter': {'Prefix': self.dir_name}, 59 | 'Expiration': {'Days': self.ttl} 60 | } 61 | 62 | lifecycle_config = { 63 | 'Rule': [rule] 64 | } 65 | 66 | response = self.client.put_bucket_lifecycle( 67 | Bucket=self.bucket_name, 68 | LifecycleConfiguration=lifecycle_config 69 | ) 70 | self.logger.info(f"腾讯云cos成功设置存储桶 {self.bucket_name} 的生命周期规则") 71 | except (CosServiceError, CosClientError) as e: 72 | self.logger.error(f"腾讯云cos设置存储桶 {self.bucket_name} 的生命周期规则失败:{str(e)}") 73 | raise 74 | 75 | def backup_dashboard_db(self, db_file: str) -> Optional[str]: 76 | key = None 77 | try: 78 | self._ensure_bucket_exists() 79 | self.set_bucket_lifecycle() 80 | 81 | now = datetime.now() 82 | date_prefix = now.strftime(self.DATE_FORMAT) 83 | month_dir = now.strftime(self.MONTH_FORMAT) 84 | 85 | file_name = os.path.basename(db_file) 86 | new_file_name = f"{date_prefix}_{file_name}" 87 | key = f"{self.dir_name}/{month_dir}/{new_file_name}" 88 | 89 | with open(db_file, 'rb') as fp: 90 | response = self.client.put_object( 91 | Bucket=self.bucket_name, 92 | Body=fp, 93 | Key=key, 94 | EnableMD5=True, 95 | StorageClass='STANDARD' 96 | ) 97 | 98 | if 'ETag' in response: 99 | self.logger.info(f"====> 腾讯云cos: [{db_file}] 上传成功 bucket_name={self.bucket_name} {key}") 100 | return f"{self.bucket_name}/{key}" 101 | else: 102 | self.logger.error(f"====> 腾讯云cos: [{db_file}] 上传失败 bucket_name={self.bucket_name} {key} 详情: {response}") 103 | return None 104 | except Exception as e: 105 | self.logger.error(f"====> 腾讯云cos: [{db_file}] 上传失败 bucket_name={self.bucket_name} {key} 错误:{str(e)}") 106 | return None 107 | -------------------------------------------------------------------------------- /qiniu_backup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from typing import Optional 4 | from logger_wrapper import LoggerWrapper 5 | from sys_config_entry import SysConfigEntry 6 | from qiniu import Auth, put_file, BucketManager 7 | import qiniu.config 8 | 9 | class QiniuBackup: 10 | _instance = None 11 | DATE_FORMAT = '%d' 12 | MONTH_FORMAT = '%Y%m' 13 | PRIVATE = "1" 14 | PUBLIC = "0" 15 | 16 | def __new__(cls, sys_config_entry: SysConfigEntry): 17 | if cls._instance is None: 18 | cls._instance = super().__new__(cls) 19 | return cls._instance 20 | 21 | def __init__(self, sys_config_entry: SysConfigEntry): 22 | if getattr(self, '_initialized', False): 23 | return 24 | self._initialized = True 25 | self.sys_config_entry = sys_config_entry 26 | self.logger = LoggerWrapper() 27 | self.access_key = self.sys_config_entry.get("QINIU_ACCESS_KEY") 28 | self.secret_key = self.sys_config_entry.get("QINIU_SECRET_KEY") 29 | self.region = self.sys_config_entry.get("QINIU_REGION") 30 | self.bucket_name = self.sys_config_entry.get("QINIU_BUCKET_NAME") 31 | self.dir_name = self.sys_config_entry.get("QINIU_DIR_NAME") 32 | self.ttl = str(self.sys_config_entry.get("QINIU_EXPIRE_DAYS", 7)) 33 | self.auth = Auth(self.access_key, self.secret_key) 34 | self.bucket_manager = BucketManager(self.auth) 35 | 36 | def _ensure_bucket_exists(self): 37 | try: 38 | buckets, _ = self.bucket_manager.list_bucket(self.region) 39 | buckets = buckets or [] 40 | bucket_ids = [bucket['id'] for bucket in buckets] 41 | if not bucket_ids or self.bucket_name not in bucket_ids: 42 | self._create_bucket() 43 | else: 44 | self.logger.info(f"====> 七牛 Bucket 已存在: {self.bucket_name}") 45 | except Exception as e: 46 | self.logger.error(f"====> 七牛检查或创建 bucket: {self.bucket_name} 时出错: {str(e)}") 47 | raise 48 | 49 | def _create_bucket(self): 50 | try: 51 | ret, info = self.bucket_manager.mkbucketv3(self.bucket_name, self.region) 52 | if info.status_code == 200: 53 | self.logger.info(f"====> 七牛成功创建 Bucket: {self.bucket_name}") 54 | self._change_bucket_permission(self.PRIVATE) 55 | else: 56 | self.logger.error(f"====> 七牛创建 Bucket 失败: {self.bucket_name}, 错误信息: {info}") 57 | raise Exception(f"创建 bucket 失败: {info}") 58 | except Exception as e: 59 | self.logger.error(f"====> 七牛创建 bucket: {self.bucket_name} 时出错: {str(e)}") 60 | raise 61 | 62 | def _change_bucket_permission(self, private: str): 63 | try: 64 | if private not in (self.PRIVATE, self.PUBLIC): 65 | raise ValueError("无效的权限参数") 66 | private_desc = "私有" if private == self.PRIVATE else "公有" 67 | ret, info = self.bucket_manager.change_bucket_permission(self.bucket_name, private) 68 | if info.status_code == 200: 69 | self.logger.info(f"====> 七牛设置 Bucket: {self.bucket_name} {private_desc} 属性成功") 70 | else: 71 | self.logger.error(f"====> 七牛设置 Bucket: {self.bucket_name} {private_desc} 属性失败, 错误信息: {info}") 72 | except Exception as e: 73 | self.logger.error(f"====> 七牛设置 bucket: {self.bucket_name} {private_desc} 属性时出错: {str(e)}") 74 | raise 75 | 76 | def _set_file_expiry(self, upload_path: str): 77 | try: 78 | ret, info = self.bucket_manager.delete_after_days(self.bucket_name, upload_path, self.ttl) 79 | if info.status_code == 200: 80 | self.logger.info(f"====> 七牛成功设置文件 {upload_path} 的过期时间为 {self.ttl} 天") 81 | else: 82 | self.logger.error(f"====> 七牛设置文件 {upload_path} 的过期时间失败: {info}") 83 | except Exception as e: 84 | self.logger.error(f"====> 七牛设置文件 {upload_path} 的过期时间时出错: {str(e)}") 85 | 86 | def backup_dashboard_db(self, db_file: str) -> Optional[str]: 87 | try: 88 | self._ensure_bucket_exists() 89 | now = datetime.now() 90 | date_prefix = now.strftime(self.DATE_FORMAT) 91 | month_dir = now.strftime(self.MONTH_FORMAT) 92 | 93 | file_name = os.path.basename(db_file) 94 | new_file_name = f"{date_prefix}_{file_name}" 95 | upload_path = f"{self.dir_name}/{month_dir}/{new_file_name}" 96 | 97 | token = self.auth.upload_token(self.bucket_name, upload_path, 3600) 98 | 99 | ret, info = put_file(token, upload_path, db_file) 100 | if info.status_code == 200: 101 | self.logger.info(f"====> 七牛: [{db_file}] 上传成功 bucket_name={self.bucket_name} {upload_path}") 102 | 103 | self._set_file_expiry(upload_path) 104 | 105 | return f"{self.bucket_name}/{upload_path}" 106 | else: 107 | self.logger.error(f"====> 七牛: [{db_file}] 上传失败 bucket_name={self.bucket_name} {upload_path} 详情: {info}") 108 | return None 109 | except Exception as e: 110 | self.logger.error(f"====> 七牛: [{db_file}] 上传失败 bucket_name={self.bucket_name} {upload_path} 错误:{str(e)}") 111 | return None 112 | -------------------------------------------------------------------------------- /qywx_app_notify.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from typing import List, Optional, Dict 3 | from datetime import datetime 4 | import pytz 5 | from logger_wrapper import LoggerWrapper 6 | from sys_config_entry import SysConfigEntry 7 | 8 | class QywxAppNotify: 9 | _instance = None 10 | QYWX_APP_TOKEN_URL = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken' 11 | QYWX_APP_PUSH_URL = 'https://qyapi.weixin.qq.com/cgi-bin/message/send' 12 | 13 | def __new__(cls, sys_config_entry: SysConfigEntry): 14 | if cls._instance is None: 15 | cls._instance = super().__new__(cls) 16 | cls._instance._initialized = False 17 | return cls._instance 18 | 19 | def __init__(self, sys_config_entry: SysConfigEntry): 20 | if getattr(self, '_initialized', False): 21 | return 22 | self._initialized = True 23 | self.sys_config_entry = sys_config_entry 24 | self.logger = LoggerWrapper() 25 | 26 | self.qywx_app_corp_id = self.sys_config_entry.get("QYWX_APP_CROP_ID") 27 | self.qywx_app_secret = self.sys_config_entry.get("QYWX_APP_SECRET") 28 | self.qywx_app_agent_id = self.sys_config_entry.get("QYWX_APP_AGENT_ID") 29 | self.qywx_app_notify_user = self.sys_config_entry.get("QYWX_APP_NOTIFY_USER", '@all') 30 | 31 | self.qywx_app_token_url = f"{self.QYWX_APP_TOKEN_URL}?corpid={self.qywx_app_corp_id}&corpsecret={self.qywx_app_secret}" 32 | self.headers = {'Content-Type': 'application/json'} 33 | 34 | def check_monitor_url_dns_fail_notify(self, url: str, e: Exception) -> None: 35 | title = "[炸弹]解析失败提醒[炸弹]" 36 | content = f"域名: {url}\n错误: {e}\n请检查dns解析" 37 | self.logger.error(f"{title}\n{content}") 38 | self._send_notify(title, content) 39 | 40 | def check_monitor_url_visit_ok_notify(self, url: str, response) -> None: 41 | title = "[鼓掌]当前服务稳如泰山[鼓掌]" 42 | content = f"域名: {url}\n状态码: {response.status_code}\n继续加油!" 43 | self.logger.info(f"监控域名{url} {title}\n{content}") 44 | self._send_notify(title, content) 45 | 46 | def check_monitor_url_visit_fail_notify(self, url: str, response) -> None: 47 | title = "[裂开]当前服务不可用[裂开]" 48 | content = f"域名: {url}\n状态码: {response.status_code}\n心跳模块会拉起进程,请稍后检查" 49 | self.logger.info(f"监控域名{url} {title}\n{content}") 50 | self._send_notify(title, content) 51 | 52 | def _build_message(self, title: str, content: str) -> Dict[str, Dict[str, str]]: 53 | system_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') 54 | beijing_time = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S') 55 | return { 56 | "msgtype": "text", 57 | "text": { 58 | "content": f"----- {title} -----\n{content}\n系统时间: {system_time}\n北京时间: {beijing_time}" 59 | } 60 | } 61 | 62 | def _send_notify(self, title: str, content: str) -> None: 63 | message = self._build_message(title, content) 64 | access_token = self._get_access_token() 65 | if access_token: 66 | self._send_message(access_token, message) 67 | else: 68 | self.logger.error("获取企业微信访问令牌失败") 69 | 70 | def _get_access_token(self) -> Optional[str]: 71 | try: 72 | response = requests.get(self.qywx_app_token_url, timeout=2) 73 | response.raise_for_status() 74 | access_token = response.json().get("access_token") 75 | if not access_token: 76 | self.logger.error("获取企业微信app应用令牌失败") 77 | return access_token 78 | except requests.RequestException as e: 79 | self.logger.error(f"获取企业微信app应用令牌异常: {e}") 80 | return None 81 | 82 | def _send_message(self, access_token: str, message: Dict[str, Dict[str, str]]) -> None: 83 | url = f"{self.QYWX_APP_PUSH_URL}?access_token={access_token}" 84 | body = { 85 | "touser": self.qywx_app_notify_user, 86 | "agentid": self.qywx_app_agent_id, 87 | "safe": 0, 88 | "enable_id_trans": 0, 89 | "enable_duplicate_check": 0, 90 | **message 91 | } 92 | 93 | try: 94 | with requests.post(url, json=body, headers=self.headers, timeout=2) as response: 95 | response.raise_for_status() 96 | self.logger.info(f"企业微信APP推送消息成功: {response.text}") 97 | except requests.RequestException as e: 98 | self.logger.error(f"企业微信APP推送消息失败,错误: {str(e)}") 99 | -------------------------------------------------------------------------------- /qywx_notify.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from datetime import datetime 3 | import pytz 4 | from typing import Dict 5 | from logger_wrapper import LoggerWrapper 6 | from sys_config_entry import SysConfigEntry 7 | 8 | class QywxNotify: 9 | _instance = None 10 | QYWX_API_URL = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={}' 11 | 12 | def __new__(cls, sys_config_entry: SysConfigEntry): 13 | if cls._instance is None: 14 | cls._instance = super().__new__(cls) 15 | cls._instance._initialized = False 16 | return cls._instance 17 | 18 | def __init__(self, sys_config_entry: SysConfigEntry): 19 | if getattr(self, '_initialized', False): 20 | return 21 | self._initialized = True 22 | self.sys_config_entry = sys_config_entry 23 | self.logger = LoggerWrapper() 24 | self.qywx_robot_key = self.sys_config_entry.get("QYWX_ROBOT_KEY") 25 | self.qywx_robot_url = self.QYWX_API_URL.format(self.qywx_robot_key) 26 | self.headers = {'Content-Type': 'application/json'} 27 | 28 | def check_monitor_url_dns_fail_notify(self, url: str, e: Exception): 29 | title = "[炸弹]解析失败提醒[炸弹]" 30 | content = f"域名: {url}\n错误: {e}\n请检查dns解析" 31 | self.logger.error(f"{title}\n{content}") 32 | self._send_notify(title, content) 33 | 34 | def check_monitor_url_visit_ok_notify(self, url: str, response): 35 | title = "[鼓掌]当前服务稳如泰山[鼓掌]" 36 | content = f"域名: {url}\n状态码: {response.status_code}\n继续加油!" 37 | self.logger.info(f"监控域名{url} {title}\n{content}") 38 | self._send_notify(title, content) 39 | 40 | def check_monitor_url_visit_fail_notify(self, url: str, response): 41 | title = "[裂开]当前服务不可用[裂开]" 42 | content = f"域名: {url}\n状态码: {response.status_code}\n心跳模块会拉起进程,请稍后检查" 43 | self.logger.info(f"监控域名{url} {title}\n{content}") 44 | self._send_notify(title, content) 45 | 46 | def _build_message(self, title: str, content: str) -> Dict[str, Dict[str, str]]: 47 | system_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') 48 | beijing_time = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S') 49 | return { 50 | "msgtype": "text", 51 | "text": { 52 | "content": f"----- {title} -----\n{content}\n系统时间: {system_time}\n北京时间: {beijing_time}" 53 | } 54 | } 55 | 56 | def _send_notify(self, title: str, content: str) -> None: 57 | message = self._build_message(title, content) 58 | try: 59 | with requests.post(self.qywx_robot_url, json=message, headers=self.headers, timeout=2) as response: 60 | response.raise_for_status() 61 | self.logger.info(f"企业微信机器人推送消息成功: {response.text}") 62 | except requests.RequestException as e: 63 | self.logger.error(f"企业微信机器人推送消息失败,错误: {str(e)}") 64 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cos_python_sdk_v5==1.9.31 2 | oss2==2.19.0 3 | paramiko==3.5.0 4 | pytz==2024.1 5 | qiniu==7.16.0 6 | Requests==2.32.3 7 | -------------------------------------------------------------------------------- /sys_config_entry.py: -------------------------------------------------------------------------------- 1 | class SysConfigEntry: 2 | _instance = None 3 | 4 | def __new__(cls, file_path): 5 | if cls._instance is None: 6 | cls._instance = super(SysConfigEntry, cls).__new__(cls) 7 | cls._instance.file_path = file_path 8 | cls._instance.config = cls._instance._parse_config_file() 9 | return cls._instance 10 | 11 | def _parse_config_file(self): 12 | config = {} 13 | try: 14 | with open(self.file_path, 'r') as file: 15 | for line in file: 16 | line = line.strip() 17 | if line and not line.startswith('#'): 18 | key, value = line.split('=', 1) 19 | config[key.strip()] = value.strip() 20 | except (IOError, OSError) as e: 21 | print(f"Failed to read config file: {e}") 22 | return config 23 | 24 | def get(self, key, default=None): 25 | return self.config.get(key, default) 26 | 27 | def __getitem__(self, key): 28 | return self.config[key] 29 | 30 | def __setitem__(self, key, value): 31 | self.config[key] = value 32 | 33 | def __delitem__(self, key): 34 | del self.config[key] 35 | 36 | def __contains__(self, key): 37 | return key in self.config 38 | 39 | def items(self): 40 | return self.config.items() 41 | 42 | def keys(self): 43 | return self.config.keys() 44 | 45 | def values(self): 46 | return self.config.values() 47 | 48 | def reload(self): 49 | self.config = self._parse_config_file() 50 | -------------------------------------------------------------------------------- /tg_notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from datetime import datetime 3 | import pytz 4 | import requests 5 | from logger_wrapper import LoggerWrapper 6 | from sys_config_entry import SysConfigEntry 7 | 8 | class TgNotify: 9 | _instance = None 10 | 11 | def __new__(cls, sys_config_entry: SysConfigEntry): 12 | if cls._instance is None: 13 | cls._instance = super().__new__(cls) 14 | cls._instance._initialized = False 15 | return cls._instance 16 | 17 | def __init__(self, sys_config_entry: SysConfigEntry): 18 | if self._initialized: 19 | return 20 | self._initialized = True 21 | self.sys_config_entry = sys_config_entry 22 | self.logger = LoggerWrapper() 23 | self.bot_token = self.sys_config_entry.get("TG_ROBOT_KEY") 24 | self.chat_id = self.sys_config_entry.get("TG_CHAT_ID") 25 | 26 | def check_monitor_url_dns_fail_notify(self, url: str, e: Exception): 27 | title = "💣 解析失败提醒 💣" 28 | content = f"域名: {url}\n错误: {e}\n请检查dns解析" 29 | self.logger.error(f"{title}\n{content}") 30 | self._send_notify(title, content) 31 | 32 | def check_monitor_url_visit_ok_notify(self, url: str, response): 33 | title = "🎉 当前服务稳如泰山 🎉" 34 | content = f"域名: {url}\n状态码: {response.status_code}\n继续加油!" 35 | self.logger.info(f"监控域名{url} {title}\n{content}") 36 | self._send_notify(title, content) 37 | 38 | def check_monitor_url_visit_fail_notify(self, url: str, response): 39 | title = "💥 当前服务不可用 💥" 40 | content = f"域名: {url}\n状态码: {response.status_code}\n心跳模块会拉起进程,请稍后检查" 41 | self.logger.info(f"监控域名{url} {title}\n{content}") 42 | self._send_notify(title, content) 43 | 44 | def _build_message(self, title: str, content: str) -> str: 45 | system_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') 46 | beijing_time = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S') 47 | return f"----- {title} -----\n{content}\n系统时间: {system_time}\n北京时间: {beijing_time}" 48 | 49 | def _send_notify(self, title: str, content: str) -> None: 50 | try: 51 | message = self._build_message(title, content) 52 | api_url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage" 53 | payload = { 54 | 'chat_id': self.chat_id, 55 | 'text': message 56 | } 57 | 58 | with requests.post(api_url, data=payload) as response: 59 | response.raise_for_status() 60 | self.logger.info(f"telegram推送消息成功: {response.text}") 61 | except requests.RequestException as e: 62 | self.logger.error(f"telegram推送消息失败,错误: {str(e)}") 63 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | import shlex 4 | import functools 5 | from time import time 6 | from getpass import getuser 7 | 8 | from logger_wrapper import LoggerWrapper 9 | 10 | 11 | # 初始化日志记录器 12 | logger = LoggerWrapper() 13 | 14 | def time_count(func): 15 | @functools.wraps(func) 16 | def wrapper(*args, **kwargs): 17 | start_time = time() 18 | result = func(*args, **kwargs) 19 | end_time = time() 20 | elapsed_time = end_time - start_time 21 | 22 | if elapsed_time < 60: 23 | print(f"=======> 函数 {func.__name__} 总共耗时: {elapsed_time:.2f} 秒") 24 | else: 25 | minutes = elapsed_time // 60 26 | seconds = elapsed_time % 60 27 | print(f"=======> 函数 {func.__name__} 总共耗时: {int(minutes)} 分 {seconds:.2f} 秒") 28 | 29 | return result 30 | return wrapper 31 | 32 | def get_shell_run_cmd(shell_path, *args): 33 | quoted_args = [shlex.quote(str(arg)) for arg in args] 34 | return f'{shell_path} {" ".join(quoted_args)}' 35 | 36 | def run_shell_script_with_os(shell_path, *args): 37 | cmd = get_shell_run_cmd(shell_path, *args) 38 | result = os.system(cmd) 39 | 40 | if result == 0: 41 | logger.info(f"Shell command executed successfully: {cmd}") 42 | return True 43 | else: 44 | logger.error(f"Shell command execution failed with exit code {result}: {cmd}") 45 | return False 46 | 47 | 48 | def overwrite_msg_to_file(msg, file_path): 49 | with open(file_path, "w", encoding="utf-8") as file: 50 | file.write(str(msg)) 51 | 52 | def get_hostname_and_username(): 53 | hostname = socket.gethostname() 54 | try: 55 | username = os.getlogin() 56 | except OSError: 57 | username = getuser() 58 | return hostname, username 59 | 60 | def get_user_home_dir(user_name): 61 | return os.path.join('/home', user_name) 62 | 63 | def get_ssh_dir(user_name): 64 | return os.path.join(get_user_home_dir(user_name), '.ssh') 65 | 66 | def get_app_dir(user_name): 67 | return os.path.join(get_user_home_dir(user_name), 'nezha_app') 68 | 69 | def get_dashboard_dir(user_name): 70 | return os.path.join(get_app_dir(user_name), 'dashboard') 71 | 72 | def get_dashboard_config_file(user_name): 73 | config_dir = get_dashboard_dir(user_name) 74 | return os.path.join(config_dir, 'data/config.yaml') 75 | 76 | def get_dashboard_db_file(user_name): 77 | dashboard_dir = get_dashboard_dir(user_name) 78 | return os.path.join(dashboard_dir, 'data/sqlite.db') 79 | 80 | def get_agent_dir(user_name): 81 | return os.path.join(get_app_dir(user_name), 'agent') 82 | 83 | def get_ssh_ed25519_pri(user_name): 84 | ssh_dir = get_ssh_dir(user_name) 85 | return os.path.expanduser(os.path.join(ssh_dir, 'id_ed25519')) 86 | 87 | def get_serv00_config_dir(serv00_ct8_dir): 88 | return os.path.join(serv00_ct8_dir, 'config') 89 | 90 | def get_serv00_config_file(serv00_ct8_dir, file_name): 91 | config_dir = get_serv00_config_dir(serv00_ct8_dir) 92 | return os.path.join(config_dir, file_name) 93 | 94 | def get_serv00_dir_file(serv00_ct8_dir, file_name): 95 | return os.path.join(serv00_ct8_dir, file_name) 96 | 97 | def check_file_exists(file_path): 98 | return os.path.exists(file_path) 99 | 100 | def parse_heart_beat_extra_info(info): 101 | if not info: 102 | return None 103 | 104 | parts = info.split('|') 105 | if len(parts) != 4: 106 | return None 107 | 108 | opt, hostname, port, username = parts 109 | return { 110 | "type": opt, 111 | "hostname": hostname, 112 | "port": int(port), 113 | "username": username 114 | } 115 | 116 | def make_heart_beat_extra_info(info, host_name, user_name): 117 | if not info: 118 | return f"0|{host_name}|22|{user_name}" 119 | 120 | return f"0|{info['hostname']}|{info['port']}|{info['username']}" 121 | 122 | def need_check_and_heart_beat(heat_beat_extra_info): 123 | # 自身定时任务执行 124 | if not heat_beat_extra_info: 125 | return True 126 | 127 | return heat_beat_extra_info.get('type') != "0" 128 | 129 | def prompt_user_input(msg): 130 | valid_inputs = {'y', 'n'} 131 | 132 | while True: 133 | user_input = input(f"是否{msg}? (Y/y 是,N/n 否): ").strip().lower() 134 | 135 | if user_input in valid_inputs: 136 | return user_input == 'y' 137 | else: 138 | logger.info("无效输入,请输入 Y 或者 y 执行,N 或者 n 不执行") 139 | -------------------------------------------------------------------------------- /utils.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 通用工具脚本 3 | 4 | init_vim() { 5 | cat < ~/.vimrc 6 | " 禁用鼠标模式 7 | set mouse= 8 | set number 9 | set nopaste 10 | EOF 11 | } 12 | 13 | alias_ll() { 14 | if [ ! -f ~/.profile ]; then 15 | touch ~/.profile 16 | fi 17 | if ! grep -q "alias ll='ls -lrhta'" ~/.profile; then 18 | echo "alias ll='ls -lrhta'" >> ~/.profile 19 | fi 20 | } 21 | 22 | add_x_to_script() { 23 | chmod +x *.sh 24 | } 25 | 26 | send_telegram_message() { 27 | if [ "$#" -ne 3 ]; then 28 | echo "Usage: $0 telegram chat_id token msg" 29 | exit 1 30 | fi 31 | 32 | local chat_id="$1" 33 | local bot_token="$2" 34 | local message="$3" 35 | local api_url="https://api.telegram.org/bot${bot_token}/sendMessage" 36 | 37 | curl -s -X POST "$api_url" -d chat_id="$chat_id" -d text="$message" 38 | } 39 | 40 | pushplus_notify() { 41 | if [ "$#" -ne 3 ]; then 42 | echo "Usage: $0 pushplus token title msg" 43 | exit 1 44 | fi 45 | 46 | local api_token="$1" 47 | local title="$2" 48 | local msg="$3" 49 | local api_url="http://www.pushplus.plus/send" 50 | 51 | local json_data 52 | json_data=$(cat < "$authorized_keys" 84 | fi 85 | 86 | find "$ssh_dir" -type f -exec chmod 600 {} \; 87 | for file in "$pub_key" "$private_key" "$authorized_keys"; do 88 | if [ ! -f "$file" ]; then 89 | echo "Error: $file does not exist." 90 | exit 1 91 | fi 92 | done 93 | } 94 | 95 | rename_config_files() { 96 | local dir="$1" 97 | 98 | if [ ! -d "$dir" ]; then 99 | echo "目录 $dir 不存在" 100 | return 1 101 | fi 102 | 103 | for file in "$dir"/*.eg; do 104 | if [ -e "$file" ]; then 105 | local new_file="${file%.eg}.conf" 106 | 107 | if [ -e "$new_file" ]; then 108 | local backup_file="${new_file}.$(date +%Y_%m_%d_%H_%M)" 109 | \cp -f "$new_file" "$backup_file" 110 | else 111 | \cp -f "$file" "$new_file" 112 | continue 113 | fi 114 | 115 | local keys_file="./keys_file_temp.txt" 116 | touch "$keys_file" 117 | 118 | awk -F= '!/^#/ {print $1}' "$new_file" > "$keys_file" 119 | 120 | while IFS= read -r line; do 121 | if [[ "$line" =~ ^# ]]; then 122 | if ! grep -Fxq "$line" "$new_file"; then 123 | echo "$line" >> "$new_file" 124 | fi 125 | else 126 | key=$(echo "$line" | awk -F= '{print $1}') 127 | 128 | if ! grep -q "^${key}" "$keys_file"; then 129 | echo "$line" >> "$new_file" 130 | fi 131 | fi 132 | done < "$file" 133 | 134 | rm -f "$keys_file" 135 | fi 136 | done 137 | } 138 | 139 | modify_config() { 140 | if [ "$#" -ne 1 ]; then 141 | echo "Usage: $0 modify_config v0/v1" 142 | exit 1 143 | fi 144 | 145 | # v0 or v1 146 | local version="$1" 147 | 148 | user_name=$(whoami) 149 | nz_app_path="/home/${user_name}/nezha_app" 150 | script_dir=$(dirname "$(readlink -f "$0")") 151 | if [ "${version}" == "v0" ]; then 152 | download_nezha_sh="${script_dir}/download_nezha.sh" 153 | echo "==> 即将修改 v0 版本的配置" 154 | else 155 | download_nezha_sh="${script_dir}/download_nezha_v1.sh" 156 | echo "==> 即将修改 v1 版本的配置" 157 | fi 158 | heart_beat_entry_sh="${script_dir}/heart_beat_entry.sh" 159 | 160 | # 执行下载配置脚本 161 | "${download_nezha_sh}" config "${nz_app_path}" 162 | if [[ $? -ne 0 ]]; then 163 | echo "Error: 执行 ${download_nezha_sh} 失败." 164 | fi 165 | 166 | # 启动进程 167 | echo "==> 修改完毕,开始重启探针进程" 168 | "${heart_beat_entry_sh}" 169 | } 170 | 171 | user_pkill() { 172 | user_name=$(whoami) 173 | echo "==> 停止用户 ${user_name} 所有应用" 174 | pkill -kill -u "${user_name}" 175 | echo "==> 停止用户 ${user_name} 完成" 176 | } 177 | 178 | restore() { 179 | echo "确定要重装系统吗?会删除整个用户目录的文件。确定重装请输入Y/y" 180 | read -r input_value 181 | if [[ "${input_value}" != "Y" && "${input_value}" != "y" ]]; then 182 | echo "操作已取消。" 183 | exit 1 184 | fi 185 | 186 | user_pkill 187 | 188 | script_dir=$(dirname "$(readlink -f "$0")") 189 | find ~ -type f ! -path "$script_dir/*" -exec chmod 644 {} + 2>/dev/null 190 | find ~ -type d ! -path "$script_dir/*" -exec chmod 755 {} + 2>/dev/null 191 | find ~ ! -path "$script_dir/*" ! -path "$script_dir" -mindepth 1 -exec rm -rf {} + 2>/dev/null 192 | } 193 | 194 | init_all() { 195 | init_vim 196 | alias_ll 197 | add_x_to_script 198 | } 199 | 200 | gen_monitor_config() { 201 | if [ "$#" -ne 5 ]; then 202 | echo "Usage: $0 monitor 配置文件的完整路径 新增的进程路径 新增的进程名 进程的启动命令 新增的进程运行方式(background-前台 foreground-后台)" 203 | exit 1 204 | fi 205 | monitor_config=$1 206 | process_dir=$2 207 | process_name=$3 208 | process_run=$4 209 | process_run_mode=$5 210 | 211 | if [ ! -f "${monitor_config}" ]; then 212 | touch "${monitor_config}" 213 | fi 214 | 215 | config="${process_dir}|${process_name}|${process_run}|${process_run_mode}" 216 | if grep -q "${config}" "${monitor_config}"; then 217 | echo "监控配置 [${config}] 已经存在于 [${monitor_config}] 中,本次不予写入" 218 | else 219 | echo "${config}" >> "${monitor_config}" 220 | fi 221 | } 222 | 223 | gen_heart_beat_config() { 224 | if [ "$#" -ne 4 ]; then 225 | echo "Usage: $0 heart 配置文件的完整路径 serv00_ct8_host serv00_ct8_port serv00_ct8_username" 226 | exit 1 227 | fi 228 | heart_beat_config=$1 229 | serv00_ct8_host=$2 230 | serv00_ct8_port=$3 231 | serv00_ct8_username=$4 232 | 233 | if [ ! -f "${heart_beat_config}" ]; then 234 | touch "${heart_beat_config}" 235 | fi 236 | 237 | config="${serv00_ct8_host}|${serv00_ct8_port}|${serv00_ct8_username}" 238 | if grep -q "${config}" "${heart_beat_config}"; then 239 | echo "心跳配置 [${config}] 已经存在于 [${heart_beat_config}] 中,本次不予写入" 240 | else 241 | echo "${config}" >> "${heart_beat_config}" 242 | fi 243 | } 244 | 245 | add_cron_job() { 246 | if [ "$#" -lt 2 ]; then 247 | echo "Usage: $0 cron '定时时间' '脚本路径' [脚本参数...]" 248 | exit 1 249 | fi 250 | 251 | cron_time=$1 252 | script_path=$2 253 | shift 2 254 | script_params="$@" 255 | 256 | new_cron_job="$cron_time $script_path $script_params" 257 | existing_cron=$(crontab -l | grep -F "$script_path") 258 | 259 | if [ -n "$existing_cron" ]; then 260 | updated_cron=$(crontab -l | sed "s|^.*$script_path.*$|$new_cron_job|") 261 | echo "$updated_cron" | crontab - 262 | echo "定时任务已更新: $new_cron_job" 263 | else 264 | (crontab -l; echo "$new_cron_job") | crontab - 265 | echo "定时任务已添加: $new_cron_job" 266 | fi 267 | } 268 | 269 | update_check_cfg() { 270 | if [ "$#" -lt 2 ]; then 271 | echo "Usage: $0 <1-开启本机监控 | 0-关闭本机监控> <配置文件的完整路径>" 272 | exit 1 273 | fi 274 | 275 | opt=$1 276 | monitor_config_file=$2 277 | 278 | if [ ! -f "$monitor_config_file" ]; then 279 | echo "配置文件不存在: $monitor_config_file" 280 | exit 1 281 | fi 282 | 283 | if [ "$(uname)" = "FreeBSD" ]; then 284 | sed_command="sed -i ''" 285 | else 286 | sed_command="sed -i" 287 | fi 288 | 289 | if grep -q '^CHECK_MONITOR_URL_DNS=' "$monitor_config_file"; then 290 | eval "$sed_command 's/^CHECK_MONITOR_URL_DNS=.*/CHECK_MONITOR_URL_DNS=${opt}/' \"$monitor_config_file\"" 291 | echo "更新了CHECK_MONITOR_URL_DNS=${opt}在配置文件中" 292 | else 293 | echo "CHECK_MONITOR_URL_DNS=${opt}" >> "$monitor_config_file" 294 | echo "追加了CHECK_MONITOR_URL_DNS=${opt}到配置文件中" 295 | fi 296 | } 297 | 298 | kill_process() { 299 | local process_name=$1 300 | local pids=$(pgrep -f "${process_name}") 301 | if [[ -n "$pids" ]]; then 302 | echo "====> 正在关闭进程 [${process_name}]" 303 | for pid in $pids; do 304 | kill -15 "$pid" && sleep 2 305 | if kill -0 "$pid" 2>/dev/null; then 306 | kill -9 "$pid" 307 | fi 308 | done 309 | echo "====> 关闭进程 [${process_name}] 成功" 310 | else 311 | echo "====> 进程 [${process_name}] 不存在" 312 | fi 313 | } 314 | 315 | restart() { 316 | local script_dir=$(dirname "$(readlink -f "$0")") 317 | local heart_beat_entry_sh="${script_dir}/heart_beat_entry.sh" 318 | 319 | echo "是否要重启 dashboard 面板?[Y/n]" 320 | read -r input_value 321 | if [[ "${input_value}" =~ ^[Yy]$ ]]; then 322 | kill_process "nezha-dashboard" 323 | fi 324 | 325 | echo "是否要重启 agent 客户端?[Y/n]" 326 | read -r input_value 327 | if [[ "${input_value}" =~ ^[Yy]$ ]]; then 328 | kill_process "nezha-agent" 329 | fi 330 | 331 | echo "正在重启服务..." 332 | if [[ -x "${heart_beat_entry_sh}" ]]; then 333 | "${heart_beat_entry_sh}" 334 | else 335 | echo "错误:${heart_beat_entry_sh} 不存在或不可执行" 336 | exit 1 337 | fi 338 | } 339 | 340 | show_agent_key() { 341 | if [ "$#" -ne 1 ]; then 342 | echo "Usage: $0 modify_config v0/v1" 343 | exit 1 344 | fi 345 | 346 | local dashboard_config_file="$1" 347 | local agent_secret_key=$(grep -E '^agentsecretkey:' "$dashboard_config_file" | awk -F ': ' '{print $2}' | sed 's/^\\s*//;s/\\s*$//') 348 | if [[ -n "$agent_secret_key" ]]; then 349 | echo "====> 已经找到用于agent连接的密钥: $agent_secret_key" 350 | else 351 | echo "====> 未找到用于agent连接的密钥, 请手工执行命令获取: grep agentsecretkey $dashboard_config_file" 352 | fi 353 | } 354 | 355 | uninstall() { 356 | local script_dir=$(cd "$(dirname "$0")" && pwd) 357 | local config_dir="${script_dir}/config" 358 | local config_file="${config_dir}/monitor.conf" 359 | if [[ ! -f "$config_file" ]]; then 360 | echo "配置文件 $config_file 不存在,退出。" 361 | return 1 362 | fi 363 | 364 | declare -a process_list 365 | while IFS='|' read -r app_path process_name script_command run_mode; do 366 | [[ "$app_path" =~ ^#.*$ ]] && continue 367 | process_list+=("$app_path|$process_name|$script_command|$run_mode") 368 | done < "$config_file" 369 | 370 | for entry in "${process_list[@]}"; do 371 | IFS='|' read -ra process_info <<< "$entry" 372 | local app_path="${process_info[0]}" 373 | local process_name="${process_info[1]}" 374 | 375 | read -r -p "是否要停止进程 [${process_name}] 并删除目录 [${app_path}]?[Y/n] " input_value 376 | input_value=${input_value:-Y} 377 | if [[ "${input_value,,}" == "y" ]]; then 378 | pkill -f "$process_name" && echo "已停止进程 ${process_name}" || echo "停止进程 ${process_name} 失败" 379 | if \rm -rf "$app_path"; then 380 | echo "已删除目录 ${app_path}" 381 | else 382 | echo "删除目录 ${app_path} 失败,请检查权限。" 383 | fi 384 | else 385 | echo "跳过进程 ${process_name} 和目录 ${app_path}。" 386 | fi 387 | done 388 | 389 | # 删除定时任务 390 | local cron_tab_serv00_ct8_nezha=$(crontab -l | grep -a "serv00_ct8_nezha") 391 | if [[ -n "$cron_tab_serv00_ct8_nezha" ]]; then 392 | crontab -l | grep -v "serv00_ct8_nezha" | crontab - 393 | echo "已删除定时任务 ${cron_tab_serv00_ct8_nezha}" 394 | else 395 | echo "未找到定时任务 serv00_ct8_nezha" 396 | fi 397 | 398 | ## 删除配置文件 399 | if [ -d "$config_dir" ]; then 400 | \rm -rf "${config_dir}"/*.conf* 401 | fi 402 | 403 | echo "==== 操作结束 ====" 404 | } 405 | 406 | case "$1" in 407 | "init") 408 | init_all 409 | ;; 410 | "kill") 411 | user_pkill 412 | ;; 413 | "key") 414 | gen_ed25519 415 | ;; 416 | "monitor") 417 | shift 1 418 | gen_monitor_config "$@" 419 | ;; 420 | "heart") 421 | shift 1 422 | gen_heart_beat_config "$@" 423 | ;; 424 | "cron") 425 | shift 1 426 | add_cron_job "$@" 427 | ;; 428 | "check") 429 | shift 1 430 | update_check_cfg "$@" 431 | ;; 432 | "rename_config") 433 | shift 1 434 | rename_config_files "$@" 435 | ;; 436 | "modify_config") 437 | shift 1 438 | modify_config "$@" 439 | ;; 440 | "telegram") 441 | shift 1 442 | send_telegram_message "$@" 443 | ;; 444 | "pushplus") 445 | shift 1 446 | pushplus_notify "$@" 447 | ;; 448 | "restore") 449 | restore 450 | ;; 451 | "restart") 452 | restart 453 | ;; 454 | "show_agent_key") 455 | shift 1 456 | show_agent_key "$@" 457 | ;; 458 | "uninstall") 459 | uninstall 460 | ;; 461 | *) 462 | echo "====== 用法 =====" 463 | echo "$0 init - 优化使用环境" 464 | echo "$0 kill - 停止用户所有应用" 465 | echo "$0 key - 生成 ed25519 公私钥" 466 | echo "$0 monitor - 写入进程监控配置, 参数: 配置文件的完整路径 新增的进程路径 新增的进程名 进程的启动命令 新增的进程运行方式(background-前台 foreground-后台)" 467 | echo "$0 heart - 写入心跳监控配置, 参数: 配置文件的完整路径 serv00_ct8_host serv00_ct8_port serv00_ct8_username" 468 | echo "$0 cron - 添加定时任务, 参数: '定时时间' '脚本路径' [脚本参数...]" 469 | echo "$0 check - [1-增加本机监控 0-关闭本机监控] 配置文件的完整路径" 470 | echo "$0 rename_config - 从配置模板文件中生成具体配置文件" 471 | echo "$0 modify_config - 修改哪吒dashboard或者agent的配置并重启服务 参数: v0 或者 v1" 472 | echo "$0 telegram - 发送telegram通知 chat_id token msg" 473 | echo "$0 pushplus - 发送pushplus通知 token title msg" 474 | echo "$0 restore - 重装系统" 475 | echo "$0 restart - 重启面板和agent" 476 | echo "$0 show_agent_key - 查看面板生成的 agentsecretkey 参数: 面板config.yaml配置文件路径" 477 | echo "$0 uninstall - 卸载哪吒dashboard和agent" 478 | exit 1 479 | ;; 480 | esac 481 | --------------------------------------------------------------------------------