9 Commits

27 changed files with 787 additions and 449 deletions
-134
View File
@@ -1,134 +0,0 @@
import os
import time
import requests
import hashlib
from github import Github
def get_github_latest_release():
g = Github()
repo = g.get_repo("nezhahq/nezha")
release = repo.get_latest_release()
if release:
print(f"Latest release tag is: {release.tag_name}")
print(f"Latest release info is: {release.body}")
files = []
for asset in release.get_assets():
url = asset.browser_download_url
name = asset.name
response = requests.get(url)
if response.status_code == 200:
with open(name, 'wb') as f:
f.write(response.content)
print(f"Downloaded {name}")
else:
print(f"Failed to download {name}")
file_abs_path = get_abs_path(asset.name)
files.append(file_abs_path)
sync_to_gitee(release.tag_name, release.body, files)
else:
print("No releases found.")
def delete_gitee_releases(latest_id, client, uri, token):
get_data = {
'access_token': token
}
release_info = []
release_response = client.get(uri, json=get_data)
if release_response.status_code == 200:
release_info = release_response.json()
else:
print(
f"Request failed with status code {release_response.status_code}")
release_ids = []
for block in release_info:
if 'id' in block:
release_ids.append(block['id'])
print(f'Current release ids: {release_ids}')
release_ids.remove(latest_id)
for id in release_ids:
release_uri = f"{uri}/{id}"
delete_data = {
'access_token': token
}
delete_response = client.delete(release_uri, json=delete_data)
if delete_response.status_code == 204:
print(f'Successfully deleted release #{id}.')
else:
raise ValueError(
f"Request failed with status code {delete_response.status_code}")
def sync_to_gitee(tag: str, body: str, files: slice):
release_id = ""
owner = "naibahq"
repo = "nezha"
release_api_uri = f"https://gitee.com/api/v5/repos/{owner}/{repo}/releases"
api_client = requests.Session()
api_client.headers.update({
'Accept': 'application/json',
'Content-Type': 'application/json'
})
access_token = os.environ['GITEE_TOKEN']
release_data = {
'access_token': access_token,
'tag_name': tag,
'name': tag,
'body': body,
'prerelease': False,
'target_commitish': 'master'
}
release_api_response = api_client.post(release_api_uri, json=release_data)
if release_api_response.status_code == 201:
release_info = release_api_response.json()
release_id = release_info.get('id')
else:
print(
f"Request failed with status code {release_api_response.status_code}")
print(f"Gitee release id: {release_id}")
asset_api_uri = f"{release_api_uri}/{release_id}/attach_files"
for file_path in files:
success = False
while not success:
files = {
'file': open(file_path, 'rb')
}
asset_api_response = requests.post(
asset_api_uri, params={'access_token': access_token}, files=files)
if asset_api_response.status_code == 201:
asset_info = asset_api_response.json()
asset_name = asset_info.get('name')
print(f"Successfully uploaded {asset_name}!")
success = True
else:
print(
f"Request failed with status code {asset_api_response.status_code}")
# 仅保留最新 Release 以防超出 Gitee 仓库配额
try:
delete_gitee_releases(release_id, api_client, release_api_uri, access_token)
except ValueError as e:
print(e)
api_client.close()
print("Sync is completed!")
def get_abs_path(path: str):
wd = os.getcwd()
return os.path.join(wd, path)
get_github_latest_release()
-125
View File
@@ -1,125 +0,0 @@
import os
import time
import requests
from github import Github
ATOMGIT_API = "https://api.atomgit.com/api/v5"
ATOMGIT_OWNER = "naiba"
ATOMGIT_REPO = "nezha-dashboard"
GITHUB_REPO = "nezhahq/nezha"
def get_github_latest_release():
g = Github()
repo = g.get_repo(GITHUB_REPO)
release = repo.get_latest_release()
if not release:
print("No releases found.")
return
print(f"Latest release tag is: {release.tag_name}")
print(f"Latest release info is: {release.body}")
files = []
for asset in release.get_assets():
url = asset.browser_download_url
name = asset.name
response = requests.get(url)
if response.status_code == 200:
with open(name, "wb") as f:
f.write(response.content)
print(f"Downloaded {name}")
else:
print(f"Failed to download {name}")
files.append(get_abs_path(name))
sync_to_atomgit(release.tag_name, release.body, files)
def sync_to_atomgit(tag, body, files):
access_token = os.environ["ATOMGIT_PAT"]
release_api_uri = f"{ATOMGIT_API}/repos/{ATOMGIT_OWNER}/{ATOMGIT_REPO}/releases"
auth_headers = {"Authorization": f"Bearer {access_token}"}
release_data = {
"tag_name": tag,
"name": tag,
"body": body,
"prerelease": False,
"target_commitish": "master",
}
release_resp = None
for attempt in range(3):
try:
release_resp = requests.post(
release_api_uri, json=release_data, headers=auth_headers, timeout=30
)
release_resp.raise_for_status()
break
except requests.exceptions.Timeout:
print(
f"Create release timed out, retrying in 30s... (attempt {attempt + 1})"
)
time.sleep(30)
except requests.exceptions.RequestException as err:
print(f"Create release failed: {err}")
if release_resp is not None:
print(f"Response: {release_resp.text}")
break
if release_resp is None or release_resp.status_code not in (200, 201):
print("Failed to create release on AtomGit, aborting.")
return
print(f"Created release {tag} on AtomGit")
for file_path in files:
upload_asset(access_token, tag, file_path)
print("Sync is completed!")
def upload_asset(access_token, tag, file_path):
file_name = os.path.basename(file_path)
upload_url_api = (
f"{ATOMGIT_API}/repos/{ATOMGIT_OWNER}/{ATOMGIT_REPO}"
f"/releases/{tag}/upload_url?file_name={file_name}"
)
for attempt in range(3):
try:
resp = requests.get(
upload_url_api,
headers={"Authorization": f"Bearer {access_token}"},
timeout=30,
)
resp.raise_for_status()
upload_info = resp.json()
obs_url = upload_info["url"]
obs_headers = upload_info["headers"]
with open(file_path, "rb") as f:
put_resp = requests.put(
obs_url, headers=obs_headers, data=f, timeout=120
)
if put_resp.text.strip() == "success" or put_resp.status_code in (200, 201):
print(f"Uploaded {file_name}")
return
else:
print(
f"Upload {file_name} failed: {put_resp.status_code} {put_resp.text}"
)
except requests.exceptions.RequestException as err:
print(f"Upload {file_name} attempt {attempt + 1} failed: {err}")
time.sleep(10)
print(f"Failed to upload {file_name} after 3 attempts")
def get_abs_path(path):
return os.path.join(os.getcwd(), path)
get_github_latest_release()
-79
View File
@@ -1,79 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '15 20 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Generate swagger docs before build (cmd/dashboard/docs is .gitignored)
- name: Generate swagger docs
run: |
go install github.com/swaggo/swag/cmd/swag@latest
swag init --pd -d . -g ./cmd/dashboard/main.go -o ./cmd/dashboard/docs --requiredByDefault
- name: Autobuild
uses: github/codeql-action/autobuild@v3
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
-29
View File
@@ -1,29 +0,0 @@
name: Contributors
on:
push:
branches: [master]
jobs:
contributors:
runs-on: ubuntu-latest
steps:
- name: Generate Contributors Images
uses: jaywcjlove/github-action-contributors@main
id: contributors
with:
filter-author: (renovate\[bot\]|renovate-bot|dependabot\[bot\])
hideName: 'false' # Hide names in htmlTable
avatarSize: 50 # Set the avatar size.
truncate: 6
avatarMargin: 8
- name: Modify htmlTable README.md
uses: jaywcjlove/github-action-modify-file-content@main
with:
message: update contributors[no ci]
token: ${{ secrets.NAIBA_PAT }}
openDelimiter: '<!--GAMFC_DELIMITER-->'
closeDelimiter: '<!--GAMFC_DELIMITER_END-->'
path: README.md
body: '${{steps.contributors.outputs.htmlList}}'
+3
View File
@@ -7,6 +7,9 @@ on:
branches: branches:
- dev - dev
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
build: build:
strategy: strategy:
-16
View File
@@ -1,16 +0,0 @@
name: Sync Code to AtomGit
on:
push:
branches: [master]
jobs:
sync-code-to-atomgit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: adambirds/sync-github-to-gitlab-action@v1.1.0
with:
destination_repository: git@atomgit.com:naiba/nezha-dashboard.git
destination_branch_name: master
destination_ssh_key: ${{ secrets.ATOMGIT_SSH_KEY }}
-16
View File
@@ -1,16 +0,0 @@
name: Sync
on:
push:
branches: [master]
jobs:
sync-to-jihulab:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: adambirds/sync-github-to-gitlab-action@v1.1.0
with:
destination_repository: git@gitee.com:naibahq/nezha.git
destination_branch_name: master
destination_ssh_key: ${{ secrets.GITEE_SSH_KEY }}
@@ -1,17 +0,0 @@
name: Sync Release to AtomGit
on:
workflow_dispatch:
jobs:
sync-release-to-atomgit:
runs-on: ubuntu-latest
timeout-minutes: 120
env:
ATOMGIT_PAT: ${{ secrets.ATOMGIT_PAT }}
steps:
- uses: actions/checkout@v4
- name: Sync to AtomGit
run: |
pip3 install PyGitHub
python3 .github/sync_atomgit.py
-17
View File
@@ -1,17 +0,0 @@
name: Sync Release to Gitee
on:
workflow_dispatch:
jobs:
sync-release-to-gitee:
runs-on: ubuntu-latest
timeout-minutes: 120
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Sync to Gitee
run: |
pip3 install PyGitHub
python3 .github/sync.py
+4 -1
View File
@@ -22,6 +22,7 @@ jobs:
runs-on: ${{ matrix.os }}-latest runs-on: ${{ matrix.os }}-latest
env: env:
GO111MODULE: on GO111MODULE: on
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -30,8 +31,10 @@ jobs:
go-version: "1.26.x" go-version: "1.26.x"
- name: generate swagger docs - name: generate swagger docs
shell: bash
run: | run: |
go install github.com/swaggo/swag/cmd/swag@latest go install github.com/swaggo/swag/cmd/swag@latest
mkdir -p ./cmd/dashboard/user-dist ./cmd/dashboard/admin-dist
touch ./cmd/dashboard/user-dist/a touch ./cmd/dashboard/user-dist/a
touch ./cmd/dashboard/admin-dist/a touch ./cmd/dashboard/admin-dist/a
swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
@@ -49,4 +52,4 @@ jobs:
env: env:
GOTOOLCHAIN: auto GOTOOLCHAIN: auto
with: with:
args: --exclude=G104,G115,G117,G203,G402,G703,G704 ./... args: --exclude=G103,G104,G107,G115,G117,G203,G402,G703,G704 ./...
+2
View File
@@ -155,6 +155,8 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
auth.POST("/domains", commonHandler(AddDomain)) auth.POST("/domains", commonHandler(AddDomain))
auth.POST("/domains/:id/verify", commonHandler(VerifyDomain)) auth.POST("/domains/:id/verify", commonHandler(VerifyDomain))
auth.POST("/domains/:id/sync", commonHandler(SyncDomainWHOIS))
auth.POST("/domains/sync-all", commonHandler(SyncAllDomains))
auth.PUT("/domains/:id", commonHandler(UpdateDomain)) auth.PUT("/domains/:id", commonHandler(UpdateDomain))
auth.DELETE("/domains/:id", commonHandler(DeleteDomain)) auth.DELETE("/domains/:id", commonHandler(DeleteDomain))
+24
View File
@@ -3,6 +3,7 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"fmt"
"strconv" "strconv"
"time" "time"
@@ -118,3 +119,26 @@ func UpdateDomainInfo(c *gin.Context) (any, error) {
return singleton.UpdateDomain(domainID, req) return singleton.UpdateDomain(domainID, req)
} }
func SyncDomainWHOIS(c *gin.Context) (any, error) {
domainID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
return nil, newGormError("无效的域名ID")
}
domain, err := singleton.GetDomainByID(domainID)
if err != nil {
return nil, newGormError("未找到域名: %s", err.Error())
}
if err := singleton.SyncDomainWHOIS(domain); err != nil {
return nil, fmt.Errorf("Whois 同步失败: %v", err)
}
return domain, nil
}
func SyncAllDomains(c *gin.Context) (any, error) {
singleton.SyncAllDomains()
return nil, nil
}
+2
View File
@@ -57,6 +57,7 @@ func createNotification(c *gin.Context) (uint64, error) {
n.RequestHeader = nf.RequestHeader n.RequestHeader = nf.RequestHeader
n.RequestBody = nf.RequestBody n.RequestBody = nf.RequestBody
n.URL = nf.URL n.URL = nf.URL
n.Type = nf.Type
verifyTLS := nf.VerifyTLS verifyTLS := nf.VerifyTLS
n.VerifyTLS = &verifyTLS n.VerifyTLS = &verifyTLS
formatMetricUnits := nf.FormatMetricUnits formatMetricUnits := nf.FormatMetricUnits
@@ -120,6 +121,7 @@ func updateNotification(c *gin.Context) (any, error) {
n.RequestHeader = nf.RequestHeader n.RequestHeader = nf.RequestHeader
n.RequestBody = nf.RequestBody n.RequestBody = nf.RequestBody
n.URL = nf.URL n.URL = nf.URL
n.Type = nf.Type
verifyTLS := nf.VerifyTLS verifyTLS := nf.VerifyTLS
n.VerifyTLS = &verifyTLS n.VerifyTLS = &verifyTLS
formatMetricUnits := nf.FormatMetricUnits formatMetricUnits := nf.FormatMetricUnits
+3 -2
View File
@@ -99,6 +99,7 @@ func updateConfig(c *gin.Context) (any, error) {
singleton.Conf.InstallHost = sf.InstallHost singleton.Conf.InstallHost = sf.InstallHost
singleton.Conf.IgnoredIPNotification = sf.IgnoredIPNotification singleton.Conf.IgnoredIPNotification = sf.IgnoredIPNotification
singleton.Conf.IPChangeNotificationGroupID = sf.IPChangeNotificationGroupID singleton.Conf.IPChangeNotificationGroupID = sf.IPChangeNotificationGroupID
singleton.Conf.ExpiryNotificationGroupID = sf.ExpiryNotificationGroupID
singleton.Conf.SiteName = sf.SiteName singleton.Conf.SiteName = sf.SiteName
singleton.Conf.DNSServers = sf.DNSServers singleton.Conf.DNSServers = sf.DNSServers
singleton.Conf.CustomCode = sf.CustomCode singleton.Conf.CustomCode = sf.CustomCode
@@ -110,8 +111,8 @@ func updateConfig(c *gin.Context) (any, error) {
singleton.Conf.CustomLogo = sf.CustomLogo singleton.Conf.CustomLogo = sf.CustomLogo
singleton.Conf.CustomDescription = sf.CustomDescription singleton.Conf.CustomDescription = sf.CustomDescription
singleton.Conf.CustomLinks = sf.CustomLinks singleton.Conf.CustomLinks = sf.CustomLinks
singleton.Conf.BackgroundImageDay = sf.BackgroundImageDay singleton.Conf.TelegramBotToken = sf.TelegramBotToken
singleton.Conf.BackgroundImageNight = sf.BackgroundImageNight singleton.Conf.TelegramAdminChatID = sf.TelegramAdminChatID
if err := singleton.Conf.Save(); err != nil { if err := singleton.Conf.Save(); err != nil {
return nil, newGormError("%v", err) return nil, newGormError("%v", err)
+9
View File
@@ -75,6 +75,15 @@ func initSystem(bus chan<- *model.Service) error {
if _, err := singleton.CronShared.AddFunc("0 0 * * * *", func() { singleton.RecordTransferHourlyUsage() }); err != nil { if _, err := singleton.CronShared.AddFunc("0 0 * * * *", func() { singleton.RecordTransferHourlyUsage() }); err != nil {
return err return err
} }
// 每天 12:00 检查域名与服务器到期
if _, err := singleton.CronShared.AddFunc("0 0 12 * * *", func() {
singleton.CronJobForDomainStatus()
singleton.CronJobForServerStatus()
}); err != nil {
return err
}
return nil return nil
} }
+3
View File
@@ -85,6 +85,9 @@ require (
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/likexian/gokit v0.25.16 // indirect
github.com/likexian/whois v1.15.7 // indirect
github.com/likexian/whois-parser v1.24.21 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.37 // indirect github.com/mattn/go-sqlite3 v1.14.37 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
+6
View File
@@ -155,6 +155,12 @@ github.com/libdns/he v1.2.1 h1:cjTZlxM5wv2lBPmtxsQCqMgmXMqTnmR4eLqUVwEkqis=
github.com/libdns/he v1.2.1/go.mod h1:SWTm80gn+7sUASGsQbRHayenoW4QIw/iGmsrkDzFghM= github.com/libdns/he v1.2.1/go.mod h1:SWTm80gn+7sUASGsQbRHayenoW4QIw/iGmsrkDzFghM=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP8=
github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4=
github.com/likexian/whois v1.15.7 h1:sajjDhi2bVD71AHJhjV7jLYxN92H4AWhTwxM8hmj7c0=
github.com/likexian/whois v1.15.7/go.mod h1:kdPQtYb+7SQVftBEbCblDadUkycN7Mg1k1/Li/rwvmc=
github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpOtTDA5OsM=
github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
+5 -2
View File
@@ -42,7 +42,7 @@ type ConfigDashboard struct {
UserTemplate string `koanf:"user_template" json:"user_template,omitempty"` UserTemplate string `koanf:"user_template" json:"user_template,omitempty"`
AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"` AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"`
EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码 EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码
// IP变更提醒 // IP变更提醒
EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"` EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"`
@@ -50,7 +50,10 @@ type ConfigDashboard struct {
Cover uint8 `koanf:"cover" json:"cover"` // 覆盖范围(0:提醒未被 IgnoredIPNotification 包含的所有服务器; 1:仅提醒被 IgnoredIPNotification 包含的服务器; Cover uint8 `koanf:"cover" json:"cover"` // 覆盖范围(0:提醒未被 IgnoredIPNotification 包含的所有服务器; 1:仅提醒被 IgnoredIPNotification 包含的服务器;
IgnoredIPNotification string `koanf:"ignored_ip_notification" json:"ignored_ip_notification,omitempty"` // 特定服务器IP(多个服务器用逗号分隔) IgnoredIPNotification string `koanf:"ignored_ip_notification" json:"ignored_ip_notification,omitempty"` // 特定服务器IP(多个服务器用逗号分隔)
DNSServers string `koanf:"dns_servers" json:"dns_servers,omitempty"` DNSServers string `koanf:"dns_servers" json:"dns_servers,omitempty"`
ExpiryNotificationGroupID uint64 `koanf:"expiry_notification_group_id" json:"expiry_notification_group_id,omitempty"`
TelegramBotToken string `koanf:"telegram_bot_token" json:"telegram_bot_token,omitempty"`
TelegramAdminChatID string `koanf:"telegram_admin_chat_id" json:"telegram_admin_chat_id,omitempty"`
} }
type Config struct { type Config struct {
+193 -4
View File
@@ -1,10 +1,14 @@
package model package model
import ( import (
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"html"
"io" "io"
"net"
"net/http" "net/http"
"net/smtp"
"net/url" "net/url"
"strings" "strings"
"time" "time"
@@ -31,14 +35,22 @@ type NotificationServerBundle struct {
Loc *time.Location Loc *time.Location
} }
const (
_ = iota
NotificationTypeWebhook
NotificationTypeSMTP
NotificationTypeTelegram
)
type Notification struct { type Notification struct {
Common Common
Name string `json:"name"` Name string `json:"name"`
URL string `json:"url"` Type uint8 `json:"type"` // 1: Webhook, 2: SMTP, 3: Telegram
URL string `json:"url"` // SMTP: host:port, Webhook: url, Telegram: bot_token
RequestMethod uint8 `json:"request_method"` RequestMethod uint8 `json:"request_method"`
RequestType uint8 `json:"request_type"` RequestType uint8 `json:"request_type"`
RequestHeader string `json:"request_header" gorm:"type:longtext"` RequestHeader string `json:"request_header" gorm:"type:longtext"` // SMTP: user:pass, Webhook: header, Telegram: chat_id
RequestBody string `json:"request_body" gorm:"type:longtext"` RequestBody string `json:"request_body" gorm:"type:longtext"` // SMTP: recipient, Webhook: body, Telegram: (ignored)
VerifyTLS *bool `json:"verify_tls,omitempty"` VerifyTLS *bool `json:"verify_tls,omitempty"`
FormatMetricUnits *bool `json:"format_metric_units,omitempty"` FormatMetricUnits *bool `json:"format_metric_units,omitempty"`
} }
@@ -111,8 +123,15 @@ func (n *Notification) setRequestHeader(req *http.Request) error {
} }
func (ns *NotificationServerBundle) Send(message string) error { func (ns *NotificationServerBundle) Send(message string) error {
var client *http.Client
n := ns.Notification n := ns.Notification
if n.Type == NotificationTypeSMTP {
return ns.sendSMTP(message)
}
if n.Type == NotificationTypeTelegram {
return ns.sendTelegram(message)
}
var client *http.Client
if n.VerifyTLS != nil && *n.VerifyTLS { if n.VerifyTLS != nil && *n.VerifyTLS {
client = utils.HttpClient client = utils.HttpClient
} else { } else {
@@ -158,6 +177,176 @@ func (ns *NotificationServerBundle) Send(message string) error {
return nil return nil
} }
func (ns *NotificationServerBundle) sendSMTP(message string) error {
n := ns.Notification
authInfo := strings.SplitN(n.RequestHeader, ":", 2)
if len(authInfo) < 2 {
return errors.New("SMTP认证信息格式错误 (user:pass)")
}
user := authInfo[0]
pass := authInfo[1]
to := n.RequestBody
host, port, err := net.SplitHostPort(n.URL)
if err != nil {
return errors.New("SMTP服务器地址格式错误 (host:port)")
}
subject := "Nezha Monitoring Alert"
if ns.Server != nil {
subject = fmt.Sprintf("Nezha Alert: %s", ns.Server.Name)
}
// 提取真实的发件人邮箱 (处理 username != email 的情况)
fromEmail := user
if !strings.Contains(user, "@") {
// 如果用户名不是邮箱,为了防止被拦截,构造一个合法的From
fromEmail = fmt.Sprintf("nezha@%s", host)
}
// 遵循 RFC 2822
header := make(map[string]string)
header["From"] = fmt.Sprintf("Nezha Monitoring <%s>", fromEmail)
header["To"] = to
header["Subject"] = subject
header["Date"] = time.Now().Format(time.RFC1123Z)
header["Content-Type"] = "text/plain; charset=UTF-8"
var msg strings.Builder
for k, v := range header {
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
}
msg.WriteString("\r\n")
msg.WriteString(message)
tlsConfig := &tls.Config{
InsecureSkipVerify: n.VerifyTLS == nil || !*n.VerifyTLS,
ServerName: host,
}
auth := smtp.PlainAuth("", user, pass, host)
if port == "465" {
// SMTPS (Implicit SSL)
conn, err := tls.Dial("tcp", n.URL, tlsConfig)
if err != nil {
return fmt.Errorf("SMTP SSL Dial error: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("SMTP NewClient error: %w", err)
}
defer client.Quit()
if err = client.Auth(auth); err != nil {
return fmt.Errorf("SMTP Auth error: %w", err)
}
if err = client.Mail(fromEmail); err != nil {
return fmt.Errorf("SMTP Mail error: %w", err)
}
if err = client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP Rcpt error: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP Data error: %w", err)
}
_, err = w.Write([]byte(msg.String()))
if err != nil {
return fmt.Errorf("SMTP Write error: %w", err)
}
err = w.Close()
if err != nil {
return fmt.Errorf("SMTP Close error: %w", err)
}
return nil
}
// STARTTLS (Port 25, 587, etc.)
conn, err := net.Dial("tcp", n.URL)
if err != nil {
return fmt.Errorf("SMTP Dial error: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("SMTP NewClient error: %w", err)
}
defer client.Quit()
if ok, _ := client.Extension("STARTTLS"); ok {
if err = client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("SMTP StartTLS error: %w", err)
}
}
if err = client.Auth(auth); err != nil {
return fmt.Errorf("SMTP Auth error: %w", err)
}
if err = client.Mail(fromEmail); err != nil {
return fmt.Errorf("SMTP Mail error: %w", err)
}
if err = client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP Rcpt error: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP Data error: %w", err)
}
_, err = w.Write([]byte(msg.String()))
if err != nil {
return fmt.Errorf("SMTP Write error: %w", err)
}
err = w.Close()
if err != nil {
return fmt.Errorf("SMTP Close error: %w", err)
}
return nil
}
func (ns *NotificationServerBundle) sendTelegram(message string) error {
n := ns.Notification
token := n.URL
chatID := n.RequestHeader
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
params := url.Values{}
params.Add("chat_id", chatID)
params.Add("text", html.EscapeString(message))
params.Add("parse_mode", "HTML")
var client *http.Client
if n.VerifyTLS != nil && *n.VerifyTLS {
client = utils.HttpClient
} else {
client = utils.HttpClientSkipTlsVerify
}
req, err := http.NewRequest(http.MethodPost, apiURL, strings.NewReader(params.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Telegram API Error (%d): %s", resp.StatusCode, string(body))
}
return nil
}
// replaceParamInString 替换字符串中的占位符 // replaceParamInString 替换字符串中的占位符
func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string { func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string {
if mod == nil { if mod == nil {
+1
View File
@@ -10,4 +10,5 @@ type NotificationForm struct {
VerifyTLS bool `json:"verify_tls,omitempty" validate:"optional"` VerifyTLS bool `json:"verify_tls,omitempty" validate:"optional"`
SkipCheck bool `json:"skip_check,omitempty" validate:"optional"` SkipCheck bool `json:"skip_check,omitempty" validate:"optional"`
FormatMetricUnits bool `json:"format_metric_units,omitempty" validate:"optional"` FormatMetricUnits bool `json:"format_metric_units,omitempty" validate:"optional"`
Type uint8 `json:"type,omitempty" validate:"optional"`
} }
+2
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/goccy/go-json" "github.com/goccy/go-json"
"gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
pb "github.com/nezhahq/nezha/proto" pb "github.com/nezhahq/nezha/proto"
@@ -21,6 +22,7 @@ type Server struct {
DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前 DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前
HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏 HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏
EnableDDNS bool `json:"enable_ddns,omitempty"` // 启用DDNS EnableDDNS bool `json:"enable_ddns,omitempty"` // 启用DDNS
BillingData datatypes.JSON `gorm:"type:json" json:"billing_data,omitempty"`
DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"` DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"`
OverrideDDNSDomainsRaw string `gorm:"default:'{}';column:override_ddns_domains_raw" json:"-"` OverrideDDNSDomainsRaw string `gorm:"default:'{}';column:override_ddns_domains_raw" json:"-"`
+7 -4
View File
@@ -17,11 +17,14 @@ type SettingForm struct {
CustomDescription string `json:"custom_description,omitempty" validate:"optional"` CustomDescription string `json:"custom_description,omitempty" validate:"optional"`
CustomLinks string `json:"custom_links,omitempty" validate:"optional"` CustomLinks string `json:"custom_links,omitempty" validate:"optional"`
BackgroundImageDay string `json:"background_image_day,omitempty" validate:"optional"` BackgroundImageDay string `json:"background_image_day,omitempty" validate:"optional"`
BackgroundImageNight string `json:"background_image_night,omitempty" validate:"optional"` BackgroundImageNight string `json:"background_image_night,omitempty" validate:"optional"`
AgentTLS bool `json:"tls,omitempty" validate:"optional"` AgentTLS bool `json:"tls,omitempty" validate:"optional"`
EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"` EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"`
EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"` EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"`
ExpiryNotificationGroupID uint64 `json:"expiry_notification_group_id,omitempty"`
TelegramBotToken string `json:"telegram_bot_token,omitempty" validate:"optional"`
TelegramAdminChatID string `json:"telegram_admin_chat_id,omitempty" validate:"optional"`
} }
type Setting struct { type Setting struct {
+2 -2
View File
@@ -14,8 +14,8 @@ type testSt struct {
func TestSplitDomainSOA(t *testing.T) { func TestSplitDomainSOA(t *testing.T) {
cases := []testSt{ cases := []testSt{
{ {
domain: "www.example.co.uk", domain: "www.google.co.uk",
zone: "example.co.uk.", zone: "google.co.uk.",
prefix: "www", prefix: "www",
}, },
{ {
+281 -1
View File
@@ -9,13 +9,218 @@ import (
"fmt" "fmt"
"log" "log"
"net" "net"
"net/http"
"strings" "strings"
"time" "time"
"github.com/nezhahq/nezha/model" "github.com/nezhahq/nezha/model"
"gorm.io/datatypes" "gorm.io/datatypes"
whois "github.com/likexian/whois"
whoisparser "github.com/likexian/whois-parser"
) )
// SyncDomainPrice 从 哪煮米(nazhumi.com) 获取域名续费价格
func SyncDomainPrice(billing *model.BillingDataMod, domainName string) {
// 获取 TLD
parts := strings.Split(domainName, ".")
if len(parts) < 2 {
return
}
tld := parts[len(parts)-1]
// 匹配注册商代码 (简单启示式匹配)
registrarCode := ""
regNameLower := strings.ToLower(billing.Registrar)
// 这里可以扩展更多的映射关系
mapping := map[string]string{
"aliyun": "aliyun", "tencent": "tencent", "cloudflare": "cloudflare",
"namesilo": "namesilo", "porkbun": "porkbun", "dynadot": "dynadot",
"google": "google", "namecheap": "namecheap", "godaddy": "godaddy",
"spaceship": "spaceship", "huawei": "huawei", "baidu": "baidu",
"volcengine": "volcengine", "juming": "juming", "quyu": "quyu",
"west": "west", "xinnet": "xinnet", "ename": "ename",
}
for key, code := range mapping {
if strings.Contains(regNameLower, key) {
registrarCode = code
break
}
}
if registrarCode == "" {
// 备选方案:去除常用后缀和空格
registrarCode = strings.ReplaceAll(regNameLower, " ", "")
registrarCode = strings.ReplaceAll(registrarCode, "inc", "")
registrarCode = strings.ReplaceAll(registrarCode, "llc", "")
registrarCode = strings.ReplaceAll(registrarCode, ".", "")
registrarCode = strings.ReplaceAll(registrarCode, ",", "")
}
apiURL := fmt.Sprintf("https://www.nazhumi.com/api/v1?registrar=%s&domain=%s", registrarCode, tld)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(apiURL)
if err != nil {
return
}
defer resp.Body.Close()
var results []struct {
Renew interface{} `json:"renew"`
Currency string `json:"currency"`
}
if err := json.NewDecoder(resp.Body).Decode(&results); err != nil || len(results) == 0 {
return
}
// 转换价格
res := results[0]
priceStr := ""
switch v := res.Renew.(type) {
case float64:
priceStr = fmt.Sprintf("%.2f", v)
case string:
if v != "n/a" {
priceStr = v
}
}
if priceStr != "" {
billing.RenewalPrice = fmt.Sprintf("%s %s", priceStr, res.Currency)
}
}
// RDAPResponse 简化的 RDAP 响应结构
type RDAPResponse struct {
Events []struct {
EventAction string `json:"eventAction"`
EventDate string `json:"eventDate"`
} `json:"events"`
Entities []struct {
Roles []string `json:"roles"`
VcardArray []interface{} `json:"vcardArray"`
} `json:"entities"`
}
// SyncDomainWHOIS 使用 RDAP (主要) 和 Whois (备用) 同步域名信息
func SyncDomainWHOIS(d *model.Domain) error {
var billing model.BillingDataMod
if d.BillingData != nil && len(d.BillingData) > 0 {
json.Unmarshal(d.BillingData, &billing)
}
// 1. 尝试使用官方 RDAP 协议 (JSON格式,更可靠,无需解析正则)
rdapSuccess := false
apiURL := fmt.Sprintf("https://rdap.org/domain/%s", d.Domain)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(apiURL)
if err == nil && resp.StatusCode == http.StatusOK {
var rdap RDAPResponse
if err := json.NewDecoder(resp.Body).Decode(&rdap); err == nil {
rdapSuccess = true
for _, event := range rdap.Events {
switch event.EventAction {
case "expiration":
billing.EndDate = event.EventDate
case "registration":
billing.RegisteredDate = event.EventDate
}
}
// 提取注册商
for _, entity := range rdap.Entities {
isRegistrar := false
for _, role := range entity.Roles {
if role == "registrar" {
isRegistrar = true
break
}
}
if isRegistrar && len(entity.VcardArray) > 1 {
if vcard, ok := entity.VcardArray[1].([]interface{}); ok {
for _, field := range vcard {
if f, ok := field.([]interface{}); ok && len(f) > 3 {
if f[0] == "fn" {
billing.Registrar = fmt.Sprint(f[3])
break
}
}
}
}
}
}
}
resp.Body.Close()
}
// 2. 如果 RDAP 失败,回退到传统的 Whois 查询
if !rdapSuccess {
raw, err := whois.Whois(d.Domain)
if err == nil {
result, err := whoisparser.Parse(raw)
if err == nil {
if result.Registrar.Name != "" {
billing.Registrar = result.Registrar.Name
}
if result.Domain.ExpirationDate != "" {
billing.EndDate = result.Domain.ExpirationDate
}
if result.Domain.CreatedDate != "" {
billing.RegisteredDate = result.Domain.CreatedDate
}
}
}
}
// 3. 补充价格同步
SyncDomainPrice(&billing, d.Domain)
newBillingData, err := json.Marshal(billing)
if err != nil {
return err
}
d.BillingData = newBillingData
saveErr := DB.Save(d).Error
if saveErr != nil {
return fmt.Errorf("数据库保存失败: %w", saveErr)
}
if !rdapSuccess && billing.EndDate == "" {
return fmt.Errorf("RDAP 和 Whois 同步均失败,请检查网络或手动输入")
}
return nil
}
// SyncAllDomains 异步批量同步所有已验证域名的 Whois 和价格信息
func SyncAllDomains() {
go func() {
log.Println("NEZHA>> 开始批量同步所有域名的 Whois 和价格信息...")
domains, err := GetDomains("admin")
if err != nil {
log.Printf("NEZHA>> 批量同步域名失败: %v", err)
return
}
successCount := 0
for _, d := range domains {
if d.Status == "verified" {
if err := SyncDomainWHOIS(&d); err != nil {
log.Printf("NEZHA>> 域名 %s 同步失败: %v", d.Domain, err)
} else {
successCount++
}
// 避免并发过高被 API 限制
time.Sleep(2 * time.Second)
}
}
log.Printf("NEZHA>> 批量同步域名结束,成功 %d/%d", successCount, len(domains))
}()
}
// GetDomains 获取所有域名记录 // GetDomains 获取所有域名记录
func GetDomains(scope string) ([]model.Domain, error) { func GetDomains(scope string) ([]model.Domain, error) {
var domains []model.Domain var domains []model.Domain
@@ -81,13 +286,23 @@ func VerifyDomain(id uint64) (bool, error) {
return false, fmt.Errorf("DNS查询失败: %w", err) return false, fmt.Errorf("DNS查询失败: %w", err)
} }
found := false
for _, record := range txtRecords { for _, record := range txtRecords {
if record == domain.VerifyToken { if record == domain.VerifyToken {
domain.Status = "verified" domain.Status = "verified"
return true, DB.Save(domain).Error found = true
break
} }
} }
if found {
// 自动同步 Whois 信息
if err := SyncDomainWHOIS(domain); err != nil {
log.Printf("NEZHA>> 域名 %s 验证成功但 Whois 同步失败: %v", domain.Domain, err)
}
return true, DB.Save(domain).Error
}
return false, nil return false, nil
} }
@@ -158,6 +373,22 @@ func CronJobForDomainStatus() {
continue continue
} }
daysLeft := int(endDate.Sub(now).Hours() / 24)
// 只有在到期前一定天数通知,且避开重复通知 (简单逻辑:每天通知一次)
if Conf.ExpiryNotificationGroupID != 0 {
msg := ""
switch daysLeft + 1 {
case 60, 30, 15, 7, 3, 1:
msg = fmt.Sprintf("域名 [%s] 即通知期,剩余 %d 天。到期时间: %s", d.Domain, daysLeft+1, endDate.Format("2006-01-02"))
case 0:
msg = fmt.Sprintf("域名 [%s] 已到期!到期时间: %s", d.Domain, endDate.Format("2006-01-02"))
}
if msg != "" {
NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-domain-%d-%d", d.ID, daysLeft))
}
}
if now.After(endDate) { if now.After(endDate) {
if billing.AutoRenewal == "1" { if billing.AutoRenewal == "1" {
var newEndDate time.Time var newEndDate time.Time
@@ -192,3 +423,52 @@ func CronJobForDomainStatus() {
} }
log.Println("NEZHA>> Cron::域名状态检查任务执行完毕") log.Println("NEZHA>> Cron::域名状态检查任务执行完毕")
} }
// CronJobForServerStatus 检查服务器/VPS 到期通知
func CronJobForServerStatus() {
log.Println("NEZHA>> Cron::开始执行服务器到期检查任务")
var servers []model.Server
if err := DB.Find(&servers).Error; err != nil {
log.Printf("NEZHA>> Cron::Error fetching servers: %v", err)
return
}
now := time.Now()
for i := range servers {
s := servers[i]
if s.BillingData == nil {
continue
}
var billing model.BillingDataMod
if err := json.Unmarshal(s.BillingData, &billing); err != nil {
continue
}
if billing.EndDate == "" {
continue
}
endDate, err := time.Parse(time.RFC3339, billing.EndDate)
if err != nil {
continue
}
daysLeft := int(endDate.Sub(now).Hours() / 24)
if Conf.ExpiryNotificationGroupID != 0 {
msg := ""
switch daysLeft + 1 {
case 30, 15, 7, 3, 1:
msg = fmt.Sprintf("VPS [%s] 即将到期,剩余 %d 天。到期时间: %s", s.Name, daysLeft+1, endDate.Format("2006-01-02"))
case 0:
msg = fmt.Sprintf("VPS [%s] 已到期!到期时间: %s", s.Name, endDate.Format("2006-01-02"))
}
if msg != "" {
NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-server-%d-%d", s.ID, daysLeft))
}
}
}
log.Println("NEZHA>> Cron::服务器到期检查任务执行完毕")
}
+3
View File
@@ -61,6 +61,9 @@ func LoadSingleton(bus chan<- *model.Service) (err error) {
CronShared = NewCronClass() CronShared = NewCronClass()
// 最后初始化 ServiceSentinel // 最后初始化 ServiceSentinel
ServiceSentinelShared, err = NewServiceSentinel(bus) ServiceSentinelShared, err = NewServiceSentinel(bus)
if err == nil {
InitTelegramBot()
}
return return
} }
+208
View File
@@ -0,0 +1,208 @@
package singleton
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
)
type tgUpdate struct {
UpdateID int `json:"update_id"`
Message *struct {
MessageID int `json:"message_id"`
From *struct {
ID int64 `json:"id"`
} `json:"from"`
Chat *struct {
ID int64 `json:"id"`
} `json:"chat"`
Text string `json:"text"`
} `json:"message"`
}
func InitTelegramBot() {
if Conf.TelegramBotToken == "" {
log.Println("NEZHA>> TG Bot Token 未配置,跳过启动互动机器人")
return
}
log.Println("NEZHA>> 正在启动 Telegram 互动机器人...")
go func() {
offset := 0
for {
updates, err := getTGUpdates(Conf.TelegramBotToken, offset)
if err != nil {
// 避免过于频繁报错
time.Sleep(30 * time.Second)
continue
}
for _, update := range updates {
offset = update.UpdateID + 1
if update.Message != nil {
handleTGUpdate(update)
}
}
time.Sleep(3 * time.Second)
}
}()
}
func getTGUpdates(token string, offset int) ([]tgUpdate, error) {
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?offset=%d&timeout=20", token, offset)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
resp, err := utils.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
OK bool `json:"ok"`
Result []tgUpdate `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Result, nil
}
func handleTGUpdate(update tgUpdate) {
if update.Message == nil || update.Message.Chat == nil {
return
}
chatID := update.Message.Chat.ID
adminChatID, _ := strconv.ParseInt(Conf.TelegramAdminChatID, 10, 64)
// 权限检查
if adminChatID != 0 && chatID != adminChatID {
sendTGMessage(chatID, "🚫 您没有权限操作此机器人。")
return
}
text := update.Message.Text
switch {
case text == "/start" || text == "/help":
sendTGMainMenu(chatID)
case text == "/status" || text == "📊 运行状态":
sendTGStatus(chatID)
case text == "/domains" || text == "🌐 域名监控":
sendTGDomains(chatID)
default:
if strings.HasPrefix(text, "/") {
sendTGMessage(chatID, "❓ 未知命令,请输入 /start 查看菜单。")
}
}
}
func sendTGMainMenu(chatID int64) {
menu := "👋 您好!我是哪吒监控助手。\n\n请选择以下操作:"
keyboard := map[string]interface{}{
"keyboard": [][]map[string]string{
{{"text": "📊 运行状态"}, {"text": "🌐 域名监控"}},
},
"resize_keyboard": true,
}
kbJSON, _ := json.Marshal(keyboard)
sendTGRequest("sendMessage", url.Values{
"chat_id": {strconv.FormatInt(chatID, 10)},
"text": {menu},
"reply_markup": {string(kbJSON)},
})
}
func sendTGStatus(chatID int64) {
var sb strings.Builder
sb.WriteString("📊 <b>服务器实时状态</b>\n\n")
ServerShared.Range(func(id uint64, s *model.Server) bool {
statusIcon := "🟢"
if !s.LastActive.After(time.Now().Add(-time.Second * 30)) {
statusIcon = "🔴"
}
sb.WriteString(fmt.Sprintf("%s <b>%s</b>\n", statusIcon, s.Name))
sb.WriteString(fmt.Sprintf("├ CPU: %.1f%% | Mem: %.1f%%\n", s.State.CPU, float64(s.State.MemUsed)/float64(s.Host.MemTotal)*100))
sb.WriteString(fmt.Sprintf("└ Net: ↓%s/s ↑%s/s\n\n", utils.Bytes(s.State.NetInSpeed), utils.Bytes(s.State.NetOutSpeed)))
return true
})
if sb.Len() < 50 {
sb.WriteString("暂无在线服务器。")
}
sendTGMessage(chatID, sb.String())
}
func sendTGDomains(chatID int64) {
domains, err := GetDomains("admin")
if err != nil {
sendTGMessage(chatID, "❌ 获取域名列表失败。")
return
}
var sb strings.Builder
sb.WriteString("🌐 <b>域名监控状态</b>\n\n")
now := time.Now()
for _, d := range domains {
statusIcon := "✅"
if d.Status == "pending" {
statusIcon = "⏳"
} else if d.Status == "expired" {
statusIcon = "❌"
}
expiresInfo := "N/A"
if d.BillingData != nil {
var billing model.BillingDataMod
if json.Unmarshal(d.BillingData, &billing) == nil && billing.EndDate != "" {
if endDate, err := time.Parse(time.RFC3339, billing.EndDate); err == nil {
daysLeft := int(endDate.Sub(now).Hours() / 24)
expiresInfo = fmt.Sprintf("%d 天", daysLeft)
}
}
}
sb.WriteString(fmt.Sprintf("%s <b>%s</b>\n", statusIcon, d.Domain))
sb.WriteString(fmt.Sprintf("└ 剩余: %s | 状态: %s\n\n", expiresInfo, d.Status))
}
if len(domains) == 0 {
sb.WriteString("暂无监控中的域名。")
}
sendTGMessage(chatID, sb.String())
}
func sendTGMessage(chatID int64, text string) {
sendTGRequest("sendMessage", url.Values{
"chat_id": {strconv.FormatInt(chatID, 10)},
"text": {text},
"parse_mode": {"HTML"},
})
}
func sendTGRequest(method string, params url.Values) {
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/%s", Conf.TelegramBotToken, method)
req, err := http.NewRequest(http.MethodPost, apiURL, strings.NewReader(params.Encode()))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := utils.HttpClient.Do(req)
if err == nil {
resp.Body.Close()
}
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"fmt"
"time"
"github.com/miekg/dns"
)
func main() {
c := &dns.Client{Timeout: 10 * time.Second}
domain := "example.co.uk."
m := new(dns.Msg)
m.SetQuestion(domain, dns.TypeSOA)
r, _, err := c.Exchange(m, "1.1.1.1:53")
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Answer count: %d\n", len(r.Answer))
for _, a := range r.Answer {
fmt.Printf("Answer: %v\n", a)
}
fmt.Printf("Ns count: %d\n", len(r.Ns))
for _, a := range r.Ns {
fmt.Printf("Ns: %v\n", a)
}
}